ffmpeg-skill 1.4.2 → 1.4.4

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
@@ -293,7 +293,7 @@ The contract is generated from the code that runs, not maintained beside it. For
293
293
  | `mutates_input` | always `false` |
294
294
  | `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
295
295
 
296
- `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | missing_tool", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
296
+ `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
297
297
 
298
298
  ### MCP
299
299
 
package/SKILL.md CHANGED
@@ -265,7 +265,7 @@ Steps: probe -> color (failed); nothing written
265
265
  Notes: send a valid .cube, or say if you want the clip left as is
266
266
  ```
267
267
 
268
- Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
268
+ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
269
269
 
270
270
  ## Things that look right but are wrong
271
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
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",
@@ -190,3 +190,40 @@ that merge -- no tests, no CodeQL, no release -- and the release only happened w
190
190
  merged. Describe the marker in words in PR bodies and commit messages ("the skip-CI marker"),
191
191
  or wrap it so it does not match, and after any merge that touches CI check that the push
192
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.
@@ -63,6 +63,7 @@ ERROR_CODE = {
63
63
  "ffmpeg": "FFMPEG_EXECUTION_FAILED",
64
64
  "output": "OUTPUT_INVALID",
65
65
  "timeout": "TIMEOUT",
66
+ "verification": "VERIFICATION_FAILED",
66
67
  }
67
68
 
68
69
  # Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
@@ -149,12 +150,18 @@ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
149
150
  parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
150
151
 
151
152
 
152
- def die(msg: str, code: int = 1, kind: str = "input") -> "None":
153
+ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
153
154
  """Exit with a message. Under --json also print a machine-readable failure document
154
- (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
155
+ (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
156
+
157
+ `extra` fields are added to the failure document: a tool whose *result* failed (check.py's
158
+ platform rows, render.py's check stage, batch.py's per-item results, verify.py's steps) keeps
159
+ reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
160
+ `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
161
+ read a failed delivery as a success."""
155
162
  sys.stderr.write(f"error: {msg}\n")
156
163
  if STATE.json:
157
- print_json({
164
+ doc: Dict[str, Any] = {
158
165
  "status": "failed", "exit_code": code,
159
166
  "error": {
160
167
  "kind": kind, "message": msg,
@@ -162,7 +169,9 @@ def die(msg: str, code: int = 1, kind: str = "input") -> "None":
162
169
  "retryable": ERROR_RETRYABLE,
163
170
  },
164
171
  "commands": list(STATE.commands),
165
- })
172
+ }
173
+ doc.update(extra)
174
+ print_json(doc)
166
175
  sys.exit(code)
167
176
 
168
177
 
@@ -199,8 +208,8 @@ class Context:
199
208
  it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
