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
+139 -42

No files matched your search

+35 -5
View File
@@ -147,13 +147,43 @@ export default function MobileRemotionVideoPage() {
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"); }
const data = await response.json();
setVideoUrl(data.url);
// Pre-fill description with overlay text so it appears in the Facebook post
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !" });
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);
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) {
toast({ title: "Erreur", description: err.message, variant: "destructive" });
} finally { setIsRendering(false); }
setIsRendering(false);
}
};
const handlePublish = async () => {
+37 -5
View File
@@ -114,13 +114,45 @@ export default function RemotionVideoPage() {
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"); }
const data = await response.json();
setVideoUrl(data.url);
// Pre-fill description with overlay text so it appears in the Facebook post
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
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);
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) {
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 () => {
+58 -31
View File
@@ -33,6 +33,20 @@ async function generateVideoThumbnail(videoPath: string, outputPath: string, see
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");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
@@ -294,45 +308,58 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
inputProps,
});
const outputFilename = `out-${Date.now()}.mp4`;
const outputLocation = path.join(uploadDir, outputFilename);
const jobId = Date.now().toString();
renderJobs.set(jobId, { status: 'processing' });
res.json({ jobId, message: "Rendu démarré en arrière-plan" });
console.log("🎬 Rendering media...", totalFrames, "frames");
// Run async to prevent 504 Gateway Timeout
(async () => {
try {
const outputFilename = `out-${Date.now()}.mp4`;
const outputLocation = path.join(uploadDir, outputFilename);
await renderMedia({
composition: { ...composition, durationInFrames: totalFrames },
serveUrl: bundleLocation,
codec: "h264",
outputLocation,
inputProps,
chromiumOptions: {
disableWebSecurity: true,
ignoreCertificateErrors: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
},
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
});
console.log("🎬 Rendering media...", totalFrames, "frames (Job:", jobId, ")");
console.log("✅ Render completed:", outputFilename);
await renderMedia({
composition: { ...composition, durationInFrames: totalFrames },
serveUrl: bundleLocation,
codec: "h264",
outputLocation,
inputProps,
chromiumOptions: {
disableWebSecurity: true,
ignoreCertificateErrors: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
},
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
});
// Generate thumbnail for the video
const thumbnailFilename = outputFilename.replace('.mp4', '-thumb.jpg');
const thumbnailPath = path.join(uploadDir, thumbnailFilename);
const thumbnailGenerated = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
console.log("✅ Render completed:", outputFilename);
if (thumbnailGenerated) {
console.log("🖼️ Thumbnail generated:", thumbnailFilename);
}
// Generate thumbnail for the video
const thumbnailFilename = outputFilename.replace('.mp4', '-thumb.jpg');
const thumbnailPath = path.join(uploadDir, thumbnailFilename);
const thumbnailGenerated = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
// Return relative URL so any client (mobile, desktop, Docker) can access it
res.json({
url: `/uploads/temp/${outputFilename}`,
thumbnailUrl: thumbnailGenerated ? `/uploads/temp/${thumbnailFilename}` : null,
});
if (thumbnailGenerated) {
console.log("🖼️ Thumbnail generated:", thumbnailFilename);
}
renderJobs.set(jobId, {
status: 'done',
url: `/uploads/temp/${outputFilename}`,
thumbnailUrl: thumbnailGenerated ? `/uploads/temp/${thumbnailFilename}` : null,
});
} catch (err: any) {
console.error("❌ Remotion render error for Job", jobId, ":", err);
renderJobs.set(jobId, { status: 'error', error: "Erreur lors du rendu: " + err.message });
}
})();
} catch (err: any) {
console.error("❌ Remotion render error:", err);
res.status(500).json({ error: "Erreur lors du rendu: " + err.message });
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
**Phase: EXECUTION** - Fix Remotion Render Target Closed error.
**Phase: EXECUTION** - Fix 504 Gateway Timeout on Remotion Render (Async Polling).
## 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] 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`.