mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 04:36:32 +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
@@ -0,0 +1,78 @@
|
||||
"""Exécution asynchrone de FFmpeg/ffprobe : le serveur reste disponible
|
||||
pendant un encodage (l'ancien subprocess.run bloquait toute l'API)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
"""Commande externe en échec, avec la fin de sa sortie d'erreur."""
|
||||
|
||||
def __init__(self, cmd: list[str], returncode: int, stderr: str):
|
||||
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
||||
super().__init__(f"{cmd[0]} a échoué (code {returncode}) :\n{tail}")
|
||||
self.returncode = returncode
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
async def run(cmd: list[str], timeout: float = 900) -> str:
|
||||
"""Lance une commande et renvoie sa sortie standard."""
|
||||
log.debug("exec: %s", " ".join(cmd))
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
|
||||
except TimeoutError as error:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise CommandError(cmd, -1, f"délai de {timeout:.0f} s dépassé") from error
|
||||
if process.returncode != 0:
|
||||
raise CommandError(cmd, process.returncode, stderr.decode(errors="replace"))
|
||||
return stdout.decode(errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
duration: float
|
||||
has_audio: bool
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
color_transfer: str = ""
|
||||
|
||||
@property
|
||||
def is_hdr(self) -> bool:
|
||||
# HLG (iPhone) ou PQ (HDR10)
|
||||
return self.color_transfer in ("arib-std-b67", "smpte2084")
|
||||
|
||||
|
||||
async def probe(path: Path) -> MediaInfo:
|
||||
out = await run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
str(path),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
data = json.loads(out)
|
||||
streams = data.get("streams", [])
|
||||
video = next((s for s in streams if s.get("codec_type") == "video"), {})
|
||||
duration = float(data.get("format", {}).get("duration") or video.get("duration") or 0)
|
||||
return MediaInfo(
|
||||
duration=duration,
|
||||
has_audio=any(s.get("codec_type") == "audio" for s in streams),
|
||||
width=int(video.get("width") or 0),
|
||||
height=int(video.get("height") or 0),
|
||||
color_transfer=video.get("color_transfer") or "",
|
||||
)
|
||||
Reference in new issue
Block a user