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 +1 -1
- package/SKILL.md +2 -2
- package/docs/contract.md +23 -3
- package/package.json +1 -1
- package/references/scripts.md +11 -1
- package/scripts/_common.py +136 -4
- package/scripts/_contract.py +9 -3
- package/scripts/export.py +1 -0
- package/scripts/loudness.py +4 -1
- package/scripts/render.py +87 -3
- package/scripts/report.py +1 -1
- package/scripts/verify.py +2 -0
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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
|
package/scripts/_common.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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(":", "\\\\:")
|
package/scripts/_contract.py
CHANGED
|
@@ -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)")
|
package/scripts/loudness.py
CHANGED
|
@@ -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
|