mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 03:39:15 +02:00
feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité
Service ffmpeg-api réécrit (ffmpeg-service/app/) : - Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu - Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON - Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible - 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme), clé en en-tête, modèle configurable avec repli - Edge TTS 7 (minutage des mots restauré), secours en voix françaises - Voix traitée : filtre, compression, niveau constant ; musique bouclée et baissée automatiquement sous la voix ; mix final à -14 LUFS - Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine, à la place de ffsubsync), style Montserrat, placés hors des boutons Reels - Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse - Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et modèle Whisper intégrés à l'image ; tests pytest et ruff Application : - Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute - Reel images : minutage réel des mots, interrupteur voix respecté - sync-info ne génère plus de voix à chaque frappe (estimation locale) - Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée - Log « [ReelQueue] Worker démarré » pour vérifier la version déployée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
47 files changed
+2206
-2285
No files matched your search
+32
-102
@@ -5,14 +5,13 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { User } from '@shared/schema';
|
||||
import { storage } from '../storage';
|
||||
import { ffmpegService } from '../services/ffmpeg';
|
||||
import { resolveInternalUrl } from '../services/minio';
|
||||
import { videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveStoreName } from '../services/reels/assets';
|
||||
import { resolveGeminiApiKey, resolveStoreName } from '../services/reels/assets';
|
||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||
|
||||
import { ttsSyncService } from '../services/ttsSync';
|
||||
import { estimateVoiceTiming } from '../services/ttsSync';
|
||||
/** Piste musicale telle qu'attendue par le client. */
|
||||
interface MusicTrack {
|
||||
id: string;
|
||||
@@ -270,105 +269,37 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser un Reel (traitement sans publication)
|
||||
* POST /api/reels/preview
|
||||
*/
|
||||
reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const {
|
||||
videoMediaId,
|
||||
musicTrackId,
|
||||
musicUrl,
|
||||
overlayText,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
wordDuration = 0.6,
|
||||
fontSize = 64,
|
||||
musicVolume = 0.25,
|
||||
drawText = true,
|
||||
stabilize = false,
|
||||
enableEndingEffect = true,
|
||||
} = req.body;
|
||||
|
||||
// Récupérer le média vidéo
|
||||
const media = await storage.getMediaById(videoMediaId);
|
||||
if (!media) {
|
||||
return res.status(404).json({ error: 'Vidéo non trouvée' });
|
||||
}
|
||||
|
||||
if (media.type !== 'video') {
|
||||
return res.status(400).json({ error: 'Le média doit être une vidéo' });
|
||||
}
|
||||
|
||||
const [finalMusicUrl, logoPath, geminiApiKey] = await Promise.all([
|
||||
resolveMusicUrl(musicTrackId, musicUrl),
|
||||
resolveLogoPath(),
|
||||
resolveGeminiApiKey(ttsEngine),
|
||||
]);
|
||||
const watermarkUrl = logoPath ? resolveInternalUrl(logoPath) : undefined;
|
||||
|
||||
const finalWordDuration = wordDuration;
|
||||
|
||||
// Traiter la vidéo via FFmpeg
|
||||
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
||||
text: overlayText,
|
||||
musicUrl: finalMusicUrl,
|
||||
ttsEnabled,
|
||||
ttsVoice,
|
||||
ttsEngine,
|
||||
geminiApiKey,
|
||||
wordDuration: finalWordDuration,
|
||||
fontSize,
|
||||
musicVolume,
|
||||
drawText,
|
||||
stabilize,
|
||||
watermarkUrl,
|
||||
enableEndingEffect,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error || 'Erreur de traitement vidéo' });
|
||||
}
|
||||
|
||||
// Retourner la vidéo en base64 pour prévisualisation
|
||||
res.json({
|
||||
success: true,
|
||||
videoBase64: result.videoBase64,
|
||||
duration: result.duration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error previewing Reel:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la prévisualisation du Reel' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser la voix TTS
|
||||
* POST /api/reels/tts-preview
|
||||
*/
|
||||
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
const parsed = ttsPreviewSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
|
||||
}
|
||||
const { text, ttsVoice, ttsEngine, ttsStyle } = parsed.data;
|
||||
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { text, ttsVoice, ttsEngine } = req.body;
|
||||
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Texte requis' });
|
||||
}
|
||||
|
||||
const geminiApiKey = await resolveGeminiApiKey(ttsEngine);
|
||||
const result = await ffmpegService.previewTTS(text, ttsVoice, ttsEngine, geminiApiKey);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
|
||||
}
|
||||
|
||||
res.json({ success: true, audioBase64: result.audioBase64 });
|
||||
const preview = await ffmpegService.previewVoice(text, {
|
||||
voice: ttsVoice,
|
||||
engine: ttsEngine,
|
||||
style: ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
audioBase64: preview.audio.toString('base64'),
|
||||
duration: preview.duration,
|
||||
words: preview.words,
|
||||
engine: preview.engine,
|
||||
voice: preview.voice,
|
||||
warnings: preview.warnings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating TTS preview:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la génération de la voix' });
|
||||
const message = error instanceof Error ? error.message : 'Erreur lors de la génération de la voix';
|
||||
res.status(error instanceof FFmpegServiceError && error.status === 400 ? 400 : 502).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -378,13 +309,12 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
*/
|
||||
reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { text, ttsVoice, ttsEngine } = req.body;
|
||||
if (!text || !ttsVoice) {
|
||||
return res.status(400).json({ error: 'Texte et voix requis' });
|
||||
const { text } = req.body;
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Texte requis' });
|
||||
}
|
||||
const geminiApiKey = await resolveGeminiApiKey(ttsEngine);
|
||||
const sync = await ttsSyncService.calculateSyncTiming(text, ttsVoice, ttsEngine, geminiApiKey);
|
||||
res.json(sync);
|
||||
// Estimation locale : ne déclenche aucune synthèse (payante avec Gemini)
|
||||
res.json(estimateVoiceTiming(text));
|
||||
} catch (error) {
|
||||
console.error('❌ Error calculating sync:', error);
|
||||
res.status(500).json({ error: 'Erreur de calcul de synchronisation' });
|
||||
|
||||
@@ -70,8 +70,10 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
||||
overlayText: req.body.overlayText,
|
||||
musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
|
||||
musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
|
||||
ttsEnabled: req.body.ttsEnabled !== "false",
|
||||
ttsEngine: req.body.ttsEngine || undefined,
|
||||
ttsVoice: req.body.ttsVoice,
|
||||
ttsStyle: req.body.ttsStyle || undefined,
|
||||
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
||||
tempFiles: uploaded.map((f) => f.path),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user