feat: implement Remotion video generation, text-to-speech, and automated social media publishing workflow

This commit is contained in:
Michael committed 2026-04-29 11:09:28 +02:00
1 parent 73e3a9d30f
commit 9913c307ed
4 files changed
+109 -12

No files matched your search

+33 -3
View File
@@ -147,13 +147,43 @@ export default function MobileRemotionVideoPage() {
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 e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json(); const data = await response.json();
setVideoUrl(data.url);
// Pre-fill description with overlay text so it appears in the Facebook post if (data.jobId) {
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
const checkStatus = async () => {
try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`);
if (!res.ok) throw new Error("Erreur de suivi du rendu");
const job = await res.json();
if (job.status === 'done') {
setVideoUrl(job.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText); if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !" }); toast({ title: "Vidéo générée !" });
setIsRendering(false);
} else if (job.status === 'error') {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false);
} else {
setTimeout(checkStatus, 3000);
}
} catch (e: any) {
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
setIsRendering(false);
}
};
setTimeout(checkStatus, 3000);
} else {
setVideoUrl(data.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !" });
setIsRendering(false);
}
} catch (err: any) { } catch (err: any) {
toast({ title: "Erreur", description: err.message, variant: "destructive" }); toast({ title: "Erreur", description: err.message, variant: "destructive" });
} finally { setIsRendering(false); } setIsRendering(false);
}
}; };
const handlePublish = async () => { const handlePublish = async () => {
+35 -3
View File
@@ -114,13 +114,45 @@ export default function RemotionVideoPage() {
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 e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json(); const data = await response.json();
setVideoUrl(data.url);
// Pre-fill description with overlay text so it appears in the Facebook post if (data.jobId) {
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
const checkStatus = async () => {
try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`);
if (!res.ok) throw new Error("Erreur de suivi du rendu");
const job = await res.json();
if (job.status === 'done') {
setVideoUrl(job.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText); if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." }); toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
setIsRendering(false);
} else if (job.status === 'error') {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false);
} else {
setTimeout(checkStatus, 3000);
}
} catch (e: any) {
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
setIsRendering(false);
}
};
setTimeout(checkStatus, 3000);
} else {
// Fallback for older sync backend
setVideoUrl(data.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
setIsRendering(false);
}
} catch (err: any) { } 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); } setIsRendering(false);
}
// Note: removed finally block so isRendering stays true during polling
}; };
const handlePublish = async () => { const handlePublish = async () => {
+32 -5
View File
@@ -33,6 +33,20 @@ async function generateVideoThumbnail(videoPath: string, outputPath: string, see
export const remotionRouter = Router(); export const remotionRouter = Router();
interface RenderJob {
status: 'processing' | 'done' | 'error';
url?: string;
thumbnailUrl?: string | null;
error?: string;
}
const renderJobs = new Map<string, RenderJob>();
remotionRouter.get("/render/status/:jobId", (req, res) => {
const job = renderJobs.get(req.params.jobId);
if (!job) return res.status(404).json({ error: "Job introuvable" });
res.json(job);
});
const uploadDir = path.join(process.cwd(), "uploads", "temp"); const uploadDir = path.join(process.cwd(), "uploads", "temp");
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
@@ -294,10 +308,17 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
inputProps, inputProps,
}); });
const jobId = Date.now().toString();
renderJobs.set(jobId, { status: 'processing' });
res.json({ jobId, message: "Rendu démarré en arrière-plan" });
// Run async to prevent 504 Gateway Timeout
(async () => {
try {
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...", totalFrames, "frames"); console.log("🎬 Rendering media...", totalFrames, "frames (Job:", jobId, ")");
await renderMedia({ await renderMedia({
composition: { ...composition, durationInFrames: totalFrames }, composition: { ...composition, durationInFrames: totalFrames },
@@ -324,15 +345,21 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
console.log("🖼️ Thumbnail generated:", thumbnailFilename); console.log("🖼️ Thumbnail generated:", thumbnailFilename);
} }
// Return relative URL so any client (mobile, desktop, Docker) can access it renderJobs.set(jobId, {
res.json({ status: 'done',
url: `/uploads/temp/${outputFilename}`, url: `/uploads/temp/${outputFilename}`,
thumbnailUrl: thumbnailGenerated ? `/uploads/temp/${thumbnailFilename}` : null, thumbnailUrl: thumbnailGenerated ? `/uploads/temp/${thumbnailFilename}` : null,
}); });
} catch (err: any) { } catch (err: any) {
console.error("❌ Remotion render error:", err); console.error("❌ Remotion render error for Job", jobId, ":", err);
res.status(500).json({ error: "Erreur lors du rendu: " + err.message }); renderJobs.set(jobId, { status: 'error', error: "Erreur lors du rendu: " + err.message });
}
})();
} catch (err: any) {
console.error("❌ Remotion pre-render error:", err);
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + err.message });
} }
}); });
+9 -1
View File
@@ -6,7 +6,7 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
## Current Focus ## Current Focus
**Phase: EXECUTION** - Fix Remotion Render Target Closed error. **Phase: EXECUTION** - Fix 504 Gateway Timeout on Remotion Render (Async Polling).
## Master Plan ## Master Plan
@@ -269,3 +269,11 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
- [x] Ajouter les arguments `--disable-dev-shm-usage`, `--no-sandbox` aux options de Chromium. - [x] Ajouter les arguments `--disable-dev-shm-usage`, `--no-sandbox` aux options de Chromium.
- [x] Limiter la concurrence `concurrency: 1` dans `renderMedia` pour économiser la mémoire en Docker. - [x] Limiter la concurrence `concurrency: 1` dans `renderMedia` pour économiser la mémoire en Docker.
### Phase 26: Fix 504 Gateway Timeout (Async Remotion Render) 🚀
- [x] Backend: Modifier `POST /api/remotion/render` pour retourner un `jobId` immédiatement.
- [x] Backend: Créer la route `GET /api/remotion/render/status/:jobId` pour suivre l'état (in-memory map).
- [x] Frontend: Modifier `remotion-video.tsx` pour faire du polling sur le `jobId` après la soumission.
- [x] Frontend: Appliquer la même modification de polling à `mobile/remotion-video.tsx`.