ffmpeg-skill 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -246,7 +246,7 @@ Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory
246
246
  ```
247
247
  Done: final.mp4 — 59.98 s, 1080x1920, 30 fps, H.264, AAC stereo, -14.1 LUFS
248
248
  Steps: cut 0:12-1:12 (lossless) -> fit 9:16 crop -> captions (pop, karaoke) -> loudness -14 -> export reels
249
- Check: reels — all 12 checks pass
249
+ Check: reels — all 12 checks pass (verified: true)
250
250
  Look: final_sheet.png (captions inside the safe area, logo top-right)
251
251
  Notes: source was VFR, conformed to 30 fps; audio was mono, made stereo
252
252
  ```
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.6.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.7.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.6.0", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.7.0", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -165,7 +165,11 @@ generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL
165
165
  `--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
166
166
  "tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
167
167
  "output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
168
- implies `--dry-run`, so the same execution rules apply. `render.py FILE` executes a plan:
168
+ implies `--dry-run`, so the same execution rules apply; `inputs` covers the `-i` files of the
169
+ planned commands, every existing file named in argv (a recipe, a still, an SRT) and the
170
+ subtitle/LUT/font files a filter reads. Tools that print their document without `--json`
171
+ (`probe`, the analysis tools) still write the plan at exit; `verify.py` and `render.py`
172
+ refuse `--plan` (their steps run for real; a project file is already a plan). `render.py FILE` executes a plan:
169
173
  it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
170
174
  runs the tool with the planned `argv`, then the verify steps, and reports `plan`, `tool`,
171
175
  `tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
@@ -307,10 +311,18 @@ Success (`exit 0`): one document matching `output_schema`, always with
307
311
  `status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
308
312
  file was written. `probe` prints its measurement document directly.
309
313
 
314
+ Every writing tool also reports what it verified itself (1.7): `verification` lists the
315
+ steps (`{"step": "probe", "ok": true}`; `loudness` with the measured and target values for
316
+ `loudness.py` and the platform presets of `export.py`; `check` with the platform for
317
+ `render.py`), and `verified` is `true` only when the artifact was written, probed and every
318
+ listed step met its target. A `--dry-run` document has `verified: false` and an empty list.
319
+ `export.py` whose written file misses the platform's loudness spec stays `completed` (the
320
+ file is valid) with `verified: false` and the fix in `notes`, so a caller keys on one field.
321
+
310
322
  With `FFMPEG_SKILL_RESULT_V2=1` in the environment, every writing tool's success document
311
323
  also carries `result_v2`: a preview of the one shape 2.0 will use for every tool
312
324
  (issue #189). `{"schema": 2, "output", "probe", "commands", "metrics", "notes", "dropped":
313
- {"non_av_streams"}, "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
325
+ {"non_av_streams"}, "verified", "verification", "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
314
326
  measurement dicts flattened, plus any numeric top-level key such as `expected_duration` or
315
327
  `offset_seconds`), `notes` the free text, `details` the tool's remaining keys unchanged. The
316
328
  1.x keys are not moved; the environment variable only adds the key, and its absence is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -176,6 +176,7 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
