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.
- package/README.md +4 -3
- package/SKILL.md +1 -1
- package/docs/contract.md +53 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -1
- package/references/scripts.md +34 -8
- package/scripts/_common/__init__.py +9 -7
- package/scripts/_common/drawtext.py +65 -6
- package/scripts/_common/emit.py +154 -2
- package/scripts/_common/runner.py +155 -33
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +59 -8
- package/scripts/color.py +142 -41
- package/scripts/overlay.py +66 -22
- package/scripts/render.py +91 -31
package/scripts/_contract.py
CHANGED
|
@@ -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":
|
|
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
|
|
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\":
|
|
1693
|
-
|
|
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
|
-
|
|
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 = {}
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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(
|
|
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
|
|
414
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
371
415
|
return 0
|
|
372
416
|
|
|
373
417
|
|