ffmpeg-skill 1.10.0 → 1.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
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",
@@ -33,6 +33,7 @@
33
33
  "references/scripts.md",
34
34
  "references/devices.md",
35
35
  "references/ci-platform-pitfalls.md",
36
+ "references/gotchas.md",
36
37
  "docs/contract.md",
37
38
  "SKILL.md",
38
39
  "README.md",
@@ -0,0 +1,172 @@
1
+ # Gotchas and things that look right but are wrong
2
+
3
+ The long form of SKILL.md's "Things that look right but are wrong" and "Gotchas"
4
+ bullets. SKILL.md keeps one line per item and points here with the anchors below;
5
+ nothing here is optional reading when the job touches that area — it is the
6
+ detail, not an aside.
7
+
8
+ ## Colour, HDR and Log
9
+
10
+ ### HDR and colour
11
+ SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py` reports
12
+ `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing script keeps
13
+ the output HDR (HEVC Main10, source colour tags) so nothing is silently
14
+ flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
15
+ `color.py --to-sdr` first for SDR-only destinations, LUT work or H.264
16
+ deliverables. `hdr: true` counts BT.2020 primaries too, so it is also true for a
17
+ wide-gamut SDR file; `hdr_signal: true` is the narrower fact — a real PQ / HLG /
18
+ Dolby Vision transfer — and `hdr_format` names the in-between case
19
+ (`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR input.
20
+ iPhone `.mov` files also carry timecode/metadata tracks; scripts map only the
21
+ first audio track, so extra tracks are dropped on re-encode. Keep ProRes masters
22
+ at source colour: `export.py --preset prores` does not retag.
23
+
24
+ Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
25
+ colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
26
+ do not tag BT.709 on BT.2020 pixels.
27
+
28
+ ### Log footage
29
+ S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
30
+ `probe.py --analyze`; `looks_like_log: true` means apply the manufacturer's
31
+ `.cube` with `color.py --lut` before anything else.
32
+
33
+ ## Cutting
34
+
35
+ ### Keyframe cuts
36
+ A lossless `cut.py` result may start up to one GOP (often 1–10 s) earlier than
37
+ requested; the script re-encodes automatically when the deviation exceeds 0.5 s.
38
+ If the user insists on lossless output, pass `--tolerance -1` and tell them the
39
+ cut lands on the nearest earlier keyframe. A `-c copy` cut on VFR or a
40
+ non-keyframe boundary produces a file that "works" but starts on a frozen or
41
+ wrong frame — respect the automatic re-encode rather than forcing the copy.
42
+
43
+ ### Variable frame rate
44
+ `probe.py` sets `variable_frame_rate_suspected` when `r_frame_rate` and
45
+ `avg_frame_rate` disagree (phone and screen recordings). Every re-encoding
46
+ script then adds `-fps_mode cfr` at the source's average rate, and `cut.py`
47
+ switches itself to `--accurate` (copy-cuts on VFR are unreliable). Pick the rate
48
+ explicitly with `fit.py --fps 30|60` when the average is odd (e.g. 23.4 fps from
49
+ dropped frames).
50
+
51
+ ## Audio
52
+
53
+ ### Sync, multicam and drift
54
+ A sync or multicam alignment with `confidence` under 0.3, or an offset larger
55
+ than 60 % of the analysis window, is probably wrong: enlarge `--analyze-seconds`
56
+ or find a clap. `multicam.py` reports one `confidence` per camera — check all of
57
+ them, not just that the command succeeded, before trusting the cut.
58
+
59
+ `sync.py`/`multicam.py` align audio tracks to each other, never lip sync (mouth
60
+ movement vs. audio) — there is no face or mouth detection anywhere in this skill.
61
+ High confidence means the audio matched well, not that the picture looks right;
62
+ whether lip sync is correct needs a look at the video, not the reported offset.
63
+
64
+ Don't mix files with different frame rates or sample rates in one
65
+ `cut.py --segments` join without re-encoding (`--accurate`). After `sync.py`,
66
+ verify by running it again on the output: offset (and drift ppm with
67
+ `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from separate
68
+ devices: always use `--fix-drift`.
69
+
70
+ ### Loudness and ambience
71
+ "Normalised" audio can still clip: check true peak, not just LUFS (`check.py`
72
+ does both). And do not normalise ambience or near-silence to a speech target — a
73
+ clip measured at -40 LUFS or below is room tone, wind or nothing; raising it
74
+ 25 dB raises the noise, not the content. Leave the level, say so, and offer music
75
+ or narration.
76
+
77
+ ## Text and framing
78
+
79
+ ### Captions, fonts and text order
80
+ Captions burned before a crop/resize land off-frame: frame changes first, then
81
+ text. Captions burned at an intermediate size and then upscaled by `export.py`
82
+ come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to
83
+ 1080x1920) — fit to the delivery size first
84
+ (`fit.py --width 1080 --height 1920`), then caption, then export.
85
+
86
+ CJK and other non-Latin text: libass and drawtext need a font that has the
87
+ glyphs. Check with `fc-list | grep -i cjk`. Then either name it
88
+ (`caption.py --font "Noto Sans CJK JP"`) or point at the file
89
+ (`overlay.py --font-file /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,
90
+ `caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a matching
91
+ font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
92
+ `brew install --cask font-noto-sans-cjk`.
93
+
94
+ Windows drawtext crashes on some real builds (#100): the drawtext tools resolve a
95
+ concrete `--font-file` by default, which avoids it; if one still crashes, pass
96
+ `--font-file` explicitly. Details: `references/ci-platform-pitfalls.md`.
97
+
98
+ ### Reframing, fps and duration
99
+ `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot
100
+ loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y`
101
+ toward the subject, or a reframe is often the honest answer — a silent centre
102
+ crop is a guess, not a decision.
103
+
104
+ Conforming 60 fps to 30 halves the motion samples: fine for a talking head,
105
+ visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows.
106
+
107
+ "Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (3×); by
108
+ trim it drops two thirds of the words. Ask which, or propose a highlight cut with
109
+ `scenes.py`.
110
+
111
+ ### Dimensions and rotation
112
+ `yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
113
+ automatically. Phone footage often carries a `rotation` tag; `probe.py` reports
114
+ it and `fit.py` accounts for it when computing the output frame.
115
+
116
+ ## Planning
117
+
118
+ ### Highlights
119
+ `scenes.py --highlights` defaults to the loudest scenes (`--rank-by audio`): a
120
+ quiet but important moment (a confession, a punchline landing in silence) is
121
+ skipped, and pure crowd noise or a mic bump can outrank it. `--rank-by duration`
122
+ picks the longest unbroken scenes instead. Neither is "the best parts" — check
123
+ the contact sheet (`--sheet`) before treating the picks as final.
124
+
125
+ ### Chaining and speed
126
+ Anything chained by hand through three re-encodes should be one `render.py`
127
+ project instead, so the plan is one file and the user can change one number.
128
+ Re-encodes use x264 `medium`; for long files add `--preset veryfast` to
129
+ intermediates and keep the default for the final export.
130
+
131
+ ## Audio-only files
132
+
133
+ Audio files are a first-class input, not a special case. `probe.py`, `cut.py`,
134
+ `silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform
135
+ podcast` all accept WAV, FLAC, MP3, M4A/AAC, OGG and Opus (any container ffmpeg
136
+ can read) and write the codec that fits the output extension, so the same
137
+ commands work with `talk.wav` in place of `talk.mp4`. What changes:
138
+
139
+ - The output extension picks the format: `-o out.mp3` converts, `-o out.wav`
140
+ keeps PCM, `-o out.m4a` writes AAC. `audio.py in.wav -o out.mp3` with no
141
+ other flag is a plain conversion.
142
+ - `cut.py` stream-copies audio too, so trims land on a packet boundary
143
+ (`precision: packet`, a few ms; the JSON reports `duration_error_ms`). Pass
144
+ `--accurate` for a sample-exact trim: `precision: sample` when the output is
145
+ PCM or FLAC, `codec_frame` when a lossy codec (AAC, MP3, Opus) frames it
146
+ again. A `.wav` output is always PCM, never AAC packets inside a WAV.
147
+ - `join.py` joins audio-only clips as audio (`acrossfade` or a butt join) at
148
+ one sample rate and channel layout; the output must have an audio extension.
149
+ Video and audio clips cannot be mixed in one join.
150
+ - An audio extension on a video input (`audio.py talk.mp4 -o talk.wav`,
151
+ `cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav`) extracts the audio; the
152
+ output has no video stream. `audio.py --audio-stream N` picks a track when
153
+ `probe` lists several under `audio_streams`.
154
+ - `Look: not needed` in the report; `Check:` still applies for loudness
155
+ (`check.py file.wav --platform podcast` measures LUFS and true peak).
156
+ - Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
157
+ `color`, `export`, `scenes`, `look`) refuse an audio file with
158
+ "input has no video stream". Say so instead of forcing a video wrapper.
159
+
160
+ ### Audio-only recipes
161
+
162
+ | User says (audio file) | Do |
163
+ |-----------|----|
164
+ | "normalise this WAV to -14 LUFS", "podcast levels" | `loudness.py talk.wav -I -14 --tp -1 -o talk_norm.wav` (`-I -16 --tp -1.5` podcast) |
165
+ | "remove the silence from this recording" | `silence.py talk.wav -o talk_tight.wav` |
166
+ | "clean up the noise in this M4A" | `audio.py talk.m4a --voice -o talk_clean.m4a` (speech) or `--denoise` |
167
+ | "convert this WAV to MP3" | `audio.py talk.wav -o talk.mp3` |
168
+ | "trim this audio from 00:30 to 02:00" | `cut.py talk.wav --start 0:30 --end 2:00 -o talk_cut.wav` (`--accurate` for sample-exact) |
169
+ | "join these recordings", "intro + episode + outro" | `join.py intro.wav episode.m4a outro.wav -o full.flac` (`--transition none` for a butt join) |
170
+ | "extract the audio from the video" | `audio.py talk.mp4 -o talk.wav` (`--voice -o talk.m4a` to clean it on the way) |
171
+ | "compress / limit / gate the voice" | `audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav` |
172
+ | "is this loud enough for Apple Podcasts?" | `check.py talk.m4a --platform podcast` |
@@ -1,6 +1,6 @@
1
1
  # Script reference
2
2
 
3
- Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.10.2: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--crf` is deprecated since 1.10.0 (it warns on stderr and is removed in 2.0): use `--quality`, except on `export.py`, whose `--crf` is not an alias and stays. With `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment, any tool refuses (`kind: input`) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
4
4
 
5
5
  ## Time grammar (every time-taking flag, 1.9)
6
6
 
@@ -406,7 +406,9 @@ and the project has no `loudness` stage; `"normalize": false` opts out.
406
406
 
407
407
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
408
408
  captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
409
- CLI flags of each script (see the docstring). Use it whenever an edit has
409
+ CLI flags of each script (see the docstring); a key `render.py` does not read -- at
410
+ the top level or in any stage/clip object -- is refused (`kind: input`) naming the
411
+ key and the nearest valid one, never silently ignored. Use it whenever an edit has
410
412
  more than two steps or the user is likely to ask for changes: edit the JSON,
411
413
  re-render, and the result is reproducible. `--dry-run --json` prints the
412
414
  complete command plan for review.
@@ -7,6 +7,7 @@ error reporting, and provides a compact media probe used by every script.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import platform
12
13
  import argparse
@@ -177,7 +178,9 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
177
178
  read a failed delivery as a success."""
178
179
  hint = extra.pop("hint", None)
179
180
  ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
181
+ _set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
180
182
  ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
183
+ STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
181
184
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
182
185
  if ctx.json:
183
186
  doc: Dict[str, Any] = {
@@ -234,7 +237,7 @@ class Context:
234
237
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
235
238
  """
236
239
 
237
- __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
240
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
238
241
 
239
242
  def __init__(self) -> None:
240
243
  self.reset()
@@ -242,6 +245,7 @@ class Context:
242
245
  def reset(self) -> None:
243
246
  self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
244
247
  self.json = False # emit() prints a JSON document instead of the output path
248
+ self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
245
249
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
246
250
  self.fast = False # x264 preset forced to veryfast
247
251
  self.duration_hint: Optional[float] = None # expected output length, for the progress percent
@@ -259,6 +263,15 @@ class Context:
259
263
 
260
264
  STATE = Context()
261
265
 
266
+ # The atexit plan hook takes no arguments, so emit()/die() record the Context they were given
267
+ # here; nothing passed a ctx = it stays None and the hook falls back to STATE, as before (1.10).
268
+ _CURRENT_CTX: "Optional[Context]" = None
269
+
270
+
271
+ def _set_current_ctx(ctx: "Context") -> None:
272
+ global _CURRENT_CTX
273
+ _CURRENT_CTX = ctx
274
+
262
275
 
263
276
  def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
264
277
  """Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
@@ -266,6 +279,8 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
266
279
  g = ap.add_argument_group("agent options")
267
280
  g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
268
281
  g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
282
+ g.add_argument("--json-brief", action="store_true",
283
+ help="like --json but trimmed: status, output, dry_run, verified, a compact summary of the output probe, this tool's own keys, and the command count instead of the command lines (failures print the full failure document, unchanged)")
269
284
  g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
270
285
  g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
271
286
  if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
@@ -280,8 +295,17 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
280
295
  # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
281
296
  # here, once, rather than in each re-encoding tool's own parser.
282
297
  crf = ap._option_string_actions["--crf"]
283
- if crf.help and "deprecated" not in crf.help:
284
- crf.help += " (deprecated: use --quality)"
298
+ # The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
299
+ # spelling argparse accepts, including the --cr / --c abbreviations) from the default;
300
+ # apply_common() puts _CRF_DEFAULT back when the flag was absent.
301
+ global _CRF_DEFAULT
302
+ _CRF_DEFAULT = crf.default
303
+ crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
304
+ crf.default = None
305
+ if "deprecated" not in (crf.help or ""):
306
+ # the nine tools that declare --crf with no help string used to fall through this and
307
+ # never show the mark at all (review 9)
308
+ crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
285
309
  # only the tools that re-encode (they declare --crf before add_common): one encoder choice
286
310
  # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
287
311
  g.add_argument("--codec", choices=CODECS, default=None,
@@ -290,13 +314,18 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
290
314
  help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
291
315
 
292
316
 
293
- def _passed_explicitly(flag: str) -> bool:
294
- """True when this process was given `flag` on the command line (`--flag` or `--flag=V`),
295
- as opposed to argparse filling in its default. Used for the deprecation warnings."""
296
- return any(a == flag or a.startswith(flag + "=") for a in sys.argv[1:])
317
+ # The declared default of a deprecated --crf, parked by add_common() (one parser per process).
318
+ _CRF_DEFAULT: Optional[int] = None
297
319
 
298
320
 
299
321
  def apply_common(args: "argparse.Namespace") -> None:
322
+ # Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
323
+ # whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
324
+ # an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
325
+ # prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
326
+ crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
327
+ if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
328
+ args.crf = _CRF_DEFAULT
300
329
  STATE.plan = getattr(args, "plan", None) or None
301
330
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
302
331
  if STATE.plan:
@@ -304,7 +333,10 @@ def apply_common(args: "argparse.Namespace") -> None:
304
333
  # tools without --json) still get their plan written, at exit, unless die() ran (review 6)
305
334
  import atexit
306
335
  atexit.register(_plan_at_exit)
307
- STATE.json = bool(getattr(args, "json", False))
336
+ STATE.json_brief = bool(getattr(args, "json_brief", False))
337
+ # --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
338
+ # passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
339
+ STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
308
340
  STATE.progress = bool(getattr(args, "progress", False))
309
341
  STATE.fast = bool(getattr(args, "fast", False))
310
342
  STATE.overwrite = bool(getattr(args, "overwrite", False))
@@ -330,12 +362,10 @@ def apply_common(args: "argparse.Namespace") -> None:
330
362
  die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
331
363
  hint="give -o NAME.mov")
332
364
  crf = getattr(args, "crf", None)
333
- # The warning the deprecation policy asks for, only when the user typed the flag: argparse's
334
- # own default for --crf is 18 on every re-encoding tool, so `args.crf is not None` cannot tell
335
- # an explicit --crf from the default (sys.argv can).
336
- # export.py has no --quality (its preset chooses the encoder), so its --crf is not an alias and
337
- # is not deprecated: warn only where --quality exists.
338
- if crf is not None and hasattr(args, "quality") and _passed_explicitly("--crf"):
365
+ # The warning the deprecation policy asks for, only when the caller typed the flag (see
366
+ # crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
367
+ # --crf is not an alias and is not deprecated: warn only where --quality exists.
368
+ if crf is not None and crf_explicit:
339
369
  info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
340
370
  top = 63 if STATE.codec == "av1" else 51
341
371
  if crf is not None and not 0 <= int(crf) <= top:
@@ -408,6 +438,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
408
438
  `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
409
439
  omitted, every read falls back to the process-global STATE as before."""
410
440
  ctx = ctx or STATE
441
+ _set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
411
442
  meta: Dict[str, Any] = {}
412
443
  if output and not ctx.dry_run:
413
444
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
@@ -430,22 +461,74 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
430
461
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
431
462
  doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
432
463
  if ctx.plan:
433
- doc["plan"] = write_plan(ctx.plan, output, extra)
434
- print_json(doc)
464
+ doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
465
+ print_json(_brief(doc, meta) if ctx.json_brief else doc)
435
466
  elif ctx.plan:
436
- print(write_plan(ctx.plan, output, extra))
467
+ print(write_plan(ctx.plan, output, extra, ctx=ctx))
437
468
  elif output:
438
469
  print(output)
439
470
 
440
471
 
472
+ # Keys the brief document replaces or drops: the full probe (summarised), the command lines
473
+ # (counted), the per-step verification list (its verdict stays as `verified`) and the 2.0 preview.
474
+ _BRIEF_DROP = ("probe", "commands", "verification", "result_v2")
475
+
476
+
477
+ def _brief_summary(meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
478
+ """The handful of output facts a caller reports or branches on, from the probe this tool
479
+ already ran -- plus the measured loudness when the tool measured one. Keys whose value is
480
+ unknown are left out rather than emitted as null."""
481
+ video = (meta or {}).get("video") or {}
482
+ audio = (meta or {}).get("audio") or {}
483
+ summary: Dict[str, Any] = {}
484
+ duration = (meta or {}).get("duration")
485
+ if duration is not None:
486
+ summary["duration_s"] = round(float(duration), 3)
487
+ for key, value in (("width", video.get("width")), ("height", video.get("height")), ("fps", video.get("fps")),
488
+ ("vcodec", video.get("codec")), ("acodec", audio.get("codec")), ("channels", audio.get("channels"))):
489
+ if value is not None:
490
+ summary[key] = value
491
+ lufs = None
492
+ for source, key in ((extra.get("result"), "input_i"), (extra.get("measured"), "input_i")):
493
+ if lufs is None and isinstance(source, dict):
494
+ lufs = _to_float(source.get(key))
495
+ for step in extra.get("verification") or []:
496
+ if lufs is None and isinstance(step, dict):
497
+ lufs = _to_float(step.get("lufs"))
498
+ # a silent file measures -inf, which json.dumps writes as the non-standard -Infinity: the
499
+ # brief document stays valid JSON by leaving the key out instead (the full document's own
500
+ # `measured`/`result` still carries whatever the tool reported).
501
+ if lufs is not None and math.isfinite(lufs):
502
+ summary["lufs"] = round(lufs, 2)
503
+ return summary
504
+
505
+
506
+ def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
507
+ """--json-brief: the same success document with the bulky parts replaced by what a caller
508
+ acts on. Same keys, same meanings -- `commands` becomes the count of the command lines,
509
+ `probe` becomes `summary` -- plus every tool-specific key the tool itself passed to emit().
510
+ Failures are untouched: die() prints the full failure document either way."""
511
+ brief: Dict[str, Any] = {"status": doc["status"], "output": doc["output"], "dry_run": doc["dry_run"],
512
+ "verified": doc.get("verified", False)}
513
+ summary = _brief_summary(meta, doc)
514
+ if summary:
515
+ brief["summary"] = summary
516
+ brief["commands"] = len(doc.get("commands") or [])
517
+ for key, value in doc.items():
518
+ if key not in brief and key not in _BRIEF_DROP:
519
+ brief[key] = value
520
+ return brief
521
+
522
+
441
523
  PLAN_VERSION = 1
442
524
  _PLAN_STRIP = ("--plan", "--dry-run", "--json")
443
525
 
444
526
 
445
527
  def _plan_at_exit() -> None:
446
- if STATE.plan and not STATE.plan_written:
528
+ ctx = _CURRENT_CTX or STATE
529
+ if ctx.plan and not ctx.plan_written:
447
530
  try:
448
- write_plan(STATE.plan, None, {})
531
+ write_plan(ctx.plan, None, {}, ctx=ctx)
449
532
  except SystemExit:
450
533
  pass
451
534
 
@@ -468,13 +551,13 @@ def fingerprint(path: str) -> Dict[str, Any]:
468
551
  return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
469
552
 
470
553
 
471
- def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]:
554
+ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Optional[Context]" = None) -> List[str]:
472
555
  """Every existing file the plan depends on: the `-i` inputs of the planned commands, any
473
556
  existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
474
557
  inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
475
558
  import shlex
476
559
  seen: List[str] = []
477
- for a in list(argv) + list(STATE.plan_inputs):
560
+ for a in list(argv) + list((ctx or STATE).plan_inputs):
478
561
  if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
479
562
  seen.append(a)
480
563
  for line in commands:
@@ -488,10 +571,14 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]
488
571
  return seen
489
572
 
490
573
 
491
- def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
574
+ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
492
575
  """The dry run as an artifact: what will run, on which exact inputs, producing what, checked
493
- how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C)."""
576
+ how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
577
+
578
+ `ctx` is the Context whose commands and inputs the plan describes (emit()/die() pass the one
579
+ they were given); omitted, it is the process-global STATE as before."""
494
580
  import datetime
581
+ ctx = ctx or STATE
495
582
  argv = [a for a in sys.argv[1:]]
496
583
  cleaned: List[str] = []
497
584
  skip = False
@@ -520,8 +607,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
520
607
  "tool": tool,
521
608
  "argv": cleaned,
522
609
  "cwd": os.getcwd(),
523
- "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands, cleaned)],
524
- "commands": list(STATE.commands),
610
+ "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
611
+ "commands": list(ctx.commands),
525
612
  "output": os.path.abspath(output) if output else None,
526
613
  "verify": verify,
527
614
  "notes": list(extra.get("notes") or []),
@@ -534,8 +621,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
534
621
  os.replace(tmp, path)
535
622
  except OSError as exc:
536
623
  die(f"cannot write plan {path}: {exc}", kind="output")
537
- STATE.plan_written = True
538
- info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
624
+ ctx.plan_written = True
625
+ info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}", ctx)
539
626
  return path
540
627
 
541
628
 
@@ -1018,7 +1105,6 @@ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool
1018
1105
  """RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
1019
1106
  block must be the same length for the correlation); remove_mean subtracts the envelope's mean
1020
1107
  (sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
1021
- import math
1022
1108
  step = max(1, int(step))
1023
1109
  n = len(samples)
1024
1110
  stop = n - step + 1 if full_blocks_only else n
@@ -1497,7 +1583,11 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
1497
1583
  raise ValueError(f"bad time: {value}")
1498
1584
  total = 0.0
1499
1585
  for part in parts:
1500
- total = total * 60 + float(part)
1586
+ try:
1587
+ total = total * 60 + float(part)
1588
+ except ValueError:
1589
+ # not the interpreter's "could not convert string to float: 'zz'" (review 9)
1590
+ raise ValueError(f"'{value}': not a time")
1501
1591
  return total
1502
1592
 
1503
1593
 
@@ -342,12 +342,15 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
342
342
  if isinstance(action, argparse._HelpAction):
343
343
  continue
344
344
  prop: Dict[str, Any] = _json_type(action)
345
+ # add_common() parks a deprecated --crf's default aside (so an explicit flag is
346
+ # distinguishable from the default); the schema still advertises the real one
347
+ default = getattr(action, "deprecated_default", action.default)
345
348
  if action.help and action.help != argparse.SUPPRESS:
346
- prop["description"] = action.help % {"default": action.default} if "%(default)" in action.help else action.help
349
+ prop["description"] = action.help % {"default": default} if "%(default)" in action.help else action.help
347
350
  if action.choices:
348
351
  prop["enum"] = list(action.choices)
349
- if action.default not in (None, False, argparse.SUPPRESS):
350
- prop["default"] = action.default
352
+ if default not in (None, False, argparse.SUPPRESS):
353
+ prop["default"] = default
351
354
  if action.option_strings:
352
355
  prop["cli"] = list(action.option_strings)
353
356
  if action.required:
@@ -927,6 +930,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
927
930
  "semantics": "prints the ffmpeg command lines that would run; no output file is written",
928
931
  **({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
929
932
  "supports_json": "json" in schema["properties"],
933
+ # additive mirror of supports_json (1.10.2): --json-brief is the same document trimmed
934
+ "supports_json_brief": "json_brief" in schema["properties"],
930
935
  "mutates_input": False,
931
936
  "produces_artifact": meta["produces_artifact"],
932
937
  "verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
@@ -1129,7 +1134,14 @@ def main() -> int:
1129
1134
  else:
1130
1135
  print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
1131
1136
  print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
1132
- print(f"available: {', '.join(d['available'])}")
1137
+ # counts, not the full capability list: the names of the ~60 available capabilities
1138
+ # answer no question a caller has (they are in `doctor --json .available` when one
1139
+ # does), while what is MISSING is the whole reason to run doctor (1.10.2 token diet).
1140
+ head = (f"{'ok' if d['ok'] else 'NOT ok'}: {len(d['available'])} capabilities available, "
1141
+ f"{len(d['missing'])} required missing, {len(d['missing_optional'])} optional missing")
1142
+ if d["unknown"]:
1143
+ head += f", {len(d['unknown'])} unknown"
1144
+ print(head)
1133
1145
  print(f"missing required: {', '.join(d['missing']) or 'none'}")
1134
1146
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
1135
1147
  if d["unknown"]:
@@ -1139,9 +1151,11 @@ def main() -> int:
1139
1151
  print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
1140
1152
  gpu = d["gpu_encoders"]
1141
1153
  if gpu["status"] == "parsed":
1142
- print(f"GPU-backed encoders in this build: {', '.join(gpu['present']) or 'none'} (no tool here uses one yet; this build-presence check does not prove the GPU/driver will accept a job)")
1154
+ print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
1143
1155
  fonts = d["fonts"]
1144
- print(f"default drawtext font '{fonts['default_font']}': {fonts['status']} ({fonts['detail']})")
1156
+ print(f"default drawtext font '{fonts['default_font']}': {fonts['status']}"
1157
+ + (f" ({fonts['detail']})" if fonts["status"] != "available" else ""))
1158
+ print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
1145
1159
  for err in d["errors"]:
1146
1160
  print(f"detection error: {err}", file=sys.stderr)
1147
1161
  if d["ok"]:
package/scripts/batch.py CHANGED
@@ -83,6 +83,9 @@ def run_step(argv: List[str]) -> bool:
83
83
  if proc.returncode != 0:
84
84
  info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
85
85
  return False
86
+ for line in proc.stderr.splitlines():
87
+ if line.startswith("warning:"): # a step's deprecation notice is not swallowed by a success (review 9)
88
+ info(" " + line)
86
89
  return True
87
90
 
88
91