ffmpeg-skill 3.1.7 → 3.2.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/scripts/export.py CHANGED
@@ -15,6 +15,13 @@ Presets:
15
15
  youtube-av1 1080p AV1 (libsvtav1, libaom fallback); missing_tool when neither is built
16
16
  x 1280x720 H.264 CRF 22, AAC 128k, max 140 s (Twitter/X)
17
17
  prores ProRes 422 HQ .mov, PCM 16-bit audio (editing master)
18
+ dnxhr-lb / dnxhr-sq / dnxhr-hq DNxHR 8-bit 4:2:2 .mov, PCM 16-bit audio
19
+ dnxhr-hqx DNxHR HQX 10-bit 4:2:2; dnxhr-444 DNxHR 444 10-bit 4:4:4
20
+ (editing masters at the source's size and rate, like prores)
21
+ key-fill fill + key pair (<out>, <out stem>_key.mov) from an alpha source
22
+ prores4444 / prores4444xq / qtrle / vp9-alpha / hap-alpha
23
+ masters that keep the source's alpha (refused without one);
24
+ --alpha-mode straight|premultiplied; alpha read back after
18
25
  h265 HEVC CRF 24 (libx265) with hvc1 tag for Apple compatibility
19
26
  gif 480px wide palette-optimised GIF at 12 fps (short previews)
20
27
  copy stream copy, no re-encode: same codecs, container and colour
@@ -25,6 +32,7 @@ Examples:
25
32
  python3 export.py final.mp4 --preset reels --fit crop
26
33
  python3 export.py final.mp4 --preset reels --normalize # meet the platform's loudness in the same call
27
34
  python3 export.py final.mp4 --preset prores -o master.mov
35
+ python3 export.py final.mp4 --preset dnxhr-hq -o master.mov
28
36
  python3 export.py final.mp4 --preset copy -o delivered.mp4
29
37
  python3 export.py --list
30
38
  """
@@ -38,11 +46,13 @@ import tempfile
38
46
  from pathlib import Path
39
47
  from typing import Dict, List
40
48
 
49
+ from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
41
50
  from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
42
51
  from _common.decision import hw_preset_video, restate_last_swap
43
52
  from _common.emit import note, record_fallback, record_skip, warn
44
53
  from _common.probe import square_pixels, square_pixels_note
45
- from _common.runner import subtitle_codec_for
54
+ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, run_analysis as _run_analysis, subtitle_codec_for
55
+ from _common.emit import file_identity
46
56
  from check import SPECS as PLATFORMS, measure_loudness
47
57
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
48
58
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -90,7 +100,44 @@ PRESETS: Dict[str, Dict] = {
90
100
  "youtube4k": dict(_from_table("youtube", w=3840, h=2160, max=None), ext="mp4", video=_H264_HIGH, audio=_AAC_192, desc="2160p H.264, AAC 192k"),
91
101
  "reels": dict(_from_table("reels"), ext="mp4", video=_social(), audio=_AAC_128, desc="9:16 1080x1920, 30fps, max 90s (Instagram Reels)"),
92
102
  "x": dict(_from_table("x"), ext="mp4", video=_social("22"), audio=["-c:a", "aac", "-b:a", "128k", "-ar", "44100"], desc="720p H.264, max 140s (Twitter/X)"),
93
- "prores": {"w": None, "h": None, "ext": "mov", "video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"], "audio": ["-c:a", "pcm_s16le"], "max": None, "desc": "ProRes 422 HQ master, PCM audio, source resolution"},
103
+ "prores": {"w": None, "h": None, "ext": "mov", "video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"], "audio": ["-c:a", "pcm_s16le"], "max": None, "intermediate": True,
104
+ "expect": {"codec": "prores", "profile": "HQ", "pix_fmt": "yuv422p10le"}, "desc": "ProRes 422 HQ master, PCM audio, source resolution"},
105
+ # DNxHR (the dnxhd encoder's resolution-independent profiles): editing masters like prores, at
106
+ # the source's size and rate; the written profile and pixel format are checked after the write
107
+ **{f"dnxhr-{name}": {"w": None, "h": None, "ext": "mov",
108
+ "video": ["-c:v", "dnxhd", "-profile:v", f"dnxhr_{name}", "-pix_fmt", pix],
109
+ "audio": ["-c:a", "pcm_s16le"], "max": None, "intermediate": True,
110
+ "expect": {"codec": "dnxhd", "profile": f"DNXHR {name.upper()}", "pix_fmt": pix},
111
+ "desc": f"DNxHR {name.upper()} master ({desc}), PCM audio, source resolution"}
112
+ for name, pix, desc in (("lb", "yuv422p", "8-bit 4:2:2, low bandwidth"), ("sq", "yuv422p", "8-bit 4:2:2, standard"),
113
+ ("hq", "yuv422p", "8-bit 4:2:2, high quality"), ("hqx", "yuv422p10le", "10-bit 4:2:2"),
114
+ ("444", "yuv444p10le", "10-bit 4:4:4"))},
115
+ # Alpha masters (#417): editing masters that keep the source's alpha channel; the alpha is read
116
+ # back from a written frame and compared with the source's. A source without alpha is refused.
117
+ "prores4444": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
118
+ "video": ["-c:v", "prores_ks", "-profile:v", "4", "-vendor", "apl0", "-pix_fmt", "yuva444p10le", "-alpha_bits", "16"],
119
+ "audio": ["-c:a", "pcm_s16le"], "max": None, "expect": {"codec": "prores", "profile": "4444"},
120
+ "desc": "ProRes 4444 with alpha (16-bit alpha), PCM audio, source resolution"},
121
+ "prores4444xq": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
122
+ "video": ["-c:v", "prores_ks", "-profile:v", "5", "-vendor", "apl0", "-pix_fmt", "yuva444p10le", "-alpha_bits", "16"],
123
+ "audio": ["-c:a", "pcm_s16le"], "max": None, "expect": {"codec": "prores", "profile": "XQ"},
124
+ "desc": "ProRes 4444 XQ with alpha (16-bit alpha), PCM audio, source resolution"},
125
+ "qtrle": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
126
+ "video": ["-c:v", "qtrle", "-pix_fmt", "argb"], "audio": ["-c:a", "pcm_s16le"], "max": None,
127
+ "expect": {"codec": "qtrle", "pix_fmt": "argb"}, "desc": "QuickTime Animation (lossless RGB + alpha), PCM audio"},
128
+ "vp9-alpha": {"w": None, "h": None, "ext": "webm", "alpha": True, "intermediate": True,
129
+ "video": ["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-crf", "20", "-b:v", "0", "-row-mt", "1"],
130
+ "audio": ["-c:a", "libopus", "-b:a", "128k"], "max": None, "expect": {"codec": "vp9"},
131
+ "desc": "WebM VP9 with alpha (CRF 20), Opus audio, source resolution"},
132
+ "hap-alpha": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
133
+ "video": ["-c:v", "hap", "-format", "hap_alpha"], "audio": ["-c:a", "pcm_s16le"], "max": None,
134
+ "expect": {"codec": "hap"}, "desc": "HAP Alpha (needs an ffmpeg built with snappy; size a multiple of 4), PCM audio"},
135
+ # Key and fill for a switcher's downstream keyer (#418): two ProRes 422 HQ files from one alpha
136
+ # source, the fill (colour) and the key (the alpha as a luma matte), frame for frame.
137
+ "key-fill": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True, "keyfill": True,
138
+ "video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"],
139
+ "audio": ["-c:a", "pcm_s16le"], "max": None,
140
+ "desc": "fill + key pair (<out> and <out stem>_key.mov), ProRes 422 HQ, frame for frame, for a switcher's keyer"},
94
141
  "h265": {"w": None, "h": None, "ext": "mp4", "video": ["-c:v", "libx265", "-preset", "medium", "-crf", "24", "-pix_fmt", "yuv420p", "-tag:v", "hvc1"], "audio": ["-c:a", "aac", "-b:a", "160k"], "max": None, "desc": "HEVC CRF 24, hvc1 tag, source resolution"},
95
142
  "gif": {"w": 480, "h": None, "ext": "gif", "video": [], "audio": [], "max": None, "desc": "480px palette GIF, 12fps"},
96
143
  "copy": {"w": None, "h": None, "ext": None, "video": ["-c:v", "copy"], "audio": ["-c:a", "copy"], "max": None, "desc": "stream copy, no re-encode (source codecs/container/colour tags unchanged)"},
@@ -125,6 +172,188 @@ PLATFORM_OF["youtube4k"] = PLATFORM_TABLE["youtube"]["check"]
125
172
  PRESET_CHOICES: List[str] = sorted(set(PRESETS) | {a for a in _ALIASES if resolve_platform(a) in PRESETS})
126
173
 
127
174
 
175
+ # A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
176
+ _ALPHA_PIX = re.compile(r"^(yuva|gbrap|ya\d|rgba|bgra|argb|abgr|rgba64|bgra64)")
177
+
178
+
179
+ def vp9_decoder_args(codec: "str | None") -> List[str]:
180
+ """The input option that decodes a VP9 stream with libvpx-vp9: FFmpeg's native vp9 decoder
181
+ drops the alpha layer (a WebM's yuva420p comes out yuv420p, fully opaque). [] for any other
182
+ codec, or when this build has no libvpx (its encoder list stands in for the decoder's)."""
183
+ return ["-c:v", "libvpx-vp9"] if codec == "vp9" and "libvpx-vp9" in ffmpeg_encoders() else []
184
+
185
+
186
+ def vp9_alpha_tagged(path: str) -> bool:
187
+ """True when the first video stream is VP9 stored with an alpha layer (Matroska/WebM's
188
+ ALPHA_MODE=1 stream tag): ffprobe reports such a stream as yuv420p, so the pixel format alone
189
+ does not show the alpha. A metadata read, so a dry run can decide without decoding."""
190
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
191
+ "stream=codec_name:stream_tags", "-of", "json", path], quiet=True)
192
+ try:
193
+ streams = json.loads(proc.stdout or "{}").get("streams") or []
194
+ except ValueError:
195
+ return False
196
+ s = streams[0] if streams else {}
197
+ return s.get("codec_name") == "vp9" and any(k.lower() == "alpha_mode" and str(v).strip() == "1"
198
+ for k, v in (s.get("tags") or {}).items())
199
+
200
+
201
+ _MEASURE_W, _MEASURE_H = 64, 36 # the measuring copy: small enough to read every frame, large enough for exact edges
202
+
203
+
204
+ def _frame_means(cmd_head: List[str], path: str, chain: str) -> "Dict[str, object] | None":
205
+ """Every decoded frame of `path` through `chain` to a 64x36 grey copy: {mean (over every frame),
206
+ min (the lowest frame's mean), frames, per_frame (each frame's mean, in decode order)}. None
207
+ when no frame decodes."""
208
+ w, h = _MEASURE_W, _MEASURE_H
209
+ cmd = cmd_head + ["-i", path, "-map", "0:v:0", "-vf", f"{chain},scale={w}:{h}:flags=area,format=gray",
210
+ "-f", "rawvideo", "-"]
211
+ proc = _run_analysis(cmd, check=False, text=False)
212
+ data = proc.stdout or b""
213
+ size = w * h
214
+ if proc.returncode != 0 or len(data) < size:
215
+ return None
216
+ frames = [sum(data[i:i + size]) / size for i in range(0, len(data) - size + 1, size)]
217
+ return {"mean": sum(frames) / len(frames), "min": min(frames), "frames": len(frames), "per_frame": frames}
218
+
219
+
220
+ def alpha_stats(path: str, codec: "str | None" = None) -> "Dict[str, object] | None":
221
+ """The alpha (0-255) of every decoded frame, on a 64x36 copy: {mean, min, frames, per_frame}.
222
+ 255.0 is fully opaque, and a picture with no alpha plane reads 255 too (format=rgba fills it).
223
+ Every frame, not the first: a clip that opens fully opaque and turns transparent later would
224
+ read as opaque from its first frame, in the source and in a written file that lost its alpha.
225
+ A VP9 file is decoded with libvpx-vp9 (vp9_decoder_args). None when no frame decodes."""
226
+ head = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"] + vp9_decoder_args(codec)
227
+ return _frame_means(head, path, "format=rgba,alphaextract")
228
+
229
+
230
+ ALPHA_TOLERANCE = 2.0 # of 255, per frame (or on the mean and the most transparent frame, below)
231
+ OPAQUE = 254.5 # a frame whose mean alpha is at least this is fully opaque
232
+
233
+
234
+ def _alpha_compare(written, source) -> Dict[str, object]:
235
+ """The written file's alpha against the source's. With the same number of frames, frame by
236
+ frame (frame i against frame i, each within ALPHA_TOLERANCE): an aggregate let a loss confined
237
+ to a few frames through (one of 200 frames turned opaque moves the mean by ~1). With a
238
+ different count (a variable-rate source conformed to a constant rate repeats or drops frames,
239
+ so indices no longer pair), the mean and the most transparent frame, and `paired: false`
240
+ says so. {ok, paired, worst_delta, worst_frame}."""
241
+ if written is None or source is None:
242
+ return {"ok": False, "paired": None, "worst_delta": None, "worst_frame": None}
243
+ w, s = written.get("per_frame") or [], source.get("per_frame") or []
244
+ if w and len(w) == len(s):
245
+ deltas = [abs(a - b) for a, b in zip(w, s)]
246
+ worst = max(range(len(deltas)), key=deltas.__getitem__)
247
+ return {"ok": deltas[worst] <= ALPHA_TOLERANCE, "paired": True, "worst_delta": round(deltas[worst], 2),
248
+ "worst_frame": worst}
249
+ delta = max(abs(written["mean"] - source["mean"]), abs(written["min"] - source["min"]))
250
+ return {"ok": delta <= ALPHA_TOLERANCE, "paired": False, "worst_delta": round(delta, 2), "worst_frame": None}
251
+
252
+
253
+ def _rounded(st) -> "Dict[str, float] | None":
254
+ return None if st is None else {"mean": round(st["mean"], 2), "min": round(st["min"], 2), "frames": st["frames"]}
255
+
256
+
257
+ def key_path(fill: str) -> str:
258
+ stem, _ = os.path.splitext(fill)
259
+ return f"{stem}_key.mov"
260
+
261
+
262
+ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[str], has_audio: bool,
263
+ source_alpha, notes: List[str], subs: List[Dict], data: List[Dict]) -> int:
264
+ """--preset key-fill: one decode split into the fill (the colour, straight or premultiplied)
265
+ and the key (alphaextract: alpha 255 is video white, 0 black), both ProRes 422 HQ in one ffmpeg
266
+ run so they hold the same frames; the source's start timecode is written on both. Checked
267
+ after: the same frame count, size, rate and length, the key's mean level against the source's
268
+ alpha, and both files hashed (output_identity, key.identity)."""
269
+ key = key_path(output)
270
+ # both files are this run's outputs, but ffmpeg's last argument (which run() guards and stages)
271
+ # is only the key: the fill is checked here the same way -- not the input under another
272
+ # spelling, not someone's file without --overwrite -- and written through a hidden temp file
273
+ guard_sidecar(output, args.input, siblings=[key])
274
+ guard_sidecar(key, args.input, siblings=[output])
275
+ if STATE.hw and "-c:v" in p["video"]:
276
+ note(f"--hw: --preset {args.preset} stays on {p['video'][p['video'].index('-c:v') + 1]}: one ffmpeg run writes "
277
+ "the fill and the key as a pair, and VideoToolbox's ProRes encoder is not used for it")
278
+ fill_chain = ",".join(vf + (["premultiply=inplace=1"] if args.alpha_mode == "premultiplied" else []) + ["format=yuv422p10le"])
279
+ key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
280
+ tc = ((meta.get("timecode") or {}).get("value"))
281
+ tc_args = ["-timecode", tc] if tc else []
282
+ cmd = cmd + ["-filter_complex", f"[0:v:0]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
283
+ cmd += ["-map", "[fill]"] + (["-map", "0:a:0"] if has_audio else []) + p["video"] + tc_args + cfr_args(meta)
284
+ # the fill is always written to a hidden sibling and moved into place only after the encode
285
+ # succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
286
+ # left a partial new fill (the retry was refused as output_exists) or truncated an existing one
287
+ fill_tmp = None
288
+ if not STATE.dry_run:
289
+ d, base = os.path.split(output)
290
+ stem, ext = os.path.splitext(base)
291
+ fill_tmp = os.path.join(d, f".{stem}.ffskill-fill-{os.getpid()}{ext}")
292
+ cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
293
+ cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
294
+ try:
295
+ run(cmd)
296
+ if fill_tmp:
297
+ os.replace(fill_tmp, output)
298
+ finally:
299
+ if fill_tmp:
300
+ # the command is recorded with the path the caller asked for, not the temp name
301
+ STATE.commands[:] = [c.replace(fill_tmp, output) for c in STATE.commands]
302
+ if os.path.exists(fill_tmp):
303
+ os.remove(fill_tmp)
304
+ remember_written(output, key)
305
+ extra: Dict[str, object] = {"alpha_mode": args.alpha_mode or "straight", "key": {"output": key}}
306
+ # a fill and a key for a switcher carry the picture and the sound; the source's subtitle and
307
+ # data streams are not written, and the document says so (as for a GIF)
308
+ non_av = _non_av_report(subs, [], data, ["subtitle", "data"], output, "key_fill")
309
+ extra["non_av_streams"] = non_av
310
+ extra["dropped_non_av_streams"] = bool(non_av["dropped"])
311
+ if non_av["dropped"]:
312
+ notes.append(f"a key/fill pair holds the picture and the sound only: the source's {len(non_av['dropped'])} "
313
+ "subtitle/data stream(s) were not written; non_av_streams lists them")
314
+ if STATE.dry_run:
315
+ emit(output, **extra, **({"notes": notes} if notes else {}))
316
+ return 0
317
+ fill_m, key_m = probe(output, role="output"), probe(key, role="output")
318
+ fv, kv = fill_m["video"], key_m["video"]
319
+ pair = {k: [fv.get(k), kv.get(k)] for k in ("width", "height", "nb_frames", "r_frame_rate")}
320
+ # the video streams' lengths: the fill's container also counts its sound, which may run longer
321
+ # than the picture, and the key has none
322
+ pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
323
+ same = all(a == b and a not in (None, 0) for a, b in pair.values())
324
+ steps = [{"step": "key_fill_pair", "property": "frames_size_rate_length", "ok": same, "measured": pair,
325
+ "provenance": "output_measurement"}]
326
+ key_level = _luma_stats(key)
327
+ level = _alpha_compare(key_level, source_alpha)
328
+ steps.append({"step": "key_level", "property": "key_mean_vs_source_alpha", "ok": level["ok"], "comparison": level,
329
+ "expected": None if source_alpha is None else round(source_alpha["mean"], 2),
330
+ "measured": None if key_level is None else round(key_level["mean"], 2), "tolerance": ALPHA_TOLERANCE,
331
+ "frames": {"expected": _rounded(source_alpha), "measured": _rounded(key_level)},
332
+ "provenance": "input_premeasurement"})
333
+ extra["key"] = {"output": key, "identity": file_identity(key), "sha256": _sha256(key), "timecode": tc}
334
+ extra["fill_sha256"] = _sha256(output)
335
+ info(f"wrote {output} (fill) and {key} (key), {fv['width']}x{fv['height']}, {fv.get('nb_frames')} frames each")
336
+ emit(output, verification=steps, expect_streams={"video": 1, "audio": 1 if has_audio else 0}, **extra,
337
+ **({"notes": notes} if notes else {}))
338
+ return 0
339
+
340
+
341
+ def _sha256(path: str) -> str:
342
+ """The whole file's SHA-256: a key/fill pair is handed to a playout system as two files that
343
+ must stay a pair, and the hashes let it check that neither was replaced."""
344
+ import hashlib
345
+ h = hashlib.sha256()
346
+ with open(path, "rb") as fh:
347
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
348
+ h.update(chunk)
349
+ return h.hexdigest()
350
+
351
+
352
+ def _luma_stats(path: str) -> "Dict[str, float] | None":
353
+ """The level (0-255) of every frame of a file, on a 64x36 grey copy: {mean, min, frames}."""
354
+ return _frame_means([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"], path, "format=gray")
355
+
356
+
128
357
  def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False) -> None:
129
358
  """The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse): the
130
359
  platform's loudness was not verified, and the document says so -- a `loudness` block with null
@@ -183,6 +412,9 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
183
412
  attempt = run(cmd + extra + [output], check=False)
184
413
  if attempt.returncode == 0:
185
414
  return dropped
415
+ # a full disk or a missing encoder fails every rung the same way: that error, not a
416
+ # fallback that blames the subtitles and encodes again (#415, #389's rule)
417
+ fail_if_classified(attempt)
186
418
  error = " | ".join([ln.strip() for ln in (attempt.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
187
419
  going = rungs[n + 1][1]
188
420
  record_fallback(f"{os.path.basename(output)} could not hold the source's {' and '.join(t for t in ('subtitle', 'data') if t in going)} "
@@ -311,6 +543,9 @@ def main() -> int:
311
543
  help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
312
544
  "running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
313
545
  ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
546
+ ap.add_argument("--alpha-mode", choices=["straight", "premultiplied"],
547
+ help="alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): write the colour straight "
548
+ "(default, as decoded) or premultiplied by the alpha; written to the result as alpha_mode")
314
549
  ap.add_argument("--list", action="store_true", help="list presets and exit")
315
550
  add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
316
551
  args = ap.parse_args()
@@ -340,14 +575,39 @@ def main() -> int:
340
575
  die(f"--preset {args.preset} delivers HDR and this source is SDR ({meta['video'].get('codec')}, "
341
576
  f"{meta['video'].get('color_transfer') or 'untagged'})",
342
577
  hint="use --preset youtube for an SDR delivery; there is no way to invent HDR range from an SDR master")
343
- if meta["video"].get("bt2020_or_hdr") and args.preset not in ("prores", "copy", "youtube-hdr"):
578
+ if meta["video"].get("bt2020_or_hdr") and not p.get("intermediate") and args.preset not in ("copy", "youtube-hdr"):
344
579
  # 2.0: `hdr` is a real PQ/HLG/DV signal; bt2020_or_hdr also covers BT.2020 SDR, which is
345
580
  # not HDR but still needs converting before BT.709 tags describe it (eval 24 h1)
346
581
  kind = "HDR" if meta["video"].get("hdr") else "wide-gamut SDR, not HDR"
347
582
  notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
348
583
  warn(notes[-1], "colour_not_converted", preset=args.preset, hdr_format=meta["video"].get("hdr_format"))
349
584
  has_audio = bool(meta.get("audio"))
350
- output = args.output or default_output(args.input, args.preset, p["ext"])
585
+ source_alpha = None
586
+ if p.get("alpha"):
587
+ encoder = p["video"][p["video"].index("-c:v") + 1]
588
+ encoders = ffmpeg_encoders()
589
+ if encoders and encoder not in encoders:
590
+ die(f"--preset {args.preset} needs the {encoder} encoder and this ffmpeg build has none"
591
+ + (" (HAP needs an ffmpeg built with snappy)" if encoder == "hap" else ""),
592
+ kind="missing_tool", error_extra={"reason": "encoder_missing", "details": {"name": encoder}},
593
+ hint="python3 scripts/_contract.py doctor lists the encoders this build has")
594
+ if encoder == "hap" and (meta["video"]["width"] % 4 or meta["video"]["height"] % 4):
595
+ die(f"--preset hap-alpha needs a width and height that are multiples of 4 (HAP's texture blocks); "
596
+ f"this source is {meta['video']['width']}x{meta['video']['height']}",
597
+ hint="crop or pad it first (crop.py, pad.py)")
598
+ # a pixel format with an alpha plane, or a VP9 stream stored with one (probed as yuv420p);
599
+ # a dry run decides on these metadata reads alone and decodes nothing
600
+ has_alpha_plane = bool(_ALPHA_PIX.match(meta["video"].get("pix_fmt") or "")) or vp9_alpha_tagged(args.input)
601
+ if not STATE.dry_run:
602
+ source_alpha = alpha_stats(args.input, meta["video"].get("codec"))
603
+ if not has_alpha_plane and (STATE.dry_run or source_alpha is None or source_alpha["min"] >= OPAQUE):
604
+ die(f"--preset {args.preset} keeps an alpha channel and this source has none "
605
+ f"({meta['video'].get('pix_fmt')}, fully opaque): use prores or dnxhr-* for an opaque master")
606
+ elif args.alpha_mode:
607
+ die("--alpha-mode applies only to the alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha)")
608
+ # copy keeps the source's container, so its default name keeps the source's extension (as
609
+ # metadata.py's does): default_output()'s .webm -> .mp4 is for the re-encoding presets
610
+ output = args.output or default_output(args.input, args.preset, p["ext"] or Path(args.input).suffix.lstrip(".") or None)
351
611
  out_ext = Path(output).suffix.lstrip(".").lower()
352
612
 
353
613
  vf: List[str] = []
@@ -376,7 +636,12 @@ def main() -> int:
376
636
  if args.max_mb is not None and args.preset == "gif":
377
637
  # the GIF branch below returns before the size is ever applied or measured
378
638
  die("--max-mb runs a two-pass x264 encode; --preset gif does not encode with x264 (shorten or shrink the GIF instead)")
379
- cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
639
+ if args.max_mb is not None and p.get("keyfill"):
640
+ # the key/fill branch below writes ProRes and returns before the size is applied or measured
641
+ die("--max-mb runs a two-pass x264 encode; --preset key-fill writes ProRes (a fill and a key), which has no size target")
642
+ # an alpha source in VP9 is decoded with libvpx-vp9, or its alpha never reaches the encoder
643
+ dec_args = vp9_decoder_args(meta["video"].get("codec")) if p.get("alpha") else []
644
+ cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + dec_args + ["-i", args.input]
380
645
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
381
646
  # kept where the container can hold them: ffmpeg's default stream selection takes one video
382
647
  # and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
@@ -384,7 +649,7 @@ def main() -> int:
384
649
  subs = meta.get("subtitle_stream_details") or []
385
650
  data = _data_streams(args.input) if meta.get("data_streams") else []
386
651
  sub_codecs = [_subtitle_codec(s.get("codec") or "", out_ext) for s in subs]
387
- if args.preset != "gif" and (subs or data):
652
+ if args.preset != "gif" and not p.get("keyfill") and (subs or data):
388
653
  cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
389
654
 
390
655
  if args.preset == "gif":
@@ -399,6 +664,10 @@ def main() -> int:
399
664
  emit(output, non_av_streams=non_av, dropped_non_av_streams=bool(non_av["dropped"]), **({"notes": notes} if notes else {}))
400
665
  return 0
401
666
 
667
+ if p.get("keyfill"):
668
+ return _key_fill(args, p, meta, output, cmd, vf, has_audio, source_alpha, notes, subs, data)
669
+ if p.get("alpha") and args.alpha_mode == "premultiplied":
670
+ vf.append("premultiply=inplace=1")
402
671
  if vf:
403
672
  cmd += ["-vf", ",".join(vf)]
404
673
  video = list(p["video"])
@@ -419,9 +688,14 @@ def main() -> int:
419
688
  video[video.index("-crf") + 1] = str(args.crf)
420
689
  if STATE.fast and "-preset" in video and not p.get("codec"):
421
690
  video[video.index("-preset") + 1] = "veryfast"
422
- # a fixed preset's line is tagged BT.709 (prores, an editing master, keeps the source's tags)
423
- tag_bt709 = args.preset not in ("prores",) and not p.get("codec")
424
- if not p.get("codec") and video:
691
+ # a fixed preset's line is tagged BT.709 (prores and dnxhr, editing masters, keep the source's tags)
692
+ tag_bt709 = not p.get("intermediate") and not p.get("codec")
693
+ if p.get("alpha") and STATE.hw and video and video[video.index("-c:v") + 1] == "prores_ks":
694
+ # VideoToolbox's ProRes line is 422 HQ: under --hw a 4444 alpha master would come out
695
+ # opaque, so these presets stay on prores_ks and say so
696
+ note(f"--hw: --preset {args.preset} stays on prores_ks: VideoToolbox's ProRes encoder writes 422 HQ, "
697
+ "without the alpha this preset keeps")
698
+ elif not p.get("codec") and video:
425
699
  # --hw (never $FFMPEG_SKILL_HW here) puts a fixed preset on VideoToolbox: the same codec,
426
700
  # its CRF mapped to -q:v, the preset's frame rate kept; tags come with the VT line itself.
427
701
  # A build without libx264/libx265 has no other encoder, so it goes there without --hw too
@@ -437,8 +711,8 @@ def main() -> int:
437
711
  cmd += cfr_args(meta)
438
712
  if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
439
713
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
440
- if args.preset != "prores":
441
- # every line but the prores master's says BT.709 (the fixed presets' tags above, an
714
+ if not p.get("intermediate"):
715
+ # every line but an editing master's (prores, dnxhr) says BT.709 (the fixed presets' tags above, an
442
716
  # SDR hevc/av1 line's own): a BT.601 source's pixels are converted to match, in front
443
717
  # of the -vf above (run() folds the two); an untagged, BT.709 or HDR source adds nothing
444
718
  cmd += bt709_matrix_args(meta)
@@ -466,10 +740,36 @@ def main() -> int:
466
740
  dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
467
741
  if not (subs or data):
468
742
  run(cmd + [output])
743
+ if not STATE.dry_run:
744
+ # the export is on disk: a stop in the reads and the --normalize pass that follow keeps it,
745
+ # and says so (#415)
746
+ mark_delivered(output)
469
747
  result = probe(output, role="output")
470
748
  v = result["video"]
471
749
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
472
750
  extra: Dict[str, object] = {"trimmed": trimmed} if trimmed else {}
751
+ encoder = video[video.index("-c:v") + 1] if "-c:v" in video else None
752
+ if p.get("expect") and not STATE.dry_run and encoder == p["video"][p["video"].index("-c:v") + 1]:
753
+ # an editing master is asked for by codec, profile and pixel format: read all three back
754
+ # (a VideoToolbox prores line under --hw writes its own pixel format, so it is not compared)
755
+ measured = {k: v.get(k) for k in p["expect"]}
756
+ extra.setdefault("verification", []).append(
757
+ {"step": "codec", "property": "codec_profile_pix_fmt", "ok": measured == p["expect"],
758
+ "expected": dict(p["expect"]), "measured": measured, "provenance": "specification"})
759
+ if p.get("alpha"):
760
+ extra["alpha_mode"] = args.alpha_mode or "straight"
761
+ if not STATE.dry_run:
762
+ # the alpha survived when every written frame's alpha matches the source frame's (an
763
+ # encoder, decoder or muxer that drops it reads fully opaque); premultiplying changes
764
+ # colour, not alpha
765
+ written_alpha = alpha_stats(output, v.get("codec"))
766
+ cmp = _alpha_compare(written_alpha, source_alpha)
767
+ extra.setdefault("verification", []).append(
768
+ {"step": "alpha", "property": "alpha_mean", "ok": cmp["ok"], "comparison": cmp,
769
+ "expected": None if source_alpha is None else round(source_alpha["mean"], 2),
770
+ "measured": None if written_alpha is None else round(written_alpha["mean"], 2), "tolerance": ALPHA_TOLERANCE,
771
+ "frames": {"expected": _rounded(source_alpha), "measured": _rounded(written_alpha)},
772
+ "provenance": "input_premeasurement"})
473
773
  platform = PLATFORM_OF.get(args.preset)
474
774
  if platform and has_audio and not STATE.dry_run:
475
775
  # Every eval run that exported for a platform then had to come back with loudness.py: the
@@ -508,7 +808,12 @@ def main() -> int:
508
808
  err = child.get("error") or {}
509
809
  if os.path.exists(tmp):
510
810
  os.remove(tmp)
511
- die(f"--normalize: loudness.py failed: {err.get('message') or proc.stderr.strip()[-300:]}",
811
+ # the child's "nothing was written" is about its own temp file; this run's export is
812
+ # on disk, and the child's exit code (124 timeout, 130/143 interrupted) is the
813
+ # run's, as render.py and waveform.py pass a stage's on (#415)
814
+ why = (err.get("message") or proc.stderr.strip()[-300:]).replace("; nothing was written", "")
815
+ die(f"--normalize: loudness.py failed: {why} ({kept_clause()})",
816
+ code=child_exit_code(child, proc.returncode) if proc.returncode else 1,
512
817
  kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
513
818
  os.replace(tmp, output)
514
819
  m = measure_loudness(output)
package/scripts/look.py CHANGED
@@ -8,6 +8,7 @@ Examples:
8
8
  python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
9
9
  python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
10
10
  python3 look.py reel.mp4 --safe tiktok --at 3 # shade what TikTok's own UI covers
11
+ python3 look.py show.mp4 --guides title,action,4:3 --at 10 # broadcast safe-area outlines
11
12
  Then view the PNG (Read tool / image viewer) and verify before reporting.
12
13
  """
13
14
  import argparse
@@ -25,6 +26,36 @@ from _common.emit import note, record_fallback # noqa: E402
25
26
  from _common.color import bt709_label_filter # noqa: E402
26
27
  from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
27
28
 
29
+ # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
30
+ # Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
31
+ # is the 4:3 window of a 16:9 frame with SD's own 90 % action / 80 % title safe inside it.
32
+ GUIDES = {"action": 0.93, "title": 0.90}
33
+ GUIDE_43 = {"action": 0.90, "title": 0.80}
34
+ GUIDE_COLOURS = {"action": "yellow", "title": "cyan", "4:3": "white"}
35
+
36
+
37
+ def guide_boxes(names) -> "tuple":
38
+ """drawbox outlines for --guides and the fractions they stand for (the result's `guides`)."""
39
+ t = "max(1\\,ih/270)"
40
+ boxes, described = [], []
41
+ for name in names:
42
+ if name in GUIDES:
43
+ f = GUIDES[name]
44
+ m = (1 - f) / 2
45
+ boxes.append(f"drawbox=x=iw*{m:g}:y=ih*{m:g}:w=iw*{f:g}:h=ih*{f:g}:color={GUIDE_COLOURS[name]}@0.9:t={t}")
46
+ described.append({"guide": name, "fraction": f, "of": "frame"})
47
+ else: # "4:3": the centre-cut window, then its own action and title safe
48
+ w = "ih*4/3"
49
+ boxes.append(f"drawbox=x=(iw-{w})/2:y=0:w={w}:h=ih:color=white@0.9:t={t}")
50
+ for sub, f in GUIDE_43.items():
51
+ m = (1 - f) / 2
52
+ boxes.append(f"drawbox=x=(iw-{w})/2+{w}*{m:g}:y=ih*{m:g}:w={w}*{f:g}:h=ih*{f:g}:"
53
+ f"color={GUIDE_COLOURS[sub]}@0.6:t={t}")
54
+ described.append({"guide": "4:3", "window": "centre 4:3 of the frame", "action": GUIDE_43["action"],
55
+ "title": GUIDE_43["title"]})
56
+ return boxes, described
57
+
58
+
28
59
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
29
60
 
30
61
 
@@ -157,6 +188,10 @@ def main() -> int:
157
188
  ap.add_argument("--no-timecode", action="store_true")
158
189
  ap.add_argument("--safe", choices=PLATFORM_CHOICES, help="shade the zones this platform's UI covers (its description bar, "
159
190
  "like column, status bar) so you can see whether anything readable is under them")
191
+ ap.add_argument("--guides", metavar="LIST",
192
+ help="draw broadcast safe-area outlines on the PNG (a preview only; nothing is written into a video): "
193
+ "a comma list of action (93%%), title (90%%) and 4:3 (the 4:3 centre cut of a 16:9 frame, "
194
+ "with its 90%% action and 80%% title safe)")
160
195
  ap.add_argument("--ink", action="store_true", help="measure non-background pixels in each written PNG (bbox, "
161
196
  "ink_fraction, row_bands per tile for a contact sheet) instead of "
162
197
  "just eyeballing it -- a pixel-only signal, not a legibility judgement")
@@ -217,6 +252,17 @@ def main() -> int:
217
252
  safe_filter = "," + ",".join(boxes) if boxes else ""
218
253
  info(f"--safe {args.safe}: shaded top {z['top'] * 100:.0f}% / bottom {z['bottom'] * 100:.0f}% / "
219
254
  f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
255
+ guides = None
256
+ if args.guides:
257
+ names = [g.strip() for g in args.guides.split(",") if g.strip()]
258
+ bad = [g for g in names if g not in ("action", "title", "4:3")]
259
+ if bad or not names:
260
+ die(f"--guides takes action, title and 4:3, got {args.guides!r}")
261
+ dw, dh = display_size(meta["video"])
262
+ if "4:3" in names and dw and dh and dw / dh < 4 / 3 + 0.01:
263
+ die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; this source is {dw}x{dh}")
264
+ boxes, guides = guide_boxes(list(dict.fromkeys(names)))
265
+ safe_filter += "," + ",".join(boxes)
220
266
  tc = safe_filter + tc
221
267
  # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
222
268
  if meta["video"].get("bt2020_or_hdr"):
@@ -242,6 +288,11 @@ def main() -> int:
242
288
  if not args.at:
243
289
  die("--compare needs --at TIME")
244
290
  cmeta = probe(args.compare)
291
+ if guides and any(g.get("guide") == "4:3" for g in guides):
292
+ # the guides are drawn on both pictures: the second must be wider than 4:3 as well
293
+ cw, ch = display_size(cmeta.get("video") or {})
294
+ if cw and ch and cw / ch < 4 / 3 + 0.01:
295
+ die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; the --compare clip is {cw}x{ch}")
245
296
  lab2 = bt709_label_filter(cmeta)
246
297
  lab2 = lab2 + "," if lab2 else ""
247
298
  csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
@@ -256,15 +307,18 @@ def main() -> int:
256
307
  out = os.path.join(outdir, f"{stem_part}_vs_{Path(args.compare).stem}_{idx}_{sec:.3f}s.png")
257
308
  half = args.width // 2
258
309
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
259
- tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
310
+ # the tone map and the --safe / --guides boxes go on each picture as scaled, before the
311
+ # pad: drawn after it, a picture letterboxed in the box got guides from the padded
312
+ # height (a 2:1 clip beside a 16:9 one); the timecode stamp is drawn on the box
313
+ pre = tc.replace("," + timecode_filter(font_prefix), "")
260
314
  # each picture keeps its own aspect inside a half-width box as tall as the taller of the
261
315
  # two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
262
316
  # second's size, so a 4:3 beside a 16:9 came out squashed (#411)
263
317
  box_h = max(_half_height(half, display_size(meta["video"])),
264
318
  _half_height(half, display_size(cmeta.get("video"))))
265
- fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease,"
266
- f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1")
267
- fc = (f"[0:v]{lab}{sq}{fit}{tcs}[a];[1:v]{lab2}{csq}{fit}{tcs}[b];"
319
+ fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{pre},"
320
+ f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
321
+ fc = (f"[0:v]{lab}{sq}{fit}[a];[1:v]{lab2}{csq}{fit}[b];"
268
322
  f"[a][b]hstack=inputs=2[out]")
269
323
  cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
270
324
  "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
@@ -341,7 +395,8 @@ def main() -> int:
341
395
  else:
342
396
  ink = {"frames": [measure_ink(o) for o in outputs]}
343
397
 
344
- emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs, **({"ink": ink} if ink is not None else {}))
398
+ emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs, **({"ink": ink} if ink is not None else {}),
399
+ **({"guides": guides} if guides else {}))
345
400
  if len(outputs) > 1 and not args.json:
346
401
  for o in outputs:
347
402
  print(o)