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.
- package/README.md +72 -32
- package/SKILL.md +51 -43
- package/bin/install.js +1 -1
- package/docs/contract.md +66 -9
- package/package.json +4 -2
- package/references/gotchas.md +23 -0
- package/references/scripts.md +160 -14
- package/scripts/_common.py +6 -3
- package/scripts/_contract.py +17 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +17 -1
- package/scripts/check.py +28 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +93 -8
- package/scripts/look.py +35 -0
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +37 -13
- package/scripts/render.py +376 -39
- package/scripts/report.py +73 -1
- package/templates/facebook.json +47 -0
- package/templates/linkedin.json +47 -0
- package/templates/podcast.json +22 -0
- package/templates/reels.json +47 -0
- package/templates/shorts.json +47 -0
- package/templates/tiktok.json +47 -0
- package/templates/x.json +47 -0
- package/templates/youtube-shorts.json +47 -0
- package/templates/youtube.json +47 -0
|
@@ -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",
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
package/scripts/caption.py
CHANGED
|
@@ -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=
|
|
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),
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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=
|
|
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.
|
|
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:
|