ffmpeg-skill 1.5.2 → 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/README.md CHANGED
@@ -143,7 +143,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
143
143
 
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
- 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; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) 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.
146
+ 3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--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; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) 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
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.
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), `--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`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `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) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `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
 
@@ -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.5.2`) | 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.5.2", "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"},
@@ -162,6 +162,18 @@ tool's job; only the artifact is skipped, including side files such as `--edl`,
162
162
  generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
163
163
  `references/scripts.md` repeat the same list; the contract is the authority.
164
164
 
165
+ `--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
166
+ "tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
167
+ "output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
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:
173
+ it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
174
+ runs the tool with the planned `argv`, then the verify steps, and reports `plan`, `tool`,
175
+ `tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
176
+
165
177
  ### Repeatability
166
178
 
167
179
  No tool keeps state or uses randomness. `deterministic_inputs` is `false` only for
@@ -299,10 +311,18 @@ Success (`exit 0`): one document matching `output_schema`, always with
299
311
  `status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
300
312
  file was written. `probe` prints its measurement document directly.
301
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
+
302
322
  With `FFMPEG_SKILL_RESULT_V2=1` in the environment, every writing tool's success document
303
323
  also carries `result_v2`: a preview of the one shape 2.0 will use for every tool
304
324
  (issue #189). `{"schema": 2, "output", "probe", "commands", "metrics", "notes", "dropped":
305
- {"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
306
326
  measurement dicts flattened, plus any numeric top-level key such as `expected_duration` or
307
327
  `offset_seconds`), `notes` the free text, `details` the tool's remaining keys unchanged. The
308
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.5.2",
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",
@@ -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) 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`).
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`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-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
@@ -375,7 +375,17 @@ output; the result says so with `dropped_non_av_streams: true`.
375
375
  ```
376
376
  render.py --init project.json # starter file
377
377
  render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
378
+ render.py plan.json # execute a plan written by <tool> --plan plan.json
378
379
  ```
380
+ A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
381
+ --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
382
+ head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
383
+ re-fingerprints the inputs (refusing, `kind: input`, if any changed since the
384
+ plan), runs the tool with the planned argv, then the verify steps (probe; `check`
385
+ for a `--platform` or a platform export preset), and reports `plan`, `tool`,
386
+ `tool_result` and `check`. Show the plan to the user, get the yes, execute:
387
+ one round trip instead of re-deriving the command.
388
+
379
389
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
380
390
  captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
381
391
  CLI flags of each script (see the docstring). Use it whenever an edit has
@@ -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")
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()
@@ -243,6 +244,9 @@ class Context:
243
244
  self.overwrite = False # --overwrite: an existing output may be replaced
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
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
246
250
 
247
251
 
248
252
 
@@ -261,10 +265,18 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
261
265
  help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
262
266
  g.add_argument("--overwrite", action="store_true",
263
267
  help="allow replacing an existing output (warned today, refused from 2.0)")
268
+ g.add_argument("--plan", metavar="FILE",
269
+ help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
264
270
 
265
271
 
266
272
  def apply_common(args: "argparse.Namespace") -> None:
267
- STATE.dry_run = bool(getattr(args, "dry_run", False))
273
+ STATE.plan = getattr(args, "plan", None) or None
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)
268
280
  STATE.json = bool(getattr(args, "json", False))
269
281
  STATE.progress = bool(getattr(args, "progress", False))
270
282
  STATE.fast = bool(getattr(args, "fast", False))
@@ -347,15 +359,131 @@ def emit(output: Optional[str], **extra: Any) -> None:
347
359
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
348
360
  if meta:
349
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
350
373
  doc.update(extra)
351
374
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
352
- doc["result_v2"] = _result_v2(output, meta, extra)
375
+ doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
376
+ if STATE.plan:
377
+ doc["plan"] = write_plan(STATE.plan, output, extra)
353
378
  print_json(doc)
379
+ elif STATE.plan:
380
+ print(write_plan(STATE.plan, output, extra))
354
381
  elif output:
355
382
  print(output)
356
383
 
357
384
 
358
- _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams")
385
+ PLAN_VERSION = 1
386
+ _PLAN_STRIP = ("--plan", "--dry-run", "--json")
387
+
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
+
397
+ def fingerprint(path: str) -> Dict[str, Any]:
398
+ """Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
399
+ or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
400
+ planning slower than the edit)."""
401
+ import hashlib
402
+ st = os.stat(path)
403
+ h = hashlib.sha256()
404
+ chunk = 8 * 1024 * 1024
405
+ with open(path, "rb") as f:
406
+ h.update(f.read(chunk))
407
+ if st.st_size > 2 * chunk:
408
+ f.seek(-chunk, os.SEEK_END)
409
+ h.update(f.read(chunk))
410
+ elif st.st_size > chunk:
411
+ h.update(f.read())
412
+ return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
413
+
414
+
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)."""
419
+ import shlex
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)
424
+ for line in commands:
425
+ try:
426
+ toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
427
+ except ValueError:
428
+ continue
429
+ for i, tok in enumerate(toks[:-1]):
430
+ if tok == "-i" and os.path.isfile(toks[i + 1]) and toks[i + 1] not in seen:
431
+ seen.append(toks[i + 1])
432
+ return seen
433
+
434
+
435
+ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
436
+ """The dry run as an artifact: what will run, on which exact inputs, producing what, checked
437
+ how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C)."""
438
+ import datetime
439
+ argv = [a for a in sys.argv[1:]]
440
+ cleaned: List[str] = []
441
+ skip = False
442
+ for a in argv:
443
+ if skip:
444
+ skip = False
445
+ continue
446
+ if a in _PLAN_STRIP:
447
+ skip = a == "--plan"
448
+ continue
449
+ if a.startswith("--plan="):
450
+ continue
451
+ cleaned.append(a)
452
+ tool = os.path.splitext(os.path.basename(sys.argv[0]))[0]
453
+ verify: List[Dict[str, Any]] = [{"tool": "probe"}] if output else []
454
+ platform = None
455
+ if "--platform" in cleaned:
456
+ platform = cleaned[cleaned.index("--platform") + 1]
457
+ elif tool == "export" and "--preset" in cleaned:
458
+ platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
459
+ if platform and output and tool != "check":
460
+ verify.append({"tool": "check", "platform": platform})
461
+ doc = {
462
+ "plan_version": PLAN_VERSION,
463
+ "created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
464
+ "tool": tool,
465
+ "argv": cleaned,
466
+ "cwd": os.getcwd(),
467
+ "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands, cleaned)],
468
+ "commands": list(STATE.commands),
469
+ "output": os.path.abspath(output) if output else None,
470
+ "verify": verify,
471
+ "notes": list(extra.get("notes") or []),
472
+ }
473
+ try:
474
+ tmp = f"{path}.tmp{os.getpid()}"
475
+ with open(tmp, "w", encoding="utf-8") as f:
476
+ json.dump(doc, f, indent=2, ensure_ascii=False)
477
+ f.write("\n")
478
+ os.replace(tmp, path)
479
+ except OSError as exc:
480
+ die(f"cannot write plan {path}: {exc}", kind="output")
481
+ STATE.plan_written = True
482
+ info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
483
+ return path
484
+
485
+
486
+ _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams", "verified", "verification")
359
487
 
360
488
 
361
489
  def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
@@ -382,6 +510,8 @@ def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any
382
510
  "metrics": metrics,
383
511
  "notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
384
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 []),
385
515
  "details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
386
516
  }
387
517
 
@@ -1344,6 +1474,8 @@ def escape_filter_path(path: str) -> str:
1344
1474
  filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
1345
1475
  (measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
1346
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)
1347
1479
  p = str(Path(path))
1348
1480
  p = p.replace("\\", "/")
1349
1481
  p = p.replace(":", "\\\\:")
@@ -333,7 +333,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
333
333
  props: Dict[str, Any] = {}
334
334
  required: List[str] = []
335
335
  positional: List[str] = []
336
- common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite"}
336
+ common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan"}
337
337
  for action in parser._actions:
338
338
  if isinstance(action, argparse._HelpAction):
339
339
  continue
@@ -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"},
@@ -386,7 +389,10 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
386
389
  elif name == "look":
387
390
  extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
388
391
  elif name == "render":
389
- extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]}}
392
+ extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
393
+ "plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
394
+ "tool": {"type": "string", "description": "plan execution: the tool the plan ran"},
395
+ "tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"}}
390
396
  elif name == "verify":
391
397
  extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
392
398
  elif name == "batch":
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
@@ -49,13 +49,14 @@ Examples:
49
49
  python3 render.py project.json --fast # preview quality
50
50
  """
