ffmpeg-skill 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -269,7 +269,7 @@ These are the rules the skill file gives the agent and the code enforces.
269
269
  |---|---|
270
270
  | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `dnxhr-lb`/`sq`/`hq`/`hqx`/`444`, the alpha masters `prores4444`/`prores4444xq`/`qtrle`/`vp9-alpha`/`hap-alpha` (alpha read back), `key-fill` (a switcher's fill + key pair), `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR or are an editing master (`prores`, `dnxhr-*`: the source's tags kept, codec, profile and pixel format read back) (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
271
271
  | `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
272
- | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
272
+ | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference, and with `--expect-changed SPAN\|none` fails when the picture changed outside the spans the caller declared; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
273
273
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
274
274
 
275
275
  **Orchestration**
package/SKILL.md CHANGED
@@ -155,7 +155,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
155
155
  | "the recording is corrupted" | `export.py in.mp4 --preset copy --repair` |
156
156
  | "a small proxy file" | `proxy.py in.mp4 --width 640` |
157
157
  | "is this OK to upload?" | `check.py final.mp4 --platform reels` |
158
- | "how much quality was lost" | `check.py final.mp4 --compare master.mp4` |
158
+ | "how much quality was lost" / "did anything else change?" | `check.py final.mp4 --compare master.mp4` / `check.py new.mp4 --compare old.mp4 --expect-changed 12-18` (or `none`) |
159
159
  | "is it broadcast legal?" | `check.py final.mp4 --levels ebu-r103` (clamp: `color.py in.mp4 --correct --legalize ebu-r103`) |
160
160
  | "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 -o report.html` |
161
161
  | "title card", "Ken Burns on a photo" | `insert.py title.png --duration 3` (`--zoom in --pan right`) |
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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.3.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.4.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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.3.0", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.4.0", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -231,7 +231,9 @@ the loudness pass or, when loudness is skipped, one volumedetect pass). `check -
231
231
  (opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
232
232
  from one decode pass, `dropouts` (2.17) from one audio-only pass, and `flashes` and `first frame` (2.23) from one
233
233
  video-only pass and a one-frame decode; without it the row set is unchanged.
234
- `--compare REF` adds `similarity` (SSIM/PSNR against a reference) and `--expect-identity SIZE:MTIME_NS`
234
+ `--compare REF` adds `similarity` (SSIM/PSNR against a reference); with it, `--expect-changed
235
+ SPAN[@X,Y,W,H]|none` adds `change_scope`, which FAILs on a change outside the declared spans and when
236
+ the two files could not be compared, and WARNs when a declared span did not change. `--expect-identity SIZE:MTIME_NS`
235
237
  adds `identity`, against the `output_identity` every writing tool reports (2.24). `--levels
236
238
  bt709|ebu-r103` adds `legal levels` (the share of luma and chroma samples outside the legal range,
237
239
  counted in every frame; FAIL when one frame passes `--levels-tolerance`, 1 %) and the `levels`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -1210,7 +1210,7 @@ measurements that combine with each other and with `--beats`/`--highlights`:
1210
1210
 
1211
1211
  ### check.py — pre-delivery compliance
1212
1212
  ```
