ffmpeg-skill 1.6.0 → 1.7.1

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
@@ -355,6 +355,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
355
355
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
356
356
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
357
357
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
358
+ | **108 / 108** | 1.7.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 24 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 25/25, report format 108/108 by regex (104/108 by the stricter grader), user's language 101/108 (six English refusals answered in Spanish or Portuguese, one Japanese request in English); trigger set 22/22. Iteration-6 fixes held (fade-in only, Japanese audio trims, music no longer shortens the video). Details in `evals/results/iteration-7.json` |
358
359
  | **36 / 36** | 1.4.15 re-run (2026-09-12, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, report format 25/25, user's language 9/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3, one Japanese report with English labels; trigger set 22/22. Both iteration-5 defects gone (no raw ffmpeg fallback, music no longer shortens the video). Details in `evals/results/iteration-6.json` |
359
360
  | **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
360
361
  | **6 / 6** | 0.9.1 audio evals (audio join, extraction, track selection, sample-accurate trim, typed dynamics; 2 in Japanese): routing, report format and audio-as-audio handling 6/6 |
package/SKILL.md CHANGED
@@ -160,7 +160,7 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
160
160
  | "remove the green screen", "chroma key this" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
161
161
  | "sync the lav mic to the camera", "line up the two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
162
162
  | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
163
- | "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
163
+ | "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` (`--normalize` meets the platform's loudness spec in the same call, no separate `loudness.py` pass) |
164
164
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
165
165
  | "make a small/low-res proxy for an analysis pass", "a cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset, see `export.py` for those |
166
166
  | "cut out the pauses / dead air", "tighten it up", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
@@ -239,14 +239,14 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
239
239
 
240
240
  ## Report format
241
241
 
242
- Reply in the language the user wrote their request in a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
242
+ Reply in the language the request itself is written in: the language of the user's own sentences, not a language the request talks about (an English request for Spanish subtitles gets an English report) and not the language of a tool's error text or of the file names. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English, and never drift into another language because the job is short or the report is a failure: a one-line "file does not exist" is written in the request's language too. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
243
243
 
244
244
  Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
245
245
 
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
  ```
@@ -263,7 +263,7 @@ Look: not needed (nothing written)
263
263
  Notes: send a valid .cube, or say if you want the clip left as is
264
264
  ```
265
265
 
266
- A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
266
+ A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. That includes the shortest failure: a missing input or an invalid LUT still gets `Failed:`, `Steps:`, `Check:`, `Look:` and `Notes:` lines, never prose headings in their place. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
267
267
 
268
268
  Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification | interrupted, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
269
269
 
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.6.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.7.1`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.6.0", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.7.1", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -165,7 +165,11 @@ generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL
165
165
  `--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
166
166
  "tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
167
167
  "output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
168
- implies `--dry-run`, so the same execution rules apply. `render.py FILE` executes a plan:
168
+ implies `--dry-run`, so the same execution rules apply; `inputs` covers the `-i` files of the
169
+ planned commands, every existing file named in argv (a recipe, a still, an SRT) and the
170
+ subtitle/LUT/font files a filter reads. Tools that print their document without `--json`
171
+ (`probe`, the analysis tools) still write the plan at exit; `verify.py` and `render.py`
172
+ refuse `--plan` (their steps run for real; a project file is already a plan). `render.py FILE` executes a plan:
169
173
  it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
170
174
  runs the tool with the planned `argv`, then the verify steps, and reports `plan`, `tool`,
171
175
  `tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
@@ -307,10 +311,18 @@ Success (`exit 0`): one document matching `output_schema`, always with
307
311
  `status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
308
312
  file was written. `probe` prints its measurement document directly.
309
313
 
314
+ Every writing tool also reports what it verified itself (1.7): `verification` lists the
315
+ steps (`{"step": "probe", "ok": true}`; `loudness` with the measured and target values for
316
+ `loudness.py` and the platform presets of `export.py`; `check` with the platform for
317
+ `render.py`), and `verified` is `true` only when the artifact was written, probed and every
318
+ listed step met its target. A `--dry-run` document has `verified: false` and an empty list.
319
+ `export.py` whose written file misses the platform's loudness spec stays `completed` (the
320
+ file is valid) with `verified: false` and the fix in `notes`, so a caller keys on one field.
321
+
310
322
  With `FFMPEG_SKILL_RESULT_V2=1` in the environment, every writing tool's success document
311
323
  also carries `result_v2`: a preview of the one shape 2.0 will use for every tool
312
324
  (issue #189). `{"schema": 2, "output", "probe", "commands", "metrics", "notes", "dropped":
313
- {"non_av_streams"}, "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
325
+ {"non_av_streams"}, "verified", "verification", "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
314
326
  measurement dicts flattened, plus any numeric top-level key such as `expected_duration` or
315
327
  `offset_seconds`), `notes` the free text, `details` the tool's remaining keys unchanged. The
316
328
  1.x keys are not moved; the environment variable only adds the key, and its absence is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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",
@@ -640,7 +640,7 @@ integrated loudness ended more than 1 LU from the target because of it.
640
640
 
641
641
  ### export.py — delivery presets
642
642
  ```
643
- export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [-o OUT]
643
+ export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
644
644
  export.py --list
645
645
  ```
646
646
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
@@ -648,7 +648,9 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
648
648
  does not touch levels: for youtube / youtube4k / reels / x the written file is
649
649
  measured and the result's `loudness` (and a `notes` line) says when it is
650
650
  outside the platform's LUFS / true-peak spec, naming the `loudness.py` call
651
- that fixes it -- plan that pass instead of discovering it from `check.py`.
651
+ that fixes it -- or pass `--normalize`, which runs that call on the written
652
+ file itself (audio re-encoded, video copied; `loudness.normalized: true`) so a
653
+ platform export is one command instead of export, loudness, export again.
652
654
 
653
655
  ### proxy.py — low-bitrate proxy for analysis/preview
654
656
  ```
@@ -176,6 +176,7 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
176
176
  `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
177
177
  read a failed delivery as a success."""
178
178
  hint = extra.pop("hint", None)
179
+ STATE.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
179
180
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
180
181
  if STATE.json:
181
182
  doc: Dict[str, Any] = {
@@ -227,7 +228,7 @@ class Context:
227
228
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
228
229
  """
229
230
 
230
- __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan")
231
+ __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs")
231
232
 
232
233
  def __init__(self) -> None:
233
234
  self.reset()
@@ -244,6 +245,8 @@ class Context:
244
245
  self.written: set = set() # output paths this process has written itself
245
246
  self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
246
247
  self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
248
+ self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
249
+ self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
247
250
 
248
251
 
249
252
 
@@ -269,6 +272,11 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
269
272
  def apply_common(args: "argparse.Namespace") -> None:
270
273
  STATE.plan = getattr(args, "plan", None) or None
271
274
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
275
+ if STATE.plan:
276
+ # tools that print their document instead of calling emit() (probe, and the analysis
277
+ # tools without --json) still get their plan written, at exit, unless die() ran (review 6)
278
+ import atexit
279
+ atexit.register(_plan_at_exit)
272
280
  STATE.json = bool(getattr(args, "json", False))
273
281
  STATE.progress = bool(getattr(args, "progress", False))
274
282
  STATE.fast = bool(getattr(args, "fast", False))
@@ -351,9 +359,20 @@ def emit(output: Optional[str], **extra: Any) -> None:
351
359
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
352
360
  if meta:
353
361
  doc["probe"] = meta
362
+ # What this tool itself verified about its artifact (issue #189 C, "verify as part of the
363
+ # contract"): the probe every writing tool runs, plus the measurements a tool adds
364
+ # (`verification` extra: loudness after the write, a platform check). `verified` is true
365
+ # only when the file was written, probed, and every self-check met its target; a dry run
366
+ # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
367
+ # keep status completed and say verified: false, so a caller keys on one field.
368
+ steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
369
+ if output and not STATE.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
370
+ steps.insert(0, {"step": "exists", "ok": True})
371
+ doc["verified"] = not STATE.dry_run and bool(steps) and all(s.get("ok") for s in steps)
372
+ doc["verification"] = steps
354
373
  doc.update(extra)
