mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 07:28:11 +02:00
feat: Implement Remotion bundling and rendering scripts.
This commit is contained in:
1 parent
526e608077
commit
1a7cbe6af9
6 files changed
+401
-100
No files matched your search
@@ -4,13 +4,18 @@ import { ImageComposition } from "@/remotion/ImageComposition";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { UploadCloud, Video, Loader2, Check } from "lucide-react";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import type { Media } from "@shared/schema";
|
||||||
|
|
||||||
export default function RemotionVideoPage() {
|
export default function RemotionVideoPage() {
|
||||||
const [images, setImages] = useState<File[]>([]);
|
const [images, setImages] = useState<File[]>([]);
|
||||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||||
|
const [overlayText, setOverlayText] = useState("");
|
||||||
|
const [productInfo, setProductInfo] = useState("");
|
||||||
|
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||||
const [isRendering, setIsRendering] = useState(false);
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -20,6 +25,20 @@ export default function RemotionVideoPage() {
|
|||||||
});
|
});
|
||||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
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>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
@@ -71,6 +90,7 @@ export default function RemotionVideoPage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
images.forEach(img => formData.append("images", img));
|
images.forEach(img => formData.append("images", img));
|
||||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||||
|
if (overlayText) formData.append("overlayText", overlayText);
|
||||||
|
|
||||||
const response = await fetch("/api/remotion/render", {
|
const response = await fetch("/api/remotion/render", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -78,7 +98,8 @@ export default function RemotionVideoPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const data = await response.json();
|
||||||
@@ -89,10 +110,10 @@ export default function RemotionVideoPage() {
|
|||||||
description: "Votre vidéo Remotion est prête.",
|
description: "Votre vidéo Remotion est prête.",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de créer la vidéo.",
|
description: err.message || "Impossible de créer la vidéo.",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
} 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>
|
<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">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
<Card>
|
{/* LEFT COLUMN: Selection + Text */}
|
||||||
<CardHeader>
|
<div className="space-y-6">
|
||||||
<CardTitle>1. Sélectionner des Images</CardTitle>
|
<Card>
|
||||||
<CardDescription>Choisissez entre 3 et 4 images.</CardDescription>
|
<CardHeader>
|
||||||
</CardHeader>
|
<CardTitle>1. Sélectionner des Images</CardTitle>
|
||||||
<CardContent className="space-y-4">
|
<CardDescription>Choisissez entre 3 et 4 images (bibliothèque ou upload).</CardDescription>
|
||||||
<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">
|
</CardHeader>
|
||||||
<input
|
<CardContent className="space-y-4">
|
||||||
type="file"
|
<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">
|
||||||
multiple
|
<input
|
||||||
accept="image/*"
|
type="file"
|
||||||
onChange={handleFileChange}
|
multiple
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
accept="image/*"
|
||||||
/>
|
onChange={handleFileChange}
|
||||||
<UploadCloud className="h-10 w-10 text-muted-foreground mb-4" />
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
<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>
|
<UploadCloud className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
</div>
|
<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>
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm text-primary font-medium py-2">
|
{imageMediaList.length > 0 && (
|
||||||
<span>Total sélectionné : {totalSelected} / 4 images</span>
|
<div className="pt-4 border-t border-border">
|
||||||
</div>
|
<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 && (
|
<div className="flex justify-between text-sm text-primary font-medium py-2">
|
||||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full mt-2">
|
<span>Total sélectionné : {totalSelected} / 4 images</span>
|
||||||
{isRendering ? (
|
</div>
|
||||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours...</>
|
</CardContent>
|
||||||
) : (
|
</Card>
|
||||||
<><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>
|
|
||||||
)}
|
{/* TEXT OVERLAY CARD */}
|
||||||
</Button>
|
<Card>
|
||||||
)}
|
<CardHeader>
|
||||||
{videoUrl && (
|
<CardTitle className="flex items-center gap-2"><Type className="w-4 h-4" /> 2. Texte Overlay</CardTitle>
|
||||||
<div className="pt-4">
|
<CardDescription>Ce texte apparaîtra en bas de la vidéo sur toutes les images.</CardDescription>
|
||||||
<p className="font-semibold mb-2 text-green-600">Vidéo prête :</p>
|
</CardHeader>
|
||||||
<video src={videoUrl} controls className="w-full rounded-md shadow" />
|
<CardContent className="space-y-4">
|
||||||
<Button variant="outline" className="w-full mt-2" asChild>
|
{/* AI Generation */}
|
||||||
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
|
<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>
|
</Button>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>2. Aperçu</CardTitle>
|
<CardTitle>3. Aperçu</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-4 overflow-hidden">
|
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-4 overflow-hidden">
|
||||||
{combinedUrls.length > 0 ? (
|
{combinedUrls.length > 0 ? (
|
||||||
<Player
|
<Player
|
||||||
component={ImageComposition}
|
component={ImageComposition}
|
||||||
inputProps={{ images: combinedUrls }}
|
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined }}
|
||||||
durationInFrames={totalFrames}
|
durationInFrames={totalFrames}
|
||||||
compositionWidth={1080}
|
compositionWidth={1080}
|
||||||
compositionHeight={1920}
|
compositionHeight={1920}
|
||||||
fps={30}
|
fps={30}
|
||||||
controls
|
controls
|
||||||
|
acknowledgeRemotionLicense
|
||||||
style={{
|
style={{
|
||||||
width: "auto",
|
width: "auto",
|
||||||
height: "600px",
|
height: "600px",
|
||||||
|
|||||||
@@ -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 = {
|
export type ImageCompositionProps = {
|
||||||
images: string[];
|
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 { fps } = useVideoConfig();
|
||||||
|
const frame = useCurrentFrame();
|
||||||
const durationPerImage = 3 * fps;
|
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 (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||||
|
{/* Images sequence */}
|
||||||
{images.map((imgUrl, index) => (
|
{images.map((imgUrl, index) => (
|
||||||
<Sequence
|
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
|
||||||
key={index}
|
|
||||||
from={index * durationPerImage}
|
|
||||||
durationInFrames={durationPerImage}
|
|
||||||
>
|
|
||||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||||
<Img src={imgUrl} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
<Img src={imgUrl} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
</Sequence>
|
</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>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+113
-23
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { bundle } from "@remotion/bundler";
|
import { bundle } from "@remotion/bundler";
|
||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
|
import { ffmpegService } from "../services/ffmpeg";
|
||||||
|
|
||||||
export const remotionRouter = Router();
|
export const remotionRouter = Router();
|
||||||
|
|
||||||
@@ -22,6 +23,54 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({ storage });
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips hashtags and emojis from text for TTS synthesis.
|
||||||
|
* Visual display keeps the full text.
|
||||||
|
*/
|
||||||
|
function stripForTTS(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/#\w+/g, '') // remove hashtags
|
||||||
|
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FEFF}]/gu, '') // emojis
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates word timings (in frames at 30fps) based on audio duration.
|
||||||
|
* Distributes frames proportionally across display words.
|
||||||
|
* Hashtags and emojis are display-only (shown but voice skips them).
|
||||||
|
*/
|
||||||
|
function computeWordTimings(
|
||||||
|
displayText: string,
|
||||||
|
audioDurationSeconds: number,
|
||||||
|
fps: number,
|
||||||
|
startFrame: number
|
||||||
|
): Array<{ word: string; startFrame: number; endFrame: number }> {
|
||||||
|
// Split into display words (preserving hashtags, emojis)
|
||||||
|
const displayWords = displayText.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
// TTS words (stripped - this matches what audio says)
|
||||||
|
const ttsText = stripForTTS(displayText);
|
||||||
|
const ttsWords = ttsText.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
// Frames per TTS word (based on actual audio duration)
|
||||||
|
const totalTTSFrames = audioDurationSeconds * fps;
|
||||||
|
const framesPerTTSWord = ttsWords.length > 0 ? totalTTSFrames / ttsWords.length : fps;
|
||||||
|
|
||||||
|
let currentFrame = startFrame;
|
||||||
|
let ttsIdx = 0;
|
||||||
|
|
||||||
|
return displayWords.map((word) => {
|
||||||
|
const wordStart = currentFrame;
|
||||||
|
// Use TTS pacing if this word is a real spoken word
|
||||||
|
const isSpoken = !/^#/.test(word) && !/^\p{Emoji}/u.test(word);
|
||||||
|
const frameDuration = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4; // hashtags flash briefly
|
||||||
|
currentFrame += Math.round(frameDuration);
|
||||||
|
if (isSpoken) ttsIdx++;
|
||||||
|
return { word, startFrame: wordStart, endFrame: currentFrame };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
|
remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const files = req.files as Express.Multer.File[];
|
const files = req.files as Express.Multer.File[];
|
||||||
@@ -29,59 +78,100 @@ remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
|
|||||||
|
|
||||||
const existing = req.body.existingImageUrls;
|
const existing = req.body.existingImageUrls;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (Array.isArray(existing)) {
|
if (Array.isArray(existing)) imageUrls.push(...existing);
|
||||||
imageUrls.push(...existing);
|
else imageUrls.push(existing);
|
||||||
} else {
|
|
||||||
imageUrls.push(existing);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = req.protocol + "://" + req.get("host");
|
const host = req.protocol + "://" + req.get("host");
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const uploadedUrls = files.map(file => `${host}/uploads/temp/${path.basename(file.path)}`);
|
imageUrls.push(...files.map(f => `${host}/uploads/temp/${path.basename(f.path)}`));
|
||||||
imageUrls.push(...uploadedUrls);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageUrls.length === 0) {
|
if (imageUrls.length === 0) {
|
||||||
return res.status(400).json({ error: "Aucune image fournie" });
|
return res.status(400).json({ error: "Aucune image fournie" });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Bundling Remotion project...");
|
const overlayText: string | undefined = typeof req.body.overlayText === 'string' && req.body.overlayText.trim()
|
||||||
|
? req.body.overlayText.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const ttsVoice: string = req.body.ttsVoice || "fr-FR-VivienneMultilingualNeural";
|
||||||
|
const fps = 30;
|
||||||
|
const totalVideoDuration = imageUrls.length * 3; // 3s per image
|
||||||
|
|
||||||
|
// --- TTS Audio Generation ---
|
||||||
|
let audioUrl: string | undefined;
|
||||||
|
let wordTimings: Array<{ word: string; startFrame: number; endFrame: number }> | undefined;
|
||||||
|
let totalFrames = totalVideoDuration * fps;
|
||||||
|
|
||||||
|
if (overlayText) {
|
||||||
|
const ttsText = stripForTTS(overlayText);
|
||||||
|
if (ttsText) {
|
||||||
|
console.log("🎙️ Generating TTS for:", ttsText);
|
||||||
|
try {
|
||||||
|
const ttsResult = await ffmpegService.previewTTS(ttsText, ttsVoice);
|
||||||
|
if (ttsResult.success && ttsResult.audioBase64) {
|
||||||
|
// Save audio to temp file
|
||||||
|
const audioFilename = `tts-${Date.now()}.mp3`;
|
||||||
|
const audioPath = path.join(uploadDir, audioFilename);
|
||||||
|
fs.writeFileSync(audioPath, Buffer.from(ttsResult.audioBase64, "base64"));
|
||||||
|
audioUrl = `${host}/uploads/temp/${audioFilename}`;
|
||||||
|
|
||||||
|
// Estimate audio duration (MP3 at 128kbps ~ 16KB/s)
|
||||||
|
const audioBytes = Buffer.from(ttsResult.audioBase64, "base64").length;
|
||||||
|
const estimatedAudioDuration = Math.max(audioBytes / 16000, ttsText.split(/\s+/).length * 0.5);
|
||||||
|
|
||||||
|
// Expand video if text needs more time than images
|
||||||
|
if (estimatedAudioDuration > totalVideoDuration) {
|
||||||
|
totalFrames = Math.ceil(estimatedAudioDuration * fps) + fps; // +1s buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
wordTimings = computeWordTimings(overlayText, estimatedAudioDuration, fps, 0);
|
||||||
|
console.log(`✅ TTS generated, ~${estimatedAudioDuration.toFixed(1)}s, ${wordTimings.length} words`);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ TTS failed:", ttsResult.error);
|
||||||
|
}
|
||||||
|
} catch (ttsErr) {
|
||||||
|
console.warn("⚠️ TTS error (rendering without audio):", ttsErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎬 Bundling Remotion project...");
|
||||||
const bundleLocation = await bundle({
|
const bundleLocation = await bundle({
|
||||||
entryPoint: path.resolve(process.cwd(), "client/src/remotion/index.ts"),
|
entryPoint: path.resolve(process.cwd(), "client/src/remotion/index.ts"),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Selecting composition...");
|
const inputProps = { images: imageUrls, overlayText, audioUrl, wordTimings };
|
||||||
|
|
||||||
|
console.log("🎬 Selecting composition...");
|
||||||
const composition = await selectComposition({
|
const composition = await selectComposition({
|
||||||
serveUrl: bundleLocation,
|
serveUrl: bundleLocation,
|
||||||
id: "ImageVideo",
|
id: "ImageVideo",
|
||||||
inputProps: {
|
inputProps,
|
||||||
images: imageUrls,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Override duration if TTS needs more frames
|
||||||
|
const effectiveDurationInFrames = Math.max(totalFrames, composition.durationInFrames);
|
||||||
|
|
||||||
const outputFilename = `out-${Date.now()}.mp4`;
|
const outputFilename = `out-${Date.now()}.mp4`;
|
||||||
const outputLocation = path.join(uploadDir, outputFilename);
|
const outputLocation = path.join(uploadDir, outputFilename);
|
||||||
|
|
||||||
console.log("Rendering media to", outputLocation);
|
console.log("🎬 Rendering media...", effectiveDurationInFrames, "frames");
|
||||||
await renderMedia({
|
await renderMedia({
|
||||||
composition,
|
composition: { ...composition, durationInFrames: effectiveDurationInFrames },
|
||||||
serveUrl: bundleLocation,
|
serveUrl: bundleLocation,
|
||||||
codec: "h264",
|
codec: "h264",
|
||||||
outputLocation,
|
outputLocation,
|
||||||
inputProps: {
|
inputProps,
|
||||||
images: imageUrls,
|
chromiumOptions: { disableWebSecurity: true }
|
||||||
},
|
|
||||||
chromiumOptions: {
|
|
||||||
disableWebSecurity: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Render completed:", outputFilename);
|
console.log("✅ Render completed:", outputFilename);
|
||||||
res.json({ url: `${host}/uploads/temp/${outputFilename}` });
|
res.json({ url: `${host}/uploads/temp/${outputFilename}` });
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Remotion render error:", err);
|
console.error("❌ Remotion render error:", err);
|
||||||
res.status(500).json({ error: "Erreur lors du rendu de la vidéo: " + err.message });
|
res.status(500).json({ error: "Erreur lors du rendu: " + err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { bundle } from "@remotion/bundler";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const loc = await bundle({ entryPoint: path.resolve("client/src/remotion/index.ts") });
|
||||||
|
console.log("Success:", loc);
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Error bundling", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { bundle } from "@remotion/bundler";
|
||||||
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const loc = await bundle({ entryPoint: path.resolve("client/src/remotion/index.ts") });
|
||||||
|
console.log("Bundle success:", loc);
|
||||||
|
const comp = await selectComposition({
|
||||||
|
serveUrl: loc,
|
||||||
|
id: "ImageVideo",
|
||||||
|
inputProps: { images: ["https://placehold.co/1080x1920?text=Image+1"] }
|
||||||
|
});
|
||||||
|
console.log("Comp:", comp?.id);
|
||||||
|
await renderMedia({
|
||||||
|
composition: comp,
|
||||||
|
serveUrl: loc,
|
||||||
|
codec: "h264",
|
||||||
|
outputLocation: path.resolve("out.mp4"),
|
||||||
|
inputProps: { images: ["https://placehold.co/1080x1920?text=Image+1"] }
|
||||||
|
});
|
||||||
|
console.log("Render success!");
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Error rendering", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
Reference in new issue
Block a user