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 +1 -1
- package/SKILL.md +1 -1
- package/docs/contract.md +5 -3
- package/package.json +1 -1
- package/references/scripts.md +20 -1
- package/scripts/_common/decision.py +6 -2
- package/scripts/_common/probe.py +92 -0
- package/scripts/_common/runner.py +28 -0
- package/scripts/_contract.py +5 -3
- package/scripts/check.py +215 -1
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.
|
|
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.
|
|
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)
|
|
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
|
+
"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": [
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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")
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
-
"
|
|
626
|
-
"
|
|
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:
|