mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-12 01:36:54 +02:00
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
305 lines
11 KiB
Python
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))
|