From d98d43cd063a01484a75baca38080daadd6a4277 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 24 Mar 2026 13:28:28 +0100 Subject: [PATCH] feat: Add Remotion video generation page and API for image-based videos with text overlay and text-to-speech capabilities. --- client/src/pages/remotion-video.tsx | 320 ++++++++++++++++------------ server/routes/remotion.ts | 5 +- 2 files changed, 183 insertions(+), 142 deletions(-) diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx index 03548fe..50a5886 100644 --- a/client/src/pages/remotion-video.tsx +++ b/client/src/pages/remotion-video.tsx @@ -5,24 +5,61 @@ import { useToast } from "@/hooks/use-toast"; 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, Type } from "lucide-react"; +import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2 } from "lucide-react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest } from "@/lib/queryClient"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import type { Media } from "@shared/schema"; +const TTS_VOICES = [ + { id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme" }, + { id: "fr-FR-RemyMultilingualNeural", label: "Rémy", type: "Homme" }, + { id: "fr-FR-DeniseNeural", label: "Denise", type: "Femme" }, + { id: "fr-FR-HenriNeural", label: "Henri", type: "Homme" }, + { id: "fr-FR-EloiseNeural", label: "Éloïse", type: "Enfant" }, +]; + +/** strips hashtags & emojis for preview word timing (mirrors server logic) */ +function stripForTTS(text: string): string { + return text + .replace(/#\w+/g, '') + .replace(/[\uD800-\uDFFF]/g, '') // surrogate pairs (most emojis) + .replace(/[\u2600-\u27BF]/g, '') // misc symbols + .replace(/\s+/g, ' ') + .trim(); +} + +/** Client-side word timing estimate for live preview */ +function estimateWordTimings(text: string, fps = 30, wps = 2.5) { + const words = text.split(/\s+/).filter(Boolean); + const ttsWords = stripForTTS(text).split(/\s+/).filter(Boolean); + const framesPerTTSWord = fps / wps; + let frame = 0; + let ttsIdx = 0; + return words.map(word => { + const isSpoken = !/^#/.test(word) && !(/[\uD800-\uDFFF\u2600-\u27BF]/.test(word)); + const dur = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4; + const start = frame; + frame += Math.round(dur); + if (isSpoken) ttsIdx++; + return { word, startFrame: start, endFrame: frame }; + }); +} + export default function RemotionVideoPage() { const [images, setImages] = useState([]); const [selectedLibraryImages, setSelectedLibraryImages] = useState([]); const [overlayText, setOverlayText] = useState(""); const [productInfo, setProductInfo] = useState(""); const [generatedVariants, setGeneratedVariants] = useState([]); + const [ttsEnabled, setTtsEnabled] = useState(true); + const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural"); const [isRendering, setIsRendering] = useState(false); const [videoUrl, setVideoUrl] = useState(null); const { toast } = useToast(); - const { data: allMedia = [] } = useQuery({ - queryKey: ['/api/media'], - }); + const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'] }); const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const generateTextMutation = useMutation({ @@ -43,22 +80,16 @@ export default function RemotionVideoPage() { if (e.target.files) { const files = Array.from(e.target.files); const remainingSlots = 4 - selectedLibraryImages.length; - if (files.length > remainingSlots) { - toast({ title: "Limite atteinte", description: `Vous ne pouvez ajouter que ${remainingSlots} image(s) supplémentaire(s).`, variant: "destructive" }); - } setImages(files.slice(0, remainingSlots)); } }; const toggleLibraryImage = (media: Media) => { setSelectedLibraryImages(prev => { - const isSelected = prev.find(m => m.id === media.id); - if (isSelected) { - return prev.filter(m => m.id !== media.id); - } + if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id); if (images.length + prev.length >= 4) { - toast({ title: "Limite", description: "Vous pouvez sélectionner 4 images maximum au total.", variant: "destructive" }); - return prev; + toast({ title: "Limite", description: "4 images maximum.", variant: "destructive" }); + return prev; } return [...prev, media]; }); @@ -73,13 +104,40 @@ export default function RemotionVideoPage() { const totalSelected = images.length + selectedLibraryImages.length; + // For the Player preview: estimate word timings locally (no server roundtrip) + const previewWordTimings = useMemo(() => { + if (!overlayText) return undefined; + return estimateWordTimings(overlayText); + }, [overlayText]); + + const totalFrames = useMemo(() => { + if (previewWordTimings && previewWordTimings.length > 0) { + const textFrames = previewWordTimings[previewWordTimings.length - 1].endFrame + 30; + const imageFrames = combinedUrls.length * 3 * 30; + return Math.max(imageFrames, textFrames); + } + return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300; + }, [previewWordTimings, combinedUrls]); + + const handleTtsPreview = async () => { + if (!overlayText) return; + const ttsText = stripForTTS(overlayText); + if (!ttsText) { toast({ title: "Aucun texte à lire", description: "Hashtags et emojis ne sont pas lus.", variant: "destructive" }); return; } + try { + const response = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, voice: ttsVoice }); + const data = await response.json(); + if (data.success && data.audioBase64) { + const audio = new Audio(`data:audio/mp3;base64,${data.audioBase64}`); + audio.play(); + } + } catch { + toast({ title: "Erreur", description: "Prévisualisation voix impossible.", variant: "destructive" }); + } + }; + const handleGenerate = async () => { if (totalSelected < 3) { - toast({ - title: "Pas assez d'images", - description: "Veuillez sélectionner au moins 3 images.", - variant: "destructive" - }); + toast({ title: "Pas assez d'images", description: "Au moins 3 images requises.", variant: "destructive" }); return; } @@ -91,11 +149,9 @@ export default function RemotionVideoPage() { images.forEach(img => formData.append("images", img)); selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl)); if (overlayText) formData.append("overlayText", overlayText); + if (ttsEnabled && overlayText) formData.append("ttsVoice", ttsVoice); - const response = await fetch("/api/remotion/render", { - method: "POST", - body: formData, - }); + const response = await fetch("/api/remotion/render", { method: "POST", body: formData }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: "Erreur inconnue" })); @@ -104,147 +160,135 @@ export default function RemotionVideoPage() { const data = await response.json(); setVideoUrl(data.url); - - toast({ - title: "Vidéo générée !", - description: "Votre vidéo Remotion est prête.", - }); + toast({ title: "Vidéo générée !", description: "Votre vidéo Remotion est prête." }); } catch (err: any) { - toast({ - title: "Erreur", - description: err.message || "Impossible de créer la vidéo.", - variant: "destructive" - }); + toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" }); } finally { setIsRendering(false); } }; - // 3s per image, 30fps - const totalFrames = combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300; - return (

Générateur Remotion

-

Créez une vidéo à partir de vos images avec Remotion.

+

Créez une vidéo animée à partir de vos images avec voix et texte style TikTok.

- {/* LEFT COLUMN: Selection + Text */} + {/* ─── LEFT COLUMN ─── */}
+ + {/* 1. Images */} - 1. Sélectionner des Images - Choisissez entre 3 et 4 images (bibliothèque ou upload). + 1. Images + Sélectionnez 3 à 4 images (bibliothèque ou upload). -
- - -

Glissez vos images ici ou cliquez

-

{images.length} image(s) uploadée(s)

+
+ + +

Glissez ou cliquez

+

{images.length} uploadée(s)

{imageMediaList.length > 0 && ( -
-

Ou choisissez depuis la Bibliothèque :

-
- {imageMediaList.map((media) => { - const isSelected = selectedLibraryImages.some(m => m.id === media.id); - return ( -
toggleLibraryImage(media)} - className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`} - > - - {isSelected && ( -
- -
- )} -
- ) - })} -
+
+

Bibliothèque :

+
+ {imageMediaList.map(media => { + const isSelected = selectedLibraryImages.some(m => m.id === media.id); + return ( +
toggleLibraryImage(media)} + className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`}> + + {isSelected && ( +
+ +
+ )} +
+ ); + })} +
)} - -
- Total sélectionné : {totalSelected} / 4 images -
+

Total : {totalSelected} / 4

- {/* TEXT OVERLAY CARD */} + {/* 2. Texte + Voix */} - 2. Texte Overlay - Ce texte apparaîtra en bas de la vidéo sur toutes les images. + 2. Texte & Voix + La voix lira votre texte. Les hashtags et emojis s'affichent mais ne sont pas lus. - {/* AI Generation */} -
-