ffmpeg-skill 2.10.10 → 2.11.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.
@@ -185,7 +185,10 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
185
185
  video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
186
186
  "color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
187
187
  required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
188
- {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
188
+ {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": "bsf:h264_metadata", "when": "--retag on H.264 (without it the video is re-encoded)"},
189
+ {"capability": "bsf:hevc_metadata", "when": "--retag on HEVC (without it the video is re-encoded)"},
190
+ {"capability": "bsf:mpeg2_metadata", "when": "--retag on MPEG-2 (without it the video is re-encoded)"}, {"capability": "bsf:av1_metadata", "when": "--retag on AV1 (without it the video is re-encoded)"},
191
+ {"capability": "bsf:vp9_metadata", "when": "--retag on VP9 (without it the video is re-encoded)"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
189
192
  {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
190
193
  {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
191
194
  {"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
@@ -240,6 +243,7 @@ DRY_RUN_ANALYSIS = {
240
243
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
241
244
  "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
242
245
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
246
+ "overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
243
247
  }
244
248
  DRY_RUN_NOTES = {
245
249
  "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
@@ -287,7 +291,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
287
291
  "loudness": dict(video="never", audio="always"),
288
292
  "silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
289
293
  "join": dict(video="always", audio="always"),
290
- "color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails; --to-sdr / --lut / --correct always re-encode both"),
294
+ "color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails or ffprobe still reads the old tags on the copy (a codec with no metadata bitstream filter here); --to-sdr / --lut / --correct always re-encode both"),
291
295
  "proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
292
296
  "export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
293
297
  "check": dict(video="never", audio="never", note="read-only, no artifact"),
@@ -393,6 +397,37 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
393
397
  return schema
394
398
 
395
399
 
400
+ # The collector's records (docs/design-decisions.md, "Nothing happens silently"): on success and
401
+ # failure documents alike, present only when one list is not empty.
402
+ DIAGNOSTICS_SCHEMA = {
403
+ "type": "object",
404
+ "description": "what the run decided on its own, recorded as it happened (each also a stderr line): warnings (something went "
405
+ "other than asked), fallbacks (another route was taken and the run went on), skipped (a step that would "
406
+ "normally run did not). Each entry is {code, message, ...facts}; an entry a render.py stage reported carries "
407
+ "`stage`. Absent when all three are empty",
408
+ "properties": {k: {"type": "array", "items": {"type": "object", "properties": {"code": {"type": "string"}, "message": {"type": "string"}}}}
409
+ for k in ("warnings", "fallbacks", "skipped")},
410
+ }
411
+
412
+ # error.reason: a stable code for why a refusal happened, beside `kind` (which says what class of
413
+ # failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
414
+ ERROR_REASONS = {
415
+ "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
416
+ "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
417
+ "output_is_directory": "the output path is a directory; details: {output}",
418
+ "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
419
+ "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
420
+ "output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
421
+ "plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
422
+ "inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
423
+ "sha256_head_tail} | \"missing\"}], unbound_inputs, audit_log}",
424
+ "plan_version_unsupported": "render.py PLAN: plan_version is not one this version executes; details: {plan, plan_sha256, checked_at, plan_version, supported, audit_log}",
425
+ "plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
426
+ "plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
427
+ "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
428
+ }
429
+
430
+
396
431
  def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
397
432
  """What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
398
433
  tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
@@ -407,7 +442,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
407
442
  "hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
408
443
  "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
409
444
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
410
- "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets) or check (render)"}}
445
+ "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets) or check (render)"},
446
+ "diagnostics": DIAGNOSTICS_SCHEMA}
411
447
  extra: Dict[str, Any] = {}
412
448
  if name == "check":
413
449
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
@@ -444,11 +480,11 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
444
480
  "sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given"}}
445
481
  elif name == "look":
446
482
  extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
447
- elif name == "overlay":
448
- extra = {"notes": {"type": "array", "items": {"type": "string"}, "description": "a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0, whose drawtext draws a box at each line break (fixed in 8.0.2 and 8.1.1)"}}
449
483
  elif name == "render":
450
484
  extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
451
485
  "plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
486
+ "plan_sha256": {"type": ["string", "null"], "description": "plan execution: sha256 of the plan file as executed"},
487
+ "unbound_inputs": {"type": "array", "items": {"type": "string"}, "description": "plan execution: inputs that did not exist when the plan was made, so carry no fingerprint and were not checked (also a diagnostics.warnings entry, code plan_unbound_inputs)"},
452
488
  "tool": {"type": "string", "description": "plan execution: the tool the plan ran"},
453
489
  "tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
454
490
  "timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
@@ -473,7 +509,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
473
509
  elif name == "color":
474
510
  extra = {"dropped_non_av_streams": {"type": "boolean"},
475
511
  "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
476
- "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
512
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr: which path was taken and why; --retag: why the video was re-encoded (the stream copy failed, or ffprobe still read the old tags on it)"},
513
+ "reencoded": {"type": "boolean", "description": "--retag: true when the stream copy could not carry the tags and the video was re-encoded with them on its frames"},
477
514
  "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
478
515
  elif name == "loop":
479
516
  extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
@@ -1689,8 +1726,22 @@ def build(detect: bool = True) -> Dict[str, Any]:
1689
1726
  },
1690
1727
  "json_output": {
1691
1728
  "success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
1692
- "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
1693
- "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
1729
+ "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ..., \"code\": ..., \"retryable\": false}, \"commands\": [...]} when --json was given; a refusal adds error.reason (error_reasons) and error.details (the facts the message is written from), and a run that recorded warnings, fallbacks or skipped steps before failing carries diagnostics", "stderr": "human-readable message",
1730
+ "error": {"kind": "error_kinds", "message": "the reason in prose", "code": "a static relabelling of kind", "retryable": "always false",
1731
+ "hint": "optional: the flag change that would make a retry meaningful",
1732
+ "reason": "optional, on refusals: a stable code from error_reasons",
1733
+ "details": "optional, with reason: an object of the facts behind the refusal (paths, expected and actual values)"},
1734
+ "diagnostics": DIAGNOSTICS_SCHEMA["description"]},
1735
+ "error_reasons": ERROR_REASONS,
1736
+ "audit_log": {"format": "JSON lines, appended: {ts, tool, event, pid, plan_sha256 (plan events), details}",
1737
+ "events": {"plan_rejected": "render.py refused a plan (details: the refusal's error.details plus reason)",
1738
+ "lock_takeover": "a stale output lock was taken over (details: lock, output, pid, pid_alive, deadline, why)",
1739
+ "output_unlocked": "no output lock could be created and the run went on without one",
1740
+ "output_is_input_refused": "the output-is-input refusal (only when an audit log was named)"},
1741
+ "location": "render.py --audit-log FILE when given; otherwise a refused plan's <plan>.audit.jsonl, and a lock event's "
1742
+ "hidden .<output>.ffskill-audit.jsonl beside the output. Never written under --dry-run; a write that "
1743
+ "fails is a diagnostics.warnings entry (audit_log_unwritable), never a failure"},
1744
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
1694
1745
  "success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
1695
1746
  },
1696
1747
  "capabilities": caps,
package/scripts/color.py CHANGED
@@ -18,11 +18,12 @@ Examples:
18
18
  python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
19
19
  """
20
20
  import argparse
21
+ import json
21
22
  import os
22
23
  import sys
23
- from typing import List
24
+ from typing import List, Optional
24
25
 
25
- from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
26
+ from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, ffmpeg_exe, info, probe, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
26
27
 
27
28
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
28
29
 
@@ -193,6 +194,144 @@ def is_bt2020_sdr(v: dict) -> bool:
193
194
  and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
194
195
 
195
196
 
197
+ # --retag targets: ffprobe's names (colorspace, primaries, transfer) and the ITU-T H.273 code points
198
+ # h264_metadata/hevc_metadata write into the VUI (matrix_coefficients, colour_primaries,
199
+ # transfer_characteristics).
200
+ RETAG = {
201
+ "bt709": (("bt709", "bt709", "bt709"), (1, 1, 1)),
202
+ "bt2020-pq": (("bt2020nc", "bt2020", "smpte2084"), (9, 9, 16)),
203
+ "bt2020-hlg": (("bt2020nc", "bt2020", "arib-std-b67"), (9, 9, 18)),
204
+ "bt601": (("smpte170m", "smpte170m", "smpte170m"), (6, 6, 6)),
205
+ }
206
+ # codecs whose colour description lives in the bitstream, and the bitstream filter that rewrites it
207
+ # without touching a coded picture (each in every FFmpeg from 5.0: tests/fixtures/ffmpeg_bsfs_*)
208
+ RETAG_BSF = {"h264": "h264_metadata", "hevc": "hevc_metadata", "mpeg2video": "mpeg2_metadata",
209
+ "av1": "av1_metadata", "vp9": "vp9_metadata"}
210
+ # VP9 has only a colour space (its primaries and transfer are the container's); vp9_metadata's names
211
+ VP9_SPACE = {"bt709": "bt709", "smpte170m": "smpte170", "bt2020nc": "bt2020"}
212
+
213
+
214
+ def retag_bsf(meta: dict, target: str) -> List[str]:
215
+ """`-bsf:v:0 <codec>_metadata=...` writing `target`'s colour description into the first video
216
+ stream's own headers (H.264/HEVC VUI, MPEG-2 sequence display extension, AV1 sequence header,
217
+ VP9 frame header), or [] when the codec has no such filter (ProRes, DNxHD, MPEG-4 Part 2 ...)
218
+ or this build lacks it.
219
+
220
+ A stream copy with -colorspace/-color_primaries/-color_trc changes only the container's
221
+ description (mp4 `colr`, Matroska Colour); decoders, ffprobe included, read the stream's own
222
+ description first, so a source that already carried tags there kept them while the tool
223
+ reported success. The filter rewrites those headers and nothing else: the copy stays
224
+ lossless. Where the filter has a range field it gets the source's probed range (pc/tv),
225
+ because h264_metadata/hevc_metadata set it to limited when the VUI had no signal type at all;
226
+ an unknown range is left alone."""
227
+ v = meta["video"]
228
+ codec = v.get("codec") or ""
229
+ bsf = RETAG_BSF.get(codec)
230
+ if not bsf or bsf not in ffmpeg_bsfs():
231
+ return []
232
+ names, (matrix, prim, trc) = RETAG[target]
233
+ rng = v.get("color_range") if v.get("color_range") in ("tv", "pc") else None
234
+ if codec == "vp9":
235
+ opts = f"color_space={VP9_SPACE[names[0]]}" + (f":color_range={rng}" if rng else "")
236
+ elif codec == "av1":
237
+ opts = (f"color_primaries={prim}:transfer_characteristics={trc}:matrix_coefficients={matrix}"
238
+ + (f":color_range={rng}" if rng else ""))
239
+ else:
240
+ opts = f"colour_primaries={prim}:transfer_characteristics={trc}:matrix_coefficients={matrix}"
241
+ if rng and codec != "mpeg2video":
242
+ opts += f":video_full_range_flag={1 if rng == 'pc' else 0}"
243
+ return ["-bsf:v:0", f"{bsf}={opts}"]
244
+
245
+
246
+ def retag_mismatch(output: str, want: tuple) -> "Optional[str]":
247
+ """None when the output carries `want` (colorspace, primaries, transfer), else what was read
248
+ -- the check behind every completed --retag.
249
+
250
+ Two readings, because they disagree: ffprobe's stream tags (from 8.1 the container's `colr`
251
+ wins there over an H.264 VUI that still says bt709, while 6.1 and 8.0 show the VUI) and the
252
+ first decoded frame's, which is what a player or filter graph gets. Each stream tag must be
253
+ the requested one; a frame tag must be the requested one or unspecified (8.1's ProRes decoder
254
+ leaves its frames untagged and players read the container's)."""
255
+ got = probe(output, role="output")["video"]
256
+ stream = (got.get("color_space"), got.get("color_primaries"), got.get("color_transfer"))
257
+ keys = ("color_space", "color_primaries", "color_transfer")
258
+ proc = run_analysis([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_frames",
259
+ "-read_intervals", "%+#1", "-show_entries", "frame=" + ",".join(keys), "-of", "json", output],
260
+ check=False)
261
+ try:
262
+ frames = json.loads(proc.stdout or "{}").get("frames") or [{}]
263
+ except ValueError:
264
+ frames = [{}]
265
+ frame = tuple(frames[0].get(k) for k in keys)
266
+ if stream == tuple(want) and all(f in (None, "unknown", w) for f, w in zip(frame, want)):
267
+ return None
268
+ shown = "/".join(str(t) for t in stream)
269
+ return shown if frame in ((None,) * 3, stream) else f"{shown} (decoded frames: {'/'.join(str(t) for t in frame)})"
270
+
271
+
272
+ def retag(args: argparse.Namespace, meta: dict, has_audio: bool) -> int:
273
+ """--retag: rewrite the colour description, never the pixels.
274
+
275
+ First a stream copy of every stream: the container options plus, where the codec keeps its
276
+ own description, its rewrite (retag_bsf). When the copy fails, or ffprobe still reads other
277
+ tags on its output (a codec with no such filter here), the video is re-encoded with the tags
278
+ put on the frames (`setparams`): from FFmpeg 7.1 the encoder takes them from the frames and the output options feed the
279
+ filter graph's colourspace negotiation, which converted the matrix of a tagged source and left
280
+ its primaries and transfer as they were (measured on 8.0.1 and 8.1.3), while setparams labels
281
+ the frames on 5.x-8.x alike. Every completed run has been checked: ffprobe reads the requested
282
+ tags on the output (`verification`'s `color_tags` step), or the run fails with kind `verification`."""
283
+ names = RETAG[args.retag][0]
284
+ output = args.output or default_output(args.input, "retag")
285
+ ext = os.path.splitext(output)[1].lower()
286
+ tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
287
+ bsf = retag_bsf(meta, args.retag)
288
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy"] + bsf + tags
289
+ if ext in (".mp4", ".mov", ".m4v"):
290
+ cmd += ["-movflags", "+faststart"]
291
+ cmd.append(output)
292
+ proc = run(cmd, check=False)
293
+ notes: List[str] = []
294
+ reencoded = proc.returncode != 0
295
+ if not reencoded and not STATE.dry_run:
296
+ wrong = retag_mismatch(output, names)
297
+ if wrong:
298
+ reencoded = True
299
+ notes.append(f"a stream copy cannot rewrite the colour tags of {meta['video'].get('codec')} here "
300
+ f"(the output still read {wrong}), so the video was re-encoded with the tags on its frames")
301
+ info("note: " + notes[-1])
302
+ elif reencoded:
303
+ notes.append("the stream copy failed, so the video was re-encoded with the tags on its frames")
304
+ dropped_streams = False
305
+ if reencoded:
306
+ # The copy path (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles,
307
+ # chapters, attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same
308
+ # guarantee for subtitle/data streams even though video/audio must be re-encoded. Only if
309
+ # THAT also fails (e.g. a subtitle codec genuinely incompatible with the target container)
310
+ # do we drop to video+selected-audio-only, and even then we say so explicitly rather than
311
+ # silently reporting "completed" with streams missing.
312
+ info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
313
+ label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
314
+ enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
315
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
316
+ "-map", "0:s?", "-map", "0:d?"] + label + enc + ["-c:s", "copy", "-c:d", "copy", output]
317
+ proc2 = run(cmd, check=False)
318
+ if proc2.returncode != 0:
319
+ info("re-encode with subtitles/data streams kept also failed; dropping them")
320
+ dropped_streams = True
321
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + label + enc + [output]
322
+ run(cmd)
323
+ wrong = None if STATE.dry_run else retag_mismatch(output, names)
324
+ if wrong:
325
+ die(f"--retag {args.retag}: the re-encoded output still reads {wrong}, not {'/'.join(names)}",
326
+ kind="verification", hint=f"the output was written to {output} but is not tagged as asked")
327
+ info(f"wrote {output} (tags -> {args.retag})")
328
+ check = [] if STATE.dry_run else [{"step": "color_tags", "ok": True, "color_space": names[0],
329
+ "color_primaries": names[1], "color_transfer": names[2]}]
330
+ emit(output, reencoded=reencoded, dropped_non_av_streams=dropped_streams, verification=check,
331
+ **({"notes": notes} if notes else {}))
332
+ return 0
333
+
334
+
196
335
  def main() -> int:
197
336
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
198
337
  ap.add_argument("input")
@@ -261,45 +400,7 @@ def main() -> int:
261
400
  return 0
262
401
 
263
402
  if args.retag:
264
- tags = {
265
- "bt709": ["bt709", "bt709", "bt709"],
266
- "bt2020-pq": ["bt2020nc", "bt2020", "smpte2084"],
267
- "bt2020-hlg": ["bt2020nc", "bt2020", "arib-std-b67"],
268
- "bt601": ["smpte170m", "smpte170m", "smpte170m"],
269
- }[args.retag]
270
- output = args.output or default_output(args.input, "retag")
271
- ext = os.path.splitext(output)[1].lower()
272
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy",
273
- "-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
274
- if ext in (".mp4", ".mov", ".m4v"):
275
- cmd += ["-movflags", "+faststart"]
276
- cmd.append(output)
277
- proc = run(cmd, check=False)
278
- dropped_streams = False
279
- if proc.returncode != 0:
280
- # Some codecs cannot carry retagged colour info without a bitstream filter, so the
281
- # stream copy above fails and we fall back to re-encoding video+audio. The copy path
282
- # (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
283
- # attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
284
- # for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
285
- # also fails (e.g. a subtitle codec genuinely incompatible with the target container)
286
- # do we drop to video+selected-audio-only, and even then we say so explicitly rather
287
- # than silently reporting "completed" with streams missing.
288
- info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
289
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
290
- "-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
291
- cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
292
- cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
293
- proc2 = run(cmd, check=False)
294
- if proc2.returncode != 0:
295
- info("re-encode with subtitles/data streams kept also failed; dropping them")
296
- dropped_streams = True
297
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + x264_args(args.crf, args.preset, keep_bt709=False)
298
- cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
299
- run(cmd)
300
- info(f"wrote {output} (tags -> {args.retag})")
301
- emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
302
- return 0
403
+ return retag(args, meta, has_audio)
303
404
 
304
405
  measurements = None
305
406
  sdr_extra: dict = {}
@@ -21,11 +21,12 @@ Examples:
21
21
  python3 overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00 --chromakey-similarity 0.15
22
22
  """
23
23
  import argparse
24
+ import re
24
25
  import sys
25
26
  from typing import List, Optional
26
27
 
27
28
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
28
- from _common import STATE, script_font_for_text, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
29
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
29
30
 
30
31
  # Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
31
32
  # like column is 14 % of the width, its description block 22 % of the height), so a position
@@ -87,6 +88,56 @@ def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fad
87
88
  return "max(0," + "*".join(parts) + ")"
88
89
 
89
90
 
91
+ def _num(v: float) -> str:
92
+ """A measured pixel value as an expression literal (no exponent form)."""
93
+ return f"{v:.6f}".rstrip("0").rstrip(".")
94
+
95
+
96
+ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: argparse.Namespace,
97
+ box: List[str], timing: List[str]) -> str:
98
+ """The drawtext chain for a multi-line --text on a release whose drawtext draws a box at each
99
+ line break (drawtext_draws_newline_glyph): one drawtext per line, each line alone, placed
100
+ where one multi-line drawtext puts it on a fixed release.
101
+
102
+ That block's layout is drawtext's own (measure_text/draw_text): text_w is the widest line,
103
+ text_h = lh*(lines-1) + the first line's top_a + the last line's bottom_d, the lines are
104
+ left-aligned at x, and line i's baseline is y + top_a + i*lh. Each line is measured alone
105
+ (drawtext_line_metrics), so the box the bug adds is in no number; x and y keep the caller's
106
+ position expressions with text_w/text_h replaced by the block's values, and each line is
107
+ drawn with y_align=baseline at its own baseline. The outlines of every line are drawn before
108
+ any line's text, as one drawtext draws them, so a descender's outline never covers the next
109
+ line's glyphs; the --box is drawn once, by the first line, at the block's size (boxw/boxh).
110
+ Bit-identical to one multi-line drawtext on 8.1.3 for every named position, with and without
111
+ --box; a font whose glyph boxes are not whole pixels (no hinting) can sit one row lower, since
112
+ drawtext reports top_a rounded up."""
113
+ m = drawtext_line_metrics(lines, font_opt, args.font_size)
114
+ seen = [d for d in m if d]
115
+ lh = seen[0]["lh"]
116
+ width = max(d["w"] for d in seen)
117
+ # an empty first/last line has no glyphs: drawtext's -32000/64 start values, as it measures them
118
+ top = m[0]["top"] if m[0] else -500.0
119
+ bottom = m[-1]["bottom"] if m[-1] else -500.0
120
+ height = lh * (len(lines) - 1) + top + bottom
121
+
122
+ def block(expr: str) -> str:
123
+ return re.sub(r"\btext_h\b", _num(height), re.sub(r"\btext_w\b", _num(width), expr))
124
+
125
+ bx, by = block(x), block(y)
126
+ passes = ([[f"borderw={args.border}", f"bordercolor={args.border_color}", "fontcolor=black@0"],
127
+ ["fontcolor=" + args.font_color]] if args.border else [["fontcolor=" + args.font_color]])
128
+ filters = []
129
+ for n, paint in enumerate(passes):
130
+ for i, line in enumerate(lines):
131
+ if not line:
132
+ continue
133
+ opts = [drawtext_text_opts(line), f"fontsize={args.font_size}", f"x={bx}", f"y={by}+{_num(top + i * lh)}",
134
+ "y_align=baseline", font_opt] + paint + timing[:1]
135
+ if box and n == 0 and not filters:
136
+ opts += box + [f"boxw={_num(width)}", f"boxh={_num(height)}"]
137
+ filters.append("drawtext=" + ":".join(opts + timing[1:]))
138
+ return ",".join(filters)
139
+
140
+
90
141
  def main() -> int:
91
142
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
92
143
  ap.add_argument("input")
@@ -144,7 +195,6 @@ def main() -> int:
144
195
  add_common(ap)
145
196
  args = ap.parse_args()
146
197
  apply_common(args)
147
- notes: List[str] = []
148
198
 
149
199
  brand = load_brand(args.brand)
150
200
  if args.logo:
@@ -335,29 +385,23 @@ def main() -> int:
335
385
  x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
336
386
  # 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
337
387
  # (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
388
+ font_opt = (f"fontfile={escape_filter_path(args.font_file)}" if args.font_file
389
+ else f"font='{escape_drawtext(args.font)}'")
390
+ alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
391
+ timing = ([f"alpha='{alpha}'"] if alpha != "1" else []) + ([f"enable='{enable}'"] if enable else [])
392
+ box = ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"] if args.box else []
338
393
  if is_multiline(args.text) and drawtext_draws_newline_glyph():
339
- # overlay has no libass route to send it to (graphics.py's --text-render auto does)
340
- notes.append("this ffmpeg's drawtext (FFmpeg 8.0, 8.0.1 and 8.1.0; fixed in 8.0.2 and 8.1.1) draws "
341
- "the font's missing-glyph box at the end of every line but the last of a multi-line "
342
- "--text: draw multi-line text with graphics.py or caption.py, or upgrade FFmpeg")
343
- info("warning: " + notes[-1])
344
- opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
345
- f"borderw={args.border}", f"bordercolor={args.border_color}"]
346
- if args.font_file:
347
- opts.append(f"fontfile={escape_filter_path(args.font_file)}")
394
+ # this release's drawtext draws the font's missing-glyph box at every line break and
395
+ # counts it in text_w: one drawtext per line, placed where one block would put it
396
+ drawing = per_line_drawtext(drawtext_clean(args.text).split("\n"), x, y, font_opt, args, box, timing)
348
397
  else:
349
- opts.append(f"font='{escape_drawtext(args.font)}'")
350
- alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
351
- opts.append(f"fontcolor={args.font_color}")
352
- if alpha != "1":
353
- opts.append(f"alpha='{alpha}'")
354
- if args.box:
355
- opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
356
- if enable:
357
- opts.append(f"enable='{enable}'")
398
+ opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
399
+ f"borderw={args.border}", f"bordercolor={args.border_color}", font_opt,
400
+ f"fontcolor={args.font_color}"] + timing[:1] + box + timing[1:]
401
+ drawing = "drawtext=" + ":".join(opts)
358
402
  # a font/border/box colour with chroma lands in the BT.709 the output is tagged with
359
403
  colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
360
- cmd += ["-vf", drawn_chain("drawtext=" + ":".join(opts), meta, colours), "-map", "0:v:0"]
404
+ cmd += ["-vf", drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
361
405
  if meta.get("audio"):
362
406
  cmd += ["-map", f"0:a:{args.audio_stream}"]
363
407
 
@@ -367,7 +411,7 @@ def main() -> int:
367
411
  if not STATE.dry_run:
368
412
  result = probe(output, role="output")
369
413
  info(f"wrote {output} ({fmt_secs(result['duration'])})")
370
- emit(output, dropped_non_av_streams=dropped_streams, **({"notes": notes} if notes else {}))
414
+ emit(output, dropped_non_av_streams=dropped_streams)
371
415
  return 0
372
416
 
373
417