mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
feat: implement Remotion video generation, text-to-speech, and automated social media publishing workflow
This commit is contained in:
1 parent
73e3a9d30f
commit
9913c307ed
4 files changed
+139
-42
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user