feat(reels): file d'attente persistante des rendus (reel_jobs)

L'ancienne file comptait les posts « processing » : un redémarrage pendant
un rendu la bloquait définitivement. Les rendus passent désormais par une
table reel_jobs, réservée avec FOR UPDATE SKIP LOCKED, avec heartbeat et
reprise des jobs interrompus au démarrage (2 tentatives maximum).

- Pipeline vidéo et pipeline images (Remotion) extraits des routes vers
  server/services/reels/, les routes ne font que valider (zod) et mettre en file
- Rendus d'images suivis en base au lieu d'une Map en mémoire
- storeName conservé pour les jobs mis en attente
- Publication Facebook en binaire : l'URL relative /uploads/... transmise
  auparavant n'était pas téléchargeable par Facebook
- Job en échec si toutes les pages échouent ou si la voix demandée manque
  (le service Python renseigne enfin tts_error)
- La voix choisie sur la page images est réellement utilisée
- sync-info mesure la voix Gemini avec sa clé
- Timeouts sur les appels au service FFmpeg, vignettes via execFile,
  /debug-ffmpeg protégé par la clé API, plus de voix anglaise en secours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-24 09:31:31 +00:00
1 parent fcbc6b706f
commit d24ab0d4b7
19 files changed
+1071 -901

No files matched your search

