ffmpeg-skill 1.4.0 → 1.4.1

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
@@ -13,9 +13,13 @@
13
13
 
14
14
  <p align="center">
15
15
  <a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
16
+ <a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/codeql.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
16
17
  <a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
17
- <img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
18
- <img src="https://img.shields.io/badge/ffmpeg-5.0%2B-orange" alt="FFmpeg 5.0+">
18
+ <a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/dm/ffmpeg-skill" alt="npm downloads"></a>
19
+ <a href="https://github.com/kajisho5/ffmpeg-skill/stargazers"><img src="https://img.shields.io/github/stars/kajisho5/ffmpeg-skill" alt="GitHub stars"></a>
20
+ <a href="https://github.com/kajisho5/ffmpeg-skill/commits/main"><img src="https://img.shields.io/github/last-commit/kajisho5/ffmpeg-skill" alt="last commit"></a>
21
+ <img src="https://img.shields.io/badge/python-3.9%20%7C%203.13-blue" alt="Python 3.9 and 3.13 tested">
22
+ <a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-5.1%20%7C%206.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 5.1, 6.1, 7.1, 8 and 9 tested in CI"></a>
19
23
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
20
24
  <a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
21
25
  </p>
@@ -139,7 +143,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
139
143
 
140
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.
141
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.
142
- 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) and `--progress` (percent and ETA). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
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.
143
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`.
144
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.
145
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.
@@ -336,6 +340,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
336
340
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
337
341
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
338
342
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
343
+ | **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
339
344
  | **6 / 6** | 0.9.1 audio evals (audio join, extraction, track selection, sample-accurate trim, typed dynamics; 2 in Japanese): routing, report format and audio-as-audio handling 6/6 |
340
345
 
341
346
  ```bash
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress`. Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
9
9
 
10
10
  ## Workflow (always follow this order)
11
11
 
@@ -41,7 +41,9 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
41
41
  be a placeholder, not a computed preview — see `docs/contract.md`). Use
42
42
  them to confirm a plan before long encodes and to report exact facts.
43
43
  `--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
44
- prints percent and ETA on stderr for long encodes.
44
+ prints percent and ETA on stderr for long encodes. Never point `-o` at a file
45
+ you did not create in this job unless the user asked for it to be replaced;
46
+ pass `--overwrite` only then.
45
47
  4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
46
48
  join → silence → fit → caption/overlay → sync → audio → loudness → export.
47
49
  Do frame changes (fit/crop) before captions and overlays so text is sized
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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",
@@ -158,3 +158,25 @@ inputs (or `Unexpected input(s)` in the first log) before trusting a parameter,
158
158
  any step whose output decides an irreversible action as something to unit-test with fixed
159
159
  inputs, not something to confirm by reading its YAML. And watch the first real run's
160
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.
@@ -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`, `-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) 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`).
4
4
 
5
5
  ## Contents
6
6
  - probe.py — inspect
@@ -62,8 +62,25 @@ ERROR_CODE = {
62
62
  "missing_tool": "DEPENDENCY_MISSING",
63
63
  "ffmpeg": "FFMPEG_EXECUTION_FAILED",
64
64
  "output": "OUTPUT_INVALID",
65
+ "timeout": "TIMEOUT",
65
66
  }
66
67
 
68
+ # Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
69
+ # that deadlocks on a filter combination, a stalled network mount, an input that never ends)
70
+ # used to hang the calling agent with it, with no error document and no way out short of
71
+ # killing the process by hand. The ceiling is generous on purpose: it exists to turn a hang
72
+ # into a reported failure, not to police slow encodes. --timeout and FFMPEG_SKILL_TIMEOUT
73
+ # override it; 0 disables it.
74
+ DEFAULT_TIMEOUT = 1800.0
75
+ PROBE_TIMEOUT = 120.0
76
+
77
+
78
+ def _env_timeout() -> float:
79
+ try:
80
+ return max(0.0, float(os.environ.get("FFMPEG_SKILL_TIMEOUT", DEFAULT_TIMEOUT)))
81
+ except ValueError:
82
+ return DEFAULT_TIMEOUT
83
+
67
84
  # None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
68
85
  # always deterministic (the same bad path or absent binary fails identically every time), and a
69
86
  # "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
@@ -182,8 +199,8 @@ class Context:
182
199
  it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
183
200
  """
184
201
 
185
- __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
186
- _KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
202
+ __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
203
+ _KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
187
204
 
188
205
  def __init__(self) -> None:
189
206
  self.reset()
@@ -195,6 +212,9 @@ class Context:
195
212
  self.fast = False # x264 preset forced to veryfast
196
213
  self.duration_hint: Optional[float] = None # expected output length, for the progress percent
197
214
  self.commands: List[str] = [] # every ffmpeg command line, for --json and --dry-run
215
+ self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
216
+ self.overwrite = False # --overwrite: an existing output may be replaced
217
+ self.written: set = set() # output paths this process has written itself
198
218
 
199
219
  # mapping-style access kept for backwards compatibility
200
220
  def __getitem__(self, key: str) -> Any:
@@ -221,6 +241,11 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
221
241
  g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
222
242
  g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
223
243
  g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
244
+ if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
245
+ g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
246
+ help=f"kill any single ffmpeg run that exceeds this many seconds and report kind=timeout (default {DEFAULT_TIMEOUT:.0f}, or FFMPEG_SKILL_TIMEOUT; 0 = no limit)")
247
+ g.add_argument("--overwrite", action="store_true",
248
+ help="allow replacing an output file that already exists (without it a warning is printed today; from 2.0 an existing output is refused, and FFMPEG_SKILL_NO_OVERWRITE=1 opts into that now)")
224
249
 
225
250
 
226
251
  def apply_common(args: "argparse.Namespace") -> None:
@@ -228,6 +253,9 @@ def apply_common(args: "argparse.Namespace") -> None:
228
253
  STATE.json = bool(getattr(args, "json", False))
229
254
  STATE.progress = bool(getattr(args, "progress", False))
230
255
  STATE.fast = bool(getattr(args, "fast", False))
256
+ STATE.overwrite = bool(getattr(args, "overwrite", False))
257
+ if getattr(args, "timeout", None) is not None:
258
+ STATE.timeout = max(0.0, float(args.timeout))
231
259
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
232
260
  args.preset = "veryfast"
233
261
 
@@ -312,6 +340,46 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
312
340
  continue
313
341
 
314
342
 
343
+ def _check_existing_output(cmd: Sequence[str]) -> None:
344
+ """An output path that already exists is someone's file: a previous result, a source the
345
+ agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
346
+ a run never blocks on a y/N prompt) would replace it without a word. Until 2.0 this only
347
+ warns, per docs/contract.md's deprecation policy; FFMPEG_SKILL_NO_OVERWRITE=1 opts into the
348
+ 2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
349
+ process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
350
+ output = cmd[-1]
351
+ if STATE.overwrite or output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
352
+ return
353
+ try:
354
+ exists = os.path.isfile(output)
355
+ real = os.path.realpath(output)
356
+ except OSError:
357
+ return
358
+ if not exists or real in STATE.written:
359
+ return
360
+ if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
361
+ die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
362
+ info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
363
+ f"from 2.0 an existing output is refused without it, FFMPEG_SKILL_NO_OVERWRITE=1 enables that now)")
364
+
365
+
366
+ def _remember_output(cmd: Sequence[str]) -> None:
367
+ output = cmd[-1]
368
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
369
+ return
370
+ try:
371
+ STATE.written.add(os.path.realpath(output))
372
+ except OSError:
373
+ pass
374
+
375
+
376
+ def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
377
+ _cleanup_partial_output(cmd)
378
+ die(f"{os.path.basename(cmd[0])} exceeded the {seconds:.0f} s time limit and was killed; nothing was written. "
379
+ f"Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall",
380
+ code=124, kind="timeout")
381
+
382
+
315
383
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
316
384
  """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
317
385
 
@@ -322,6 +390,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
322
390
  is_ffmpeg = _is_ffmpeg(cmd)
323
391
  if is_ffmpeg:
324
392
  _check_no_overwrite_input(cmd)
393
+ _check_existing_output(cmd)
325
394
  STATE.commands.append(_cmdline(cmd))
326
395
  if not quiet:
327
396
  info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
@@ -346,9 +415,23 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
346
415
  return True
347
416
 
348
417
 
418
+ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
419
+ """The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a fixed
420
+ short one, ffmpeg the configured one; None means unlimited."""
421
+ if not _is_ffmpeg(cmd):
422
+ return PROBE_TIMEOUT if STATE.timeout else None
423
+ return STATE.timeout or None
424
+
425
+
349
426
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
350
427
  """Plain run with stdout/stderr captured."""
351
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
428
+ limit = _limit_for(cmd)
429
+ try:
430
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
431
+ except subprocess.TimeoutExpired:
432
+ _timed_out(cmd, limit or 0)
433
+ if proc.returncode == 0 and _is_ffmpeg(cmd):
434
+ _remember_output(cmd)
352
435
  if proc.returncode != 0:
353
436
  # Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
354
437
  # path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
@@ -377,10 +460,17 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
377
460
  total = STATE.duration_hint or 0.0
378
461
  full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
379
462
  t0 = time.time()
463
+ limit = _limit_for(cmd)
380
464
  proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
381
465
  last = ""
382
466
  assert proc.stdout is not None
383
467
  for line in proc.stdout:
468
+ if limit and time.time() - t0 > limit:
469
+ proc.kill()
470
+ proc.communicate()
471
+ if last:
472
+ sys.stderr.write("\r" + " " * len(last) + "\r")
473
+ _timed_out(cmd, limit)
384
474
  if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
385
475
  try:
386
476
  done = int(line.split("=")[1]) / 1_000_000
@@ -391,9 +481,16 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
391
481
  sys.stderr.write(msg)
392
482
  sys.stderr.flush()
393
483
  last = msg
394
- _, err = proc.communicate()
484
+ try:
485
+ _, err = proc.communicate(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
486
+ except subprocess.TimeoutExpired:
487
+ proc.kill()
488
+ proc.communicate()
489
+ _timed_out(cmd, limit or 0)
395
490
  if last:
396
491
  sys.stderr.write("\r" + " " * len(last) + "\r")
492
+ if proc.returncode == 0:
493
+ _remember_output(cmd)
397
494
  if proc.returncode != 0:
398
495
  _cleanup_partial_output(cmd)
399
496
  if check:
@@ -329,7 +329,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
329
329
  props: Dict[str, Any] = {}
330
330
  required: List[str] = []
331
331
  positional: List[str] = []
332
- common = {"dry_run", "json", "progress", "fast"}
332
+ common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite"}
333
333
  for action in parser._actions:
334
334
  if isinstance(action, argparse._HelpAction):
335
335
  continue
@@ -1037,7 +1037,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1037
1037
  "json_output": {
1038
1038
  "success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
1039
1039
  "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
1040
- "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH"},
1040
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124"},
1041
1041
  "success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
1042
1042
  },
1043
1043
  "capabilities": caps,
package/scripts/audio.py CHANGED
@@ -196,18 +196,29 @@ def main() -> int:
196
196
  post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
197
197
  if args.fade_out and dur:
198
198
  post.append(f"afade=t=out:st={max(0.0, dur - args.fade_out):.3f}:d={args.fade_out:g}")
199
- if args.replace and dur:
199
+ # The audio track is conformed to the source duration whenever it is known: padded with
200
+ # silence if the graph came out short, trimmed if long. A mixed track can come out a few
201
+ # hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
202
+ # -shortest below that used to shorten the *video* to match: 12.00 s in, 11.925 s out, four
203
+ # frames of a stream-copied picture gone (#164). Padding the audio, not cutting the picture,
204
+ # is the only correct answer for a tool whose contract says the video is never touched.
205
+ keep_video = has_video and not audio_out
206
+ if dur and (args.replace or args.music or keep_video):
200
207
  post.append(f"apad,atrim=0:{dur:.3f}")
201
208
  if post:
202
209
  graph.append(f"[{last}]{','.join(post)}[out]")
203
210
  last = "out"
204
211
 
205
212
  cmd = ffmpeg_base() + inputs + ["-filter_complex", ";".join(graph), "-map", f"[{last}]"]
206
- if has_video and not audio_out:
213
+ if keep_video:
207
214
  cmd += ["-map", "0:v:0", "-c:v", "copy"]
208
215
  elif has_video:
209
216
  cmd += ["-vn"] # audio extension: the picture is dropped, not copied into a container that cannot hold it
210
- cmd += audio_codec_for(output, args.bitrate) + ["-shortest", output]
217
+ cmd += audio_codec_for(output, args.bitrate)
218
+ if not keep_video:
219
+ # audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
220
+ cmd.append("-shortest")
221
+ cmd.append(output)
211
222
  run(cmd)
212
223
  r = probe(output, role="output")
213
224
  a = r["audio"]