Files
Socialflow/server/services/ffmpeg.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

310 lines
11 KiB
TypeScript

/**
* Client du service FFmpeg (conteneur Python `ffmpeg-service`).
*
* Le service rend le Reel puis expose le MP4 en téléchargement
* (GET /files/{job}/output.mp4) : la vidéo ne transite plus en base64 dans du
* 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. */
const PROCESS_TIMEOUT_MS = 20 * 60_000;
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
const TTS_TIMEOUT_MS = 3 * 60_000;
const HEALTH_TIMEOUT_MS = 5_000;
export interface ReelRenderOptions {
text?: string;
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsEngine?: TtsEngine;
ttsStyle?: TtsStyle;
geminiApiKey?: string;
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
watermarkUrl?: string;
storeName?: string;
enableEndingEffect?: boolean;
}
export interface ReelRenderResult {
video: Buffer;
duration: number;
ttsEngine?: string | null;
ttsVoice?: string | null;
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;
end: number;
}
export interface VoicePreview {
audio: Buffer;
duration: number;
words: TimedWord[];
engine: string;
voice: string;
warnings: string[];
}
interface FFmpegConfig {
apiUrl: string;
apiKey: string;
}
/** Erreur renvoyée par le service, avec son message lisible. */
export class FFmpegServiceError extends Error {
constructor(message: string, readonly status?: number) {
super(message);
this.name = 'FFmpegServiceError';
}
}
export class FFmpegService {
private config: FFmpegConfig | null = null;
configure(apiUrl: string, apiKey: string): void {
this.config = { apiUrl: apiUrl.replace(/\/$/, ''), apiKey };
console.log('🎬 FFmpeg Service configured:', this.config.apiUrl);
}
private ensureConfigured(): FFmpegConfig {
if (!this.config) {
throw new FFmpegServiceError("Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY).");
}
return this.config;
}
private async call(path: string, init: RequestInit & { timeoutMs: number }): Promise<Response> {
const config = this.ensureConfigured();
const { timeoutMs, headers, ...rest } = init;
const response = await fetch(`${config.apiUrl}${path}`, {
...rest,
headers: { 'X-API-Key': config.apiKey, ...headers },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const body = await response.text();
let detail = body;
try {
detail = JSON.parse(body).detail ?? body;
} catch { /* corps non JSON */ }
throw new FFmpegServiceError(String(detail).slice(0, 2000), response.status);
}
return response;
}
/**
* Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit.
* Lève FFmpegServiceError en cas d'échec (voix comprise).
*/
async renderReel(videoUrl: string, options: ReelRenderOptions = {}): Promise<ReelRenderResult> {
const body = {
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,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
watermark_url: options.watermarkUrl,
store_name: options.storeName,
enable_ending_effect: options.enableEndingEffect ?? true,
};
console.log('🎬 Rendu du Reel :', {
videoUrl,
textLength: options.text?.length ?? 0,
music: !!options.musicUrl,
tts: options.ttsEnabled ? `${options.ttsEngine}/${options.ttsVoice}/${options.ttsStyle ?? 'neutral'}` : false,
});
const response = await this.call('/process-reel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
timeoutMs: PROCESS_TIMEOUT_MS,
});
const data = await response.json() as {
job_id: string;
output_path: string;
duration: number;
tts_engine?: string | null;
tts_voice?: string | null;
warnings?: string[];
};
try {
const file = await this.call(data.output_path, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
const video = Buffer.from(await file.arrayBuffer());
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
return {
video,
duration: data.duration,
ttsEngine: data.tts_engine,
ttsVoice: data.tts_voice,
warnings: data.warnings ?? [],
};
} finally {
// Le fichier n'est plus utile au service une fois récupéré
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
.catch(() => { /* purgé plus tard par le service */ });
}
}
/**
* 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 });
return true;
} catch {
return false;
}
}
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
async previewVoice(
text: string,
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string } = {},
): Promise<VoicePreview> {
const response = await this.call('/preview-tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
tts_voice: options.voice,
tts_engine: options.engine,
tts_style: options.style,
gemini_api_key: options.geminiApiKey,
}),
timeoutMs: TTS_TIMEOUT_MS,
});
const data = await response.json() as {
audio_base64: string;
duration: number;
words: TimedWord[];
engine: string;
voice: string;
warnings?: string[];
};
return {
audio: Buffer.from(data.audio_base64, 'base64'),
duration: data.duration,
words: data.words ?? [],
engine: data.engine,
voice: data.voice,
warnings: data.warnings ?? [],
};
}
}
export const ffmpegService = new FFmpegService();