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 +13 -2
- package/SKILL.md +1 -1
- package/bin/install.js +11 -1
- package/docs/contract.md +18 -3
- package/package.json +2 -1
- package/references/gotchas.md +7 -0
- package/references/scripts.md +20 -1
- package/scripts/_common/color.py +18 -3
- package/scripts/_common/decision.py +6 -2
- package/scripts/_common/emit.py +71 -0
- package/scripts/_common/probe.py +113 -0
- package/scripts/_common/runner.py +36 -3
- package/scripts/_contract.py +7 -3
- package/scripts/caption.py +6 -1
- package/scripts/check.py +215 -1
- package/scripts/stabilize.py +14 -3
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
|
|
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.
|
|
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`
|
|
@@ -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.
|
|
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",
|
package/references/gotchas.md
CHANGED
|
@@ -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.
|
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
|
package/scripts/_common/color.py
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
|
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] =
|
|
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
|
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"},
|
|
@@ -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
|
-
"
|
|
624
|
-
"
|
|
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)"},
|
package/scripts/caption.py
CHANGED
|
@@ -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={
|
|
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:
|
package/scripts/stabilize.py
CHANGED
|
@@ -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
|
-
|
|
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.
|