ffmpeg-skill 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env python3
2
+ """One table per delivery destination (internal module, not a tool).
3
+
4
+ Before 1.14 the same destination was described three times: check.py held the
5
+ compliance spec (duration, aspect, codecs, loudness), export.py held the frame and
6
+ encoder settings, and nothing at all held the part of the frame the app's own UI
7
+ covers. A TikTok export therefore passed every check while its captions sat under
8
+ the description bar. This module is the single table the delivery tools read:
9
+
10
+ PLATFORMS[name] = {
11
+ "frame": {"w", "h", "aspect"} or None (audio-only destinations),
12
+ "fps": the frame rate a delivery is conformed to (None = leave alone),
13
+ "spec": check.py's compliance row values (max_duration, aspects,
14
+ min_height, fps_max, codecs, max_bytes, lufs, lufs_tol, tp,
15
+ sdr_only) -- the keys check.py's SPECS has always had,
16
+ "safe": the fraction of the frame each edge's UI covers
17
+ (top/bottom/left/right, 0..1) -- nothing readable goes there,
18
+ "caption": the caption defaults a template uses (size as a fraction of the
19
+ frame height, position, box, outline, animate),
20
+ "preset": the export.py preset that writes this destination's file,
21
+ "check": the check.py platform name a delivery is verified against.
22
+ }
23
+
24
+ Read by check.py (SPECS), export.py (PRESETS/PLATFORM_OF), render.py (templates),
25
+ caption.py and graphics.py (--platform margins) and look.py (--safe).
26
+
27
+ Safe zones are the app's own overlay, measured from each platform's published
28
+ design guidance: TikTok's caption/description block and the like/comment column,
29
+ Instagram's Reels UI, the Shorts player. They are deliberately generous -- a
30
+ caption 2 % too high is readable, a caption under the share button is not. The
31
+ feed destinations (YouTube, X, LinkedIn, Facebook) have no persistent overlay, so
32
+ they carry the conventional 5 % title-safe border instead.
33
+
34
+ ASS note: caption.py's --size and --margin are in the 288-line ASS script grid, so
35
+ a fraction of the frame height is that fraction * 288 (ass_units() below); the
36
+ burn scales it back to the real frame. graphics.py and look.py work in pixels.
37
+ """
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ ASS_SCRIPT_HEIGHT = 288 # caption.py's --size/--margin reference grid
41
+
42
+
43
+ def ass_units(fraction: float) -> int:
44
+ """A fraction of the frame height as a caption.py --size / --margin value."""
45
+ return int(round(fraction * ASS_SCRIPT_HEIGHT))
46
+
47
+
48
+ # The edges a feed destination reserves: no app chrome, just the conventional title-safe border.
49
+ _SAFE_5 = {"top": 0.05, "bottom": 0.05, "left": 0.05, "right": 0.05}
50
+ _NO_SAFE = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
51
+
52
+ # caption defaults: size is a fraction of the frame height (0.0833 = the 24 that every
53
+ # vertical job in this repo has used since 1.2), position/box/outline/animate as the
54
+ # caption.py flags of the same name.
55
+ _CAP_VERTICAL = {"size": 0.0833, "position": "bottom", "box": False, "outline": 2, "animate": "pop"}
56
+ _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "animate": "none"}
57
+
58
+ PLATFORMS: Dict[str, Dict[str, Any]] = {
59
+ "tiktok": {
60
+ "title": "TikTok",
61
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
62
+ "fps": 30,
63
+ "spec": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60,
64
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
65
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
66
+ # the description/caption block along the bottom, the like/comment/share column on the
67
+ # right, the status bar and the "Following | For You" tabs at the top
68
+ "safe": {"top": 0.10, "bottom": 0.22, "left": 0.05, "right": 0.14},
69
+ "caption": _CAP_VERTICAL,
70
+ "preset": "tiktok", "check": "tiktok",
71
+ },
72
+ "reels": {
73
+ "title": "Instagram Reels",
74
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
75
+ "fps": 30,
76
+ "spec": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60,
77
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
78
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
79
+ "safe": {"top": 0.08, "bottom": 0.20, "left": 0.05, "right": 0.12},
80
+ "caption": _CAP_VERTICAL,
81
+ "preset": "reels", "check": "reels",
82
+ },
83
+ "shorts": {
84
+ "title": "YouTube Shorts",
85
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
86
+ "fps": 30,
87
+ "spec": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60,
88
+ "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3,
89
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
90
+ "safe": {"top": 0.06, "bottom": 0.18, "left": 0.05, "right": 0.12},
91
+ "caption": _CAP_VERTICAL,
92
+ "preset": "shorts", "check": "shorts",
93
+ },
94
+ "youtube": {
95
+ "title": "YouTube",
96
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
97
+ "fps": None,
98
+ "spec": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
99
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
100
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
101
+ "safe": _SAFE_5,
102
+ "caption": _CAP_WIDE,
103
+ "preset": "youtube", "check": "youtube",
104
+ },
105
+ "youtube-hdr": {
106
+ "title": "YouTube (HDR10)",
107
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
108
+ "fps": None,
109
+ "spec": dict({"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
110
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
111
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False}),
112
+ "safe": _SAFE_5,
113
+ "caption": _CAP_WIDE,
114
+ "preset": "youtube-hdr", "check": "youtube",
115
+ },
116
+ "youtube-av1": {
117
+ "title": "YouTube (AV1)",
118
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
119
+ "fps": None,
120
+ "spec": dict({"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
121
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
122
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False}),
123
+ "safe": _SAFE_5,
124
+ "caption": _CAP_WIDE,
125
+ "preset": "youtube-av1", "check": "youtube",
126
+ },
127
+ "x": {
128
+ "title": "X (Twitter)",
129
+ "frame": {"w": 1280, "h": 720, "aspect": "16:9"},
130
+ "fps": 30,
131
+ "spec": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60,
132
+ "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2,
133
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
134
+ "safe": _SAFE_5,
135
+ "caption": _CAP_WIDE,
136
+ "preset": "x", "check": "x",
137
+ },
138
+ "linkedin": {
139
+ "title": "LinkedIn",
140
+ "frame": {"w": 1080, "h": 1080, "aspect": "1:1"},
141
+ "fps": 30,
142
+ "spec": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60,
143
+ "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3,
144
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
145
+ "safe": _SAFE_5,
146
+ "caption": _CAP_WIDE,
147
+ "preset": "linkedin", "check": "linkedin",
148
+ },
149
+ "facebook": {
150
+ "title": "Facebook",
151
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
152
+ "fps": 30,
153
+ "spec": {"max_duration": 240 * 60, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60,
154
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
155
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
156
+ "safe": _SAFE_5,
157
+ "caption": _CAP_WIDE,
158
+ "preset": "facebook", "check": "facebook",
159
+ },
160
+ "podcast": {
161
+ "title": "Podcast (audio)",
162
+ "frame": None,
163
+ "fps": None,
164
+ "spec": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None,
165
+ "codecs": None, "max_bytes": None,
166
+ "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
167
+ "safe": _NO_SAFE,
168
+ "caption": _CAP_WIDE,
169
+ "preset": None, "check": "podcast",
170
+ },
171
+ # Not destinations an app owns, but compliance targets check.py has always had.
172
+ "broadcast": {
173
+ "title": "Broadcast (EBU R128)",
174
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
175
+ "fps": None,
176
+ "spec": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60,
177
+ "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None,
178
+ "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
179
+ "safe": _SAFE_5,
180
+ "caption": _CAP_WIDE,
181
+ "preset": "prores", "check": "broadcast",
182
+ },
183
+ "custom": {
184
+ "title": "Custom",
185
+ "frame": None,
186
+ "fps": None,
187
+ "spec": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None,
188
+ "codecs": None, "max_bytes": None,
189
+ "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
190
+ "safe": _NO_SAFE,
191
+ "caption": _CAP_WIDE,
192
+ "preset": None, "check": "custom",
193
+ },
194
+ }
195
+
196
+ # The canonical destination names.
197
+ PLATFORM_NAMES: List[str] = sorted(PLATFORMS)
198
+ # The spellings people actually write for those destinations. resolve() maps them onto the
199
+ # canonical name, and every tool's --platform/--safe/--preset accepts both, so
200
+ # `--platform youtube-shorts` and `--platform shorts` are the same request everywhere.
201
+ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "yt": "youtube",
202
+ "instagram": "reels", "ig": "reels", "twitter": "x", "fb": "facebook"}
203
+ # One vocabulary for the word "platform": check.py, caption.py, graphics.py and look.py all
204
+ # offer this list (review 12 found three different ones). It is the compliance targets -- the
205
+ # destinations a delivery is checked against -- plus every alias; youtube-hdr and youtube-av1
206
+ # are export presets of the youtube target, not separate destinations, so they are not in it.
207
+ PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
208
+
209
+
210
+ def has_frame(name: str) -> bool:
211
+ """True when this destination has a frame, and therefore a safe zone to place text inside."""
212
+ return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
213
+
214
+
215
+ def spec_of(name: str) -> Dict[str, Any]:
216
+ """check.py's compliance row values for a destination."""
217
+ return dict(PLATFORMS[name]["spec"])
218
+
219
+
220
+ def safe_of(name: str) -> Dict[str, float]:
221
+ return dict(PLATFORMS[name]["safe"])
222
+
223
+
224
+ def loudness_of(name: str) -> Dict[str, float]:
225
+ s = PLATFORMS[name]["spec"]
226
+ return {"lufs": s["lufs"], "lufs_tol": s["lufs_tol"], "tp": s["tp"]}
227
+
228
+
229
+ def safe_margins_px(name: str, width: int, height: int) -> Dict[str, int]:
230
+ """The safe zone in pixels for a frame of this size, as whole pixels per edge."""
231
+ s = PLATFORMS[name]["safe"]
232
+ return {"top": int(round(s["top"] * height)), "bottom": int(round(s["bottom"] * height)),
233
+ "left": int(round(s["left"] * width)), "right": int(round(s["right"] * width))}
234
+
235
+
236
+ def caption_defaults(name: str) -> Dict[str, Any]:
237
+ """caption.py flag values for this destination: --size/--margin in ASS units."""
238
+ cap = dict(PLATFORMS[name]["caption"])
239
+ safe = PLATFORMS[name]["safe"]
240
+ edge = safe["top"] if cap["position"].startswith("top") else safe["bottom"]
241
+ cap["size"] = ass_units(cap["size"])
242
+ cap["margin"] = ass_units(edge)
243
+ return cap
244
+
245
+
246
+ def resolve(name: Optional[str]) -> Optional[str]:
247
+ """Accept the spellings people write for a destination ('youtube-shorts', 'ig')."""
248
+ if not name:
249
+ return None
250
+ key = str(name).strip().lower()
251
+ return ALIASES.get(key, key)
package/scripts/audio.py CHANGED
@@ -7,9 +7,12 @@ drops the picture, so `audio.py talk.mp4 -o talk.wav` is an extraction.
7
7
  Examples:
8
8
  python3 audio.py interview.mp4 --denoise # FFT noise reduction
9
9
  python3 audio.py interview.mp4 --voice # highpass + de-esser + compressor + denoise
10
+ python3 audio.py interview.mp4 --voice light # highpass + gentle compression only (light|medium|strong)
10
11
  python3 audio.py talk.mp4 --music bed.mp3 --duck # music under speech, auto-ducked
11
12
  python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --music-fade-out 3 # bed fades, voice does not
12
13
  python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
14
+ python3 audio.py talk.mp4 --music bed.mp3 --duck --duck-threshold -30 --duck-release 250 # ducks earlier and recovers faster
15
+ python3 audio.py band.wav --stereo-widen 0.5 -o wide.wav # wider stereo image (a real stereo source; mono is refused)
13
16
  python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
14
17
  python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
15
18
  python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
@@ -18,12 +21,29 @@ Examples:
18
21
  """
19
22
  import argparse
20
23
  import sys
21
- from typing import List
24
+ from typing import Any, Dict, List
22
25
 
23
26
  from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
24
27
 
25
28
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
26
29
 
30
+ # --voice [light|medium|strong] (1.13). "medium" is the chain --voice has always produced, so a
31
+ # bare --voice (and every existing call and MCP request) is byte-identical to before. "light"
32
+ # leaves the noise floor and the sibilance alone -- it only removes rumble and evens the level,
33
+ # which is what a good room recording needs; "strong" is for phone/laptop audio: a harder
34
+ # de-esser, a second compression stage and a soft limiter at -1 dBFS so the peaks stop there
35
+ # instead of at whatever the make-up gain produced.
36
+ VOICE_LEVELS = {
37
+ "light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
38
+ "medium": VOICE_CHAIN,
39
+ "strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled",
40
+ }
41
+
42
+ # The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
43
+ # sidechaincompress takes; -26.0206 dBFS is that same number in the unit the flag speaks, so the
44
+ # default command line is unchanged to the byte while the value is now sayable.
45
+ DUCK_THRESHOLD_DB = -26.0206
46
+
27
47
  # Typed dynamics: every flag maps to one real option of one ffmpeg filter, validated against the
28
48
  # range that filter documents (ffmpeg -h filter=acompressor / alimiter / agate). dB flags are
29
49
  # converted to the linear value the filter takes, so no string reaches the graph unchecked.
@@ -79,13 +99,23 @@ def main() -> int:
79
99
  clean = ap.add_argument_group("clean-up")
80
100
  clean.add_argument("--denoise", action="store_true", help="FFT noise reduction (afftdn, adaptive)")
81
101
  clean.add_argument("--denoise-strength", type=float, default=25.0, help="noise floor in dB to remove, 10..60 (default 25)")
82
- clean.add_argument("--voice", action="store_true", help="speech preset: highpass 80 Hz, de-esser, denoise, gentle compression")
102
+ clean.add_argument("--voice", nargs="?", const="medium", choices=["light", "medium", "strong"], default=None,
103
+ help="speech preset (default medium when the flag is given bare): light = highpass 80 Hz + gentle compression; "
104
+ "medium = highpass, de-esser, denoise, gentle compression; strong = medium plus a harder de-esser, a second "
105
+ "compressor and a soft limiter at -1 dBFS. MCP/JSON callers may still send the 1.12 boolean true, "
106
+ "which is the bare flag and so means medium")
83
107
  clean.add_argument("--gain", type=float, help="gain in dB applied to the main track")
84
108
  music = ap.add_argument_group("music")
85
109
  music.add_argument("--music", help="music file to mix underneath")
86
110
  music.add_argument("--music-volume", type=float, default=-14.0, help="music level in dB relative to full scale (default -14)")
87
111
  music.add_argument("--duck", action="store_true", help="auto-duck the music when the main track has speech (sidechain compressor)")
88
112
  music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB to duck (default 12)")
113
+ music.add_argument("--duck-threshold", type=float, default=DUCK_THRESHOLD_DB,
114
+ help="sidechain threshold in dBFS: the main track is heard as speech above this (default -26.02, the 0.05 linear used since 1.4)")
115
+ music.add_argument("--duck-attack", type=float, default=20.0, help="ms the bed takes to duck once speech starts (default 20)")
116
+ music.add_argument("--duck-release", type=float, default=400.0, help="ms the bed takes to come back up after speech (default 400)")
117
+ music.add_argument("--effects", help="a third track (sound effects/atmos) mixed in at --effects-volume; never ducked")
118
+ music.add_argument("--effects-volume", type=float, default=-14.0, help="effects level in dB relative to full scale (default -14)")
89
119
  music.add_argument("--music-loop", action="store_true", help="loop the music if shorter than the video")
90
120
  fades = ap.add_argument_group("fades / layout")
91
121
  fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
@@ -94,6 +124,10 @@ def main() -> int:
94
124
  channels = fades.add_mutually_exclusive_group()
95
125
  channels.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
96
126
  channels.add_argument("--mono", action="store_true", help="force 1-channel output")
127
+ fades.add_argument("--stereo-widen", type=float, default=None, metavar="AMOUNT",
128
+ help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
129
+ "refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
130
+ "channels are refused unless --downmix folds them to stereo first")
97
131
  fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
98
132
  fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
99
133
  dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
@@ -124,6 +158,26 @@ def main() -> int:
124
158
  if not getattr(args, switch) and any(getattr(args, f) is not None for f in DYNAMICS[flag_group]):
125
159
  die(f"--{switch} is off but one of its parameters was given; add --{switch}")
126
160
 
161
+ # Same rule as the typed dynamics above, for the ducking knobs: a parameter for a switch that
162
+ # is off does nothing, and a caller who says --duck-release 250 and gets the default 400 ms has
163
+ # no way to notice. --duck itself needs a bed to duck.
164
+ duck_params = [f"--duck-{name}" for name in ("threshold", "attack", "release")
165
+ if getattr(args, f"duck_{name}") != ap.get_default(f"duck_{name}")] + \
166
+ (["--duck-amount"] if args.duck_amount != ap.get_default("duck_amount") else [])
167
+ if not args.duck and duck_params:
168
+ die(f"--duck is off but {duck_params[0]} was given; add --duck")
169
+ if args.duck and not args.music:
170
+ die("--duck ducks the music bed under the main track, but no --music was given; add --music FILE")
171
+
172
+ for flag, value, lo, hi in (("--duck-amount", args.duck_amount, 0.0, 60.0),
173
+ ("--duck-threshold", args.duck_threshold, -60.0, 0.0),
174
+ ("--duck-attack", args.duck_attack, 0.01, 2000.0),
175
+ ("--duck-release", args.duck_release, 0.01, 9000.0)):
176
+ if not (lo <= value <= hi):
177
+ die(f"{flag} {value:g} is outside {lo:g}..{hi:g} (the range ffmpeg's sidechaincompress accepts)")
178
+ if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
179
+ die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
180
+
127
181
  meta = probe(args.input)
128
182
  dur = meta.get("duration") or 0.0
129
183
  has_video = bool(meta.get("video"))
@@ -136,6 +190,19 @@ def main() -> int:
136
190
  die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
137
191
  if args.audio_stream and not streams and not STATE.dry_run:
138
192
  die("--audio-stream needs an input with audio streams")
193
+ in_channels = (meta.get("audio") or {}).get("channels") or 0
194
+ if args.stereo_widen is not None:
195
+ if args.mono:
196
+ die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
197
+ # Widening scales the side signal (L-R). Duplicating a mono track to two channels leaves
198
+ # L == R, so the side signal is exactly zero and scaling it changes nothing: --stereo is
199
+ # not a way in, it is a way to a file that measures mono no matter the amount asked for.
200
+ if in_channels == 1:
201
+ die("--stereo-widen needs a real stereo source: mono has no stereo image to widen; keep it mono or "
202
+ "use --stereo to duplicate it, but widening needs a real stereo source")
203
+ if in_channels > 2 and not args.downmix:
204
+ die(f"--stereo-widen needs a stereo track; this input has {in_channels} channels. Add --downmix to fold it "
205
+ "to stereo first (the widening then happens after the downmix), or leave the channels alone.")
139
206
 
140
207
  inputs: List[str] = ["-i", args.input]
141
208
  main_src = f"0:a:{args.audio_stream}"
@@ -150,7 +217,7 @@ def main() -> int:
150
217
  if args.downmix:
151
218
  fx.append("pan=stereo|FL=0.707*FC+FL+0.5*BL+0.5*SL+0.5*LFE|FR=0.707*FC+FR+0.5*BR+0.5*SR+0.5*LFE")
152
219
  if args.voice:
153
- fx.append(VOICE_CHAIN)
220
+ fx.append(VOICE_LEVELS[args.voice])
154
221
  elif args.denoise:
155
222
  if not 10 <= args.denoise_strength <= 60:
156
223
  die(f"--denoise-strength must be 10..60 (dB of noise floor to remove), got {args.denoise_strength:g}")
@@ -172,6 +239,11 @@ def main() -> int:
172
239
  # 1 channel: already mono; the stereo pan used to halve it (-6 dB) because c1 was silence
173
240
  elif args.stereo:
174
241
  fx.append("aformat=channel_layouts=stereo")
242
+ if args.stereo_widen is not None:
243
+ # extrastereo widens by scaling the side (L-R) signal: m=1 is the input, m=3 is as wide
244
+ # as it goes before the centre collapses. It runs after the channel layout is settled, so
245
+ # a --downmix 5.1 source is widened on the stereo fold-down rather than on six channels.
246
+ fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
175
247
 
176
248
  graph: List[str] = []
177
249
  graph.append(f"[{main_src}]{','.join(fx) if fx else 'anull'}[main]")
@@ -192,13 +264,26 @@ def main() -> int:
192
264
  if args.duck:
193
265
  graph.append("[main]asplit=2[mainA][sc]")
194
266
  graph.append(
195
- f"[music][sc]sidechaincompress=threshold=0.05:ratio={max(2.0, args.duck_amount / 3):.1f}:attack=20:release=400:makeup=1[ducked]"
267
+ f"[music][sc]sidechaincompress=threshold={db_to_linear(args.duck_threshold):.6g}"
268
+ f":ratio={max(2.0, args.duck_amount / 3):.1f}:attack={args.duck_attack:g}:release={args.duck_release:g}:makeup=1[ducked]"
196
269
  )
197
270
  graph.append("[mainA][ducked]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
198
271
  else:
199
272
  graph.append("[main][music]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
200
273
  last = "mix"
201
274
 
275
+ if args.effects:
276
+ # A third bed, mixed in at its own level and deliberately never ducked: effects are cut
277
+ # to the picture, so dipping them under speech would move them off their own frames.
278
+ probe(args.effects)
279
+ inputs += ["-i", args.effects]
280
+ e = f"{idx}:a:0"
281
+ idx += 1
282
+ efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
283
+ graph.append(f"[{e}]{','.join(efx)}[effects]")
284
+ graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
285
+ last = "mixfx"
286
+
202
287
  post: List[str] = []
203
288
  if args.fade_in:
204
289
  post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
@@ -237,7 +322,16 @@ def main() -> int:
237
322
  die(f"{output} unexpectedly contains a video stream")
238
323
  info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
239
324
  + (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
240
- emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
325
+ audio_block: Dict[str, Any] = {"voice": args.voice, "stereo_widen": args.stereo_widen,
326
+ "effects": bool(args.effects), "effects_volume": args.effects_volume if args.effects else None}
327
+ if args.music:
328
+ audio_block["music_volume"] = args.music_volume
329
+ audio_block["duck"] = ({"amount_db": args.duck_amount, "threshold_db": round(args.duck_threshold, 4),
330
+ "threshold_linear": float(f"{db_to_linear(args.duck_threshold):.6g}"),
331
+ "ratio": round(max(2.0, args.duck_amount / 3), 1),
332
+ "attack_ms": args.duck_attack, "release_ms": args.duck_release}
333
+ if args.duck else None)
334
+ emit(output, audio=audio_block, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
241
335
  dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
242
336
  dropped_non_av_streams=dropped_streams)
243
337
  return 0
@@ -40,6 +40,7 @@ import unicodedata
40
40
  from pathlib import Path
41
41
  from typing import List, Optional, Tuple
42
42
 
43
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
43
44
  from _common import STATE, brand_states_font, char_script, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
44
45
 
45
46
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
@@ -759,7 +760,10 @@ def main() -> int:
759
760
  sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
760
761
  sty.add_argument("--bold", action="store_true")
761
762
  sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
762
- sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
763
+ sty.add_argument("--margin", type=int, default=None, help="vertical margin from the edge in ASS units (default 30, or the --platform safe zone)")
764
+ sty.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
765
+ help="keep the captions out of this destination's UI: the margin becomes the platform's safe "
766
+ "zone (TikTok's description bar, the Reels/Shorts chrome). An explicit --margin/--position wins")
763
767
  sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
764
768
  sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
765
769
  sty.add_argument("--min-duration", type=float, default=1.0, help="shortest time a cue stays on screen in seconds, never past the next cue (default 1.0)")
@@ -792,6 +796,18 @@ def main() -> int:
792
796
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
793
797
  args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
794
798
  args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
799
+ # --platform: the margin is the fraction of the frame that platform's own UI covers
800
+ # (scripts/_platforms.py). An explicit --margin is the more specific statement and wins;
801
+ # without either, the historical default 30 is unchanged.
802
+ # every tool resolves the spellings people write ('youtube-shorts' is 'shorts') in one place
803
+ args.platform = resolve_platform(args.platform)
804
+ if args.margin is None and args.platform and PLATFORMS[args.platform].get("frame"):
805
+ edge = PLATFORMS[args.platform]["safe"]["top" if args.position.startswith("top") else "bottom"]
806
+ args.margin = ass_units(edge)
807
+ info(f"--platform {args.platform}: caption margin {args.margin} ASS units ({edge * 100:.0f}% of the frame height, "
808
+ f"clear of the app's own UI)")
809
+ if args.margin is None:
810
+ args.margin = 30
795
811
  # a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
796
812
  # to be applied anyway and then refused as "animation is burn only" -- ignore it there
797
813
  args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
package/scripts/check.py CHANGED
@@ -10,7 +10,8 @@ row's `fix` is the command that resolves it; a few of the less obvious FAILs
10
10
  not a restatement of the spec value -- for a caller reporting this to someone
11
11
  who doesn't already know why the spec says what it says.
12
12
 
13
- Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128), podcast, custom
13
+ Platforms: youtube, shorts, reels, tiktok, x, linkedin, facebook, broadcast (EBU R128),
14
+ podcast, custom -- one table, shared with export.py and the render.py templates
14
15
 
15
16
  Examples:
16
17
  python3 check.py final.mp4 --platform youtube
@@ -25,19 +26,15 @@ import sys
25
26
  from fractions import Fraction
26
27
  from typing import Any, Dict, List
27
28
 
29
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
28
30
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
29
31
 
30
- SPECS: Dict[str, Dict[str, Any]] = {
31
- "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
32
- "shorts": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
33
- "reels": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
34
- "tiktok": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
35
- "x": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
36
- "linkedin": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
37
- "broadcast": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60, "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None, "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
38
- "podcast": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
39
- "custom": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
40
- }
32
+ # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
33
+ # render.py templates all read it, so a platform's loudness spec is stated once. Only the
34
+ # destinations that are compliance targets appear here; youtube-hdr / youtube-av1 are export
35
+ # presets of the youtube target, not separate specs.
36
+ SPECS: Dict[str, Dict[str, Any]] = {name: spec_of(name) for name in sorted(PLATFORMS)
37
+ if PLATFORMS[name]["check"] == name}
41
38
 
42
39
 
43
40
  def measure_loudness(path: str) -> Dict[str, float]:
@@ -66,7 +63,7 @@ def aspect_name(w: int, h: int) -> str:
66
63
  def main() -> int:
67
64
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
68
65
  ap.add_argument("input")
69
- ap.add_argument("--platform", choices=sorted(SPECS), default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
66
+ ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
70
67
  ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
71
68
  ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
72
69
  ap.add_argument("--lufs", type=float, help="override loudness target")
@@ -81,7 +78,7 @@ def main() -> int:
81
78
  # spent a paragraph explaining why they left them alone. Without a named platform the
82
79
  # judgement rows are advisory: WARN, not FAIL, and not counted as failed.
83
80
  named = args.platform is not None
84
- args.platform = args.platform or "youtube"
81
+ args.platform = resolve_platform(args.platform) or "youtube"
85
82
  spec = dict(SPECS[args.platform])
86
83
  if args.max_duration is not None:
87
84
  spec["max_duration"] = args.max_duration
@@ -134,10 +131,10 @@ def main() -> int:
134
131
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
135
132
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
136
133
  if spec["codecs"]:
137
- row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube").replace("broadcast", "prores"),
134
+ row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + (PLATFORMS[args.platform].get("preset") or "youtube"),
138
135
  reason="the platform's player may refuse to decode this codec at all, not just look worse")
139
136
  pf = v.get("pix_fmt") or ""
140
- if args.platform in ("reels", "tiktok", "x", "linkedin"):
137
+ if args.platform in ("reels", "tiktok", "x", "linkedin", "facebook"):
141
138
  row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
142
139
  reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
143
140
  if spec["sdr_only"] and v.get("hdr"):
@@ -162,6 +159,21 @@ def main() -> int:
162
159
 
163
160
  if a:
164
161
  row("audio", "PASS", f"{a.get('codec')} {a.get('channels')}ch {a.get('sample_rate')}Hz", "present")
162
+ if args.platform == "podcast":
163
+ # Podcast rows, informational: neither can fail a delivery, both are things a
164
+ # publisher notices after the fact. A 5.1 podcast master is the common one -- every
165
+ # player downmixes it, none of them the same way, and the centre-heavy dialogue
166
+ # comes back at a level nobody checked.
167
+ ch = a.get("channels") or 0
168
+ row("channels", "PASS" if ch in (1, 2) else "WARN", f"{ch}ch", "1 (mono) or 2 (stereo)",
169
+ "audio.py --downmix (5.1/7.1 to stereo with the standard weights) or audio.py --mono",
170
+ reason="podcast players downmix 5.1 unpredictably")
171
+ if args.platform == "podcast":
172
+ chapters = meta.get("chapters") or []
173
+ row("chapters", "PASS" if chapters else "WARN", f"{len(chapters)}" if chapters else "none", ">= 1 chapter marker",
174
+ "metadata.py episode.m4a --chapters chapters.txt (`TIME TITLE` per line; streams copied)",
175
+ reason="chapter markers are optional, but a podcast app shows them as the episode's seekable table of contents")
176
+ if a:
165
177
  if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
166
178
  row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
167
179
  if not args.no_loudness and spec["lufs"] is not None: