ffmpeg-skill 3.1.2 → 3.1.4

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
@@ -1,14 +1,7 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="brand/app-icon-dark.png">
4
- <img src="brand/app-icon.png" width="128" alt="ffmpeg-skill app icon">
5
- </picture>
6
- </p>
7
-
8
- <p align="center">
9
- <picture>
10
- <source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-wordmark-white.svg">
11
- <img src="brand/logo/ffmpeg-skill-wordmark-black.svg" height="48" alt="ffmpeg-skill">
3
+ <source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-horizontal-on-dark.svg">
4
+ <img src="brand/logo/ffmpeg-skill-horizontal.svg" height="72" alt="ffmpeg-skill">
12
5
  </picture>
13
6
  </p>
14
7
 
@@ -17,6 +10,7 @@ Claude Code, Cursor, Codex or any MCP client edits your footage with local FFmpe
17
10
  No cloud · No API keys · Python standard library</p>
18
11
 
19
12
  <p align="center">
13
+ <a href="https://kajisho5.github.io/ffmpeg-skill/"><b>Website</b></a> ·
20
14
  <a href="#quick-start"><b>Quick start</b></a> ·
21
15
  <a href="docs/demos.md">Demos</a> ·
22
16
  <a href="#tools">Tools</a> ·
@@ -32,6 +26,10 @@ No cloud · No API keys · Python standard library</p>
32
26
  <a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
33
27
  </p>
34
28
 
