feat: Implement Remotion video generation feature, allowing users to combine images with text overlay and text-to-speech.

This commit is contained in:
Michael committed 2026-03-24 16:44:06 +01:00
1 parent 4f69a2edda
commit 24a1d39e88
4 files changed
+479 -253

No files matched your search

+267 -159
View File
@@ -1,48 +1,82 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useRef } from "react";
import { Player } from "@remotion/player";
import { ImageComposition } from "@/remotion/ImageComposition";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { UploadCloud, Video, Loader2, Menu, Check } from "lucide-react";
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 } from "lucide-react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import type { Media } from "@shared/schema";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
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" },
];
function stripForTTS(text: string) {
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
}
function estimateWordTimings(text: string, fps = 30, wps = 2.5) {
const words = text.split(/\s+/).filter(Boolean);
const framesPerWord = fps / wps;
let frame = 0;
return words.map(word => {
const isSpoken = !/^#/.test(word) && !(/[\uD800-\uDFFF\u2600-\u27BF]/.test(word));
const dur = isSpoken ? framesPerWord : framesPerWord * 0.4;
const start = frame;
frame += Math.round(dur);
return { word, startFrame: start, endFrame: frame };
});
}
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 [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
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 audioRef = useRef<HTMLAudioElement>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
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: () => toast({ title: "Erreur IA", variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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));
}
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
};
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 (images.length + prev.length >= 4) {
toast({ title: "Limite", description: "Vous pouvez sélectionner 4 images maximum au total.", variant: "destructive" });
return 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];
});
};
@@ -55,176 +89,250 @@ export default function MobileRemotionVideoPage() {
}, [images, selectedLibraryImages]);
const totalSelected = images.length + selectedLibraryImages.length;
const previewWordTimings = useMemo(() => overlayText ? estimateWordTimings(overlayText) : undefined, [overlayText]);
const totalFrames = useMemo(() => {
if (previewWordTimings?.length)
return Math.max(combinedUrls.length * 3 * 30, previewWordTimings[previewWordTimings.length - 1].endFrame + 30);
return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300;
}, [previewWordTimings, combinedUrls]);
const handleTtsPreview = async () => {
const ttsText = stripForTTS(overlayText);
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, voice: ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
};
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: "Pas assez d'images",
description: "Veuillez sélectionner au moins 3 images.",
variant: "destructive"
});
return;
}
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
setIsRendering(true);
setVideoUrl(null);
try {
const formData = new FormData();
images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
const response = await fetch("/api/remotion/render", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Erreur de génération");
}
if (overlayText) formData.append("overlayText", overlayText);
if (ttsEnabled && overlayText) formData.append("ttsVoice", ttsVoice);
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 });
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json();
setVideoUrl(data.url);
toast({
title: "Vidéo générée !",
description: "Votre vidéo Remotion est prête.",
});
} catch (err) {
toast({
title: "Erreur",
description: "Impossible de créer la vidéo.",
variant: "destructive"
});
} finally {
setIsRendering(false);
}
toast({ title: "Vidéo générée !" });
} catch (err: any) {
toast({ title: "Erreur", description: err.message, variant: "destructive" });
} finally { setIsRendering(false); }
};
// 3s per image, 30fps
const totalFrames = combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300;
return (
<div className="min-h-screen bg-background pb-20">
{/* Header Mobile */}
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center justify-between">
<div className="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 Remotion</h1>
</div>
<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-6">
<p className="text-sm text-muted-foreground">Créez une vidéo à partir de vos images (version mobile).</p>
<main className="p-4 space-y-4">
{/* 1. Images */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">1. Sélectionner des Images</CardTitle>
<CardDescription className="text-xs">Choisissez entre 3 et 4 images.</CardDescription>
<CardHeader className="pb-2">
<CardTitle className="text-base">1. Images</CardTitle>
</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 bg-muted/30">
<input
type="file"
multiple
accept="image/*"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<UploadCloud className="h-8 w-8 text-muted-foreground mb-2" />
<p className="font-medium text-sm text-center">Appuyez pour choisir</p>
<p className="text-xs text-muted-foreground mt-1">{images.length} image(s)</p>
<CardContent className="space-y-3">
<div className="border-2 border-dashed border-border rounded-lg p-5 flex flex-col items-center relative bg-muted/30">
<input type="file" multiple accept="image/*" onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-7 w-7 text-muted-foreground mb-1" />
<p className="text-sm font-medium">Appuyez pour choisir</p>
<p className="text-xs text-muted-foreground">{images.length} uploadée(s)</p>
</div>
{imageMediaList.length > 0 && (
<div className="pt-2 border-t border-border mt-2">
<p className="font-medium mb-3 text-sm">Bibliothèque :</p>
<div className="flex overflow-x-auto gap-2 pb-2">
{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-20 h-20 relative 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 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'}`}>
<img src={media.originalUrl} className="w-full h-full object-cover" />
{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>
<div className="flex justify-between text-xs text-primary font-medium py-1">
<span>Total sélectionné : {totalSelected} / 4</span>
</div>
{totalSelected > 0 && (
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12">
{isRendering ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération...</>
) : (
<><Video className="mr-2 h-4 w-4" /> Générer MP4</>
)}
</Button>
{/* 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>
)}
{videoUrl && (
<div className="pt-4 border-t mt-4">
<p className="font-semibold text-sm mb-2 text-green-600">Vidéo prête :</p>
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
<Button variant="outline" className="w-full mt-3" asChild>
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
<Textarea placeholder="Texte overlay…" value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
<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">
<div className="grid grid-cols-3 gap-1.5">
{TTS_VOICES.map(v => (
<div key={v.id} onClick={() => setTtsVoice(v.id)}
className={`cursor-pointer p-1.5 rounded border-2 text-center ${ttsVoice === v.id ? 'border-primary bg-primary/10' : 'border-transparent bg-muted/30'}`}>
<div className="font-semibold text-xs">{v.label}</div>
<div className="text-[10px] text-muted-foreground">{v.type}</div>
</div>
))}
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
</div>
)}
</CardContent>
</Card>
{/* 3. Musique */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">2. Aperçu</CardTitle>
<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="flex justify-center bg-zinc-950 rounded-lg p-2 overflow-hidden">
{combinedUrls.length > 0 ? (
<Player
component={ImageComposition}
inputProps={{ images: combinedUrls }}
durationInFrames={totalFrames}
compositionWidth={1080}
compositionHeight={1920}
fps={30}
controls
style={{
width: "auto",
height: "400px", // Smaller height for mobile
maxWidth: "100%",
borderRadius: "6px"
}}
/>
) : (
<div className="h-[400px] w-full flex items-center justify-center text-xs text-center text-muted-foreground px-4">
Sélectionnez des images pour prévisualiser.
</div>
)}
<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>
{/* Aperçu */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-2">
{combinedUrls.length > 0 ? (
<Player
component={ImageComposition}
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined, wordTimings: previewWordTimings }}
durationInFrames={totalFrames}
compositionWidth={1080}
compositionHeight={1920}
fps={30}
controls
acknowledgeRemotionLicense
style={{ width: "auto", height: "380px", maxWidth: "100%", borderRadius: "6px" }}
/>
) : (
<div className="h-[380px] w-full flex items-center justify-center text-xs text-muted-foreground text-center px-4">
Sélectionnez des images pour prévisualiser.
</div>
)}
</CardContent>
</Card>
{/* Générer */}
{totalSelected > 0 && (
<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" /> Génération (1-2 min)…</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
</Button>
)}
{videoUrl && (
<div className="space-y-2">
<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" />
<Button variant="outline" className="w-full" asChild>
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
</Button>
</div>
)}
</main>
</div>
);
+125 -65
View File
@@ -1,11 +1,12 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useRef } from "react";
import { Player } from "@remotion/player";
import { ImageComposition } from "@/remotion/ImageComposition";
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, Mic, Volume2 } from "lucide-react";
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Label } from "@/components/ui/label";
@@ -20,33 +21,36 @@ const TTS_VOICES = [
{ 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(/[\uD800-\uDFFF]/g, '')
.replace(/[\u2600-\u27BF]/g, '')
.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;
const framesPerWord = 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 dur = isSpoken ? framesPerWord : framesPerWord * 0.4;
const start = frame;
frame += Math.round(dur);
if (isSpoken) ttsIdx++;
return { word, startFrame: start, endFrame: frame };
});
}
interface AudioTrack {
id: string;
title: string;
fileName: string;
url: string;
duration: number;
}
export default function RemotionVideoPage() {
const [images, setImages] = useState<File[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
@@ -55,11 +59,19 @@ export default function RemotionVideoPage() {
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
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 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 imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const generateTextMutation = useMutation({
@@ -71,26 +83,17 @@ export default function RemotionVideoPage() {
setGeneratedVariants(data.variants || []);
toast({ title: "Textes générés", description: "Cliquez sur un texte pour l'appliquer." });
},
onError: () => {
toast({ title: "Erreur IA", description: "Impossible de générer le texte.", variant: "destructive" });
}
onError: () => toast({ title: "Erreur IA", description: "Impossible de générer le texte.", variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
const remainingSlots = 4 - selectedLibraryImages.length;
setImages(files.slice(0, remainingSlots));
}
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: "Limite", description: "4 images maximum.", variant: "destructive" });
return prev;
}
if (images.length + prev.length >= 4) { toast({ title: "4 images maximum.", variant: "destructive" }); return prev; }
return [...prev, media];
});
};
@@ -104,17 +107,11 @@ 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 previewWordTimings = useMemo(() => overlayText ? estimateWordTimings(overlayText) : undefined, [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);
if (previewWordTimings?.length) {
return Math.max(combinedUrls.length * 3 * 30, previewWordTimings[previewWordTimings.length - 1].endFrame + 30);
}
return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300;
}, [previewWordTimings, combinedUrls]);
@@ -122,46 +119,50 @@ export default function RemotionVideoPage() {
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; }
if (!ttsText) { toast({ title: "Aucun texte à lire", 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();
}
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).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: "Au moins 3 images requises.", variant: "destructive" });
return;
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);
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);
if (ttsEnabled && overlayText) formData.append("ttsVoice", ttsVoice);
const response = await fetch("/api/remotion/render", { method: "POST", body: formData });
if (!response.ok) {
const errData = await response.json().catch(() => ({ error: "Erreur inconnue" }));
throw new Error(errData.error || "Erreur de génération");
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 });
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
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 est prête." });
} catch (err: any) {
toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" });
} finally {
@@ -174,6 +175,8 @@ export default function RemotionVideoPage() {
<h1 className="text-3xl font-bold tracking-tight">Générateur Remotion</h1>
<p className="text-muted-foreground">Créez une vidéo animée à partir de vos images avec voix et texte style TikTok.</p>
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
<div className="grid md:grid-cols-2 gap-8">
{/* ─── LEFT COLUMN ─── */}
<div className="space-y-6">
@@ -192,7 +195,6 @@ export default function RemotionVideoPage() {
<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>
@@ -203,11 +205,7 @@ export default function RemotionVideoPage() {
<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>
)}
{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>
);
})}
@@ -225,7 +223,6 @@ export default function RemotionVideoPage() {
<CardDescription>La voix lira votre texte. Les hashtags et emojis s'affichent mais ne sont pas lus.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* AI generation */}
<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}
@@ -233,7 +230,6 @@ export default function RemotionVideoPage() {
{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>
@@ -245,16 +241,12 @@ export default function RemotionVideoPage() {
))}
</div>
)}
<Textarea placeholder="Votre texte overlay..." value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={3} />
{/* TTS toggle */}
<div className="flex items-center gap-3 pt-1">
<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 font-medium">Voix :</p>
@@ -278,7 +270,75 @@ export default function RemotionVideoPage() {
</CardContent>
</Card>
{/* Generate button */}
{/* 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 audio ou uploadez la vôtre.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Bibliothèque interne */}
<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-8 border-2 border-dashed border-border rounded-xl">
<Music className="w-10 h-10 mx-auto text-muted-foreground mb-3 opacity-40" />
<p className="text-sm text-muted-foreground font-medium">Aucune musique disponible</p>
<p className="text-xs text-muted-foreground mt-1">Un administrateur peut ajouter des MP3 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-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>
{/* Upload perso */}
<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 musique : {Math.round(musicVolume * 100)}%
</Label>
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
<p className="text-xs text-muted-foreground italic">Baissez si la voix TTS est couverte par la musique.</p>
</div>
)}
</CardContent>
</Card>
{/* Générer */}
{totalSelected > 0 && (
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
{isRendering
@@ -301,7 +361,7 @@ export default function RemotionVideoPage() {
{/* ─── RIGHT COLUMN: PREVIEW ─── */}
<Card>
<CardHeader>
<CardTitle>3. Aperçu en direct</CardTitle>
<CardTitle>Aperçu en direct</CardTitle>
<CardDescription>Le texte animé style TikTok s'affiche ici en temps réel.</CardDescription>
</CardHeader>
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-4 overflow-hidden">
+75 -26
View File
@@ -1,8 +1,8 @@
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Audio } from "remotion";
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
export type WordTiming = {
word: string; // display word (may include emoji/hashtag)
startFrame: number; // frame this word becomes active
word: string;
startFrame: number;
endFrame: number;
};
@@ -11,48 +11,100 @@ export type ImageCompositionProps = {
overlayText?: string;
audioUrl?: string;
wordTimings?: WordTiming[];
musicUrl?: string;
musicVolume?: number;
};
// 6 modern, simple entrance transitions
type Transition = "fade" | "slideLeft" | "slideRight" | "zoomIn" | "zoomOut" | "slideUp";
const TRANSITIONS: Transition[] = ["zoomIn", "slideLeft", "fade", "slideRight", "zoomOut", "slideUp"];
// Deterministic "random" — varied order that never repeats two in a row
const TRANSITION_ORDER = [0, 1, 2, 3, 4, 5, 1, 3, 0, 4, 2, 5];
function pickTransition(index: number): Transition {
return TRANSITIONS[TRANSITION_ORDER[index % TRANSITION_ORDER.length]];
}
// Sub-component: one image with its entrance spring animation
const ImageSlide: React.FC<{ src: string; transition: Transition }> = ({ src, transition }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 22, stiffness: 160 }, durationInFrames: 22 });
let wrapStyle: React.CSSProperties = {};
switch (transition) {
case "fade":
wrapStyle = { opacity: progress };
break;
case "slideLeft":
wrapStyle = {
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateX(${interpolate(progress, [0, 1], [110, 0])}%)`,
};
break;
case "slideRight":
wrapStyle = {
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateX(${interpolate(progress, [0, 1], [-110, 0])}%)`,
};
break;
case "slideUp":
wrapStyle = {
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateY(${interpolate(progress, [0, 1], [80, 0])}%)`,
};
break;
case "zoomIn":
wrapStyle = {
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `scale(${interpolate(progress, [0, 1], [0.75, 1])})`,
};
break;
case "zoomOut":
wrapStyle = {
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `scale(${interpolate(progress, [0, 1], [1.25, 1])})`,
};
break;
}
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", ...wrapStyle }}>
<Img src={src} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
</AbsoluteFill>
);
};
export const ImageComposition = ({
images, overlayText, audioUrl, wordTimings
images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3
}: ImageCompositionProps) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
const durationPerImage = 3 * fps;
// Determine which word is currently "active" for TikTok highlight
const activeWordIdx = wordTimings
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
: -1;
// Words displayed up to the current active one (progressive reveal)
const visibleWords = wordTimings
? wordTimings.filter(w => w.startFrame <= frame)
: [];
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images sequence */}
{/* Images with entrance transitions */}
{images.map((imgUrl, index) => (
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<Img src={imgUrl} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
</AbsoluteFill>
<ImageSlide src={imgUrl} transition={pickTransition(index)} />
</Sequence>
))}
{/* TTS Audio */}
{audioUrl && <Audio src={audioUrl} startFrom={0} />}
{audioUrl && <Html5Audio src={audioUrl} />}
{/* Background music */}
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
{/* TikTok-style word-by-word text overlay */}
{wordTimings && wordTimings.length > 0 ? (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 120,
}}
>
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
<div
style={{
maxWidth: "88%",
@@ -92,10 +144,7 @@ export const ImageComposition = ({
</div>
</AbsoluteFill>
) : overlayText ? (
/* Fallback: static text if no word timings */
<AbsoluteFill
style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}
>
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
<div
style={{
backgroundColor: "rgba(0,0,0,0.55)",
+12 -3
View File
@@ -85,9 +85,10 @@ function computeWordTimings(
});
}
remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
const fields = req.files as Record<string, Express.Multer.File[]>;
const files = fields["images"] ?? [];
let imageUrls: string[] = [];
const existing = req.body.existingImageUrls;
@@ -110,6 +111,14 @@ remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
: undefined;
const ttsVoice: string = req.body.ttsVoice || "fr-FR-VivienneMultilingualNeural";
const musicVolume: number = parseFloat(req.body.musicVolume ?? "0.3");
// Music: uploaded file takes priority, else use catalog track URL directly
const musicFile = fields["music"]?.[0];
const musicUrl: string | undefined = musicFile
? `${host}/uploads/temp/${path.basename(musicFile.path)}`
: (req.body.musicTrackUrl as string | undefined) || undefined;
const fps = 30;
const totalVideoDuration = imageUrls.length * 3; // 3s per image
@@ -154,7 +163,7 @@ remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
console.log("🎬 Getting Remotion bundle (cached)...");
const bundleLocation = await getBundle();
const inputProps = { images: imageUrls, overlayText, audioUrl, wordTimings };
const inputProps = { images: imageUrls, overlayText, audioUrl, wordTimings, musicUrl, musicVolume };
console.log("🎬 Selecting composition...");
const composition = await selectComposition({