1213
- check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--compare REF] [--levels bt709|ebu-r103 [--levels-window S] [--levels-tolerance PCT]] [--expect-identity SIZE:MTIME_NS] [--json]
1213
+ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--compare REF [--expect-changed SPAN[@X,Y,W,H]|none]... [--change-floor N]] [--levels bt709|ebu-r103 [--levels-window S] [--levels-tolerance PCT]] [--expect-identity SIZE:MTIME_NS] [--json]
1214
1214
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
1215
1215
  ```
1216
1216
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -1239,6 +1239,25 @@ someone else's file. `--compare REF` (2.24) measures the picture against a refer
1239
1239
  the worst frame and its time, and the mean PSNR (`identical` when every pixel matches); WARN under a
1240
1240
  mean SSIM of 0.98. Frames of another size are not compared (WARN, "not compared"); a different frame
1241
1241
  count is compared pair by pair and named in `notes`, since a dropped frame shifts every later pair.
1242
+ `--expect-changed` (with `--compare`) declares where the picture is allowed to differ from the
1243
+ reference, and a `change_scope` row grades the measurement against it: `--expect-changed 12-18`
1244
+ (a span in this file's time, both ends included, repeatable), `12-18@0,880,1920,200` (only that
1245
+ rectangle of displayed pixels during the span) or `none`. A change anywhere else is a FAIL with
1246
+ its span, frame count and place, and a `look.py --compare` command at its start; a declared span
1247
+ in which nothing changed is a WARN (the edit probably did not apply); two files that are not
1248
+ encoded alike (codec, pixel format or frame rate) are a WARN and a note. **When the two cannot be
1249
+ compared (another frame size, another frame count, an unreadable frame count or a failed pass) the
1250
+ row is FAIL**, since "not measured" must not read as "nothing else changed". The measure: both
1251
+ pictures as 8-bit RGB, subtracted, each frame area-scaled to 32x18 tiles; a tile changed when its
1252
+ largest channel is above the floor (`--change-floor`, default 24 of 255). The JSON
1253
+ `change_scope` document has `declared`, `observed`, `undeclared`, `declared_unchanged`, `floor`,
1254
+ `tile`, `frames` and `quiet_peak` (the largest tile that stayed under the floor: how close the
1255
+ unchanged part came). Limits: a change much smaller than a tile is diluted (a full-contrast
1256
+ change needs about a tenth of a tile, 19x19 px at 1080p); a faint shift over the whole picture
1257
+ stays under the floor in every tile, and `similarity` is the row for that; it compares the
1258
+ picture only; an edit that changes the length cannot be paired and FAILs as not compared. Compare
1259
+ two renders made with the same encoder settings: a different preset alone moved every frame's
1260
+ largest tile to 11 in the measurements behind the default.
1242
1261
  `--content` (opt-in, one decode pass) adds three rows: `black` (share of the duration that is
1243
1262
  black: WARN > 10%, FAIL >= 95%), `frozen` (longest frozen span: WARN > max(3 s, 30% of the
1244
1263
  duration), FAIL when it covers >= 95%, i.e. the whole video) and `silence` (share below
@@ -179,8 +179,8 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
179
179
  fps = float(rate)
180
180
  except ValueError:
181
181
  raise ValueError(f"bad @fps suffix in '{value}' (expected a number such as @29.97)")
182
- if fps <= 0:
183
- raise ValueError(f"bad @fps suffix in '{value}': the rate must be positive")
182
+ if not math.isfinite(fps) or fps <= 0:
183
+ raise ValueError(f"bad @fps suffix in '{value}': the rate must be a positive, finite number")
184
184
  if len(v.split(":")) != 4:
185
185
  raise ValueError(f"'{value}': the @fps suffix belongs to an hh:mm:ss:ff timecode, not to seconds or mm:ss")
186
186
  parts = v.split(":")
@@ -208,6 +208,10 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
208
208
  except ValueError:
209
209
  # not the interpreter's "could not convert string to float: 'zz'" (review 9)
210
210
  raise ValueError(f"'{value}': not a time")
211
+ # float() reads "nan", "inf" and "1e999": none of them is a time, and each got past the
212
+ # comparisons that bound a time (nan compares false with everything)
213
+ if not math.isfinite(total):
214
+ raise ValueError(f"'{value}': not a time (it is not finite)")
211
215
  return total
212
216
 
213
217
 
@@ -1328,3 +1328,95 @@ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
1328
1328
  mean_psnr = psnr.group(1) if psnr else None
1329
1329
  return {"ssim": float(ssim.group(1)), "worst_ssim": worst[1], "worst_frame": worst[0] - 1, "frames": len(frames),
1330
1330
  "psnr": None if mean_psnr is None else (float("inf") if mean_psnr == "inf" else float(mean_psnr))}
1331
+
1332
+
1333
+ # check.py --compare --expect-changed (#439): where the picture differs from the reference.
1334
+ CHANGE_GRID = (32, 18) # tiles across and down: one is 60x60 px at 1080p
1335
+ CHANGE_FLOOR = 24 # a tile whose largest channel difference (0-255, area mean) is above this changed
1336
+
1337
+
1338
+ def change_tiles(path: str, reference: str, floor: int, grid: "Tuple[int, int]" = CHANGE_GRID,
1339
+ squares: "Tuple[str, str]" = ("", "")) -> "Optional[Dict[str, Any]]":
1340
+ """Which tiles of which frames of `path` differ from `reference`, frame i against frame i in
1341
+ decode order (as compare_pictures() pairs them; the caller checks sizes and frame counts).
1342
+ Both pictures go to 8-bit RGB planes, are subtracted, and each frame of the difference is
1343
+ area-scaled to `grid`; a tile's value is the largest of its three channels. `squares` is each
1344
+ file's square_pixels() filter, so the grid covers the displayed picture (display_size()) and a
1345
+ caller's rectangle in displayed pixels names the tiles it means on a non-square-pixel file.
1346
+ {frames, changed {frame index: a bit mask of the tiles above `floor`, bit column + row * columns;
1347
+ change_mask_tiles() lists them}, peaks {frame index: its largest tile}, quiet_peak (the largest
1348
+ tile at or under the floor)}. A mask and not a list: two unrelated hours differ in every tile
1349
+ of every frame, which as tuples is tens of millions of objects.
1350
+ None when the pass fails, times out of its bytes mid-frame, or decodes no frame: nothing was
1351
+ compared, which the caller must not read as "nothing changed"."""
1352
+ import subprocess
1353
+ import threading
1354
+ from _common import runner as _runner
1355
+ gw, gh = grid
1356
+ sq_a, sq_b = (s + "," if s else "" for s in squares)
1357
+ graph = (f"[0:V:0]{sq_a}settb=1/1000,setpts=N,format=gbrp[a];[1:V:0]{sq_b}settb=1/1000,setpts=N,format=gbrp[b];"
1358
+ f"[a][b]blend=all_mode=difference:shortest=1,scale={gw}:{gh}:flags=area[o]")
1359
+ # passthrough: the frames carry index timestamps, and a constant-rate muxer would drop
1360
+ # nearly all of them as duplicates (12 of 300 came out without it)
1361
+ cmd = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-i", reference,
1362
+ "-filter_complex", graph, "-map", "[o]", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
1363
+ # read frame by frame as ffmpeg writes them: 1728 bytes a frame is 187 MB for an hour at
1364
+ # 30 fps, and only the frames with a changed tile are kept. Same wall-clock limit as run_analysis()
1365
+ _runner._refuse_url_media(cmd)
1366
+ limit = _runner._limit_for(cmd)
1367
+ size, tiles = gw * gh * 3, gw * gh
1368
+ changed: Dict[int, int] = {}
1369
+ peaks: Dict[int, int] = {}
1370
+ quiet, n, pending = 0, 0, b""
1371
+ child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, **_runner.child_kwargs())
1372
+ _runner._watch(child, cmd)
1373
+ expired = threading.Event()
1374
+
1375
+ def _expire() -> None:
1376
+ expired.set()
1377
+ child.kill()
1378
+
1379
+ timer = threading.Timer(limit, _expire) if limit else None
1380
+ if timer:
1381
+ timer.daemon = True
1382
+ timer.start()
1383
+ try:
1384
+ while True:
1385
+ chunk = child.stdout.read(size * 64)
1386
+ if not chunk:
1387
+ break
1388
+ pending += chunk
1389
+ whole = len(pending) - len(pending) % size
1390
+ for i in range(0, whole, size):
1391
+ frame = pending[i:i + size]
1392
+ top = max(frame)
1393
+ if top <= floor:
1394
+ quiet = max(quiet, top)
1395
+ else:
1396
+ mask = 0
1397
+ for t in range(tiles):
1398
+ value = max(frame[3 * t:3 * t + 3])
1399
+ if value > floor:
1400
+ mask |= 1 << t
1401
+ elif value > quiet:
1402
+ quiet = value
1403
+ changed[n], peaks[n] = mask, top
1404
+ n += 1
1405
+ pending = pending[whole:]
1406
+ child.wait()
1407
+ finally:
1408
+ if timer:
1409
+ timer.cancel()
1410
+ _runner._unwatch(child)
1411
+ child.stdout.close()
1412
+ if expired.is_set():
1413
+ _runner._timed_out(cmd, limit or 0)
1414
+ if child.returncode != 0 or not n or pending:
1415
+ return None
1416
+ return {"frames": n, "changed": changed, "peaks": peaks, "quiet_peak": quiet}
1417
+
1418
+
1419
+ def change_mask_tiles(mask: int, grid: "Tuple[int, int]" = CHANGE_GRID) -> "List[Tuple[int, int]]":
1420
+ """change_tiles()'s bit mask as [(column, row)], row by row."""
1421
+ gw = grid[0]
1422
+ return [(t % gw, t // gw) for t in range(grid[0] * grid[1]) if mask >> t & 1]
@@ -386,9 +386,37 @@ class Context:
386
386
  STATE = Context()
387
387
 
388
388
 
389
+ def finite_float(text: str) -> float:
390
+ """argparse's `float`, minus nan and inf. Python's float() reads "nan", "inf" and "1e999", and
391
+ no flag here means any of them: they reached int() as a traceback, ffmpeg as "Invalid
392
+ argument" (kind ffmpeg, as if the build were at fault), or a comparison that is always false
393
+ and so skipped the check it guarded. add_common() registers this for every `type=float`."""
394
+ import argparse
395
+ import math
396
+ value = float(text)
397
+ if not math.isfinite(value):
398
+ raise argparse.ArgumentTypeError(f"{text!r} is not a finite number")
399
+ return value
400
+
401
+
389
402
  def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
390
403
  """Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
391
404
  preset decides the encoder (export.py): it must not advertise --codec/--quality in its schema."""
405
+ # one place for every tool: argparse looks a `type=` up in this registry at parse time, so the
406
+ # flags a tool added before this call are covered as well
407
+ ap.register("type", float, finite_float)
408
+ usage_error = ap.error
409
+
410
+ def error(message: str) -> None:
411
+ # a --json caller gets a failure document for this refusal, as for every other bad value a
412
+ # tool checks itself (export.py --max-mb nan answered with one before this was shared);
413
+ # anything else argparse rejects stays its usage error
414
+ if "is not a finite number" in message and {"--json", "--json-brief"} & set(sys.argv[1:]):
415
+ STATE.json = True
416
+ die(message.replace("argument ", "", 1))
417
+ usage_error(message)
418
+
419
+ ap.error = error # type: ignore[method-assign]
392
420
  g = ap.add_argument_group("agent options")
393
421
  g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
394
422
  g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
@@ -209,7 +209,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
209
209
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default); the audio row's volumedetect silence test when loudness is skipped"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"},
210
210
  {"capability": "filter:blackdetect", "when": "--content (black row)"}, {"capability": "filter:freezedetect", "when": "--content (frozen row)"},
211
211
  {"capability": "filter:silencedetect", "when": "--content (silence row)"},
212
- {"capability": "filter:signalstats", "when": "--levels (legal levels row)"}, {"capability": "filter:lutyuv", "when": "--levels (legal levels row)"}],
212
+ {"capability": "filter:signalstats", "when": "--levels (legal levels row)"}, {"capability": "filter:lutyuv", "when": "--levels (legal levels row)"},
213
+ {"capability": "filter:blend", "when": "--expect-changed (change_scope row)"}],
213
214
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
214
215
  "scenes": dict(role="analysis", inputs=["video asset", "audio asset (after 3.2.0: one scene; --beats, --audio-peaks, --speech, --highlights; --sheet and --shots need a picture)"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
215
216
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"},
@@ -622,8 +623,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
622
623
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
623
624
  "input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
624
625
  "levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
625
- "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), or when --compare paired a different number of frames than the reference has"},
626
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
626
+ "change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
627
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
628
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
627
629
  elif name == "caption":
628
630
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
629
631
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
package/scripts/check.py CHANGED
@@ -44,7 +44,7 @@ from fractions import Fraction
44
44
  from typing import Any, Dict, List, Optional, Tuple
45
45
 
46
46
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
47
- from _common.probe import (LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
47
+ from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
48
48
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
49
49
  from _common.emit import file_identity
50
50
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
@@ -112,6 +112,103 @@ def display_size(v: Dict[str, Any]) -> "Tuple[int, int]":
112
112
  return (h, w) if int(v.get("rotation") or 0) % 180 == 90 else (w, h)
113
113
 
114
114
 
115
+ def parse_expect_changed(values: "List[str]") -> "List[Dict[str, Any]]":
116
+ """--expect-changed values -> [{start, end, rect}] (rect [x, y, w, h] in displayed pixels, or
117
+ None for the whole frame); [] for `none`. Refuses anything it cannot read: a declaration that
118
+ was silently dropped would turn into a FAIL the caller cannot explain, or a PASS it did not earn."""
119
+ from _common.decision import parse_time
120
+ import math
121
+ if any(v.strip().lower() == "none" for v in values):
122
+ if len(values) > 1:
123
+ die("--expect-changed none says the picture must not change at all; it cannot be combined with a span")
124
+ return []
125
+ spans = []
126
+ for raw in values:
127
+ span, _, rect_text = raw.strip().partition("@")
128
+ start_text, sep, end_text = span.partition("-")
129
+ try:
130
+ if not sep or not start_text or not end_text:
131
+ raise ValueError(raw)
132
+ start, end = parse_time(start_text), parse_time(end_text)
133
+ except (ValueError, SystemExit):
134
+ die(f"--expect-changed takes START-END (seconds or H:MM:SS), optionally @X,Y,W,H, or none; got {raw!r}")
135
+ if not (math.isfinite(start) and math.isfinite(end)) or start < 0 or end < start:
136
+ die(f"--expect-changed {raw!r}: a span starts at 0 or later and ends at or after its start")
137
+ rect = None
138
+ if "@" in raw:
139
+ try:
140
+ rect = [int(n) for n in rect_text.split(",")]
141
+ except ValueError:
142
+ rect = []
143
+ if len(rect) != 4 or rect[0] < 0 or rect[1] < 0 or rect[2] <= 0 or rect[3] <= 0:
144
+ die(f"--expect-changed {raw!r}: the rectangle is X,Y,W,H in displayed pixels, whole numbers, W and H above 0")
145
+ spans.append({"start": start, "end": end, "rect": rect})
146
+ return spans
147
+
148
+
149
+ def _runs(frames: "List[int]") -> "List[Tuple[int, int]]":
150
+ """Sorted frame indices -> [(first, last)] of consecutive runs."""
151
+ out: "List[Tuple[int, int]]" = []
152
+ for n in frames:
153
+ if out and n == out[-1][1] + 1:
154
+ out[-1] = (out[-1][0], n)
155
+ else:
156
+ out.append((n, n))
157
+ return out
158
+
159
+
160
+ def change_scope(tiles: "Dict[str, Any]", declared: "List[Dict[str, Any]]", times: "List[float]",
161
+ size: "Tuple[int, int]", floor: int) -> "Dict[str, Any]":
162
+ """The measured tiles against the declaration. `times` has one more entry than there are
163
+ frames (frame n is shown from times[n] until times[n+1]). A declared span is closed at both
164
+ ends and widened to whole frames: frame n is inside when its interval touches the span.
165
+ Inside a span with a rectangle, only the tiles the rectangle touches may change."""
166
+ gw, gh = CHANGE_GRID
167
+ w, h = size
168
+ tw, th = w / gw, h / gh
169
+
170
+ def allowed_mask(rect: "Optional[List[int]]") -> int:
171
+ if rect is None:
172
+ return (1 << gw * gh) - 1
173
+ x, y, rw, rh = rect
174
+ return sum(1 << (c + r * gw) for c in range(gw) for r in range(gh)
175
+ if c * tw < x + rw and (c + 1) * tw > x and r * th < y + rh and (r + 1) * th > y)
176
+
177
+ spans = [dict(d, mask=allowed_mask(d["rect"]), seen=False) for d in declared]
178
+
179
+ def describe(frames: "Dict[int, int]") -> "List[Dict[str, Any]]":
180
+ out = []
181
+ for first, last in _runs(sorted(frames)):
182
+ union = 0
183
+ for n in range(first, last + 1):
184
+ union |= frames[n]
185
+ hit = change_mask_tiles(union)
186
+ c0, c1 = min(c for c, _ in hit), max(c for c, _ in hit)
187
+ r0, r1 = min(r for _, r in hit), max(r for _, r in hit)
188
+ x0, y0, x1, y1 = int(c0 * tw), int(r0 * th), int(round((c1 + 1) * tw)), int(round((r1 + 1) * th))
189
+ out.append({"start": round(times[first], 3), "end": round(times[last + 1], 3), "frames": last - first + 1,
190
+ "rect": [x0, y0, x1 - x0, y1 - y0], "peak": max(tiles["peaks"][n] for n in range(first, last + 1))})
191
+ return out
192
+
193
+ outside: "Dict[int, int]" = {}
194
+ for n, changed in tiles["changed"].items():
195
+ left = changed
196
+ for d in spans:
197
+ if times[n] <= d["end"] and times[n + 1] > d["start"]:
198
+ # against the frame's own tiles, not what earlier declarations left of them: two
199
+ # declarations that overlap would otherwise depend on their order, and the later
200
+ # one would read as "nothing changed there"
201
+ if changed & d["mask"]:
202
+ d["seen"] = True
203
+ left &= ~d["mask"]
204
+ if left:
205
+ outside[n] = left
206
+ return {"declared": [{"start": d["start"], "end": d["end"], "rect": d["rect"]} for d in declared],
207
+ "observed": describe(tiles["changed"]), "undeclared": describe(outside),
208
+ "declared_unchanged": [{"start": d["start"], "end": d["end"], "rect": d["rect"]} for d in spans if not d["seen"]],
209
+ "floor": floor, "tile": [gw, gh], "frames": tiles["frames"], "quiet_peak": tiles["quiet_peak"]}
210
+
211
+
115
212
  def _span_total(spans: Any) -> float:
116
213
  return sum(max(0.0, e - s) for s, e in spans)
117
214
 
@@ -158,6 +255,102 @@ def levels_row(args: argparse.Namespace, v: Dict[str, Any], row: Any) -> "Option
158
255
  return doc
159
256
 
160
257
 
258
+ def encoding_differences(v: Dict[str, Any], rv: Dict[str, Any]) -> "List[str]":
259
+ """What makes two video streams "not encoded alike" for --expect-changed: part of a measured
260
+ difference is then the encoder's. The stored size is the file's own width and height, before
261
+ rotation: 1080x1920 turned to 1920x1080 against a stored 1920x1080 is the same displayed
262
+ picture stored another way (as is 1440x1080 at 4:3), compared and said."""
263
+ def stored(x: Dict[str, Any]) -> str:
264
+ return f"{x.get('width')}x{x.get('height')}"
265
+ pairs = (("codec", v.get("codec"), rv.get("codec")), ("pix_fmt", v.get("pix_fmt"), rv.get("pix_fmt")),
266
+ ("fps", v.get("fps"), rv.get("fps")), ("stored size", stored(v), stored(rv)))
267
+ return [f"{k} {a} against {b}" for k, a, b in pairs if a != b]
268
+
269
+
270
+ def change_scope_row(args: Any, declared: "List[Dict[str, Any]]", meta: Dict[str, Any], v: Dict[str, Any], row: Any) -> "Optional[Dict[str, Any]]":
271
+ """--expect-changed: measure where the picture differs from the reference and grade it against
272
+ the declaration. A comparison that could not be made is a FAIL, never a WARN or a PASS: the
273
+ flag exists to stop a false "nothing else changed", and a check that did not run would be one."""
274
+ want = "a change only where declared" if declared else "no change in the picture"
275
+ look = f"look.py --compare {args.compare} --at "
276
+
277
+ def not_compared(why: str, fix: str) -> None:
278
+ row("change_scope", "FAIL", f"not compared: {why}", want, fix,
279
+ reason="nothing was measured, so this does not say the rest of the picture is unchanged")
280
+
281
+ ref = probe(args.compare)
282
+ rv = ref.get("video") or {}
283
+ if not v or not rv:
284
+ return not_compared("one of the files has no picture", "--expect-changed needs a video stream in both files")
285
+ # the displayed picture: rotation applied and non-square pixels squared (a 1440x1080 file at
286
+ # 4:3 is 1920x1080), which is the frame a caller measures a rectangle on and the frame both
287
+ # pictures are brought to before they are subtracted
288
+ from _common.probe import display_size as shown_size, square_pixels
289
+ size = shown_size(v)
290
+ if size != shown_size(rv):
291
+ return not_compared("%dx%d against %dx%d (as displayed)" % (size + shown_size(rv)),
292
+ "compare two renders of the same frame size")
293
+ for d in declared:
294
+ if d["rect"] and (d["rect"][0] + d["rect"][2] > size[0] or d["rect"][1] + d["rect"][3] > size[1]):
295
+ # a rectangle that runs past the edge is a wrong size or a wrong file, and clipping it
296
+ # quietly would allow less than the caller believes it declared
297
+ die(f"--expect-changed: the rectangle {','.join(map(str, d['rect']))} does not fit inside the {size[0]}x{size[1]} picture")
298
+ origin = float(meta.get("start_time") or 0.0)
299
+ pts = presentation_times(args.input, float(v.get("start_time") or 0.0))
300
+ ref_pts = presentation_times(args.compare, float(rv.get("start_time") or 0.0))
301
+ nout, nref = v.get("nb_frames") or len(pts) or None, rv.get("nb_frames") or len(ref_pts) or None
302
+ if not nout or not nref:
303
+ return not_compared("a frame count could not be read", "remux the file that has no readable frame count (export.py --preset copy)")
304
+ if int(nout) != int(nref):
305
+ return not_compared(f"{nout} frames against the reference's {nref}",
306
+ "an edit that changes the length cannot be paired frame by frame; compare the parts separately")
307
+ tiles = change_tiles(args.input, args.compare, args.change_floor, squares=(square_pixels(v), square_pixels(rv)))
308
+ if tiles is None:
309
+ return not_compared("the comparison pass failed", look + "0")
310
+ n = tiles["frames"]
311
+ fps = float(v.get("fps") or 0.0)
312
+ if pts and len(pts) >= n:
313
+ times = [max(0.0, t - origin) for t in pts[:n]]
314
+ times.append(times[-1] + ((times[-1] - times[-2]) if n > 1 else (1.0 / fps if fps else 0.0)))
315
+ else:
316
+ # a span is placed by each frame's own time, and a change is reported at one; an average
317
+ # rate would put a frame near a span's edge on the wrong side of it, and is simply wrong
318
+ # on variable-rate video. `none` too: its FAIL names where the change is
319
+ return not_compared("the frame times could not be read", "remux the file (export.py --preset copy) and check it again")
320
+ doc = change_scope(tiles, declared, times, size, args.change_floor)
321
+
322
+ def span(d: "Dict[str, Any]") -> str:
323
+ return f"{d['start']:.2f}-{d['end']:.2f}"
324
+
325
+ def place(d: "Dict[str, Any]") -> str:
326
+ x, y, w, h = d["rect"]
327
+ return f"{span(d)} ({d['frames']} frame{'s' if d['frames'] != 1 else ''}, around x {x}-{x + w}, y {y}-{y + h})"
328
+
329
+ idle = "; ".join(f"declared {span(d)} but nothing changed there" for d in doc["declared_unchanged"])
330
+ differ = encoding_differences(v, rv)
331
+ if differ:
332
+ STATE_NOTES.append("--expect-changed: the two files are not encoded alike (" + ", ".join(differ) + "), so part of "
333
+ "what was measured is the encoder's difference, not the edit")
334
+ floor_note = f"floor {doc['floor']}, largest unchanged tile {doc['quiet_peak']}"
335
+ if doc["undeclared"]:
336
+ first = doc["undeclared"][0]
337
+ more = len(doc["undeclared"]) - 1
338
+ value = "changed " + place(first) + (f" and {more} more span{'s' if more != 1 else ''}" if more else "") + ": not declared"
339
+ row("change_scope", "FAIL", value + (f"; {idle}" if idle else "") + f" ({floor_note})", want,
340
+ look + f"{first['start']:g}", reason="the picture changed where the edit was not meant to reach")
341
+ elif idle:
342
+ row("change_scope", "WARN", idle + f" ({floor_note})", want, "look at that span: the edit probably did not apply",
343
+ reason="a declared change was not found")
344
+ else:
345
+ seen = ", ".join(span(d) for d in doc["observed"]) or "nothing"
346
+ value = (f"changed {seen} inside the declared span{'s' if len(declared) != 1 else ''}; nothing else above the floor"
347
+ if declared else "no tile above the floor in any frame") + f" ({floor_note}, {n} frames)"
348
+ row("change_scope", "WARN" if differ else "PASS", value, want,
349
+ "compare two renders made with the same encoder settings" if differ else "",
350
+ reason="the two files are not encoded alike: " + ", ".join(differ) if differ else "")
351
+ return doc
352
+
353
+
161
354
  def main() -> int:
162
355
  STATE_NOTES.clear() # module state: a second main() in one process starts without the first's notes
163
356
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -183,6 +376,14 @@ def main() -> int:
183
376
  ap.add_argument("--compare", metavar="REFERENCE",
184
377
  help="also measure the picture against REFERENCE, frame by frame (SSIM and PSNR, one pass): a "
185
378
  "similarity row, WARN when the mean SSIM is under 0.98, with the worst frame's time")
379
+ ap.add_argument("--expect-changed", action="append", metavar="SPAN[@X,Y,W,H]|none",
380
+ help="with --compare: where the picture is allowed to differ from REFERENCE, as START-END in this "
381
+ "file's time (repeatable), optionally only inside a rectangle of displayed pixels, or `none`. A "
382
+ "change_scope row FAILs on a change anywhere else (with its time and place) or when the two "
383
+ "could not be compared, and WARNs when a declared span did not change")
384
+ ap.add_argument("--change-floor", type=int, default=CHANGE_FLOOR, metavar="N",
385
+ help="--expect-changed: a 60x60-ish tile (the frame in %dx%d) counts as changed when its largest "
386
+ "channel difference is above N of 255 (default %%(default)d)" % CHANGE_GRID)
186
387
  ap.add_argument("--expect-identity", metavar="SIZE:MTIME_NS",
187
388
  help="the output_identity a writing tool reported ({size}:{mtime_ns}): an identity row FAILs when "
188
389
  "the file at INPUT is not that file (a later run replaced it, or a failed run left an older one)")
@@ -211,6 +412,14 @@ def main() -> int:
211
412
  if not 0 <= args.levels_tolerance <= 100:
212
413
  die(f"--levels-tolerance {args.levels_tolerance:g} is outside 0..100 (percent of a frame's samples)")
213
414
 
415
+ declared_changes = None
416
+ if args.expect_changed:
417
+ if not args.compare:
418
+ die("--expect-changed says where the picture may differ from a reference, so it needs --compare REFERENCE")
419
+ declared_changes = parse_expect_changed(args.expect_changed)
420
+ if not 1 <= args.change_floor <= 254:
421
+ die(f"--change-floor {args.change_floor} is outside 1..254 (a difference of 0-255 per channel)")
422
+
214
423
  expected_identity = None
215
424
  if args.expect_identity is not None:
216
425
  # parsed before any measurement; "" (an unset shell variable) is malformed, not absent
@@ -478,6 +687,9 @@ def main() -> int:
478
687
  STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
479
688
  f"order, so an added or dropped frame shifts every later pair, and only the "
480
689
  f"first {min(int(nref), int(nout))} were compared")
690
+ scope_doc = None
691
+ if declared_changes is not None and not dry_run_input_pending(args.input) and not dry_run_input_pending(args.compare):
692
+ scope_doc = change_scope_row(args, declared_changes, meta, v, row)
481
693
  # read after every measurement, --compare's included, so a replacement at any point shows
482
694
  identity = None if dry_run_input_pending(args.input) else file_identity(args.input)
483
695
  if identity is not None and identity != identity_before and expected_identity is None:
@@ -520,6 +732,8 @@ def main() -> int:
520
732
  extra: Dict[str, Any] = {"notes": notes} if notes else {}
521
733
  if levels_doc is not None:
522
734
  extra["levels"] = levels_doc
735
+ if scope_doc is not None:
736
+ extra["change_scope"] = scope_doc
523
737
  if identity is not None:
524
738
  extra["input_identity"] = identity
525
739
  if failed: