mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
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
This commit is contained in:
64 files changed
+2488
-782
No files matched your search
@@ -8,7 +8,8 @@ import { storage } from '../storage';
|
||||
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||
import { resolveGeminiApiKey, resolveStoreName } from '../services/reels/assets';
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets';
|
||||
import { configuredRenderer } from '../services/reels/videoPipeline';
|
||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||
|
||||
import { estimateVoiceTiming } from '../services/ttsSync';
|
||||
@@ -378,12 +379,18 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
||||
*/
|
||||
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
|
||||
const [ffmpegConfigured, logoUrl] = await Promise.all([
|
||||
ffmpegService.healthCheck().catch(() => false),
|
||||
resolveLogoPath(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
jamendo: { configured: false },
|
||||
freesound: { configured: false },
|
||||
ffmpeg: { configured: ffmpegConfigured },
|
||||
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
|
||||
logoUrl: logoUrl ?? null,
|
||||
renderer: configuredRenderer(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching Reels config:', error);
|
||||
|
||||
@@ -74,6 +74,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
||||
ttsEngine: req.body.ttsEngine || undefined,
|
||||
ttsVoice: req.body.ttsVoice,
|
||||
ttsStyle: req.body.ttsStyle || undefined,
|
||||
captionStyle: req.body.captionStyle || undefined,
|
||||
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
||||
tempFiles: uploaded.map((f) => f.path),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import type { ReadableStream as WebReadableStream } from 'stream/web';
|
||||
import type { TtsEngine, TtsStyle } from '@shared/voices';
|
||||
|
||||
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
|
||||
@@ -39,6 +43,19 @@ export interface ReelRenderResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */
|
||||
export interface PreparedReel {
|
||||
videoPath: string;
|
||||
audioPath: string | null;
|
||||
totalDuration: number;
|
||||
videoDuration: number;
|
||||
logoStart: number | null;
|
||||
words: TimedWord[];
|
||||
ttsEngine?: string | null;
|
||||
ttsVoice?: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface TimedWord {
|
||||
text: string;
|
||||
start: number;
|
||||
@@ -164,6 +181,86 @@ export class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare un rendu Remotion : image recadrée à la durée finale et piste son
|
||||
* finale, écrites dans `targetDir`, plus le minutage des mots.
|
||||
*/
|
||||
async prepareReel(
|
||||
videoUrl: string,
|
||||
options: ReelRenderOptions & { hasLogo: boolean },
|
||||
targetDir: string,
|
||||
): Promise<PreparedReel> {
|
||||
const response = await this.call('/prepare-reel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
video_url: videoUrl,
|
||||
text: options.text,
|
||||
music_url: options.musicUrl,
|
||||
tts_enabled: options.ttsEnabled ?? false,
|
||||
tts_voice: options.ttsVoice,
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
store_name: options.storeName,
|
||||
enable_ending_effect: options.enableEndingEffect ?? true,
|
||||
has_logo: options.hasLogo,
|
||||
}),
|
||||
timeoutMs: PROCESS_TIMEOUT_MS,
|
||||
});
|
||||
const data = await response.json() as {
|
||||
job_id: string;
|
||||
video_path: string;
|
||||
audio_path: string | null;
|
||||
total_duration: number;
|
||||
video_duration: number;
|
||||
logo_start: number | null;
|
||||
words: TimedWord[];
|
||||
tts_engine?: string | null;
|
||||
tts_voice?: string | null;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||
const videoPath = `${targetDir}/video.mp4`;
|
||||
await this.downloadTo(data.video_path, videoPath);
|
||||
let audioPath: string | null = null;
|
||||
if (data.audio_path) {
|
||||
audioPath = `${targetDir}/audio.wav`;
|
||||
await this.downloadTo(data.audio_path, audioPath);
|
||||
}
|
||||
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
|
||||
return {
|
||||
videoPath,
|
||||
audioPath,
|
||||
totalDuration: data.total_duration,
|
||||
videoDuration: data.video_duration,
|
||||
logoStart: data.logo_start,
|
||||
words: data.words ?? [],
|
||||
ttsEngine: data.tts_engine,
|
||||
ttsVoice: data.tts_voice,
|
||||
warnings: data.warnings ?? [],
|
||||
};
|
||||
} finally {
|
||||
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
|
||||
.catch(() => { /* purgé plus tard par le service */ });
|
||||
}
|
||||
}
|
||||
|
||||
/** Télécharge un fichier du service en flux, sans le charger en mémoire. */
|
||||
private async downloadTo(servicePath: string, target: string): Promise<void> {
|
||||
const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
||||
if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`);
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as unknown as WebReadableStream<Uint8Array>),
|
||||
fs.createWriteStream(target),
|
||||
);
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
|
||||
|
||||
@@ -6,113 +6,16 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
|
||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { ffmpegService, type TimedWord } from "../ffmpeg";
|
||||
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 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 { REMOTION_TEMP_DIR };
|
||||
|
||||
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
||||
const params = imagesReelParamsSchema.parse(job.params);
|
||||
@@ -126,15 +29,15 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
||||
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
||||
const { overlayText, storeName } = params;
|
||||
|
||||
// --- Voix ---
|
||||
// --- Voix et minutage réel des mots ---
|
||||
let audioUrl: string | undefined;
|
||||
let wordTimings: WordTiming[] | undefined;
|
||||
let words: { text: string; start: number; end: number }[] = [];
|
||||
let audioDuration = 0;
|
||||
|
||||
const ttsText = overlayText ? stripForTTS(overlayText) : "";
|
||||
if (params.ttsEnabled && overlayText && ttsText) {
|
||||
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
|
||||
if (params.ttsEnabled && spokenText) {
|
||||
await progress(15, "voice");
|
||||
const voice = await ffmpegService.previewVoice(ttsText, {
|
||||
const voice = await ffmpegService.previewVoice(spokenText, {
|
||||
voice: params.ttsVoice,
|
||||
engine: params.ttsEngine,
|
||||
style: params.ttsStyle,
|
||||
@@ -142,52 +45,40 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
||||
});
|
||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||
|
||||
const audioFilename = `tts-${job.id}.mp3`;
|
||||
const audioPath = path.join(REMOTION_TEMP_DIR, audioFilename);
|
||||
const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
|
||||
await fs.promises.writeFile(audioPath, voice.audio);
|
||||
jobTempFiles.push(audioPath);
|
||||
audioUrl = localHttpUrl(`/uploads/temp/${audioFilename}`);
|
||||
audioUrl = tempFileUrl(audioPath);
|
||||
audioDuration = voice.duration;
|
||||
wordTimings = toWordTimings(voice.words, FPS);
|
||||
words = voice.words;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
// --- 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 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);
|
||||
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
|
||||
let lastReported = 25;
|
||||
|
||||
await renderMedia({
|
||||
composition: { ...composition, durationInFrames: totalFrames },
|
||||
serveUrl,
|
||||
codec: "h264",
|
||||
await renderReelComposition({
|
||||
compositionId: "ImageVideo",
|
||||
outputLocation,
|
||||
inputProps,
|
||||
chromiumOptions: {
|
||||
disableWebSecurity: true,
|
||||
ignoreCertificateErrors: true,
|
||||
inputProps: {
|
||||
images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle: params.captionStyle,
|
||||
audioUrl,
|
||||
musicUrl,
|
||||
musicVolume: params.musicVolume,
|
||||
logoUrl,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
},
|
||||
concurrency: 1, // évite d'épuiser la mémoire du conteneur
|
||||
onProgress: ({ progress: ratio }) => {
|
||||
onProgress: (ratio) => {
|
||||
const pct = 25 + Math.floor(ratio * 70);
|
||||
if (pct >= lastReported + 5) {
|
||||
lastReported = pct;
|
||||
@@ -196,16 +87,12 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
||||
},
|
||||
});
|
||||
|
||||
const thumbnailFilename = outputFilename.replace(".mp4", "-thumb.jpg");
|
||||
const thumbnailOk = await generateVideoThumbnail(
|
||||
outputLocation,
|
||||
path.join(REMOTION_TEMP_DIR, thumbnailFilename),
|
||||
2,
|
||||
);
|
||||
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
|
||||
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||
|
||||
return {
|
||||
url: `/uploads/temp/${outputFilename}`,
|
||||
thumbnailUrl: thumbnailOk ? `/uploads/temp/${thumbnailFilename}` : null,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toDataUrl } from "./remotionAssets";
|
||||
|
||||
describe("toDataUrl", () => {
|
||||
it("refuse un chemin qui sort du dossier uploads", async () => {
|
||||
await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/);
|
||||
});
|
||||
|
||||
it("laisse les URL externes telles quelles", async () => {
|
||||
await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Ressources fournies aux compositions Remotion pendant un rendu serveur.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */
|
||||
export const REMOTION_TEMP_DIR =
|
||||
process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp");
|
||||
|
||||
/**
|
||||
* URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont
|
||||
* lus côté Node par le moteur de rendu : l'adresse locale suffit.
|
||||
*/
|
||||
export function localHttpUrl(url: string): string {
|
||||
if (!url.startsWith("/")) return url;
|
||||
const port = process.env.PORT || "5555";
|
||||
return `http://localhost:${port}${url}`;
|
||||
}
|
||||
|
||||
/** URL servie d'un fichier du dossier temporaire. */
|
||||
export function tempFileUrl(absolutePath: string): string {
|
||||
const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/");
|
||||
return localHttpUrl(`/uploads/temp/${relative}`);
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
|
||||
/**
|
||||
* Convertit un chemin local /uploads/... en data URL : les images sont
|
||||
* affichées par Chromium, qui ne lit pas http://localhost de façon fiable
|
||||
* dans Docker. Refuse tout chemin sortant du dossier uploads.
|
||||
*/
|
||||
export async function toDataUrl(relativeUrl: string): Promise<string> {
|
||||
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
|
||||
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
|
||||
const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep;
|
||||
const filePath = relativeUrl.startsWith("/uploads/temp/")
|
||||
? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length))
|
||||
: path.resolve(process.cwd(), "." + relativeUrl);
|
||||
if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) {
|
||||
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
|
||||
}
|
||||
try {
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`;
|
||||
} catch {
|
||||
console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`);
|
||||
return relativeUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis
|
||||
* en cache, réglages d'encodage des réseaux sociaux, progression.
|
||||
*/
|
||||
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
|
||||
let bundleCache: Promise<string> | null = null;
|
||||
|
||||
/** Bundle des compositions, construit une fois par processus. */
|
||||
export function getRemotionBundle(): 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("📦 [Remotion] Bundle de", entryPoint);
|
||||
bundleCache = bundle({
|
||||
entryPoint,
|
||||
// Mêmes alias que Vite : les compositions importent @shared/*
|
||||
webpackOverride: (config) => ({
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: {
|
||||
...(config.resolve?.alias ?? {}),
|
||||
"@shared": path.resolve(process.cwd(), "shared"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).catch((error) => {
|
||||
bundleCache = null; // réessayer au prochain rendu
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return bundleCache;
|
||||
}
|
||||
|
||||
/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */
|
||||
function renderConcurrency(): number {
|
||||
const configured = Number(process.env.RENDER_CONCURRENCY);
|
||||
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
|
||||
return Math.max(1, Math.floor(os.cpus().length / 2));
|
||||
}
|
||||
|
||||
export async function renderReelComposition(options: {
|
||||
compositionId: "ReelVideo" | "ImageVideo";
|
||||
inputProps: Record<string, unknown>;
|
||||
outputLocation: string;
|
||||
onProgress?: (ratio: number) => void;
|
||||
}): Promise<void> {
|
||||
const serveUrl = await getRemotionBundle();
|
||||
const composition = await selectComposition({
|
||||
serveUrl,
|
||||
id: options.compositionId,
|
||||
inputProps: options.inputProps,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
await renderMedia({
|
||||
composition,
|
||||
serveUrl,
|
||||
inputProps: options.inputProps,
|
||||
outputLocation: options.outputLocation,
|
||||
codec: "h264",
|
||||
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
|
||||
crf: 18,
|
||||
x264Preset: "medium",
|
||||
pixelFormat: "yuv420p",
|
||||
colorSpace: "bt709",
|
||||
audioCodec: "aac",
|
||||
audioBitrate: "192k",
|
||||
concurrency: renderConcurrency(),
|
||||
onProgress: ({ progress }) => options.onProgress?.(progress),
|
||||
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
|
||||
timeoutInMilliseconds: 60_000,
|
||||
onBrowserLog: (log) => {
|
||||
if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300));
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`,
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
/**
|
||||
* Rendu d'un Reel à partir d'une vidéo : FFmpeg → médiathèque → publication.
|
||||
* 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 { videoReelParamsSchema } from "@shared/reel";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { FADE_SECONDS } from "@shared/captions";
|
||||
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
|
||||
import { storage } from "../../storage";
|
||||
import { ffmpegService } from "../ffmpeg";
|
||||
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é");
|
||||
@@ -28,9 +46,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
resolveGeminiApiKey(params.ttsEngine),
|
||||
]);
|
||||
|
||||
await progress(15, "render");
|
||||
const startedAt = Date.now();
|
||||
const rendered = await ffmpegService.renderReel(resolveInternalUrl(media.originalUrl), {
|
||||
const options: ReelRenderOptions = {
|
||||
text: params.overlayText,
|
||||
musicUrl,
|
||||
ttsEnabled: params.ttsEnabled,
|
||||
@@ -42,18 +58,23 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
musicVolume: params.musicVolume,
|
||||
drawText: params.drawText,
|
||||
stabilize: params.stabilize,
|
||||
watermarkUrl: logoPath ? resolveInternalUrl(logoPath) : undefined,
|
||||
storeName: params.storeName,
|
||||
enableEndingEffect: params.enableEndingEffect,
|
||||
});
|
||||
console.log(`⏱️ [Reels] Rendu : ${((Date.now() - startedAt) / 1000).toFixed(1)} s, vidéo de ${rendered.duration.toFixed(1)} s`);
|
||||
};
|
||||
|
||||
await progress(65, "store");
|
||||
const videoBuffer = rendered.video;
|
||||
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(85, "publish");
|
||||
await progress(93, "publish");
|
||||
const results = await publishReelToPages({
|
||||
postId,
|
||||
pageIds: params.pageIds,
|
||||
@@ -66,5 +87,66 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
throw new Error(describePublishFailure(results));
|
||||
}
|
||||
|
||||
return { mediaId: processedMedia.id, 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");
|
||||
const rendered = await ffmpegService.renderReel(videoUrl, {
|
||||
...options,
|
||||
watermarkUrl: 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,
|
||||
storeName: params.enableEndingEffect ? params.storeName : undefined,
|
||||
logoStart: prepared.logoStart,
|
||||
fadeStart: params.enableEndingEffect ? Math.max(0, prepared.totalDuration - FADE_SECONDS) : null,
|
||||
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 */ });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateVoiceTiming } from "./ttsSync";
|
||||
|
||||
describe("estimateVoiceTiming", () => {
|
||||
it("ignore hashtags et liens dans le décompte", () => {
|
||||
expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2);
|
||||
});
|
||||
|
||||
it("prévient quand la voix sera trop longue", () => {
|
||||
const timing = estimateVoiceTiming("mot ".repeat(200));
|
||||
expect(timing.isHealthy).toBe(false);
|
||||
expect(timing.warnings[0]).toMatch(/Texte long/);
|
||||
});
|
||||
});
|
||||
+22
-8
@@ -3,6 +3,7 @@ import {
|
||||
socialPages,
|
||||
media,
|
||||
posts,
|
||||
reelJobs,
|
||||
postMedia,
|
||||
scheduledPosts,
|
||||
aiGenerations,
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
type InsertFacebookConfig,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||
import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
|
||||
|
||||
/**
|
||||
@@ -147,7 +148,7 @@ export interface IStorage {
|
||||
getPosts(userId: string): Promise<Post[]>;
|
||||
getPost(id: string): Promise<Post | undefined>;
|
||||
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
||||
getOngoingReelPosts(userId: string): Promise<Post[]>;
|
||||
getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>;
|
||||
createPost(post: InsertPost): Promise<Post>;
|
||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
||||
@@ -551,21 +552,34 @@ export class DatabaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reels dont la génération est en cours. Filtré en SQL : la route chargeait
|
||||
* tous les posts de l'utilisateur pour n'en garder qu'une poignée, toutes les
|
||||
* trois secondes.
|
||||
* Reels dont la génération est en cours, plus ceux qui ont échoué dans les
|
||||
* dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape
|
||||
* courante du rendu. Filtré en SQL : la route est interrogée toutes les
|
||||
* trois secondes pendant un rendu.
|
||||
*/
|
||||
async getOngoingReelPosts(userId: string): Promise<Post[]> {
|
||||
return await db
|
||||
async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> {
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, userId),
|
||||
inArray(posts.generationStatus, ['processing', 'pending'])
|
||||
or(
|
||||
inArray(posts.generationStatus, ['processing', 'pending']),
|
||||
and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)),
|
||||
),
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const jobs = await db
|
||||
.select({ postId: reelJobs.postId, step: reelJobs.step })
|
||||
.from(reelJobs)
|
||||
.where(inArray(reelJobs.postId, rows.map((p) => p.id)));
|
||||
const stepByPost = new Map(jobs.map((j) => [j.postId, j.step]));
|
||||
return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null }));
|
||||
}
|
||||
|
||||
// AI Generations
|
||||
|
||||
Reference in new issue
Block a user