+35 -465
View File
@@ -3,17 +3,14 @@
*/
import { Router, Request, Response } from 'express';
import type { User, Media, SocialPage } from '@shared/schema';
import type { User } from '@shared/schema';
import { storage } from '../storage';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { tiktokService } from '../services/tiktok';
import { createVideoThumbnail } from '../services/thumbnail';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
import { resolveInternalUrl } from '../services/minio';
import { videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveStoreName } from '../services/reels/assets';
import { openRouterService, describeGenerationError } from '../services/openrouter';
import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm';
import { ttsSyncService } from '../services/ttsSync';
/** Piste musicale telle qu'attendue par le client. */
@@ -306,42 +303,12 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Le média doit être une vidéo' });
}
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
if (musicTrackId.startsWith('internal_')) {
const internalId = musicTrackId.replace('internal_', '');
const track = await storage.getAudioTrack(internalId);
if (track) {
// Utiliser INTERNAL_APP_URL pour que le container ffmpeg (réseau Docker interne)
// puisse télécharger le fichier audio. APP_URL (HTTPS public) n'est pas accessible
// depuis le réseau internal Docker.
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
finalMusicUrl = track.url.startsWith('http')
? track.url
: `${internalBaseUrl}${track.url}`;
console.log(`🎵 Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} else {
}
}
let watermarkUrl: string | undefined = undefined;
try {
const config = await storage.getCloudinaryConfig();
if (config && config.logoPublicId) {
watermarkUrl = resolveInternalUrl(buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl));
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
}
// Get Gemini API key if using Gemini TTS
let geminiApiKey: string | undefined = undefined;
if (ttsEngine === "gemini") {
const appCfg = await storage.getAppConfig();
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
const [finalMusicUrl, logoPath, geminiApiKey] = await Promise.all([
resolveMusicUrl(musicTrackId, musicUrl),
resolveLogoPath(),
resolveGeminiApiKey(ttsEngine),
]);
const watermarkUrl = logoPath ? resolveInternalUrl(logoPath) : undefined;
const finalWordDuration = wordDuration;
@@ -391,12 +358,7 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Texte requis' });
}
let geminiApiKey: string | undefined = undefined;
if (ttsEngine === "gemini") {
const appCfg = await storage.getAppConfig();
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
const geminiApiKey = await resolveGeminiApiKey(ttsEngine);
const result = await ffmpegService.previewTTS(text, ttsVoice, ttsEngine, geminiApiKey);
if (!result.success) {
@@ -420,7 +382,8 @@ reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
if (!text || !ttsVoice) {
return res.status(400).json({ error: 'Texte et voix requis' });
}
const sync = await ttsSyncService.calculateSyncTiming(text, ttsVoice, ttsEngine);
const geminiApiKey = await resolveGeminiApiKey(ttsEngine);
const sync = await ttsSyncService.calculateSyncTiming(text, ttsVoice, ttsEngine, geminiApiKey);
res.json(sync);
} catch (error) {
console.error('❌ Error calculating sync:', error);
@@ -429,444 +392,51 @@ reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
});
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
*/
/**
* Vérifie la file d'attente et lance le prochain job si disponible
*/
async function checkQueueAndProcessNext() {
try {
const nextPost = await storage.getNextPendingReel();
if (nextPost) {
console.log(`📥 [Queue] Found pending job: ${nextPost.id}. Starting processing...`);
// Marquer comme processing immédiatement
await storage.updatePostGenerationStatus(nextPost.id, 'processing', 0);
// Reconstruire le payload depuis les données stockées (si stockées) ou les defaults
// Note: En mode "pending", on a perdu le body de la requête initiale car on ne stocke pas tout dans Post.
// Pour une solution robuste, il faudrait stocker les paramètres de génération dans une table 'reel_jobs'.
// ICI: HACK PROVISOIRE -> On suppose que les données sont stockées dans 'content' ou 'productInfo' mais ce n'est pas le cas.
// SOLUTION: On ne peut pas relancer processReelBackground sans les arguments (ttsVoice, music, etc).
//
// FIX: Pour le MVP, comme on n'a pas de table 'jobs', on va devoir stocker les paramètres requis dans 'productInfo' (jsonb) du Post
// lors de la création en mode 'pending'.
// Récupérer les paramètres stockés
const jobData = nextPost.productInfo as any;
if (!jobData || !jobData.videoMediaId) {
console.error(`❌ [Queue] Job ${nextPost.id} has no stored job data in productInfo.`);
await storage.updatePostGenerationStatus(nextPost.id, 'failed', 0, "Données de job manquantes");
return;
}
// Lancer le traitement
processReelBackground(nextPost.userId, nextPost.id, jobData)
.catch(err => console.error('🔥 [Queue] Unhandled error starting queued job:', err));
} else {
console.log('🏁 [Queue] No more pending jobs.');
}
} catch (error) {
console.error('❌ [Queue] Error checking queue:', error);
}
}
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
*/
async function processReelBackground(
userId: string,
postId: string,
data: {
videoMediaId: string;
musicTrackId?: string;
musicUrl?: string;
overlayText?: string;
description?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsEngine?: string;
pageIds: string[];
scheduledFor?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
enableEndingEffect?: boolean;
},
storeName?: string
) {
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
description,
ttsEnabled,
ttsVoice,
ttsEngine,
pageIds,
scheduledFor,
wordDuration,
fontSize,
musicVolume,
drawText,
stabilize,
enableEndingEffect,
} = data;
console.log(`🔄 [Background] Starting processing for Post ${postId}`);
// Helper to update generation progress
const updateProgress = async (progress: number, status: string = 'processing') => {
try {
await storage.updatePostGenerationStatus(postId, status, progress);
} catch (e) {
console.error(`⚠️ [Background] Failed to update progress for ${postId}:`, e);
}
};
try {
await updateProgress(5);
// 1. Récupérer le média vidéo source
const media = await storage.getMediaById(videoMediaId);
if (!media || media.type !== 'video') {
throw new Error('Vidéo source introuvable ou invalide');
}
// 2. Récupérer l'URL de la musique
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
if (musicTrackId.startsWith('internal_')) {
try {
const internalId = musicTrackId.replace('internal_', '');
const track = await storage.getAudioTrack(internalId);
if (track) {
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
finalMusicUrl = track.url.startsWith('http')
? track.url
: `${internalBaseUrl}${track.url}`;
console.log(`🎵 [Background] Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} catch (e) { console.error('Error fetching internal track', e); }
}
}
console.log('🎬 [Background] FFmpeg Processing:', {
postId,
videoUrl: media.originalUrl,
hasMusic: !!finalMusicUrl, // Log boolean to avoid long URL
hasText: !!overlayText
});
// 3. Traiter la vidéo via FFmpeg
await updateProgress(15);
const startTime = Date.now();
let watermarkUrl: string | undefined = undefined;
try {
const config = await storage.getCloudinaryConfig();
if (config && config.logoPublicId) {
watermarkUrl = resolveInternalUrl(buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl));
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
}
// Get Gemini API key if using Gemini TTS
let geminiApiKey: string | undefined = undefined;
if (ttsEngine === "gemini") {
const appCfg = await storage.getAppConfig();
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
const finalWordDuration = wordDuration ?? 0.6;
console.log('🔊 [Background] TTS config:', { ttsEnabled, ttsVoice, ttsEngine });
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsEngine,
geminiApiKey,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
drawText,
stabilize,
watermarkUrl,
storeName,
enableEndingEffect,
});
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
if (ffmpegResult.ttsError) {
console.error(`❌ [Background] TTS FAILED: ${ffmpegResult.ttsError}`);
await storage.updatePost(postId, { generationError: `TTS échoué: ${ffmpegResult.ttsError}` });
}
if (!ffmpegResult.success || !ffmpegResult.videoBase64) {
throw new Error(ffmpegResult.error || 'Erreur de traitement vidéo FFmpeg');
}
// 4. Upload sur Cloudinary
await updateProgress(65);
console.log('☁️ [Background] Uploading to Cloudinary...');
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
const cloudinaryResult = await cloudinaryService.uploadMedia(
videoBuffer,
`reel-${Date.now()}.mp4`,
userId,
'video/mp4'
);
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
// 5. Créer l'enregistrement Media pour la vidéo traitée
// La vignette est extraite maintenant : la vidéo sera supprimée du disque
// après publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
const processedMedia = await storage.createMedia({
userId: userId,
type: 'video',
cloudinaryPublicId: cloudinaryResult.publicId,
originalUrl: cloudinaryResult.originalUrl,
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
thumbnailUrl,
fileName: `reel-processed-${Date.now()}.mp4`,
fileSize: videoBuffer.length,
});
// 6. Lier le média traité au Post existant
await storage.updatePostMedia(postId, [processedMedia.id]);
await updateProgress(85);
// 7. Publier sur les pages
await updateProgress(90);
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
for (const pageId of pageIds) {
try {
const page = await storage.getSocialPage(pageId);
if (!page) {
results.push({ pageId, success: false, error: 'Page non trouvée' });
continue;
}
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
// Créer l'entrée scheduled_post (log de publication)
const scheduledPost = await storage.createScheduledPost({
postId: postId,
pageId: page.id,
postType: 'reel',
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
if (!scheduledFor) {
// Publication immédiate — la même vidéo part sur chaque destination
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
const finalDescription = description || overlayText || '';
if (page.platform === 'facebook') {
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
finalDescription
);
// Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
} else {
const publishId = await tiktokService.publishVideoFromBuffer(
page,
videoBuffer,
finalDescription
);
// TikTok finalise la publication de son côté : on marque l'envoi
// comme effectué pour ne pas republier, et le poller de statut
// renseignera l'identifiant définitif du post.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
});
results.push({ pageId, success: true, reelId: publishId });
}
} else {
// Planifié
results.push({ pageId, success: true, reelId: 'scheduled' });
}
} catch (pageError: any) {
console.error(`❌ [Background] Error publishing to page ${pageId}:`, pageError);
results.push({
pageId,
success: false,
error: pageError.message || 'Erreur inconnue',
});
}
}
// 8. Mettre à jour le statut global du Post
const allSuccess = results.every(r => r.success);
const anySuccess = results.some(r => r.success);
// Si au moins une réussite, on considère "published" (ou partial), sinon "failed"
// Si planifié, reste "scheduled".
let finalStatus: 'failed' | 'scheduled' | 'published' = 'failed';
if (scheduledFor) {
finalStatus = 'scheduled';
} else if (allSuccess) {
finalStatus = 'published';
} else if (anySuccess) {
finalStatus = 'published'; // Partiellement publié
}
await storage.updatePost(postId, {
status: finalStatus,
});
await updateProgress(100, 'completed');
console.log(`✅ [Background] Processing complete for Post ${postId}. Status: ${finalStatus}`);
} catch (error: any) {
console.error(`❌ [Background] Critical error for Post ${postId}:`, error);
await storage.updatePost(postId, {
status: 'failed',
});
try {
await storage.updatePostGenerationStatus(
postId, 'failed', 0,
error?.message || 'Erreur inconnue lors du traitement'
);
} catch (e) {
console.error(`⚠️ [Background] Failed to update error status for ${postId}:`, e);
}
} finally {
// IMPORTANT: Toujours vérifier la file d'attente à la fin (succès ou échec)
await checkQueueAndProcessNext();
}
}
/**
* Créer et publier un Reel (Asynchrone avec File d'Attente)
* Créer et publier un Reel : le rendu part dans la file persistante.
* POST /api/reels
*/
reelsRouter.post('/reels', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
description,
ttsEnabled,
ttsVoice,
ttsEngine,
pageIds,
scheduledFor,
wordDuration = 0.6,
fontSize = 64,
musicVolume = 0.25,
drawText = true,
stabilize = false,
enableEndingEffect = true,
} = req.body;
// Validation immédiate
if (!videoMediaId) {
return res.status(400).json({ error: 'Vidéo requise' });
const parsed = videoReelParamsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
}
if (!pageIds || pageIds.length === 0) {
return res.status(400).json({ error: 'Au moins une page requise' });
}
const params: VideoReelParams = {
...parsed.data,
storeName: await resolveStoreName(user.id, parsed.data.pageIds[0]),
};
// Vérifier le nombre de jobs en cours
const processingCount = await storage.countProcessingReels();
// MAX_CONCURRENT = 1
const isQueueBusy = processingCount >= 1;
const waiting = await countActiveReelJobs();
const initialStatus = isQueueBusy ? 'pending' : 'processing';
const initialMessage = isQueueBusy
? "File d'attente pleine. Votre vidéo sera traitée dès que possible."
: "Traitement démarré en arrière-plan.";
// Récupérer le nom de la première page pour l'utiliser comme storeName
let storeName: string | undefined = undefined;
try {
const page = await storage.getSocialPage(pageIds[0]);
if (page) {
storeName = page.pageName;
}
} catch (e) {
console.error('Erreur récupération nom de page', e);
}
// Créer immédiatement le Post en base
// ON STOCKE LES PARAMS DU JOB DANS productInfo POUR POUVOIR LE REPRENDRE PLUS TARD
// C'est un hack car on n'a pas de table params_job, mais ça marche car productInfo est jsonb
const post = await storage.createPost({
userId: user.id,
content: description || overlayText || '',
content: params.description || params.overlayText || '',
aiGenerated: 'false',
status: scheduledFor ? 'scheduled' : 'draft',
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
generationStatus: initialStatus,
status: params.scheduledFor ? 'scheduled' : 'draft',
scheduledFor: params.scheduledFor ? new Date(params.scheduledFor) : undefined,
generationStatus: 'pending',
generationProgress: 0,
productInfo: req.body, // Stockage complet des paramètres
});
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Status: ${initialStatus}`);
await enqueueReelJob({ kind: 'video', userId: user.id, postId: post.id, params });
console.log(`✨ Reel en file. Post ${post.id}, ${waiting} job(s) avant lui.`);
if (!isQueueBusy) {
// Démarrer le traitement en arrière-plan (Fire & Forget)
processReelBackground(user.id, post.id, req.body, storeName).catch(err => {
console.error('🔥 Unhandled background error:', err);
});
} else {
console.log(`⏳ [Queue] Worker busy (count=${processingCount}). Job ${post.id} is queued.`);
}
// Réponse immédiate au client
res.json({
success: true,
postId: post.id,
message: initialMessage,
queued: isQueueBusy,
queued: waiting > 0,
message: waiting > 0
? "File d'attente occupée. Votre vidéo sera traitée dès que possible."
: 'Traitement démarré en arrière-plan.',
results: [],
videoUrl: ""
videoUrl: '',
});
} catch (error) {
console.error('❌ Error initiating Reel:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel',
});
}
+81 -405
View File
@@ -2,358 +2,104 @@ 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";
import { ffmpegService } from "../services/ffmpeg";
import type { User } from "@shared/schema";
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
import { storage as dbStorage } from "../storage";
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
import { facebookService } from "../services/facebook";
import { tiktokService } from "../services/tiktok";
import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
import * as musicMetadata from "music-metadata";
import { enqueueReelJob, getReelJob } from "../services/reels/queue";
import { REMOTION_TEMP_DIR } from "../services/reels/imagesPipeline";
import { resolveStoreName } from "../services/reels/assets";
import { publishReelToPages, storeRenderedVideo } from "../services/reels/publish";
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 = REMOTION_TEMP_DIR;
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: 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 });
/** Cached Remotion bundle URL — only bundle once per process lifecycle */
let remotionBundleCache: string | null = null;
async function getBundle(): Promise<string> {
if (remotionBundleCache) return remotionBundleCache;
// Use process.cwd() (= /app in Docker, project root in dev) — works in both CJS and ESM
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
console.log("📦 Bundling Remotion from:", entryPoint);
remotionBundleCache = await bundle({ entryPoint });
console.log("📦 Bundle cached at:", remotionBundleCache);
return remotionBundleCache;
}
/**
* Strips hashtags and emojis from text for TTS synthesis.
* Uses Unicode-aware regex to handle French accented chars in hashtags
* (e.g. #AménagementExtérieur must be fully removed, not just #Am).
* GET /api/remotion/render/status/:jobId
* État d'un rendu, lu dans la file persistante (survit à un redémarrage).
*/
function stripForTTS(text: string): string {
return text
// Hashtags with Unicode letters (covers all French accented chars)
.replace(/#[\w\u00C0-\u024F\u1E00-\u1EFF]*/g, ' ')
// Emoji: surrogate pairs (covers virtually all emoji in UTF-16 strings)
.replace(/[\uD800-\uDFFF][\uDC00-\uDFFF]/g, ' ') // surrogate pairs (most emojis)
.replace(/[\u2600-\u27BF]/g, ' ') // misc symbols & arrows
.replace(/[\u2B00-\u2BFF]/g, ' ') // misc symbols extended
.replace(/\s+/g, ' ')
.trim();
}
/** Estimates syllable count for a French word (vowel-group method). */
function countSyllablesFr(word: string): number {
const clean = word.replace(/[^a-zàâéèêëîïôùûüç]/gi, '').toLowerCase();
if (!clean) return 1;
const groups = clean.match(/[aeiouyàâéèêëîïôùûü]+/gi);
return Math.max(1, groups?.length ?? 1);
}
/** Purely-punctuation token (should not appear in overlay). */
const PUNCT_ONLY = /^[.,!?;:…\-—«»"''()\[\]]+$/;
/**
* Calculates word timings (in frames) for SPOKEN words only.
* Duration per word is weighted by syllable count → much tighter sync with TTS voice.
*/
function computeWordTimings(
displayText: string,
audioDurationSeconds: number,
fps: number,
startFrame: number
): Array<{ word: string; startFrame: number; endFrame: number }> {
const ttsText = stripForTTS(displayText);
// Filter out standalone punctuation tokens
const spokenWords = ttsText.split(/\s+/).filter(w => w && !PUNCT_ONLY.test(w));
if (spokenWords.length === 0) return [];
// Strip leading/trailing punctuation from each word for clean display
const cleanWords = spokenWords.map(w => w.replace(/^[.,!?;:…«»"''()\[\]]+|[.,!?;:…«»"''()\[\]]+$/g, '') || w);
// Syllable-weighted distribution: longer words get proportionally more screen time
const syllables = cleanWords.map(countSyllablesFr);
const totalSyllables = syllables.reduce((a, b) => a + b, 0);
const totalFrames = audioDurationSeconds * fps;
let currentFrame = startFrame;
return cleanWords.map((word, i) => {
const wordStart = currentFrame;
const wordFrames = Math.round((syllables[i] / totalSyllables) * totalFrames);
currentFrame += wordFrames;
return { word, startFrame: wordStart, endFrame: currentFrame };
});
}
// Duration constants
const FPS = 30;
const ENDING_SECONDS = 3; // ending slide (logo + store name)
const MIN_CONTENT_SECONDS = 22; // so total >= 25s
const MAX_CONTENT_SECONDS = 27; // so total <= 30s
/**
* Converts a local /uploads/... relative path to a base64 data URL.
* Chromium inside Docker cannot reliably fetch http://localhost via HTTP,
* so we embed images directly — zero network dependency.
*/
async function toDataUrl(relativeUrl: string): Promise<string> {
if (!relativeUrl || !relativeUrl.startsWith('/uploads/')) return relativeUrl;
const filePath = path.join(process.cwd(), relativeUrl);
try {
const buffer = await fs.promises.readFile(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
const mime = ext === 'png' ? 'image/png'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp'
: 'image/jpeg';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
console.warn(`⚠️ toDataUrl: file not found: ${filePath}`);
return relativeUrl;
remotionRouter.get("/render/status/:jobId", async (req, res) => {
const user = req.user as User;
const job = await getReelJob(req.params.jobId);
if (!job || job.kind !== "images" || (job.userId !== user.id && user.role !== "admin")) {
return res.status(404).json({ error: "Job introuvable" });
}
}
if (job.status === "completed") {
const result = job.result as ImagesReelResult;
return res.json({ status: "done", url: result.url, thumbnailUrl: result.thumbnailUrl });
}
if (job.status === "failed") {
return res.json({ status: "error", error: "Erreur lors du rendu: " + (job.error ?? "inconnue") });
}
res.json({ status: "processing", progress: job.progress, step: job.step, queued: job.status === "pending" });
});
/**
* POST /api/remotion/render
* Met en file le rendu d'un Reel à partir d'images (1 à 4).
*/
remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
const fields = (req.files ?? {}) as Record<string, Express.Multer.File[]>;
const uploaded = [...(fields["images"] ?? []), ...(fields["music"] ?? [])];
try {
const fields = req.files as Record<string, Express.Multer.File[]>;
const files = fields["images"] ?? [];
let imageUrls: string[] = [];
// For audio/music we still need HTTP URLs (data URLs for audio are too large)
const port = process.env.PORT || "5555";
const host = `http://localhost:${port}`;
// Library images: resolve relative paths → data URLs (avoids Chromium HTTP issues)
const user = req.user as User;
const existing = req.body.existingImageUrls;
if (existing) {
const rawUrls = Array.isArray(existing) ? existing : [existing];
const resolved = await Promise.all(rawUrls.map(toDataUrl));
imageUrls.push(...resolved);
}
// Newly uploaded temp files: read from disk → data URL
if (files && files.length > 0) {
const resolved = await Promise.all(files.map(async f => {
const buffer = await fs.promises.readFile(f.path);
const ext = path.extname(f.originalname).slice(1).toLowerCase();
const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
return `data:${mime};base64,${buffer.toString('base64')}`;
}));
imageUrls.push(...resolved);
}
const libraryUrls: string[] = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
const uploadedImageUrls = (fields["images"] ?? []).map((f) => `/uploads/temp/${path.basename(f.path)}`);
if (imageUrls.length === 0) {
return res.status(400).json({ error: "Aucune image fournie" });
}
const overlayText: string | undefined = typeof req.body.overlayText === 'string' && req.body.overlayText.trim()
? req.body.overlayText.trim()
: undefined;
const musicVolume: number = parseFloat(req.body.musicVolume ?? "0.3");
// Music: HTTP URLs are fine for audio (Remotion uses Web Audio API, not <Img>)
const musicFile = fields["music"]?.[0];
const rawMusicTrackUrl = req.body.musicTrackUrl as string | undefined;
const musicUrl: string | undefined = musicFile
? `${host}/uploads/temp/${path.basename(musicFile.path)}`
: rawMusicTrackUrl?.startsWith('/') ? `${host}${rawMusicTrackUrl}` : rawMusicTrackUrl || undefined;
const musicVolume = parseFloat(req.body.musicVolume ?? "0.3");
// --- Fetch logo and store name from config ---
let logoUrl: string | undefined;
let storeName: string | undefined;
try {
const cloudinaryConfig = await dbStorage.getCloudinaryConfig();
if (cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId) {
const relLogo = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId, cloudinaryConfig.publicUrl);
logoUrl = await toDataUrl(relLogo);
console.log("🏢 Logo embedded as data URL:", logoUrl.slice(0, 40) + "...");
}
} catch (e) {
console.warn("⚠️ Could not fetch logo config:", e);
}
try {
const user = req.user as any;
if (user?.id) {
const pages = await dbStorage.getSocialPages(user.id);
if (pages.length > 0) {
// Use the page selected by the user, or fall back to first page
const selectedPageId = req.body.selectedPageId as string | undefined;
const page = selectedPageId ? pages.find(p => p.id === selectedPageId) : pages[0];
storeName = (page ?? pages[0]).pageName;
console.log("🏪 Store name:", storeName);
}
}
} catch (e) {
console.warn("⚠️ Could not fetch store name:", e);
}
// --- TTS Audio Generation ---
let audioUrl: string | undefined;
let wordTimings: Array<{ word: string; startFrame: number; endFrame: number }> | undefined;
let estimatedAudioDuration = 0;
if (overlayText) {
const ttsText = stripForTTS(overlayText);
if (ttsText) {
console.log("🎙️ Generating TTS for:", ttsText);
try {
const ttsResult = await ffmpegService.previewTTS(ttsText);
if (ttsResult.success && ttsResult.audioBase64) {
const audioFilename = `tts-${Date.now()}.mp3`;
const audioPath = path.join(uploadDir, audioFilename);
const audioBuffer = Buffer.from(ttsResult.audioBase64, "base64");
fs.writeFileSync(audioPath, audioBuffer);
audioUrl = `${host}/uploads/temp/${audioFilename}`;
// Get exact audio duration from MP3 metadata
try {
const meta = await musicMetadata.parseFile(audioPath);
estimatedAudioDuration = meta.format.duration ?? 0;
} catch {
// Fallback: rough estimate from byte size (128kbps = 16KB/s)
estimatedAudioDuration = audioBuffer.length / 16000;
}
estimatedAudioDuration = Math.max(estimatedAudioDuration, ttsText.split(/\s+/).length * 0.35);
wordTimings = computeWordTimings(overlayText, estimatedAudioDuration, FPS, 0);
console.log(`✅ TTS generated, ~${estimatedAudioDuration.toFixed(1)}s, ${wordTimings.length} words`);
} else {
console.warn("⚠️ TTS failed:", ttsResult.error);
}
} catch (ttsErr) {
console.warn("⚠️ TTS error (rendering without audio):", ttsErr);
}
}
}
// --- Duration: 25-30s total ---
// content = max(tts duration, images*3s), clamped to 22-27s; ending = 3s
const naturalContent = Math.max(estimatedAudioDuration, imageUrls.length * 3);
const contentSeconds = Math.min(Math.max(naturalContent, MIN_CONTENT_SECONDS), MAX_CONTENT_SECONDS);
const endingFrames = (logoUrl || storeName) ? ENDING_SECONDS * FPS : 0;
const totalFrames = Math.round(contentSeconds * FPS) + endingFrames;
console.log(`🎬 Duration: ${contentSeconds.toFixed(1)}s content + ${ENDING_SECONDS}s ending = ${(totalFrames / FPS).toFixed(1)}s total`);
console.log("🎬 Getting Remotion bundle (cached)...");
const bundleLocation = await getBundle();
const inputProps = {
images: imageUrls,
overlayText,
audioUrl,
wordTimings,
musicUrl,
musicVolume,
logoUrl,
storeName,
endingFrames,
};
console.log("🎬 Selecting composition...");
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "ImageVideo",
inputProps,
const parsed = imagesReelParamsSchema.safeParse({
imageUrls: [...libraryUrls, ...uploadedImageUrls],
overlayText: req.body.overlayText,
musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
ttsEngine: req.body.ttsEngine || undefined,
ttsVoice: req.body.ttsVoice,
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
tempFiles: uploaded.map((f) => f.path),
});
if (!parsed.success) {
await removeFiles(uploaded);
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Paramètres invalides" });
}
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 outputLocation = path.join(uploadDir, outputFilename);
console.log("🎬 Rendering media...", totalFrames, "frames (Job:", jobId, ")");
await renderMedia({
composition: { ...composition, durationInFrames: totalFrames },
serveUrl: bundleLocation,
codec: "h264",
outputLocation,
inputProps,
// Ne pas y remettre "args" : Remotion ne lit jamais cette clé (elle est
// absente de ChromiumOptions et de son code), les drapeaux passés là
// n'atteignaient donc jamais le navigateur.
chromiumOptions: {
disableWebSecurity: true,
ignoreCertificateErrors: true,
},
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
});
console.log("✅ Render completed:", outputFilename);
// 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);
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) {
const job = await enqueueReelJob({ kind: "images", userId: user.id, params: parsed.data });
res.json({ jobId: job.id, message: "Rendu démarré en arrière-plan" });
} catch (err) {
console.error("❌ Remotion pre-render error:", err);
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + err.message });
await removeFiles(uploaded);
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + (err instanceof Error ? err.message : err) });
}
});
async function removeFiles(files: Express.Multer.File[]): Promise<void> {
await Promise.all(files.map((f) => fs.promises.unlink(f.path).catch(() => { /* déjà absent */ })));
}
/**
* POST /api/remotion/publish
* Uploads the rendered MP4 to Cloudinary and publishes (or schedules) it as a Reel.
* Range la vidéo rendue dans la médiathèque puis la publie (ou la planifie).
*/
remotionRouter.post("/publish", async (req, res) => {
try {
const user = req.user as any;
if (!user?.id) return res.status(401).json({ error: "Non authentifié" });
const user = req.user as User;
const { videoUrl, pageIds, scheduledFor, description } = req.body as {
videoUrl: string;
@@ -365,106 +111,36 @@ remotionRouter.post("/publish", async (req, res) => {
if (!videoUrl) return res.status(400).json({ error: "videoUrl requis" });
if (!pageIds?.length) return res.status(400).json({ error: "Au moins une page requise" });
// Resolve the local file path from the temp URL
// Chemin local du rendu à partir de son URL temporaire
const filename = path.basename(videoUrl.split("?")[0]);
const localPath = path.join(uploadDir, filename);
if (!fs.existsSync(localPath)) {
return res.status(400).json({ error: "Fichier vidéo introuvable (expiré ?)" });
}
console.log("☁️ Uploading Remotion video to Cloudinary...");
const cloudinaryResult = await cloudinaryService.uploadMedia(
localPath,
filename,
user.id,
"video/mp4"
);
console.log("✅ Cloudinary upload:", cloudinaryResult.originalUrl);
const videoBuffer = await fs.promises.readFile(localPath);
const mediaRecord = await storeRenderedVideo(user.id, videoBuffer, filename);
// Create a Post record to track this publication
const post = await dbStorage.createPost({
userId: user.id,
content: description || "",
aiGenerated: "false",
status: scheduledFor ? "scheduled" : "published",
status: scheduledFor ? "scheduled" : "draft",
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
});
// Create a Media record for the processed video
// Vignette extraite avant que la vidéo ne soit purgée du disque
const thumbnailUrl = await createVideoThumbnail(localPath);
const mediaRecord = await dbStorage.createMedia({
userId: user.id,
type: "video",
cloudinaryPublicId: cloudinaryResult.publicId,
originalUrl: cloudinaryResult.originalUrl,
facebookFeedUrl: null,
instagramFeedUrl: null,
instagramStoryUrl: null,
thumbnailUrl,
fileName: filename,
fileSize: fs.statSync(localPath).size,
});
await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
const results = await publishReelToPages({
postId: post.id,
pageIds,
videoBuffer,
description: description || "",
scheduledFor,
});
for (const pageId of pageIds) {
try {
const page = await dbStorage.getSocialPage(pageId);
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
if (page.platform !== "facebook" && page.platform !== "tiktok") {
results.push({ pageId, success: false, error: `Plateforme non supportée pour les reels : ${page.platform}` });
continue;
}
const scheduledPost = await dbStorage.createScheduledPost({
postId: post.id,
pageId: page.id,
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
if (!scheduledFor) {
console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
// Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
const videoBuffer = await fs.promises.readFile(localPath);
if (page.platform === "facebook") {
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
results.push({ pageId, success: true, reelId });
} else {
// TikTok finalise la publication de son côté : le poller de statut
// renseignera l'identifiant définitif du post.
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: "PROCESSING_UPLOAD",
});
results.push({ pageId, success: true, reelId: publishId });
}
} else {
results.push({ pageId, success: true, reelId: "scheduled" });
}
} catch (pageErr: any) {
console.error(`❌ Error publishing to page ${pageId}:`, pageErr);
results.push({ pageId, success: false, error: pageErr.message });
}
}
const anySuccess = results.some(r => r.success);
if (!anySuccess) {
await dbStorage.updatePost(post.id, { status: "failed" });
}
res.json({ success: anySuccess, results, postId: post.id });
} catch (err: any) {
res.json({ success: results.some((r) => r.success), results, postId: post.id });
} catch (err) {
console.error("❌ Remotion publish error:", err);
res.status(500).json({ error: "Erreur lors de la publication: " + err.message });
res.status(500).json({ error: "Erreur lors de la publication: " + (err instanceof Error ? err.message : err) });
}
});