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 +1 -1
- package/docs/contract.md +16 -4
- package/package.json +1 -1
- package/scripts/_common.py +43 -6
- package/scripts/_contract.py +4 -1
- package/scripts/export.py +1 -0
- package/scripts/loudness.py +4 -1
- package/scripts/render.py +24 -9
- package/scripts/report.py +1 -1
- package/scripts/verify.py +2 -0
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.
|
|
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"},
|
|
@@ -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
|
|
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.
|
|
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/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", "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
|
|
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(":", "\\\\:")
|
package/scripts/_contract.py
CHANGED
|
@@ -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)")
|
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
|
@@ -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")
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
194
|
-
|
|
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
|