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

+4
View File
@@ -0,0 +1,4 @@
__pycache__
.pytest_cache
.ruff_cache
tests
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.pytest_cache/
.ruff_cache/
+23 -22
View File
@@ -1,38 +1,39 @@
FROM python:3.11-slim
# Install system dependencies including FFmpeg with vidstab support and fonts
# We need to build/install libvidstab and compile ffmpeg with it, OR use static build
RUN apt-get update && apt-get install -y \
wget \
xz-utils \
fonts-dejavu \
fontconfig \
build-essential \
python3-dev \
# Polices des sous-titres (Montserrat) et des emojis, installées au build
# plutôt que téléchargées à chaque démarrage.
RUN apt-get update && apt-get install -y --no-install-recommends \
wget xz-utils ca-certificates fontconfig \
fonts-montserrat fonts-dejavu-core fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
# Download static FFmpeg build with all filters including vidstab
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
&& tar xf ffmpeg-release-amd64-static.tar.xz \
&& mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \
&& mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \
# FFmpeg statique (vidstab, zimg pour le tone-mapping HDR, libass).
# Version épinglée : une mise à jour ne change plus le rendu à notre insu.
ARG FFMPEG_VERSION=7.0.2
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
|| wget -q https://johnvansickle.com/ffmpeg/old-releases/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
&& tar xf ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz \
&& mv ffmpeg-*-amd64-static/ffmpeg ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \
&& rm -rf ffmpeg-* \
&& fc-cache -fv
&& fc-cache -f
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
# Modèle Whisper (calage des sous-titres) téléchargé au build : aucun accès
# réseau nécessaire au premier rendu.
ARG WHISPER_MODEL=base
ENV WHISPER_MODEL=${WHISPER_MODEL} \
HF_HOME=/opt/hf-cache \
HOME=/tmp \
XDG_CACHE_HOME=/tmp/.cache
RUN python -c "from faster_whisper import WhisperModel; WhisperModel('${WHISPER_MODEL}', device='cpu', compute_type='int8')"
COPY main.py .
COPY app ./app
# Create API Key env var (should be overridden in docker-compose)
ENV API_KEY=default-dev-key
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Binary file not shown.
+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()],
}
+5 -1385
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.5
ruff==0.11.8
+7 -9
View File
@@ -1,9 +1,7 @@
fastapi==0.109.0
uvicorn==0.27.0
python-multipart==0.0.6
requests==2.31.0
pydantic==2.6.0
edge-tts==6.1.12
emoji
ffsubsync==0.4.26
httpx>=0.25.0
fastapi==0.115.12
uvicorn[standard]==0.34.2
pydantic==2.11.4
httpx==0.28.1
edge-tts==7.2.8
emoji==2.14.1
faster-whisper==1.2.1
+9
View File
@@ -0,0 +1,9 @@
line-length = 110
target-version = "py311"
[lint]
select = ["E", "F", "W", "I", "B", "UP"]
ignore = [
"B008", # Depends() dans les signatures FastAPI
"B905", # zip() sur des séquences dont les longueurs sont liées par construction
]
-14
View File
@@ -1,14 +0,0 @@
[Script Info]
ScriptType: v4.00+
PlayResX: 1080
PlayResY: 1920
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Sans,65,&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,50,50,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:02.00,0:00:02.71,Default,,0,0,0,,{\kf31}Test {\kf31}test {\kf10}1
Dialogue: 0,0:00:02.71,0:00:02.87,Default,,0,0,0,,{\kf10}2 {\kf10}3
-13
View File
@@ -1,13 +0,0 @@
[Script Info]
ScriptType: v4.00+
PlayResX: 1080
PlayResY: 1920
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Sans,65,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,50,50,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:02.00,0:00:04.00,Default,,0,0,0,,Hello World
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
import os
os.environ.setdefault("API_KEY", "test-key")
+31
View File
@@ -0,0 +1,31 @@
from app.align import Word, align_words, display_tokens, normalize
def test_normalize_strips_accents_and_punctuation():
assert normalize("Été,") == "ete"
assert normalize("«") == ""
def test_display_tokens_attach_isolated_punctuation():
assert display_tokens("Bonjour ! Venez vite .") == ["Bonjour!", "Venez", "vite."]
def test_align_uses_engine_timings_and_keeps_display_text():
spoken = [Word("Bonjour", 0.05, 0.6), Word("découvrez", 0.99, 1.35), Word("nos", 1.35, 1.44)]
words = align_words("Bonjour, découvrez nos", spoken)
assert [w.text for w in words] == ["Bonjour,", "découvrez", "nos"]
assert words[1].start == 0.99
def test_unmatched_words_are_interpolated_between_neighbours():
spoken = [Word("les", 0.0, 0.2), Word("prix", 1.0, 1.3)]
words = align_words("les super promos prix", spoken)
assert [w.text for w in words] == ["les", "super", "promos", "prix"]
assert 0.2 <= words[1].start < words[2].start < 1.0
assert all(b.start > a.start for a, b in zip(words, words[1:]))
def test_without_spoken_words_text_is_spread_over_duration():
words = align_words("un deux trois", [], total_duration=3.0)
assert words[0].start == 0.0
assert abs(words[-1].end - 3.0) < 1e-6
+77
View File
@@ -0,0 +1,77 @@
from pathlib import Path
from app.render import RenderPlan, build_command
def _graph(cmd):
return cmd[cmd.index("-filter_complex") + 1]
def test_voice_longer_than_video_extends_with_frozen_frame():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=5.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=10.0,
)
assert plan.total_duration == 12.8
cmd = build_command(plan)
assert "tpad=stop_mode=clone:stop_duration=7.800" in _graph(cmd)
assert cmd[cmd.index("-t") + 1] == "12.800"
def test_music_is_looped_and_ducked_under_voice():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=20.0,
output=Path("out.mp4"),
music=Path("m.mp3"),
voice=Path("v.wav"),
voice_duration=5.0,
)
cmd = build_command(plan)
assert cmd[cmd.index("m.mp3") - 3 : cmd.index("m.mp3")] == ["-stream_loop", "-1", "-i"]
graph = _graph(cmd)
assert "sidechaincompress" in graph
assert "amix=inputs=2:duration=longest" in graph
assert "loudnorm=I=-14" in graph
def test_no_brightness_hack_and_hdr_tonemapped():
plan = RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), is_hdr=True)
graph = _graph(build_command(plan))
assert "eq=" not in graph
assert "tonemap=tonemap=hable" in graph
assert "flags=lanczos" in graph
def test_original_audio_kept_when_nothing_added():
plan = RenderPlan(
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
)
cmd = build_command(plan)
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "0:a:0"
def test_encoding_targets_social_networks():
cmd = build_command(RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4")))
joined = " ".join(cmd)
assert "-crf 19" in joined and "-b:v" not in joined
assert "-ar 48000" in joined and "-b:a 192k" in joined
assert "-colorspace bt709" in joined
def test_big_logo_waits_for_the_end_of_the_voice():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=6.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.8,
watermark=Path("logo.png"),
outro=Path("o.ass"),
)
assert plan.logo_start == 6.8
assert plan.total_duration == 9.3
assert "gte(t,6.800)" in _graph(build_command(plan))
+23
View File
@@ -0,0 +1,23 @@
from app.align import Word
from app.subtitles import ass_time, group_lines, write_captions
def test_ass_time_format():
assert ass_time(0) == "0:00:00.00"
assert ass_time(61.234) == "0:01:01.23"
def test_lines_break_on_punctuation_and_length():
words = [Word(t, i, i + 0.5) for i, t in enumerate("Bonjour, venez découvrir nos nouveautés".split())]
lines = group_lines(words)
assert [w.text for w in lines[0]] == ["Bonjour,"]
assert all(len(line) <= 3 for line in lines)
def test_captions_are_offset_and_escape_braces(tmp_path):
path = tmp_path / "c.ass"
write_captions([Word("{prix}", 0.0, 0.5), Word("fous", 0.5, 1.0)], path, offset=2.0, font_size=80)
content = path.read_text()
assert "Dialogue: 0,0:00:02.00," in content
assert "{prix}" not in content.split("[Events]")[1].replace("{\\", "")
assert "(prix)" in content
+23
View File
@@ -0,0 +1,23 @@
from app.tts.gemini import build_prompt
from app.voices import GEMINI_VOICES, edge_fallbacks, resolve_edge_voice, resolve_gemini_voice
def test_thirty_gemini_voices():
assert len(GEMINI_VOICES) == 30
def test_legacy_gemini_identifiers_still_resolve():
assert resolve_gemini_voice("fr-FR-Standard-B").id == "Charon"
assert resolve_gemini_voice("fr-FR-Standard-A").id == "Kore"
assert resolve_gemini_voice("puck").id == "Puck"
def test_edge_fallbacks_are_french_and_same_gender():
voice = resolve_edge_voice("Fenrir") # voix Gemini masculine
assert voice.gender == "male"
assert all(v.id.startswith("fr-FR") and v.gender == "male" for v in edge_fallbacks(voice))
def test_style_prompt_precedes_text():
assert build_prompt("Salut", "neutral") == "Salut"
assert build_prompt("Salut", "dynamic").endswith(":\nSalut")