ffmpeg-skill 3.2.3 → 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
@@ -20,6 +20,7 @@ No cloud · No API keys · Python standard library</p>
20
20
 
21
21
  <p align="center">
22
22
  <a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
23
+ <a href="https://scorecard.dev/viewer/?uri=github.com/kajisho5/ffmpeg-skill"><img src="https://api.scorecard.dev/projects/github.com/kajisho5/ffmpeg-skill/badge" alt="OpenSSF Scorecard"></a>
23
24
  <a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
24
25
  <a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-6.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 6.1, 7.1, 8 and 9 tested in CI"></a>
25
26
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
@@ -268,7 +269,7 @@ These are the rules the skill file gives the agent and the code enforces.
268
269
  |---|---|
269
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 |
270
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 |
271
- | `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`) |
272
273
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
273
274
 
274
275
  **Orchestration**
@@ -595,6 +596,16 @@ claude plugin marketplace add kajisho5/ffmpeg-skill # older v
595
596
  claude plugin install ffmpeg-skill@ffmpeg-skill # then install from it
596
597
  ```
597
598
 
599
+ The same repository installs as a plugin in two more hosts (checked against Codex CLI 0.167 and Gemini CLI 0.68):
600
+
601
+ ```bash
602
+ codex plugin marketplace add kajisho5/ffmpeg-skill # Codex: add the marketplace,
603
+ codex plugin add ffmpeg-skill@ffmpeg-skill # then the plugin
604
+ gemini extensions install https://github.com/kajisho5/ffmpeg-skill # Gemini CLI
605
+ ```
606
+
607
+ Those hosts find a plugin's skill only at `skills/ffmpeg-skill/SKILL.md`, a generated copy of the root `SKILL.md` (`.github/scripts/build_plugin_skill.py`; a test fails when it is stale). A Cursor plugin manifest (`.cursor-plugin/plugin.json`) is in the repository for the Cursor Marketplace; it follows Cursor's reference and has not been through their review yet.
608
+
598
609
  The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
599
610
 
600
611
  Without Node: clone this repository and copy `SKILL.md`, `package.json`, `scripts/`, `templates/`, `references/`, `docs/` and `mcp/` into your agent's skills directory (the same set `npx ffmpeg-skill` installs; without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown).
@@ -629,7 +640,7 @@ FFmpeg itself:
629
640
 
630
641
  ## Stability
631
642
 
632
- 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
643
+ 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. Releases are batched: related fixes and features ship together as one version rather than one version per merged pull request, so a dependent project sees fewer, larger steps. Pin a tagged version, not `main`. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
633
644
 
634
645
  ## Development
635
646
 
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/bin/install.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * npx ffmpeg-skill --uninstall # remove from the selected targets
15
15
  * npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
16
16
  * npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
17
+ * npx ffmpeg-skill mcp # stdio MCP server (python3 mcp/server.py); registry clients pass `mcp`
17
18
  *
18
19
  * Already installed? re-run `npx ffmpeg-skill` to refresh ~/.claude/skills/ffmpeg-skill
19
20
  * Copies are not updated automatically.
@@ -98,7 +99,8 @@ function warnMissingPython(platform, spawn, warn) {
98
99
  }
99
100
 
100
101
  function main() {
101
- // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
102
+ // `contract` / `doctor` are answered by scripts/_contract.py. `mcp` is the stdio server
103
+ // (server.json's packageArguments). Everything else installs.
102
104
  if (args[0] === 'contract' || args[0] === 'doctor') {
103
105
  const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
104
106
  if (py.missing) {
@@ -107,6 +109,14 @@ if (args[0] === 'contract' || args[0] === 'doctor') {
107
109
  }
108
110
  process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
109
111
  }
112
+ if (args[0] === 'mcp') {
113
+ const py = runWithPython([path.join(ROOT, 'mcp', 'server.py'), ...args.slice(1)], process.platform, spawnSync);
114
+ if (py.missing) {
115
+ console.error(`error: could not find a Python 3.10+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.10+ and ensure it is on PATH.`);
116
+ process.exit(127);
117
+ }
118
+ process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
119
+ }
110
120
 
111
121
  const home = os.homedir();
112
122
  const targets = [];
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.2.3`) | 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.2.3", "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`
@@ -918,6 +920,19 @@ that is so: once a tool has written its deliverable and runs a further step on i
918
920
  says it is kept without the step that was stopped, and the document carries it as `output`
919
921
  (3.1.6, #415).
920
922
 
923
+ **Source carry-over.** Every tool that writes a media file compares what its first input carried
924
+ besides the picture and the sound with the file it wrote, and says so in `source_carry:
925
+ {kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
926
+ `audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
927
+ `timecode`. The key is present only when the source had at least one of them, so a result for a
928
+ plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
929
+ or more inputs have a picture or sound (which one's items the output should carry is the tool's
930
+ choice). An item bound to a stream the output does not have at all is not compared (a sound-only
931
+ extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
932
+ it. A dropped `subtitles`, `chapters`, `cover_art` or `audio_channels` also records one
933
+ `source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
934
+ `dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
935
+
921
936
  **Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
922
937
  skipped -- is recorded as it happens (a stderr line too) and lands in the document as
923
938
  `diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.2.3",
3
+ "version": "3.4.0",
4
+ "mcpName": "io.github.kajisho5/ffmpeg-skill",
4
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.",
5
6
  "keywords": [
6
7
  "ffmpeg",
@@ -54,6 +54,13 @@ on a stream copy as on a re-encode
54
54
  (`cut.py` reports it: `dropped_audio_streams`, an `audio_tracks_dropped` warning). Keep ProRes masters
55
55
  at source colour: `export.py --preset prores` does not retag.
56
56
 
57
+ What else the source carried is compared on every write: `source_carry: {kept, dropped}` lists
58
+ subtitles, chapters, a cover picture, surround channels, `creation_time`, the audio language tag
59
+ and the start timecode, and a lost subtitle track, chapter list, cover picture or surround layout
60
+ is a `source_items_dropped` warning. Read it before telling the user the file is complete; to
61
+ carry the items over, use the tool that keeps them (`export.py` keeps subtitle tracks,
62
+ `metadata.py` writes chapters and tags).
63
+
57
64
  Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
58
65
  colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
59
66
  do not tag BT.709 on BT.2020 pixels.
@@ -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
@@ -69,8 +69,11 @@ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
69
69
  return bool(_RGB_PIX_FMT_RE.match(str(((meta or {}).get("video") or {}).get("pix_fmt") or "")))
70
70
 
71
71
 
72
- _MATRIX_FILTER_RE = re.compile(r"^(?:colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709|(?:format=rgb24,)?%s)$"
73
- % ("|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
72
+ # An odd-sized BT.601 source is padded to even for the colorspace filter and cropped back to its
73
+ # own size after it (bt709_matrix_filter): both wrap the conversion in the one -vf the merge finds.
74
+ _EVEN_PAD = "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0,"
75
+ _MATRIX_FILTER_RE = re.compile(r"^(?:(?:%s)?colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709(?:,crop=iw-[01]:ih-[01]:0:0:exact=1)?|(?:format=rgb24,)?%s)$"
76
+ % (re.escape(_EVEN_PAD), "|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
74
77
  _INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
75
78
 
76
79
 
@@ -102,7 +105,19 @@ def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
102
105
  return ("format=rgb24," if v.get("pix_fmt") == "pal8" else "") + rgb_to_bt709_filter()
103
106
  if matrix not in NON_BT709_MATRICES:
104
107
  return None
105
- return f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
108
+ # the colorspace filter refuses an odd width or height outright ("Invalid odd size"), and a
109
+ # 4:2:0 JPEG can have one (a phone screenshot, #437). The frame is padded by one row and/or
110
+ # column, converted, and cropped back to its own size: no source pixel is lost or resampled
111
+ # (a 4:2:0 frame's chroma planes are the same size either way), so a caller's own crop or
112
+ # scale sees the frame it measured. Nothing changes for an even-sized source. FFmpeg rotates
113
+ # a rotated source before the graph, so the odd axis is read after the same swap.
114
+ w, h = int(v.get("width") or 0), int(v.get("height") or 0)
115
+ if int(v.get("rotation") or 0) % 180 == 90:
116
+ w, h = h, w
117
+ conv = f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
118
+ if w % 2 or h % 2:
119
+ return f"{_EVEN_PAD}{conv},crop=iw-{w % 2}:ih-{h % 2}:0:0:exact=1"
120
+ return conv
106
121
 
107
122
 
108
123
  def bt709_label_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
@@ -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
 
@@ -163,6 +163,74 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
163
163
  ctx.notes.append(note_line)
164
164
 
165
165
 
166
+ # source_carry: the items compared, in report order. The first group is content a viewer or
167
+ # listener notices missing and raises the warning; the second is tags, listed but never warned
168
+ # about (every camera file has a creation_time, so a warning for it would fire on every run).
169
+ CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
170
+ CARRY_TAGS = ("creation_time", "audio_language", "timecode")
171
+ _CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
172
+ "audio_channels": "audio channels"}
173
+
174
+
175
+ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
176
+ """What the first input carried besides its picture and sound, and whether the written file
177
+ still has it (#401): {"kept": [...], "dropped": [...]}, or None when the source had none of
178
+ it or the two files are not comparable. Measured on the written file, so the tool, encoder or
179
+ FFmpeg version that dropped it does not matter. A report, not a check: `verified` is not
180
+ touched. Not compared: two or more picture or sound sources (which one's items the output
181
+ should carry is the tool's choice), an output that is not a media file, and items bound to a
182
+ stream the output does not have at all (a sound-only extract has no subtitle track or
183
+ timecode to lose; a picture-only output has no audio channels). Audio channels are an item
184
+ only for a surround source (more than two)."""
185
+ from .probe import MEDIA_EXT, _STILL_EXT, carry_inventory
186
+ first = getattr(STATE, "first_input", None)
187
+ if not first or not output:
188
+ return None
189
+ # a tool that reads its own output back (cut.py measures the cut it wrote) probed it as an
190
+ # input too: that is not a second source
191
+ own = os.path.realpath(output)
192
+ if any(len(set(getattr(STATE, name, None) or ()) - {own}) > 1 for name in ("video_inputs", "audio_inputs")):
193
+ return None
194
+ ext = os.path.splitext(output)[1].lower()
195
+ if ext not in MEDIA_EXT or ext in _STILL_EXT:
196
+ return None # a still, a contact sheet or an animated GIF holds none of these
197
+ src, out = carry_inventory(first[0]), carry_inventory(output)
198
+ if not src or not out or os.path.realpath(first[0]) == os.path.realpath(output):
199
+ return None
200
+ kept: List[str] = []
201
+ dropped: List[str] = []
202
+ for item in CARRY_CONTENT + CARRY_TAGS:
203
+ if not src.get(item):
204
+ continue
205
+ if item == "audio_channels" and src[item] <= 2:
206
+ continue # mono and stereo are what every tool writes; only a surround layout can be folded down
207
+ if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
208
+ continue
209
+ if item in ("audio_channels", "audio_language") and not out["has_audio"]:
210
+ continue
211
+ (kept if out.get(item, 0) >= src[item] else dropped).append(item)
212
+ if not kept and not dropped:
213
+ return None
214
+ said = set()
215
+ if extra.get("dropped_non_av_streams") or any(w.get("code") == "non_av_streams_dropped" for w in ctx.fallbacks):
216
+ said.add("subtitles") # the tool's own field already says the track went
217
+ loud = [i for i in dropped if i in CARRY_CONTENT and i not in said]
218
+ if loud:
219
+ parts = []
220
+ for i in loud:
221
+ if i == "audio_channels":
222
+ parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
223
+ else:
224
+ parts.append("its " + _CARRY_NAMES[i])
225
+ warn("the source carried " + ", ".join(parts) + " that this output does not have "
226
+ "(source_carry lists everything kept and dropped)",
227
+ "source_items_dropped", ctx=ctx, dropped=loud)
228
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
229
+ if note_line and note_line not in ctx.notes:
230
+ ctx.notes.append(note_line)
231
+ return {"kept": kept, "dropped": dropped}
232
+
233
+
166
234
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
167
235
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
168
236
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -710,6 +778,9 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
710
778
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
711
779
  _report_dropped_audio_tracks(meta, ctx)
712
780
  _report_dropped_hdr10_metadata(meta, ctx)
781
+ carry = source_carry(output, ctx, extra)
782
+ if carry and "source_carry" not in extra:
783
+ extra["source_carry"] = carry
713
784
  if ctx.json:
714
785
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
715
786
  if meta:
@@ -402,9 +402,30 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
402
402
  (out.get("video") or {}).get("hdr10_static"))
403
403
  if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
404
404
  STATE.source_timecode = (out["timecode"]["value"], duration)
405
+ # what a source carries besides its picture and sound, for emit()'s source_carry comparison:
406
+ # kept beside the probe, not in it, so no probe document changes
407
+ lang = (audio.get("tags") or {}).get("language") if audio else None
408
+ _CARRY[os.path.realpath(path)] = {
409
+ "subtitles": len(subs),
410
+ "chapters": len(raw.get("chapters") or []),
411
+ "cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
412
+ "creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
413
+ "audio_language": 1 if lang and lang.lower() != "und" else 0,
414
+ "timecode": 1 if out["timecode"].get("value") else 0,
415
+ "audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
416
+ "has_video": bool(video), "has_audio": bool(audio),
417
+ }
405
418
  return out
406
419
 
407
420
 
421
+ _CARRY: Dict[str, Dict[str, Any]] = {}
422
+
423
+
424
+ def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
425
+ """The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
426
+ return _CARRY.get(os.path.realpath(path)) if path else None
427
+
428
+
408
429
  def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
409
430
  """A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
410
431
  a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
@@ -1307,3 +1328,95 @@ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
1307
1328
  mean_psnr = psnr.group(1) if psnr else None
1308
1329
  return {"ssim": float(ssim.group(1)), "worst_ssim": worst[1], "worst_frame": worst[0] - 1, "frames": len(frames),
1309
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")
@@ -1067,9 +1095,14 @@ class _OutputLock:
1067
1095
  def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
1068
1096
  """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
1069
1097
  encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
1070
- with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
1098
+ with an even-dimension scale appended to its -vf chain (or a new -vf when the command had
1071
1099
  none); None when the failure is something else or the graph is a -filter_complex the
1072
- caller has to fix itself."""
1100
+ caller has to fix itself.
1101
+
1102
+ Appended, not prepended: what the encoder refuses is the frame the chain hands it, and a
1103
+ chain can make an even frame odd again (squaring non-square pixels to 636x1401, a conversion
1104
+ that restores the source's own size). Through 3.2.3 the scale went first, so caption.py on an
1105
+ odd-sized clip retried and failed the same way (#438)."""
1073
1106
  if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
1074
1107
  return None
1075
1108
  if "-filter_complex" in cmd:
@@ -1077,7 +1110,7 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
1077
1110
  new = list(cmd)
1078
1111
  if "-vf" in new:
1079
1112
  i = new.index("-vf") + 1
1080
- new[i] = EVEN_SCALE + "," + new[i]
1113
+ new[i] = new[i] + "," + EVEN_SCALE
1081
1114
  return new
1082
1115
  if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
1083
1116
  return None
@@ -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"},
@@ -472,6 +473,7 @@ DIAGNOSTIC_CODES = {
472
473
  # fonts and text
473
474
  "emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
474
475
  "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
476
+ "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
475
477
  "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
476
478
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
477
479
  "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
@@ -614,14 +616,16 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
614
616
  "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"},
615
617
  "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)"},
616
618
  "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"},
619
+ "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode. Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
617
620
  "diagnostics": DIAGNOSTICS_SCHEMA}
618
621
  extra: Dict[str, Any] = {}
619
622
  if name == "check":
620
623
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
621
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"},
622
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, ...})}"},
623
- "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"},
624
- "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"]}}}}}
625
629
  elif name == "caption":
