new storyboard
This commit is contained in:
parent
f14981afee
commit
cf348c3ea4
5 changed files with 362 additions and 159 deletions
|
|
@ -20,11 +20,49 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Social platforms (Instagram/TikTok/YouTube) normalise to roughly -14 LUFS.
|
||||
# Mixing the narration WAVs raw left the muxed track at ~-24 LUFS: viewers
|
||||
# had to crank the volume, which reads as low production quality. Target a
|
||||
# touch under the platform norm so speech-only audio never pumps.
|
||||
LOUDNORM_TARGET = "I=-15:TP=-1.5:LRA=11"
|
||||
|
||||
|
||||
def measure_loudness(cmd_head: list[str], filter_complex: str) -> dict[str, str] | None:
|
||||
"""First loudnorm pass: measure the mixed track's loudness stats.
|
||||
|
||||
Returns the measured values for the linear second pass, or None if the
|
||||
measurement failed (in which case the caller falls back to one-pass).
|
||||
"""
|
||||
probe_cmd = cmd_head + [
|
||||
"-filter_complex",
|
||||
f"{filter_complex};[aout]loudnorm={LOUDNORM_TARGET}:print_format=json[anorm]",
|
||||
"-map",
|
||||
"[anorm]",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
match = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", result.stderr, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
stats = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
keys = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
|
||||
if not all(k in stats for k in keys):
|
||||
return None
|
||||
return stats
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
|
|
@ -134,9 +172,10 @@ def main() -> int:
|
|||
+ "\n - ".join(overlaps)
|
||||
)
|
||||
|
||||
cmd: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(video_path)]
|
||||
cmd_head: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(video_path)]
|
||||
for it in items:
|
||||
cmd += ["-i", str(audio_dir / it["wav"])]
|
||||
cmd_head += ["-i", str(audio_dir / it["wav"])]
|
||||
cmd = list(cmd_head)
|
||||
|
||||
filter_parts: list[str] = []
|
||||
mix_inputs: list[str] = []
|
||||
|
|
@ -156,13 +195,34 @@ def main() -> int:
|
|||
)
|
||||
filter_complex = ";".join(filter_parts + [mix])
|
||||
|
||||
# Two-pass EBU R128 normalisation: measure the mix, then apply a linear
|
||||
# gain against the measured stats. linear=true avoids the dynamic
|
||||
# (pumping) mode that one-pass loudnorm falls into on speech with pauses.
|
||||
measured = measure_loudness(cmd_head, filter_complex)
|
||||
if measured:
|
||||
loudnorm = (
|
||||
f"loudnorm={LOUDNORM_TARGET}"
|
||||
f":measured_I={measured['input_i']}"
|
||||
f":measured_TP={measured['input_tp']}"
|
||||
f":measured_LRA={measured['input_lra']}"
|
||||
f":measured_thresh={measured['input_thresh']}"
|
||||
f":offset={measured['target_offset']}"
|
||||
f":linear=true"
|
||||
)
|
||||
else:
|
||||
print("[mux] loudness measurement failed; using one-pass loudnorm", file=sys.stderr)
|
||||
loudnorm = f"loudnorm={LOUDNORM_TARGET}"
|
||||
filter_complex = (
|
||||
f"{filter_complex};[aout]{loudnorm},aresample=48000[anorm]"
|
||||
)
|
||||
|
||||
cmd += [
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"[anorm]",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue