ffmpeg-skill 1.4.5 → 1.4.7

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.7",
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.
@@ -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`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to measure or analyse (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`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- 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
  ## Contents
6
6
  - probe.py — inspect
@@ -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()
@@ -476,12 +462,17 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
476
462
  return proc
477
463
 
478
464
 
479
- def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
480
- """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
481
- output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
482
- --dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
483
- limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
484
- instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
465
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
466
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
467
+ silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
468
+ file, no deliverable written. These are not run() calls -- they run under --dry-run too,
469
+ since a plan built on a fake measurement is not a plan (silence.py used to report "0
470
+ silences" and loudness.py a made-up -20 LUFS under --dry-run) -- but they get the same
471
+ wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
472
+ failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
473
+ lists the command in the --json `commands` like run() does."""
474
+ if record:
475
+ STATE.commands.append(_cmdline(cmd))
485
476
  limit = _limit_for(cmd)
486
477
  try:
487
478
  proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
@@ -493,6 +484,86 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -
493
484
  return proc
494
485
 
495
486
 
487
+ def dry_run_input_pending(path: str) -> bool:
488
+ """True when a measurement cannot run because its input does not exist yet under --dry-run:
489
+ in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
490
+ run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
491
+ on a real file the measurement runs even under --dry-run."""
492
+ if STATE.dry_run and not os.path.exists(path):
493
+ info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
494
+ return True
495
+ return False
496
+
497
+
498
+ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
499
+ """Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
500
+ stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
501
+ under its own --timeout, so the outer ceiling is a multiple of that plus a margin: it never
502
+ fires first on a healthy run, and it is the only thing that ends a child hung for a reason
503
+ that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
504
+ limit = STATE.timeout if per_call is None else per_call
505
+ return (limit * 4 + 60) if limit else None
506
+
507
+
508
+ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subprocess.CompletedProcess:
509
+ """Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
510
+ child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
511
+ document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
512
+ exactly as they would from the child itself."""
513
+ limit = child_limit(per_call)
514
+ try:
515
+ return subprocess.run([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
516
+ except subprocess.TimeoutExpired as e:
517
+ name = os.path.basename(str(argv[0]))
518
+ msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
519
+ doc = {"status": "failed", "exit_code": 124,
520
+ "error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
521
+ "commands": []}
522
+ partial = e.stderr.decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
523
+ return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
524
+
525
+
526
+ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
527
+ *, check: bool = True) -> List[float]:
528
+ """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
529
+ single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
530
+ sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
531
+ ffmpeg = require_tool("ffmpeg")
532
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
533
+ if start:
534
+ cmd += ["-ss", f"{start:.3f}"]
535
+ cmd += ["-i", path]
536
+ if seconds is not None:
537
+ cmd += ["-t", f"{seconds:.3f}"]
538
+ cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
539
+ proc = run_analysis(cmd, check=False, text=False)
540
+ if proc.returncode != 0 or not proc.stdout:
541
+ if check:
542
+ die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
543
+ return []
544
+ n = len(proc.stdout) // 2
545
+ import struct
546
+ return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
547
+
548
+
549
+ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
550
+ """RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
551
+ block must be the same length for the correlation); remove_mean subtracts the envelope's mean
552
+ (sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
553
+ import math
554
+ step = max(1, int(step))
555
+ n = len(samples)
556
+ stop = n - step + 1 if full_blocks_only else n
557
+ env: List[float] = []
558
+ for i in range(0, max(0, stop), step):
559
+ block = samples[i:i + step]
560
+ env.append(math.sqrt(sum(x * x for x in block) / len(block)))
561
+ if remove_mean and env:
562
+ mean = sum(env) / len(env)
563
+ env = [e - mean for e in env]
564
+ return env
565
+
566
+
496
567
  def child_args() -> List[str]:
497
568
  """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
498
569
  so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
@@ -689,9 +760,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
689
760
  role="output" marks a file this tool just wrote: a read failure is then reported as an
690
761
  output-verification failure (kind "output") instead of an input problem."""
691
762
  if not os.path.exists(path):
692
- if role == "output" and not STATE["dry_run"]:
763
+ if role == "output" and not STATE.dry_run:
693
764
  _output_failed(path, "not written")
694
- if STATE["dry_run"]:
765
+ if STATE.dry_run:
695
766
  # width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
696
767
  # below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
697
768
  # probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
@@ -728,8 +799,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
728
799
  duration = _to_float(video.get("duration"))
729
800
  if duration is None and audio:
730
801
  duration = _to_float(audio.get("duration"))
731
- if duration and STATE.get("duration_hint") is None:
732
- STATE["duration_hint"] = duration
802
+ if duration and STATE.duration_hint is None:
803
+ STATE.duration_hint = duration
733
804
 
734
805
  out: Dict[str, Any] = {
735
806
  "file": path,
@@ -1087,6 +1158,9 @@ def db_to_linear(db: float) -> float:
1087
1158
  def read_text_or_die(path: str, flag: str) -> str:
1088
1159
  """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1089
1160
  with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
1161
+ if os.path.isdir(path):
1162
+ # checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
1163
+ die(f"{flag}: {path} is a directory, not a text file")
1090
1164
  try:
1091
1165
  with open(path, "r", encoding="utf-8") as fh:
1092
1166
  return fh.read()
@@ -215,10 +215,13 @@ DRY_RUN_ANALYSIS = {
215
215
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
216
216
  "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
217
217
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
218
+ "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
219
+ "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
220
+ "check": "read-only tool; the loudness measurement runs under --dry-run too, so every row is present",
221
+ "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
218
222
  }
219
223
  DRY_RUN_NOTES = {
220
224
  "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
221
- "check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
222
225
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
223
226
  }
