Files
Socialflow/server/services/reels/imagesPipeline.ts
T
Claude 3c6a66bf64 feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité
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
2026-09-24 12:31:27 +00:00

217 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 */ });
}
}
}