51
51
  import argparse
52
+ import re
52
53
  import json
53
54
  import os
54
55
  import sys
55
56
  from pathlib import Path
56
57
  from typing import Any, Dict, List
57
58
 
58
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input
59
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION
59
60
 
60
61
  HERE = Path(__file__).resolve().parent
61
62
 
@@ -101,9 +102,85 @@ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
101
102
  return str(doc.get("output") or "")
102
103
 
103
104
 
105
+ def execute_plan(plan: Dict[str, Any], path: str) -> int:
106
+ """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
107
+ the plan was made (the plan's commands would then describe a different edit), run the tool
108
+ with the planned argv, then the plan's verify steps. --dry-run prints the planned commands."""
109
+ if plan.get("plan_version") != PLAN_VERSION:
110
+ die(f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}")
111
+ tool = str(plan.get("tool") or "")
112
+ script = HERE / f"{tool}.py"
113
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", tool) or not script.exists() or tool == "render":
114
+ die(f"{path}: unknown tool {tool!r}")
115
+ changed = []
116
+ for inp in plan.get("inputs") or []:
117
+ p = inp.get("path")
118
+ if not p or not os.path.isfile(p):
119
+ changed.append(f"{p}: missing")
120
+ continue
121
+ now = fingerprint(p)
122
+ if now["size"] != inp.get("size") or now["sha256_head_tail"] != inp.get("sha256_head_tail"):
123
+ changed.append(f"{p}: content changed since the plan was made")
124
+ if changed:
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
+ hint="plans are bound to the exact input files they were made from")
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")
130
+ os.chdir(plan["cwd"])
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"]]
134
+ if STATE.dry_run:
135
+ for c in plan.get("commands") or []:
136
+ STATE.commands.append(c)
137
+ info("[dry-run] " + c)
138
+ emit(plan.get("output"), plan=path, tool=tool, stages=[tool], check=None)
139
+ return 0
140
+ info(f"executing plan {path}: {tool} " + " ".join(argv))
141
+ proc = run_tool([str(script)] + argv + child_args() + ["--json"])
142
+ for line in proc.stderr.splitlines():
143
+ if line.startswith("$ "):
144
+ STATE.commands.append(line[2:])
145
+ elif line.strip():
146
+ info(" " + line)
147
+ try:
148
+ doc = json.loads(proc.stdout.strip() or "{}")
149
+ except ValueError:
150
+ doc = {}
151
+ if proc.returncode != 0 or doc.get("status") != "completed":
152
+ err = doc.get("error") or {}
153
+ die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
154
+ kind=err.get("kind", "ffmpeg"), plan=path, tool=tool)
155
+ output = doc.get("output") or plan.get("output")
156
+ check_result = None
157
+ exit_code = 0
158
+ for step in plan.get("verify") or []:
159
+ if step.get("tool") == "check" and step.get("platform") and output:
160
+ cp = run_tool([str(HERE / "check.py"), output, "--platform", step["platform"], "--json"] + child_args())
161
+ try:
162
+ check_result = json.loads(cp.stdout)
163
+ except ValueError:
164
+ check_result = {"error": cp.stderr.strip()[-300:]}
165
+ if check_result.get("failed") or check_result.get("status") == "failed" or check_result.get("error"):
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)
170
+ if exit_code:
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"])
178
+ return 0
179
+
180
+
104
181
  def main() -> int:
105
182
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
106
- ap.add_argument("project", nargs="?", help="project.json")
183
+ ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
107
184
  ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
108
185
  ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
109
186
  ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
@@ -119,10 +196,16 @@ def main() -> int:
119
196
  return 0
120
197
  if not args.project:
121
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)")
122
201
  try:
123
202
  proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
124
203
  except (OSError, ValueError) as exc:
125
204
  die(f"cannot read project: {exc}")
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))
126
209
  base = Path(args.project).resolve().parent
127
210
 
128
211
  def rel(p: Any) -> str:
@@ -404,7 +487,8 @@ def main() -> int:
404
487
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
405
488
  probe=probe(output, role="output"))
406
489
  info(f"rendered {output} via {' → '.join(stages_done)}")
407
- 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 [])
408
492
  return 0
409
493
 
410
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