224
227
 
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, read_text_or_die
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
@@ -103,7 +102,10 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict
103
102
  final = final_path(src, recipe, outdir)
104
103
  t0 = time.time()
105
104
  if recipe.get("project"):
106
- proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
105
+ try:
106
+ proj = json.loads(read_text_or_die(str(recipe["project"]), "recipe.project"))
107
+ except ValueError as e:
108
+ die(f"recipe.project: {recipe['project']} is not valid JSON: {e}")
107
109
  idx = int(recipe.get("clip_key", 0))
108
110
  proj.setdefault("clips", [{}])
109
111
  while len(proj["clips"]) <= idx:
@@ -191,7 +193,7 @@ def main() -> int:
191
193
  info(f"=== {src.name}")
192
194
  r = process(src, recipe, outdir, work)
193
195
  results.append(r)
194
- if r["ok"] and not STATE["dry_run"]:
196
+ if r["ok"] and not STATE.dry_run:
195
197
  cache[key] = r
196
198
  # write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
197
199
  # 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)
@@ -36,7 +36,7 @@ import sys
36
36
  from pathlib import Path
37
37
  from typing import List, Optional, Tuple
38
38
 
39
- from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS
39
+ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die
40
40
 
41
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
42
42
 
@@ -48,33 +48,32 @@ TIME_RE = re.compile(
48
48
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
49
49
  cues: List[Tuple[float, float, str]] = []
50
50
  cursor = 0.0
51
- with open(path, encoding="utf-8") as fh:
52
- for raw in fh:
53
- line = raw.rstrip("\n")
54
- if not line.strip():
55
- continue
56
- m = TIME_RE.match(line)
57
- if m:
58
- try:
59
- start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
60
- except MissingFpsError as e:
61
- die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
62
- except ValueError:
63
- # TIME_RE matched (so m.group("text") is the real cue text, not the broken
64
- # timestamp), but one of the two timestamps itself failed to parse (e.g. a
65
- # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
66
- # burn the whole raw line, broken timestamp included, into the caption instead
67
- # of just the text after it.
68
- start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
69
- else:
70
- text = m.group("text").strip()
51
+ for raw in read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True):
52
+ line = raw.rstrip("\n")
53
+ if not line.strip():
54
+ continue
55
+ m = TIME_RE.match(line)
56
+ if m:
57
+ try:
58
+ start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
59
+ except MissingFpsError as e:
60
+ die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
61
+ except ValueError:
62
+ # TIME_RE matched (so m.group("text") is the real cue text, not the broken
63
+ # timestamp), but one of the two timestamps itself failed to parse (e.g. a
64
+ # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
65
+ # burn the whole raw line, broken timestamp included, into the caption instead
66
+ # of just the text after it.
67
+ start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
71
68
  else:
72
- start, end, text = cursor, cursor + auto_seconds, line.strip()
73
- if end <= start:
74
- die(f"cue '{line}': end must be after start")
75
- text = text.replace(" | ", "\n").replace("|", "\n")
76
- cues.append((start, end, text))
77
- cursor = end + gap
69
+ text = m.group("text").strip()
70
+ else:
71
+ start, end, text = cursor, cursor + auto_seconds, line.strip()
72
+ if end <= start:
73
+ die(f"cue '{line}': end must be after start")
74
+ text = text.replace(" | ", "\n").replace("|", "\n")
75
+ cues.append((start, end, text))
76
+ cursor = end + gap
78
77
  if not cues:
79
78
  die(f"no cues found in {path}")
80
79
  return cues
@@ -173,8 +172,7 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
173
172
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
174
173
  cues: List[Tuple[float, float, str]] = []
175
174
  block: List[str] = []
176
- with open(path, encoding="utf-8-sig") as fh:
177
- content = fh.read().replace("\r\n", "\n") + "\n\n"
175
+ content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
178
176
  for line in content.split("\n"):
179
177
  if line.strip():
180
178
  block.append(line)
package/scripts/check.py CHANGED
@@ -25,7 +25,7 @@ import sys
25
25
  from fractions import Fraction
26
26
  from typing import Any, Dict, List
27
27
 
28
- from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run
28
+ from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
29
29
 
30
30
  SPECS: Dict[str, Dict[str, Any]] = {
31
31
  "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
@@ -41,8 +41,10 @@ SPECS: Dict[str, Dict[str, Any]] = {
41
41
 
42
42
 
43
43
  def measure_loudness(path: str) -> Dict[str, float]:
44
+ if dry_run_input_pending(path):
45
+ return {}
44
46
  ffmpeg = require_tool("ffmpeg")
45
- proc = run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], quiet=True, check=False)
47
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
46
48
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
47
49
  if not m:
48
50
  return {}
@@ -42,14 +42,22 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
42
42
  ffmpeg = require_tool("ffmpeg")
43
43
  per_window = max(0.5, seconds / max(1, samples))
44
44
  rects: List[Tuple[int, int, int, int]] = []
45
+ failures: List[List[str]] = []
45
46
  for i in range(samples):
46
47
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
47
48
  start = max(0.0, start)
48
49
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
49
50
  "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
50
- proc = run_analysis(cmd)
51
+ proc = run_analysis(cmd, check=False)
52
+ if proc.returncode != 0:
53
+ # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
54
+ # still measure. Only when every window fails is there nothing to report.
55
+ failures.append(proc.stderr.strip().splitlines()[-1:] or ["?"])
56
+ continue
51
57
  for m in CROP_RE.finditer(proc.stderr):
52
58
  rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
59
+ if failures and len(failures) == samples:
60
+ die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
53
61
  return rects
54
62
 
55
63
 
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":