Files
Socialflow/ffmpeg-service/app/subtitles.py
T
Claude 3c6a66bf64 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
2026-09-24 12:31:27 +00:00

129 lines
4.7 KiB
Python

"""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("'", "\\'")