mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-15 11:18:54 +02:00
feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité
Service ffmpeg-api réécrit (ffmpeg-service/app/) : - Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu - Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON - Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible - 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme), clé en en-tête, modèle configurable avec repli - Edge TTS 7 (minutage des mots restauré), secours en voix françaises - Voix traitée : filtre, compression, niveau constant ; musique bouclée et baissée automatiquement sous la voix ; mix final à -14 LUFS - Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine, à la place de ffsubsync), style Montserrat, placés hors des boutons Reels - Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse - Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et modèle Whisper intégrés à l'image ; tests pytest et ruff Application : - Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute - Reel images : minutage réel des mots, interrupteur voix respecté - sync-info ne génère plus de voix à chaque frappe (estimation locale) - Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée - Log « [ReelQueue] Worker démarré » pour vérifier la version déployée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
47 files changed
+2206
-2285
No files matched your search
@@ -0,0 +1,4 @@
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
tests
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
+23
-22
@@ -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.
@@ -0,0 +1 @@
|
||||
"""Service audio et vidéo des Reels SocialFlow."""
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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",
|
||||
"-",
|
||||
]
|
||||
@@ -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("'", "\\'")
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.5
|
||||
ruff==0.11.8
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
os.environ.setdefault("API_KEY", "test-key")
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user