ffmpeg-skill 2.17.4 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -150,7 +150,6 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
150
150
  | "where does the subject move, so I can crop it myself" | `cropdetect.py input.mp4 --motion-centre` — motion centroid per second, report-only; the calling agent picks the crop |
151
151
  | "does it look like Log / S-Log?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
152
152
  | "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
153
- | "show me progress", "quick preview first" | any encoding script with `--progress` and/or `--fast` |
154
153
  | "it's a phone video with variable frame rate" | nothing extra: re-encodes conform VFR to constant fps; `fit.py --fps 30` picks the rate |
155
154
 
156
155
  ## Audio-only files
@@ -181,7 +180,7 @@ Look: final_sheet.png(字幕はセーフエリア内、ロゴは右上)
181
180
  Notes: 元は VFR だったので 30 fps に揃えた。音声はモノラルだったのでステレオにした
182
181
  ```
183
182
 
184
- Keep it to those five lines plus anything the user must decide. Never describe a fix you did not run.
183
+ Keep it to those five lines plus anything the user must decide. Never describe a fix you did not run. `verified` covers only what was measured: in `Notes:` name any `unverified` item the user would assume checked. Report a step with `attribution: unresolved`; do not re-run it.
185
184
 
186
185
  When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
187
186
 
@@ -196,9 +195,7 @@ Notes: send a valid .cube, or say if you want the clip left as is
196
195
  Those filler lines are sentences, not labels: the same report for a Japanese request ends
197
196
  `Check: 検証するものなし` / `Look: 不要`.
198
197
 
199
- A refusal uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run, `Look: not needed`. A partial result is `Done:` with the shortfall in `Notes:`; a refusal that still delivers something is `Failed:` — never a third label like `Done (partially):`. Quote a failure's `error.hint` in `Notes:`.
200
-
201
- A failure's `--json` is `status: failed` plus `error.kind` and `error.message`; quote the message, never paraphrase it.
198
+ A refusal uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run, `Look: not needed`. A partial result is `Done:` with the shortfall in `Notes:`; a refusal that still delivers something is `Failed:` — never a third label like `Done (partially):`. A failure's `--json` is `status: failed` plus `error.kind`, `error.message` and `error.hint`: quote the message in `Failed:`, never paraphrase it, and the hint in `Notes:`.
202
199
 
203
200
  ## Things that look right but are wrong
204
201
 
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 (`2.17.4`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.18.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`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.17.4", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.18.0", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
@@ -169,6 +169,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
169
169
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
170
170
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
171
171
  | `requires_visual_verification` | the picture changed; run `ffmpeg-skill/look` and inspect the PNG |
172
+ | `unverified_properties` | 2.18: the output properties this tool does not measure, the same list its results carry as `unverified` (empty when the tool names none) |
172
173
  | `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
173
174
  | `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
174
175
  | `video_required` | refuses an input without a video stream ("input has no video stream") |
@@ -685,6 +686,19 @@ Per-tool keys added after 2.2.2, all additive:
685
686
  | `vfr_check` | `cut.py` (a video input that could still stream-copy) | the sampled frame timing, `{heuristic: true, measured, method: "sampled", guard, windows, deltas}`; `measured` is `sampled_cfr`, `vfr` or `inconclusive`. `--vfr-guard average` (the default) keeps the 2.x guard: a source whose nominal and average frame rates differ (`variable_frame_rate_suspected`) re-encodes as `--accurate` (reason `vfr`, stderr says "variable-frame-rate"), and only then is the timing sampled and reported, with a `notes` line naming `--vfr-guard sampled` when it measured `sampled_cfr`; the key is absent otherwise. `--vfr-guard sampled` samples before every copy and re-encodes on `vfr` (reason `vfr`) or `inconclusive` (fewer than 20 intervals; `vfr_inconclusive`); `--vfr-guard off` samples and keeps the copy, with a `notes` line. Absent for audio-only input or output, `--accurate`, `--codec` and a pending `--dry-run` input |