200
209
  """
201
210
 
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")
211
+ __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
212
+ _KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
204
213
 
205
214
  def __init__(self) -> None:
206
215
  self.reset()
@@ -215,6 +224,7 @@ class Context:
215
224
  self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
216
225
  self.overwrite = False # --overwrite: an existing output may be replaced
217
226
  self.written: set = set() # output paths this process has written itself
227
+ self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
218
228
 
219
229
  # mapping-style access kept for backwards compatibility
220
230
  def __getitem__(self, key: str) -> Any:
@@ -300,8 +310,19 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
300
310
  if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
301
311
  return
302
312
  try:
303
- if os.path.exists(output):
304
- os.remove(output)
313
+ if not os.path.exists(output):
314
+ return
315
+ # A file that was already there before this command ran is someone's deliverable, not
316
+ # our partial. If ffmpeg died before opening it (bad filter argument, unreadable input:
317
+ # the common case) it is byte-for-byte what it was, so leave it alone. Only when ffmpeg
318
+ # did open and truncate it (size or mtime changed) is what remains a partial of ours,
319
+ # and the original is already gone either way; then removing it is still right.
320
+ before = STATE.preexisting.get(os.path.realpath(output))
321
+ if before is not None:
322
+ st = os.stat(output)
323
+ if (st.st_size, st.st_mtime_ns) == before:
324
+ return
325
+ os.remove(output)
305
326
  except OSError:
306
327
  pass
307
328
 
@@ -348,7 +369,7 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
348
369
  2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
349
370
  process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
350
371
  output = cmd[-1]
351
- if STATE.overwrite or output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
372
+ if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
352
373
  return
353
374
  try:
354
375
  exists = os.path.isfile(output)
@@ -357,6 +378,13 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
357
378
  return
358
379
  if not exists or real in STATE.written:
359
380
  return
381
+ try:
382
+ st = os.stat(output)
383
+ STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
384
+ except OSError:
385
+ pass
386
+ if STATE.overwrite:
387
+ return
360
388
  if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
361
389
  die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
362
390
  info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
@@ -380,12 +408,40 @@ def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
380
408
  code=124, kind="timeout")
381
409
 
382
410
 
411
+ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
412
+ """When the output path already holds someone's file, run ffmpeg against a hidden sibling
413
+ temp path and move it over the original only on success.
414
+
415
+ ffmpeg's -y truncates the output the moment it opens it, and *when* it opens it depends on
416
+ the version: 6.1+ initialises the filter graph first (a bad LUT fails before the file is
417
+ touched), 5.x opens the output during option parsing, before any filter runs, so the same
418
+ bad LUT leaves a 0-byte file where the deliverable was. No amount of post-failure cleanup
419
+ can undo that; the only way to keep an existing file safe across a failed run is for ffmpeg
420
+ never to write to it. Same directory, same extension (the muxer is chosen by it), hidden
421
+ name, so nothing else changes for the encoder. Returns (command to execute, final path,
422
+ temp path); (cmd, None, None) when no staging is needed."""
423
+ output = cmd[-1]
424
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
425
+ return list(cmd), None, None
426
+ try:
427
+ if not os.path.isfile(output) or os.path.realpath(output) in STATE.written:
428
+ return list(cmd), None, None
429
+ except OSError:
430
+ return list(cmd), None, None
431
+ d, base = os.path.split(output)
432
+ stem, ext = os.path.splitext(base)
433
+ tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
434
+ return list(cmd[:-1]) + [tmp], output, tmp
435
+
436
+
383
437
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
384
438
  """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
385
439
 
386
440
  ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
387
441
  (a fake successful CompletedProcess is returned so scripts can keep planning), and run
388
- with a progress readout under --progress. ffprobe and other tools always run.
442
+ with a progress readout under --progress. ffprobe and other tools always run. An output
443
+ path that already exists is written through a temp file and replaced only on success
444
+ (see _stage_existing_output), so a failed run never costs the caller the file that was there.
389
445
  """
390
446
  is_ffmpeg = _is_ffmpeg(cmd)
391
447
  if is_ffmpeg:
@@ -396,9 +452,55 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
396
452
  info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
397
453
  if STATE.dry_run and is_ffmpeg:
398
454
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
399
- if STATE.progress and is_ffmpeg and cmd[-1] != "-":
400
- return _run_with_progress(list(cmd), check)
401
- return _run_captured(list(cmd), check)
455
+ exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
456
+ if STATE.progress and is_ffmpeg and exec_cmd[-1] != "-":
457
+ proc = _run_with_progress(exec_cmd, check)
458
+ else:
459
+ proc = _run_captured(exec_cmd, check)
460
+ if final and tmp:
461
+ if proc.returncode == 0:
462
+ try:
463
+ os.replace(tmp, final)
464
+ except OSError as e:
465
+ _cleanup_partial_output(exec_cmd)
466
+ die(f"could not replace {final} with the new output: {e}", kind="output")
467
+ _remember_output(cmd)
468
+ else:
469
+ _cleanup_partial_output(exec_cmd)
470
+ return proc
471
+
472
+
473
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
474
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
475
+ output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
476
+ --dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
477
+ limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
478
+ instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
479
+ limit = _limit_for(cmd)
480
+ try:
481
+ proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
482
+ except subprocess.TimeoutExpired:
483
+ _timed_out(cmd, limit or 0)
484
+ if check and proc.returncode != 0:
485
+ err = proc.stderr if text else proc.stderr.decode(errors="replace")
486
+ _fail(cmd, proc.returncode, err)
487
+ return proc
488
+
489
+
490
+ def child_args() -> List[str]:
491
+ """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
492
+ so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
493
+ stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
494
+ stopped at render.py."""
495
+ args: List[str] = []
496
+ if STATE.fast:
497
+ args.append("--fast")
498
+ if STATE.dry_run:
499
+ args.append("--dry-run")
500
+ if STATE.overwrite:
501
+ args.append("--overwrite")
502
+ args += ["--timeout", f"{STATE.timeout:g}"]
503
+ return args
402
504
 
403
505
 
404
506
  def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
@@ -455,22 +557,57 @@ def _progress_line(done: float, total: float, elapsed: float) -> str:
455
557
 
456
558
 
457
559
  def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
458
- """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
560
+ """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
561
+
562
+ The time limit is checked on a clock, not per progress line: a deadlocked ffmpeg (the very
563
+ case --timeout exists for) prints nothing, so a loop that only looked at the deadline when a
564
+ line arrived waited on it forever. Reader threads drain both pipes; the main loop wakes at
565
+ least twice a second to compare the clock against the limit."""
566
+ import queue
567
+ import threading
459
568
  import time
