feat: Implement Remotion bundling and rendering scripts.

This commit is contained in:
Michael committed 2026-03-24 13:25:22 +01:00
1 parent 526e608077
commit 1a7cbe6af9
6 files changed
+401 -100

No files matched your search

+148 -70
View File
@@ -4,13 +4,18 @@ 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 { UploadCloud, Video, Loader2, Check } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Textarea } from "@/components/ui/textarea";
import { UploadCloud, Video, Loader2, Check, Sparkles, Type } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import type { Media } from "@shared/schema";
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 [isRendering, setIsRendering] = useState(false);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const { toast } = useToast();
@@ -20,6 +25,20 @@ export default function RemotionVideoPage() {
});
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const generateTextMutation = useMutation({
mutationFn: async (text: string) => {
const response = await apiRequest('POST', '/api/reels/generate-text', { productInfo: text });
return response.json();
},
onSuccess: (data: any) => {
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" });
}
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
@@ -71,6 +90,7 @@ export default function RemotionVideoPage() {
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);
const response = await fetch("/api/remotion/render", {
method: "POST",
@@ -78,7 +98,8 @@ export default function RemotionVideoPage() {
});
if (!response.ok) {
throw new Error("Erreur de génération");
const errData = await response.json().catch(() => ({ error: "Erreur inconnue" }));
throw new Error(errData.error || "Erreur de génération");
}
const data = await response.json();
@@ -89,10 +110,10 @@ export default function RemotionVideoPage() {
description: "Votre vidéo Remotion est prête.",
});
} catch (err) {
} catch (err: any) {
toast({
title: "Erreur",
description: "Impossible de créer la vidéo.",
description: err.message || "Impossible de créer la vidéo.",
variant: "destructive"
});
} finally {
@@ -109,89 +130,146 @@ export default function RemotionVideoPage() {
<p className="text-muted-foreground">Créez une vidéo à partir de vos images avec Remotion.</p>
<div className="grid md:grid-cols-2 gap-8">
<Card>
<CardHeader>
<CardTitle>1. Sélectionner des Images</CardTitle>
<CardDescription>Choisissez entre 3 et 4 images.</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>
{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>
{/* LEFT COLUMN: Selection + Text */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>1. Sélectionner des Images</CardTitle>
<CardDescription>Choisissez entre 3 et 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>
)}
<div className="flex justify-between text-sm text-primary font-medium py-2">
<span>Total sélectionné : {totalSelected} / 4 images</span>
</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>
)}
{totalSelected > 0 && (
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full mt-2">
{isRendering ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours...</>
) : (
<><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>
)}
</Button>
)}
{videoUrl && (
<div className="pt-4">
<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>
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
<div className="flex justify-between text-sm text-primary font-medium py-2">
<span>Total sélectionné : {totalSelected} / 4 images</span>
</div>
</CardContent>
</Card>
{/* TEXT OVERLAY CARD */}
<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>
</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>
)}
</CardContent>
</Card>
{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>
{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="Ou saisissez votre texte ici..."
value={overlayText}
onChange={e => setOverlayText(e.target.value)}
rows={3}
className="mt-2"
/>
</CardContent>
</Card>
{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</>
)}
</Button>
)}
{videoUrl && (
<div className="pt-4">
<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>
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
</Button>
</div>
)}
</div>
{/* RIGHT COLUMN: Preview */}
<Card>
<CardHeader>
<CardTitle>2. Aperçu</CardTitle>
<CardTitle>3. Aperçu</CardTitle>
</CardHeader>
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-4 overflow-hidden">
{combinedUrls.length > 0 ? (
<Player
component={ImageComposition}
inputProps={{ images: combinedUrls }}
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined }}
durationInFrames={totalFrames}
compositionWidth={1080}
compositionHeight={1920}
fps={30}
controls
acknowledgeRemotionLicense
style={{
width: "auto",
height: "600px",
+101 -7
View File
@@ -1,26 +1,120 @@
import { AbsoluteFill, Img, Sequence, useVideoConfig } from "remotion";
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Audio } from "remotion";
export type WordTiming = {
word: string; // display word (may include emoji/hashtag)
startFrame: number; // frame this word becomes active
endFrame: number;
};
export type ImageCompositionProps = {
images: string[];
overlayText?: string;
audioUrl?: string;
wordTimings?: WordTiming[];
};
export const ImageComposition = ({ images }: ImageCompositionProps) => {
export const ImageComposition = ({
images, overlayText, audioUrl, wordTimings
}: 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.map((imgUrl, index) => (
<Sequence
key={index}
from={index * durationPerImage}
durationInFrames={durationPerImage}
>
<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>
</Sequence>
))}
{/* TTS Audio */}
{audioUrl && <Audio src={audioUrl} startFrom={0} />}
{/* TikTok-style word-by-word text overlay */}
{wordTimings && wordTimings.length > 0 ? (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 120,
}}
>
<div
style={{
maxWidth: "88%",
textAlign: "center",
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "0 14px",
alignItems: "baseline",
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
}}
>
{wordTimings.map((w, i) => {
const isActive = i === activeWordIdx;
const isVisible = w.startFrame <= frame;
return (
<span
key={i}
style={{
color: isActive ? "#FFE600" : "white",
fontFamily: "'Arial Black', 'Impact', sans-serif",
fontSize: 72,
fontWeight: 900,
lineHeight: 1.15,
opacity: isVisible ? 1 : 0,
WebkitTextStroke: "3px rgba(0,0,0,0.7)",
textShadow: "0 4px 18px rgba(0,0,0,0.85)",
transform: isActive ? "scale(1.08)" : "scale(1)",
display: "inline-block",
transition: "all 0.1s",
}}
>
{w.word}
</span>
);
})}
</div>
</AbsoluteFill>
) : overlayText ? (
/* Fallback: static text if no word timings */
<AbsoluteFill
style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}
>
<div
style={{
backgroundColor: "rgba(0,0,0,0.55)",
borderRadius: 16,
padding: "24px 32px",
color: "white",
fontSize: 60,
fontWeight: "bold",
fontFamily: "sans-serif",
textAlign: "center",
lineHeight: 1.4,
maxWidth: "90%",
WebkitTextStroke: "1px rgba(0,0,0,0.4)",
}}
>
{overlayText}
</div>
</AbsoluteFill>
) : null}
</AbsoluteFill>
);
};