Files
Socialflow/server/services/reels/imagesPipeline.ts
T
Claude 9b66a6dc5a feat(reels): montage Remotion unifié, aperçu en direct, tests et CI
Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

Interface (étape 4) :
- Aperçu en direct avec @remotion/player, identique au rendu final ; la voix
  testée cale les sous-titres, sinon minutage estimé
- Choix du style de sous-titres sur les 4 pages Reel
- Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur
  cause ; fin de la barre simulée et du faux « publié avec succès »

Outillage (étape 5) :
- Tests vitest (minutage identique à Python, validation, sécurité) et
  pytest ; CI GitHub Actions (tsc, tests, build, ruff)
- Captures d'écran, out.mp4 et scripts de test retirés de la racine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 14:37:42 +00:00

104 lines
3.8 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 { cleanCaptionText, computeImagesTiming } from "@shared/captions";
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";
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
export { REMOTION_TEMP_DIR };
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 et minutage réel des mots ---
let audioUrl: string | undefined;
let words: { text: string; start: number; end: number }[] = [];
let audioDuration = 0;
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
if (params.ttsEnabled && spokenText) {
await progress(15, "voice");
const voice = await ffmpegService.previewVoice(spokenText, {
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 audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
await fs.promises.writeFile(audioPath, voice.audio);
jobTempFiles.push(audioPath);
audioUrl = tempFileUrl(audioPath);
audioDuration = voice.duration;
words = voice.words;
}
// --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
const { total, endingSeconds } = computeImagesTiming({
imageCount: images.length,
voiceDuration: audioDuration,
hasEnding: Boolean(logoUrl || storeName),
});
await progress(25, "render");
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
let lastReported = 25;
await renderReelComposition({
compositionId: "ImageVideo",
outputLocation,
inputProps: {
images,
totalDuration: total,
words,
captionStyle: params.captionStyle,
audioUrl,
musicUrl,
musicVolume: params.musicVolume,
logoUrl,
storeName,
endingSeconds,
},
onProgress: (ratio) => {
const pct = 25 + Math.floor(ratio * 70);
if (pct >= lastReported + 5) {
lastReported = pct;
progress(pct, "render").catch(() => { /* non bloquant */ });
}
},
});
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
return {
url: `/uploads/temp/${path.basename(outputLocation)}`,
thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : 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 */ });
}
}
}