ffmpeg-skill 3.8.10 → 3.8.12

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
@@ -441,7 +441,7 @@ Without the other 30, an agent writes raw ffmpeg for those jobs, and a mistake t
441
441
  - `join.py` puts transitions between clips that differ in size, frame rate or audio layout. Done by hand, those clips have to be normalised first or xfade fails.
442
442
  - `sync.py` and `multicam.py` find the offset between two recordings by audio cross-correlation. No ffmpeg filter measures it.
443
443
 
444
- Every command a tool ran is in its `--json` `commands`, shell-quoted so it can be pasted and run again, and `--dry-run` prints the commands without running anything. In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
444
+ Every command a tool records as run is in its `--json` `commands` (`batch.py` lists none of its steps' commands, and a measurement pass is listed only where the tool records it), shell-quoted so it can be pasted and run again, except a line that names a file the tool wrote for the run and removed when it exited (such as a concat list, an ffmetadata file, a drawtext text file, a two-pass log or a stage's intermediate), and `--dry-run` on a writing tool prints the commands without writing the output (some dry runs still run a measurement, and `verify.py` ignores `--dry-run`: see below). In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
445
445
 
446
446
  ### What does `verified` check, and what can it not tell me?
447
447
 
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.8.10`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.12`) | 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.8.10", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.12", "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"},
@@ -936,7 +936,8 @@ before, and, when `--json` was given, on stdout:
936
936
  `message` carries the script's own reason (missing input, ffprobe failure, the last
937
937
  stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
938
938
  flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
939
- or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
939
+ or run so the caller can retry or report without re-deriving the command (a line that names a file
940
+ the tool wrote for the run and removed when it exited, such as a concat list, is a record of it); `kind: ffmpeg`
940
941
  failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
941
942
  purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
942
943
  doesn't already make) for a caller that wants a stable enum instead of matching `kind`
@@ -976,8 +977,10 @@ says it is kept without the step that was stopped, and the document carries it a
976
977
  **Source carry-over.** Every tool that writes a media file compares what its first input carried
977
978
  besides the picture and the sound with the file it wrote, and says so in `source_carry:
978
979
  {kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
979
- `audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
980
- `timecode`. A timecode is compared by its value: it is kept when the written start timecode
980
+ `audio_channels` (a surround source only: more than two), `attachments` (Matroska attached files,
981
+ such as the fonts of a subtitle track; only a Matroska output holds them), `video_streams` (a
982
+ source with more than one playable picture: the tools write one), `creation_time`,
983
+ `audio_language` and `timecode`. A timecode is compared by its value: it is kept when the written start timecode
981
984
  labels the frame the output starts on, which is the source's own label or, where the tool moved
982
985
  the picture's start (`cut` from a later frame, `fit --method trim`, `sync --trim-second`, a
983
986
  `render.py` clip with an `in`), the label of the source frame the output now starts on. A join of
@@ -985,9 +988,12 @@ several ranges (`cut --segments`, `--remove` leaving two or more) writes no star
985
988
  no single start labels the frames after the first join. The key is present only when the source
986
989
  had at least one of them, so a result for a plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
987
990
  or more inputs have a picture or sound (which one's items the output should carry is the tool's
988
- choice). An item bound to a stream the output does not have at all is not compared (a sound-only
991
+ choice). `render.py`, whose stages run as their own tools, compares a project whose clips are all
992
+ one file, with no music, replacement or effects track and no video layer, with that file; its
993
+ timecode counts as kept when every stage that compared it kept it. An item bound to a stream the output does not have at all is not compared (a sound-only
989
994
  extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
990
- it. A dropped `subtitles`, `chapters`, `cover_art` or `audio_channels` also records one
995
+ it. A dropped `subtitles`, `chapters`, `cover_art`, `audio_channels`, `attachments` or
996
+ `video_streams` also records one
991
997
  `source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
992
998
  `dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
993
999
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.10",
3
+ "version": "3.8.12",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -412,7 +412,9 @@ burned-in line that does not move. That is a border blend, not inpainting,
412
412
  and a logo that travels or subtitles that change position are out of
413
413
  scope. The filter reads a 1 px ring outside the box, so the box cannot
414
414
  touch the frame edge, and it only interpolates 8-bit YUV (an HDR or 10-bit
415
- source is refused; `color.py --to-sdr` first). The rest of the frame, and
415
+ source is refused; `color.py --to-sdr` first). Blur and pixelate cover the
416
+ rectangle exactly at an odd `--x`/`--y` too (on 4:2:0 the chroma sample on an
417
+ odd edge is shared with the pixel outside). The rest of the frame, and
416
418
  the whole clip's timeline, are untouched -- to cover only part of the
