mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 14:38:11 +02:00
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
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
/**
|
||
* Rendu d'un Reel à partir d'images avec Remotion.
|
||
* Exécuté par la file `reel_jobs` ; la publication reste une étape distincte,
|
||
* déclenchée par l'utilisateur après l'aperçu.
|
||
*/
|
||
|
||
import fs from "fs";
|
||
import path from "path";
|
||
import * as musicMetadata from "music-metadata";
|
||
import { bundle } from "@remotion/bundler";
|
||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||
import { ffmpegService } from "../ffmpeg";
|
||
import { generateVideoThumbnail } from "../thumbnail";
|
||
import type { JobContext } from "./queue";
|
||
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
||
|
||
export const REMOTION_TEMP_DIR = path.join(process.cwd(), "uploads", "temp");
|
||
|
||
// Durées
|
||
const FPS = 30;
|
||
const ENDING_SECONDS = 3; // diapositive de fin (logo + nom du magasin)
|
||
const MIN_CONTENT_SECONDS = 22; // total >= 25 s
|
||
const MAX_CONTENT_SECONDS = 27; // total <= 30 s
|
||
|
||
/** Bundle Remotion mis en cache : un seul bundle par processus. */
|
||
let bundleCache: Promise<string> | null = null;
|
||
|
||
function getBundle(): Promise<string> {
|
||
if (!bundleCache) {
|
||
// process.cwd() = /app dans Docker, racine du projet en dev
|
||
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
|
||
console.log("📦 Bundling Remotion from:", entryPoint);
|
||
bundleCache = bundle({ entryPoint }).catch((error) => {
|
||
bundleCache = null; // réessayer au prochain rendu
|
||
throw error;
|
||
});
|
||
}
|
||
return bundleCache;
|
||
}
|
||
|
||
/** URL HTTP locale : l'audio est lu par Chromium, trop lourd pour une data URL. */
|
||
function localHttpUrl(url: string): string {
|
||
if (!url.startsWith("/")) return url;
|
||
const port = process.env.PORT || "5555";
|
||
return `http://localhost:${port}${url}`;
|
||
}
|
||
|
||
const MIME_BY_EXT: Record<string, string> = {
|
||
png: "image/png",
|
||
gif: "image/gif",
|
||
webp: "image/webp",
|
||
};
|
||
|
||
/**
|
||
* Convertit un chemin local /uploads/... en data URL : Chromium dans Docker ne
|
||
* lit pas http://localhost de façon fiable, les images sont donc embarquées.
|
||
*/
|
||
async function toDataUrl(relativeUrl: string): Promise<string> {
|
||
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
|
||
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
|
||
const filePath = path.resolve(process.cwd(), "." + relativeUrl);
|
||
if (!filePath.startsWith(uploadsRoot)) {
|
||
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
|
||
}
|
||
try {
|
||
const buffer = await fs.promises.readFile(filePath);
|
||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||
const mime = MIME_BY_EXT[ext] ?? "image/jpeg";
|
||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||
} catch {
|
||
console.warn(`⚠️ toDataUrl: fichier introuvable : ${filePath}`);
|
||
return relativeUrl;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Retire hashtags et emojis avant la synthèse vocale. Les lettres accentuées
|
||
* font partie du hashtag (#AménagementExtérieur est retiré en entier).
|
||
*/
|
||
export function stripForTTS(text: string): string {
|
||
return text
|
||
.replace(/#[\wÀ-ɏḀ-ỿ]*/g, " ")
|
||
.replace(/[\uD800-\uDFFF][\uDC00-\uDFFF]/g, " ") // paires de substitution (la plupart des emojis)
|
||
.replace(/[☀-➿]/g, " ") // symboles divers
|
||
.replace(/[⬀-⯿]/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
/** Nombre de syllabes d'un mot français (groupes de voyelles). */
|
||
function countSyllablesFr(word: string): number {
|
||
const clean = word.replace(/[^a-zàâéèêëîïôùûüç]/gi, "").toLowerCase();
|
||
if (!clean) return 1;
|
||
return Math.max(1, clean.match(/[aeiouyàâéèêëîïôùûü]+/gi)?.length ?? 1);
|
||
}
|
||
|
||
const PUNCT_ONLY = /^[.,!?;:…\-—«»"''()\[\]]+$/;
|
||
const EDGE_PUNCT = /^[.,!?;:…«»"''()\[\]]+|[.,!?;:…«»"''()\[\]]+$/g;
|
||
|
||
/**
|
||
* Timings des mots prononcés (en images), répartis au prorata des syllabes.
|
||
* Estimation provisoire : remplacée par les vrais timings de la voix au lot
|
||
* « voix et sous-titres ».
|
||
*/
|
||
export function computeWordTimings(
|
||
displayText: string,
|
||
audioDurationSeconds: number,
|
||
fps: number,
|
||
startFrame: number,
|
||
): Array<{ word: string; startFrame: number; endFrame: number }> {
|
||
const spokenWords = stripForTTS(displayText)
|
||
.split(/\s+/)
|
||
.filter((w) => w && !PUNCT_ONLY.test(w));
|
||
if (spokenWords.length === 0) return [];
|
||
|
||
const cleanWords = spokenWords.map((w) => w.replace(EDGE_PUNCT, "") || w);
|
||
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;
|
||
currentFrame += Math.round((syllables[i] / totalSyllables) * totalFrames);
|
||
return { word, startFrame: wordStart, endFrame: currentFrame };
|
||
});
|
||
}
|
||
|
||
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
||
const params = imagesReelParamsSchema.parse(job.params);
|
||
const jobTempFiles: string[] = [];
|
||
|
||
try {
|
||
await progress(5, "prepare");
|
||
const images = await Promise.all(params.imageUrls.map(toDataUrl));
|
||
const logoPath = await resolveLogoPath();
|
||
const logoUrl = logoPath ? await toDataUrl(logoPath) : undefined;
|
||
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
||
const { overlayText, storeName } = params;
|
||
|
||
// --- Voix ---
|
||
let audioUrl: string | undefined;
|
||
let wordTimings: ReturnType<typeof computeWordTimings> | undefined;
|
||
let audioDuration = 0;
|
||
|
||
const ttsText = overlayText ? stripForTTS(overlayText) : "";
|
||
if (overlayText && ttsText) {
|
||
await progress(15, "voice");
|
||
const geminiApiKey = await resolveGeminiApiKey(params.ttsEngine);
|
||
const tts = await ffmpegService.previewTTS(ttsText, params.ttsVoice, params.ttsEngine, geminiApiKey);
|
||
if (!tts.success || !tts.audioBase64) {
|
||
throw new Error(`La voix n'a pas pu être générée : ${tts.error ?? "réponse vide"}`);
|
||
}
|
||
|
||
const audioFilename = `tts-${job.id}.mp3`;
|
||
const audioPath = path.join(REMOTION_TEMP_DIR, audioFilename);
|
||
const audioBuffer = Buffer.from(tts.audioBase64, "base64");
|
||
await fs.promises.writeFile(audioPath, audioBuffer);
|
||
jobTempFiles.push(audioPath);
|
||
audioUrl = localHttpUrl(`/uploads/temp/${audioFilename}`);
|
||
|
||
try {
|
||
audioDuration = (await musicMetadata.parseFile(audioPath)).format.duration ?? 0;
|
||
} catch {
|
||
audioDuration = audioBuffer.length / 16000; // estimation à 128 kb/s
|
||
}
|
||
audioDuration = Math.max(audioDuration, ttsText.split(/\s+/).length * 0.35);
|
||
wordTimings = computeWordTimings(overlayText, audioDuration, FPS, 0);
|
||
}
|
||
|
||
// --- Durée : 25 à 30 s ---
|
||
const naturalContent = Math.max(audioDuration, images.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;
|
||
|
||
await progress(25, "render");
|
||
const serveUrl = await getBundle();
|
||
const inputProps = {
|
||
images,
|
||
overlayText,
|
||
audioUrl,
|
||
wordTimings,
|
||
musicUrl,
|
||
musicVolume: params.musicVolume,
|
||
logoUrl,
|
||
storeName,
|
||
endingFrames,
|
||
};
|
||
const composition = await selectComposition({ serveUrl, id: "ImageVideo", inputProps });
|
||
|
||
const outputFilename = `out-${job.id}.mp4`;
|
||
const outputLocation = path.join(REMOTION_TEMP_DIR, outputFilename);
|
||
let lastReported = 25;
|
||
|
||
await renderMedia({
|
||
composition: { ...composition, durationInFrames: totalFrames },
|
||
serveUrl,
|
||
codec: "h264",
|
||
outputLocation,
|
||
inputProps,
|
||
chromiumOptions: {
|
||
disableWebSecurity: true,
|
||
ignoreCertificateErrors: true,
|
||
},
|
||
concurrency: 1, // évite d'épuiser la mémoire du conteneur
|
||
onProgress: ({ progress: ratio }) => {
|
||
const pct = 25 + Math.floor(ratio * 70);
|
||
if (pct >= lastReported + 5) {
|
||
lastReported = pct;
|
||
progress(pct, "render").catch(() => { /* non bloquant */ });
|
||
}
|
||
},
|
||
});
|
||
|
||
const thumbnailFilename = outputFilename.replace(".mp4", "-thumb.jpg");
|
||
const thumbnailOk = await generateVideoThumbnail(
|
||
outputLocation,
|
||
path.join(REMOTION_TEMP_DIR, thumbnailFilename),
|
||
2,
|
||
);
|
||
|
||
return {
|
||
url: `/uploads/temp/${outputFilename}`,
|
||
thumbnailUrl: thumbnailOk ? `/uploads/temp/${thumbnailFilename}` : null,
|
||
};
|
||
} finally {
|
||
// Images, musique importée et voix ne servent plus une fois le rendu fini
|
||
for (const file of [...params.tempFiles, ...jobTempFiles]) {
|
||
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
|
||
}
|
||
}
|
||
}
|