ffmpeg-skill 2.10.0 → 2.10.1

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 CHANGED
@@ -248,7 +248,7 @@ These are the rules the skill file gives the agent and the code enforces.
248
248
 
249
249
  | Tool | What it does |
250
250
  |---|---|
251
- | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR; `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
251
+ | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
252
252
  | `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
253
253
  | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row |
254
254
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.10.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.10.1`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.10.0", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.10.1", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -217,6 +217,10 @@ BT.709 for SDR; av1 is SVT-AV1 with libaom as the fallback; prores is 422 HQ and
217
217
  its default is the tool's own (18, `proxy.py` 30). 1.x also accepted `--crf` as an alias; 2.0
218
218
  removed it (`export.py` keeps its own `--crf`). Without `--codec` the encoder is what it always was (x264 for SDR, x265
219
219
  Main10 for HDR), so the flags add no behaviour to a caller that does not pass them.
220
+ Every SDR line except prores is tagged BT.709; a source whose matrix is BT.601 (`smpte170m`,
221
+ `bt470bg`, or `fcc`/`smpte240m`) is converted to it in the filter graph (`colorspace`, matrix
222
+ only; `_common.bt709_matrix_filter()`), so the label and the pixels agree. An untagged or BT.709
223
+ source adds no filter, and the tags themselves never convert (`bt709_tag_args()`).
220
224
  `cut.py --keep-hevc` (opt-in) re-encodes an SDR HEVC source as x265 8-bit BT.709 instead of x264; an HDR or
221
225
  BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
222
226
  encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -8,7 +8,10 @@ detail, not an aside.
8
8
  ## Colour, HDR and Log
9
9
 
10
10
  ### HDR and colour
11
- SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py` reports
11
+ SDR outputs are H.264 tagged BT.709 `yuv420p`. A source tagged with a BT.601 matrix
12
+ (`smpte170m`, `bt470bg`; also `fcc`, `smpte240m`) has its pixels converted to that BT.709 matrix
13
+ by every re-encode (through 2.10.0 only the label changed); `color.py --retag bt709` is a
14
+ stream copy and still only relabels. Untagged sources are taken as BT.709 and left alone. When `probe.py` reports
12
15
  `bt2020_or_hdr: true` (HDR10/PQ, HLG, Dolby Vision, or BT.2020 SDR), every editing script keeps
13
16
  the output on the 10-bit path (HEVC Main10, source colour tags) so nothing is silently
14
17
  flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
