mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 11:49:30 +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,304 @@
|
||||
"""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))
|
||||
Reference in new issue
Block a user