mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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
374 lines
14 KiB
Python
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))
|