Files
Socialflow/ffmpeg-service/app/api.py
T
Claude 3c6a66bf64 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
2026-09-24 12:31:27 +00:00

305 lines
11 KiB
Python

"""API HTTP du service : voix, rendu des Reels et récupération des fichiers."""
import asyncio
import base64
import contextlib
import logging
import time
from pathlib import Path
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from . import align, config, jobs, proc, render, subtitles, tts, voices
from .audio import encode_preview
from .text import clean_text
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("reels")
if not config.API_KEY:
raise RuntimeError("API_KEY doit être défini : le service refuse de démarrer sans clé.")
# Un seul encodage à la fois : ils saturent le CPU, les suivants attendent
render_slot = asyncio.Semaphore(1)
@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI):
removed = jobs.purge_expired()
log.info("Service prêt (%d dossier(s) expiré(s) purgé(s))", removed)
async def purge_loop():
while True:
await asyncio.sleep(600)
jobs.purge_expired()
task = asyncio.create_task(purge_loop())
yield
task.cancel()
app = FastAPI(title="SocialFlow Reels", lifespan=lifespan)
def require_key(x_api_key: str | None = Header(None)) -> None:
if x_api_key != config.API_KEY:
raise HTTPException(status_code=401, detail="Invalid API Key")
class TtsRequest(BaseModel):
text: str
tts_voice: str | None = None
tts_engine: str | None = "gemini"
tts_style: str | None = None
gemini_api_key: str | None = None
class ReelRequest(BaseModel):
video_base64: str | None = None
video_url: str | None = None
text: str | None = None
music_url: str | None = None
watermark_url: str | None = None
store_name: str | None = None
font_size: int = 64
music_volume: float = 0.25
tts_enabled: bool = False
tts_voice: str | None = None
tts_engine: str | None = "gemini"
tts_style: str | None = None
gemini_api_key: str | None = None
draw_text: bool = True
stabilize: bool = False
enable_ending_effect: bool = True
# Champs d'anciennes versions, acceptés et ignorés
music_id: str | None = None
word_duration: float | None = None
@app.get("/health", dependencies=[Depends(require_key)])
async def health():
return {"status": "ok", "version": 2}
@app.get("/voices", dependencies=[Depends(require_key)])
async def list_voices():
return voices.catalog()
@app.post("/preview-tts", dependencies=[Depends(require_key)])
async def preview_tts(request: TtsRequest):
text = clean_text(request.text)
if not text:
raise HTTPException(status_code=400, detail="Texte vide après nettoyage (emojis et hashtags retirés)")
job_id, workdir = jobs.new_job()
try:
track = await _synthesize(text, request.text, request, workdir)
preview = workdir / "preview.mp3"
await encode_preview(track.path, preview)
return {
"success": True,
"audio_base64": base64.b64encode(preview.read_bytes()).decode(),
"duration": track.duration,
"words": [w.to_dict() for w in track.words],
"engine": track.engine,
"voice": track.voice,
"warnings": track.warnings,
}
finally:
jobs.remove_job(job_id)
@app.post("/process-reel", dependencies=[Depends(require_key)])
async def process_reel(request: ReelRequest):
"""Produit le Reel ; le MP4 se récupère ensuite via GET /files/{job_id}/output.mp4."""
job_id, workdir = jobs.new_job()
stats: dict[str, float] = {}
started = time.monotonic()
try:
async with render_slot:
return await _process(request, job_id, workdir, stats, started)
except HTTPException:
jobs.remove_job(job_id)
raise
except proc.CommandError as error:
jobs.remove_job(job_id)
log.error("Rendu %s en échec : %s", job_id, error)
raise HTTPException(status_code=500, detail=str(error)) from error
except Exception as error:
jobs.remove_job(job_id)
log.exception("Rendu %s en échec", job_id)
raise HTTPException(status_code=500, detail=f"Erreur de rendu : {error}") from error
@app.get("/files/{job_id}/{name}", dependencies=[Depends(require_key)])
async def get_file(job_id: str, name: str):
path = jobs.job_file(job_id, name)
if not path:
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
return FileResponse(path, media_type="video/mp4" if name.endswith(".mp4") else None)
@app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
async def delete_job(job_id: str):
jobs.remove_job(job_id)
return {"success": True}
# --- Orchestration --------------------------------------------------------
async def _synthesize(text: str, display_source: str | None, request, workdir: Path) -> tts.VoiceTrack:
try:
return await tts.synthesize(
text=text,
display_text=clean_text(display_source) or text,
engine=request.tts_engine,
voice=request.tts_voice,
style=request.tts_style,
gemini_api_key=request.gemini_api_key,
workdir=workdir,
)
except Exception as error:
log.exception("Voix impossible à générer")
raise HTTPException(status_code=502, detail=f"La voix n'a pas pu être générée : {error}") from error
async def _download(url: str, target: Path, what: str, required: bool) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=15), follow_redirects=True) as client:
async with client.stream("GET", url, headers={"User-Agent": "Mozilla/5.0"}) as response:
response.raise_for_status()
with target.open("wb") as out:
async for chunk in response.aiter_bytes(1 << 20):
out.write(chunk)
return True
except Exception as error:
if required:
detail = f"Téléchargement de {what} impossible : {error}"
raise HTTPException(status_code=400, detail=detail) from error
log.warning("Téléchargement de %s impossible, on continue sans : %s", what, error)
return False
async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict, started: float) -> dict:
step = time.monotonic()
video = workdir / "input.mp4"
if request.video_base64:
video.write_bytes(base64.b64decode(request.video_base64))
elif request.video_url:
await _download(request.video_url, video, "la vidéo", required=True)
else:
raise HTTPException(status_code=400, detail="Aucune vidéo fournie")
music = workdir / "music.audio"
has_music = bool(request.music_url) and await _download(
request.music_url, music, "la musique", required=False
)
watermark = workdir / "watermark.png"
has_watermark = bool(request.watermark_url) and await _download(
request.watermark_url, watermark, "le logo", required=False
)
info = await proc.probe(video)
if info.duration <= 0:
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
stats["download"] = time.monotonic() - step
# --- Voix ---
step = time.monotonic()
track = None
spoken_text = clean_text(request.text)
if request.tts_enabled and spoken_text:
track = await _synthesize(spoken_text, request.text, request, workdir)
stats["tts"] = time.monotonic() - step
plan = render.RenderPlan(
video=video,
video_duration=info.duration,
output=workdir / "output.mp4",
is_hdr=info.is_hdr,
music=music if has_music else None,
music_volume=request.music_volume,
voice=track.path if track else None,
voice_duration=track.duration if track else 0.0,
watermark=watermark if has_watermark else None,
ending_effect=request.enable_ending_effect,
keep_original_audio=info.has_audio,
)
# --- Sous-titres ---
step = time.monotonic()
font_size = max(48, round(request.font_size * 1.4))
display = clean_text(request.text)
if request.draw_text and display:
captions = workdir / "captions.ass"
if track:
subtitles.write_captions(track.words, captions, offset=plan.voice_delay, font_size=font_size)
else:
words = await _caption_words_without_voice(display, video, info, plan)
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
plan.captions = captions
if request.store_name and request.enable_ending_effect and has_watermark:
outro = workdir / "outro.ass"
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
plan.outro = outro
stats["subtitles"] = time.monotonic() - step
# --- Stabilisation (1re passe) ---
step = time.monotonic()
if request.stabilize:
transforms = workdir / "transforms.trf"
try:
await proc.run(render.stabilize_detect_command(video, transforms), timeout=600)
plan.stabilize_transforms = transforms
except proc.CommandError as error:
log.warning("Stabilisation ignorée : %s", error)
stats["stabilize"] = time.monotonic() - step
# --- Encodage ---
step = time.monotonic()
await proc.run(render.build_command(plan), timeout=1200)
stats["encode"] = time.monotonic() - step
stats["total"] = time.monotonic() - started
duration = (await proc.probe(plan.output)).duration
log.info(
"Rendu %s terminé : %.1f s de vidéo, étapes %s",
job_id,
duration,
{k: round(v, 1) for k, v in stats.items()},
)
# Seul le résultat est conservé jusqu'au téléchargement
for entry in workdir.iterdir():
if entry != plan.output:
entry.unlink(missing_ok=True)
return {
"success": True,
"job_id": job_id,
"output_path": f"/files/{job_id}/output.mp4",
"duration": duration,
"tts_engine": track.engine if track else None,
"tts_voice": track.voice if track else None,
"warnings": track.warnings if track else [],
"processing_stats": stats,
}
async def _caption_words_without_voice(display: str, video: Path, info, plan: render.RenderPlan):
"""Sans voix de synthèse : calage sur la parole de la vidéo si elle en contient,
sinon texte réparti sur la durée (hors effet de fin)."""
if info.has_audio:
try:
spoken = await align.transcribe(video, hint=display)
if len(spoken) >= max(2, len(display.split()) // 3):
return align.align_words(display, spoken, info.duration)
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
log.warning("Transcription de la vidéo impossible : %s", error)
end = plan.logo_start if plan.ending_effect and plan.watermark else plan.total_duration
return align.align_words(display, [], max(1.0, end - 0.5))