mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 19:59:42 +02:00
Retire le générateur vidéo, les badges « New » et permet d'effacer les Reels en échec
- Suppression de la page « Générateur Vidéo » (/remotion) : routes /api/remotion, pipeline de rendu d'images, composition ImageVideo et aperçu associé. Le générateur de Reels est conservé. - Plus de badge « New » à côté des menus. - Tableau de bord : bouton pour supprimer un Reel en échec et sa notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
This commit is contained in:
19 files changed
+64
-1615
No files matched your search
@@ -87,11 +87,6 @@ const ScheduleReel = createResponsiveRoute(
|
||||
() => import("@/pages/schedule-reel")
|
||||
);
|
||||
|
||||
const RemotionVideo = createResponsiveRoute(
|
||||
() => import("@/pages/remotion-video"),
|
||||
() => import("@/pages/mobile/remotion-video")
|
||||
);
|
||||
|
||||
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
@@ -143,7 +138,6 @@ function Router() {
|
||||
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
|
||||
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
|
||||
<Route path="/reel/schedule">{() => <ProtectedRoute component={ScheduleReel} />}</Route>
|
||||
<Route path="/remotion">{() => <ProtectedRoute component={RemotionVideo} />}</Route>
|
||||
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
|
||||
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
|
||||
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Post } from "@shared/schema";
|
||||
|
||||
type OngoingReel = Post & { generationStep?: string | null };
|
||||
@@ -35,6 +38,27 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Un Reel en échec n'a rien à publier : le supprimer retire aussi la notification
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: async (postId: string) => (await apiRequest("DELETE", `/api/reels/${postId}`)).json(),
|
||||
onSuccess: (_data, postId) => {
|
||||
queryClient.setQueryData<OngoingReel[]>(["/api/reels/ongoing"], (posts) =>
|
||||
posts?.filter((p) => p.id !== postId),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/reels/ongoing"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "Impossible de supprimer la notification"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Nothing to show — render nothing (not even a card)
|
||||
if (ongoingPosts.length === 0) {
|
||||
return null;
|
||||
@@ -112,6 +136,21 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFailed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => dismissMutation.mutate(post.id)}
|
||||
disabled={dismissMutation.isPending && dismissMutation.variables === post.id}
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer ce Reel en échec"
|
||||
data-testid={`button-dismiss-reel-${post.id}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
REEL_WIDTH,
|
||||
VOICE_DELAY,
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from "@shared/captions";
|
||||
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
|
||||
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
|
||||
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
|
||||
import type { VoicePreviewResult } from "./voice-picker";
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
|
||||
@@ -40,9 +38,7 @@ interface CommonProps {
|
||||
srtCues?: SrtCue[] | null;
|
||||
}
|
||||
|
||||
type ReelPreviewProps =
|
||||
| (CommonProps & { kind: "video"; videoUrl: string })
|
||||
| (CommonProps & { kind: "images"; images: string[] });
|
||||
type ReelPreviewProps = CommonProps & { kind: "video"; videoUrl: string };
|
||||
|
||||
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
|
||||
function useVideoDuration(url: string | undefined): number | null {
|
||||
@@ -75,12 +71,12 @@ function estimatedVoiceDuration(text: string): number {
|
||||
* Sans voix générée, le minutage des mots est estimé.
|
||||
*/
|
||||
export function ReelPreview(props: ReelPreviewProps) {
|
||||
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
|
||||
const videoDuration = useVideoDuration(props.videoUrl);
|
||||
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } =
|
||||
props;
|
||||
const logoUrl = props.logoUrl ?? undefined;
|
||||
const storeName = endingEffect ? props.storeName || undefined : undefined;
|
||||
const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null;
|
||||
const srtCues = props.srtCues?.length ? props.srtCues : null;
|
||||
const cleanText = cleanCaptionText(text);
|
||||
const voiceDuration = srtCues
|
||||
? ttsEnabled ? srtEnd(srtCues) : 0
|
||||
@@ -88,34 +84,6 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
|
||||
|
||||
const composition = useMemo(() => {
|
||||
if (props.kind === "images") {
|
||||
if (props.images.length === 0) return null;
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: props.images.length,
|
||||
voiceDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
const contentEnd = total - endingSeconds;
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
|
||||
}
|
||||
const inputProps: ImageCompositionProps = {
|
||||
images: props.images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle,
|
||||
audioUrl: voice?.audioUrl,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
showWatermark,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
};
|
||||
return { component: ImageComposition, inputProps, total };
|
||||
}
|
||||
|
||||
if (videoDuration == null) return null;
|
||||
const timing = computeReelTiming({
|
||||
videoDuration,
|
||||
@@ -153,8 +121,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
return { component: ReelVideo, inputProps, total: timing.total };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.kind,
|
||||
props.kind === "video" ? props.videoUrl : props.images.join("|"),
|
||||
props.videoUrl,
|
||||
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
|
||||
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
|
||||
]);
|
||||
@@ -172,7 +139,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
<div className="overflow-hidden rounded-lg bg-black">
|
||||
<Player
|
||||
// Nouvelle instance quand la durée change : le lecteur repart du début
|
||||
key={`${props.kind}-${composition.total}`}
|
||||
key={composition.total}
|
||||
component={composition.component as React.ComponentType<Record<string, unknown>>}
|
||||
inputProps={composition.inputProps as unknown as Record<string, unknown>}
|
||||
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
|
||||
|
||||
@@ -70,13 +70,12 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
const navItems = [
|
||||
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
||||
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: "New" },
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: "New" },
|
||||
{ icon: Video, label: "Générateur Vidéo", href: "/remotion", badge: "New" },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: null },
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: null },
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: "New" },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: null },
|
||||
];
|
||||
|
||||
const statsItems = [
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Users,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Film,
|
||||
CalendarClock
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
@@ -31,7 +30,6 @@ export default function DashboardMobile() {
|
||||
|
||||
const secondaryMenu = [
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" },
|
||||
{ icon: Film, label: "Générateur Vidéo", href: "/remotion" },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media" },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor" },
|
||||
{ icon: Clock, label: "Historique", href: "/history" },
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera, ImagePlus } from "lucide-react";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
||||
|
||||
|
||||
function stripForTTS(text: string) {
|
||||
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
|
||||
export default function MobileRemotionVideoPage() {
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||
const [overlayText, setOverlayText] = useState("");
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`page-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={checked =>
|
||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
|
||||
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
const remaining = 4 - selectedLibraryImages.length - images.length;
|
||||
|
||||
// Corriger les noms de fichiers pour les fichiers venant de la galerie mobile
|
||||
const processedFiles = filesArray.slice(0, remaining).map(file => {
|
||||
if (!file.name || file.name === 'blob' || file.name === '' || file.name === 'image') {
|
||||
const ext = file.type.split('/')[1] || 'jpg';
|
||||
return new File([file], `gallery-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${ext}`, { type: file.type });
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
setImages(prev => [...prev, ...processedFiles].slice(0, 4 - selectedLibraryImages.length));
|
||||
}
|
||||
// Reset input pour permettre de resélectionner les mêmes fichiers
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const remaining = 4 - selectedLibraryImages.length - images.length;
|
||||
if (remaining <= 0) {
|
||||
toast({ title: "4 images max.", variant: "destructive" });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
let fileToAdd = file;
|
||||
if (!file.name || file.name === 'blob' || file.name === '') {
|
||||
const ext = file.type.split('/')[1] || 'jpg';
|
||||
fileToAdd = new File([file], `camera-${Date.now()}.${ext}`, { type: file.type });
|
||||
}
|
||||
setImages(prev => [...prev, fileToAdd].slice(0, 4 - selectedLibraryImages.length));
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const toggleLibraryImage = (media: Media) => {
|
||||
setSelectedLibraryImages(prev => {
|
||||
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
||||
if (images.length + prev.length >= 4) { toast({ title: "4 images max.", variant: "destructive" }); return prev; }
|
||||
return [...prev, media];
|
||||
});
|
||||
};
|
||||
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
|
||||
const togglePlayPreview = (track: AudioTrack) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
if (overlayText) formData.append("overlayText", overlayText);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.jobId) {
|
||||
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
||||
throw new Error("Erreur de suivi du rendu");
|
||||
}
|
||||
const job = await res.json();
|
||||
|
||||
if (job.status === 'done') {
|
||||
setVideoUrl(job.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !" });
|
||||
setIsRendering(false);
|
||||
} else if (job.status === 'error') {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
setVideoUrl(data.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur", description: err.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!videoUrl) return;
|
||||
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/remotion/publish', {
|
||||
videoUrl, pageIds: selectedPageIds,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
description: publishDescription || undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
||||
toast({
|
||||
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
||||
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||
} finally { setIsPublishing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-24">
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="-ml-2"><Menu className="h-6 w-6" /></Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-[80%]"><Sidebar /></SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="font-semibold text-lg">Générateur Vidéo</h1>
|
||||
</div>
|
||||
|
||||
<main className="p-4 space-y-4">
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">1. Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Input caché pour la caméra */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Input caché pour la galerie - séparé pour meilleure compatibilité mobile */}
|
||||
<input
|
||||
ref={galleryInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||
onClick={() => galleryInputRef.current?.click()}
|
||||
disabled={totalSelected >= 4}
|
||||
>
|
||||
<ImagePlus className="h-6 w-6 text-primary" />
|
||||
<span className="text-xs font-medium">Galerie</span>
|
||||
<span className="text-[10px] text-muted-foreground">{images.length} choisie(s)</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={totalSelected >= 4}
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-xs">Photo</span>
|
||||
</Button>
|
||||
</div>
|
||||
{imageMediaList.length > 0 && (
|
||||
<div className="flex overflow-x-auto gap-2 pb-1">
|
||||
{imageMediaList.map(media => {
|
||||
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
||||
return (
|
||||
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
||||
className={`flex-shrink-0 w-16 h-16 relative rounded overflow-hidden border-2 cursor-pointer ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
||||
<MediaThumbnail src={media.originalUrl} alt="" type="image" />
|
||||
{isSelected && <div className="absolute top-0.5 right-0.5 bg-primary text-white p-0.5 rounded-full"><Check className="w-2.5 h-2.5" /></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-primary font-medium">{totalSelected} / 4 sélectionnée(s)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Texte & Voix */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea placeholder="Décrivez votre produit…" value={productInfo}
|
||||
onChange={e => setProductInfo(e.target.value)} rows={2} className="text-sm" />
|
||||
<Button variant="secondary" size="sm" className="w-full"
|
||||
disabled={!productInfo || generateTextMutation.isPending}
|
||||
onClick={() => generateTextMutation.mutate(productInfo)}>
|
||||
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-3 h-3 animate-spin" /> : <Sparkles className="mr-2 w-3 h-3" />}
|
||||
Générer avec l'IA
|
||||
</Button>
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{generatedVariants.map((v: any, i: number) => (
|
||||
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Appliqué" }); }}
|
||||
className={`text-xs p-2 rounded border cursor-pointer ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea placeholder="Texte overlay…" value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
||||
</div>
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Musique */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="max-h-40 overflow-y-auto space-y-1.5">
|
||||
{tracksLoading ? (
|
||||
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-primary" /></div>
|
||||
) : audioTracks.length === 0 ? (
|
||||
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
||||
<p className="text-xs text-muted-foreground">Aucune musique disponible.<br />Un administrateur peut en ajouter via la Bibliothèque Audio.</p>
|
||||
</div>
|
||||
) : (
|
||||
audioTracks.map(track => {
|
||||
const isSelected = selectedTrack?.id === track.id;
|
||||
const playing = isPlaying === track.id;
|
||||
return (
|
||||
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
||||
className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
||||
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{track.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative border-2 border-dashed border-border rounded p-3 flex items-center gap-2">
|
||||
<input type="file" accept="audio/*"
|
||||
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground truncate">{musicFile ? musicFile.name : "Ou uploadez votre fichier audio"}</p>
|
||||
{musicFile && <Check className="w-3 h-3 text-primary shrink-0" />}
|
||||
</div>
|
||||
{(musicFile || selectedTrack) && (
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
<Volume2 className="w-3 h-3" /> Volume : {Math.round(musicVolume * 100)}%
|
||||
</Label>
|
||||
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
||||
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Destinations */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
|
||||
</Button>
|
||||
|
||||
{videoUrl && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
|
||||
|
||||
<Card className="border-primary/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{facebookPages.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
|
||||
) : selectedPageIds.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
|
||||
) : (
|
||||
<p className="text-xs text-primary font-medium">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||
onChange={e => setPublishDescription(e.target.value)} />
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||
<Button className="w-full" onClick={handlePublish}
|
||||
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||
{isPublishing
|
||||
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
|
||||
interface AudioTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export default function RemotionVideoPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||
const [overlayText, setOverlayText] = useState("");
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`pre-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={checked =>
|
||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||
onSuccess: (data: any) => {
|
||||
setGeneratedVariants(data.variants || []);
|
||||
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
|
||||
},
|
||||
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
|
||||
};
|
||||
|
||||
const toggleLibraryImage = (media: Media) => {
|
||||
setSelectedLibraryImages(prev => {
|
||||
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
||||
if (images.length + prev.length >= 4) { toast({ title: "4 images maximum.", variant: "destructive" }); return prev; }
|
||||
return [...prev, media];
|
||||
});
|
||||
};
|
||||
|
||||
const togglePlayPreview = (track: AudioTrack) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
if (overlayText) formData.append("overlayText", overlayText);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.jobId) {
|
||||
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
||||
throw new Error("Erreur de suivi du rendu");
|
||||
}
|
||||
const job = await res.json();
|
||||
|
||||
if (job.status === 'done') {
|
||||
setVideoUrl(job.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
|
||||
setIsRendering(false);
|
||||
} else if (job.status === 'error') {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
// Fallback for older sync backend
|
||||
setVideoUrl(data.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
|
||||
setIsRendering(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
// Note: removed finally block so isRendering stays true during polling
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!videoUrl) return;
|
||||
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/remotion/publish', {
|
||||
videoUrl, pageIds: selectedPageIds,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
description: publishDescription || undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
||||
toast({
|
||||
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
||||
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||
} finally { setIsPublishing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
<div className={`fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-2xl mx-auto space-y-6">
|
||||
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Images</CardTitle>
|
||||
<CardDescription>Sélectionnez 3 à 4 images (bibliothèque ou upload).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-6 flex flex-col items-center justify-center relative hover:bg-muted/50 transition-colors">
|
||||
<input type="file" multiple accept="image/*" onChange={handleFileChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="font-medium text-center">Glissez ou cliquez</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{images.length} uploadée(s)</p>
|
||||
</div>
|
||||
{imageMediaList.length > 0 && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
<p className="font-medium mb-2 text-sm">Bibliothèque :</p>
|
||||
<div className="grid grid-cols-5 gap-2 max-h-44 overflow-y-auto">
|
||||
{imageMediaList.map(media => {
|
||||
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
||||
return (
|
||||
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
||||
className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
||||
<img src={media.originalUrl} className="w-full h-full object-cover" />
|
||||
{isSelected && <div className="absolute top-1 right-1 bg-primary text-white p-0.5 rounded-full"><Check className="w-3 h-3" /></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-primary font-medium">Total : {totalSelected} / 4</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Texte + Voix */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
||||
<CardDescription>La voix lira votre texte. Les hashtags s'affichent mais ne sont pas lus.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea placeholder="Décrivez votre produit pour générer un texte IA..." value={productInfo}
|
||||
onChange={e => setProductInfo(e.target.value)} rows={2} />
|
||||
<Button variant="secondary" className="w-full" disabled={!productInfo || generateTextMutation.isPending}
|
||||
onClick={() => generateTextMutation.mutate(productInfo)}>
|
||||
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-4 h-4 animate-spin" /> : <Sparkles className="mr-2 w-4 h-4" />}
|
||||
Générer avec l'IA
|
||||
</Button>
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Cliquez pour appliquer :</p>
|
||||
{generatedVariants.map((v: any, i: number) => (
|
||||
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}
|
||||
className={`text-sm p-2 rounded border cursor-pointer hover:bg-accent transition-colors ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={3} />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts">Activer la voix TTS</Label>
|
||||
</div>
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Musique */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique de fond</CardTitle>
|
||||
<CardDescription>Choisissez une musique de la bibliothèque ou uploadez la vôtre.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5">
|
||||
{tracksLoading ? (
|
||||
<div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
|
||||
) : audioTracks.length === 0 ? (
|
||||
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
||||
<p className="text-sm text-muted-foreground">Aucune musique disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
audioTracks.map(track => {
|
||||
const isSelected = selectedTrack?.id === track.id;
|
||||
const playing = isPlaying === track.id;
|
||||
return (
|
||||
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
||||
className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{track.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-3 flex items-center gap-3 hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<input type="file" accept="audio/*"
|
||||
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{musicFile ? musicFile.name : "Ou uploadez votre propre fichier audio"}
|
||||
</p>
|
||||
{musicFile && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
{(musicFile || selectedTrack) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-sm">
|
||||
<Volume2 className="w-4 h-4" /> Volume : {Math.round(musicVolume * 100)}%
|
||||
</Label>
|
||||
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
||||
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Destinations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
||||
<CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[300px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
|
||||
</Button>
|
||||
|
||||
{/* Vidéo générée */}
|
||||
{videoUrl && (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-semibold text-green-600 mb-2">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-lg shadow-lg" />
|
||||
</div>
|
||||
|
||||
{/* Publication */}
|
||||
<Card className="border-primary/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||
</CardTitle>
|
||||
<CardDescription>Sélectionnez la page et planifiez (optionnel).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedPageIds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
|
||||
) : (
|
||||
<p className="text-sm text-primary font-medium">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||
onChange={e => setPublishDescription(e.target.value)} />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">Date de publication</Label>
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handlePublish}
|
||||
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||
{isPublishing
|
||||
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
images: string[];
|
||||
totalDuration: number;
|
||||
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
audioUrl?: string;
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
|
||||
showWatermark?: boolean;
|
||||
storeName?: string;
|
||||
/** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
|
||||
endingSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ken Burns presets — deterministic per image index.
|
||||
* Each entry: starting transform → ending transform over the slide duration.
|
||||
* Scale > 1 to avoid black edges when translating.
|
||||
*/
|
||||
const KB_PRESETS = [
|
||||
// zoom in, center
|
||||
{ fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
|
||||
// zoom in + drift right
|
||||
{ fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
|
||||
// zoom in + drift left
|
||||
{ fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
|
||||
// zoom out, center
|
||||
{ fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
|
||||
// pan up + slight zoom
|
||||
{ fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
|
||||
// pan down + zoom in
|
||||
{ fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
|
||||
// diagonal drift bottom-right
|
||||
{ fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
|
||||
// diagonal drift top-left + zoom out
|
||||
{ fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
|
||||
];
|
||||
|
||||
/**
|
||||
* ImageSlide with Ken Burns effect (slow zoom / pan).
|
||||
* The effect index is derived from the image index for deterministic behaviour in Remotion.
|
||||
*/
|
||||
const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
|
||||
const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
|
||||
// progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
|
||||
const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
|
||||
const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
|
||||
const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
|
||||
<Img
|
||||
src={src}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
transform: `scale(${scale}) translate(${transX}%, ${transY}%)`,
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
|
||||
}) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||
const contentFrames = durationInFrames - endingFrames;
|
||||
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames / fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * perImage} durationInFrames={perImage}>
|
||||
<ImageSlide src={imgUrl} effectIndex={index} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
{musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) =>
|
||||
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||
? musicVolume * 0.35
|
||||
: musicVolume) *
|
||||
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Composition, type CalculateMetadataFunction } from "remotion";
|
||||
import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
|
||||
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
|
||||
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
|
||||
|
||||
/** La durée vient des propriétés : chaque Reel a la sienne. */
|
||||
@@ -27,21 +26,6 @@ export const RemotionRoot: React.FC = () => {
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ReelVideoProps}
|
||||
/>
|
||||
<Composition
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 25}
|
||||
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||
defaultProps={{
|
||||
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||
totalDuration: 25,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ImageCompositionProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user