@@ -1615,7 +1615,7 @@ export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook
1615
1615
  [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
1616
1616
  export.py --list
1617
1617
  ```
1618
- Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
1618
+ Scales into the preset frame (pad by default), tags BT.709 (a BT.601-matrix source is converted to it, as by every re-encode), sets `+faststart`,
1619
1619
  trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
1620
1620
  does not touch levels: for youtube / youtube4k / reels / x the written file is
1621
1621
  measured and the result's `loudness` (and a `notes` line) says when it is
@@ -83,7 +83,7 @@ from _common.decision import (
83
83
  _onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
84
84
  brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
85
85
  fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
86
- signed_time_arg, source_codec_video_args, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
86
+ signed_time_arg, source_codec_video_args, matrix_chain, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
87
87
  )
88
88
  from _common.asr import (
89
89
  ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
@@ -91,7 +91,8 @@ from _common.asr import (
91
91
  whisper_word_timings, write_srt
92
92
  )
93
93
  from _common.color import (
94
- bt709_tag_args, color_hex, _COLOR_TOKEN_RE, _sdr_bt709, validate_color
94
+ bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, color_hex, _COLOR_TOKEN_RE, fold_bt709_matrix,
95
+ NON_BT709_MATRICES, _sdr_bt709, validate_color
95
96
  )
96
97
  from _common.text import (
97
98
  ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
@@ -178,7 +179,7 @@ __all__ = [
178
179
  "platform", "re", "shutil", "subprocess", "sys", "unicodedata",
179
180
  "aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
180
181
  "audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
181
- "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
182
+ "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "_char_em",
182
183
  "char_script", "_check_existing_output", "refuse_existing_outputs", "_check_no_overwrite_input", "_check_output_path", "child_args",
183
184
  "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
184
185
  "concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
@@ -196,12 +197,12 @@ __all__ = [
196
197
  "_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
197
198
  "escape_filter_path", "EVEN_SCALE", "_execute", "_fail", "_family_rank", "FC_LANG", "_fc_list_fonts",
198
199
  "FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_VERSION", "ffmpeg_version", "fingerprint",
199
- "flush_drawtext_textfiles", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
200
+ "flush_drawtext_textfiles", "fold_bt709_matrix", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
200
201
  "font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
201
202
  "fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
202
203
  "is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
203
204
  "keyframes_near", "classify_frame_timing", "measure_frame_timing", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
204
- "load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
205
+ "load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS", "NON_BT709_MATRICES",
205
206
  "_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
206
207
  "place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
207
208
  "print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
@@ -212,7 +213,7 @@ __all__ = [
212
213
  "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
213
214
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
214
215
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
215
- "source_codec_video_args", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
216
+ "source_codec_video_args", "matrix_chain", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
216
217
  "_x264_raw", "_ZWJ",
217
218
  "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
218
219
  "_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
@@ -4,7 +4,7 @@ validation for the tools that take a `--color`.
4
4
  from __future__ import annotations
5
5
 
6
6
  import re
7
- from typing import List, Tuple
7
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
8
8
  from _common.emit import die
9
9
  from _common.runner import ffmpeg_version
10
10
 
@@ -18,9 +18,13 @@ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
18
18
  screen recordings, many cameras) the CLI now auto-inserts a *real* matrix conversion (its
19
19
  guess for "unknown" is bt601) into every SDR re-encode: a --lut-strength 0 no-op grade
20
20
  came back ~24 dB PSNR from its source on 7.1. From 7.1 on, the tags therefore go through
21
- the encoder's own VUI parameters instead, which libavfilter never sees; a source that is
22
- genuinely tagged bt601/bt2020 is left alone either way (it keeps its own tags on the old
23
- path, and the encoder VUI is a label, not a conversion, on the new one).
21
+ the encoder's own VUI parameters instead, which libavfilter never sees.
22
+
23
+ These tags are a label on every FFmpeg version: they never convert the pixels. A source
24
+ whose matrix is not BT.709 (a BT.601 `smpte170m`/`bt470bg` file) gets its pixels converted
25
+ separately, by bt709_matrix_filter() in the filter graph; through 2.10.0 nothing did, and
26
+ such a source came out labelled BT.709 over BT.601 pixels (measured on 6.1: 48.5 dB YUV
27
+ PSNR against the source, i.e. untouched, tagged bt709/bt709/bt709).
24
28
  """
25
29
  if ffmpeg_version() < (7, 1):
26
30
  return ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
@@ -42,6 +46,101 @@ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
42
46
  return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
43
47
 
44
48
 
49
+ # The SDR matrices (ffprobe's names) whose pixels an output tagged BT.709 has to convert:
50
+ # BT.601 (`smpte170m` 525-line, `bt470bg` 625-line: the same coefficients) and the two rare
51
+ # pre-BT.709 ones the colorspace filter also reads. bt2020nc/c sources take the HDR/BT.2020 path.
52
+ NON_BT709_MATRICES = ("smpte170m", "bt470bg", "fcc", "smpte240m")
53
+
54
+ _MATRIX_FILTER_RE = re.compile(r"^colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709$"
55
+ % "|".join(NON_BT709_MATRICES))
56
+ _INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
57
+
58
+
59
+ def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
60
+ """The filter that turns the probed source's pixels into BT.709 ones before an output is
61
+ tagged BT.709, or None when nothing needs converting: an SDR source tagged with a non-BT.709
62
+ matrix (NON_BT709_MATRICES) only. An untagged or BT.709 source, an HDR/BT.2020 one (it keeps
63
+ its own tags, or color.py --to-sdr converts it) and a missing probe get None, so their
64
+ commands are what they were.
65
+
66
+ The matrix is converted and nothing else: primaries and transfer are passed through
67
+ (iprimaries/itrc = the output's), so the picture a player that decodes with the source's
68
+ matrix showed is the picture the BT.709 output shows. The `colorspace` filter is in every
69
+ FFmpeg build (no zscale needed) and takes explicit input properties, so the conversion does
70
+ not depend on how a build reads or negotiates the frames' own tags. The range is left as it is."""
71
+ v = (meta or {}).get("video") or {}
72
+ matrix = v.get("color_space")
73
+ if v.get("bt2020_or_hdr") or matrix not in NON_BT709_MATRICES:
74
+ return None
75
+ return f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
76
+
77
+
78
+ def bt709_matrix_args(meta: Optional[Dict[str, Any]]) -> List[str]:
79
+ """["-vf", bt709_matrix_filter(meta)] for an encoder line that tags its output BT.709, or [].
80
+
81
+ It rides on the encoder line (decision.video_args() and friends) so every tool that
82
+ re-encodes through that line converts without building it into its own graph. That -vf is
83
+ valid as it stands for a command with no other filter; run() folds it into the tool's own
84
+ -vf or -filter_complex (fold_bt709_matrix()) before the command is recorded or run."""
85
+ f = bt709_matrix_filter(meta)
86
+ return ["-vf", f] if f else []
87
+
88
+
89
+ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
90
+ """`cmd` with the conversion bt709_matrix_args() appended to its encoder line moved into the
91
+ tool's own filter graph, where it runs first, on the decoded source:
92
+
93
+ - no other filter: unchanged (the -vf is the graph);
94
+ - a -vf: the conversion is prepended to that chain;
95
+ - a -filter_complex: every `[N:v]` / `[N:v:0]` reference to an input that reads the same file
96
+ as input 0 (the file the encoder line was built for; cut.py's segment join opens it once
97
+ per segment) reads that input through the conversion instead. Other inputs are untouched.
98
+
99
+ A -filter_complex with no such reference is refused rather than run with two graphs (ffmpeg
100
+ would refuse it anyway, less clearly). Every other command is returned as it is."""
101
+ out: List[str] = []
102
+ conv, at = None, -1
103
+ i = 0
104
+ while i < len(cmd):
105
+ if cmd[i] == "-vf" and i + 1 < len(cmd) and _MATRIX_FILTER_RE.match(str(cmd[i + 1])):
106
+ conv, at = conv or str(cmd[i + 1]), (at if at >= 0 else len(out))
107
+ i += 2
108
+ continue
109
+ out.append(cmd[i])
110
+ i += 1
111
+ if conv is None:
112
+ return list(cmd)
113
+ graph_at = next((k for k, a in enumerate(out[:-1]) if a in ("-filter_complex", "-lavfi")), None)
114
+ if graph_at is not None:
115
+ inputs = [out[k + 1] for k in range(len(out) - 1) if out[k] == "-i"]
116
+ source = {n for n, p in enumerate(inputs) if p == inputs[0]} if inputs else set()
117
+ refs: Dict[int, List[str]] = {} # input index -> [its first reference, the labels standing in for it]
118
+
119
+ def through(m: "re.Match[str]") -> str:
120
+ n = int(m.group(1))
121
+ if n not in source:
122
+ return m.group(0)
123
+ label = f"[bt709src{sum(len(v) - 1 for v in refs.values())}]"
124
+ refs.setdefault(n, [m.group(0)]).append(label)
125
+ return label
126
+
127
+ graph = _INPUT_VIDEO_REF.sub(through, str(out[graph_at + 1]))
128
+ if not refs:
129
+ die("internal: a BT.709 matrix conversion was requested for a -filter_complex that never reads "
130
+ "input 0's video as [0:v]", kind="ffmpeg")
131
+ # one conversion per input, split when the graph reads that input more than once
132
+ # (speedramp.py trims [0:v] once per segment)
133
+ chains = [f"{ref}{conv}" + (f",split={len(labels)}" if len(labels) > 1 else "") + "".join(labels)
134
+ for ref, *labels in refs.values()]
135
+ out[graph_at + 1] = ";".join(chains + [graph])
136
+ return out
137
+ vf_at = next((k for k, a in enumerate(out[:-1]) if a == "-vf"), None)
138
+ if vf_at is not None:
139
+ out[vf_at + 1] = f"{conv},{out[vf_at + 1]}"
140
+ return out
141
+ return out[:at] + ["-vf", conv] + out[at:]
142
+
143
+
45
144
  def color_hex(value: str) -> str:
46
145
  """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
47
146
  v = str(value).strip().lstrip("#")
@@ -14,7 +14,7 @@ import argparse
14
14
  from fractions import Fraction
15
15
  from pathlib import Path
16
16
  from typing import Any, Dict, List, Optional, Sequence, Tuple
17
- from _common.color import bt709_tag_args, _sdr_bt709
17
+ from _common.color import bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, _sdr_bt709
18
18
  from _common.emit import die
19
19
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_version, hw_platform_reason
20
20
 
@@ -497,30 +497,55 @@ def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
497
497
  return args
498
498
 
499
499
 
500
- def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
500
+ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
501
+ source: Optional[Dict[str, Any]] = None) -> List[str]:
501
502
  """SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
502
- (color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
503
+ (color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it).
504
+
505
+ `source`: the probe of the file being re-encoded, when the pixels reaching the encoder are
506
+ still that file's. A BT.709-tagged line then carries the conversion of a non-BT.709 matrix
507
+ (bt709_matrix_args); a caller whose graph already outputs BT.709 (--to-sdr) leaves it out."""
503
508
  if STATE.codec and STATE.codec != "h264":
504
- return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
505
- return _maybe_hw("h264", crf, None, keep_bt709, _x264_raw(crf, preset, keep_bt709))
509
+ args = encoder_args(STATE.codec, crf, preset, None, keep_bt709)
510
+ else:
511
+ args = _maybe_hw("h264", crf, None, keep_bt709, _x264_raw(crf, preset, keep_bt709))
512
+ return args + (bt709_matrix_args(source) if keep_bt709 and STATE.codec != "prores" else [])
506
513
 
507
514
 
508
- def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
515
+ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True) -> List[str]:
509
516
  """Encoder args that preserve what the source is.
510
517
 
511
518
  SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
512
519
  Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
513
520
  so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
514
521
  washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
522
+
523
+ An SDR source tagged with a BT.601 (or another non-BT.709) matrix is converted to BT.709
524
+ before it is tagged so: the line ends with the `-vf` of bt709_matrix_args(), which run()
525
+ folds into the tool's own graph (color.fold_bt709_matrix). `convert_matrix=False` is for a
526
+ tool that mixes several sources and converts each one in its own graph (join, multicam,
527
+ broll, overlay --video). --codec prores keeps the source's tags, so it converts nothing.
515
528
  """
516
- if STATE.codec:
517
- return encoder_args(STATE.codec, crf, preset, meta)
518
529
  v = (meta or {}).get("video") or {}
530
+ fix = bt709_matrix_args(meta) if convert_matrix and STATE.codec != "prores" else []
531
+ if STATE.codec:
532
+ return encoder_args(STATE.codec, crf, preset, meta) + fix
519
533
  if not v.get("bt2020_or_hdr"):
520
- return x264_args(crf, preset)
534
+ return x264_args(crf, preset) + fix
521
535
  return _maybe_hw("hevc", crf, meta, True, _x265_hdr_default(v, crf, preset))
522
536
 
523
537
 
538
+ def matrix_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
539
+ """For a tool that mixes several sources in one graph and encodes it through
540
+ video_args(line_meta, convert_matrix=False): the conversion one source's own chain starts
541
+ with ("<filter>," -- bt709_matrix_filter), or "" when that source needs none or the line does
542
+ not tag BT.709 (an HDR/BT.2020 line keeps its tags, --codec prores keeps the source's)."""
543
+ if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
544
+ return ""
545
+ f = bt709_matrix_filter(meta)
546
+ return f + "," if f else ""
547
+
548
+
524
549
  def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
525
550
  keep_hevc: bool = False) -> List[str]:
526
551
  """video_args(), except that with `keep_hevc` (cut.py --keep-hevc) an SDR HEVC source re-encodes
@@ -530,7 +555,7 @@ def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, prese
530
555
  source always takes video_args()'s Main10 line, and --codec always wins."""
531
556
  v = (meta or {}).get("video") or {}
532
557
  if keep_hevc and not STATE.codec and v.get("codec") == "hevc" and not v.get("bt2020_or_hdr"):
533
- return encoder_args("hevc", crf, preset, meta)
558
+ return encoder_args("hevc", crf, preset, meta) + bt709_matrix_args(meta)
534
559
  return video_args(meta, crf, preset)
535
560
 
536
561
 
@@ -878,6 +878,11 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
878
878
  ctx = ctx or STATE
879
879
  is_ffmpeg = _is_ffmpeg(cmd)
880
880
  if is_ffmpeg:
881
+ # an encoder line that converts a BT.601 source to the BT.709 it is tagged with carries
882
+ # that conversion as a -vf; it joins the tool's own graph here, before anything records
883
+ # or runs the command (color.py imports this module, hence the late import)
884
+ from _common.color import fold_bt709_matrix
885
+ cmd = fold_bt709_matrix(cmd)
881
886
  _check_no_overwrite_input(cmd)
882
887
  _check_output_path(cmd)
883
888
  _check_existing_output(cmd)
package/scripts/broll.py CHANGED
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
24
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, time_arg, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
25
25
 
26
26
 
27
27
  def main() -> int:
@@ -87,7 +87,7 @@ def main() -> int:
87
87
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
88
88
  if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
89
89
  die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
90
- cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio"))})
90
+ cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b})
91
91
  if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
92
92
  die("--audio b/mix needs audio on every B-roll clip")
93
93
 
@@ -101,10 +101,14 @@ def main() -> int:
101
101
  # A shows through unchanged, so A's timeline is never touched.
102
102
  parts: List[str] = []
103
103
  cur = "[0:v]"
104
+ # every clip is converted by its own colour tags to the BT.709 the output is tagged with
105
+ if matrix_chain(meta_a, meta_a):
106
+ parts.append(f"[0:v]{matrix_chain(meta_a, meta_a)}null[a709]")
107
+ cur = "[a709]"
104
108
  for i, c in enumerate(cutaways):
105
109
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
106
110
  parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
107
- f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
111
+ f"{matrix_chain(c['meta'], meta_a)}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
108
112
  parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
109
113
  cur = f"[v{i}]"
110
114
  vout = cur
@@ -131,7 +135,7 @@ def main() -> int:
131
135
  cmd += ["-filter_complex", ";".join(parts), "-map", vout]
132
136
  if aout:
133
137
  cmd += ["-map", aout]
134
- cmd += video_args(meta_a, args.crf, args.preset) + cfr_args(meta_a, None)
138
+ cmd += video_args(meta_a, args.crf, args.preset, convert_matrix=False) + cfr_args(meta_a, None)
135
139
  if aout == "0:a:0":
136
140
  cmd += ["-c:a", "copy"]
137
141
  elif aout:
package/scripts/check.py CHANGED
@@ -175,10 +175,10 @@ def main() -> int:
175
175
  # untagged 8-bit video is treated as BT.709 by every player and platform; nothing to fix
176
176
  row("colour", "PASS", shown + " (players assume bt709)" if matrix else "untagged (players assume bt709)", "bt709/bt709 tagged (or HDR)")
177
177
  elif matrix in ("smpte170m", "bt470bg"):
178
- # The pixels are BT.601-encoded. Nothing here converts the matrix: --retag bt709 and
179
- # every SDR re-encode (bt709_tag_args) only relabel it, which shifts the colours.
178
+ # The pixels are BT.601-encoded. A re-encode here converts them to the BT.709 it tags
179
+ # (bt709_matrix_filter); --retag bt709 is a stream copy and only relabels the matrix.
180
180
  row("colour", "WARN", shown, "bt709/bt709 tagged (or HDR)",
181
- "re-export from the master as BT.709; color.py --retag bt709 (like any re-encode here) only relabels the matrix: right only if the pixels really are BT.709",
181
+ "export.py (any re-encode here converts the BT.601 matrix to BT.709); color.py --retag bt709 only relabels it: right only if the pixels really are BT.709",
182
182
  reason="a player that honours the BT.601 tag and one that ignores it on HD video (decoding it as BT.709) show different colours")
183
183
  else:
184
184
  row("colour", "WARN", shown, "bt709/bt709 tagged (or HDR)", "color.py --retag bt709 when the picture really is 709; color.py --to-sdr when it is HDR")
package/scripts/color.py CHANGED
@@ -353,7 +353,9 @@ def main() -> int:
353
353
  tag = "lut"
354
354
 
355
355
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
356
- cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
356
+ # --lut/--correct grade the source's own pixels, so a BT.601 source is converted to the BT.709
357
+ # the line is tagged with; --to-sdr's zscale chain already writes BT.709 and converts nothing more
358
+ cmd += x264_args(args.crf, args.preset, source=None if args.to_sdr else meta) + cfr_args(meta) + (aac_args() if has_audio else [])
357
359
  dropped_streams = run_keeping_subtitles(cmd, output)
358
360
  r = probe(output, role="output")
359
361
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
package/scripts/export.py CHANGED
@@ -36,7 +36,7 @@ import sys
36
36
  from pathlib import Path
37
37
  from typing import Dict, List
38
38
 
39
- from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
39
+ from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
40
40
  from _common.decision import hw_preset_video, restate_last_swap
41
41
  from check import SPECS as PLATFORMS, measure_loudness
42
42
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
@@ -216,6 +216,11 @@ def main() -> int:
216
216
  cmd += cfr_args(meta)
217
217
  if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
218
218
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
219
+ if args.preset != "prores":
220
+ # every line but the prores master's says BT.709 (the fixed presets' tags above, an
221
+ # SDR hevc/av1 line's own): a BT.601 source's pixels are converted to match, in front
222
+ # of the -vf above (run() folds the two); an untagged, BT.709 or HDR source adds nothing
223
+ cmd += bt709_matrix_args(meta)
219
224
  if out_ext == "mp4":
220
225
  cmd += ["-movflags", "+faststart"]
221
226
  cmd += (p["audio"] if has_audio else ["-an"])
package/scripts/join.py CHANGED
@@ -39,7 +39,7 @@ import os
39
39
  import sys
40
40
  from typing import Any, Dict, List, Optional, Tuple
41
41
 
42
- from _common import STATE, AUDIO_MEDIA_EXT, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
42
+ from _common import STATE, AUDIO_MEDIA_EXT, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
43
43
 
44
44
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
45
45
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -497,7 +497,8 @@ def main() -> int:
497
497
  if len(set(chans)) > 1:
498
498
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
499
499
  for i in range(n):
500
- parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
500
+ # a BT.601 clip is converted to the BT.709 the join is tagged with, each clip by its own tags
501
+ parts.append(f"[{i}:v]{matrix_chain(metas[i], hdr_meta or metas[0])}{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
501
502
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
502
503
 
503
504
  # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
@@ -541,7 +542,7 @@ def main() -> int:
541
542
  # to meet the AAC tail (a 51-frame dissolve came out 52), which newer builds no longer do -- pass
542
543
  # the frames through as built, for a plain cut as much as for a transition
543
544
  cmd += ["-fps_mode", "passthrough"]
544
- cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
545
+ cmd += video_args(hdr_meta or metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + [output]
545
546
  run(cmd)
546
547
  expected = unpending_length(lens, d)
547
548
  r = probe(output, role="output")
@@ -31,7 +31,7 @@ import math
31
31
  import sys
32
32
  from typing import Dict, List, Tuple
33
33
 
34
- from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
34
+ from _common import matrix_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
35
  from sync import measure_offset
36
36
 
37
37
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -276,7 +276,8 @@ def main() -> int:
276
276
  if src_s < 0:
277
277
  info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
278
278
  c, src_s, src_e = 0, s, e
279
- parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{geo}[v{i}]")
279
+ # each camera converted by its own colour tags to the BT.709 the cut is tagged with
280
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{matrix_chain(metas[c], metas[0])}{geo}[v{i}]")
280
281
  labels.append(f"[v{i}]")
281
282
  project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
282
283
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
@@ -297,7 +298,7 @@ def main() -> int:
297
298
 
298
299
  output = args.output or default_output(args.inputs[0], "multicam", "mp4")
299
300
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
300
- cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
301
+ cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
301
302
 
302
303
  if args.write_project:
303
304
  # Same cut decision as the combined render above (project_clips was built alongside the
@@ -25,7 +25,7 @@ import sys
25
25
  from typing import List, Optional
26
26
 
27
27
  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, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, 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
28
+ from _common import STATE, script_font_for_text, drawtext_text_opts, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, matrix_chain, 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
29
 
30
30
  # Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
31
31
  # like column is 14 % of the width, its description block 22 % of the height), so a position
@@ -299,7 +299,10 @@ def main() -> int:
299
299
  if enable:
300
300
  ov += f":enable='{enable}'"
301
301
  cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
302
- fc = f"[1:v]{','.join(chain)}[ov];[0:v][ov]{ov}[out]"
302
+ # both pictures converted by their own colour tags to the BT.709 the output is tagged with
303
+ base = matrix_chain(meta, meta)
304
+ fc = (f"[1:v]{matrix_chain(pip_meta, meta)}{','.join(chain)}[ov];"
305
+ + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
303
306
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
304
307
  if meta.get("duration"):
305
308
  # See the --image branch above: -t (exact, bounds only the main input) instead of
@@ -329,7 +332,7 @@ def main() -> int:
329
332
  if meta.get("audio"):
330
333
  cmd += ["-map", f"0:a:{args.audio_stream}"]
331
334
 
332
- cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
335
+ cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
333
336
  cmd += aac_args() if meta.get("audio") else ["-an"]
334
337
  dropped_streams = run_keeping_subtitles(cmd, output)
335
338
  if not STATE.dry_run: