Files
Socialflow/server/services/reels/imagesPipeline.ts
T
Claude 1d47763b29 feat(reels): interrupteur « Afficher le logo sur la vidéo »
Le petit logo affiché pendant la vidéo devient optionnel (activé par
défaut), sur les 4 pages Reel (vidéo et images, bureau et mobile) ainsi que
dans l'aperçu. Le grand logo de l'effet de fin reste piloté par son propre
interrupteur. Pris en charge par le rendu Remotion et par le rendu FFmpeg de
secours (show_watermark).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 06:45:38 +00:00

105 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,
showWatermark: params.showLogo,
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 */ });
}
}
}