mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
L'ancienne file comptait les posts « processing » : un redémarrage pendant un rendu la bloquait définitivement. Les rendus passent désormais par une table reel_jobs, réservée avec FOR UPDATE SKIP LOCKED, avec heartbeat et reprise des jobs interrompus au démarrage (2 tentatives maximum). - Pipeline vidéo et pipeline images (Remotion) extraits des routes vers server/services/reels/, les routes ne font que valider (zod) et mettre en file - Rendus d'images suivis en base au lieu d'une Map en mémoire - storeName conservé pour les jobs mis en attente - Publication Facebook en binaire : l'URL relative /uploads/... transmise auparavant n'était pas téléchargeable par Facebook - Job en échec si toutes les pages échouent ou si la voix demandée manque (le service Python renseigne enfin tts_error) - La voix choisie sur la page images est réellement utilisée - sync-info mesure la voix Gemini avec sa clé - Timeouts sur les appels au service FFmpeg, vignettes via execFile, /debug-ffmpeg protégé par la clé API, plus de voix anglaise en secours Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
/**
|
|
* Génération de vignettes vidéo.
|
|
*
|
|
* Les vidéos sources sont supprimées du disque une fois publiées (le
|
|
* planificateur libère la place, la purge quotidienne fait le reste), alors que
|
|
* la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface
|
|
* tente alors de lire un fichier disparu : d'où les « Video thumbnail generation
|
|
* failed » et les tuiles vides.
|
|
*
|
|
* On extrait donc une image au moment de la création du média, et elle reste
|
|
* disponible après la disparition de la vidéo.
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { minioService } from './minio';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
|
|
|
/**
|
|
* Extrait une image d'une vidéo avec FFmpeg.
|
|
*
|
|
* @param videoPath chemin du fichier vidéo
|
|
* @param outputPath chemin de l'image à écrire
|
|
* @param seekTime instant de la capture, en secondes
|
|
* @returns true si l'image a bien été écrite
|
|
*/
|
|
export async function generateVideoThumbnail(
|
|
videoPath: string,
|
|
outputPath: string,
|
|
seekTime: number = 1
|
|
): Promise<boolean> {
|
|
try {
|
|
// Arguments séparés : aucun passage par le shell, les chemins ne sont jamais interprétés
|
|
await execFileAsync('ffmpeg', [
|
|
'-y', '-ss', String(seekTime), '-i', videoPath, '-vframes', '1', '-q:v', '2', outputPath,
|
|
]);
|
|
return fs.existsSync(outputPath);
|
|
} catch (error) {
|
|
console.warn('⚠️ Failed to generate video thumbnail:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Produit la vignette d'une vidéo et la range dans le stockage public.
|
|
*
|
|
* Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit
|
|
* jamais faire échouer une publication.
|
|
*
|
|
* @param source chemin du fichier vidéo, ou son contenu en mémoire
|
|
* @returns l'URL publique de la vignette, ou null si l'extraction a échoué
|
|
*/
|
|
export async function createVideoThumbnail(source: string | Buffer): Promise<string | null> {
|
|
const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
|
const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`);
|
|
|
|
// Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg
|
|
let videoPath: string;
|
|
let tempVideoPath: string | null = null;
|
|
|
|
try {
|
|
if (!fs.existsSync(TEMP_DIR)) {
|
|
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
|
}
|
|
|
|
if (Buffer.isBuffer(source)) {
|
|
tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`);
|
|
await fs.promises.writeFile(tempVideoPath, source);
|
|
videoPath = tempVideoPath;
|
|
} else {
|
|
videoPath = source;
|
|
if (!fs.existsSync(videoPath)) {
|
|
console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const ok = await generateVideoThumbnail(videoPath, outputPath);
|
|
if (!ok) return null;
|
|
|
|
const buffer = await fs.promises.readFile(outputPath);
|
|
const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`);
|
|
|
|
console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`);
|
|
return url;
|
|
} catch (error) {
|
|
console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error);
|
|
return null;
|
|
} finally {
|
|
for (const file of [outputPath, tempVideoPath]) {
|
|
if (file) {
|
|
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
|
|
}
|
|
}
|
|
}
|
|
}
|