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
218 lines
7.0 KiB
Python
218 lines
7.0 KiB
Python
"""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",
|
|
"-",
|
|
]
|