mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
feat: Add Remotion video generation page and API for image-based videos with text overlay and text-to-speech capabilities.
This commit is contained in:
1 parent
1a7cbe6af9
commit
d98d43cd06
2 files changed
+183
-142
No files matched your search
+180
-140
@@ -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<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 [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||
queryKey: ['/api/media'],
|
||||
});
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ 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 (
|
||||
<div className="container mx-auto p-6 space-y-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Générateur Remotion</h1>
|
||||
<p className="text-muted-foreground">Créez une vidéo à partir de vos images avec Remotion.</p>
|
||||
<p className="text-muted-foreground">Créez une vidéo animée à partir de vos images avec voix et texte style TikTok.</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* LEFT COLUMN: Selection + Text */}
|
||||
{/* ─── LEFT COLUMN ─── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Sélectionner des Images</CardTitle>
|
||||
<CardDescription>Choisissez entre 3 et 4 images (bibliothèque ou upload).</CardDescription>
|
||||
<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-8 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-4" />
|
||||
<p className="font-medium text-center">Glissez vos images ici ou cliquez</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">{images.length} image(s) uploadée(s)</p>
|
||||
<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-4 border-t border-border">
|
||||
<p className="font-medium mb-3 text-sm">Ou choisissez depuis la Bibliothèque :</p>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto pr-2">
|
||||
{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 className="pt-3 border-t border-border">
|
||||
<p className="font-medium mb-2 text-sm">Bibliothèque :</p>
|
||||
<div className="grid grid-cols-4 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>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-sm text-primary font-medium py-2">
|
||||
<span>Total sélectionné : {totalSelected} / 4 images</span>
|
||||
</div>
|
||||
<p className="text-sm text-primary font-medium">Total : {totalSelected} / 4</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TEXT OVERLAY CARD */}
|
||||
{/* 2. Texte + Voix */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Type className="w-4 h-4" /> 2. Texte Overlay</CardTitle>
|
||||
<CardDescription>Ce texte apparaîtra en bas de la vidéo sur toutes les images.</CardDescription>
|
||||
<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 et emojis s'affichent mais ne sont pas lus.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* AI Generation */}
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
{/* 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}
|
||||
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-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Cliquez sur un texte pour l'appliquer :</p>
|
||||
<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'}`}
|
||||
>
|
||||
<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="Ou saisissez votre texte ici..."
|
||||
value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-2"
|
||||
/>
|
||||
<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>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TTS_VOICES.map(v => (
|
||||
<div key={v.id} onClick={() => setTtsVoice(v.id)}
|
||||
className={`cursor-pointer p-2 rounded border-2 text-center transition-all ${ttsVoice === v.id ? 'border-primary bg-primary/10' : 'border-transparent bg-muted/30'}`}>
|
||||
<div className="font-semibold text-sm">{v.label}</div>
|
||||
<div className="text-xs 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>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
ℹ️ Les hashtags et emojis s'affichent à l'écran, mais la voix ne les lit pas.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Generate button */}
|
||||
{totalSelected > 0 && (
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||
{isRendering ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours (peut prendre 1-2 min)...</>
|
||||
) : (
|
||||
<><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>
|
||||
)}
|
||||
{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="pt-4">
|
||||
<div>
|
||||
<p className="font-semibold mb-2 text-green-600">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-md shadow" />
|
||||
<Button variant="outline" className="w-full mt-2" asChild>
|
||||
@@ -254,34 +298,30 @@ export default function RemotionVideoPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Preview */}
|
||||
{/* ─── RIGHT COLUMN: PREVIEW ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>3. Aperçu</CardTitle>
|
||||
<CardTitle>3. 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">
|
||||
{combinedUrls.length > 0 ? (
|
||||
<Player
|
||||
component={ImageComposition}
|
||||
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined }}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
fps={30}
|
||||
controls
|
||||
acknowledgeRemotionLicense
|
||||
style={{
|
||||
width: "auto",
|
||||
height: "600px",
|
||||
maxWidth: "100%",
|
||||
borderRadius: "8px"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[600px] w-full flex items-center justify-center text-muted-foreground">
|
||||
Sélectionnez des images pour prévisualiser.
|
||||
</div>
|
||||
)}
|
||||
{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: "600px", maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[600px] w-full flex items-center justify-center text-muted-foreground text-center px-4">
|
||||
Sélectionnez des images pour voir l'aperçu.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user