460
569
  total = STATE.duration_hint or 0.0
461
570
  full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
462
571
  t0 = time.time()
463
572
  limit = _limit_for(cmd)
464
573
  proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
574
+ assert proc.stdout is not None and proc.stderr is not None
575
+ lines: "queue.Queue[Optional[str]]" = queue.Queue()
576
+ err_chunks: List[str] = []
577
+
578
+ def pump_out() -> None:
579
+ for line in proc.stdout: # type: ignore[union-attr]
580
+ lines.put(line)
581
+ lines.put(None)
582
+
583
+ def pump_err() -> None:
584
+ err_chunks.append(proc.stderr.read()) # type: ignore[union-attr]
585
+
586
+ threading.Thread(target=pump_out, daemon=True).start()
587
+ err_thread = threading.Thread(target=pump_err, daemon=True)
588
+ err_thread.start()
465
589
  last = ""
466
- assert proc.stdout is not None
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)
590
+
591
+ def clear_line() -> None:
592
+ if last:
593
+ sys.stderr.write("\r" + " " * len(last) + "\r")
594
+
595
+ def timed_out() -> None:
596
+ proc.kill()
597
+ proc.wait()
598
+ clear_line()
599
+ _timed_out(cmd, limit or 0)
600
+
601
+ while True:
602
+ remaining = (limit - (time.time() - t0)) if limit else None
603
+ if remaining is not None and remaining <= 0:
604
+ timed_out()
605
+ try:
606
+ line = lines.get(timeout=min(0.5, remaining) if remaining is not None else 0.5)
607
+ except queue.Empty:
608
+ continue
609
+ if line is None:
610
+ break
474
611
  if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
475
612
  try:
476
613
  done = int(line.split("=")[1]) / 1_000_000
@@ -482,13 +619,12 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
482
619
  sys.stderr.flush()
483
620
  last = msg
484
621
  try:
485
- _, err = proc.communicate(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
622
+ proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
486
623
  except subprocess.TimeoutExpired:
487
- proc.kill()
488
- proc.communicate()
489
- _timed_out(cmd, limit or 0)
490
- if last:
491
- sys.stderr.write("\r" + " " * len(last) + "\r")
624
+ timed_out()
625
+ err_thread.join()
626
+ err = "".join(err_chunks)
627
+ clear_line()
492
628
  if proc.returncode == 0:
493
629
  _remember_output(cmd)
494
630
  if proc.returncode != 0:
@@ -951,7 +1087,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
951
1087
  ffmpeg = require_tool("ffmpeg")
952
1088
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
953
1089
  "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
954
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
1090
+ proc = run_analysis(cmd, check=False)
955
1091
  vals: Dict[str, List[float]] = {}
956
1092
  for line in proc.stdout.splitlines():
957
1093
  if "lavfi.signalstats." in line and "=" in line:
@@ -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", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124"},
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", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1"},
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/batch.py CHANGED
@@ -32,7 +32,7 @@ import time
32
32
  from pathlib import Path
33
33
  from typing import Any, Dict, List
34
34
 
35
- from _common import STATE, add_common, apply_common, die, emit, info
35
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info
36
36
 
37
37
  HERE = Path(__file__).resolve().parent
38
38
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
@@ -78,11 +78,7 @@ def run_step(argv: List[str]) -> bool:
78
78
  if script not in ALLOWED_STEP_SCRIPTS:
79
79
  die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
80
80
  f"(must be a bare filename like 'silence.py', found in scripts/)")