626
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"},
627
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)"},
@@ -75,6 +75,11 @@ __all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_
75
75
  "text_width_em"]
76
76
 
77
77
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
78
+ # libass reads a force_style Alignment in the legacy SSA numbering (1-3 bottom, 5-7 top, 9-11
79
+ # middle), not the v4+ numpad one a Style line uses: only the bottom row is the same number in
80
+ # both. Through 3.2.3 the SRT burn passed the numpad value, so --position top landed mid-left,
81
+ # center top-left, top-left top-right (#436, measured on libass 0.17.1 and FFmpeg 9.0.2).
82
+ FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
78
83
 
79
84
  TIME_RE = re.compile(
80
85
  r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
@@ -1750,7 +1755,7 @@ def main() -> int:
1750
1755
  f"Outline={args.outline:g}",
1751
1756
  f"Shadow={args.shadow:g}",
1752
1757
  f"Bold={-1 if args.bold else 0}",
1753
- f"Alignment={ALIGN[args.position]}",
1758
+ f"Alignment={FORCE_STYLE_ALIGN[args.position]}",
1754
1759
  f"MarginV={args.margin}",
1755
1760
  ]
1756
1761
  force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")
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:
@@ -27,7 +27,7 @@ import sys
27
27
  import tempfile
28
28
  from pathlib import Path