686
687
  | `join_check`, `segment_end_snap_seconds` | `cut.py --segments` | `join_check`: a video stream-copy join measured by demuxing, `{packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}`. `expected_packets` counts the source's video packets from each part's start keyframe to its end (or the end of the video); `max_step_seconds` is the largest presentation step, `null` when `vfr_check` measured anything but `sampled_cfr` (a `--vfr-guard off` join of variable timing: count only), and each step at a join must be the step its parts predict within 1 ms. `audio_offset_ms`: per part, its sound's shift minus its picture's in ms (+ = sound late), from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than `a:0`; an MPEG-TS source's AAC is hashed without its ADTS headers); `null` for a part with no unique match (digital silence, a periodic tone), and as a whole when the source has no audio or the video check failed. `audio_parts_checked` counts the measured parts. `ok` is the conjunction (count, steps, every measured part within 5 ms); `ok: false` means the join was re-cut (`concat_fallback`) and this is the failed measurement. `null` for a single segment, audio, `--dry-run`, any re-encode forced for every segment, a segment that re-encoded, or a join re-cut before a copy was tried (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error). `segment_end_snap_seconds`: per segment, the signed seconds its end moved to the keyframe the copied part stops at, the first at or after the requested end (`null` for a part that reaches the end of the video, a part of a source with no B-frames, cut to its requested end as in 2.5.1, or a Matroska/MPEG-TS part, which is not snapped); `null` as a whole unless the output is those copied parts |
687
688
 
689
+ Keys added in 2.18, all additive. They make a result carry what the VDC paper
690
+ (Verification-Driven Coding) calls the co-reported items: what was measured against what, how far,
691
+ from where the expected value came, and what was not measured.
692
+
693
+ | key | tool | what it holds |
694
+ |---|---|---|
695
+ | `verification[].property`, `expected`, `measured`, `delta`, `tolerance`, `provenance` | steps that measure a property (join's `duration`, loudness's `loudness`, export's `loudness`, every `streams` step) | the property measured, its expected and measured values, their difference, the tolerance applied, and where the expected value came from: `specification` (the platform table), `user_specified` (a flag), `independent_measurement`, or `input_premeasurement` (the input's own probe) |
696
+ | `verification[]` step `streams` | `cut.py`, `join.py`, `export.py` | the output's video/audio streams against the ones the input had and the operation was not told to drop (an audio extension drops the picture, a GIF the sound). A missing stream is `ok: false` with `missing`, so the result is `verified: false` (#367: a TS cut that lost its video) |
697
+ | `verification[].attribution` | any step with `ok: false` | `unresolved` until a tool knows whether the artifact, the reference, the measuring instrument or the expected value's provenance is at fault. A caller does not re-run on an unresolved failure: a re-run can hide which one it was |
698
+ | `unverified` | every writing tool `UNVERIFIED` names (`_common/emit.py`) | the output's properties this tool did not measure, e.g. `caption`'s `rendered_placement`, `sync`'s `lip_sync`. `verified` is about the measured properties only; a report that passes the verdict on must pass this list with it. The contract carries the same list per tool as `unverified_properties` |
699
+ | `audio_substituted` | `join.py` (video join) | the clips that had no audio stream, whose span of the join is generated silence; `notes` says the same |
700
+ | loudness `tolerance.tp` | `loudness.py`, `export.py`, and `check.py`'s true-peak row | the one dBTP margin every tool applies (`_platforms.TP_TOLERANCE_DB`, 0.1 dB); before 2.18 it was 0.1, 0.05 and 0.0 in the three tools |
701
+
688
702
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
689
703
 
690
704
  | key | tool | what it holds |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.17.4",
3
+ "version": "2.18.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",
@@ -372,6 +372,45 @@ def steps_encoder_report(steps: Sequence["Tuple[str, Any]"]) -> Dict[str, Any]:
372
372
  return out
373
373
 
374
374
 
375
+ # The properties each writing tool does NOT measure about its output, by tool name: the unverified
376
+ # range U that every --json result reports (docs/contract.md "unverified"). A property listed here
377
+ # is one a caller might assume was checked; naming it is the difference between "verified" meaning
378
+ # "the listed properties were measured" and "everything is fine". Kept beside emit() so a tool
379
+ # cannot report verified without this list travelling with it.
380
+ UNVERIFIED: Dict[str, List[str]] = {
381
+ "cut": ["picture_content"],
382
+ "join": ["picture_content", "transition_look"],
383
+ "fit": ["subject_framing"],
384
+ "crop": ["subject_framing"],
385
+ "caption": ["rendered_placement", "legibility"],
386
+ "overlay": ["rendered_placement"],
387
+ "graphics": ["rendered_placement"],
388
+ "color": ["grade_look"],
389
+ "export": ["picture_content"],
390
+ "render": ["picture_content", "rendered_placement"],
391
+ "silence": ["removed_span_content"],
392
+ "audio": ["mix_balance"],
393
+ "loudness": ["perceived_loudness"],
394
+ "sync": ["lip_sync"],
395
+ "multicam": ["lip_sync", "shot_choice"],
396
+ }
397
+
398
+
399
+ def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, bool]) -> Dict[str, Any]:
400
+ """The output's stream composition checked against what the operation should have produced
401
+ (#367: a cut of an MPEG-TS source lost its whole video stream and still said verified: true,
402
+ because the only properties measured were duration and start skew). `expect` comes from the
403
+ input's own probe and the tool's arguments (provenance "input_premeasurement"): a stream the
404
+ input had and the operation was not told to drop must still be there."""
405
+ measured = {kind: bool(meta.get(kind)) for kind in expect}
406
+ missing = [k for k, want in expect.items() if want and not measured[k]]
407
+ step: Dict[str, Any] = {"step": "streams", "property": "stream_inventory", "expected": dict(expect),
408
+ "measured": measured, "provenance": "input_premeasurement", "ok": not missing}
409
+ if missing:
410
+ step["missing"] = missing
411
+ return step
412
+
413
+
375
414
  def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
376
415
  """Final stdout line: the output path, or a JSON document with --json.
377
416
 
@@ -392,13 +431,22 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
392
431
  # only when the file was written, probed, and every self-check met its target; a dry run
393
432
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
394
433
  # keep status completed and say verified: false, so a caller keys on one field.
434
+ expect_streams = extra.pop("expect_streams", None)
395
435
  steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
436
+ if meta and expect_streams:
437
+ steps.append(stream_inventory_step(meta, expect_streams))
396
438
  # a tool that ran others (render.py) verified what its stages verified too (absorb_stage())
397
439
  steps += [s for s in ctx.stage_verification if s not in steps]
398
440
  if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
399
441
  steps.insert(0, {"step": "exists", "ok": True})
400
442
  doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
443
+ # a failed check asserts that something in artifact, reference, instrument or the expected
444
+ # value's provenance is wrong; until a tool knows which, it says it does not know
445
+ steps = [dict(s, attribution=s.get("attribution") or "unresolved") if s.get("ok") is False else s for s in steps]
401
446
  doc["verification"] = steps
447
+ tool = os.path.splitext(os.path.basename(sys.argv[0] or ""))[0]
448
+ if output and not ctx.dry_run and tool in UNVERIFIED and "unverified" not in extra:
449
+ doc["unverified"] = list(UNVERIFIED[tool])
402
450
  doc.update(_encoder_report(ctx))
403
451
  doc.update(extra)
404
452
  hw_notes = [n for n in _hw_notes_for(ctx) if n not in (doc.get("notes") or [])]
@@ -536,7 +536,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
536
536
  "hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
537
537
  "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
538
538
  "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"},
539
- "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's"},
539
+ "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
540
+ "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
540
541
  "diagnostics": DIAGNOSTICS_SCHEMA}
541
542
  extra: Dict[str, Any] = {}
542
543
  if name == "check":
@@ -683,6 +684,13 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
683
684
  return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
684
685
 
685
686
  @functools.lru_cache(maxsize=1)
687
+ def _unverified_table() -> Dict[str, List[str]]:
688
+ """emit()'s per-tool unverified range, read where the results are made so the two cannot differ."""
689
+ sys.path.insert(0, str(ROOT / "scripts"))
690
+ from _common.emit import UNVERIFIED
691
+ return UNVERIFIED
692
+
693
+
686
694
  def skill_examples() -> Dict[str, List[Dict[str, Any]]]:
687
695
  """1.20.0: per-tool `examples`, parsed from SKILL.md's "User says" / "Do" table -- the
688
696
  same facts that table already states, machine-readable instead of prose-only. A row
@@ -1547,6 +1555,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1547
1555
  "reencodes_audio": REENCODE_META.get(name, {}).get("audio", "conditional"),
1548
1556
  **({"reencode_note": REENCODE_META[name]["note"]} if REENCODE_META.get(name, {}).get("note") else {}),
1549
1557
  "requires_visual_verification": meta["visual"],
1558
+ # 2.18: what `verified` does not cover for this tool (the result's `unverified`)
1559
+ "unverified_properties": list(_unverified_table().get(name, [])),
1550
1560
  "audio_only": meta["audio_only"],
1551
1561
  "video_required": meta["video_required"],
1552
1562
  "deterministic_inputs": meta["deterministic"],
@@ -55,6 +55,13 @@ _NO_SAFE = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
55
55
  _CAP_VERTICAL = {"size": 0.0833, "position": "bottom", "box": False, "outline": 2, "animate": "pop"}
56
56
  _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "animate": "none"}
57
57
 
58
+ # The one true-peak tolerance every tool applies to a dBTP ceiling, reported beside the verdict.
59
+ # Before 2.18 loudness.py allowed +0.1 dB, check.py +0.05 and export.py 0.0, so "verified" meant a
60
+ # different scale in each tool and export could fail a file check passed (the VDC paper's
61
+ # sensitivity run). 0.1 dB is loudness.py's margin: two loudnorm passes on the same encode differ
62
+ # by up to that much, so a tighter rule fails a file on measurement noise.
63
+ TP_TOLERANCE_DB = 0.1
64
+
58
65
  PLATFORMS: Dict[str, Dict[str, Any]] = {
59
66
  "tiktok": {
60
67
  "title": "TikTok",
package/scripts/check.py CHANGED
@@ -34,7 +34,7 @@ import sys
34
34
  from fractions import Fraction
35
35
  from typing import Any, Dict, List
36
36
 
37
- from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
37
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
38
38
  from _common.probe import SILENT_PEAK_DBFS, detect_content, measured_level_dbfs
39
39
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
40
40
 
@@ -249,7 +249,7 @@ def main() -> int:
249
249
  diff = abs(lm["lufs"] - spec["lufs"])
250
250
  row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
251
251
  reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
252
- row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
252
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
253
253
  else:
254
254
  # ffmpeg's loudnorm JSON didn't parse out of stderr (unexpected/garbled output).
255
255
  # Dropping the loudness/true-peak rows silently here would report an overall PASS
package/scripts/cut.py CHANGED
@@ -1677,7 +1677,11 @@ def main() -> int:
1677
1677
  notes.append(skew_note)
1678
1678
  info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
1679
1679
  + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
1680
+ # the streams the source had and this cut was not told to drop (an audio extension drops the
1681
+ # picture): #367, a TS cut that lost its video must not say verified
1682
+ expect_streams = {"video": bool(meta.get("video")) and not is_audio_output(output), "audio": bool(meta.get("audio"))}
1680
1683
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
1684
+ expect_streams=expect_streams,
1681
1685
  dropped_non_av_streams=bool(DROPPED_STREAMS),
1682
1686
  requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
1683
1687
  requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
package/scripts/export.py CHANGED
@@ -40,10 +40,22 @@ from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_ta
40
40
  from _common.decision import hw_preset_video, restate_last_swap
41
41
  from _common.emit import note, record_skip, warn
42
42
  from check import SPECS as PLATFORMS, measure_loudness
43
- from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
43
+ from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
44
44
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
45
45
  # from a literal restated here: before 1.14 they were typed twice and the facebook preset had
46
46
  # already drifted (no duration cap against the table's 14400 s).
47
+
48
+ def _loudness_step(ok: bool, platform: str, spec: dict, m: dict) -> dict:
49
+ """export's loudness verification step: the platform's targets, what was measured, the
50
+ difference and the tolerance each was held to (docs/contract.md, 2.18 keys)."""
51
+ expected = {"lufs": spec["lufs"], "tp_max": spec["tp"]}
52
+ measured = {"lufs": m["lufs"], "tp": m["tp"]}
53
+ delta = {"lufs": round(m["lufs"] - spec["lufs"], 2) if math.isfinite(m["lufs"]) else None,
54
+ "tp": round(m["tp"] - spec["tp"], 2) if math.isfinite(m["tp"]) else None}
55
+ return {"step": "loudness", "ok": ok, "platform": platform, "property": "loudness",
56
+ "expected": expected, "measured": measured, "delta": delta,
57
+ "tolerance": {"lufs": spec["lufs_tol"], "tp": TP_TOLERANCE_DB}, "provenance": "specification"}
58
+
47
59
  def _from_table(dest: str, **over) -> Dict:
48
60
  """A preset's w/h/max read from PLATFORMS[dest], with the encoder settings given here.
49
61
 
@@ -268,9 +280,9 @@ def main() -> int:
268
280
  if not m:
269
281
  _loudness_unmeasured(extra, notes, platform, spec)
270
282
  if m:
271
- ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
283
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
272
284
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
273
- extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
285
+ extra["verification"] = [_loudness_step(ok, platform, spec, m)]
274
286
  if not math.isfinite(m["lufs"]):