176
176
  `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
177
177
  read a failed delivery as a success."""
178
178
  hint = extra.pop("hint", None)
179
+ STATE.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
179
180
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
180
181
  if STATE.json:
181
182
  doc: Dict[str, Any] = {
@@ -227,7 +228,7 @@ class Context:
227
228
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
228
229
  """
229
230
 
230
- __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan")
231
+ __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs")
231
232
 
232
233
  def __init__(self) -> None:
233
234
  self.reset()
@@ -244,6 +245,8 @@ class Context:
244
245
  self.written: set = set() # output paths this process has written itself
245
246
  self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
246
247
  self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
248
+ self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
249
+ self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
247
250
 
248
251
 
249
252
 
@@ -269,6 +272,11 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
269
272
  def apply_common(args: "argparse.Namespace") -> None:
270
273
  STATE.plan = getattr(args, "plan", None) or None
271
274
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
275
+ if STATE.plan:
276
+ # tools that print their document instead of calling emit() (probe, and the analysis
277
+ # tools without --json) still get their plan written, at exit, unless die() ran (review 6)
278
+ import atexit
279
+ atexit.register(_plan_at_exit)
272
280
  STATE.json = bool(getattr(args, "json", False))
273
281
  STATE.progress = bool(getattr(args, "progress", False))
274
282
  STATE.fast = bool(getattr(args, "fast", False))
@@ -351,9 +359,20 @@ def emit(output: Optional[str], **extra: Any) -> None:
351
359
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
352
360
  if meta:
353
361
  doc["probe"] = meta
362
+ # What this tool itself verified about its artifact (issue #189 C, "verify as part of the
363
+ # contract"): the probe every writing tool runs, plus the measurements a tool adds
364
+ # (`verification` extra: loudness after the write, a platform check). `verified` is true
365
+ # only when the file was written, probed, and every self-check met its target; a dry run
366
+ # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
367
+ # keep status completed and say verified: false, so a caller keys on one field.
368
+ steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
369
+ if output and not STATE.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
370
+ steps.insert(0, {"step": "exists", "ok": True})
371
+ doc["verified"] = not STATE.dry_run and bool(steps) and all(s.get("ok") for s in steps)
372
+ doc["verification"] = steps
354
373
  doc.update(extra)
355
374
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
356
- doc["result_v2"] = _result_v2(output, meta, extra)
375
+ doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
357
376
  if STATE.plan:
358
377
  doc["plan"] = write_plan(STATE.plan, output, extra)
359
378
  print_json(doc)
@@ -367,6 +386,14 @@ PLAN_VERSION = 1
367
386
  _PLAN_STRIP = ("--plan", "--dry-run", "--json")
368
387
 
369
388
 
389
+ def _plan_at_exit() -> None:
390
+ if STATE.plan and not STATE.plan_written:
391
+ try:
392
+ write_plan(STATE.plan, None, {})
393
+ except SystemExit:
394
+ pass
395
+
396
+
370
397
  def fingerprint(path: str) -> Dict[str, Any]:
371
398
  """Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
372
399
  or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
@@ -385,10 +412,15 @@ def fingerprint(path: str) -> Dict[str, Any]:
385
412
  return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
386
413
 
387
414
 
388
- def _plan_inputs(commands: Sequence[str]) -> List[str]:
389
- """Every existing file named by `-i` in the planned commands (shell-quoted lines)."""
415
+ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]:
416
+ """Every existing file the plan depends on: the `-i` inputs of the planned commands, any
417
+ existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
418
+ inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
390
419
  import shlex
391
420
  seen: List[str] = []
421
+ for a in list(argv) + list(STATE.plan_inputs):
422
+ if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
423
+ seen.append(a)
392
424
  for line in commands:
393
425
  try:
394
426
  toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
@@ -432,7 +464,7 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
432
464
  "tool": tool,
433
465
  "argv": cleaned,
434
466
  "cwd": os.getcwd(),
435
- "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands)],
467
+ "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands, cleaned)],
436
468
  "commands": list(STATE.commands),
437
469
  "output": os.path.abspath(output) if output else None,
438
470
  "verify": verify,
@@ -446,11 +478,12 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
446
478
  os.replace(tmp, path)
447
479
  except OSError as exc:
448
480
  die(f"cannot write plan {path}: {exc}", kind="output")
481
+ STATE.plan_written = True
449
482
  info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
450
483
  return path
451
484
 
452
485
 
453
- _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams")
486
+ _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams", "verified", "verification")
454
487
 
455
488
 
456
489
  def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
@@ -477,6 +510,8 @@ def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any
477
510
  "metrics": metrics,
478
511
  "notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
479
512
  "dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
513
+ "verified": bool(extra.get("verified", False)),
514
+ "verification": list(extra.get("verification") or []),
480
515
  "details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
481
516
  }
482
517
 
@@ -1439,6 +1474,8 @@ def escape_filter_path(path: str) -> str:
1439
1474
  filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
1440
1475
  (measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
1441
1476
  """
1477
+ if os.path.isfile(path) and path not in STATE.plan_inputs:
1478
+ STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
1442
1479
  p = str(Path(path))
1443
1480
  p = p.replace("\\", "/")
1444
1481
  p = p.replace(":", "\\\\:")
@@ -372,7 +372,10 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
372
372
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}}, "additionalProperties": True}
373
373
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
374
374
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
375
- "probe": {"type": "object", "description": "probe of the output when a file was written"}}
375
+ "probe": {"type": "object", "description": "probe of the output when a file was written"},
376
+ "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
377
+ "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
378
+ "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets) or check (render)"}}
376
379
  extra: Dict[str, Any] = {}
377
380
  if name == "check":
378
381
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
package/scripts/export.py CHANGED
@@ -139,6 +139,7 @@ def main() -> int:
139
139
  if m:
140
140
  ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
141
141
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
142
+ extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
142
143
  if not ok:
143
144
  notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
144
145
  f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file (or before export)")
@@ -154,7 +154,10 @@ def main() -> int:
154
154
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
155
155
  kind="verification", output=output, result=result,
156
156
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
157
- emit(output, result=result, dropped_non_av_streams=dropped_streams)
157
+ emit(output, result=result, dropped_non_av_streams=dropped_streams,
158
+ verification=[{"step": "loudness",
159
+ "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + 0.1),
160
+ "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp}])
158
161
  return 0
159
162
 
160
163
 
package/scripts/render.py CHANGED
@@ -124,9 +124,13 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
124
124
  if changed:
125
125
  die("plan inputs differ from what was planned; re-run the tool with --plan to make a new plan:\n " + "\n ".join(changed),
126
126
  hint="plans are bound to the exact input files they were made from")
127
- if plan.get("cwd") and os.path.isdir(plan["cwd"]):
127
+ if plan.get("cwd"):
128
+ if not os.path.isdir(plan["cwd"]):
129
+ die(f"plan cwd {plan['cwd']} no longer exists; relative paths in the plan would resolve elsewhere -- re-plan")
128
130
  os.chdir(plan["cwd"])
129
- argv = [str(a) for a in plan.get("argv") or []]
131
+ if not isinstance(plan.get("argv"), list):
132
+ die(f"{path}: argv must be a list")
133
+ argv = [str(a) for a in plan["argv"]]
130
134
  if STATE.dry_run:
131
135
  for c in plan.get("commands") or []:
132
136
  STATE.commands.append(c)
@@ -160,11 +164,17 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
160
164
  check_result = {"error": cp.stderr.strip()[-300:]}
161
165
  if check_result.get("failed") or check_result.get("status") == "failed" or check_result.get("error"):
162
166
  exit_code = 1
167
+ # a failed platform check is reported the way the direct path reports it: the tool completed,
168
+ # verified is false, the rows say what to fix (review 6: a plan must not fail harder than the
169
+ # same command run by hand)
163
170
  if exit_code:
164
- die(f"plan executed but {output} does not meet the {[s.get('platform') for s in plan.get('verify') or [] if s.get('tool') == 'check'][0]} spec",
165
- kind="verification", output=output, plan=path, tool=tool, stages=[tool, "check"], check=check_result)
166
- info(f"plan done: {output}")
167
- emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc)
171
+ failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
172
+ info(f"plan done: {output}, but the {check_result.get('platform')} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
173
+ else:
174
+ info(f"plan done: {output}")
175
+ emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
176
+ verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else [])
177
+ + [s for s in (doc.get("verification") or []) if s.get("step") != "probe"])
168
178
  return 0
169
179
 
170
180
 
@@ -186,12 +196,16 @@ def main() -> int:
186
196
  return 0
187
197
  if not args.project:
188
198
  die("give a project.json (or --init FILE)")
199
+ if STATE.plan:
200
+ die("render.py has no --plan: the project file is the plan (use --dry-run to preview it)")
189
201
  try:
190
202
  proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
191
203
  except (OSError, ValueError) as exc:
192
204
  die(f"cannot read project: {exc}")
193
- if isinstance(proj, dict) and "plan_version" in proj:
194
- return execute_plan(proj, args.project)
205
+ if not isinstance(proj, dict):
206
+ die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
207
+ if "plan_version" in proj:
208
+ return execute_plan(proj, os.path.abspath(args.project))
195
209
  base = Path(args.project).resolve().parent
196
210
 
197
211
  def rel(p: Any) -> str:
@@ -473,7 +487,8 @@ def main() -> int:
473
487
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
474
488
  probe=probe(output, role="output"))
475
489
  info(f"rendered {output} via {' → '.join(stages_done)}")
476
- emit(output, stages=stages_done, check=check_result)
490
+ emit(output, stages=stages_done, check=check_result,
491
+ verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
477
492
  return 0
478
493
 
479
494
 
package/scripts/report.py CHANGED
@@ -163,7 +163,7 @@ def main() -> int:
163
163
  except OSError as e:
164
164
  die(f"cannot write {output}: {e}", kind="output")
165
165
  info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
166
- emit(None, report=output, check=chk)
166
+ emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] if not STATE.dry_run else []))
167
167
  if not args.json:
168
168
  print(output)
169
169
  return 0
package/scripts/verify.py CHANGED
@@ -101,6 +101,8 @@ def main() -> int:
101
101
  add_common(ap)
102
102
  args = ap.parse_args()
103
103
  apply_common(args)
104
+ if STATE.plan:
105
+ die("verify.py does not support --plan: its steps run for real (it ignores --dry-run too)")
104
106
 
105
107
  files = collect(args.paths)
106
108
  tmp = None