ffmpeg-skill 2.10.1 → 2.10.2

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.2`) | 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.2", "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,12 @@ 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.
224
229
  `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
230
  BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
226
231
  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.2",
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",
@@ -11,7 +11,10 @@ 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. When `probe.py` reports
15
18
  `bt2020_or_hdr: true` (HDR10/PQ, HLG, Dolby Vision, or BT.2020 SDR), every editing script keeps
16
19
  the output on the 10-bit path (HEVC Main10, source colour tags) so nothing is silently
17
20
  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
@@ -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, 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, fold_bt709_matrix,
95
+ is_rgb, NON_BT709_MATRICES, 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", "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,44 @@ 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
+
78
126
  def bt709_matrix_args(meta: Optional[Dict[str, Any]]) -> List[str]:
79
127
  """["-vf", bt709_matrix_filter(meta)] for an encoder line that tags its output BT.709, or [].
80
128
 
@@ -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_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, rgb_to_bt709_filter, _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
 
@@ -512,7 +512,8 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
512
512
  return args + (bt709_matrix_args(source) if keep_bt709 and STATE.codec != "prores" else [])
513
513
 
514
514
 
515
- def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True) -> List[str]:
515
+ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True,
516
+ source: Optional[Dict[str, Any]] = None) -> List[str]:
516
517
  """Encoder args that preserve what the source is.
517
518
 
518
519
  SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
@@ -525,9 +526,14 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
525
526
  folds into the tool's own graph (color.fold_bt709_matrix). `convert_matrix=False` is for a
526
527
  tool that mixes several sources and converts each one in its own graph (join, multicam,
527
528
  broll, overlay --video). --codec prores keeps the source's tags, so it converts nothing.
529
+ An RGB source (a PNG, an image sequence, a qtrle clip) is converted the same way.
530
+
531
+ `source`: the probe of the picture the encoder gets when the line is chosen without it
532
+ (insert.py and sequence.py make a still or a PNG sequence into video on the SDR line,
533
+ video_args(None)); its RGB or BT.601 pixels are converted to the BT.709 that line is tagged with.
528
534
  """
529
535
  v = (meta or {}).get("video") or {}
530
- fix = bt709_matrix_args(meta) if convert_matrix and STATE.codec != "prores" else []
536
+ fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and STATE.codec != "prores" else []
531
537
  if STATE.codec:
532
538
  return encoder_args(STATE.codec, crf, preset, meta) + fix
533
539
  if not v.get("bt2020_or_hdr"):
@@ -546,6 +552,28 @@ def matrix_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, A
546
552
  return f + "," if f else ""
547
553
 
548
554
 
555
+ def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
556
+ """For a picture that meets a converted one in a mixing filter (a still over the video, a grid
557
+ cell beside a converted cell), encoded through video_args(line_meta): the BT.709 label an
558
+ untagged one's chain starts with ("<setparams>," -- bt709_label_filter), or "" (a tagged, RGB or
559
+ HDR picture, or a line that does not tag BT.709)."""
560
+ if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
561
+ return ""
562
+ f = bt709_label_filter(meta)
563
+ return f + "," if f else ""
564
+
565
+
566
+ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva420p") -> str:
567
+ """For a picture the tool's own graph has made RGB(A) -- a still's `format=rgba` chain
568
+ (overlay.py --image, an emoji PNG) or an RGBA composite (waveform.py's --image plate) -- that
569
+ is encoded through video_args(line_meta): "," + the conversion to BT.709 YUV to end that chain
570
+ with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
571
+ leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
572
+ if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
573
+ return ""
574
+ return "," + rgb_to_bt709_filter(pix_fmt)
575
+
576
+
549
577
  def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
550
578
  keep_hevc: bool = False) -> List[str]:
551
579
  """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)
@@ -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, 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
@@ -1562,7 +1562,9 @@ def main() -> int:
1562
1562
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
1563
1563
 
1564
1564
  cmd = ffmpeg_base() + ["-i", args.input]
1565
- chains, emoji_inputs = emoji_filter_chain(emoji_plan or {}, "vsub", "vout") if emoji_plan else ([], [])
1565
+ chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
1566
+ base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
1567
+ if emoji_plan else ([], []))
1566
1568
  if chains:
1567
1569
  for spec in emoji_inputs:
1568
1570
  cmd += spec
@@ -34,7 +34,7 @@ from typing import List, Optional
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
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,
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,
@@ -599,7 +599,9 @@ def main() -> int:
599
599
  vf += f":fontsdir={escape_filter_path(fdir)}"
600
600
  chains.append(f"[{last}]{vf}[vtxt]")
601
601
  last = "vtxt"
602
- eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1)
602
+ eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
603
+ to_yuv=rgb_chain_to_bt709(meta),
604
+ base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
603
605
  for spec in emoji_inputs:
604
606
  cmd += spec
605
607
  asset = spec[-1]
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
+ 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,6 +91,13 @@ 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
+ convert = [matrix_chain(m, None) for m in metas]
99
+ if any(convert):
100
+ convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
94
101
  parts = []
95
102
  for i, p in enumerate(args.inputs):
96
103
  chain = [
@@ -109,7 +116,7 @@ def main() -> int:
109
116
  chain.append(f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
110
117
  f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
111
118
  f"box=1:boxcolor=black@0.5:boxborderw=4")
112
- parts.append(f"[{i}:v]{','.join(chain)}[v{i}]")
119
+ parts.append(f"[{i}:v]{convert[i]}{','.join(chain)}[v{i}]")
113
120
 
114
121
  cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
115
122
  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
 
@@ -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, 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
@@ -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.
@@ -36,7 +36,7 @@ import sys
36
36
 
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
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,
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,7 +185,9 @@ 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