81
- cmd = [sys.executable, str(HERE / script)] + argv[1:]
82
- if STATE["fast"]:
83
- cmd.append("--fast")
84
- if STATE["dry_run"]:
85
- cmd.append("--dry-run")
81
+ cmd = [sys.executable, str(HERE / script)] + argv[1:] + child_args()
86
82
  info(" → " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
87
83
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
88
84
  if proc.returncode != 0:
@@ -219,11 +215,15 @@ def main() -> int:
219
215
  pass
220
216
  done = sum(1 for r in results if r["ok"])
221
217
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
222
- emit(None, results=results, processed=done, total=len(results))
223
218
  if not args.json:
224
219
  for r in results:
225
220
  print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
226
- return 0 if done == len(results) else 1
221
+ if done != len(results):
222
+ failed_files = [r["file"] for r in results if not r["ok"]]
223
+ die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
224
+ kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
225
+ emit(None, results=results, processed=done, total=len(results))
226
+ return 0
227
227
 
228
228
 
229
229
  if __name__ == "__main__":
@@ -87,12 +87,36 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
87
87
  import shutil
88
88
  import subprocess
89
89
  import tempfile
90
- from _common import require_tool
90
+ from _common import require_tool, run_analysis, STATE
91
91
  ffmpeg = require_tool("ffmpeg")
92
92
  tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
93
+ try:
94
+ return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
95
+ finally:
96
+ shutil.rmtree(tmpdir, ignore_errors=True)
97
+
98
+
99
+ def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
100
+ """Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
101
+ from _common import STATE, die
102
+ limit = STATE.timeout or None
103
+ try:
104
+ return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
105
+ except subprocess.TimeoutExpired:
106
+ die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
107
+ code=124, kind="timeout")
108
+ return None # unreachable
109
+
110
+
111
+ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
112
+ ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
113
+ from _common import run_analysis
93
114
  wav = os.path.join(tmpdir, "audio.wav")
94
- subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
95
- "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
115
+ # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
116
+ # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
117
+ # reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
118
+ run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
119
+ "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
96
120
  # 1. whisper.cpp
97
121
  cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
98
122
  if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
@@ -106,7 +130,7 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
106
130
  cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
107
131
  if language:
108
132
  cmd += ["-l", language]
109
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
133
+ proc = _asr_run(cmd, subprocess, "whisper.cpp")
110
134
  if proc.returncode == 0 and os.path.exists(base + ".srt"):
111
135
  info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
112
136
  cues = parse_srt(base + ".srt")
@@ -130,7 +154,7 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
130
154
  cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
131
155
  if language:
132
156
  cmd += ["--language", language]
133
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
157
+ proc = _asr_run(cmd, subprocess, "openai-whisper")
134
158
  srt = os.path.join(tmpdir, "audio.srt")
135
159
  if proc.returncode == 0 and os.path.exists(srt):
136
160
  info("transcribed with openai-whisper")
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 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
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},
@@ -190,8 +190,12 @@ def main() -> int:
190
190
  line += f" -> {r['fix']}"
191
191
  print(line)
192
192
  print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
193
- emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=not failed)
194
- return 1 if failed else 0
193
+ if failed:
194
+ die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
195
+ kind="verification", output=None, dry_run=STATE.dry_run,
196
+ platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False)
197
+ emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True)
198
+ return 0
195
199
 
196
200
 
197
201
  if __name__ == "__main__":
@@ -29,12 +29,11 @@ Examples:
29
29
  """
30
30
  import argparse
31
31
  import re
32
- import subprocess
33
32
  import sys
34
33
  from collections import Counter
35
34
  from typing import Dict, List, Tuple
36
35
 
37
- from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool
36
+ from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis
38
37
 
39
38
  CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
40
39
 
@@ -48,7 +47,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
48
47
  start = max(0.0, start)
49
48
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
50
49
  "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
51
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
50
+ proc = run_analysis(cmd)
52
51
  for m in CROP_RE.finditer(proc.stderr):
53
52
  rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
54
53
  return rects
package/scripts/render.py CHANGED
@@ -56,7 +56,7 @@ import sys
56
56
  from pathlib import Path
57
57
  from typing import Any, Dict, List
58
58
 
59
- from _common import STATE, add_common, apply_common, die, emit, info, probe
59
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe
60
60
 
61
61
  HERE = Path(__file__).resolve().parent
62
62
 
@@ -80,11 +80,7 @@ TEMPLATE = {
80
80
 
81
81
  def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
82
82
  """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 [])
84
- if STATE["fast"]:
85
- cmd.append("--fast")
86
- if STATE["dry_run"]:
87
- cmd.append("--dry-run")
83
+ cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args()
88
84
  info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
89
85
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
90
86
  for line in proc.stderr.splitlines():
@@ -360,12 +356,12 @@ def main() -> int:
360
356
  check_result = json.loads(proc.stdout)
361
357
  except ValueError:
362
358
  check_result = {"error": proc.stderr.strip()[-300:]}
363
- if check_result.get("error"):
364
- info(f"check: could not run check.py — {check_result['error']}")
365
- exit_code = 1
366
- elif check_result.get("failed"):
359
+ if check_result.get("failed"):
367
360
  info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
368
361
  exit_code = 1
362
+ elif check_result.get("error") or check_result.get("status") == "failed":
363
+ info(f"check: could not run check.py — {check_result.get('error')}")
364
+ exit_code = 1
369
365
  else:
370
366
  info(f"check: OK for {ck['platform']}")
371
367
  stages_done.append("check")
@@ -378,9 +374,15 @@ def main() -> int:
378
374
  # to before the PID suffix was added.
379
375
  import shutil
380
376
  shutil.rmtree(work, ignore_errors=True)
377
+ if exit_code:
378
+ # The deliverable is written and verified, but it does not meet the requested platform
379
+ # spec (or the check itself could not run): a failed delivery, reported as one.
380
+ failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
381
+ die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
382
+ kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
381
383
  info(f"rendered {output} via {' → '.join(stages_done)}")
382
384
  emit(output, stages=stages_done, check=check_result)
383
- return exit_code
385
+ return 0
384
386
 
385
387
 
386
388
  if __name__ == "__main__":
package/scripts/scenes.py CHANGED
@@ -22,11 +22,10 @@ import math
22
22
  import os
23
23
  import re
24
24
  import struct
25
- import subprocess
26
25
  import sys
27
26
  from typing import Dict, List, Tuple
28
27
 
29
- from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
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
30
29
 
31
30
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
32
31
 
@@ -38,9 +37,8 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
38
37
  a real cut is a one-frame spike. On real footage this roughly doubles precision at
39
38
  equal recall compared with the raw scdet threshold."""
40
39
  ffmpeg = require_tool("ffmpeg")
41
- proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
42
- "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"],
43
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
40
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
41
+ "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
44
42
  # No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
45
43
  # score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
46
44
  # card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
@@ -90,8 +88,8 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
90
88
 
91
89
  def audio_envelope(path: str, step_s: float) -> List[float]:
92
90
  ffmpeg = require_tool("ffmpeg")
93
- proc = subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
94
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
91
+ proc = run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
92
+ check=False, text=False)
95
93
  n = len(proc.stdout) // 2
96
94
  if n == 0:
97
95
  return []
package/scripts/sync.py CHANGED
@@ -30,11 +30,10 @@ import json
30
30
  import math
31
31
  import os
32
32
  import struct
33
- import subprocess
34
33
  import sys
35
34
  from typing import List
36
35
 
37
- 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, x264_args
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
38
37
 
39
38
  SR = 8000 # decode sample rate
40
39
 
@@ -59,7 +58,7 @@ def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
59
58
  ffmpeg = require_tool("ffmpeg")
60
59
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
61
60
  "-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
62
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
61
+ proc = run_analysis(cmd, check=False, text=False)
63
62
  if proc.returncode != 0 or not proc.stdout:
64
63
  die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
65
64
  n = len(proc.stdout) // 2
package/scripts/verify.py CHANGED
@@ -24,7 +24,7 @@ import time
24
24
  from pathlib import Path
25
25
  from typing import Dict, List
26
26
 
27
- from _common import add_common, apply_common, die, emit, info, probe
27
+ from _common import STATE, add_common, apply_common, die, emit, info, probe
28
28
 
29
29
  HERE = Path(__file__).resolve().parent
30
30
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
@@ -176,13 +176,16 @@ def main() -> int:
176
176
  if args.report:
177
177
  Path(args.report).write_text(report, encoding="utf-8")
178
178
  info(f"wrote {args.report}")
179
- if args.json:
180
- emit(None, report=args.report, files=results, failed=failed, total=total)
181
- else:
179
+ if not args.json:
182
180
  print(report)
183
181
  if tmp and not args.keep:
184
182
  tmp.cleanup()
185
- return 1 if failed else 0
183
+ if failed:
184
+ die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
185
+ report=args.report, files=results, failed=failed, total=total)
186
+ if args.json:
187
+ emit(None, report=args.report, files=results, failed=failed, total=total)
188
+ return 0
186
189
 
187
190
 
188
191
  if __name__ == "__main__":