Files
Socialflow/ffmpeg-service/app/api.py
T
Claude 9b66a6dc5a feat(reels): montage Remotion unifié, aperçu en direct, tests et CI
Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

Interface (étape 4) :
- Aperçu en direct avec @remotion/player, identique au rendu final ; la voix
  testée cale les sous-titres, sinon minutage estimé
- Choix du style de sous-titres sur les 4 pages Reel
- Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur
  cause ; fin de la barre simulée et du faux « publié avec succès »

Outillage (étape 5) :
- Tests vitest (minutage identique à Python, validation, sécurité) et
  pytest ; CI GitHub Actions (tsc, tests, build, ruff)
- Captures d'écran, out.mp4 et scripts de test retirés de la racine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 14:37:42 +00:00

374 lines
14 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
# Préparation Remotion : le logo est composé par Remotion, mais sa présence
# allonge la vidéo pour laisser place à l'effet de fin.
has_logo: bool = False
# 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):
"""Rendu complet par FFmpeg ; le MP4 se récupère via GET /files/{job_id}/output.mp4."""
return await _guarded(_process, request)
@app.post("/prepare-reel", dependencies=[Depends(require_key)])
async def prepare_reel(request: ReelRequest):
"""Prépare un rendu Remotion : image recadrée à la durée finale, piste son
finale et minutage des mots. Rien n'est incrusté dans l'image."""
return await _guarded(_prepare, request)
async def _guarded(handler, request: ReelRequest) -> dict:
job_id, workdir = jobs.new_job()
started = time.monotonic()
try:
async with render_slot:
return await handler(request, job_id, workdir, 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é")
media_type = {"mp4": "video/mp4", "wav": "audio/wav"}.get(name.rsplit(".", 1)[-1])
return FileResponse(path, media_type=media_type)
@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
class Stopwatch:
def __init__(self, started: float):
self.started = started
self.last = time.monotonic()
self.stats: dict[str, float] = {}
def lap(self, name: str) -> None:
now = time.monotonic()
self.stats[name] = round(now - self.last, 2)
self.last = now
def summary(self) -> dict[str, float]:
return {**self.stats, "total": round(time.monotonic() - self.started, 2)}
async def _gather(request: ReelRequest, workdir: Path, clock: Stopwatch, *, fetch_logo: bool):
"""Téléchargements, analyse, voix et stabilisation : communs aux deux modes."""
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 = (
fetch_logo
and 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)")
clock.lap("download")
track = None
spoken_text = clean_text(request.text)
if request.tts_enabled and spoken_text:
track = await _synthesize(spoken_text, request.text, request, workdir)
clock.lap("tts")
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,
outro_expected=not fetch_logo and request.has_logo,
ending_effect=request.enable_ending_effect,
keep_original_audio=info.has_audio,
)
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)
clock.lap("stabilize")
return plan, track, info
async def _caption_words(request: ReelRequest, plan: render.RenderPlan, track, info) -> list[align.Word]:
"""Mots à afficher, en secondes depuis le début de la vidéo."""
display = clean_text(request.text)
if not request.draw_text or not display:
return []
if track:
return [align.Word(w.text, w.start + plan.voice_delay, w.end + plan.voice_delay) for w in track.words]
return await _caption_words_without_voice(display, plan.video, info, plan)
def _keep_only(workdir: Path, keep: set[Path]) -> None:
"""Seuls les fichiers à télécharger restent jusqu'à la récupération."""
for entry in workdir.iterdir():
if entry not in keep:
entry.unlink(missing_ok=True)
def _voice_info(track) -> dict:
return {
"tts_engine": track.engine if track else None,
"tts_voice": track.voice if track else None,
"warnings": track.warnings if track else [],
}
async def _process(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=True)
words = await _caption_words(request, plan, track, info)
if words:
captions = workdir / "captions.ass"
font_size = max(48, round(request.font_size * 1.4))
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
plan.captions = captions
if request.store_name and plan.has_outro and plan.watermark:
outro = workdir / "outro.ass"
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
plan.outro = outro
clock.lap("subtitles")
await proc.run(render.build_command(plan), timeout=1200)
clock.lap("encode")
duration = (await proc.probe(plan.output)).duration
log.info("Rendu %s terminé : %.1f s de vidéo, étapes %s", job_id, duration, clock.summary())
_keep_only(workdir, {plan.output})
return {
"success": True,
"job_id": job_id,
"output_path": f"/files/{job_id}/output.mp4",
"duration": duration,
**_voice_info(track),
"processing_stats": clock.summary(),
}
async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=False)
words = await _caption_words(request, plan, track, info)
clock.lap("subtitles")
video_out = workdir / "video.mp4"
audio_out = workdir / "audio.wav"
await proc.run(render.build_prepared_video_command(plan, video_out), timeout=1200)
clock.lap("video")
mix = render.build_audio_mix_command(plan, audio_out)
if mix:
await proc.run(mix, timeout=600)
clock.lap("audio")
log.info("Préparation %s terminée : %.1f s, étapes %s", job_id, plan.total_duration, clock.summary())
_keep_only(workdir, {video_out, audio_out})
return {
"success": True,
"job_id": job_id,
"video_path": f"/files/{job_id}/video.mp4",
"audio_path": f"/files/{job_id}/audio.wav" if mix else None,
"total_duration": plan.total_duration,
"video_duration": info.duration,
"logo_start": plan.logo_start if plan.has_outro else None,
"words": [w.to_dict() for w in words],
**_voice_info(track),
"processing_stats": clock.summary(),
}
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.has_outro else plan.total_duration
return align.align_words(display, [], max(1.0, end - 0.5))