mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Le petit logo affiché pendant la vidéo devient optionnel (activé par défaut), sur les 4 pages Reel (vidéo et images, bureau et mobile) ainsi que dans l'aperçu. Le grand logo de l'effet de fin reste piloté par son propre interrupteur. Pris en charge par le rendu Remotion et par le rendu FFmpeg de secours (show_watermark). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
267 lines
10 KiB
Python
267 lines
10 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
|
|
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:
|
|
return max(0.0, self.total_duration - FADE_SECONDS)
|
|
|
|
@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 audio_graph(
|
|
plan: RenderPlan, music_idx: int | None, voice_idx: int | None
|
|
) -> tuple[list[str], str | None]:
|
|
"""Graphe audio : voix décalée, musique bouclée et baissée 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]")
|
|
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"
|
|
else:
|
|
return graph, None
|
|
|
|
tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
|
|
if plan.ending_effect:
|
|
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
|
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={FADE_SECONDS}")
|
|
tail.append("format=yuv420p")
|
|
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
|
|
|
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
|
|
graph += audio_filters
|
|
if audio_out:
|
|
audio_map = ["-map", f"[{audio_out}]"]
|
|
elif plan.keep_original_audio:
|
|
audio_map = ["-map", "0:a:0"]
|
|
else:
|
|
audio_map = []
|
|
|
|
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}"
|
|
if not plan.music and not plan.voice:
|
|
if not plan.keep_original_audio:
|
|
return None
|
|
# Son d'origine seul : même niveau final que les autres Reels
|
|
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
|
|
if plan.ending_effect:
|
|
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
|
return [
|
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
|
"-map", "0:a:0", "-af", ",".join(chain), "-t", total,
|
|
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
|
] # fmt: skip
|
|
|
|
audio_args, music_idx, voice_idx, _ = _audio_inputs(plan, 0)
|
|
graph, audio_out = audio_graph(plan, music_idx, voice_idx)
|
|
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
|