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:
Claude committed 2026-09-24 12:31:27 +00:00
1 parent d24ab0d4b7
commit 3c6a66bf64
47 files changed
+2206 -2285

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Service audio et vidéo des Reels SocialFlow."""
+132
View File
@@ -0,0 +1,132 @@
"""Calage mot à mot du texte affiché sur la voix.
Le texte affiché (ponctuation, majuscules) diffère légèrement des mots
reconnus ou annoncés par le moteur de voix : on apparie les deux séquences
(difflib) et on interpole les mots sans correspondance. Remplace ffsubsync,
qui ne faisait qu'un décalage global du texte réparti uniformément.
"""
import asyncio
import difflib
import logging
import re
import unicodedata
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from . import config
log = logging.getLogger(__name__)
@dataclass
class Word:
text: str
start: float
end: float
def to_dict(self) -> dict:
return {"text": self.text, "start": round(self.start, 3), "end": round(self.end, 3)}
def normalize(token: str) -> str:
"""Forme comparable d'un mot : minuscules, sans accents ni ponctuation."""
decomposed = unicodedata.normalize("NFKD", token.lower())
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]", "", stripped)
def display_tokens(text: str) -> list[str]:
"""Mots à afficher ; la ponctuation isolée est rattachée au mot précédent."""
tokens: list[str] = []
for raw in text.split():
if tokens and not normalize(raw):
tokens[-1] += raw if raw in ",.!?;:…" else f" {raw}"
else:
tokens.append(raw)
return tokens
def align_words(display_text: str, spoken: list[Word], total_duration: float | None = None) -> list[Word]:
"""Attribue à chaque mot affiché un début et une fin tirés des mots prononcés."""
tokens = display_tokens(display_text)
if not tokens:
return []
if not spoken:
return _spread(tokens, 0.0, total_duration or len(tokens) * 0.4)
timed: list[Word | None] = [None] * len(tokens)
matcher = difflib.SequenceMatcher(
a=[normalize(t) for t in tokens], b=[normalize(w.text) for w in spoken], autojunk=False
)
for block in matcher.get_matching_blocks():
for k in range(block.size):
src = spoken[block.b + k]
timed[block.a + k] = Word(tokens[block.a + k], src.start, src.end)
# Mots non appariés : répartis dans le trou entre leurs voisins datés
end_of_speech = max(spoken[-1].end, total_duration or 0.0)
i = 0
while i < len(tokens):
if timed[i] is not None:
i += 1
continue
j = i
while j < len(tokens) and timed[j] is None:
j += 1
gap_start = timed[i - 1].end if i > 0 else 0.0
gap_end = timed[j].start if j < len(tokens) else end_of_speech
if gap_end - gap_start < 0.05 * (j - i):
gap_end = gap_start + 0.25 * (j - i)
timed[i:j] = _spread(tokens[i:j], gap_start, gap_end)
i = j
words = [w for w in timed if w is not None]
# Monotonie stricte : un mot ne commence jamais avant la fin du précédent
for prev, cur in zip(words, words[1:]):
cur.start = max(cur.start, prev.start + 0.01)
cur.end = max(cur.end, cur.start + 0.05)
return words
def _spread(tokens: list[str], start: float, end: float) -> list[Word]:
"""Répartit des mots sur un intervalle au prorata de leur longueur."""
weights = [max(1, len(normalize(t))) for t in tokens]
total = sum(weights)
words, cursor = [], start
for token, weight in zip(tokens, weights):
duration = (end - start) * weight / total
words.append(Word(token, cursor, cursor + duration))
cursor += duration
return words
@lru_cache(maxsize=1)
def _model():
from faster_whisper import WhisperModel
log.info("Chargement du modèle Whisper %s", config.WHISPER_MODEL)
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
def _transcribe_sync(audio: Path, hint: str | None) -> list[Word]:
segments, _ = _model().transcribe(
str(audio),
language="fr",
word_timestamps=True,
initial_prompt=hint or None,
vad_filter=False,
beam_size=5,
)
return [
Word(w.word.strip(), float(w.start), float(w.end))
for segment in segments
for w in (segment.words or [])
if w.word.strip()
]
async def transcribe(audio: Path, hint: str | None = None) -> list[Word]:
"""Mots prononcés et leurs instants (Whisper, exécuté hors de la boucle async)."""
return await asyncio.to_thread(_transcribe_sync, audio, hint)
+304
View File
@@ -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))
+60
View File
@@ -0,0 +1,60 @@
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
from pathlib import Path
from . import proc
# Coupe les basses inutiles, lisse la dynamique et amène la voix à -16 LUFS :
# elle reste intelligible par-dessus la musique sans saturer.
VOICE_CHAIN = (
"highpass=f=80,"
"acompressor=threshold=0.1:ratio=3:attack=5:release=120:makeup=2,"
"loudnorm=I=-16:TP=-1.5:LRA=7,"
"aresample=48000"
)
async def process_voice(source: Path, target: Path) -> None:
"""Produit un WAV 48 kHz mono prêt à mixer."""
await proc.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(source),
"-af",
VOICE_CHAIN,
"-ac",
"1",
"-ar",
"48000",
"-c:a",
"pcm_s16le",
str(target),
],
timeout=120,
)
async def encode_preview(source: Path, target: Path) -> None:
"""MP3 de bonne qualité pour l'écoute dans le navigateur."""
await proc.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(source),
"-c:a",
"libmp3lame",
"-b:a",
"160k",
str(target),
],
timeout=120,
)
+32
View File
@@ -0,0 +1,32 @@
"""Configuration lue dans l'environnement."""
import os
from pathlib import Path
# Pas de clé par défaut : un service exposé avec une clé connue de tous
# n'est pas protégé. Le démarrage échoue si elle manque.
API_KEY = os.environ.get("API_KEY", "")
TEMP_DIR = Path(os.environ.get("TEMP_DIR", "/tmp/ffmpeg_processing"))
# Durée de conservation des fichiers produits (téléchargés par l'application
# puis supprimés ; la purge ne rattrape que les oublis).
FILE_TTL_SECONDS = int(os.environ.get("FILE_TTL_SECONDS", "3600"))
GEMINI_TTS_MODEL = os.environ.get("GEMINI_TTS_MODEL", "gemini-2.5-flash-preview-tts")
# Modèle Whisper utilisé pour caler les sous-titres sur la voix.
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
WHISPER_COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
# Proxy sortant éventuel pour Edge TTS (aiohttp ne lit pas HTTPS_PROXY seul).
OUTBOUND_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or None
# Rendu
WIDTH = 1080
HEIGHT = 1920
FPS = 30
# La voix démarre après ce délai (le temps de capter l'attention).
VOICE_DELAY = 2.0
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
+44
View File
@@ -0,0 +1,44 @@
"""Dossiers de travail des rendus et purge des fichiers oubliés."""
import re
import shutil
import time
import uuid
from pathlib import Path
from . import config
_SAFE_ID = re.compile(r"^[0-9a-f-]{36}$")
_SAFE_NAME = re.compile(r"^[\w.-]+$")
def new_job() -> tuple[str, Path]:
job_id = str(uuid.uuid4())
workdir = config.TEMP_DIR / job_id
workdir.mkdir(parents=True)
return job_id, workdir
def job_file(job_id: str, name: str) -> Path | None:
"""Fichier d'un job, ou None si l'identifiant ou le nom sont suspects."""
if not _SAFE_ID.match(job_id) or not _SAFE_NAME.match(name):
return None
path = config.TEMP_DIR / job_id / name
return path if path.is_file() else None
def remove_job(job_id: str) -> None:
if _SAFE_ID.match(job_id):
shutil.rmtree(config.TEMP_DIR / job_id, ignore_errors=True)
def purge_expired() -> int:
"""Supprime les dossiers plus vieux que FILE_TTL_SECONDS."""
config.TEMP_DIR.mkdir(parents=True, exist_ok=True)
limit = time.time() - config.FILE_TTL_SECONDS
removed = 0
for entry in config.TEMP_DIR.iterdir():
if entry.is_dir() and entry.stat().st_mtime < limit:
shutil.rmtree(entry, ignore_errors=True)
removed += 1
return removed
+78
View File
@@ -0,0 +1,78 @@
"""Exécution asynchrone de FFmpeg/ffprobe : le serveur reste disponible
pendant un encodage (l'ancien subprocess.run bloquait toute l'API)."""
import asyncio
import json
import logging
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
class CommandError(RuntimeError):
"""Commande externe en échec, avec la fin de sa sortie d'erreur."""
def __init__(self, cmd: list[str], returncode: int, stderr: str):
tail = "\n".join(stderr.strip().splitlines()[-15:])
super().__init__(f"{cmd[0]} a échoué (code {returncode}) :\n{tail}")
self.returncode = returncode
self.stderr = stderr
async def run(cmd: list[str], timeout: float = 900) -> str:
"""Lance une commande et renvoie sa sortie standard."""
log.debug("exec: %s", " ".join(cmd))
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
except TimeoutError as error:
process.kill()
await process.wait()
raise CommandError(cmd, -1, f"délai de {timeout:.0f} s dépassé") from error
if process.returncode != 0:
raise CommandError(cmd, process.returncode, stderr.decode(errors="replace"))
return stdout.decode(errors="replace")
@dataclass
class MediaInfo:
duration: float
has_audio: bool
width: int = 0
height: int = 0
color_transfer: str = ""
@property
def is_hdr(self) -> bool:
# HLG (iPhone) ou PQ (HDR10)
return self.color_transfer in ("arib-std-b67", "smpte2084")
async def probe(path: Path) -> MediaInfo:
out = await run(
[
"ffprobe",
"-v",
"error",
"-print_format",
"json",
"-show_format",
"-show_streams",
str(path),
],
timeout=60,
)
data = json.loads(out)
streams = data.get("streams", [])
video = next((s for s in streams if s.get("codec_type") == "video"), {})
duration = float(data.get("format", {}).get("duration") or video.get("duration") or 0)
return MediaInfo(
duration=duration,
has_audio=any(s.get("codec_type") == "audio" for s in streams),
width=int(video.get("width") or 0),
height=int(video.get("height") or 0),
color_transfer=video.get("color_transfer") or "",
)
+217
View File
@@ -0,0 +1,217 @@
"""Construction de la commande FFmpeg d'un Reel (fonction pure, testable)."""
from dataclasses import dataclass
from pathlib import Path
from . import config
from .subtitles import filter_path
FADE_SECONDS = 2.0
LOGO_SECONDS = 5.0
# Silence laissé après la dernière phrase avant la fin de la vidéo
VOICE_TAIL = 0.8
# Durée minimale de l'effet de fin (grand logo + nom du magasin) après la voix
OUTRO_MIN = 2.5
# HDR (HLG/PQ) → SDR BT.709 : sans cela, les vidéos iPhone sortent ternes
TONEMAP = (
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,"
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
)
@dataclass
class RenderPlan:
video: Path
video_duration: float
output: Path
is_hdr: bool = False
stabilize_transforms: Path | None = None
music: Path | None = None
music_volume: float = 0.25
voice: Path | None = None
voice_duration: float = 0.0
voice_delay: float = config.VOICE_DELAY
captions: Path | None = None
watermark: Path | None = None
outro: Path | None = None # nom du magasin (effet de fin)
ending_effect: bool = True
keep_original_audio: bool = False
@property
def speech_end(self) -> float:
return self.voice_delay + self.voice_duration if self.voice else 0.0
@property
def total_duration(self) -> float:
"""La vidéo s'allonge (dernière image figée) si la voix dure plus longtemps."""
duration = self.video_duration
if self.voice:
duration = max(duration, self.speech_end + VOICE_TAIL)
if self.ending_effect and self.watermark:
duration = max(duration, self.speech_end + OUTRO_MIN)
return round(duration, 3)
@property
def logo_start(self) -> float:
"""Le grand logo n'arrive qu'une fois la voix terminée."""
return max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end)
@property
def freeze_duration(self) -> float:
return max(0.0, self.total_duration - self.video_duration)
def build_command(plan: RenderPlan) -> list[str]:
total = plan.total_duration
logo_start = plan.logo_start
fade_start = max(0.0, total - FADE_SECONDS)
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
index = 1
music_idx = voice_idx = wm_idx = None
if plan.music:
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
cmd += ["-stream_loop", "-1", "-i", str(plan.music)]
music_idx, index = index, index + 1
if plan.voice:
cmd += ["-i", str(plan.voice)]
voice_idx, index = index, index + 1
if plan.watermark:
cmd += ["-i", str(plan.watermark)]
wm_idx, index = index, index + 1
graph: list[str] = []
# --- Vidéo ---
chain = []
if plan.stabilize_transforms:
chain.append(
f"vidstabtransform=input={filter_path(plan.stabilize_transforms)}:smoothing=30:relative=1:zoom=5,"
"unsharp=5:5:0.6:5:5:0.0"
)
if plan.is_hdr:
chain.append(TONEMAP)
chain.append(
f"scale={config.WIDTH}:{config.HEIGHT}:force_original_aspect_ratio=increase:flags=lanczos,"
f"crop={config.WIDTH}:{config.HEIGHT},setsar=1,fps={config.FPS}"
)
if plan.freeze_duration > 0:
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
if plan.captions:
chain.append(f"subtitles='{filter_path(plan.captions)}'")
graph.append(f"[0:v]{','.join(chain)}[vbase]")
current = "vbase"
if wm_idx is not None:
corner = "W-w-30:H-h-30"
if plan.outro and plan.ending_effect:
graph.append(f"[{wm_idx}:v]scale=200:-1,split=2[wm_small][wm_big0]")
graph.append("[wm_big0]scale=-1:300[wm_big]")
graph.append(f"[{current}][wm_small]overlay={corner}:enable='lt(t,{logo_start:.3f})'[vwm1]")
graph.append(f"[vwm1][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]")
current = "vwm2"
else:
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
current = "vwm1"
if plan.outro and plan.ending_effect:
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
current = "vout0"
tail = []
if plan.ending_effect:
tail.append(f"fade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}")
tail.append("format=yuv420p")
graph.append(f"[{current}]{','.join(tail)}[vout]")
# --- Audio ---
mix = None
if voice_idx is not None:
delay_ms = int(plan.voice_delay * 1000)
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
if music_idx is not None:
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[music]")
if voice_idx is not None and music_idx is not None:
# La musique baisse automatiquement quand la voix parle (ducking)
graph.append("[voice]asplit=2[vmix][vkey]")
graph.append("[music][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
graph.append("[ducked][vmix]amix=inputs=2:duration=longest:normalize=0[mix]")
mix = "mix"
elif voice_idx is not None:
mix = "voice"
elif music_idx is not None:
mix = "music"
audio_map: list[str] = []
if mix:
# Niveau final recommandé par les réseaux sociaux (~ -14 LUFS)
audio_tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
if plan.ending_effect:
audio_tail.append(f"afade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}")
graph.append(f"[{mix}]{','.join(audio_tail)}[aout]")
audio_map = ["-map", "[aout]"]
elif plan.keep_original_audio:
audio_map = ["-map", "0:a:0"]
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
cmd += [
"-t",
f"{total:.3f}",
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"19",
"-maxrate",
"10M",
"-bufsize",
"20M",
"-profile:v",
"high",
"-level",
"4.2",
"-g",
str(config.FPS * 2),
"-keyint_min",
str(config.FPS),
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-colorspace",
"bt709",
"-c:a",
"aac",
"-b:a",
"192k",
"-ar",
"48000",
"-ac",
"2",
"-movflags",
"+faststart",
str(plan.output),
]
return cmd
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
return [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(video),
"-vf",
f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
"-f",
"null",
"-",
]
+128
View File
@@ -0,0 +1,128 @@
"""Sous-titres ASS : mot à mot, style « Reels » lisible sur tout fond."""
from pathlib import Path
from . import config
from .align import Word
# Couleurs ASS au format &HAABBGGRR
YELLOW = "&H0000E6FF"
WHITE = "&H00FFFFFF"
BLACK = "&H00000000"
SHADOW = "&H64000000"
# Au-delà, une ligne déborde ou se lit mal en un coup d'œil
MAX_WORDS_PER_LINE = 3
MAX_CHARS_PER_LINE = 18
# Bas du texte à ~70 % de la hauteur : au-dessus de la légende et des boutons
# qu'Instagram et TikTok superposent en bas de l'écran.
MARGIN_V = 560
POP_MS = 90
def ass_time(seconds: float) -> str:
seconds = max(0.0, seconds)
centis = int(round(seconds * 100))
hours, centis = divmod(centis, 360000)
minutes, centis = divmod(centis, 6000)
secs, centis = divmod(centis, 100)
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
def escape(text: str) -> str:
return text.replace("\\", "").replace("{", "(").replace("}", ")")
def header(styles: list[str]) -> str:
return (
"[Script Info]\n"
"ScriptType: v4.00+\n"
f"PlayResX: {config.WIDTH}\n"
f"PlayResY: {config.HEIGHT}\n"
"ScaledBorderAndShadow: yes\n"
"WrapStyle: 2\n\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
+ "".join(f"{s}\n" for s in styles)
+ "\n[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
def group_lines(words: list[Word]) -> list[list[Word]]:
"""Découpe en lignes courtes, coupées de préférence à la ponctuation."""
lines: list[list[Word]] = []
current: list[Word] = []
for word in words:
chars = sum(len(w.text) + 1 for w in current) + len(word.text)
if current and (len(current) >= MAX_WORDS_PER_LINE or chars > MAX_CHARS_PER_LINE):
lines.append(current)
current = []
current.append(word)
if word.text.endswith((".", "!", "?", ",", ":", ";", "…")):
lines.append(current)
current = []
if current:
lines.append(current)
return lines
def karaoke_line(line: list[Word], line_end: float) -> str:
"""Chaque mot passe du blanc au jaune et « saute » légèrement quand il est dit."""
parts = []
line_start = line[0].start
for index, word in enumerate(line):
next_start = line[index + 1].start if index + 1 < len(line) else line_end
fill_cs = max(1, int(round((next_start - word.start) * 100)))
t0 = int(round((word.start - line_start) * 1000))
pop = (
f"\\fscx100\\fscy100"
f"\\t({t0},{t0 + POP_MS},\\fscx112\\fscy112)"
f"\\t({t0 + POP_MS},{t0 + 2 * POP_MS},\\fscx100\\fscy100)"
)
parts.append(f"{{\\kf{fill_cs}{pop}}}{escape(word.text)}")
return " ".join(parts)
def write_captions(
words: list[Word], path: Path, *, offset: float, font_size: int, end_time: float | None = None
) -> None:
"""Écrit le fichier ASS des sous-titres, décalés de `offset` secondes."""
style = (
f"Style: Caption,{config.SUBTITLE_FONT},{font_size},{YELLOW},{WHITE},{BLACK},{SHADOW},"
f"-1,0,0,0,100,100,0,0,1,6,3,2,80,80,{MARGIN_V},1"
)
lines = group_lines(words)
events = []
for index, line in enumerate(lines):
start = line[0].start
natural_end = line[-1].end + 0.25
if index + 1 < len(lines):
end = min(max(natural_end, line[-1].end), lines[index + 1][0].start)
else:
end = max(natural_end, end_time - offset if end_time else natural_end)
end = max(end, start + 0.3)
events.append(
f"Dialogue: 0,{ass_time(start + offset)},{ass_time(end + offset)},Caption,,0,0,0,,"
f"{karaoke_line(line, end)}"
)
path.write_text(header([style]) + "\n".join(events) + "\n", encoding="utf-8")
def write_outro(store_name: str, path: Path, start: float, end: float) -> None:
"""Nom du magasin sous le logo, en fondu, pendant les dernières secondes."""
style = (
f"Style: Outro,{config.SUBTITLE_FONT},72,{WHITE},{WHITE},{BLACK},{SHADOW},"
"-1,0,0,0,100,100,0,0,1,3,4,2,60,60,700,1"
)
event = (
f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Outro,,0,0,0,,{{\\fad(1200,0)}}{escape(store_name)}"
)
path.write_text(header([style]) + event + "\n", encoding="utf-8")
def filter_path(path: Path) -> str:
"""Chemin utilisable dans un filtre FFmpeg (échappement de : et ')."""
return str(path).replace("\\", "/").replace(":", "\\:").replace("'", "\\'")
+20
View File
@@ -0,0 +1,20 @@
"""Nettoyage du texte avant synthèse et affichage."""
import re
import emoji
_HIDDEN = str.maketrans("", "", "​")
_HASHTAG = re.compile(r"#[\wÀ-ɏ]+")
_URL = re.compile(r"https?://\S+")
def clean_text(text: str | None) -> str:
"""Retire emojis, hashtags, liens et caractères invisibles."""
if not text:
return ""
text = text.translate(_HIDDEN)
text = emoji.replace_emoji(text, replace="")
text = _URL.sub("", text)
text = _HASHTAG.sub("", text)
return " ".join(text.split())
+63
View File
@@ -0,0 +1,63 @@
"""Synthèse vocale : moteur au choix, repli sur Edge, voix traitée et mots minutés."""
import logging
from dataclasses import dataclass, field
from pathlib import Path
from .. import align, audio, proc
from ..align import Word
from . import edge, gemini
log = logging.getLogger(__name__)
@dataclass
class VoiceTrack:
path: Path # WAV 48 kHz traité
duration: float
words: list[Word] # mots affichés, minutés depuis le début de la voix
engine: str
voice: str
warnings: list[str] = field(default_factory=list)
async def synthesize(
*,
text: str,
display_text: str,
engine: str | None,
voice: str | None,
style: str | None,
gemini_api_key: str | None,
workdir: Path,
) -> VoiceTrack:
warnings: list[str] = []
raw: Path | None = None
spoken: list[Word] = []
used_engine, used_voice = "edge", ""
if (engine or "gemini") == "gemini":
if not gemini_api_key:
warnings.append("Clé Gemini absente : voix Edge utilisée à la place.")
else:
try:
raw, used_voice = await gemini.synthesize(text, voice, style, gemini_api_key, workdir)
used_engine = "gemini"
except Exception as error: # noqa: BLE001 — repli sur Edge
log.warning("Gemini TTS en échec, repli sur Edge : %s", error)
warnings.append(f"Gemini indisponible ({error}) : voix Edge utilisée à la place.")
if raw is None:
raw, spoken, used_voice = await edge.synthesize(text, voice, style, workdir)
processed = workdir / "voice.wav"
await audio.process_voice(raw, processed)
duration = (await proc.probe(processed)).duration
if not spoken:
# Gemini ne donne pas le minutage : Whisper le retrouve dans l'audio
spoken = await align.transcribe(processed, hint=text)
words = align.align_words(display_text, spoken, duration)
log.info("Voix prête : %s/%s, %.1f s, %d mots", used_engine, used_voice, duration, len(words))
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
+58
View File
@@ -0,0 +1,58 @@
"""Synthèse vocale Edge (gratuite, fournit le minutage des mots)."""
import logging
from pathlib import Path
import edge_tts
from .. import config
from ..align import Word
from ..voices import edge_fallbacks, resolve_edge_voice
log = logging.getLogger(__name__)
# Réglages de lecture par style (Edge n'interprète pas de consigne en texte)
STYLE_PROSODY = {
"dynamic": ("+8%", "+2Hz"),
"promo": ("+10%", "+3Hz"),
"calm": ("-8%", "-2Hz"),
"warm": ("-2%", "+0Hz"),
}
async def synthesize(
text: str, voice: str | None, style: str | None, workdir: Path
) -> tuple[Path, list[Word], str]:
"""Génère la voix ; renvoie le MP3, les mots minutés et la voix utilisée."""
rate, pitch = STYLE_PROSODY.get(style or "", ("+0%", "+0Hz"))
last_error: Exception | None = None
for candidate in edge_fallbacks(resolve_edge_voice(voice)):
target = workdir / "edge.mp3"
try:
communicate = edge_tts.Communicate(
text,
candidate.id,
rate=rate,
pitch=pitch,
# edge-tts 7 ne renvoie plus que les phrases par défaut
boundary="WordBoundary",
proxy=config.OUTBOUND_PROXY,
)
words: list[Word] = []
with target.open("wb") as out:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
out.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
start = chunk["offset"] / 10_000_000
words.append(Word(chunk["text"], start, start + chunk["duration"] / 10_000_000))
if target.stat().st_size == 0:
raise RuntimeError("audio vide")
log.info("Edge TTS : voix=%s, %d mots minutés", candidate.id, len(words))
return target, words, candidate.id
except Exception as error: # noqa: BLE001 — on essaie la voix suivante
log.warning("Edge TTS %s en échec : %s", candidate.id, error)
last_error = error
raise RuntimeError(f"Aucune voix Edge disponible : {last_error}")
+102
View File
@@ -0,0 +1,102 @@
"""Synthèse vocale Gemini (API generateContent, modalité audio)."""
import base64
import logging
import re
from pathlib import Path
import httpx
from .. import config, proc
from ..voices import resolve_gemini_voice, style_instruction
log = logging.getLogger(__name__)
API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
FALLBACK_MODEL = "gemini-2.5-flash-preview-tts"
class GeminiError(RuntimeError):
pass
def build_prompt(text: str, style: str | None) -> str:
"""Texte envoyé au modèle : la consigne de style précède le texte à lire."""
instruction = style_instruction(style)
return f"{instruction} :\n{text}" if instruction else text
async def _request(model: str, prompt: str, voice: str, api_key: str) -> dict:
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
},
}
async with httpx.AsyncClient(timeout=120) as client:
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
response = await client.post(
f"{API_ROOT}/{model}:generateContent",
json=payload,
headers={"x-goog-api-key": api_key},
)
if response.status_code != 200:
raise GeminiError(f"Gemini {model} : HTTP {response.status_code} {response.text[:300]}")
return response.json()
async def synthesize(
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path
) -> tuple[Path, str]:
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée."""
gemini_voice = resolve_gemini_voice(voice).id
prompt = build_prompt(text, style)
model = config.GEMINI_TTS_MODEL
log.info("Gemini TTS : modèle=%s voix=%s style=%s (%d car.)", model, gemini_voice, style, len(text))
try:
data = await _request(model, prompt, gemini_voice, api_key)
except GeminiError as error:
if model == FALLBACK_MODEL or "HTTP 404" not in str(error):
raise
log.warning("Modèle %s indisponible, repli sur %s", model, FALLBACK_MODEL)
data = await _request(FALLBACK_MODEL, prompt, gemini_voice, api_key)
try:
part = next(p for p in data["candidates"][0]["content"]["parts"] if "inlineData" in p)["inlineData"]
except (KeyError, IndexError, StopIteration) as error:
raise GeminiError(f"Réponse Gemini sans audio : {str(data)[:300]}") from error
audio = base64.b64decode(part["data"])
mime = part.get("mimeType", "")
raw = workdir / "gemini_raw.wav"
if "wav" in mime:
raw.write_bytes(audio)
else:
# PCM brut 16 bits (« audio/L16;codec=pcm;rate=24000 »)
rate_match = re.search(r"rate=(\d+)", mime)
pcm = workdir / "gemini.pcm"
pcm.write_bytes(audio)
await proc.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"s16le",
"-ar",
rate_match.group(1) if rate_match else "24000",
"-ac",
"1",
"-i",
str(pcm),
str(raw),
],
timeout=60,
)
pcm.unlink(missing_ok=True)
return raw, gemini_voice
+131
View File
@@ -0,0 +1,131 @@
"""Catalogue des voix et des styles de lecture."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Voice:
id: str
label: str
gender: str # "female" | "male"
# Voix natives de Gemini TTS (toutes multilingues, françaises comprises).
GEMINI_VOICES: tuple[Voice, ...] = (
Voice("Kore", "Kore — ferme", "female"),
Voice("Aoede", "Aoede — légère", "female"),
Voice("Leda", "Leda — jeune", "female"),
Voice("Zephyr", "Zephyr — lumineuse", "female"),
Voice("Callirrhoe", "Callirrhoe — décontractée", "female"),
Voice("Autonoe", "Autonoe — lumineuse", "female"),
Voice("Despina", "Despina — douce", "female"),
Voice("Erinome", "Erinome — claire", "female"),
Voice("Laomedeia", "Laomedeia — enjouée", "female"),
Voice("Achernar", "Achernar — tendre", "female"),
Voice("Gacrux", "Gacrux — mûre", "female"),
Voice("Pulcherrima", "Pulcherrima — affirmée", "female"),
Voice("Vindemiatrix", "Vindemiatrix — délicate", "female"),
Voice("Sulafat", "Sulafat — chaleureuse", "female"),
Voice("Charon", "Charon — informative", "male"),
Voice("Puck", "Puck — enjouée", "male"),
Voice("Fenrir", "Fenrir — enthousiaste", "male"),
Voice("Orus", "Orus — ferme", "male"),
Voice("Enceladus", "Enceladus — soufflée", "male"),
Voice("Iapetus", "Iapetus — claire", "male"),
Voice("Umbriel", "Umbriel — décontractée", "male"),
Voice("Algieba", "Algieba — veloutée", "male"),
Voice("Algenib", "Algenib — rocailleuse", "male"),
Voice("Rasalgethi", "Rasalgethi — informative", "male"),
Voice("Alnilam", "Alnilam — ferme", "male"),
Voice("Schedar", "Schedar — posée", "male"),
Voice("Achird", "Achird — amicale", "male"),
Voice("Zubenelgenubi", "Zubenelgenubi — naturelle", "male"),
Voice("Sadachbia", "Sadachbia — vive", "male"),
Voice("Sadaltager", "Sadaltager — experte", "male"),
)
EDGE_VOICES: tuple[Voice, ...] = (
Voice("fr-FR-VivienneMultilingualNeural", "Vivienne", "female"),
Voice("fr-FR-DeniseNeural", "Denise", "female"),
Voice("fr-FR-EloiseNeural", "Eloise", "female"),
Voice("fr-FR-RemyMultilingualNeural", "Rémy", "male"),
Voice("fr-FR-HenriNeural", "Henri", "male"),
)
# Consignes de lecture ajoutées au texte envoyé à Gemini.
STYLES: dict[str, tuple[str, str]] = {
"neutral": ("Neutre", ""),
"dynamic": (
"Dynamique",
"Lis ce texte en français sur un ton dynamique et enthousiaste, avec un rythme "
"entraînant, comme une vidéo courte sur les réseaux sociaux",
),
"warm": (
"Chaleureux",
"Lis ce texte en français sur un ton chaleureux, souriant et proche, comme si tu conseillais un ami",
),
"calm": (
"Calme",
"Lis ce texte en français sur un ton calme, posé et rassurant, sans te presser",
),
"promo": (
"Promo",
"Lis ce texte en français comme une annonce promotionnelle énergique, en "
"insistant sur les offres et les prix",
),
}
_GEMINI_BY_ID = {v.id.lower(): v for v in GEMINI_VOICES}
_EDGE_BY_ID = {v.id: v for v in EDGE_VOICES}
def resolve_gemini_voice(voice: str | None) -> Voice:
"""Voix Gemini à utiliser.
Accepte aussi les anciens identifiants « fr-FR-Standard-X » encore stockés
dans des jobs (B/D = voix masculine).
"""
if voice and voice.lower() in _GEMINI_BY_ID:
return _GEMINI_BY_ID[voice.lower()]
if voice and voice.endswith(("-B", "-D")) or voice == "male":
return _GEMINI_BY_ID["charon"]
return _GEMINI_BY_ID["kore"]
def voice_gender(voice: str | None) -> str:
"""Genre d'une voix, quel que soit le moteur d'origine."""
if not voice:
return "female"
if voice.lower() in _GEMINI_BY_ID:
return _GEMINI_BY_ID[voice.lower()].gender
if voice in _EDGE_BY_ID:
return _EDGE_BY_ID[voice].gender
if voice == "male" or voice.endswith(("-B", "-D")):
return "male"
return "male" if any(name in voice for name in ("Remy", "Henri", "Paul")) else "female"
def resolve_edge_voice(voice: str | None) -> Voice:
"""Voix Edge à utiliser ; une voix Gemini est remplacée par une voix Edge du même genre."""
if voice in _EDGE_BY_ID:
return _EDGE_BY_ID[voice]
gender = voice_gender(voice)
return next(v for v in EDGE_VOICES if v.gender == gender)
def edge_fallbacks(primary: Voice) -> list[Voice]:
"""Voix françaises de secours du même genre (jamais d'anglais)."""
same = [v for v in EDGE_VOICES if v.gender == primary.gender and v != primary]
return [primary, *same]
def style_instruction(style: str | None) -> str:
return STYLES.get(style or "neutral", STYLES["neutral"])[1]
def catalog() -> dict:
return {
"gemini": [v.__dict__ for v in GEMINI_VOICES],
"edge": [v.__dict__ for v in EDGE_VOICES],
"styles": [{"id": k, "label": v[0]} for k, v in STYLES.items()],
}