417
419
  timeline, `cut.py` the clip into segments first.
418
420
 
@@ -471,6 +473,8 @@ background.py -o OUT.mov --duration T --width W --height H --transparent
471
473
  No input file: ffmpeg's own `color`/`gradients` source filters generate the
472
474
  clip directly. For a title-card background, a placeholder layer, or a base
473
475
  for `overlay.py` to composite onto. `--width`/`--height` must be even.
476
+ `--angle DEG` (any value, 0 = left to right, 90 = top to bottom) runs the gradient from the corner
477
+ the direction starts from (C1) to the opposite one (C2).
474
478
  `--transparent` writes a canvas with alpha 0 in every pixel in an alpha line:
475
479
  ProRes 4444 with 16-bit alpha (default; `.mov`/`.mkv`), `qtrle` (`.mov`), `png`
476
480
  (`.mov`/`.mkv`) or `vp9` (`.webm`/`.mkv`). It is the base layer
@@ -485,7 +489,9 @@ reverse.py INPUT [--no-audio] [-o OUT]
485
489
  Reverses video (and audio, unless `--no-audio`) with ffmpeg's `reverse`/
486
490
  `areverse` filters, which buffer the whole clip in memory — keep this to
487
491
  clips it makes sense to reverse (seconds to a couple of minutes), not
488
- something this tool limits for you.
492
+ something this tool limits for you. A `memory_heavy` warning says when the
493
+ decoded frames come to more than about 2 GiB (size, chroma, depth and frame
494
+ count, not the length alone).
489
495
 
490
496
  ### stabilize.py — motion stabilisation
491
497
  ```
@@ -567,10 +573,11 @@ extend` only works with `--at` at (or past) the clip's end and just makes
567
573
  the last frame last `--hold` seconds longer, with nothing pushed; `--mode insert` at
568
574
  the clip's end (no `--at`) does the same. Audio is
