mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-19 05:07:08 +02:00
Service ffmpeg-api réécrit (ffmpeg-service/app/) : - Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu - Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON - Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible - 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme), clé en en-tête, modèle configurable avec repli - Edge TTS 7 (minutage des mots restauré), secours en voix françaises - Voix traitée : filtre, compression, niveau constant ; musique bouclée et baissée automatiquement sous la voix ; mix final à -14 LUFS - Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine, à la place de ffsubsync), style Montserrat, placés hors des boutons Reels - Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse - Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et modèle Whisper intégrés à l'image ; tests pytest et ruff Application : - Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute - Reel images : minutage réel des mots, interrupteur voix respecté - sync-info ne génère plus de voix à chaque frappe (estimation locale) - Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée - Log « [ReelQueue] Worker démarré » pour vérifier la version déployée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
217 lines
7.6 KiB
TypeScript
217 lines
7.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 { bundle } from "@remotion/bundler";
|
||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||
import { ffmpegService, type TimedWord } 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();
|
||
}
|
||
|
||
const EDGE_PUNCT = /^[.,!?;:…«»"'()\[\]]+|[.,!?;:…«»"'()\[\]]+$/g;
|
||
|
||
export interface WordTiming {
|
||
word: string;
|
||
startFrame: number;
|
||
endFrame: number;
|
||
}
|
||
|
||
/**
|
||
* Convertit le minutage réel des mots (en secondes, fourni par la voix) en
|
||
* images. Chaque mot reste affiché jusqu'au début du suivant : pas de trou
|
||
* pendant les respirations.
|
||
*/
|
||
export function toWordTimings(words: TimedWord[], fps: number): WordTiming[] {
|
||
const timings = words
|
||
.map((w) => ({ word: w.text.replace(EDGE_PUNCT, "") || w.text, start: w.start, end: w.end }))
|
||
.filter((w) => w.word.trim());
|
||
return timings.map((w, i) => ({
|
||
word: w.word,
|
||
startFrame: Math.round(w.start * fps),
|
||
endFrame: Math.max(
|
||
Math.round(w.start * fps) + 1,
|
||
Math.round((i + 1 < timings.length ? timings[i + 1].start : w.end) * fps),
|
||
),
|
||
}));
|
||
}
|
||
|
||
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: WordTiming[] | undefined;
|
||
let audioDuration = 0;
|
||
|
||
const ttsText = overlayText ? stripForTTS(overlayText) : "";
|
||
if (params.ttsEnabled && overlayText && ttsText) {
|
||
await progress(15, "voice");
|
||
const voice = await ffmpegService.previewVoice(ttsText, {
|
||
voice: params.ttsVoice,
|
||
engine: params.ttsEngine,
|
||
style: params.ttsStyle,
|
||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||
});
|
||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||
|
||
const audioFilename = `tts-${job.id}.mp3`;
|
||
const audioPath = path.join(REMOTION_TEMP_DIR, audioFilename);
|
||
await fs.promises.writeFile(audioPath, voice.audio);
|
||
jobTempFiles.push(audioPath);
|
||
audioUrl = localHttpUrl(`/uploads/temp/${audioFilename}`);
|
||
audioDuration = voice.duration;
|
||
wordTimings = toWordTimings(voice.words, FPS);
|
||
}
|
||
|
||
// --- 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 */ });
|
||
}
|
||
}
|
||
}
|