mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Fins de phrases coupées : - Le fondu final ne commence plus avant la fin de la voix (il rognait la dernière phrase quand la vidéo finissait peu après) ; il se raccourcit au besoin. Même règle côté Python, aperçu et rendu Remotion - SRT : découpe au milieu de la zone la plus calme de chaque pause, mesurée sur l'enveloppe sonore (tranches de 10 ms), avec un seuil relatif au niveau de la voix qui respecte les fins douces ; 300 ms gardées après le dernier son Bruits parasites : - Normalisation de la voix en deux passes, en mode linéaire : le mode dynamique relevait souffles et bruits entre les phrases (mesuré : -64 dB avant, -75 dB après dans les pauses) - Réduction de bruit douce (afftdn), compresseur sans gain ajouté, fondus de 5 ms aux extrémités ; mix final moins « pompant » (LRA 20) - Voix Gemini vérifiée : les bruits ajoutés avant le premier ou après le dernier mot sont retirés Vérification de lecture comparée lettre à lettre : les petites erreurs de Whisper ne déclenchent plus de régénérations inutiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""Construction des commandes FFmpeg d'un Reel (fonctions pures, testables).
|
|
|
|
Deux usages :
|
|
- rendu complet par FFmpeg (sous-titres ASS incrustés) : `build_command` ;
|
|
- préparation pour Remotion, qui compose ensuite les sous-titres animés, le
|
|
logo et l'effet de fin : `build_prepared_video_command` (image seule, déjà
|
|
recadrée et allongée) et `build_audio_mix_command` (piste son finale).
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from . import config
|
|
from .subtitles import filter_path
|
|
|
|
FADE_SECONDS = 2.0
|
|
# Fondu raccourci quand la voix finit tard, mais jamais sec
|
|
MIN_FADE = 0.3
|
|
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"
|
|
)
|
|
|
|
COLOR_TAGS = ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"]
|
|
|
|
|
|
@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)
|
|
# Effet de fin prévu sans que le logo passe par FFmpeg (rendu Remotion)
|
|
outro_expected: bool = False
|
|
ending_effect: bool = True
|
|
# Petit logo en coin pendant la vidéo (le grand logo de fin dépend de ending_effect)
|
|
show_watermark: bool = True
|
|
keep_original_audio: bool = False
|
|
|
|
@property
|
|
def has_outro(self) -> bool:
|
|
return self.ending_effect and (self.watermark is not None or self.outro_expected)
|
|
|
|
@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.has_outro:
|
|
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 round(max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end), 3)
|
|
|
|
@property
|
|
def fade_start(self) -> float:
|
|
"""Le fondu final ne commence jamais avant la fin de la voix : il
|
|
rognait la dernière phrase quand la vidéo finissait peu après."""
|
|
start = max(0.0, self.total_duration - FADE_SECONDS)
|
|
if self.voice:
|
|
start = max(start, min(self.speech_end + 0.15, self.total_duration - MIN_FADE))
|
|
return round(start, 3)
|
|
|
|
@property
|
|
def fade_duration(self) -> float:
|
|
return round(max(MIN_FADE, self.total_duration - self.fade_start), 3)
|
|
|
|
@property
|
|
def freeze_duration(self) -> float:
|
|
return max(0.0, self.total_duration - self.video_duration)
|
|
|
|
|
|
def video_filters(plan: RenderPlan) -> list[str]:
|
|
"""Stabilisation, HDR, recadrage 9:16, cadence fixe, dernière image figée."""
|
|
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}")
|
|
return chain
|
|
|
|
|
|
def uses_original_audio(plan: RenderPlan) -> bool:
|
|
"""Sans musique choisie, la bande son d'origine de la vidéo sert de fond sonore."""
|
|
return plan.music is None and plan.keep_original_audio
|
|
|
|
|
|
def audio_graph(
|
|
plan: RenderPlan, music_idx: int | None, voice_idx: int | None, original_idx: int | None = None
|
|
) -> tuple[list[str], str | None]:
|
|
"""Graphe audio : voix décalée sur un fond sonore (musique bouclée, ou à
|
|
défaut bande son d'origine) baissé sous la voix, niveau final ~ -14 LUFS.
|
|
Renvoie les filtres et l'étiquette de sortie."""
|
|
graph: list[str] = []
|
|
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]")
|
|
|
|
bed = None
|
|
if music_idx is not None:
|
|
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[bed]")
|
|
bed = "bed"
|
|
elif original_idx is not None:
|
|
# Son d'origine conservé à son niveau ; il se tait après la fin de la vidéo
|
|
graph.append(f"[{original_idx}:a:0]aresample=48000,apad[bed]")
|
|
bed = "bed"
|
|
|
|
if voice_idx is not None and bed:
|
|
# Le fond sonore baisse automatiquement quand la voix parle (ducking)
|
|
graph.append("[voice]asplit=2[vmix][vkey]")
|
|
graph.append(f"[{bed}][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 bed:
|
|
mix = bed
|
|
else:
|
|
return graph, None
|
|
|
|
tail = ["loudnorm=I=-14:TP=-1.5:LRA=20", "aresample=48000"]
|
|
if plan.ending_effect:
|
|
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
|
|
graph.append(f"[{mix}]{','.join(tail)}[aout]")
|
|
return graph, "aout"
|
|
|
|
|
|
def _audio_inputs(plan: RenderPlan, first_index: int) -> tuple[list[str], int | None, int | None, int]:
|
|
args: list[str] = []
|
|
index = first_index
|
|
music_idx = voice_idx = None
|
|
if plan.music:
|
|
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
|
|
args += ["-stream_loop", "-1", "-i", str(plan.music)]
|
|
music_idx, index = index, index + 1
|
|
if plan.voice:
|
|
args += ["-i", str(plan.voice)]
|
|
voice_idx, index = index, index + 1
|
|
return args, music_idx, voice_idx, index
|
|
|
|
|
|
def _video_encoding(crf: int, preset: str) -> list[str]:
|
|
return [
|
|
"-c:v", "libx264", "-preset", preset, "-crf", str(crf),
|
|
"-maxrate", "10M", "-bufsize", "20M",
|
|
"-profile:v", "high", "-level", "4.2",
|
|
"-g", str(config.FPS * 2), "-keyint_min", str(config.FPS),
|
|
"-pix_fmt", "yuv420p", *COLOR_TAGS,
|
|
] # fmt: skip
|
|
|
|
|
|
def build_command(plan: RenderPlan) -> list[str]:
|
|
"""Rendu complet par FFmpeg, sous-titres ASS incrustés."""
|
|
total = plan.total_duration
|
|
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
|
|
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 1)
|
|
cmd += audio_args
|
|
wm_idx = None
|
|
if plan.watermark:
|
|
cmd += ["-i", str(plan.watermark)]
|
|
wm_idx = index
|
|
|
|
chain = video_filters(plan)
|
|
if plan.captions:
|
|
chain.append(f"subtitles='{filter_path(plan.captions)}'")
|
|
graph = [f"[0:v]{','.join(chain)}[vbase]"]
|
|
|
|
current = "vbase"
|
|
if wm_idx is not None:
|
|
corner = "W-w-30:H-h-30"
|
|
big_logo = bool(plan.outro and plan.ending_effect)
|
|
logo_start = plan.logo_start
|
|
if big_logo and plan.show_watermark:
|
|
graph.append(f"[{wm_idx}:v]split=2[wm_small0][wm_big0]")
|
|
graph.append("[wm_small0]scale=200:-1[wm_small]")
|
|
elif plan.show_watermark:
|
|
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
|
|
elif big_logo:
|
|
graph.append(f"[{wm_idx}:v]null[wm_big0]")
|
|
if plan.show_watermark:
|
|
until = f":enable='lt(t,{logo_start:.3f})'" if big_logo else ""
|
|
graph.append(f"[{current}][wm_small]overlay={corner}{until}[vwm1]")
|
|
current = "vwm1"
|
|
if big_logo:
|
|
graph.append("[wm_big0]scale=-1:300[wm_big]")
|
|
graph.append(
|
|
f"[{current}][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]"
|
|
)
|
|
current = "vwm2"
|
|
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={plan.fade_start:.3f}:d={plan.fade_duration}")
|
|
tail.append("format=yuv420p")
|
|
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
|
|
|
# La vidéo (entrée 0) fournit la bande son d'origine si aucune musique n'est choisie
|
|
original_idx = 0 if uses_original_audio(plan) else None
|
|
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
|
graph += audio_filters
|
|
audio_map = ["-map", f"[{audio_out}]"] if audio_out else []
|
|
|
|
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
|
|
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
|
|
cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart"]
|
|
cmd.append(str(plan.output))
|
|
return cmd
|
|
|
|
|
|
def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
|
|
"""Image seule, prête pour Remotion : recadrée, en cadence fixe, à la durée finale.
|
|
Qualité élevée (CRF 16) : elle sera réencodée une fois par Remotion."""
|
|
chain = [*video_filters(plan), "format=yuv420p"]
|
|
return [
|
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
|
"-vf", ",".join(chain), "-an", "-t", f"{plan.total_duration:.3f}",
|
|
*_video_encoding(16, "veryfast"), "-movflags", "+faststart", str(output),
|
|
] # fmt: skip
|
|
|
|
|
|
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
|
|
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
|
|
total = f"{plan.total_duration:.3f}"
|
|
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 0)
|
|
original_idx = None
|
|
if uses_original_audio(plan):
|
|
audio_args += ["-i", str(plan.video)]
|
|
original_idx = index
|
|
graph, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
|
if not audio_out:
|
|
return None
|
|
return [
|
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
|
|
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
|
|
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
|
] # fmt: skip
|
|
|
|
|
|
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", "-",
|
|
] # fmt: skip
|