29
+ <p align="center">
30
+ <a href="https://kajisho5.github.io/ffmpeg-skill/"><img src="brand/og.png" width="720" alt="ffmpeg-skill v3: give your coding agent a video editor. An agent runs probe, cut, fit, export and check, and the final file is measured: 59.98 s, 1080x1920, 30 fps."></a>
31
+ </p>
32
+
35
33
  ```bash
36
34
  npx ffmpeg-skill
37
35
  ```
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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.1.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.4`) | 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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.1.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.4", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
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",
@@ -474,7 +474,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
474
474
  return _x264_raw(crf, preset, keep_bt709)
475
475
  if codec == "hevc":
476
476
  if hdr:
477
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
477
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
478
478
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(_clamped_crf("libx265", crf + 2, 51, crf)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
479
479
  "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
480
480
  params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
@@ -485,7 +485,8 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
485
485
  if "libsvtav1" in ffmpeg_encoders():
486
486
  args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(_clamped_crf("libsvtav1", crf, 63, crf)), "-pix_fmt", pix]
487
487
  if hdr:
488
- args += hdr_tags
488
+ svt = hdr10_svtav1_params(v)
489
+ args += (["-svtav1-params", svt] if svt else []) + hdr_tags
489
490
  elif keep_bt709:
490
491
  params, extra = _sdr_bt709("libsvtav1")
491
492
  args += (["-svtav1-params", params] if params else []) + extra
@@ -508,6 +509,42 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
508
509
  return []
509
510
 
510
511
 
512
+ def hdr10_x265_params(v: Dict[str, Any]) -> str:
513
+ """The source's HDR10 static metadata as x265 options (":master-display=...:max-cll=..."), or "".
514
+ FFmpeg before 7.0 does not pass the decoded frames' mastering-display / content-light side data
515
+ to libx265, so without these a 6.1 re-encode wrote PQ video with neither (#401). Given on 7+ too,
516
+ where they restate what FFmpeg would pass. x265 units: chromaticity 0.00002, luminance 0.0001 cd/m2."""
517
+ md, cl = _hdr10_parts(v)
518
+ out = ""
519
+ if md:
520
+ c = lambda k: round(md[k] * 50000)
521
+ out += (f":master-display=G({c('green_x')},{c('green_y')})B({c('blue_x')},{c('blue_y')})R({c('red_x')},{c('red_y')})"
522
+ f"WP({c('white_point_x')},{c('white_point_y')})L({round(md['max_luminance'] * 10000)},{round(md['min_luminance'] * 10000)})")
523
+ if cl:
524
+ out += f":max-cll={cl['max_content']},{cl['max_average']}"
525
+ return out
526
+
527
+
528
+ def hdr10_svtav1_params(v: Dict[str, Any]) -> str:
529
+ """The same for SVT-AV1 (-svtav1-params), which takes chromaticities and cd/m2 as decimals."""
530
+ md, cl = _hdr10_parts(v)
531
+ parts = []
532
+ if md:
533
+ f = lambda k: f"{md[k]:.5f}".rstrip("0").rstrip(".")
534
+ parts.append(f"mastering-display=G({f('green_x')},{f('green_y')})B({f('blue_x')},{f('blue_y')})R({f('red_x')},{f('red_y')})"
535
+ f"WP({f('white_point_x')},{f('white_point_y')})L({f('max_luminance')},{f('min_luminance')})")
536
+ if cl:
537
+ parts.append(f"content-light={cl['max_content']},{cl['max_average']}")
538
+ return ":".join(parts)
539
+
540
+
541
+ def _hdr10_parts(v: Dict[str, Any]) -> "Tuple[Optional[Dict[str, float]], Optional[Dict[str, int]]]":
542
+ st = v.get("hdr10_static") or {}
543
+ if v.get("color_transfer") != "smpte2084":
544
+ return None, None
545
+ return st.get("mastering_display"), st.get("content_light")
546
+
547
+
511
548
  def _clamped_crf(encoder: str, value: int, top: int, requested: int) -> int:
512
549
  """`value` capped at the encoder's maximum CRF, the cap recorded (`crf_clamped`) when it
513
550
  bites: the HEVC HDR line asks x265 for CRF+2, so --quality 50 or 51 there is encoded at 51."""
@@ -667,7 +704,7 @@ def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
667
704
  cs = v.get("color_space") or "bt2020nc"
668
705
  prim = v.get("color_primaries") or "bt2020"
669
706
  trc = v.get("color_transfer") or "arib-std-b67"
670
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
707
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
671
708
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
672
709
  "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
673
710
 
@@ -13,6 +13,59 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
13
13
  from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
14
14
 
15
15
 
16
+ # Characters that never belong in a message a calling agent reads (#400): C0 controls (a line
17
+ # break is one), DEL and C1, the Unicode line/paragraph separators and the bidi embedding,
18
+ # override and isolate marks. A path or other argument carrying one would otherwise reach
19
+ # error.message / a diagnostic verbatim, where a newline can start what reads as a new line of
20
+ # instructions and a bidi override can reorder what is shown.
21
+ _UNSAFE = frozenset([chr(c) for c in range(0x20) if c not in (0x09, 0x0A)] + [chr(0x7F)]
22
+ + [chr(c) for c in range(0x80, 0xA0)]
23
+ + ["\u2028", "\u2029", "\u200e", "\u200f"] + [chr(c) for c in range(0x202A, 0x202F)]
24
+ + [chr(c) for c in range(0x2066, 0x206A)])
25
+ _LINE_BREAKS = frozenset("\n")
26
+
27
+
28
+ def shown(value: Any) -> str:
29
+ """`value` as text with every unsafe character (and any line break) written as an escape
30
+ (\\n, \\x1b, \\u202e). A clean value comes back unchanged, so an ordinary path, a Windows one
31
+ with backslashes included, reads exactly as before."""
32
+ text = str(value)
33
+ if not any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in text):
34
+ return text
35
+ return "".join((ch.encode("unicode_escape").decode("ascii") if (ch in _UNSAFE or ch in _LINE_BREAKS) else ch)
36
+ for ch in text)
37
+
38
+
39
+ def _argument_forms(arg: str) -> List[str]:
40
+ """The spellings a message may print an argument in: as given, resolved, and its file name;
41
+ for `--opt=value`, the same for the value, which is what a tool prints back."""
42
+ given = [arg] + ([arg.split("=", 1)[1]] if arg.startswith("-") and "=" in arg else [])
43
+ forms = set(given)
44
+ for a in given:
45
+ try:
46
+ forms.update({os.path.abspath(a), os.path.realpath(a), os.path.basename(a)})
47
+ except (OSError, ValueError):
48
+ pass
49
+ return sorted((f for f in forms if f), key=len, reverse=True)
50
+
51
+
52
+ def neutralize(msg: Any) -> Any:
53
+ """A message with caller-supplied text made inert (#400): every command-line argument that
54
+ carries a line break or an unsafe character is replaced by its shown() form wherever it
55
+ appears (as given, resolved or as a file name), then any unsafe character left is escaped.
56
+ Line breaks the tool itself wrote (an ffmpeg stderr tail) stay; one inside an argument does not."""
57
+ if not isinstance(msg, str):
58
+ return msg
59
+ for arg in sys.argv[1:]:
60
+ if isinstance(arg, str) and any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in arg):
61
+ for form in _argument_forms(arg):
62
+ if form in msg and (form != os.path.basename(arg) or any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in form)):
63
+ msg = msg.replace(form, shown(form))
64
+ if any(ch in _UNSAFE for ch in msg):
65
+ msg = "".join(ch.encode("unicode_escape").decode("ascii") if ch in _UNSAFE else ch for ch in msg)
66
+ return msg
67
+
68
+
16
69
  def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None,
17
70
  error_extra: "Optional[Dict[str, Any]]" = None, **extra: Any) -> "None":
18
71
  """Exit with a message. Under --json also print a machine-readable failure document
@@ -26,7 +79,8 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
26
79
 
27
80
  `error_extra` goes inside `error` (refuse() passes `reason` and `details` there); the
28
81
  collector's records (warn() and friends) are merged into the document as emit() merges them."""
29
- hint = extra.pop("hint", None)
82
+ hint = neutralize(extra.pop("hint", None))
83
+ msg = neutralize(msg)
30
84
  ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
31
85
  _set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
32
86
  ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
@@ -61,6 +115,52 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
61
115
  sys.exit(code)
62
116
 
63
117
 
118
+ def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
119
+ """The tools write one audio track: a source with several must say how many did not come
120
+ along (#397, every tool, not only cut.py). Only when the output kept sound at all: a tool told
121
+ to drop it (--no-audio, a picture-only output) left nothing out by surprise."""
122
+ first = getattr(STATE, "first_input", None)
123
+ if not first or first[1] < 2 or not meta:
124
+ return
125
+ if len(getattr(STATE, "audio_inputs", None) or ()) > 1:
126
+ return # several sources with sound: which one's tracks the output carries is the tool's own choice
127
+ kept = len(meta.get("audio_streams") or [])
128
+ if not kept or kept >= first[1]:
129
+ return
130
+ if any(w.get("code") == "audio_tracks_dropped" for w in ctx.warnings):
131
+ return # the tool said it already (cut.py, with its own field)
132
+ warn(f"the source has {first[1]} audio tracks; this output kept {kept} and left {first[1] - kept} out "
133
+ "(the tools write one audio track; --audio-stream N picks which, where a tool takes it)",
134
+ "audio_tracks_dropped", ctx=ctx, source=first[1], kept=kept, dropped=first[1] - kept)
135
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
136
+ if note_line and note_line not in ctx.notes:
137
+ ctx.notes.append(note_line)
138
+
139
+
140
+ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None:
141
+ """A PQ source's mastering-display / content-light metadata that a PQ output came out without
142
+ (#401): measured on the written file, so whatever encoder or FFmpeg version dropped it is said.
143
+ An output that is no longer PQ (color.py --to-sdr, an HLG conversion) was converted on purpose."""
144
+ first = getattr(STATE, "first_input", None)
145
+ src = first[2] if first and len(first) > 2 else None
146
+ v = (meta or {}).get("video") or {}
147
+ if not src or v.get("color_transfer") != "smpte2084":
148
+ return
149
+ if len(getattr(STATE, "video_inputs", None) or ()) > 1:
150
+ return # several pictures: the first input's metadata need not be the output's source
151
+ have = v.get("hdr10_static") or {}
152
+ lost = [k for k in ("mastering_display", "content_light") if src.get(k) and not have.get(k)]
153
+ if not lost:
154
+ return
155
+ names = {"mastering_display": "mastering display", "content_light": "content light level"}
156
+ warn("the source's HDR10 " + " and ".join(names[k] for k in lost) + " metadata did not come through to this output "
157
+ "(the encoder or this FFmpeg build did not write it); players may tone-map it differently",
158
+ "hdr10_metadata_dropped", ctx=ctx, dropped=lost, codec=v.get("codec"))
159
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
160
+ if note_line and note_line not in ctx.notes:
161
+ ctx.notes.append(note_line)
162
+
163
+
64
164
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
65
165
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
66
166
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -72,6 +172,7 @@ def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code
72
172
 
73
173
  def _record(bucket: str, msg: str, code: str, ctx: "Optional[Context]", data: Dict[str, Any], prefix: str) -> Dict[str, Any]:
74
174
  ctx = ctx or STATE
175
+ msg = neutralize(msg)
75
176
  entry: Dict[str, Any] = {"code": code, "message": msg}
76
177
  entry.update(data)
77
178
  # once per run: a decision taken for every encode of a job (an AV1 encoder fallback, a CRF
@@ -101,8 +202,9 @@ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data:
101
202
 
102
203
 
103
204
  def note(msg: str, ctx: "Optional[Context]" = None) -> None:
104
- """A plain `notes` line that also goes to stderr."""
205
+ """A plain `notes` line that also goes to stderr, made inert as messages are (#400)."""
105
206
  ctx = ctx or STATE
207
+ msg = neutralize(msg)
106
208
  if msg not in ctx.notes:
107
209
  ctx.notes.append(msg)
108
210
  sys.stderr.write(f"{msg}\n")
@@ -116,6 +218,9 @@ def _merge_collector(doc: Dict[str, Any], ctx: "Context") -> None:
116
218
  extra_notes = [n for n in ctx.notes if n not in (doc.get("notes") or [])]
117
219
  if extra_notes:
118
220
  doc["notes"] = list(doc.get("notes") or []) + extra_notes
221
+ if doc.get("notes"):
222
+ # a tool's own notes can quote an input path (join.py's duplicates): made inert as messages are (#400)
223
+ doc["notes"] = [neutralize(n) for n in doc["notes"]]
119
224
  diag = {k: list(getattr(ctx, k)) for k in ("warnings", "fallbacks", "skipped") if getattr(ctx, k)}
120
225
  if diag:
121
226
  merged = dict(doc.get("diagnostics") or {})
@@ -601,6 +706,8 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
601
706
  meta: Dict[str, Any] = {}
602
707
  if output and not ctx.dry_run:
603
708
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
709
+ _report_dropped_audio_tracks(meta, ctx)
710
+ _report_dropped_hdr10_metadata(meta, ctx)
604
711
  if ctx.json:
605
712
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
606
713
  if meta:
@@ -317,6 +317,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
317
317
  "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
318
318
  "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
319
319
  "dolby_vision": dovi,
320
+ # HDR10 static metadata (mastering display, content light level) as the encoders take it:
321
+ # FFmpeg before 7 does not hand it from the decoded frames to libx265 / libsvtav1, so the
322
+ # tools pass it explicitly (decision.hdr10_x265_params). None when the source has none.
323
+ "hdr10_static": _hdr10_static(ffprobe, path, video) if trc == "smpte2084" else None,
320
324
  "color_space": video.get("color_space"),
321
325
  "color_primaries": video.get("color_primaries"),
322
326
  "color_transfer": video.get("color_transfer"),
@@ -355,9 +359,76 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
355
359
  "language": (a.get("tags") or {}).get("language"),
356
360
  "title": (a.get("tags") or {}).get("title"),
357
361
  } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
362
+ if role == "input" and out.get("video") and out["video"].get("codec") \
363
+ and os.path.splitext(path)[1].lower() not in _STILL_EXT:
364
+ # a still (an overlay's logo, an insert card) is not a second picture source (#412 review)
365
+ STATE.video_inputs.add(os.path.realpath(path))
366
+ if role == "input" and out.get("audio_streams"):
367
+ STATE.audio_inputs.add(os.path.realpath(path))
368
+ if role == "input" and getattr(STATE, "first_input", None) is None:
369
+ STATE.first_input = (path, len(out.get("audio_streams") or []),
370
+ (out.get("video") or {}).get("hdr10_static"))
358
371
  return out
359
372
 
360
373
 
374
+ def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
375
+ """A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
376
+ a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
377
+ frame is decoded when either part is missing or incomplete in the stream. Each part is taken
378
+ from wherever it is complete first, the stream before the frame. Chromaticities are CIE 1931
379
+ x/y, luminances cd/m2. None when it has neither."""
380
+ out = _hdr10_parts(stream.get("side_data_list") or [])
381
+ if len(out) < 2:
382
+ # V:0, not v:0: an attached cover picture before the motion video has no HDR side data
383
+ proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-read_intervals", "%+#1", "-show_frames",
384
+ "-show_entries", "frame=side_data_list", "-of", "json", path], quiet=True, check=False)
385
+ frames: List[Dict[str, Any]] = []
386
+ if proc.returncode == 0:
387
+ try:
388
+ frames = json.loads(proc.stdout or "{}").get("frames") or []
389
+ except ValueError:
390
+ pass
391
+ for k, v in _hdr10_parts((frames[0].get("side_data_list") or []) if frames else []).items():
392
+ out.setdefault(k, v)
393
+ return out or None
394
+
395
+
396
+ def _hdr10_parts(sides: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
397
+ """The complete parts in one side-data list: a mastering display with every field, a content
398
+ light level with both values; an incomplete record counts as absent."""
399
+ out: Dict[str, Any] = {}
400
+ for sd in sides:
401
+ kind = _side_kind(sd)
402
+ if kind == "mastering" and "mastering_display" not in out:
403
+ vals = {k: _ratio(sd.get(k)) for k in ("red_x", "red_y", "green_x", "green_y", "blue_x", "blue_y",
404
+ "white_point_x", "white_point_y", "min_luminance", "max_luminance")}
405
+ if all(v is not None for v in vals.values()):
406
+ out["mastering_display"] = {k: float(v) for k, v in vals.items()}
407
+ elif kind == "light" and "content_light" not in out:
408
+ mc, ma = _to_int(sd.get("max_content")), _to_int(sd.get("max_average"))
409
+ if mc is not None and ma is not None:
410
+ out["content_light"] = {"max_content": mc, "max_average": ma}
411
+ return out
412
+
413
+
414
+ _STILL_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".avif", ".heic")
415
+
416
+
417
+ def _ratio(v: Any) -> "Optional[Fraction]":
418
+ """A side-data value such as "34000/50000" or "0/10000". Unlike _fraction() a zero is a value
419
+ (a mastering display's minimum luminance may be 0 cd/m2); only a missing or malformed one is None."""
420
+ try:
421
+ f = Fraction(str(v))
422
+ except (ValueError, ZeroDivisionError, TypeError):
423
+ return None
424
+ return f if f >= 0 else None
425
+
426
+
427
+ def _side_kind(sd: Dict[str, Any]) -> "Optional[str]":
428
+ t = str(sd.get("side_data_type") or "")
429
+ return "mastering" if t == "Mastering display metadata" else "light" if t == "Content light level metadata" else None
430
+
431
+
361
432
  def _bit_depth(pix_fmt: Optional[str]) -> int:
362
433
  """Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
363
434
  chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
@@ -324,7 +324,7 @@ class Context:
324
324
 
325
325
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
326
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
- "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification")
327
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
328
328
 
329
329
  def __init__(self) -> None:
330
330
  self.reset()
@@ -332,6 +332,9 @@ class Context:
332
332
  def reset(self) -> None:
333
333
  self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
334
334
  self.json = False # emit() prints a JSON document instead of the output path
335
+ self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
336
+ self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
337
+ self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
335
338
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
336
339
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
337
340
  self.fast = False # x264 preset forced to veryfast
@@ -109,7 +109,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
109
109
  required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
110
110
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
111
111
  "grid": dict(role="execution", inputs=["cols*rows video assets, filled left-to-right top-to-bottom"], outputs=["composited grid video artifact"],
112
- required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default)"}, {"capability": AAC, "when": "--audio-from"}],
112
+ required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default; without drawtext the cells are not labelled, fallback grid_no_drawtext)"}, {"capability": AAC, "when": "--audio-from"}],
113
113
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
114
114
  "straighten": dict(role="execution", inputs=["video asset"], outputs=["video artifact rotated by the given angle (horizon correction)"],
115
115
  required=FF + [X264, AAC, "filter:rotate"], optional=[HDR_X265],
@@ -464,7 +464,10 @@ DIAGNOSTIC_CODES = {
464
464
  "crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
465
465
  # fonts and text
466
466
  "emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
467
- "audio_tracks_dropped": ("warnings", "cut.py: the source has more audio tracks than the cut wrote (one); {source, kept, dropped}"),
467
+ "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
468
+ "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
469
+ "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
470
+ "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
468
471
  "look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
469
472
  "font_substituted": ("fallbacks", "the asked font family is not installed: fontconfig or the Windows Fonts folder gave another (font_used names it); {asked, file, family, via}"),
470
473
  "font_lacks_script": ("warnings", "an explicit --font does not cover the text's script and is kept as asked; {font, script}"),
package/scripts/batch.py CHANGED
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
36
  from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
37
- from _common.emit import absorb_stage_hw, audit, note, steps_encoder_report, warn
37
+ from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
38
38
 
39
39
  HERE = Path(__file__).resolve().parent
40
40
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -722,7 +722,7 @@ def main() -> int:
722
722
  why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
723
723
  info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
724
724
  f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
725
- unverified = [os.path.basename(r["file"]) for r in results if r.get("ok") and not r.get("verified")]
725
+ unverified = [shown(os.path.basename(r["file"])) for r in results if r.get("ok") and not r.get("verified")]
726
726
  if unverified and not STATE.dry_run:
727
727
  note(f"{len(unverified)} item(s) completed but did not verify everything they wrote (see each row's unverified): "
728
728
  + ", ".join(unverified[:5]) + (" ..." if len(unverified) > 5 else ""))
package/scripts/grid.py CHANGED
@@ -29,6 +29,8 @@ from _common import add_common, apply_common, aac_args, cfr_args, default_output
29
29
  draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
30
30
  from _common.fonts import font_choice, record_font_choice
31
31
  from _common import drawtext_fontfile # noqa: E402
32
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
33
+ from _common.emit import note, record_fallback # noqa: E402
32
34
 
33
35
  LABEL_MARGIN = 10
34
36
 
@@ -56,6 +58,13 @@ def main() -> int:
56
58
  add_common(ap)
57
59
  args = ap.parse_args()
58
60
  apply_common(args)
61
+ if args.label == "auto" and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
62
+ # the labels are the only drawtext in the grid: without the filter (Homebrew's plain
63
+ # ffmpeg) draw the grid unlabelled and say so, as look.py does for its stamps (#394)
64
+ args.label = "none"
65
+ record_fallback("this ffmpeg build has no drawtext filter, so the cells are not labelled "
66
+ "(the same as --label none)", "grid_no_drawtext")
67
+ note("no cell labels: this ffmpeg build has no drawtext filter (" + filter_missing_hint("drawtext") + ")")
59
68
 
60
69
  n = args.cols * args.rows
61
70
  if args.cols <= 0 or args.rows <= 0:
package/scripts/scenes.py CHANGED
@@ -27,6 +27,8 @@ from _common import (guard_sidecar, detect_scenes, STATE, add_common, apply_comm
27
27
  ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
28
28
  rms_envelope, BEAT_MIN_CONFIDENCE, decode_gray_frames, frame_flow, label_shot_flow)
29
29
  from _common import drawtext_fontfile # noqa: E402
30
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
31
+ from _common.emit import note, record_fallback # noqa: E402
30
32
 
31
33
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
32
34
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -287,6 +289,13 @@ def main() -> int:
287
289
  fps = meta["video"].get("fps") or 30.0
288
290
  expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
289
291
  stamp = ""
292
+ if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
293
+ # as look.py (#394): the sheet needs drawtext only for its stamps
294
+ args.no_timecode = True
295
+ record_fallback("this ffmpeg build has no drawtext filter, so the --sheet has no timecode stamps "
296
+ "(the same as --no-timecode)", "scenes_sheet_no_drawtext")
297
+ note("--sheet without timecode stamps: this ffmpeg build has no drawtext filter ("
298
+ + filter_missing_hint("drawtext") + ")")
290
299
  if not args.no_timecode:
291
300
  default_font = default_font_file("DejaVu Sans")
292
301
  font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
@@ -297,7 +306,7 @@ def main() -> int:
297
306
  info(f"wrote {args.sheet}")
298
307
  result["sheet"] = args.sheet
299
308
 
300
- if args.json:
309
+ if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
301
310
  emit(None, **result)
302
311
  else:
303
312
  print_json(result)