Files
Socialflow/server/services/ttsSync.ts
T
Claude d24ab0d4b7 feat(reels): file d'attente persistante des rendus (reel_jobs)
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
2026-09-24 09:31:31 +00:00

77 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ffmpegService } from './ffmpeg';
import * as musicMetadata from 'music-metadata';
export interface SyncTiming {
wordDuration: number;
audioDuration: number;
wordCount: number;
punctuationPause: number;
isHealthy: boolean;
warnings: string[];
}
export class TtsSyncService {
/**
* Calcule le word_duration optimal pour synchroniser l'affichage du texte
* avec la durée réelle de la voix TTS générée.
*/
async calculateSyncTiming(
text: string,
voice: string,
ttsEngine?: string,
geminiApiKey?: string
): Promise<SyncTiming> {
const cleanText = this.cleanText(text);
// 1. Générer le TTS preview (avec la même voix que le rendu) et mesurer sa durée exacte
const ttsResult = await ffmpegService.previewTTS(cleanText, voice, ttsEngine, geminiApiKey);
if (!ttsResult.success || !ttsResult.audioBase64) {
throw new Error('TTS preview failed: ' + (ttsResult.error || 'unknown'));
}
const audioBuffer = Buffer.from(ttsResult.audioBase64, 'base64');
const metadata = await musicMetadata.parseBuffer(audioBuffer, 'audio/mpeg');
const audioDuration = metadata.format.duration || 0;
// 2. Analyser le texte (compte les mots réellement lus par la voix)
const wordCount = this.calculateWordCount(cleanText);
// 3. Calculer le word_duration
const wordDuration = wordCount > 0 ? audioDuration / wordCount : 0.6;
// 4. Validation
const warnings: string[] = [];
const isHealthy = wordDuration >= 0.25 && wordDuration <= 1.5;
if (wordDuration < 0.25) {
warnings.push('Texte trop long : les mots défileront très vite. Envisagez de raccourcir.');
}
if (wordDuration > 1.2) {
warnings.push('Texte très court : affichage lent.');
}
return {
wordDuration,
audioDuration,
wordCount,
punctuationPause: 0,
isHealthy,
warnings,
};
}
private cleanText(text: string): string {
return text
.replace(/#\w+/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[\uD83C-\uD83E][\uDC00-\uDFFF]|[☀-⛿✀-➿]/g, '')
.trim();
}
private calculateWordCount(text: string): number {
const tokens = text.split(/\s+/).filter(w => w.length > 0);
return tokens.filter(w => /[a-zA-Z0-9À-ſ]/.test(w)).length;
}
}
export const ttsSyncService = new TtsSyncService();