mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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
129 lines
4.7 KiB
Python
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("'", "\\'")
|