355
374
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
356
- doc["result_v2"] = _result_v2(output, meta, extra)
375
+ doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
357
376
  if STATE.plan:
358
377
  doc["plan"] = write_plan(STATE.plan, output, extra)
359
378
  print_json(doc)
@@ -367,6 +386,14 @@ PLAN_VERSION = 1
367
386
  _PLAN_STRIP = ("--plan", "--dry-run", "--json")
368
387
 
369
388
 
389
+ def _plan_at_exit() -> None:
390
+ if STATE.plan and not STATE.plan_written:
391
+ try:
392
+ write_plan(STATE.plan, None, {})
393
+ except SystemExit:
394
+ pass
395
+
396
+
370
397
  def fingerprint(path: str) -> Dict[str, Any]:
371
398
  """Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
372
399
  or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
@@ -385,10 +412,15 @@ def fingerprint(path: str) -> Dict[str, Any]:
385
412
  return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
386
413
 
387
414
 
388
- def _plan_inputs(commands: Sequence[str]) -> List[str]:
389
- """Every existing file named by `-i` in the planned commands (shell-quoted lines)."""
415
+ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]:
416
+ """Every existing file the plan depends on: the `-i` inputs of the planned commands, any
417
+ existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
418
+ inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
390
419
  import shlex
391
420
  seen: List[str] = []
421
+ for a in list(argv) + list(STATE.plan_inputs):
422
+ if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
423
+ seen.append(a)
392
424
  for line in commands:
393
425
  try:
394
426
  toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
@@ -432,7 +464,7 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
432
464
  "tool": tool,
433
465
  "argv": cleaned,
434
466
  "cwd": os.getcwd(),
435
- "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands)],
467
+ "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands, cleaned)],
436
468
  "commands": list(STATE.commands),
437
469
  "output": os.path.abspath(output) if output else None,
438
470
  "verify": verify,
@@ -446,11 +478,12 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
446
478
  os.replace(tmp, path)
447
479
  except OSError as exc:
448
480
  die(f"cannot write plan {path}: {exc}", kind="output")
481
+ STATE.plan_written = True
449
482
  info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
450
483
  return path
451
484
 
452
485
 
453
- _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams")
486
+ _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams", "verified", "verification")
454
487
 
455
488
 
456
489
  def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
@@ -477,6 +510,8 @@ def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any
477
510
  "metrics": metrics,
478
511
  "notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
479
512
  "dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
513
+ "verified": bool(extra.get("verified", False)),
514
+ "verification": list(extra.get("verification") or []),
480
515
  "details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
481
516
  }
482
517
 
@@ -1439,6 +1474,8 @@ def escape_filter_path(path: str) -> str:
1439
1474
  filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