29
29
 
30
- from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs
30
+ from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -63,7 +63,18 @@ def main() -> int:
63
63
  trf_arg = escape_filter_path(trf)
64
64
 
65
65
  ffmpeg = require_tool("ffmpeg")
66
- detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
66
+ # vid.stab asserts on an odd frame (ffmpeg aborts in pass 1, #438), so both passes see an
67
+ # even one. Recorded like the runner's own odd-size retry: the output is a pixel narrower
68
+ # or shorter than the source.
69
+ v = meta["video"]
70
+ w, h = int(v.get("width") or 0), int(v.get("height") or 0)
71
+ if int(v.get("rotation") or 0) % 180 == 90:
72
+ w, h = h, w
73
+ even = f"{EVEN_SCALE}," if (w % 2 or h % 2) else ""
74
+ if even:
75
+ record_fallback("source has odd dimensions; scaling to even before stabilizing (vid.stab needs it)",
76
+ "odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
77
+ detect_vf = f"{even}vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
67
78
  if args.tripod:
68
79
  # A frame number, not a boolean: frame 1 is the standard reference for "lock to
69
80
  # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
@@ -78,7 +89,7 @@ def main() -> int:
78
89
  die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
79
90
 
80
91
  crop_mode = {"keep": 0, "black": 1}[args.crop]
81
- transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
92
+ transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
82
93
  if args.tripod:
83
94
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
84
95
  # camera path makes no sense once every frame is locked to one fixed reference.