ffmpeg-skill 2.10.1 → 2.10.3

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 (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) |
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 or RGB 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.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.10.3`) | 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.1", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.10.3", "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"},
@@ -220,7 +220,17 @@ Main10 for HDR), so the flags add no behaviour to a caller that does not pass th
220
220
  Every SDR line except prores is tagged BT.709; a source whose matrix is BT.601 (`smpte170m`,
221
221
  `bt470bg`, or `fcc`/`smpte240m`) is converted to it in the filter graph (`colorspace`, matrix
222
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()`).
223
+ source adds no filter, and the tags themselves never convert (`bt709_tag_args()`). An RGB picture
224
+ (a PNG or JPEG still, a PNG sequence, a qtrle clip, an `overlay --image` logo, an emoji PNG,
225
+ `waveform --image`) is converted to BT.709 YUV explicitly (`scale=out_color_matrix=bt709`; a JPEG is
226
+ a `bt470bg` source); `grid.py` converts each cell by its own tags. Where a converted picture meets an
227
+ untagged one in a mixing filter, the untagged one is labelled BT.709 (`setparams`, tags only), so
228
+ FFmpeg 7.1+'s colourspace negotiation converts neither (`join`, `multicam`, `broll` and `overlay --video`
229
+ too, beside a converted clip). A colour a tool draws from a hex or named value (a pad, `tpad` or
230
+ `rotate` fill, `drawtext` and libass text and boxes, a `graphics.py` box or bar, `background.py`'s
231
+ `color`/`gradients`, `waveform.py`'s plate and waves) lands in the same BT.709: from FFmpeg 7.1
232
+ an untagged picture is labelled for the drawing filters, before 7.1 the drawing runs on an RGB copy
233
+ and only the pixels it touched are taken from it (`_common.drawn_chain()`); a grey changes nothing.
224
234
  `cut.py --keep-hevc` (opt-in) re-encodes an SDR HEVC source as x265 8-bit BT.709 instead of x264; an HDR or
225
235
  BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
226
236
  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.1",
3
+ "version": "2.10.3",
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",
@@ -162,6 +162,11 @@ these had ever shown up before.
162
162
  before measuring (40 dB for re-matrixed pixels), and it then flagged the fixed, byte-clean
163
163
  output as 26 dB instead. The tests' `_psnr` helper now pins identical colour tags on both
164
164
  inputs so every build reports the same number for the same two files.
165
+ - **A test fixture drawn with `scale=out_color_matrix=bt709` is not untagged on 7.1+.** The scale
166
+ labels its output and x264 writes that label, so an "untagged" clip made that way probes as
167
+ `color_space=bt709` on the trixie/macOS/Windows jobs and the untagged case silently went untested
168
+ there (#333's colour fixtures until 2.10.3). Relabel the frames `setparams=colorspace=unknown`
169
+ before the encode, and assert the probe says unknown.
165
170
  - **The conda-forge 7.1.1 build deadlocks on `tpad` + `adelay`/`apad` (pad.py) and ignores
166
171
  SIGTERM.** Debian's 7.1.5 in CI does not. Run pad tests against CI, not that build, and note
167
172
  that the tools have no subprocess timeout to get an agent out of such a hang.
@@ -11,7 +11,12 @@ detail, not an aside.
11
11
  SDR outputs are H.264 tagged BT.709 `yuv420p`. A source tagged with a BT.601 matrix
12
12
  (`smpte170m`, `bt470bg`; also `fcc`, `smpte240m`) has its pixels converted to that BT.709 matrix
13
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
14
+ stream copy and still only relabels. Untagged sources are taken as BT.709 and left alone. An RGB
15
+ picture (PNG/JPEG still, logo, emoji PNG, PNG sequence, qtrle clip) is converted to BT.709 YUV
16
+ explicitly (through 2.10.1 FFmpeg's BT.601 default did it), and `grid.py` converts each cell by its
17
+ own tags. A colour the tools draw (pad and background colours, text, boxes, captions, waveform
18
+ colours) is drawn in BT.709 too; through 2.10.2 it was BT.601 under the BT.709 tag (pure red
19
+ showed as (255,24,0)). When `probe.py` reports
15
20
  `bt2020_or_hdr: true` (HDR10/PQ, HLG, Dolby Vision, or BT.2020 SDR), every editing script keeps
16
21
  the output on the 10-bit path (HEVC Main10, source colour tags) so nothing is silently
17
22
  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 (a BT.601-matrix source is converted to it, as by every re-encode), sets `+faststart`,
1618
+ Scales into the preset frame (pad by default), tags BT.709 (a BT.601-matrix or RGB 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
@@ -110,7 +110,9 @@ def text_overlay_ass(elements: Sequence[Dict[str, Any]], *, play_w: int, play_h:
110
110
  """Write `elements` as an ASS file at `path` and return the path."""
111
111
  header = [
112
112
  "[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}",
113
- "WrapStyle: 2", "ScaledBorderAndShadow: yes", "",
113
+ # YCbCr Matrix: None -- the colours are RGB, to be put in the video's own matrix: FFmpeg 8.x's
114
+ # ass filter reads an absent header as BT.601 whatever the picture is tagged (drawn_chain)
115
+ "WrapStyle: 2", "ScaledBorderAndShadow: yes", "YCbCr Matrix: None", "",
114
116
  "[V4+ Styles]",
115
117
  "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
116
118
  "BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
@@ -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, matrix_chain, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
86
+ signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, matrix_chain, rgb_chain_to_bt709, 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,8 +91,8 @@ from _common.asr import (
91
91
  whisper_word_timings, write_srt
92
92
  )
93
93
  from _common.color import (
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
94
+ bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, color_hex, _COLOR_TOKEN_RE, drawn_stream_format, fold_bt709_matrix,
95
+ is_neutral_color, is_rgb, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
96
96
  )
97
97
  from _common.text import (
98
98
  ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
@@ -213,7 +213,7 @@ __all__ = [
213
213
  "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
214
214
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
215
215
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
216
- "source_codec_video_args", "matrix_chain", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
216
+ "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
217
217
  "_x264_raw", "_ZWJ",
218
218
  "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
219
219
  "_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
@@ -51,8 +51,26 @@ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
51
51
  # pre-BT.709 ones the colorspace filter also reads. bt2020nc/c sources take the HDR/BT.2020 path.
52
52
  NON_BT709_MATRICES = ("smpte170m", "bt470bg", "fcc", "smpte240m")
53
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))
54
+ # RGB pictures (ffprobe's pix_fmt names: PNG rgb24/rgba/pal8/rgb48be, qtrle, gbrp...). Their
55
+ # conversion to YUV is wherever the graph first needs YUV, and FFmpeg's default for it is BT.601
56
+ # (5.1, 6.1, 7.0; 7.1+ follows the link's tag, unspecified = BT.601). Gray has no matrix.
57
+ _RGB_PIX_FMT_RE = re.compile(r"^(?:rgb|bgr|argb|abgr|0rgb|0bgr|gbr|x2rgb|x2bgr|pal8)")
58
+
59
+
60
+ def rgb_to_bt709_filter(pix_fmt: str = "yuv444p") -> str:
61
+ """An RGB picture converted to BT.709 limited-range YUV right here, in `pix_fmt`, so no later
62
+ conversion (an auto-inserted one, with FFmpeg's BT.601 default) decides the matrix. 4:4:4 by
63
+ default: the tool's own scaling and the encoder's 4:2:0 come after, as they did on the RGB."""
64
+ return f"scale=out_color_matrix=bt709:out_range=tv,format={pix_fmt}"
65
+
66
+
67
+ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
68
+ """True when the probed picture is RGB (a PNG/still, a qtrle/png-in-mov clip, an image sequence)."""
69
+ return bool(_RGB_PIX_FMT_RE.match(str(((meta or {}).get("video") or {}).get("pix_fmt") or "")))
70
+
71
+
72
+ _MATRIX_FILTER_RE = re.compile(r"^(?:colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709|(?:format=rgb24,)?%s)$"
73
+ % ("|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
56
74
  _INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
57
75
 
58
76
 
@@ -67,14 +85,113 @@ def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
67
85
  (iprimaries/itrc = the output's), so the picture a player that decodes with the source's
68
86
  matrix showed is the picture the BT.709 output shows. The `colorspace` filter is in every
69
87
  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."""
88
+ not depend on how a build reads or negotiates the frames' own tags. The range is left as it is.
89
+
90
+ An RGB source (is_rgb: a PNG/still, an image sequence, a qtrle clip) has no matrix of its own
91
+ but gets one where the graph first needs YUV, and FFmpeg's default there is BT.601: through
92
+ 2.10.1 such a source came out labelled BT.709 over BT.601-encoded pixels (pure red decoded as
93
+ (255,24,0), pure green as (0,216,0), on 5.1.1 to 8.1). It is converted to BT.709 YUV here
94
+ (rgb_to_bt709_filter). A JPEG (yuvj, tagged bt470bg) is a BT.601 source like any other."""
71
95
  v = (meta or {}).get("video") or {}
72
96
  matrix = v.get("color_space")
73
- if v.get("bt2020_or_hdr") or matrix not in NON_BT709_MATRICES:
97
+ if v.get("bt2020_or_hdr"):
98
+ return None
99
+ if is_rgb(meta):
100
+ # a palette is turned into YUV with fixed BT.601 coefficients whatever out_color_matrix
101
+ # says (5.1.1, 6.1): expand it to RGB first
102
+ return ("format=rgb24," if v.get("pix_fmt") == "pal8" else "") + rgb_to_bt709_filter()
103
+ if matrix not in NON_BT709_MATRICES:
74
104
  return None
75
105
  return f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
76
106
 
77
107
 
108
+ def bt709_label_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
109
+ """For an UNTAGGED SDR YUV picture that meets a converted (BT.709-tagged) one in a filter that
110
+ mixes pictures (overlay, xstack): its BT.709 label, tags only (setparams), or None for any
111
+ other picture (tagged, RGB, HDR, no probe).
112
+
113
+ FFmpeg 7.1+ negotiates one colourspace for all of a mixing filter's inputs and converts the
114
+ others to it, taking "unknown" as BT.601: the untagged main under a converted logo, or an
115
+ untagged grid cell beside a converted one, was shifted (7.08 mean / 25 max RGB on 7.1.1 and
116
+ 8.1.3), or the converted picture converted back. Labelled as what the output says it is, the
117
+ inputs agree and nothing is converted; on 5.1-7.0 the label changes nothing. The pixels are not
118
+ touched, as for the untagged source of a one-input re-encode (bt709_tag_args)."""
119
+ v = (meta or {}).get("video") or {}
120
+ if (not v.get("pix_fmt") or v.get("bt2020_or_hdr") or is_rgb(meta)
121
+ or (v.get("color_space") or "unknown") not in ("unknown", "unspecified")):
122
+ return None
123
+ return "setparams=colorspace=bt709" + ("" if v.get("color_range") == "pc" else ":range=tv")
124
+
125
+
126
+ # FFmpeg's named colours that are greys (libavutil/parseutils.c's table): R = G = B.
127
+ _GREY_NAMES = frozenset(("black", "white", "gray", "grey", "silver", "darkgray", "darkgrey", "dimgray", "dimgrey",
128
+ "gainsboro", "lightgray", "lightgrey", "whitesmoke"))
129
+
130
+
131
+ def _yuv8(rgb: Sequence[int], kr: float, kb: float) -> "Tuple[int, int, int]":
132
+ """8-bit limited-range YUV of an RGB colour under the matrix (kr, kb), rounded as FFmpeg's
133
+ drawing code rounds it (ff_draw_color)."""
134
+ r, g, b = (c / 255 for c in rgb)
135
+ y = kr * r + (1 - kr - kb) * g + kb * b
136
+ return (int(16 + 219 * y + 0.5), int(128 + 224 * (b - y) / (2 * (1 - kb)) + 0.5),
137
+ int(128 + 224 * (r - y) / (2 * (1 - kr)) + 0.5))
138
+
139
+
140
+ def is_neutral_color(value: Any) -> bool:
141
+ """True for a colour whose 8-bit YUV is the same under BT.601 and BT.709, however translucent: a
142
+ grey (R = G = B; black and white included), or one so near grey that both round to the same
143
+ bytes (graphics.py's default background 101418). Only another colour lands differently, so only
144
+ it needs drawn_chain(). A name counts only if it is one of FFmpeg's greys; anything else
145
+ (`red`, `random`) is not neutral."""
146
+ v = str(value or "").strip().split("@")[0].lower()
147
+ if v in _GREY_NAMES:
148
+ return True
149
+ v = v[1:] if v.startswith("#") else v[2:] if v.startswith("0x") else v
150
+ if re.fullmatch(r"[0-9a-f]{6}(?:[0-9a-f]{2})?", v):
151
+ rgb = (int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16))
152
+ return _yuv8(rgb, 0.299, 0.114) == _yuv8(rgb, 0.2126, 0.0722)
153
+ return False
154
+
155
+
156
+ def drawn_stream_format(meta: Optional[Dict[str, Any]]) -> "Tuple[str, bool]":
157
+ """(pix_fmt, full range) of the picture a tool draws on, for rgb_island(): the probed source's own
158
+ 8-bit planar YUV format (an RGB source has become yuv444p, BT.709 limited, through
159
+ bt709_matrix_filter), or yuv420p for anything else (the format the encoder line takes it to)."""
160
+ v = (meta or {}).get("video") or {}
161
+ if is_rgb(meta):
162
+ return "yuv444p", False
163
+ pf = str(v.get("pix_fmt") or "yuv420p")
164
+ full = pf.startswith("yuvj") or v.get("color_range") == "pc"
165
+ if pf not in ("yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p"):
166
+ pf = "yuv420p"
167
+ return pf, full
168
+
169
+
170
+ def rgb_island(colour_chain: str, neutral_chain: str = "null", pix_fmt: str = "yuv420p", full: bool = False,
171
+ tag: str = "d") -> str:
172
+ """`colour_chain` (filters that draw a colour: drawtext, pad, tpad, rotate's fill, libass) run on
173
+ an RGB copy of the picture, as one simple filtergraph (one input, one output; `tag` keeps its
174
+ labels unique in a bigger graph). For FFmpeg < 7.1, whose drawing filters turn every colour into
175
+ YUV with BT.601 whatever the picture is tagged (`ff_draw_init`), and drawbox on every version:
176
+ in RGB a colour is written as it is, and the copy goes back to YUV with BT.709.
177
+
178
+ Only the pixels the drawing touched come from that copy. The same round trip without the colour
179
+ (`neutral_chain`: `null`, or the same pad/tpad/rotate with a black fill, which is the same under
180
+ every matrix) is compared with it plane by plane, and maskedmerge takes the drawn copy where
181
+ the two differ and the untouched picture (`neutral_chain` on the YUV itself) everywhere else:
182
+ the RGB round trip is not lossless, so a picture that only passes through keeps its pixels."""
183
+ rng = "pc" if full else "tv"
184
+ to_rgb = f"scale=in_color_matrix=bt709:in_range={rng},format=rgb24"
185
+ to_yuv = f"scale=out_color_matrix=bt709:out_range={rng},format={pix_fmt}"
186
+ keep = "'255*gt(val,0)'"
187
+ t = f"_{tag}"
188
+ return (f"split=3[{t}p][{t}c][{t}n];[{t}p]format={pix_fmt},{neutral_chain}[{t}p1];"
189
+ f"[{t}c]{to_rgb},{colour_chain},{to_yuv},split[{t}c1][{t}c2];"
190
+ f"[{t}n]{to_rgb},{neutral_chain},{to_yuv}[{t}n1];"
191
+ f"[{t}c1][{t}n1]blend=all_mode=difference,lut=c0={keep}:c1={keep}:c2={keep}[{t}m];"
192
+ f"[{t}p1][{t}c2][{t}m]maskedmerge")
193
+
194
+
78
195
  def bt709_matrix_args(meta: Optional[Dict[str, Any]]) -> List[str]:
79
196
  """["-vf", bt709_matrix_filter(meta)] for an encoder line that tags its output BT.709, or [].
80
197
 
@@ -14,12 +14,14 @@ 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_matrix_args, bt709_matrix_filter, bt709_tag_args, _sdr_bt709
17
+ from _common.color import (bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
18
+ is_neutral_color, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
18
19
  from _common.emit import die
19
20
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_version, hw_platform_reason
20
21
 
21
22
 
22
- def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0) -> str:
23
+ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0,
24
+ meta: Optional[Dict[str, Any]] = None) -> str:
23
25
  """The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
24
26
 
25
27
  fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
@@ -30,7 +32,10 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken
30
32
  needed beyond the usual scale/pad set, and that is already required by redact.py.
31
33
  `darken` > 0 also dims that background copy by that much brightness (eq), so the picture in
32
34
  front reads as the subject instead of competing with a bright blurred copy of itself --
33
- what `fit.py --fit blur` uses (1.14)."""
35
+ what `fit.py --fit blur` uses (1.14).
36
+ `meta`: the probe of the picture being padded, when the output is that picture's re-encode: a
37
+ --pad-color with chroma is then drawn in the BT.709 the line tags (drawn_chain()). Without it
38
+ (a still made RGB, an export to GIF) the pad is what it always was."""
34
39
  if fill == "blur":
35
40
  # boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
36
41
  # <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
@@ -40,7 +45,9 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken
40
45
  f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
41
46
  f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
42
47
  f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
43
- return f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={color}"
48
+ pad = f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color="
49
+ return (f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,"
50
+ + (pad + color if meta is None else drawn_chain(pad + color, meta, [color], pad + "black", tag="pad")))
44
51
 
45
52
 
46
53
  def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
@@ -512,7 +519,8 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
512
519
  return args + (bt709_matrix_args(source) if keep_bt709 and STATE.codec != "prores" else [])
513
520
 
514
521
 
515
- def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True) -> List[str]:
522
+ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True,
523
+ source: Optional[Dict[str, Any]] = None) -> List[str]:
516
524
  """Encoder args that preserve what the source is.
517
525
 
518
526
  SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
@@ -525,9 +533,14 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
525
533
  folds into the tool's own graph (color.fold_bt709_matrix). `convert_matrix=False` is for a
526
534
  tool that mixes several sources and converts each one in its own graph (join, multicam,
527
535
  broll, overlay --video). --codec prores keeps the source's tags, so it converts nothing.
536
+ An RGB source (a PNG, an image sequence, a qtrle clip) is converted the same way.
537
+
538
+ `source`: the probe of the picture the encoder gets when the line is chosen without it
539
+ (insert.py and sequence.py make a still or a PNG sequence into video on the SDR line,
540
+ video_args(None)); its RGB or BT.601 pixels are converted to the BT.709 that line is tagged with.
528
541
  """
529
542
  v = (meta or {}).get("video") or {}
530
- fix = bt709_matrix_args(meta) if convert_matrix and STATE.codec != "prores" else []
543
+ fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and STATE.codec != "prores" else []
531
544
  if STATE.codec:
532
545
  return encoder_args(STATE.codec, crf, preset, meta) + fix
533
546
  if not v.get("bt2020_or_hdr"):
@@ -546,6 +559,61 @@ def matrix_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, A
546
559
  return f + "," if f else ""
547
560
 
548
561
 
562
+ def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
563
+ """For a picture that meets a converted one in a mixing filter (a still over the video, a grid
564
+ cell beside a converted cell), encoded through video_args(line_meta): the BT.709 label an
565
+ untagged one's chain starts with ("<setparams>," -- bt709_label_filter), or "" (a tagged, RGB or
566
+ HDR picture, or a line that does not tag BT.709)."""
567
+ if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
568
+ return ""
569
+ f = bt709_label_filter(meta)
570
+ return f + "," if f else ""
571
+
572
+
573
+ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva420p") -> str:
574
+ """For a picture the tool's own graph has made RGB(A) -- a still's `format=rgba` chain
575
+ (overlay.py --image, an emoji PNG) or an RGBA composite (waveform.py's --image plate) -- that
576
+ is encoded through video_args(line_meta): "," + the conversion to BT.709 YUV to end that chain
577
+ with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
578
+ leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
579
+ if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
580
+ return ""
581
+ return "," + rgb_to_bt709_filter(pix_fmt)
582
+
583
+
584
+ def draws_chroma(colours: Sequence[Any], line_meta: Optional[Dict[str, Any]]) -> bool:
585
+ """True when one of `colours` has chroma (is_neutral_color) and the line video_args(line_meta)
586
+ tags the output BT.709 -- when a drawn colour needs drawn_chain() at all."""
587
+ return (not all(is_neutral_color(c) for c in colours if c is not None) and STATE.codec != "prores"
588
+ and not ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"))
589
+
590
+
591
+ def drawn_chain(colour_chain: str, meta: Optional[Dict[str, Any]], colours: Sequence[Any], neutral_chain: str = "null",
592
+ line_meta: Any = "same", tag: str = "d", labelled: bool = False) -> str:
593
+ """`colour_chain` -- filters that draw a colour into the picture `meta` probes (drawtext's
594
+ font/box/border colours, pad/tpad's fill, rotate's fillcolor, libass) -- so that a colour of
595
+ `colours` lands in the BT.709 the line video_args(line_meta) tags the output with (line_meta
596
+ defaults to meta).
597
+
598
+ FFmpeg's drawing filters turn an RGB colour into YUV with a matrix of their own (ff_draw):
599
+ BT.601 always up to 7.0, the link's colourspace from 7.1 on, "unknown" read as BT.601. Through
600
+ 2.10.2 a drawn colour was BT.601 under a BT.709 tag on every build (pure red decoded as
601
+ (255,24,0)), and on 5.1/6.1 for a BT.709-tagged source too. From 7.1 an untagged picture gets
602
+ its BT.709 label (label_chain(): tags only) and the filters follow it; before 7.1 the drawing
603
+ runs in RGB (rgb_island(), `neutral_chain` being the same filters with a black fill, or null).
604
+
605
+ Unchanged: a chain whose colours are all greys (the same YUV under every matrix: the default
606
+ black pads and white text), a line that does not tag BT.709 (HDR, prores). `labelled`: the
607
+ caller's graph already labels this picture (label_chain()) earlier in the same chain."""
608
+ line = meta if line_meta == "same" else line_meta
609
+ if not draws_chroma(colours, line):
610
+ return colour_chain
611
+ if ffmpeg_version() >= (7, 1):
612
+ return ("" if labelled else label_chain(meta, line)) + colour_chain
613
+ pix_fmt, full = drawn_stream_format(meta)
614
+ return rgb_island(colour_chain, neutral_chain, pix_fmt, full, tag)
615
+
616
+
549
617
  def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
550
618
  keep_hevc: bool = False) -> List[str]:
551
619
  """video_args(), except that with `keep_hevc` (cut.py --keep-hevc) an SDR HEVC source re-encodes
@@ -286,12 +286,16 @@ def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]"
286
286
 
287
287
 
288
288
 
289
- def emoji_filter_chain(plan, base_label, out_label, first_input=1):
289
+ def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", base_chain=""):
290
290
  """(chains, inputs) that composite the planned PNGs on top of `base_label`.
291
291
 
292
292
  `inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
293
293
  ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
294
294
  a timeline the fade filter can move along; one that does not is a plain `-i`).
295
+ `to_yuv` ends each PNG's chain: the caller's rgb_chain_to_bt709(meta), so the RGBA asset is
296
+ converted to the BT.709 the output is tagged with instead of by overlay's BT.601 default;
297
+ `base_chain` (label_chain(meta, meta): an untagged source's BT.709 label) goes in front of
298
+ the first overlay, so 7.1+ does not convert the two pictures to one another.
295
299
  """
296
300
  overlays = plan.get("overlays") or []
297
301
  if not overlays:
@@ -329,11 +333,15 @@ def emoji_filter_chain(plan, base_label, out_label, first_input=1):
329
333
  chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
330
334
  else:
331
335
  inputs.append(["-i", asset])
336
+ chain += to_yuv
332
337
  if len(labels) > 1:
333
338
  chain += f",split={len(labels)}"
334
339
  chains.append(chain + "".join(f"[{l}]" for l in labels))
335
340
  pads[key] = labels
336
341
  cur = base_label
342
+ if base_chain:
343
+ chains.append(f"[{cur}]{base_chain}null[ebase]")
344
+ cur = "ebase"
337
345
  remaining = {key: list(v) for key, v in pads.items()}
338
346
  for j, o in enumerate(overlays):
339
347
  label = remaining[_key(o)].pop(0)
@@ -14,7 +14,7 @@ import argparse
14
14
  import math
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
17
+ from _common import add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -61,9 +61,16 @@ def main() -> int:
61
61
  # speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
62
62
  # motionless the filter allows.
63
63
  src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
64
+ colours = [c0, c1]
64
65
  else:
65
66
  validate_color(args.color, "--color")
66
67
  src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
68
+ colours = [args.color]
69
+ if draws_chroma(colours, None):
70
+ # the colours are made in RGB and converted to the BT.709 the output is tagged with: left to
71
+ # the encoder line's own conversion (or `color`'s YUV), they were BT.601 under that tag
72
+ # (pure red decoded as (255,24,0)); a grey is the same under every matrix
73
+ src_filter += (",format=rgb24" if not args.gradient else "") + rgb_chain_to_bt709(None, "yuv420p")
67
74
 
68
75
  output = args.output
69
76
  cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
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, matrix_chain, 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, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
25
25
 
26
26
 
27
27
  def main() -> int:
@@ -101,14 +101,22 @@ 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]")
104
+ # every clip is converted by its own colour tags to the BT.709 the output is tagged with; beside
105
+ # a converted clip (or under a --pad-color with chroma) an untagged one gets its BT.709 label,
106
+ # tags only, so 7.1+'s overlay and pad have nothing to convert or misread
107
+ convert = [matrix_chain(m, meta_a) for m in [meta_a] + [c["meta"] for c in cutaways]]
108
+ if any(convert) or draws_chroma([args.pad_color], meta_a):
109
+ convert = [cv or label_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
110
+ if convert[0]:
111
+ parts.append(f"[0:v]{convert[0]}null[a709]")
107
112
  cur = "[a709]"
113
+ pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
108
114
  for i, c in enumerate(cutaways):
109
- geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
115
+ geo = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
116
+ + drawn_chain(pad + args.pad_color, c["meta"], [args.pad_color], pad + "black", line_meta=meta_a,
117
+ tag=f"pad{i}", labelled=True))
110
118
  parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
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}]")
119
+ f"{convert[i + 1]}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
112
120
  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}]")
113
121
  cur = f"[v{i}]"
114
122
  vout = cur
@@ -44,7 +44,7 @@ from typing import Any, Dict, List, Optional, Tuple
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
46
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
47
- from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
47
+ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
48
48
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
49
49
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
50
50
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -633,7 +633,9 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
633
633
  # jump every time the highlight advances (the trap the issue this implements is filed against).
634
634
  header = [
635
635
  "[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}",
636
- f"WrapStyle: {2 if word_style else 0}", "ScaledBorderAndShadow: yes", "",
636
+ # YCbCr Matrix: None -- the colours are RGB, to be put in the video's own matrix: FFmpeg 8.x's
637
+ # ass filter reads an absent header as BT.601 whatever the picture is tagged (drawn_chain)
638
+ f"WrapStyle: {2 if word_style else 0}", "ScaledBorderAndShadow: yes", "YCbCr Matrix: None", "",
637
639
  "[V4+ Styles]",
638
640
  "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
639
641
  f"Style: Default,{ass_font_name(args.font)},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin_l},{margin_r},{margin},1",
@@ -1560,9 +1562,17 @@ def main() -> int:
1560
1562
  vf = f"subtitles={escape_filter_path(srt_path)}:force_style='{force}'"
1561
1563
  if args.fonts_dir:
1562
1564
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
1565
+ if generated_ass or not args.ass:
1566
+ # a text/outline/karaoke colour with chroma lands in the BT.709 the output is tagged with
1567
+ # (drawn_chain). A caller's own --ass is burned as libass and FFmpeg read it: its colours,
1568
+ # and its YCbCr Matrix header, are the file's.
1569
+ vf = drawn_chain(vf, meta, [args.color, args.outline_color]
1570
+ + ([args.highlight_color, args.upcoming_color] if args.karaoke else []))
1563
1571
 
1564
1572
  cmd = ffmpeg_base() + ["-i", args.input]
1565
- chains, emoji_inputs = emoji_filter_chain(emoji_plan or {}, "vsub", "vout") if emoji_plan else ([], [])
1573
+ chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
1574
+ base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
1575
+ if emoji_plan else ([], []))
1566
1576
  if chains:
1567
1577
  for spec in emoji_inputs:
1568
1578
  cmd += spec
package/scripts/export.py CHANGED
@@ -165,7 +165,10 @@ def main() -> int:
165
165
  if args.fit == "crop":
166
166
  vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=increase", f"crop={p['w']}:{p['h']}"]
167
167
  else:
168
- vf.append(pad_filters(p["w"], p["h"], args.pad_fill, args.pad_color, args.pad_blur))
168
+ # a --pad-color with chroma is drawn in the BT.709 the line is tagged with; a GIF is
169
+ # not tagged, and palettegen reads the pad back with the same BT.601 it was drawn in
170
+ vf.append(pad_filters(p["w"], p["h"], args.pad_fill, args.pad_color, args.pad_blur,
171
+ meta=None if args.preset == "gif" else meta))
169
172
  vf.append("setsar=1")
170
173
  else:
171
174
  vf.append(f"scale={p['w']}:-2")
package/scripts/fit.py CHANGED
@@ -202,7 +202,7 @@ def main() -> int:
202
202
  "run color.py --to-sdr first for the SDR look")
203
203
  vf.append(pad_filters(out_w, out_h, "blur", args.pad_color, args.pad_blur, darken))
204
204
  else:
205
- vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur))
205
+ vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur, meta=meta))
206
206
  vf.append("setsar=1")
207
207
 
208
208
  if args.fps:
@@ -33,8 +33,8 @@ from typing import List, Optional
33
33
 
34
34
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
35
35
  from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
36
- color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path,
37
- ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args,
36
+ color_hex, default_font_file, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
37
+ ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
38
38
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
39
39
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
40
40
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
@@ -42,6 +42,9 @@ from _common import (aac_args, add_common, brand_caption_style, script_font_for_
42
42
  SAFE_WIDTH_FRACTION)
43
43
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
44
44
 
45
+ # where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
46
+ TEXT = "@TEXT@"
47
+ BOX = "@BOX@" # a box's place in the filter chain, followed by its index (graphics.main fill_box)
45
48
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
46
49
 
47
50
 
@@ -185,6 +188,35 @@ def main() -> int:
185
188
  en = f"enable='between(t,{s:.3f},{e:.3f})'"
186
189
  base = min(W, H) * args.scale # scale everything from the short side
187
190
  filters: List[str] = []
191
+ rate = f"{meta['video'].get('fps') or 30:g}"
192
+ # A drawn colour lands in the BT.709 the output is tagged with. When a template's box has
193
+ # chroma, its boxes are colour sources made in RGB and converted (rgb_chain_to_bt709), overlaid
194
+ # under the text, in order: drawbox turns its colour into YUV with BT.601 on every FFmpeg (and
195
+ # blends 4:2:0 chroma four times over, so a translucent box was nearly opaque in colour). A
196
+ # template whose boxes are all grey keeps its drawboxes, which are the same under every matrix.
197
+ # The text (drawtext, libass) goes through drawn_chain() at the end.
198
+ boxes: List[tuple] = [] # (colour, alpha, drawbox geometry, x, y, w, h, enable, x_expr), in order
199
+ layers: List[tuple] = [] # (colour source chain, overlay position and enable), under the text
200
+ converted: List[str] = [] # the colour sources layer_src() converted
201
+
202
+ def fill_box(colour: str, alpha: float, drawbox_geo: str, x: int, y: int, w: int, h: int, enable: str,
203
+ x_expr: Optional[str] = None) -> None:
204
+ """A filled box: a drawbox (`drawbox_geo`) at its place in the filter chain, or a colour layer
205
+ of w x h at x, y (or at the per-frame `x_expr`) -- decided for all of a template's boxes."""
206
+ filters.append(f"{BOX}{len(boxes)}")
207
+ boxes.append((colour, alpha, drawbox_geo, x, y, w, h, enable, x_expr))
208
+
209
+ def layer_src(colour: str, alpha: float, w: int, h: int, left: int = 0, top: int = 0) -> str:
210
+ """A colour source to overlay (a box, the lower-third bar, the progress fill), converted when it
211
+ has chroma. overlay places a 4:2:0 picture on even rows and columns only: a box that starts on
212
+ an odd one is placed one earlier with a transparent first column/row (`left`, `top`), so it
213
+ covers the rows and columns the drawbox did."""
214
+ conv = rgb_chain_to_bt709(meta) if draws_chroma([colour], meta) else ""
215
+ if conv:
216
+ converted.append(colour)
217
+ edge = f",pad={w + left}:{h + top}:{left}:{top}:color=black@0" if left or top else ""
218
+ return (f"color=c=0x{colour}" + (f"@{alpha:g}" if alpha < 1 else "") + f":s={w}x{h}:r={rate},format=rgba"
219
+ + edge + conv)
188
220
  fade_a = f"if(lt(t,{s:.3f}+0.3),(t-{s:.3f})/0.3,if(gt(t,{e:.3f}-0.3),({e:.3f}-t)/0.3,1))"
189
221
 
190
222
  # --- which renderer draws the text (1.15) --------------------------------------------------
@@ -333,8 +365,8 @@ def main() -> int:
333
365
  y0 = H - m_bottom - bar_h
334
366
  # slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
335
367
  x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
336
- fc.append(f"color=c=0x{bg}@0.85:s={bar_w}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[bar]")
337
- fc.append(f"color=c=0x{primary}:s={int(base * 0.012)}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[acc]")
368
+ fc.append(f"{layer_src(bg, 0.85, bar_w, bar_h)}[bar]")
369
+ fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
338
370
  fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
339
371
  fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
340
372
  tx_pad = int(base * 0.035)
@@ -361,14 +393,16 @@ def main() -> int:
361
393
  align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
362
394
  outline_color="000000", start=max(s, e - 0.3), end=e,
363
395
  move=(x_rest, ty, x_off, ty, 0, 300), x_expr=tx))
364
- fc.append(f"[v2]{','.join(draws)}[vout]" if route != "ass" else "[v2]null[vout]")
396
+ fc.append(f"[v2]{TEXT}[vout]" if route != "ass" else "[v2]null[vout]")
397
+ if route != "ass":
398
+ filters.extend(draws) # the text chain, which drawn_chain() places at TEXT
365
399
 
366
400
  elif args.template == "title":
367
401
  if not args.title:
368
402
  die("title needs --title")
369
403
  h1 = int(base * 0.11)
370
404
  h2 = int(base * 0.045)
371
- filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
405
+ fill_box(bg, 0.55, "x=0:y=0:w=iw:h=ih", 0, 0, W, H, en)
372
406
  # the title is centred as a block, so the bar and the subtitle sit below its last line:
373
407
  # `title_h` is the block's height, as the one-line layout has always taken h1; a further
374
408
  # subtitle line hangs below the first, whose position the one-line layout fixes
@@ -380,7 +414,9 @@ def main() -> int:
380
414
  size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
381
415
  y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
382
416
  outline_color="000000", start=s, end=e, fade=(300, 300))
383
- filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{title_h // 2}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
417
+ acc_w, acc_h = int(base * 0.12), max(2, int(base * 0.006))
418
+ fill_box(primary, 1.0, f"x=(iw-{acc_w})/2:y=(ih)/2+{title_h // 2}:w={acc_w}:h={acc_h}",
419
+ (W - acc_w) // 2, H // 2 + title_h // 2, acc_w, acc_h, en)
384
420
  if args.subtitle:
385
421
  add_text(args.subtitle,
386
422
  f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{sub_y + sub_extra // 2}:alpha='{fade_a}':{en}",
@@ -412,8 +448,12 @@ def main() -> int:
412
448
  elif args.template == "progress":
413
449
  h = max(3, int(base * 0.008))
414
450
  fps = meta['video'].get('fps') or 30
415
- fc.append(f"color=c=0x{primary}:s={W}x{h}:r={fps:g},format=rgba[pb]")
416
- fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
451
+ fc.append(f"{layer_src(primary, 1.0, W, h)}[pb]")
452
+ if draws_chroma([bg], meta):
453
+ fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
454
+ fc.append(f"[0:v][pbg]overlay=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
455
+ else:
456
+ fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
417
457
  fc.append(f"[v1][pb]overlay=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
418
458
 
419
459
  elif args.template == "sticker":
@@ -465,14 +505,15 @@ def main() -> int:
465
505
  # drawing white text on the bare video. The band is clipped at the frame edges.
466
506
  band_h = min(H, max(int(base * 0.30), block_h(hook_title, h1) + int(h1 * 0.7)))
467
507
  y0 = (H - band_h) // 2
468
- filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
508
+ fill_box(bg, 0.78, f"x=0:y={y0}:w=iw:h={band_h}", 0, y0, W, band_h, hen)
469
509
  add_text(hook_title,
470
510
  f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}"
471
511
  f"{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
472
512
  size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
473
513
  outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
474
- filters.append(f"drawbox=x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}:"
475
- f"color={ff_color(primary)}:t=fill:{hen}")
514
+ # the bar empties from the right: the overlay slides a full-width bar out to the left
515
+ fill_box(primary, 1.0, f"x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}", 0, 0, W, bar_h,
516
+ hen, x_expr=f"'-W*min(1,max(0,(t-{s:.3f})/{max(0.001, he - s):.3f}))'")
476
517
 
477
518
  elif args.template == "meme":
478
519
  # The classic layout: heavy white upper-case lines with a black outline, top and bottom,
@@ -588,18 +629,47 @@ def main() -> int:
588
629
  "clusters": sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
589
630
  "assets": emoji_assets, "missing": [], "overlays": 0}
590
631
 
632
+ # The boxes with chroma (layers) go under the text, and the picture they and a converted colour
633
+ # source are overlaid on gets its BT.709 label (an untagged one, tags only), so 7.1+'s overlay
634
+ # converts neither picture to the other's matrix. The text is drawn in BT.709 (drawn_chain).
635
+ as_layers = any(draws_chroma([b[0]], meta) for b in boxes)
636
+ drawboxes = []
637
+ for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
638
+ if as_layers:
639
+ layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2), f"x={x_expr or x - x % 2}:y={y - y % 2}:{enable}"))
640
+ drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
641
+ filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
642
+ filters = [f for f in filters if f]
643
+ base_label = label_chain(meta, meta) if (layers or converted) else ""
644
+ text = (drawn_chain(",".join(filters), meta, re.findall(r"color=([0-9A-Za-z#@.]+)", ",".join(filters)),
645
+ labelled=bool(base_label)) if filters else "")
646
+ if layers:
647
+ cur = "0:v"
648
+ for k, (src, pos) in enumerate(layers):
649
+ fc.append(f"{src}[gl{k}]")
650
+ fc.append(f"[{cur}][gl{k}]overlay={pos}:eof_action=pass[gv{k}]")
651
+ cur = f"gv{k}"
652
+ fc.append(f"[{cur}]{TEXT}[vout]")
653
+ fc = [c.replace(TEXT, text or "null") for c in fc]
654
+ if fc and base_label:
655
+ fc = [f"[0:v]{base_label}null[gbase]"] + [c.replace("[0:v]", "[gbase]") for c in fc]
656
+
591
657
  cmd = ffmpeg_base() + ["-i", args.input]
592
658
  if route == "ass" or emoji_overlays:
593
- chains = list(fc) if fc else [f"[0:v]{','.join(filters) if filters else 'null'}[vout]"]
659
+ chains = list(fc) if fc else [f"[0:v]{text or 'null'}[vout]"]
594
660
  last = "vout"
595
661
  if route == "ass":
596
662
  vf = f"ass={escape_filter_path(ass_path)}"
597
663
  fdir = ass_fonts_dir()
598
664
  if fdir:
599
665
  vf += f":fontsdir={escape_filter_path(fdir)}"
666
+ vf = drawn_chain(vf, meta, [el.get(k) for el in elements for k in ("color", "outline_color", "box_color")],
667
+ labelled=bool(base_label))
600
668
  chains.append(f"[{last}]{vf}[vtxt]")
601
669
  last = "vtxt"
602
- eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1)
670
+ eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
671
+ to_yuv=rgb_chain_to_bt709(meta),
672
+ base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
603
673
  for spec in emoji_inputs:
604
674
  cmd += spec
605
675
  asset = spec[-1]
@@ -613,7 +683,7 @@ def main() -> int:
613
683
  elif fc:
614
684
  cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
615
685
  else:
616
- cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
686
+ cmd += ["-vf", text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
617
687
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
618
688
  cmd += aac_args() if meta.get("audio") else ["-an"]
619
689
  dropped_streams = run_keeping_subtitles(cmd, output)
package/scripts/grid.py CHANGED
@@ -26,7 +26,7 @@ import os
26
26
  import sys
27
27
 
28
28
  from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, fmt_secs, \
29
- escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
29
+ draws_chroma, drawn_chain, escape_drawtext, escape_filter_path, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
30
30
 
31
31
  LABEL_MARGIN = 10
32
32
 
@@ -91,25 +91,43 @@ def main() -> int:
91
91
  for p in args.inputs:
92
92
  cmd += ["-i", p]
93
93
 
94
+ # each cell converted by its own tags to the BT.709 the grid is tagged with (a BT.601 or an RGB
95
+ # input): without it 5.1/6.1 relabelled them, and 7.1+ converted cells to one another's matrix.
96
+ # Beside a converted cell an untagged one gets a BT.709 label, so 7.1+'s xstack has nothing to
97
+ # convert; a grid of untagged/BT.709 clips is what it was.
98
+ # A --background or --font-color with chroma is drawn in the BT.709 the grid is tagged with
99
+ # (drawn_chain): every untagged cell is labelled for it too, and the gaps are each cell's own
100
+ # pad instead of xstack's fill, which 5.1/6.1 draw in BT.601 (a grey needs neither).
101
+ colours = [args.background] + ([args.font_color] if args.label == "auto" else [])
102
+ chroma = draws_chroma(colours, None)
103
+ convert = [matrix_chain(m, None) for m in metas]
104
+ if any(convert) or chroma:
105
+ convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
94
106
  parts = []
95
107
  for i, p in enumerate(args.inputs):
96
- chain = [
97
- f"fps={args.fps:g}",
98
- f"scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease",
99
- f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color={args.background}",
100
- "setsar=1",
108
+ drawn = [ # (with this colour, with black)
109
+ (f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color={args.background}",
110
+ f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color=black"),
111
+ ("setsar=1",) * 2,
101
112
  ]
102
113
  if args.pad and durations[i] < target_duration:
103
- chain.append(f"tpad=stop_mode=clone:stop_duration={target_duration - durations[i]:.3f}")
114
+ drawn.append((f"tpad=stop_mode=clone:stop_duration={target_duration - durations[i]:.3f}",) * 2)
104
115
  elif not args.pad:
105
- chain.append(f"trim=duration={target_duration:.3f}")
116
+ drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
106
117
  if args.label == "auto":
107
118
  stem = os.path.splitext(os.path.basename(p))[0]
108
119
  font_opt = f"fontfile={escape_filter_path(font_file)}" if font_file else f"font='{escape_drawtext(args.font)}'"
109
- chain.append(f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
110
- f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
111
- f"box=1:boxcolor=black@0.5:boxborderw=4")
112
- parts.append(f"[{i}:v]{','.join(chain)}[v{i}]")
120
+ drawn.append((f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
121
+ f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
122
+ f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
123
+ gap_r = args.gap if i % args.cols < args.cols - 1 else 0
124
+ gap_b = args.gap if i // args.cols < args.rows - 1 else 0
125
+ if chroma and (gap_r or gap_b):
126
+ drawn.append((f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color={args.background}", f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color=black"))
127
+ chain = f"fps={args.fps:g},scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
128
+ chain += drawn_chain(",".join(c for c, _n in drawn), metas[i], colours, ",".join(n for _c, n in drawn),
129
+ line_meta=None, tag=f"c{i}", labelled=True)
130
+ parts.append(f"[{i}:v]{convert[i]}{chain}[v{i}]")
113
131
 
114
132
  cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
115
133
  layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
package/scripts/insert.py CHANGED
@@ -116,7 +116,9 @@ def main() -> int:
116
116
 
117
117
  output = args.output or default_output(args.input, "insert", "mp4")
118
118
  cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
119
- cmd += video_args(None, args.crf, args.preset)
119
+ # the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
120
+ # the BT.709 that line is tagged with
121
+ cmd += video_args(None, args.crf, args.preset, source=meta)
120
122
  cmd += ["-an", output]
121
123
  run(cmd)
122
124
 
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, 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
42
+ from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, 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"]
@@ -478,14 +478,26 @@ def main() -> int:
478
478
  added += 1
479
479
  cmd += extra_inputs
480
480
 
481
- if args.fit == "crop":
482
- geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
483
- else:
484
- geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
485
481
  # Any HDR input makes the join HDR (10-bit, HEVC via video_args on that clip's tags): an SDR
486
482
  # first clip used to drag an HDR second clip down to 8-bit without a tone map.
487
483
  hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
488
484
  pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
485
+ line = hdr_meta or metas[0]
486
+ # a BT.601 or RGB clip is converted to the BT.709 the join is tagged with, each clip by its own
487
+ # tags; beside a converted clip (or under a --pad-color with chroma) an untagged one gets its
488
+ # BT.709 label, tags only, so 7.1+'s concat/xfade and pad have nothing to convert or misread
489
+ convert = [matrix_chain(m, line) for m in metas]
490
+ chroma_pad = args.fit != "crop" and draws_chroma([args.pad_color], line)
491
+ if any(convert) or chroma_pad:
492
+ convert = [c or label_chain(m, line) for c, m in zip(convert, metas)]
493
+
494
+ def geo(i: int) -> str:
495
+ if args.fit == "crop":
496
+ return f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
497
+ pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
498
+ return (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
499
+ + drawn_chain(pad + args.pad_color, metas[i], [args.pad_color], pad + "black", line_meta=line,
500
+ tag=f"pad{i}", labelled=True))
489
501
  # the audio-only join keeps the widest layout; the video join used to force stereo and
490
502
  # silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
491
503
  # layout: plan from the inputs that exist, as join_audio() does.
@@ -497,8 +509,7 @@ def main() -> int:
497
509
  if len(set(chans)) > 1:
498
510
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
499
511
  for i in range(n):
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}]")
512
+ parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
502
513
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
503
514
 
504
515
  # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
@@ -31,7 +31,7 @@ import math
31
31
  import sys
32
32
  from typing import Dict, List, Tuple
33
33
 
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
34
+ from _common import label_chain, 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
@@ -269,6 +269,12 @@ def main() -> int:
269
269
  parts: List[str] = []
270
270
  labels: List[str] = []
271
271
  project_clips: List[Dict] = []
272
+ # each camera converted by its own colour tags to the BT.709 the cut is tagged with; beside a
273
+ # converted camera an untagged one gets its BT.709 label, tags only, so 7.1+'s concat does not
274
+ # convert one to the other's "unknown" (BT.601) matrix
275
+ convert = [matrix_chain(m, metas[0]) for m in metas]
276
+ if any(convert):
277
+ convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
272
278
  for i, (s, e, c) in enumerate(filled):
273
279
  # reference time t maps to source time (t - offset_c) * ratio_c
274
280
  src_s = (s - offsets[c]) * ratios[c]
@@ -276,8 +282,7 @@ def main() -> int:
276
282
  if src_s < 0:
277
283
  info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
278
284
  c, src_s, src_e = 0, s, e
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}]")
285
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
281
286
  labels.append(f"[v{i}]")
282
287
  project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
283
288
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
@@ -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, 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
28
+ from _common import STATE, script_font_for_text, drawtext_text_opts, 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
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
@@ -266,7 +266,12 @@ def main() -> int:
266
266
  ov += f":enable='{enable}'"
267
267
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
268
268
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
269
- fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
269
+ # the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
270
+ # RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
271
+ # 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
272
+ base = label_chain(meta, meta) if rgb_chain_to_bt709(meta) else ""
273
+ fc = (f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS{rgb_chain_to_bt709(meta)}[ov];"
274
+ + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
270
275
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
271
276
  if meta.get("duration"):
272
277
  # An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
@@ -299,9 +304,13 @@ def main() -> int:
299
304
  if enable:
300
305
  ov += f":enable='{enable}'"
301
306
  cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
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];"
307
+ # both pictures converted by their own colour tags to the BT.709 the output is tagged with;
308
+ # beside a converted one an untagged one gets its BT.709 label, tags only, so 7.1+'s
309
+ # overlay does not convert one to the other's "unknown" (BT.601) matrix
310
+ base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
311
+ if base or pip:
312
+ base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
313
+ fc = (f"[1:v]{pip}{','.join(chain)}[ov];"
305
314
  + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
306
315
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
307
316
  if meta.get("duration"):
@@ -328,7 +337,9 @@ def main() -> int:
328
337
  opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
329
338
  if enable:
330
339
  opts.append(f"enable='{enable}'")
331
- cmd += ["-vf", "drawtext=" + ":".join(opts), "-map", "0:v:0"]
340
+ # a font/border/box colour with chroma lands in the BT.709 the output is tagged with
341
+ colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
342
+ cmd += ["-vf", drawn_chain("drawtext=" + ":".join(opts), meta, colours), "-map", "0:v:0"]
332
343
  if meta.get("audio"):
333
344
  cmd += ["-map", f"0:a:{args.audio_stream}"]
334
345
 
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -46,7 +46,10 @@ def main() -> int:
46
46
  has_audio = bool(meta.get("audio"))
47
47
  output = args.output or default_output(args.input, "pad")
48
48
 
49
- vf = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color={args.color}"
49
+ tpad = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color="
50
+ # a --color with chroma lands in the BT.709 the output is tagged with (tpad's own YUV is BT.601
51
+ # before FFmpeg 7.1, and for an untagged picture after it); black is the same under every matrix
52
+ vf = drawn_chain(tpad + args.color, meta, [args.color], tpad + "black")
50
53
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
51
54
  if has_audio:
52
55
  af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
@@ -131,7 +131,8 @@ def main() -> int:
131
131
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", str(list_path)]
132
132
  vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={args.fps:g}"]
133
133
  cmd += ["-vf", ",".join(vf)]
134
- cmd += video_args(None, args.crf, args.preset)
134
+ # RGB (PNG) frames are converted to the BT.709 the line is tagged with (the first frame's probe)
135
+ cmd += video_args(None, args.crf, args.preset, source=frame_meta)
135
136
  # The concat demuxer's trailing repeated-last-file trick (needed so the last real file's
136
137
  # duration line takes effect) has been observed to produce an extra frame's worth of
137
138
  # duration on some ffmpeg builds -- force the exact intended length rather than trust it.
@@ -22,7 +22,7 @@ import argparse
22
22
  import math
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
 
27
27
 
28
28
  def main() -> int:
@@ -63,8 +63,10 @@ def main() -> int:
63
63
 
64
64
  radians = math.radians(args.degrees)
65
65
  if args.fit == "pad":
66
- rotate = (f"rotate={radians:.8f}:fillcolor={args.fill_color}:"
67
- f"ow=trunc(rotw({radians:.8f})/2)*2:oh=trunc(roth({radians:.8f})/2)*2")
66
+ geometry = f"ow=trunc(rotw({radians:.8f})/2)*2:oh=trunc(roth({radians:.8f})/2)*2"
67
+ # a --fill-color with chroma lands in the BT.709 the output is tagged with (drawn_chain)
68
+ rotate = drawn_chain(f"rotate={radians:.8f}:fillcolor={args.fill_color}:{geometry}", meta, [args.fill_color],
69
+ f"rotate={radians:.8f}:fillcolor=black:{geometry}")
68
70
  else:
69
71
  # Pre-scale the frame up by a safe, conservative factor (|cos|+|sin|, the exact growth
70
72
  # factor for a square, over-generous for a rectangle) so the rotated content fully
@@ -35,8 +35,8 @@ import os
35
35
  import sys
36
36
 
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
- from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
39
- info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
38
+ from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
39
+ info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
40
40
  fmt_secs, child_args, run_tool, measured_level_dbfs)
41
41
  from _common.emit import absorb_stage_hw
42
42
 
@@ -185,13 +185,22 @@ def main() -> int:
185
185
  graph = f"[1:v]{plate},format=rgba[plate];[0:a:{args.audio_stream}]{vf},format=rgba"
186
186
  if args.opacity < 1.0:
187
187
  graph += f",colorchannelmixer=aa={args.opacity:g}"
188
- graph += f"[vis];[plate][vis]overlay=x=(W-w)/2:y={vis_y}:format=auto[v]"
188
+ # the composite is RGBA (the plate is): converted to the BT.709 the output is tagged with,
189
+ # not by the encoder's BT.601 default
190
+ graph += f"[vis];[plate][vis]overlay=x=(W-w)/2:y={vis_y}:format=auto{rgb_chain_to_bt709(None, 'yuv420p')}[v]"
189
191
  vf = graph
190
192
  else:
191
193
  # showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
192
194
  # it over an explicit solid background instead of assuming that canvas already matches
193
195
  # --background.
194
- vf = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}[bg];[0:a:{args.audio_stream}]{vf}[vis];[bg][vis]overlay=format=auto"
196
+ bg, vis = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}", vf
197
+ if draws_chroma([args.background] + (args.color.split("|") if args.style == "waveform" else []), None):
198
+ # the background and the RGBA waves are converted to the BT.709 the output is tagged
199
+ # with (the encoder line's own RGB-to-YUV step used BT.601); the spectrum is YUV of its
200
+ # own and only gets the label the background carries, so 7.1+'s overlay converts neither
201
+ bg += ",format=rgb24" + rgb_chain_to_bt709(None, "yuv420p")
202
+ vis += rgb_chain_to_bt709(None, "yuva420p") if args.style == "waveform" else ",setparams=colorspace=bt709"
203
+ vf = f"{bg}[bg];[0:a:{args.audio_stream}]{vis}[vis];[bg][vis]overlay=format=auto"
195
204
 
196
205
  render = output
197
206
  if args.title or args.srt or args.text: