ffmpeg-skill 1.4.5 → 1.4.6

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
@@ -144,7 +144,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
144
144
  1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
145
145
  2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
146
146
  3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
147
- 4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 40 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
147
+ 4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
148
148
  5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
149
149
  6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
150
150
  7. **Unknown is not missing.** When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are `unknown`: never `missing`, never silently `available`. An installed filter is not reported absent; a failed detection is not a pass.
@@ -303,7 +303,7 @@ The contract is generated from the code that runs, not maintained beside it. For
303
303
 
304
304
  On Windows, `python3` is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes `python` (or the `py` launcher) instead — if your MCP client reports the server failed to start, change `"command"` above to `"python"` (or the full path from `where python`).
305
305
 
306
- `mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up: the same 40 names, the same order, and `inputSchema` translated from each tool's `input_schema`. `tools/call` maps structured arguments to argv and runs the named script; a raw `argv` form is accepted for compatibility and marked non-canonical. `python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
306
+ `mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up: the same 42 names, the same order, and `inputSchema` translated from each tool's `input_schema`. `tools/call` maps structured arguments to argv and runs the named script; a raw `argv` form is accepted for compatibility and marked non-canonical. `python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
307
307
 
308
308
  ### Capability detection
309
309
 
@@ -395,7 +395,7 @@ FFmpeg itself:
395
395
  - Python 3.9+, standard library only
396
396
  - Node 16+ only for the `npx` installer
397
397
 
398
- `doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...) has no timeout: a legitimate `--accurate` re-encode of a long file can genuinely take a long time, so bounding it would risk killing real work. `-nostdin` is always passed, so a hung ffmpeg process waiting on stdin cannot happen; a caller that needs a hard ceiling on a specific job should apply its own external timeout/kill around that one invocation.
398
+ `doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...) runs under `--timeout` (default 1800 s, `FFMPEG_SKILL_TIMEOUT`, `0` = none): past the limit the process is killed, its partial output removed, and the failure reported as `kind: timeout` (exit 124) — a legitimately long `--accurate` re-encode should raise the limit rather than run unbounded. `-nostdin` is always passed, so a hung ffmpeg process waiting on stdin cannot happen.
399
399
 
400
400
  ## Stability
401
401
 
@@ -424,6 +424,8 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
424
424
  | | |
425
425
  |---|---|
426
426
  | [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
427
+ | [docs/design-decisions.md](docs/design-decisions.md) | behaviours that look like bugs but are decisions, with rationale and the pinning test; read before filing a bug |
428
+ | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
427
429
  | [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
428
430
  | [SKILL.md](SKILL.md) | what the agent reads: workflow, request → tool map, audio-only rules, report format, pitfalls |
429
431
  | [references/scripts.md](references/scripts.md) | per-flag reference for every tool |
package/SKILL.md CHANGED
@@ -28,20 +28,16 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
28
28
  `cut.py` and `loudness.py` stream-copy video by default; only pass
29
29
  `--accurate` to `cut.py` when the user needs frame-exact cuts.
30
30
  3. **Plan with `--dry-run --json`, then execute.** Every script accepts
31
- `--dry-run` (prints the ffmpeg commands that would run) and `--json`
32
- (structured result: output path, probe of the output, commands run). For
33
- writing tools this means nothing is written; `probe`/`check` still run
34
- ffprobe/loudness-measurement passes (they're read-only, so `--dry-run`
35
- changes nothing for `probe`, and only skips the loudness pass for
36
- `check`), `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to
37
- measure or analyse, and `verify` accepts the flag but ignores it entirely
38
- (its steps run regardless) see `contract --json`'s `dry_run` field per
39
- tool for exact semantics. Trust `--json`, not a dry-run's human-readable
40
- summary line, for any number after the plan (dimensions in that line can
41
- be a placeholder, not a computed preview — see `docs/contract.md`). Use
42
- them to confirm a plan before long encodes and to report exact facts.
43
- `--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
44
- prints percent and ETA on stderr for long encodes. Never point `-o` at a file
31
+ `--dry-run` (prints the ffmpeg commands that would run; writing tools write
32
+ nothing, analysis tools still measure the per-tool list is in the
33
+ opening paragraph above and in `contract --json`'s `dry_run` field) and
34
+ `--json` (structured result: output path, probe of the output, commands
35
+ run). Trust `--json`, not a dry-run's human-readable summary line, for any
36
+ number after the plan (dimensions in that line can be a placeholder, not a
37
+ computed preview see `docs/contract.md`). Use them to confirm a plan
38
+ before long encodes and to report exact facts. `--fast` gives a quick
39
+ preview-quality render (x264 veryfast), `--progress` prints percent and
40
+ ETA on stderr for long encodes. Never point `-o` at a file
45
41
  you did not create in this job unless the user asked for it to be replaced;
46
42
  pass `--overwrite` only then.
47
43
  4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
@@ -320,18 +316,9 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
320
316
  `caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a
321
317
  matching font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
322
318
  `brew install --cask font-noto-sans-cjk`.
323
- - **Windows drawtext crashes on some real builds.** On certain Windows ffmpeg
324
- builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
325
- whenever it resolves a font by family name through fontconfig, even with a
326
- valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `overlay.py --text`
327
- and `graphics.py` all resolve a concrete `--font-file` by default when one is
328
- available (`fontfile=` skips fontconfig entirely and is the form confirmed
329
- not to crash), so this should already be handled automatically. If a
330
- drawtext tool still crashes, pass `--font-file` explicitly rather than
331
- relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
332
- drawtext probe and reports `filter:drawtext` missing (with the crash detail
333
- in `errors[]`) rather than a false "available" from the `-filters` listing
334
- alone.
319
+ - **Windows drawtext crashes on some real builds** (#100): the drawtext tools
320
+ resolve a concrete `--font-file` by default, which avoids it; if one still
321
+ crashes, pass `--font-file` explicitly. Details: `references/ci-platform-pitfalls.md`.
335
322
  - **Keyframe cuts.** A lossless `cut.py` result may start up to one GOP (often
336
323
  1–10 s) earlier than requested; the script re-encodes automatically when the
337
324
  deviation exceeds 0.5 s. If the user insists on lossless output, pass
package/mcp/server.py CHANGED
@@ -26,6 +26,7 @@ PROTOCOL_VERSION = "2024-11-05"
26
26
 
27
27
  sys.path.insert(0, str(SCRIPTS))
28
28
  import _contract # noqa: E402 (the contract is the only source of tool names, schemas and argument mapping)
29
+ import _common # noqa: E402 (run_tool: the outer wall-clock ceiling on a dispatched tool)
29
30
 
30
31
  _SPECS: Dict[str, Dict[str, Any]] = {}
31
32
 
@@ -91,7 +92,14 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
91
92
  if name not in specs() or not script.exists():
92
93
  return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
93
94
  argv = build_argv(name, args or {})
94
- proc = subprocess.run([sys.executable, str(script)] + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
95
+ # The child enforces --timeout on each ffmpeg call; this outer ceiling (4x that plus 60 s)
96
+ # is the only thing that ends a child hung for any other reason. The caller's own `timeout`
97
+ # argument sets both; otherwise FFMPEG_SKILL_TIMEOUT / the 1800 s default.
98
+ try:
99
+ per_call = float((args or {}).get("timeout", _common._env_timeout()))
100
+ except (TypeError, ValueError):
101
+ per_call = _common._env_timeout()
102
+ proc = _common.run_tool([str(script)] + argv, per_call=per_call)
95
103
  stdout = proc.stdout.strip()
96
104
  text = stdout
97
105
  structured = None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
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",
@@ -30,7 +30,9 @@
30
30
  "bin/",
31
31
  "scripts/",
32
32
  "mcp/",
33
- "references/",
33
+ "references/scripts.md",
34
+ "references/devices.md",
35
+ "references/ci-platform-pitfalls.md",
34
36
  "SKILL.md",
35
37
  "README.md",
36
38
  "LICENSE"
@@ -165,3 +165,18 @@ these had ever shown up before.
165
165
  the RGB stages (exposure/colortemperature/colorbalance) make swscale go yuv→rgb with the
166
166
  frame's bt709 matrix and back with its bt601 default. The identity test only ever used an
167
167
  untagged source, where both legs pick bt601 and cancel out. Tracked separately.
168
+
169
+ ### Windows: drawtext crashes when it resolves a font by family name (#100)
170
+
171
+ On certain Windows ffmpeg
172
+ builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
173
+ whenever it resolves a font by family name through fontconfig, even with a
174
+ valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `overlay.py --text`
175
+ and `graphics.py` all resolve a concrete `--font-file` by default when one is
176
+ available (`fontfile=` skips fontconfig entirely and is the form confirmed
177
+ not to crash), so this should already be handled automatically. If a
178
+ drawtext tool still crashes, pass `--font-file` explicitly rather than
179
+ relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
180
+ drawtext probe and reports `filter:drawtext` missing (with the crash detail
181
+ in `errors[]`) rather than a false "available" from the `-filters` listing
182
+ alone.
@@ -207,12 +207,11 @@ class Context:
207
207
  """Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
208
208
 
209
209
  Scripts read it as attributes (``STATE.dry_run``) or, for older call sites, like a dict
210
- (``STATE["dry_run"]``). Keeping it a single explicit object rather than module globals makes
210
+ (``STATE.dry_run``). Keeping it a single explicit object rather than module globals makes
211
211
  it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
212
212
  """
213
213
 
214
214
  __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
215
- _KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
216
215
 
217
216
  def __init__(self) -> None:
218
217
  self.reset()
@@ -229,19 +228,6 @@ class Context:
229
228
  self.written: set = set() # output paths this process has written itself
230
229
  self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
231
230
 
232
- # mapping-style access kept for backwards compatibility
233
- def __getitem__(self, key: str) -> Any:
234
- if key not in self._KEYS:
235
- raise KeyError(key)
236
- return getattr(self, key)
237
-
238
- def __setitem__(self, key: str, value: Any) -> None:
239
- if key not in self._KEYS:
240
- raise KeyError(key)
241
- setattr(self, key, value)
242
-
243
- def get(self, key: str, default: Any = None) -> Any:
244
- return getattr(self, key, default) if key in self._KEYS else default
245
231
 
246
232
 
247
233
  STATE = Context()
@@ -493,6 +479,75 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -
493
479
  return proc
494
480
 
495
481
 
482
+ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
483
+ """Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
484
+ stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
485
+ under its own --timeout, so the outer ceiling is a multiple of that plus a margin: it never
486
+ fires first on a healthy run, and it is the only thing that ends a child hung for a reason
487
+ that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
488
+ limit = STATE.timeout if per_call is None else per_call
489
+ return (limit * 4 + 60) if limit else None
490
+
491
+
492
+ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subprocess.CompletedProcess:
493
+ """Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
494
+ child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
495
+ document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
496
+ exactly as they would from the child itself."""
497
+ limit = child_limit(per_call)
498
+ try:
499
+ return subprocess.run([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
500
+ except subprocess.TimeoutExpired as e:
501
+ name = os.path.basename(str(argv[0]))
502
+ msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
503
+ doc = {"status": "failed", "exit_code": 124,
504
+ "error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
505
+ "commands": []}
506
+ partial = e.stderr.decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
507
+ return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
508
+
509
+
510
+ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
511
+ *, check: bool = True) -> List[float]:
512
+ """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
513
+ single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
514
+ sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
515
+ ffmpeg = require_tool("ffmpeg")
516
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
517
+ if start:
518
+ cmd += ["-ss", f"{start:.3f}"]
519
+ cmd += ["-i", path]
520
+ if seconds is not None:
521
+ cmd += ["-t", f"{seconds:.3f}"]
522
+ cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
523
+ proc = run_analysis(cmd, check=False, text=False)
524
+ if proc.returncode != 0 or not proc.stdout:
525
+ if check:
526
+ die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
527
+ return []
528
+ n = len(proc.stdout) // 2
529
+ import struct
530
+ return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
531
+
532
+
533
+ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
534
+ """RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
535
+ block must be the same length for the correlation); remove_mean subtracts the envelope's mean
536
+ (sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
537
+ import math
538
+ step = max(1, int(step))
539
+ n = len(samples)
540
+ stop = n - step + 1 if full_blocks_only else n
541
+ env: List[float] = []
542
+ for i in range(0, max(0, stop), step):
543
+ block = samples[i:i + step]
544
+ env.append(math.sqrt(sum(x * x for x in block) / len(block)))
545
+ if remove_mean and env:
546
+ mean = sum(env) / len(env)
547
+ env = [e - mean for e in env]
548
+ return env
549
+
550
+
496
551
  def child_args() -> List[str]:
497
552
  """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
498
553
  so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
@@ -689,9 +744,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
689
744
  role="output" marks a file this tool just wrote: a read failure is then reported as an
690
745
  output-verification failure (kind "output") instead of an input problem."""
691
746
  if not os.path.exists(path):
692
- if role == "output" and not STATE["dry_run"]:
747
+ if role == "output" and not STATE.dry_run:
693
748
  _output_failed(path, "not written")
694
- if STATE["dry_run"]:
749
+ if STATE.dry_run:
695
750
  # width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
696
751
  # below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
697
752
  # probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
@@ -728,8 +783,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
728
783
  duration = _to_float(video.get("duration"))
729
784
  if duration is None and audio:
730
785
  duration = _to_float(audio.get("duration"))
731
- if duration and STATE.get("duration_hint") is None:
732
- STATE["duration_hint"] = duration
786
+ if duration and STATE.duration_hint is None:
787
+ STATE.duration_hint = duration
733
788
 
734
789
  out: Dict[str, Any] = {
735
790
  "file": path,
package/scripts/audio.py CHANGED
@@ -131,9 +131,9 @@ def main() -> int:
131
131
  output = args.output or default_output(args.input, "audio")
132
132
  audio_out = is_audio_output(output)
133
133
  streams = meta.get("audio_streams") or []
134
- if streams and not (0 <= args.audio_stream < len(streams)) and not STATE["dry_run"]:
134
+ if streams and not (0 <= args.audio_stream < len(streams)) and not STATE.dry_run:
135
135
  die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
136
- if args.audio_stream and not streams and not STATE["dry_run"]:
136
+ if args.audio_stream and not streams and not STATE.dry_run:
137
137
  die("--audio-stream needs an input with audio streams")
138
138
 
139
139
  inputs: List[str] = ["-i", args.input]
@@ -222,7 +222,7 @@ def main() -> int:
222
222
  run(cmd)
223
223
  r = probe(output, role="output")
224
224
  a = r["audio"]
225
- if r.get("video") and audio_out and not STATE["dry_run"]:
225
+ if r.get("video") and audio_out and not STATE.dry_run:
226
226
  die(f"{output} unexpectedly contains a video stream")
227
227
  info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
228
228
  + (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
package/scripts/batch.py CHANGED
@@ -26,13 +26,12 @@ import argparse
26
26
  import hashlib
27
27
  import json
28
28
  import os
29
- import subprocess
30
29
  import sys
31
30
  import time
32
31
  from pathlib import Path
33
32
  from typing import Any, Dict, List
34
33
 
35
- from _common import STATE, add_common, apply_common, child_args, die, emit, info
34
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool
36
35
 
37
36
  HERE = Path(__file__).resolve().parent
38
37
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
@@ -78,9 +77,9 @@ def run_step(argv: List[str]) -> bool:
78
77
  if script not in ALLOWED_STEP_SCRIPTS:
79
78
  die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
80
79
  f"(must be a bare filename like 'silence.py', found in scripts/)")
81
- cmd = [sys.executable, str(HERE / script)] + argv[1:] + child_args()
82
- info(" → " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
83
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
80
+ cmd = [str(HERE / script)] + argv[1:] + child_args()
81
+ info(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
82
+ proc = run_tool(cmd)
84
83
  if proc.returncode != 0:
85
84
  info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
86
85
  return False
@@ -191,7 +190,7 @@ def main() -> int:
191
190
  info(f"=== {src.name}")
192
191
  r = process(src, recipe, outdir, work)
193
192
  results.append(r)
194
- if r["ok"] and not STATE["dry_run"]:
193
+ if r["ok"] and not STATE.dry_run:
195
194
  cache[key] = r
196
195
  # write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
197
196
  # a concurrent manual run) could leave a truncated file that json.loads() above
package/scripts/broll.py CHANGED
@@ -83,7 +83,7 @@ def main() -> int:
83
83
  if dur_a and at + length > dur_a + 0.01:
84
84
  die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
85
85
  dur_b = meta_b.get("duration") or 0.0
86
- if dur_b and start_b + length > dur_b + 0.01 and not STATE["dry_run"]:
86
+ if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
87
87
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
88
88
  if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
89
89
  die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
@@ -142,7 +142,7 @@ def main() -> int:
142
142
  run(cmd)
143
143
 
144
144
  result = probe(output, role="output")
145
- if not STATE["dry_run"] and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
145
+ if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
146
146
  die(f"output is {result.get('duration'):.3f}s but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
147
147
  info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
148
148
  emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio)
package/scripts/cut.py CHANGED
@@ -117,7 +117,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
117
117
  info("stream copy failed, falling back to re-encode")
118
118
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
119
119
  die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
120
- if not reencode and tolerance >= 0 and not STATE["dry_run"]:
120
+ if not reencode and tolerance >= 0 and not STATE.dry_run:
121
121
  got = probe(dst).get("duration") or 0.0
122
122
  if abs(got - dur) > tolerance:
123
123
  near = keyframes_near(src, start)
@@ -209,7 +209,7 @@ def main() -> int:
209
209
  expected = sum(e - s for s, e in segments)
210
210
  precision = precision_of(meta, output, reencoded)
211
211
  got = result.get("duration")
212
- error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE["dry_run"] else None
212
+ error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE.dry_run else None
213
213
  # mode: "copy" (untouched lossless), "accurate" (--accurate was asked for), "hybrid" (asked for
214
214
  # lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
215
215
  mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
package/scripts/export.py CHANGED
@@ -100,7 +100,7 @@ def main() -> int:
100
100
  video = list(p["video"])
101
101
  if args.crf is not None and "-crf" in video:
102
102
  video[video.index("-crf") + 1] = str(args.crf)
103
- if STATE["fast"] and "-preset" in video:
103
+ if STATE.fast and "-preset" in video:
104
104
  video[video.index("-preset") + 1] = "veryfast"
105
105
  cmd += video
106
106
  if args.preset != "copy":
package/scripts/fit.py CHANGED
@@ -159,7 +159,7 @@ def main() -> int:
159
159
  if target <= 0:
160
160
  die("target duration must be > 0")
161
161
  if args.method == "speed":
162
- if src_dur <= 0 and STATE["dry_run"]:
162
+ if src_dur <= 0 and STATE.dry_run:
163
163
  src_dur = target # planning against an intermediate that does not exist yet
164
164
  factor = src_dur / target # >1 = speed up
165
165
  if factor > args.max_speed or factor < 1 / args.max_speed:
@@ -175,7 +175,7 @@ def main() -> int:
175
175
  if has_audio:
176
176
  af.append(atempo_chain(factor))
177
177
  post += ["-t", f"{target:.3f}"]
178
- STATE["duration_hint"] = target
178
+ STATE.duration_hint = target
179
179
  else:
180
180
  if target < src_dur:
181
181
  start = (src_dur - target) / 2 if args.from_center else 0.0
package/scripts/join.py CHANGED
@@ -36,7 +36,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
36
36
  durs = [m.get("duration") or 0.0 for m in metas]
37
37
  d = args.duration if args.transition != "none" else 0.0
38
38
  for p, dur in zip(args.inputs, durs):
39
- if d and dur <= d * 2 and not STATE["dry_run"]:
39
+ if d and dur <= d * 2 and not STATE.dry_run:
40
40
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
41
41
  rates = [m["audio"].get("sample_rate") or 48000 for m in metas]
42
42
  chans = [m["audio"].get("channels") or 2 for m in metas]
@@ -70,7 +70,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
70
70
  expected = sum(durs) - d * (n - 1)
71
71
  r = probe(output)
72
72
  a = r.get("audio") or {}
73
- if not STATE["dry_run"]:
73
+ if not STATE.dry_run:
74
74
  if r.get("video"):
75
75
  die(f"{output} unexpectedly contains a video stream")
76
76
  if a.get("sample_rate") != rate or a.get("channels") != channels:
@@ -137,7 +137,7 @@ def main() -> int:
137
137
  durs = [m.get("duration") or 0.0 for m in metas]
138
138
  d = args.duration if args.transition != "none" else 0.0
139
139
  for p, dur in zip(args.inputs, durs):
140
- if d and dur <= d * 2 and not STATE["dry_run"]:
140
+ if d and dur <= d * 2 and not STATE.dry_run:
141
141
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
142
142
 
143
143
  cmd = ffmpeg_base()
@@ -23,7 +23,7 @@ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_c
23
23
 
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
- if STATE["dry_run"]:
26
+ if STATE.dry_run:
27
27
  return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
28
28
  ffmpeg = require_tool("ffmpeg")
29
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
@@ -136,9 +136,9 @@ def main() -> int:
136
136
 
137
137
  result = probe(output, role="output")
138
138
  written = result.get("chapters") or []
139
- if chapters is not None and not STATE["dry_run"] and len(written) != len(chapters):
139
+ if chapters is not None and not STATE.dry_run and len(written) != len(chapters):
140
140
  die(f"wrote {len(written)} chapters but {len(chapters)} were asked for", kind="output")
141
- if args.clear_chapters and not STATE["dry_run"] and written:
141
+ if args.clear_chapters and not STATE.dry_run and written:
142
142
  die(f"{len(written)} chapters survived --clear-chapters", kind="output")
143
143
  info(f"wrote {output} ({len(written)} chapters, tags: {', '.join(sorted(tags)) or 'unchanged'}, streams copied)")
144
144
  emit(output, chapters=written, tags=result.get("tags") or {}, streams_copied=True)
package/scripts/render.py CHANGED
@@ -51,12 +51,11 @@ Examples:
51
51
  import argparse
52
52
  import json
53
53
  import os
54
- import subprocess
55
54
  import sys
56
55
  from pathlib import Path
57
56
  from typing import Any, Dict, List
58
57
 
59
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe
58
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool
60
59
 
61
60
  HERE = Path(__file__).resolve().parent
62
61
 
@@ -80,12 +79,12 @@ TEMPLATE = {
80
79
 
81
80
  def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
82
81
  """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
83
- cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
84
- info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd[:-1])))
85
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
82
+ cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
83
+ info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
84
+ proc = run_tool(cmd)
86
85
  for line in proc.stderr.splitlines():
87
86
  if line.startswith("$ ") or line.startswith("[dry-run]"):
88
- STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
87
+ STATE.commands.append(line[2:] if line.startswith("$ ") else line)
89
88
  elif line.strip():
90
89
  info(" " + line)
91
90
  try:
@@ -152,7 +151,7 @@ def main() -> int:
152
151
  parts: List[str] = []
153
152
  for i, c in enumerate(clips):
154
153
  src = rel(c["src"])
155
- if not STATE["dry_run"]:
154
+ if not STATE.dry_run:
156
155
  probe(src)
157
156
  needs_cut = c.get("in") is not None or c.get("out") is not None
158
157
  part = str(work / f"clip{i:02d}.mp4")
@@ -167,7 +166,7 @@ def main() -> int:
167
166
  part = src
168
167
  if c.get("speed"):
169
168
  spd = float(c["speed"])
170
- dur = (probe(part).get("duration") or 0.0) if not STATE["dry_run"] else 10.0
169
+ dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
171
170
  fitted = str(work / f"clip{i:02d}_speed.mp4")
172
171
  sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
173
172
  part = fitted
@@ -348,7 +347,7 @@ def main() -> int:
348
347
  sh("export.py", *argv)
349
348
  stages_done.append("export")
350
349
  else:
351
- if not STATE["dry_run"]:
350
+ if not STATE.dry_run:
352
351
  import shutil
353
352
  shutil.copyfile(current, output)
354
353
  info(f"copied final stage to {output}")
@@ -358,8 +357,8 @@ def main() -> int:
358
357
  ck = proj.get("check")
359
358
  check_result = None
360
359
  exit_code = 0
361
- if ck and ck.get("platform") and not STATE["dry_run"]:
362
- proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
360
+ if ck and ck.get("platform") and not STATE.dry_run:
361
+ proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"])
363
362
  try:
364
363
  check_result = json.loads(proc.stdout)
365
364
  except ValueError:
package/scripts/report.py CHANGED
@@ -13,13 +13,12 @@ import base64
13
13
  import html
14
14
  import json
15
15
  import os
16
- import subprocess
17
16
  import sys
18
17
  import tempfile
19
18
  from pathlib import Path
20
19
  from typing import Any, Dict, List, Optional
21
20
 
22
- from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die
21
+ from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die, run_tool
23
22
 
24
23
  HERE = Path(__file__).resolve().parent
25
24
 
@@ -27,15 +26,14 @@ HERE = Path(__file__).resolve().parent
27
26
  def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
28
27
  with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
29
28
  png = os.path.join(tmp, "sheet.png")
30
- proc = subprocess.run([sys.executable, str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png],
31
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
29
+ proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png])
32
30
  if proc.returncode != 0 or not os.path.exists(png):
33
31
  return None
34
32
  return base64.b64encode(Path(png).read_bytes()).decode("ascii")
35
33
 
36
34
 
37
35
  def loudness(path: str) -> Dict[str, Any]:
38
- proc = subprocess.run([sys.executable, str(HERE / "loudness.py"), path, "--measure-only"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
36
+ proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"])
39
37
  try:
40
38
  d = json.loads(proc.stdout)
41
39
  return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
@@ -44,7 +42,7 @@ def loudness(path: str) -> Dict[str, Any]:
44
42
 
45
43
 
46
44
  def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
47
- proc = subprocess.run([sys.executable, str(HERE / "check.py"), path, "--platform", platform, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
45
+ proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"])
48
46
  try:
49
47
  doc = json.loads(proc.stdout)
50
48
  except ValueError:
package/scripts/scenes.py CHANGED
@@ -21,11 +21,10 @@ import argparse
21
21
  import math
22
22
  import os
23
23
  import re
24
- import struct
25
24
  import sys
26
25
  from typing import Dict, List, Tuple
27
26
 
28
- from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis
27
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
29
28
 
30
29
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
31
30
 
@@ -87,19 +86,9 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
87
86
 
88
87
 
89
88
  def audio_envelope(path: str, step_s: float) -> List[float]:
90
- ffmpeg = require_tool("ffmpeg")
91
- proc = run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
92
- check=False, text=False)
93
- n = len(proc.stdout) // 2
94
- if n == 0:
95
- return []
96
- samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
97
- step = max(1, int(8000 * step_s))
98
- env = []
99
- for i in range(0, n, step):
100
- block = samples[i:i + step]
101
- env.append(math.sqrt(sum(x * x for x in block) / len(block)) / 32768.0)
102
- return env
89
+ """RMS level per step_s window at 8 kHz, absolute (a loud scene scores higher); [] when the
90
+ audio cannot be decoded (the cut scoring then runs on the picture alone)."""
91
+ return rms_envelope(decode_pcm_mono(path, 8000, check=False), int(8000 * step_s))
103
92
 
104
93
 
105
94
  def main() -> int:
@@ -62,7 +62,7 @@ def main() -> int:
62
62
  trf = str(Path(tmp) / "transforms.trf")
63
63
  trf_arg = escape_filter_path(trf)
64
64
 
65
- if not STATE["dry_run"]:
65
+ if not STATE.dry_run:
66
66
  ffmpeg = require_tool("ffmpeg")
67
67
  detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
68
  if args.tripod:
package/scripts/sync.py CHANGED
@@ -29,11 +29,10 @@ import cmath
29
29
  import json
30
30
  import math
31
31
  import os
32
- import struct
33
32
  import sys
34
33
  from typing import List
35
34
 
36
- from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args
35
+ from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
36
 
38
37
  SR = 8000 # decode sample rate
39
38
 
@@ -55,26 +54,12 @@ OVERLAP_WEIGHT_EXP = 0.5
55
54
 
56
55
 
57
56
  def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
58
- ffmpeg = require_tool("ffmpeg")
59
- cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
60
- "-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
61
- proc = run_analysis(cmd, check=False, text=False)
62
- if proc.returncode != 0 or not proc.stdout:
63
- die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
64
- n = len(proc.stdout) // 2
65
- return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
57
+ return decode_pcm_mono(path, SR, seconds, start)
66
58
 
67
59
 
68
60
  def envelope(samples: List[float], step: int) -> List[float]:
69
- """RMS energy per block, mean-removed so silence does not correlate."""
70
- env = []
71
- for i in range(0, len(samples) - step + 1, step):
72
- block = samples[i : i + step]
73
- env.append(math.sqrt(sum(x * x for x in block) / step))
74
- if not env:
75
- return env
76
- mean = sum(env) / len(env)
77
- return [e - mean for e in env]
61
+ """RMS energy per full block, mean-removed so silence does not correlate."""
62
+ return rms_envelope(samples, step, full_blocks_only=True, remove_mean=True)
78
63
 
79
64
 
80
65
  def fft(a: List[complex]) -> List[complex]:
@@ -1,229 +0,0 @@
1
- # Process pitfalls
2
-
3
- Mistakes made (or nearly made) while developing this repo that were not about FFmpeg
4
- or a platform's behaviour — about the *process* of making a change safely. Written down
5
- for the same reason `references/ci-platform-pitfalls.md` exists: a mistake that isn't
6
- recorded gets repeated the next time a session starts fresh with no memory of it.
7
-
8
- **This file is a living record.** Whenever a change here is made (or nearly made, then
9
- caught before landing) because an existing guardrail — a pinned test, an environment
10
- constraint, a platform's real behaviour under repeated attempts — wasn't checked first,
11
- add an entry below. Don't wait to be asked.
12
-
13
- ## Before narrowing a `required`/`optional` capability list, grep for the pinned test that checks it
14
-
15
- `scripts/_contract.py`'s `TOOL_META[...]["required"]` drives `doctor`'s per-tool `usable`
16
- answer (`_tool_usability()` in `_contract.py` only reads `required`, never `optional`).
17
- Moving a capability from `required` to `optional` — even when it's honestly true that a
18
- new flag makes it conditional — silently changes what `doctor` reports as `usable: no`
19
- on a machine missing that capability, for the tool's *default* invocation too.
20
-
21
- `tests/test_contract.py`'s `DoctorDetectionTests` pins specific `usable` outcomes against
22
- real captured `ffmpeg -filters`/`-encoders` fixtures (e.g. a plain Homebrew macOS build
23
- correctly reporting `caption.usable: "no"` because it lacks `filter:subtitles`). A change
24
- to `required` that isn't checked against these first can pass a quick unit test and still
25
- break this fixture-based guarantee.
26
-
27
- Caught twice while adding capability metadata for new flags (`caption.py --mode mux` in
28
- #51, `doctor`'s `gpu_encoders` in #52) — in both cases the fix was to grep
29
- `tests/test_contract.py` for `usable` and `_doctor(` *before* editing `TOOL_META`, not
30
- after a test failure revealed it. Do that grep first, every time `required`/`optional`
31
- changes.
32
-
33
- ## Git tag push and GitHub Release creation are not reachable from this environment — so they are not done from it
34
-
35
- The git credentials available here can push to `refs/heads/*` (branches) but not
36
- `refs/tags/*` — confirmed by a 403 straight from the git-receive-pack endpoint, not an
37
- auth failure, meaning it's a deliberate scope restriction, not a bug to route around.
38
- The GitHub MCP tool surface has no `create_release`/`create_tag` equivalent either, and a
39
- direct call to the GitHub REST API's `/releases` endpoint with a raw token is blocked by
40
- the outbound proxy itself.
41
-
42
- Confirmed once (retried the tag push a second time "just in case" before accepting it).
43
- Don't retry either path a second time. Since 0.16.11 this is moot for releases:
44
- `.github/workflows/release.yml` creates the tag, the GitHub Release and the npm publish
45
- from GitHub Actions on every push to `main` (README, "Development" → "Releasing"), so a
46
- session never needs to push a tag at all — merging the PR is the release. The one thing
47
- still worth knowing: that workflow's own `git push origin HEAD:main` (the automatic
48
- version-bump commit) and its tag run under the built-in `GITHUB_TOKEN`, which by design
49
- triggers no further workflow runs, so a red `tests` run for that bump commit is not
50
- "missing" — it is never scheduled; the PR run before the merge is the one that counted.
51
-
52
- ## A throwaway probe must be pointed at a copy in a directory you have just verified with `pwd`, never at "the repo, probably"
53
-
54
- While validating the auto-bump script for `release.yml`, a scratch run was meant to
55
- execute against a temporary copy of the repo. It ran with the real checkout as its
56
- working directory instead — a `cd` into the scratch path happened in a shell whose
57
- working directory was reset between commands — and its `git add -A && git commit`,
58
- `git tag v0.16.12` and two empty commits landed on the real branch and moved the real
59
- local `v0.16.12` tag. Nothing was pushed, and `git reflog` plus a `git fetch --force` of
60
- the tag from `origin` restored everything, but it was only noticed because `git status`
61
- showed files the session had not edited.
62
-
63
- Rule: a probe that runs `git commit`, `git tag`, `rm -rf`, or writes into the tree goes
64
- in a directory created *and* verified in the same command (`cd "$DIR" && pwd && ...`),
65
- not one assumed from an earlier `cd`; prefer a fixture built from a few `printf` lines
66
- over a copy of the whole checkout (the copy carries the real `.git`, so a mistake there
67
- is a mistake in the real history); and run `git status` on the real repo before
68
- committing anything afterwards. `.claude/skills/destructive-operations/SKILL.md` says the
69
- same thing for the tools themselves — it applies to the person testing them too.
70
-
71
- ## A quantitative test failing three different ways across fixture redesigns means the platform, not the fixture, is the problem
72
-
73
- `test_stabilize_reduces_frame_to_frame_motion` (macOS CI, `stabilize.py`) failed with
74
- three independently redesigned shake fixtures in a row — each time the instinct was "the
75
- fixture's frequencies must be wrong," each time the retuned fixture failed a *different*
76
- way on the next CI run. The actual cause (libvidstab behaving differently across the
77
- Linux and macOS ffmpeg builds) was diagnosable from the first failure: a synthetic
78
- fixture that reliably improves under one implementation and reliably gets worse under
79
- another is evidence the implementations disagree, not that the fixture is miscalibrated.
80
-
81
- If a quantitative assertion fails on one platform, survives a redesign, and fails again
82
- on the *same* platform in a different way: stop redesigning the fixture. Either restrict
83
- the strict assertion to the platform where it's provably correct (keeping a weaker,
84
- platform-general check — output exists, has the right duration — everywhere), or escalate
85
- before spending a third CI cycle on it.
86
-
87
- ## A fix merged after CHANGELOG.md's current-version section was drafted can silently miss it
88
-
89
- `CHANGELOG.md`'s `## 0.12.0` section was written once, covering everything merged up to
90
- that point. Two fixes that closed real issues after that point (#62's `--audio-stream`
91
- extension via PR #72, #77's dry-run-dims fix via PR #88) landed with no further nudge to
92
- go back and add a bullet — #62's fix actually got a bullet (its content is genuinely
93
- described) but the `Closes #62` link was left off, and #77 was missed outright until a
94
- direct question ("shouldn't this bump the version?") prompted a manual check. Neither was
95
- caught by CI, because nothing checked CHANGELOG.md against what had actually been closed.
96
-
97
- Caught by hand both times, then closed properly with `tests/test_contract.py`'s
98
- `test_changelog_mentions_every_closed_issue_since_last_tag`, which walks `git log` back to
99
- the latest release tag, extracts every `Closes #N.` from a commit body, and fails if that
100
- issue number doesn't appear anywhere in `CHANGELOG.md`. This needs real history (`ci.yml`'s
101
- `actions/checkout` step now passes `fetch-depth: 0` for exactly this reason — the default
102
- shallow clone leaves no tag reachable to diff against, which would make the test silently
103
- skip itself in CI, not fail). If this test ever needs to skip a genuinely changelog-less
104
- closed issue (a pure process note, a duplicate, a revert of an unreleased change), name the
105
- exemption in the test itself with a reason — don't just widen the regex or drop the check.
106
-
107
- ## Automation that can publish must not take its "major" cue from text it did not write
108
-
109
- On 2026-09-11, three routine Dependabot merges (`actions/upload-artifact` 4→7,
110
- `actions/setup-node` 4→7, `dependabot/fetch-metadata` 2→3) were published to npm as
111
- **1.0.0, 1.0.1 and 1.0.2**. The release pipeline (`release.yml`, since #129) resolves the next
112
- version from PR labels; an autolabeler rule added the same day applied `major` to any PR whose
113
- *body* contained the literal breaking-change marker. Dependabot PR bodies quote the upstream
114
- project's release notes verbatim, and upload-artifact's v5.0.0 notes contain exactly that
115
- phrase — about *their* Node runtime, nothing to do with this package. One label, three
116
- accidental majors, in nine minutes, with every job green.
117
-
118
- Two things made it worse than one bad rule: every chore merge released at all (so the first
119
- accident was followed by two more before anyone looked), and nothing in the pipeline treated
120
- "the major number changed" as different from any other bump.
121
-
122
- Fixes (this commit): no autolabeler rule produces `major` any more; `chore`/`ci`/`docs`/
123
- `dependencies` PRs are excluded from version resolution so they release nothing; `release.yml`
124
- refuses to auto-bump across a major boundary regardless of labels; and the release job is
125
- serialised (`concurrency`) so back-to-back merges cannot race on the bump push.
126
-
127
- The general rule: a pipeline that publishes must never derive an irreversible decision (a
128
- major bump, a publish, a tag) from text it did not author — PR bodies, commit messages and
129
- release notes are quotations as often as they are statements. Match on labels a person
130
- applied, or on files changed, and make the irreversible step refuse anything surprising rather
131
- than assume the surprise was intended. And after wiring any such automation, watch the first
132
- few real runs' *results* (npm, tags) rather than their exit codes: the three runs here were
133
- "success" by every check the job had.
134
-
135
- ## An action input that does not exist is a warning, not an error -- and "excluded from the notes" is not "no release"
136
-
137
- The fix for the accidental majors above (#145) still released **1.0.4** for its own,
138
- workflow-only merge. Two assumptions in `release.yml` were wrong and nothing checked either:
139
-
140
- - `release-drafter/release-drafter@v6` was called with `dry-run: true` to "compute the next
141
- version read-only". That action has no `dry-run` input. GitHub Actions logs
142
- `Unexpected input(s) 'dry-run'` as a *warning* and runs the step anyway -- so every release
143
- run had been rewriting the draft release live, and the "read-only" in the comment was fiction.
144
- - `exclude-labels` in `release-drafter.yml` was expected to make a chore-only merge resolve to
145
- the same version as the last tag. It only removes those PRs from the draft *notes*; the
146
- version resolver still applies `default: patch` and reports last+patch. The workflow's "same
147
- version → no-op" guard therefore never fired.
148
-
149
- Both were visible in the first run's log and in the action's documented inputs, and both were
150
- missed because the PR's test plan verified the YAML *parsed* and the config *contained* the
151
- intended keys -- not that the action *did* what the comment claimed. Fixed by taking the
152
- decision away from the action: `.github/scripts/resolve_version.py` reads the merged PRs'
153
- labels through `gh api`, returns nothing when nothing is releasable, refuses `major`, and has
154
- a unit test in `tests/test_contract.py` with fake label data for each rule.
155
-
156
- The general rule, twice over now: when wiring a third-party action, read its `action.yml`
157
- inputs (or `Unexpected input(s)` in the first log) before trusting a parameter, and treat
158
- any step whose output decides an irreversible action as something to unit-test with fixed
159
- inputs, not something to confirm by reading its YAML. And watch the first real run's
160
- *effect* (tags, npm), which is how both incidents were actually noticed.
161
-
162
- ### The release bump step required the literal `(nothing yet)` line under `## Unreleased`
163
-
164
- Found on the first run after #163 (2026-09-11). `release.yml`'s auto-bump located the CHANGELOG
165
- insertion point with `assert "## Unreleased\n\n(nothing yet)\n\n" in changelog`. #163 did the
166
- natural thing and wrote its notes under Unreleased, so the bump step failed on the assert
167
- before the push, the tag or the publish -- a clean no-op, but a red run and no release. The
168
- script now takes whatever sits under Unreleased into the new version's section and puts the
169
- placeholder back, so hand-written notes are welcome there. Lesson: an anchor that is also
170
- prose will be edited; anchor on the heading, not on the placeholder text.
171
-
172
- ### The built-in GITHUB_TOKEN cannot push the release bump through a ruleset
173
-
174
- Found on the first release after the main ruleset went active (2026-09-11, run for #166):
175
- `git push origin HEAD:main` from release.yml was declined with GH013 ("Changes must be made
176
- through a pull request", "8 of 8 required status checks are expected"). GitHub Actions cannot
177
- be added as a ruleset bypass actor (the import rejects the actor, the UI does not list it), so
178
- the bump push now uses the `RELEASE_PUSH_TOKEN` secret -- a fine-grained PAT of a repository
179
- admin with Contents: read/write on this repo -- whose "Repository admin" bypass applies. A PAT
180
- push triggers workflows (GITHUB_TOKEN's do not), so the bump commit carries `[skip ci]`; the
181
- tag, Release and npm publish all happen in the originating run. Rotate the PAT before it
182
- expires or the next release fails at the same step, cleanly, before anything is published.
183
-
184
- ### A literal `[skip ci]` anywhere in a PR body skips every workflow on the squash merge
185
-
186
- Found on the merge of #170 (2026-09-11). The PR body quoted the new bump-commit message
187
- verbatim, including `[skip ci]`; a squash merge copies the PR body into the merge commit, and
188
- GitHub honours the marker wherever it appears in the commit message. Nothing ran on `main` for
189
- that merge -- no tests, no CodeQL, no release -- and the release only happened when the next PR
190
- merged. Describe the marker in words in PR bodies and commit messages ("the skip-CI marker"),
191
- or wrap it so it does not match, and after any merge that touches CI check that the push
192
- actually triggered the expected runs.
193
-
194
- ### Two merges minutes apart: the first release run bumps on a stale main and its push is rejected
195
-
196
- Found on 1.4.2 (2026-09-11). #167 (fix) merged, then #171 (docs) a minute later while the
197
- release run for #167 was still bumping. The concurrency group serialises the runs, but a run
198
- checks out the SHA that triggered it, so the first run's bump commit sat behind #171's merge
199
- and `git push origin HEAD:main` was rejected as non-fast-forward. The second run (for #171)
200
- then found both PRs unreleased and published 1.4.2 correctly, so nothing was lost -- one red
201
- run and a confusing timeline. release.yml now checks out `ref: main` and rebases the bump on
202
- main right before pushing. Lesson: a workflow that pushes to the branch that triggered it must
203
- start from the branch tip, not from the triggering commit.
204
-
205
- ### "Clean up the partial output on failure" deleted the user's file
206
-
207
- Found by the 1.4.2 review (2026-09-12), present since #78 (2026-09-07). The cleanup that removes a
208
- 0-byte stray after a failed encode keyed on "output path exists after failure", which is also
209
- true of a deliverable that was there before the run and that ffmpeg never opened (a bad filter
210
- argument fails at graph init, before the muxer touches the output -- on 6.1+). The --overwrite
211
- consent added in #163 guards the success path only; the failure path had its own delete. The
212
- first fix (snapshot size/mtime, leave an unchanged file alone) passed on 6.1 and failed in the
213
- 5.1.1 CI job: FFmpeg 5.x opens (truncates) the output during option parsing, before any filter
214
- initialises, so ffmpeg itself had already destroyed the file. The fix that holds on every
215
- version is to never let ffmpeg write to an existing path: run against a hidden sibling temp
216
- file and os.replace() it over the original on success only. Lessons: a destructive step must
217
- know whether it created the thing it is about to destroy, "exists" is not that knowledge; and
218
- "the tool fails before touching the file" is a version-specific fact, never a guarantee. And the second review found what the first one -- which had
219
- just written the overwrite guard next to this code -- did not: a reviewer who wrote the fix
220
- reads the file they fixed, not the one beside it.
221
-
222
- ### --timeout only worked when ffmpeg was talking
223
-
224
- Same review. The --progress runner iterated the progress pipe and compared the clock per
225
- line, so the one case the timeout exists for (a deadlocked ffmpeg, which prints nothing)
226
- never reached the comparison. The non-progress path used subprocess.run(timeout=) and was
227
- fine, and the test only exercised that path. Lesson: a deadline belongs on a clock the loop
228
- wakes up to check, never on the arrival of the thing you are waiting for; and a test for
229
- "hang" must use a shim that actually hangs silently, not one that fails fast.