1440
1475
  (measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
1441
1476
  """
1477
+ if os.path.isfile(path) and path not in STATE.plan_inputs:
1478
+ STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
1442
1479
  p = str(Path(path))
1443
1480
  p = p.replace("\\", "/")
1444
1481
  p = p.replace(":", "\\\\:")
@@ -372,7 +372,10 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
372
372
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}}, "additionalProperties": True}
373
373
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
374
374
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
375
- "probe": {"type": "object", "description": "probe of the output when a file was written"}}
375
+ "probe": {"type": "object", "description": "probe of the output when a file was written"},
376
+ "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
377
+ "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
378
+ "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets) or check (render)"}}
376
379
  extra: Dict[str, Any] = {}
377
380
  if name == "check":
378
381
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
@@ -397,7 +400,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
397
400
  elif name == "report":
398
401
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
399
402
  elif name == "export":
400
- extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec"},
403
+ extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file"},
401
404
  "notes": {"type": "array", "items": {"type": "string"}}}
402
405
  elif name == "loudness":
403
406
  extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
package/scripts/export.py CHANGED
@@ -17,16 +17,19 @@ Presets:
17
17
  Examples:
18
18
  python3 export.py final.mp4 --preset youtube
19
19
  python3 export.py final.mp4 --preset reels --fit crop
20
+ python3 export.py final.mp4 --preset reels --normalize # meet the platform's loudness in the same call
20
21
  python3 export.py final.mp4 --preset prores -o master.mov
21
22
  python3 export.py final.mp4 --preset copy -o delivered.mp4
22
23
  python3 export.py --list
23
24
  """
24
25
  import argparse
26
+ import json
27
+ import os
25
28
  import sys
26
29
  from pathlib import Path
27
30
  from typing import Dict, List
28
31
 
29
- from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args, fmt_secs
32
+ from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
30
33
  from check import SPECS as PLATFORMS, measure_loudness
31
34
  PRESETS: Dict[str, Dict] = {
32
35
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
@@ -42,6 +45,7 @@ PRESETS: Dict[str, Dict] = {
42
45
 
43
46
 
44
47
  # which check.py platform a preset targets (its loudness spec is measured after the write)
48
+ HERE = Path(__file__).resolve().parent
45
49
  PLATFORM_OF = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}
46
50
 
47
51
 
@@ -56,6 +60,7 @@ def main() -> int:
56
60
  ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
57
61
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
58
62
  ap.add_argument("--crf", type=int, help="override CRF")
63
+ ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
59
64
  ap.add_argument("--list", action="store_true", help="list presets and exit")
60
65
  add_common(ap)
61
66
  args = ap.parse_args()
@@ -139,9 +144,35 @@ def main() -> int:
139
144
  if m:
140
145
  ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
141
146
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
142
- if not ok:
147
+ extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
148
+ if not ok and args.normalize:
149
+ # Eval 7: every platform job ran export -> loudness.py -> export again (two video
150
+ # encodes). The levels pass only re-encodes audio, so do it here on the written
151
+ # file and the caller gets one export that meets the spec.
152
+ info(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s spec; normalising to {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP")
153
+ tmp = str(Path(output).with_name(Path(output).stem + "_loudnorm" + Path(output).suffix))
154
+ proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json", "--overwrite"] + child_args())
155
+ try:
156
+ child = json.loads(proc.stdout)
157
+ except ValueError:
158
+ child = {}
159
+ if proc.returncode != 0 or child.get("status") != "completed":
160
+ err = child.get("error") or {}
161
+ if os.path.exists(tmp):
162
+ os.remove(tmp)
163
+ die(f"--normalize: loudness.py failed: {err.get('message') or proc.stderr.strip()[-300:]}",
164
+ kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
165
+ os.replace(tmp, output)
166
+ m = measure_loudness(output) or m
167
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
168
+ extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
169
+ extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
170
+ if not ok:
171
+ notes.append(f"loudness is still {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP after loudness.py (target {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP): {child.get('result', {}).get('note') or 'the encoder overshoots the ceiling'}")
172
+ info("warning: " + notes[-1])
173
+ elif not ok:
143
174
  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
- f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file (or before export)")
175
+ f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file, or export with --normalize")
145
176
  info("warning: " + notes[-1])
146
177
  if notes:
147
178
  extra["notes"] = notes
@@ -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
@@ -56,6 +56,7 @@ import sys
56
56
  from pathlib import Path
57
57
  from typing import Any, Dict, List
58
58
 
59
+ from export import PRESETS
59
60
  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
60
61
 
61
62
  HERE = Path(__file__).resolve().parent
@@ -124,9 +125,13 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
124
125
  if changed:
125
126
  die("plan inputs differ from what was planned; re-run the tool with --plan to make a new plan:\n " + "\n ".join(changed),
126
127
  hint="plans are bound to the exact input files they were made from")
127
- if plan.get("cwd") and os.path.isdir(plan["cwd"]):
128
+ if plan.get("cwd"):
129
+ if not os.path.isdir(plan["cwd"]):
130
+ die(f"plan cwd {plan['cwd']} no longer exists; relative paths in the plan would resolve elsewhere -- re-plan")
128
131
  os.chdir(plan["cwd"])
129
- argv = [str(a) for a in plan.get("argv") or []]
132
+ if not isinstance(plan.get("argv"), list):
133
+ die(f"{path}: argv must be a list")
134
+ argv = [str(a) for a in plan["argv"]]
130
135
  if STATE.dry_run:
131
136
  for c in plan.get("commands") or []:
132
137
  STATE.commands.append(c)
@@ -160,14 +165,39 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
160
165
  check_result = {"error": cp.stderr.strip()[-300:]}
161
166
  if check_result.get("failed") or check_result.get("status") == "failed" or check_result.get("error"):
162
167
  exit_code = 1
168
+ # a failed platform check is reported the way the direct path reports it: the tool completed,
169
+ # verified is false, the rows say what to fix (review 6: a plan must not fail harder than the
170
+ # same command run by hand)
163
171
  if exit_code:
164
- die(f"plan executed but {output} does not meet the {[s.get('platform') for s in plan.get('verify') or [] if s.get('tool') == 'check'][0]} spec",
165
- kind="verification", output=output, plan=path, tool=tool, stages=[tool, "check"], check=check_result)
166
- info(f"plan done: {output}")
167
- emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc)
172
+ failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
173
+ info(f"plan done: {output}, but the {check_result.get('platform')} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
174
+ else:
175
+ info(f"plan done: {output}")
176
+ emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
177
+ verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else [])
178
+ + [s for s in (doc.get("verification") or []) if s.get("step") != "probe"])
168
179
  return 0
169
180
 
170
181
 
182
+ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
183
+ """Fill frame.width/height from the export preset when the project gave only an aspect.
184
+ Eval 7 (j08 twice, e01 by hand): "frame": {"aspect": "9:16"} with a reels export fitted a
185
+ 1280x720 source to 406x720, captions were burned at that size, and export.py upscaled them
186
+ soft. A preset that names a delivery frame of the same aspect is that frame."""
187
+ if not frame.get("aspect") or frame.get("width") or frame.get("height"):
188
+ return
189
+ preset = PRESETS.get(str(export.get("preset") or ""), {})
190
+ if not (preset.get("w") and preset.get("h")):
191
+ return
192
+ m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*", str(frame["aspect"]))
193
+ if not m or float(m.group(2)) == 0:
194
+ return
195
+ if abs(float(m.group(1)) / float(m.group(2)) - preset["w"] / preset["h"]) > 0.01:
196
+ return
197
+ frame["width"], frame["height"] = preset["w"], preset["h"]
198
+ info(f"frame: {preset['w']}x{preset['h']} from the {export['preset']} export preset (captions and overlays are sized for delivery)")
199
+
200
+
171
201
  def main() -> int:
172
202
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
173
203
  ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
@@ -186,12 +216,16 @@ def main() -> int:
186
216
  return 0
187
217
  if not args.project:
188
218
  die("give a project.json (or --init FILE)")
219
+ if STATE.plan:
220
+ die("render.py has no --plan: the project file is the plan (use --dry-run to preview it)")
189
221
  try:
190
222
  proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
191
223
  except (OSError, ValueError) as exc:
192
224
  die(f"cannot read project: {exc}")
193
- if isinstance(proj, dict) and "plan_version" in proj:
194
- return execute_plan(proj, args.project)
225
+ if not isinstance(proj, dict):
226
+ die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
227
+ if "plan_version" in proj:
228
+ return execute_plan(proj, os.path.abspath(args.project))
195
229
  base = Path(args.project).resolve().parent
196
230
 
197
231
  def rel(p: Any) -> str:
@@ -222,8 +256,9 @@ def main() -> int:
222
256
  import atexit
223
257
  import shutil
224
258
  atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
225
- frame = proj.get("frame") or {}
259
+ frame = dict(proj.get("frame") or {})
226
260
  trans = proj.get("transition") or {}
261
+ frame_from_preset(frame, proj.get("export") or {})
227
262
  brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
228
263
  stages_done: List[str] = []
229
264
 
@@ -473,7 +508,8 @@ def main() -> int:
473
508
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
474
509
  probe=probe(output, role="output"))
475
510
  info(f"rendered {output} via {' → '.join(stages_done)}")
476
- emit(output, stages=stages_done, check=check_result)
511
+ emit(output, stages=stages_done, check=check_result,
512
+ verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
477
513
  return 0
478
514
 
479
515
 
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