mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
feat: Implement Remotion video generation with dedicated client UI, components, and server-side rendering.
This commit is contained in:
1 parent
177f4db455
commit
e41c43c1da
13 files changed
+2998
-41
No files matched your search
+8
-1
@@ -24,7 +24,14 @@ RUN apk add --no-cache \
|
||||
fontconfig \
|
||||
curl \
|
||||
tar \
|
||||
bzip2
|
||||
bzip2 \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont \
|
||||
ffmpeg
|
||||
|
||||
# Copier les fichiers de configuration
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -81,6 +81,11 @@ const NewReel = createResponsiveRoute(
|
||||
() => import("@/pages/mobile/new-reel") // Uses same component for now
|
||||
);
|
||||
|
||||
const RemotionVideo = createResponsiveRoute(
|
||||
() => import("@/pages/remotion-video"),
|
||||
() => import("@/pages/remotion-video")
|
||||
);
|
||||
|
||||
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
@@ -131,6 +136,7 @@ function Router() {
|
||||
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
|
||||
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
|
||||
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
|
||||
<Route path="/remotion">{() => <ProtectedRoute component={RemotionVideo} />}</Route>
|
||||
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
|
||||
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
|
||||
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
||||
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: "New" },
|
||||
{ icon: Video, label: "Générateur Vidéo", href: "/remotion", badge: "New" },
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } 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";
|
||||
|
||||
export default function RemotionVideoPage() {
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
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 handleGenerate = async () => {
|
||||
if (images.length < 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));
|
||||
|
||||
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 = imageUrls.length > 0 ? imageUrls.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>
|
||||
|
||||
<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) sélectionnée(s)</p>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<Button onClick={handleGenerate} disabled={isRendering || images.length < 3} className="w-full">
|
||||
{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>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>2. Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center bg-zinc-950 rounded-lg p-4 overflow-hidden">
|
||||
{imageUrls.length > 0 ? (
|
||||
<Player
|
||||
component={ImageComposition}
|
||||
inputProps={{ images: imageUrls }}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
fps={30}
|
||||
controls
|
||||
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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AbsoluteFill, Img, Sequence, useVideoConfig } from "remotion";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
images: string[];
|
||||
};
|
||||
|
||||
export const ImageComposition = ({ images }: ImageCompositionProps) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const durationPerImage = 3 * fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{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>
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Composition } from "remotion";
|
||||
import { ImageComposition, ImageCompositionProps } from "./ImageComposition";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
durationInFrames={360}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
images: [
|
||||
"https://placehold.co/1080x1920?text=Image+1",
|
||||
"https://placehold.co/1080x1920?text=Image+2",
|
||||
"https://placehold.co/1080x1920?text=Image+3"
|
||||
],
|
||||
} as ImageCompositionProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
Generated
+2680
-40
File diff suppressed because it is too large.
Load diff
@@ -44,6 +44,10 @@
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@remotion/bundler": "^4.0.438",
|
||||
"@remotion/cli": "^4.0.438",
|
||||
"@remotion/player": "^4.0.438",
|
||||
"@remotion/renderer": "^4.0.438",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@twemoji/api": "^17.0.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -88,6 +92,7 @@
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-webcam": "^7.2.0",
|
||||
"recharts": "^2.15.2",
|
||||
"remotion": "^4.0.438",
|
||||
"sharp": "^0.34.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import session from "express-session";
|
||||
import connectPgSimple from "connect-pg-simple";
|
||||
import pg from "pg";
|
||||
@@ -64,6 +66,11 @@ app.use('/api/auth/login', authLimiter);
|
||||
const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio';
|
||||
app.use('/uploads/audio', express.static(AUDIO_UPLOAD_DIR));
|
||||
|
||||
// Serve temp uploads for Remotion
|
||||
const TEMP_UPLOAD_DIR = process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), 'uploads', 'temp');
|
||||
try { fs.mkdirSync(TEMP_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ }
|
||||
app.use('/uploads/temp', express.static(TEMP_UPLOAD_DIR));
|
||||
|
||||
declare module 'http' {
|
||||
interface IncomingMessage {
|
||||
rawBody: unknown
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/s
|
||||
import { freeSoundService } from "./services/freesound";
|
||||
import { analyticsRouter } from "./routes/analytics";
|
||||
import { reelsRouter } from "./routes/reels";
|
||||
import { remotionRouter } from "./routes/remotion";
|
||||
import { insertAudioTrackSchema } from "@shared/schema";
|
||||
|
||||
// Types MIME autorisés pour les uploads
|
||||
@@ -438,6 +439,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
// Reels & Music Routes
|
||||
app.use("/api", requireAuth, reelsRouter);
|
||||
app.use("/api/remotion", requireAuth, remotionRouter);
|
||||
|
||||
|
||||
// Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
|
||||
export const remotionRouter = Router();
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "uploads", "temp");
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
||||
cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
const host = req.protocol + "://" + req.get("host");
|
||||
const imageUrls = files.map(file => {
|
||||
return `${host}/uploads/temp/${path.basename(file.path)}`;
|
||||
});
|
||||
|
||||
console.log("Bundling Remotion project...");
|
||||
const bundleLocation = await bundle({
|
||||
entryPoint: path.resolve(process.cwd(), "client/src/remotion/index.ts"),
|
||||
});
|
||||
|
||||
console.log("Selecting composition...");
|
||||
const composition = await selectComposition({
|
||||
serveUrl: bundleLocation,
|
||||
id: "ImageVideo",
|
||||
inputProps: {
|
||||
images: imageUrls,
|
||||
},
|
||||
});
|
||||
|
||||
const outputFilename = `out-${Date.now()}.mp4`;
|
||||
const outputLocation = path.join(uploadDir, outputFilename);
|
||||
|
||||
console.log("Rendering media to", outputLocation);
|
||||
await renderMedia({
|
||||
composition,
|
||||
serveUrl: bundleLocation,
|
||||
codec: "h264",
|
||||
outputLocation,
|
||||
inputProps: {
|
||||
images: imageUrls,
|
||||
},
|
||||
chromiumOptions: {
|
||||
disableWebSecurity: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Render completed:", outputFilename);
|
||||
res.json({ url: `${host}/uploads/temp/${outputFilename}` });
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Remotion render error:", err);
|
||||
res.status(500).json({ error: "Erreur lors du rendu de la vidéo: " + err.message });
|
||||
}
|
||||
});
|
||||
@@ -253,3 +253,12 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
|
||||
- **22 Jan 2026** - Settings FFmpeg ajoutés
|
||||
- **22 Jan 2026** - Migration Jamendo -> FreeSound effectuée sur demande utilisateur
|
||||
- **22 Jan 2026** - Correction preview audio et ajout pagination "10 nouvelles musiques"
|
||||
|
||||
### Phase 24: Remotion Video Generator Tab ✅
|
||||
|
||||
- [x] Créer l'`implementation_plan.md` pour validation.
|
||||
- [x] Installer les dépendances frontend et backend de Remotion.
|
||||
- [x] Créer le composant Remotion (Composition pour 3-4 images).
|
||||
- [x] Créer la route backend `/api/remotion/render` pour compiler et générer la vidéo.
|
||||
- [x] Ajouter l'interface utilisateur frontend avec le `<Player />` et le système d'upload.
|
||||
- [x] Mettre à jour `Dockerfile` avec Chromium si nécessaire.
|
||||
Reference in new issue
Block a user