275
287
  # a silent output measures -inf LUFS: no gain reaches the target, so recommending
276
288
  # (or running, under --normalize) loudness.py only returns "nothing to normalise"
@@ -305,9 +317,9 @@ def main() -> int:
305
317
  _loudness_unmeasured(extra, notes, platform, spec, normalized=True)
306
318
  ok = True # nothing more to say about a level that was not measured
307
319
  else:
308
- ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
320
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
309
321
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
310
- extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
322
+ extra["verification"] = [_loudness_step(ok, platform, spec, m)]
311
323
  if not ok:
312
324
  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'}")
313
325
  warn(notes[-1], "loudness_outside_spec", platform=platform, lufs=m["lufs"], tp=m["tp"], normalized=True)
@@ -323,6 +335,8 @@ def main() -> int:
323
335
  notes.append(f"[dry-run] --normalize: loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} would run on the written file if it misses {platform}'s spec")
324
336
  if notes:
325
337
  extra["notes"] = notes
338
+ # the delivery keeps the source's picture and, except as a GIF, its sound
339
+ extra["expect_streams"] = {"video": bool(meta.get("video")), "audio": has_audio and args.preset != "gif"}
326
340
  emit(output, **extra)
327
341
  return 0
328
342
 
package/scripts/join.py CHANGED
@@ -565,11 +565,21 @@ def main() -> int:
565
565
  verification = []
566
566
  if expected is not None and measured is not None and not STATE.dry_run:
567
567
  tol = max(1.5 / fps, 0.05)
568
- verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
569
- "measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
568
+ verification.append({"step": "duration", "property": "video_duration", "ok": abs(measured - expected) <= tol,
569
+ "measured": round(measured, 3), "expected": expected,
570
+ "delta": round(measured - expected, 3), "tolerance": round(tol, 3),
571
+ "provenance": "input_premeasurement"})
572
+ # a clip with no audio stream is joined over generated silence: the join is right to do that, but
573
+ # the report must say which clips' sound is not theirs (the paper's join finding: absence was
574
+ # filled and nothing in the result showed it)
575
+ substituted = [p for p, m in zip(args.inputs, metas) if not m.get("audio")]
576
+ if substituted:
577
+ STATE_NOTES.append(f"{len(substituted)} clip(s) had no audio stream; their span of the join is generated "
578
+ "silence: " + ", ".join(os.path.basename(p) for p in substituted))
570
579
  info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
571
580
  emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
572
- verification=verification,
581
+ verification=verification, expect_streams={"video": True, "audio": True},
582
+ audio_substituted=substituted,
573
583
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
574
584
  return 0
575
585
 
@@ -19,6 +19,7 @@ import os
19
19
  import re
20
20
  import sys
21
21
 
22
+ from _platforms import TP_TOLERANCE_DB
22
23
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
23
24
 
24
25
 
@@ -128,7 +129,7 @@ def main() -> int:
128
129
  # integrated loudness -- reported, never hidden.
129
130
  ceiling, rounds = args.tp, 0
130
131
  steps = [] if bitrate_pinned or ext in AUDIO_CODECS and "aac" not in AUDIO_CODECS[ext] else [b for b in ("256k", "320k") if _kbps(b) > _kbps(bitrate)]
131
- while not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1 and rounds < 5:
132
+ while not after.get("silent") and float(after["input_tp"]) > args.tp + TP_TOLERANCE_DB and rounds < 5:
132
133
  rounds += 1
133
134
  overshoot = float(after["input_tp"]) - args.tp
134
135
  if steps:
@@ -152,7 +153,7 @@ def main() -> int:
152
153
  result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
153
154
  f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
154
155
  f"higher --audio-bitrate keeps both")
155
- if not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1:
156
+ if not after.get("silent") and float(after["input_tp"]) > args.tp + TP_TOLERANCE_DB:
156
157
  die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {args.tp:g}); "
157
158
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
158
159
  kind="verification", output=output, result=result,
@@ -161,8 +162,12 @@ def main() -> int:
161
162
  measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
162
163
  targets={"lufs": args.lufs, "tp": args.tp, "lra": args.lra},
163
164
  verification=[{"step": "loudness",
164
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + 0.1),
165
- "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp}])
165
+ "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
166
+ "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
167
+ "property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
168
+ "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
169
+ "delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
170
+ "tolerance": {"lufs": 1.0, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}])
166
171
  return 0
167
172
 
168
173