feat: Implement Remotion video generation with client-side image selection and server-side rendering.

This commit is contained in:
Michael committed 2026-03-24 12:48:53 +01:00
1 parent e41c43c1da
commit 526e608077
4 files changed
+325 -21

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ const NewReel = createResponsiveRoute(
const RemotionVideo = createResponsiveRoute(
() => import("@/pages/remotion-video"),
() => import("@/pages/remotion-video")
() => import("@/pages/mobile/remotion-video")
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
+231
View File
@@ -0,0 +1,231 @@
import { useState, useMemo } 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 { Button } from "@/components/ui/button";
import { UploadCloud, Video, Loader2, Menu, Check } from "lucide-react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar";
import { useQuery } from "@tanstack/react-query";
import type { Media } from "@shared/schema";
export default function MobileRemotionVideoPage() {
const [images, setImages] = useState<File[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
const [isRendering, setIsRendering] = useState(false);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
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));
}
};
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;
}
return [...prev, media];
});
};
const combinedUrls = useMemo(() => {
const urls: string[] = [];
images.forEach(f => urls.push(URL.createObjectURL(f)));
selectedLibraryImages.forEach(m => urls.push(m.originalUrl));
return urls;
}, [images, selectedLibraryImages]);
const totalSelected = images.length + selectedLibraryImages.length;
const handleGenerate = async () => {
if (totalSelected < 3) {
toast({
title: "Pas assez d'images",
description: "Veuillez sélectionner au moins 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");
}
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);
}
};
// 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>
<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>
<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>
<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>
</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>
)}
<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>
)}
{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>
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">2. Aperçu</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>
</Card>
</main>
</div>
);
}
+76 -15
View File
@@ -1,30 +1,61 @@
import { useState } from "react";
import { useState, useMemo } 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 { UploadCloud, Video, Loader2 } from "lucide-react";
import { UploadCloud, Video, Loader2, Check } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { Media } from "@shared/schema";
export default function RemotionVideoPage() {
const [images, setImages] = useState<File[]>([]);
const [imageUrls, setImageUrls] = useState<string[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
const [isRendering, setIsRendering] = useState(false);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files).slice(0, 4);
setImages(files);
const ObjectUrls = files.map(file => URL.createObjectURL(file));
setImageUrls(ObjectUrls);
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 (images.length + prev.length >= 4) {
toast({ title: "Limite", description: "Vous pouvez sélectionner 4 images maximum au total.", variant: "destructive" });
return prev;
}
return [...prev, media];
});
};
const combinedUrls = useMemo(() => {
const urls: string[] = [];
images.forEach(f => urls.push(URL.createObjectURL(f)));
selectedLibraryImages.forEach(m => urls.push(m.originalUrl));
return urls;
}, [images, selectedLibraryImages]);
const totalSelected = images.length + selectedLibraryImages.length;
const handleGenerate = async () => {
if (images.length < 3) {
if (totalSelected < 3) {
toast({
title: "Pas assez d'images",
description: "Veuillez sélectionner au moins 3 images.",
@@ -39,6 +70,7 @@ export default function RemotionVideoPage() {
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",
@@ -69,7 +101,7 @@ export default function RemotionVideoPage() {
};
// 3s per image, 30fps
const totalFrames = imageUrls.length > 0 ? imageUrls.length * 3 * 30 : 300;
const totalFrames = combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300;
return (
<div className="container mx-auto p-6 space-y-8">
@@ -93,11 +125,40 @@ export default function RemotionVideoPage() {
/>
<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) sélectionnée(s)</p>
<p className="text-sm text-muted-foreground mt-2">{images.length} image(s) uploadée(s)</p>
</div>
{images.length > 0 && (
<Button onClick={handleGenerate} disabled={isRendering || images.length < 3} className="w-full">
{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 className="flex justify-between text-sm text-primary font-medium py-2">
<span>Total sélectionné : {totalSelected} / 4 images</span>
</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...</>
) : (
@@ -122,10 +183,10 @@ export default function RemotionVideoPage() {
<CardTitle>2. Aperçu</CardTitle>
</CardHeader>
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-4 overflow-hidden">
{imageUrls.length > 0 ? (
{combinedUrls.length > 0 ? (
<Player
component={ImageComposition}
inputProps={{ images: imageUrls }}
inputProps={{ images: combinedUrls }}
durationInFrames={totalFrames}
compositionWidth={1080}
compositionHeight={1920}
+17 -5
View File
@@ -25,14 +25,26 @@ const upload = multer({ storage });
remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
return res.status(400).json({ error: "Aucune image fournie" });
let imageUrls: string[] = [];
const existing = req.body.existingImageUrls;
if (existing) {
if (Array.isArray(existing)) {
imageUrls.push(...existing);
} else {
imageUrls.push(existing);
}
}
const host = req.protocol + "://" + req.get("host");
const imageUrls = files.map(file => {
return `${host}/uploads/temp/${path.basename(file.path)}`;
});
if (files && files.length > 0) {
const uploadedUrls = files.map(file => `${host}/uploads/temp/${path.basename(file.path)}`);
imageUrls.push(...uploadedUrls);
}
if (imageUrls.length === 0) {
return res.status(400).json({ error: "Aucune image fournie" });
}
console.log("Bundling Remotion project...");
const bundleLocation = await bundle({