569
575
  silent during the held frame in `--mode insert` (there is no source audio
570
- for a frozen moment that didn't exist before). `--mode insert` drops a
571
- subtitle/data track rather than copy it with timestamps that no longer match
572
- the pushed picture (`dropped_non_av_streams: true` in the result), as
573
- `fit.py --method speed` does; `--mode extend` keeps it.
576
+ for a frozen moment that didn't exist before). A hold inside the clip or at
577
+ its start drops a subtitle/data track rather than copy it with timestamps that
578
+ no longer match the pushed picture (`dropped_non_av_streams: true` in the
579
+ result), as `fit.py --method speed` does; a hold of the last frame (`--mode
580
+ extend`, or no `--at`) pushes nothing and keeps it.
574
581
 
575
582
  ### pad.py — add black/silent padding at the start/end
576
583
  ```
@@ -176,6 +176,16 @@ def fmt_secs(value: Optional[float]) -> str:
176
176
  return "?s" if value is None else f"{value:.3f}s"
177
177
 
178
178
 
179
+ def ntsc_rate(fps: float) -> float:
180
+ """`fps` as the rate it is a rounding of: 23.976, 29.97, 47.952, 59.94 and 119.88 (what
181
+ probe() reports, and what a caller types after `@`) are N * 1000 / 1001. Any other rate is
182
+ returned as it is. timeline.exact_rate() reads the same five rates the same way."""
183
+ whole = round(fps * 1.001)
184
+ if whole in (24, 30, 48, 60, 120) and abs(fps - whole / 1.001) < 0.01 and abs(fps - whole) > 0.01:
185
+ return whole * 1000 / 1001
186
+ return fps
187
+
188
+
179
189
  def parse_time(value: str, fps: Optional[float] = None) -> float:
180
190
  """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
181
191
  or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
@@ -212,7 +222,10 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
212
222
  # 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
213
223
  # disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
214
224
  total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
215
- return total_frames / fps
225
+ # at the picture's exact rate: probe()'s `fps` is rounded to three decimals, and frame 37
226
+ # counted at 29.97 is 1.2345679 s, 1.2 microseconds past the frame at 37 * 1001 / 30000,
227
+ # so a start read from 00:00:01:07 was the frame after it (ntsc_rate)
228
+ return total_frames / ntsc_rate(fps)
216
229
  if len(parts) > 3:
217
230
  raise ValueError(f"bad time: {value}")
218
231
  total = 0.0
@@ -315,7 +328,9 @@ def fmt_smpte_time(seconds: float, fps: float) -> str:
315
328
  if seconds < 0:
316
329
  seconds = 0.0
317
330
  whole_fps = int(round(fps))
318
- total_frames = int(round(seconds * fps))
331
+ # the frames are counted at the rate parse_time() reads the same text at (29.97 as
332
+ # 30000/1001): at 29.97 itself 06:00:00:00 came back as 05:59:59:29
333
+ total_frames = int(round(seconds * ntsc_rate(fps)))
319
334
  frame = total_frames % whole_fps
320
335
  secs_total = total_frames // whole_fps
321
336
  h, rem = divmod(secs_total, 3600)
@@ -385,6 +400,25 @@ def stream_starts(meta: Optional[Dict[str, Any]]) -> Tuple[float, float]:
385
400
  return out[0], out[1]
386
401
 
387
402
 
403
+ # The size of the decoded clip above which a tool that holds every frame (reverse, a boomerang)
404
+ # says `memory_heavy`.
405
+ HELD_FRAMES_WARN_BYTES = 2 * 1024 ** 3
406
+
407
+
408
+ def held_frames_bytes(v: Dict[str, Any], frames: float) -> int:
409
+ """About how many bytes `frames` decoded frames of the picture `v` (a probe()'s `video`) take:
410
+ the picture's size, its chroma planes (4:2:0 1.5x, 4:2:2 2x, 4:4:4 3x, an alpha plane one
411
+ more) and 2 bytes a sample above 8 bits. The one estimate reverse.py and loop.py --boomerang
412
+ warn from: reverse.py's was the clip's duration alone, so a 30 s 4K60 clip (tens of GB) got
413
+ no warning and a 61 s 320x180 one did (A174)."""
414
+ pix = str(v.get("pix_fmt") or "")
415
+ planes = 3.0 if "444" in pix or pix.startswith(("gbr", "rgb", "bgr")) else 2.0 if "422" in pix else 1.5
416
+ if pix.startswith(("yuva", "gbra", "rgba", "bgra", "argb", "abgr", "ya")):
417
+ planes += 1.0
418
+ depth = 2 if (v.get("bit_depth") or 8) > 8 else 1
419
+ return int((v.get("width") or 0) * (v.get("height") or 0) * planes * depth * max(0.0, frames))
420
+
421
+
388
422
  def frame_rate(meta: Optional[Dict[str, Any]]) -> float:
389
423
  """The video's frame rate as cut.py's grid takes it: r_frame_rate, else fps; 0.0 when unknown."""
390
424
  v = (meta or {}).get("video") or {}
@@ -421,6 +455,56 @@ def grid_at_or_after(t: float, v0: float, rate: float) -> float:
421
455
  return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
422
456
 
423
457
 
458
+ def frame_window(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
459
+ half_open: bool = False) -> Optional[Tuple[Optional[float], Optional[float]]]:
460
+ """The two times a filter compares a frame's `t` with so that a window from `start` to `end`
461
+ seconds of `meta`'s picture holds the frames asked: half a frame before the first frame at or
462
+ after `start`, and half a frame after the last frame at or before `end` (`half_open`: half a
463
+ frame before the first frame at or after `end`, which is then outside). A bound written as
464
+ the time itself is compared with `t` on the frame it names: `{:.3f}` put frame 37 of a
465
+ 30000/1001 picture (1.234567 s) after a start of "1.235", and `t` is a double (pts x time
466
+ base) that is not always the number the text reads as (the frame at 1.4 s of a 25 fps MP4 is
467
+ not `<= 1.400`; FFmpeg 6.1.1). The grid is the one grid_at_or_after() counts on. None when
468
+ there is no grid to count on (no rate: a dry run's pending input; a picture probe() suspects
469
+ of a variable rate): the caller keeps the text it had. A bound of None stays None."""
470
+ v = (meta or {}).get("video") or {}
471
+ rate = frame_rate(meta)
472
+ if not rate or v.get("variable_frame_rate_suspected"):
473
+ return None
474
+ v0, half = stream_starts(meta)[0], 0.5 / rate
475
+ lo = None if start is None else grid_at_or_after(start, v0, rate) - half
476
+ if end is None:
477
+ hi = None
478
+ elif half_open:
479
+ hi = grid_at_or_after(end, v0, rate) - half
480
+ else:
481
+ hi = v0 + math.floor((end - v0) * rate + 1e-6) / rate + half
482
+ return lo, hi
483
+
484
+
485
+ def window_expr(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
486
+ half_open: bool = False) -> str:
487
+ """The expression that is true on the frames of frame_window(): `between(t,LO,HI)`, or
488
+ `gte(t,LO)` with no end. With no start the window opens at 0, as it always did. Without a
489
+ grid it is the millisecond text the tools wrote before."""
490
+ bounds = frame_window(start, end, meta, half_open)
491
+ if bounds is None:
492
+ lo = f"{start:.3f}" if start is not None else "0"
493
+ return f"gte(t,{lo})" if end is None else f"between(t,{lo},{end:.3f})"
494
+ lo = f"{bounds[0]:.6f}" if bounds[0] is not None else "0"
495
+ return f"gte(t,{lo})" if bounds[1] is None else f"between(t,{lo},{bounds[1]:.6f})"
496
+
497
+
498
+ def windowed_overlay_x(x: Any, window: str) -> str:
499
+ """An `overlay` x that puts the layer at `x` on the frames `window` is true on and off the
500
+ picture on every other frame. overlay's `enable` is not the main picture's alone: FFmpeg
501
+ evaluates it on each frame either input hands over, the layer's next one included, so a still
502
+ looped at 25 fps over a 30000/1001 picture was drawn from the frame before the window's
503
+ first to the frame before its last (36-66 for 37-67; FFmpeg 6.1.1). The position is
504
+ evaluated once per output frame with the main picture's `t`."""
505
+ return f"'if({window},{x},-w)'"
506
+
507
+
424
508
  def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bool = False) -> Tuple[str, str]:
425
509
  """The video and the audio filters that cut one segment [start, end) of the graph clock out of
426
510
  both streams and shift both by the same constant, so an offset between them survives; `end`
@@ -517,6 +601,28 @@ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Opti
517
601
  return ",".join(parts)
518
602
 
519
603
 
604
+ def tpad_length(seconds: float, meta: Optional[Dict[str, Any]]) -> float:
605
+ """How long the picture `tpad=start_duration={seconds:.3f}` puts ahead of `meta`'s clip is:
606
+ tpad adds whole frames, the nearest count to the text it is given (0.23425 s at 25 fps is
607
+ written "0.234" and becomes 6 frames, 0.240 s). A sound delayed by `seconds` itself would
608
+ start up to half a frame before or after the picture it belongs to; delayed by this length it
609
+ moves with it (both streams of a clip shift by one constant: #409). `seconds` as it is when
610
+ there is no rate to count frames at (a dry run's pending input) or probe() suspects a
611
+ variable one."""
612
+ v = (meta or {}).get("video") or {}
613
+ rate = frame_rate(meta)
614
+ if not rate or v.get("variable_frame_rate_suspected"):
615
+ return seconds
616
+ try:
617
+ exact = Fraction(str(v.get("r_frame_rate") or ""))
618
+ except (ValueError, ZeroDivisionError):
619
+ exact = None
620
+ if exact is None or abs(float(exact) - rate) > 1e-6:
621
+ exact = Fraction(rate).limit_denominator(1001)
622
+ frames = math.floor(Fraction(f"{seconds:.3f}") * exact + Fraction(1, 2)) # a half rounds up, as av_rescale does
623
+ return float(frames / exact)
624
+
625
+
520
626
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
521
627
  """The one length a clip holds once both streams start at its first frame (av_trim from the
522
628
  video's start): the video's, or where the sound ends when it runs more than a frame past the
@@ -167,10 +167,11 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
167
167
  # source_carry: the items compared, in report order. The first group is content a viewer or
168
168
  # listener notices missing and raises the warning; the second is tags, listed but never warned
169
169
  # about (every camera file has a creation_time, so a warning for it would fire on every run).
170
- CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
170
+ CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels", "attachments", "video_streams")
171
171
  CARRY_TAGS = ("creation_time", "audio_language", "timecode")
172
172
  _CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
173
- "audio_channels": "audio channels"}
173
+ "audio_channels": "audio channels", "attachments": "attached files (a subtitle track's fonts)",
174
+ "video_streams": "video streams"}
174
175
 
175
176
 
176
177
  def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
@@ -205,7 +206,9 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
205
206
  continue
206
207
  if item == "audio_channels" and src[item] <= 2:
207
208
  continue # mono and stereo are what every tool writes; only a surround layout can be folded down
208
- if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
209
+ if item == "video_streams" and src[item] <= 1:
210
+ continue # one picture is what every tool writes; only a second one can be left out
211
+ if item in ("subtitles", "timecode", "attachments", "video_streams") and src["has_video"] and not out["has_video"]:
209
212
  continue
210
213
  if item in ("audio_channels", "audio_language") and not out["has_audio"]:
211
214
  continue
@@ -227,8 +230,8 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
227
230
  if loud:
228
231
  parts = []
229
232
  for i in loud:
230
- if i == "audio_channels":
231
- parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
233
+ if i in ("audio_channels", "video_streams"):
234
+ parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} {_CARRY_NAMES[i]}")
232
235
  else:
233
236
  parts.append("its " + _CARRY_NAMES[i])
234
237
  warn("the source carried " + ", ".join(parts) + " that this output does not have "
@@ -433,6 +433,11 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
433
433
  "subtitles": len(subs),
434
434
  "chapters": len(raw.get("chapters") or []),
435
435
  "cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
436
+ # Matroska attachments (a subtitle track's fonts, A256) and the playable pictures beside
437
+ # the one probe() describes, which a tool that writes one picture leaves out (A258)
438
+ "attachments": sum(1 for s in streams if s.get("codec_type") == "attachment"),
439
+ "video_streams": sum(1 for s in streams if s.get("codec_type") == "video"
440
+ and not s.get("disposition", {}).get("attached_pic", 0)),
436
441
  "creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
437
442
  "audio_language": 1 if lang and lang.lower() != "und" else 0,
438
443
  "timecode": 1 if out["timecode"].get("value") else 0,
@@ -2253,6 +2253,45 @@ def ffmpeg_failure_extra(stderr: str) -> Dict[str, Any]:
2253
2253
  return {"hint": cls[1], "error_extra": {"reason": cls[0], "details": cls[2]}} if cls else {}
2254
2254
 
2255
2255
 
2256
+
2257
+
2258
+ MATROSKA_EXT = frozenset({".mkv", ".mka", ".mks"})
2259
+
2260
+
2261
+ def carried_stream_maps(src: Optional[str], output: str, *, chapters: bool = True, attachments: bool = True) -> List[str]:
2262
+ """What a command that keeps a source's subtitle/data streams (`-map 0:s? -map 0:d?`, or
2263
+ `-map 0`) adds so the written file carries what the source does, and nothing it does not:
2264
+
2265
+ - `-map -0:N` for an MP4/MOV chapter track. FFmpeg reads the QuickTime chapter text track as a
2266
+ data stream (codec tag `text`) and its muxer writes a new one from the chapters it is given;
2267
+ the copied one was written as a `gpmd` data track holding the old titles, so
2268
+ `metadata.py --clear-chapters` left them behind and every pass added a track (A173).
2269
+ - `-map 0:t? -c:t copy` into Matroska: a subtitle track's fonts are attachments, which no
2270
+ `-map 0:s?`/`0:d?` selects, so every tool that kept the subtitles dropped the fonts they are
2271
+ drawn with (A256). Other containers hold no attachments; source_carry reports them dropped.
2272
+
2273
+ Nothing when the source cannot be read here (the command then runs as before)."""
2274
+ if not src or not os.path.isfile(src):
2275
+ return []
2276
+ try:
2277
+ out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries",
2278
+ "stream=index,codec_type,codec_tag_string:chapter=id", "-of", "json", src],
2279
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
2280
+ errors="replace", timeout=_probe_limit()).stdout
2281
+ doc = json.loads(out or "{}")
2282
+ except (OSError, subprocess.TimeoutExpired, ValueError):
2283
+ return []
2284
+ streams = doc.get("streams") or []
2285
+ maps: List[str] = []
2286
+ if chapters and doc.get("chapters"):
2287
+ for s in streams:
2288
+ if s.get("codec_type") == "data" and str(s.get("codec_tag_string") or "").lower() == "text":
2289
+ maps += ["-map", f"-0:{s.get('index')}"]
2290
+ if attachments and os.path.splitext(output)[1].lower() in MATROSKA_EXT and any(s.get("codec_type") == "attachment" for s in streams):
2291
+ maps += ["-map", "0:t?", "-c:t", "copy"]
2292
+ return maps
2293
+
2294
+
2256
2295
  def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
2257
2296
  """Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
2258
2297
  the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
@@ -2334,7 +2373,9 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
2334
2373
  so a source's tmcd data track is not copied -- its label, at the source's rate, would win over
2335
2374
  the new one; the MOV/MP4 muxer builds the track again from the label."""
2336
2375
  skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
2337
- first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + ["-c:s", "copy", "-c:d", "copy", output], check=False)
2376
+ src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
2377
+ first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + carried_stream_maps(src, output)
2378
+ + ["-c:s", "copy", "-c:d", "copy", output], check=False)
2338
2379
  if first.returncode == 0:
2339
2380
  return False
2340
2381
  if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
@@ -489,7 +489,7 @@ DIAGNOSTIC_CODES = {
489
489
  "emoji_not_drawn": ("warnings", "caption.py / graphics.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; also graphics.py --text-render drawtext, which has no font fallback; {count, reason}"),
490
490
  "emoji_mono_drawn_in_colour": ("warnings", "caption.py / graphics.py --emoji mono on a build whose libass draws the emoji face in colour: nothing makes the glyph monochrome, so the result's emoji.mode is color (graphics.py --text-render drawtext draws none: mode none, emoji_not_drawn)"),
491
491
  "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}"),
492
- "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
492
+ "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture, surround channels, attached files or a second video stream the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
493
493
  "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}"),
494
494
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
495
495
  "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"),
@@ -689,7 +689,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
689
689
  "levels": {"type": "object", "description": "--analyze only, on an input with a video stream: picture levels sampled at 2 fps over the first 20 s, on an 8-bit scale inferred from the values measured (a 10-bit or deeper picture whose luma never passes code 255 is read as 8-bit and left unscaled) {scale, y_min, y_max, y_avg, y_low_p5, y_high_p95, saturation_avg, looks_like_log, note}; {error} when no frame was analysed. Measured under --dry-run too"}},
690
690
  "additionalProperties": True}
691
691
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
692
- "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
692
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too), shell-quoted; a line that names a file the tool wrote for the run and removed when it exited (such as a concat list, an ffmetadata file, a drawtext text file, a two-pass log or a stage's intermediate) cannot be run again as printed; a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
693
693
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
694
694
  "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
695
695
  "encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
@@ -698,7 +698,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
698
698
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
699
699
  "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
700
700
  "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
701
- "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
701
+ "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), attachments (Matroska attached files, such as a subtitle track's fonts), video_streams (a source with more than one playable picture), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound (render.py: a project whose clips are one file, with no music, replacement, effects track or video layer, is compared with that file). A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels/attachments/video_streams also records a source_items_dropped warning"},
702
702
  "diagnostics": DIAGNOSTICS_SCHEMA}
703
703
  extra: Dict[str, Any] = {}
704
704
  if name == "check":
@@ -22,6 +22,35 @@ from _common.emit import note
22
22
  from _common import STATE, 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
23
23
 
24
24
 
25
+ def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str:
26
+ """The `gradients` source for a `w`x`h` gradient at `angle` degrees (0 = left to right, 90 =
27
+ top to bottom) that runs from the frame's first corner along that direction to its last: c0
28
+ on the corner the direction starts from, c1 on the one opposite.
29
+
30
+ `gradients` takes its two points inside its own frame: a coordinate below -1 is refused and
31
+ one outside 0..w-1 / 0..h-1 is replaced by a seeded random one. The points were (0, 0) and the
32
+ width along the angle, so every angle outside 0-90 failed as kind ffmpeg (135 put x1 at -45:
33
+ A169), and inside it the far point was past the frame (x1 = w at 0, y1 = w at 90) and drawn
34
+ from the seed instead. A line of any direction that crosses the whole frame fits inside a
35
+ square canvas as wide as the frame's diagonal, so the gradient is drawn there, through the
36
+ centre, over the frame's extent along the direction (w|cos| + h|sin|), and the frame is cropped
37
+ from the middle."""
38
+ rad = math.radians(angle)
39
+ c, s = math.cos(rad), math.sin(rad)
40
+ side = 2 * math.ceil((math.hypot(w, h) + 4) / 2) # even, so the centred crop is whole pixels
41
+ half = (w * abs(c) + h * abs(s)) / 2
42
+ mid = side / 2
43
+ x0, y0, x1, y1 = (round(mid - half * c), round(mid - half * s), round(mid + half * c), round(mid + half * s))
44
+ # gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
45
+ # slow rotation applied every frame), so without pinning both, this "static" background
46
+ # was neither reproducible between runs nor actually static across its own duration --
47
+ # violating the bit_exact/deterministic contract _contract.py declares for this tool.
48
+ # speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
49
+ # motionless the filter allows.
50
+ return (f"gradients=size={side}x{side}:rate={fps:g}:c0={c0}:c1={c1}:x0={x0}:y0={y0}:x1={x1}:y1={y1}"
51
+ f":seed=0:speed=1e-05,crop={w}:{h}:{(side - w) // 2}:{(side - h) // 2}")
52
+
53
+
25
54
  def main() -> int:
26
55
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
27
56
  ap.add_argument("-o", "--output", required=True, help="output file")
@@ -66,16 +95,7 @@ def main() -> int:
66
95
  die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
67
96
  validate_color(c0, "--gradient")
68
97
  validate_color(c1, "--gradient")
69
- rad = math.radians(args.angle)
70
- x1 = round(args.width * math.cos(rad))
71
- y1 = round(args.width * math.sin(rad))
72
- # gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
73
- # slow rotation applied every frame), so without pinning both, this "static" background
74
- # was neither reproducible between runs nor actually static across its own duration --
75
- # violating the bit_exact/deterministic contract _contract.py declares for this tool.
76
- # speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
77
- # motionless the filter allows.
78
- 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"
98
+ src_filter = gradient_source(args.width, args.height, args.fps, c0, c1, args.angle)
79
99
  colours = [c0, c1]
80
100
  else:
81
101
  validate_color(args.color, "--color")