mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 13:38:10 +02:00
Fins de phrases coupées : - Le fondu final ne commence plus avant la fin de la voix (il rognait la dernière phrase quand la vidéo finissait peu après) ; il se raccourcit au besoin. Même règle côté Python, aperçu et rendu Remotion - SRT : découpe au milieu de la zone la plus calme de chaque pause, mesurée sur l'enveloppe sonore (tranches de 10 ms), avec un seuil relatif au niveau de la voix qui respecte les fins douces ; 300 ms gardées après le dernier son Bruits parasites : - Normalisation de la voix en deux passes, en mode linéaire : le mode dynamique relevait souffles et bruits entre les phrases (mesuré : -64 dB avant, -75 dB après dans les pauses) - Réduction de bruit douce (afftdn), compresseur sans gain ajouté, fondus de 5 ms aux extrémités ; mix final moins « pompant » (LRA 20) - Voix Gemini vérifiée : les bruits ajoutés avant le premier ou après le dernier mot sont retirés Vérification de lecture comparée lettre à lettre : les petites erreurs de Whisper ne déclenchent plus de régénérations inutiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
158 lines
5.9 KiB
TypeScript
158 lines
5.9 KiB
TypeScript
/**
|
|
* Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication.
|
|
* Exécuté par la file `reel_jobs` (voir queue.ts).
|
|
*
|
|
* Deux moteurs :
|
|
* - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR,
|
|
* stabilisation) et la piste son finale, Remotion compose les sous-titres
|
|
* animés, le logo et l'effet de fin — le même rendu que l'aperçu ;
|
|
* - « ffmpeg » : tout est fait par le service Python (sous-titres ASS),
|
|
* plus rapide, conservé en secours (REEL_RENDERER=ffmpeg).
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
|
|
import { storage } from "../../storage";
|
|
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
|
import { resolveInternalUrl } from "../minio";
|
|
import type { JobContext } from "./queue";
|
|
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
|
|
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
|
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
|
import { renderReelComposition } from "./remotionRenderer";
|
|
|
|
export type ReelRenderer = "remotion" | "ffmpeg";
|
|
|
|
export function configuredRenderer(): ReelRenderer {
|
|
return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion";
|
|
}
|
|
|
|
export async function runVideoReelJob({ job, progress }: JobContext) {
|
|
if (!job.postId) throw new Error("Job vidéo sans post associé");
|
|
const postId = job.postId;
|
|
const params = videoReelParamsSchema.parse(job.params);
|
|
|
|
await progress(5, "prepare");
|
|
const media = await storage.getMediaById(params.videoMediaId);
|
|
if (!media || media.type !== "video") {
|
|
throw new Error("Vidéo source introuvable ou invalide");
|
|
}
|
|
|
|
const [musicUrl, logoPath, geminiApiKey] = await Promise.all([
|
|
resolveMusicUrl(params.musicTrackId, params.musicUrl),
|
|
resolveLogoPath(),
|
|
resolveGeminiApiKey(params.ttsEngine),
|
|
]);
|
|
|
|
const options: ReelRenderOptions = {
|
|
text: params.srtCues ? undefined : params.overlayText,
|
|
srtCues: params.srtCues,
|
|
musicUrl,
|
|
ttsEnabled: params.ttsEnabled,
|
|
ttsVoice: params.ttsVoice,
|
|
ttsEngine: params.ttsEngine,
|
|
ttsStyle: params.ttsStyle,
|
|
geminiApiKey,
|
|
fontSize: params.fontSize,
|
|
musicVolume: params.musicVolume,
|
|
drawText: params.drawText,
|
|
stabilize: params.stabilize,
|
|
storeName: params.storeName,
|
|
enableEndingEffect: params.enableEndingEffect,
|
|
showLogo: params.showLogo,
|
|
};
|
|
|
|
const renderer = configuredRenderer();
|
|
const startedAt = Date.now();
|
|
const videoBuffer =
|
|
renderer === "remotion"
|
|
? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress)
|
|
: await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress);
|
|
console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
|
|
|
|
await progress(88, "store");
|
|
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
|
|
await storage.updatePostMedia(postId, [processedMedia.id]);
|
|
|
|
await progress(93, "publish");
|
|
const results = await publishReelToPages({
|
|
postId,
|
|
pageIds: params.pageIds,
|
|
videoBuffer,
|
|
description: params.description || params.overlayText || "",
|
|
scheduledFor: params.scheduledFor,
|
|
});
|
|
|
|
if (!results.some((r) => r.success)) {
|
|
throw new Error(describePublishFailure(results));
|
|
}
|
|
|
|
return { mediaId: processedMedia.id, renderer, results };
|
|
}
|
|
|
|
async function renderWithFfmpeg(
|
|
videoUrl: string,
|
|
options: ReelRenderOptions,
|
|
logoPath: string | undefined,
|
|
progress: JobContext["progress"],
|
|
): Promise<Buffer> {
|
|
await progress(15, "render");
|
|
// Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux
|
|
const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false);
|
|
const rendered = await ffmpegService.renderReel(videoUrl, {
|
|
...options,
|
|
watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined,
|
|
});
|
|
return rendered.video;
|
|
}
|
|
|
|
async function renderWithRemotion(
|
|
jobId: string,
|
|
videoUrl: string,
|
|
options: ReelRenderOptions,
|
|
params: VideoReelParams,
|
|
logoPath: string | undefined,
|
|
progress: JobContext["progress"],
|
|
): Promise<Buffer> {
|
|
const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`);
|
|
try {
|
|
// 1. Image recadrée à la durée finale, piste son finale, mots minutés
|
|
await progress(10, "voice");
|
|
const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir);
|
|
|
|
// 2. Composition : sous-titres animés, logo, effet de fin
|
|
await progress(40, "render");
|
|
const outputLocation = path.join(workdir, "reel.mp4");
|
|
let lastReported = 40;
|
|
await renderReelComposition({
|
|
compositionId: "ReelVideo",
|
|
outputLocation,
|
|
inputProps: {
|
|
videoUrl: tempFileUrl(prepared.videoPath),
|
|
videoDuration: prepared.totalDuration,
|
|
totalDuration: prepared.totalDuration,
|
|
words: prepared.words,
|
|
captionStyle: params.captionStyle,
|
|
logoUrl: logoPath ? await toDataUrl(logoPath) : undefined,
|
|
showWatermark: params.showLogo,
|
|
storeName: params.enableEndingEffect ? params.storeName : undefined,
|
|
logoStart: prepared.logoStart,
|
|
fadeStart: prepared.fadeStart,
|
|
fadeDuration: prepared.fadeDuration,
|
|
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
|
|
},
|
|
onProgress: (ratio) => {
|
|
const pct = 40 + Math.floor(ratio * 45);
|
|
if (pct >= lastReported + 5) {
|
|
lastReported = pct;
|
|
progress(pct, "render").catch(() => { /* non bloquant */ });
|
|
}
|
|
},
|
|
});
|
|
return await fs.promises.readFile(outputLocation);
|
|
} finally {
|
|
await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ });
|
|
}
|
|
}
|