agvid 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Illia Puzanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,177 @@
1
- # Temporary Holding Version
1
+ # agvid
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/agvid)](https://www.npmjs.com/package/agvid)
4
+ [![license](https://img.shields.io/npm/l/agvid)](LICENSE)
5
+ [![node](https://img.shields.io/node/v/agvid)](package.json)
6
+
7
+ **Let coding agents watch video without drowning in frames.**
8
+
9
+ `agvid` is a small CLI that turns a local video into a few timestamped JPEGs, labeled contact sheets, and a `manifest.json` that ties every image to its source time. An agent looks at a cheap overview first, then zooms in on the moments that matter.
10
+
11
+ - **Progressive:** overview → find changes → inspect a window → grab one frame.
12
+ - **Timestamped:** every tile is labeled with its timecode; the manifest maps files to source seconds.
13
+ - **Screen-recording aware:** `changes` finds the moments where the UI actually changes.
14
+ - **Crop and zoom:** cut a region at source resolution so small text stays legible.
15
+ - **Bounded:** frame width capped at 640 px by default, runs at 240 frames, change scans at 300 s of source.
16
+ - **No npm dependencies:** just Node.js and FFmpeg.
17
+
18
+ ## Contents
19
+
20
+ - [Requirements](#requirements)
21
+ - [Install](#install)
22
+ - [Quick start](#quick-start)
23
+ - [Commands](#commands)
24
+ - [Output](#output)
25
+ - [Limitations](#limitations)
26
+ - [Development](#development)
27
+ - [License](#license)
28
+
29
+ ## Requirements
30
+
31
+ - Node.js 20+
32
+ - FFmpeg 5.1+ (`ffmpeg` and `ffprobe` on `PATH`)
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ npm install -g agvid
38
+ ```
39
+
40
+ ### Agent skill
41
+
42
+ The package ships an agent skill, [`skill/agvid/SKILL.md`](skill/agvid/SKILL.md), with an [advanced reference](skill/agvid/references/advanced.md). Copy the whole directory into your agent's skills directory:
43
+
44
+ ```sh
45
+ # Claude Code (user scope)
46
+ mkdir -p ~/.claude/skills && cp -R "$(npm root -g)/agvid/skill/agvid" ~/.claude/skills/
47
+
48
+ # Claude Code (project scope, from the project root)
49
+ mkdir -p .claude/skills && cp -R "$(npm root -g)/agvid/skill/agvid" .claude/skills/
50
+
51
+ # Codex
52
+ mkdir -p ~/.codex/skills && cp -R "$(npm root -g)/agvid/skill/agvid" ~/.codex/skills/
53
+ ```
54
+
55
+ Use the same Node and npm that installed agvid: nvm and Volta keep global packages per version.
56
+
57
+ ## Quick start
58
+
59
+ ```sh
60
+ agvid probe demo.mov # duration, size, fps, codec
61
+ agvid overview demo.mov # 12 evenly spaced frames + sheet
62
+ agvid changes demo.mov # frames where the picture changes
63
+ agvid inspect demo.mov --around 00:07.5 --window 2s --fps 4
64
+ agvid frame demo.mov --at 00:07.5
65
+ agvid frame demo.mov --at 00:07.5 --crop 0.5,0,0.5,0.5 # zoom into the top-right quarter
66
+ ```
67
+
68
+ Each image command prints where it wrote its files:
69
+
70
+ ```json
71
+ {
72
+ "directory": "/repo/.agvid/runs/demo-overview",
73
+ "sheets": ["/repo/.agvid/runs/demo-overview/sheet-01.jpg"],
74
+ "manifest": "/repo/.agvid/runs/demo-overview/manifest.json",
75
+ "frames": 12
76
+ }
77
+ ```
78
+
79
+ ## Commands
80
+
81
+ | Command | What it does | Key options |
82
+ | --- | --- | --- |
83
+ | `probe <video>...` | Prints source metadata as JSON | `--video-stream` |
84
+ | `overview <video>` | Evenly spaced frames | `--frames` (1–64, default 12), `--start`, `--end` |
85
+ | `changes <video>` | Range start plus each frame where the picture changes | `--threshold`, `--max`, `--start`, `--end` |
86
+ | `inspect <video>` | Frames at a fixed rate around a moment or over a range | `--around`, `--window` (default 2s), `--fps` (default 4), `--start`, `--end` |
87
+ | `frame <video>` | Frames at exact moments | `--at` |
88
+
89
+ Image commands accept `--crop`, `--width`, `--output` and `--video-stream`. Times accept seconds, `MM:SS.s` or `HH:MM:SS.s`; durations accept seconds with an optional `s`. A time shows the frame on screen then: the last frame at or before it. `--at` and `--around` take comma lists or repeated flags; each `--around` gets its own window and sheets. A run makes at most 240 frames.
90
+
91
+ The [advanced reference](skill/agvid/references/advanced.md) covers exact detection settings, `--video-stream`, multi-file `probe`, unusual formats and timeline rules.
92
+
93
+ ### Crop
94
+
95
+ `--crop x,y,w,h` takes fractions 0–1 of the displayed frame (left, top, width, height). Estimate them from a sheet tile: a button about 80% across and 40% down, roughly 15% wide and 20% tall, is `--crop 0.8,0.4,0.15,0.2`. The region is cut at source resolution before `--width` scaling, never enlarged, and must be at least 16×16 source pixels.
96
+
97
+ ### Change detection
98
+
99
+ `changes` decodes the range once, samples up to 30 fps, and keeps the range start plus each frame where more than `--threshold` (default `0.002`) of the pixels changed in any RGB channel since the last detected change, at least `--min-gap` (default `0.5s`) apart.
100
+
101
+ - **Small changes:** a cursor-sized box stays below the default threshold. `--crop` to the region or lower `--threshold`.
102
+ - **Long videos:** `--analysis-budget` (default `300`) caps the source seconds one decode attempt may cover, so an oversized scan fails before decoding and says how to narrow `--start/--end`. It is not wall-clock time.
103
+ - **Progress:** scans longer than 5 s report progress on stderr; stdout stays JSON.
104
+ - **Truncation:** past `--max` (default 48) candidates, the strongest change of each time slice is kept so quiet stretches stay covered. Output reports `candidates` and `truncated`.
105
+
106
+ `--analysis-fps` and `--analysis-width` trade decode time for sensitivity.
107
+
108
+ ## Output
109
+
110
+ Each run writes to a fresh directory, `.agvid/runs/<video>-<command>[-N]/`, under the git root (else the current directory). `.agvid/` ignores itself in git. `--output DIR` picks another directory, which must be new or empty.
111
+
112
+ ```
113
+ .agvid/runs/demo-overview/
114
+ ├── frame-0000_00-01.066.jpg
115
+ ├── ...
116
+ ├── sheet-01.jpg
117
+ └── manifest.json
118
+ ```
119
+
120
+ - **Frames:** JPEGs named `frame-<index>_<timecode>.jpg`, at most `--width` pixels wide (default 640, range 64–4096, never above the displayed width), aspect ratio preserved.
121
+ - **Sheets:** `overview`, `inspect`, `changes`, and `frame` with several `--at` times write `sheet-01.jpg`, … with tiles labeled by timecode, at most 1568 px per side.
122
+ - **Manifest:** `manifest.json` records the `command`, the `source` metadata, and every frame's `file`, requested `time` (seconds) and `timecode`. Depending on the command it also holds `sheets`, `crop`, `range`, `windows` and `detection`; `changes` frames add a `score`.
123
+
124
+ ```json
125
+ {
126
+ "command": "changes",
127
+ "frames": [
128
+ { "file": "frame-0000_00-00.000.jpg", "time": 0, "timecode": "00:00.000", "score": null },
129
+ { "file": "frame-0001_00-00.717.jpg", "time": 0.7166666666666667, "timecode": "00:00.717", "score": 0.003955 }
130
+ ]
131
+ }
132
+ ```
133
+
134
+ A run holds `DIR/.agvid.lock`, so a concurrent run on the same directory fails. Errors, Ctrl-C and SIGTERM remove the run's files. A run killed outright leaves its lock and `.agvid.lock.work-*` staging directory; agvid never removes them, so delete them by hand once no agvid run uses the directory.
135
+
136
+ ## Limitations
137
+
138
+ - **Frames are evidence for a moment, not frame-accurate.** FFmpeg seeking can land on a nearby frame for some codecs.
139
+ - **Raw elementary streams** (`.h264`, `.m2v`) have no timestamps and are rejected. Wrap them first: `ffmpeg -r FPS -i video.h264 video.mp4`.
140
+ - **MPEG-TS** decodes from the video start, and other formats retry from there when seeking finds no frame, so late times can be slow.
141
+ - **No audio, subtitles or coordinate grids:** agvid covers visual inspection only.
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ git clone https://github.com/zoilorys/agvid.git
147
+ cd agvid
148
+ npm link # puts the checkout's agvid on PATH
149
+ npm test
150
+ npm pack --dry-run
151
+ ```
152
+
153
+ To make the skill track your checkout, symlink it (this replaces any existing copy):
154
+
155
+ ```sh
156
+ mkdir -p ~/.claude/skills && rm -rf ~/.claude/skills/agvid && ln -s "$PWD/skill/agvid" ~/.claude/skills/agvid
157
+ ```
158
+
159
+ Tests run against real videos generated with FFmpeg and the fixture in `test/fixtures`.
160
+
161
+ ### Benchmark
162
+
163
+ `scripts/benchmark.js` times every command, including Node startup, on the bundled fixture and a generated 20 s 1080p60 clip (`--video long` adds a 120 s one; clips are cached in `benchmark/.cache/`). `benchmark/baseline-src/` is a frozen copy of the CLI before the performance work, and `benchmark/baseline.json` holds its results:
164
+
165
+ ```sh
166
+ # Recreate the baseline
167
+ node scripts/benchmark.js --cli benchmark/baseline-src/bin/agvid.js --video bundled --video short --runs 3 --json benchmark/baseline.json
168
+
169
+ # Compare the checkout against it
170
+ node scripts/benchmark.js --video bundled --video short --runs 3 --compare benchmark/baseline.json
171
+ ```
172
+
173
+ `--case` picks cases (`probe`, `overview12`, `overview48`, `frame1`, `frame5`, `inspect8`, `inspect100`, `changes`, `changesCrop`). Compare runs on the same machine under similar load.
174
+
175
+ ## License
176
+
177
+ [MIT](LICENSE) © Illia Puzanov
package/bin/agvid.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ console.error(`agvid: ${error.message}`);
6
+ process.exitCode = error.exitCode ?? 1;
7
+ });
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "agvid",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Video frames and metadata for coding agents",
5
+ "license": "MIT",
6
+ "author": "Illia Puzanov",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zoilorys/agvid.git"
10
+ },
11
+ "homepage": "https://github.com/zoilorys/agvid#readme",
12
+ "bugs": "https://github.com/zoilorys/agvid/issues",
13
+ "keywords": [
14
+ "video",
15
+ "ffmpeg",
16
+ "frames",
17
+ "cli",
18
+ "agents"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "agvid": "bin/agvid.js"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "src",
27
+ "skill",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test"
36
+ }
37
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: agvid
3
+ description: Inspect local video clips with timecode-labeled frame sheets, change detection for screen recordings, short frame sequences, and source metadata using the agvid CLI.
4
+ ---
5
+
6
+ # Inspect video with agvid
7
+
8
+ Use when a task requires understanding a local video. Needs Node.js 20+ and FFmpeg/FFprobe 5.1+ on `PATH`. Look at cheap sheets first, then zoom in.
9
+
10
+ 1. `agvid probe video.mov` when source size matters: `start`/`end`/`duration`, displayed size, fps, codec.
11
+ 2. `agvid overview video.mov` writes 12 evenly spaced frames (`--frames 1-64`). Open the printed `sheets` first; each tile is labeled bottom-left with its timecode.
12
+ 3. Screen recording or UI flow: `agvid changes video.mov` writes the range start plus each frame where the picture changes. Use `--start/--end` on long videos and `--crop` for small UI changes; a cursor-sized change stays under the default `--threshold 0.002`. Its planned scan may span at most 300 s of source per decode attempt (`--analysis-budget`; not wall-clock time, and seeking back to a keyframe is extra) and it reports progress on stderr every 5 s. With more than `--max` (48) candidates, it keeps the strongest per time slice and prints `truncated: true`.
13
+ 4. Motion near a moment: `agvid inspect video.mov --around 00:07.5 --window 2s --fps 4` (`--window` is the total centered duration, clipped to the video), or `--start 00:05 --end 00:09` instead.
14
+ 5. One moment: `agvid frame video.mov --at 00:07.5`.
15
+
16
+ Batch instead of repeating calls: `--at 1,4.5,9` and `--around 3,12` (comma lists or repeated flags). A run makes at most 240 frames.
17
+
18
+ Zoom with `--crop x,y,w,h` on any image command: fractions 0-1 of the displayed frame (left, top, width, height). Estimate from a tile: a button about 80% across and 40% down, roughly 15% wide and 20% tall, is `--crop 0.8,0.4,0.15,0.2`. The crop is cut at source resolution before `--width` (default 640) scaling, so small text becomes legible.
19
+
20
+ TIME is seconds, `MM:SS.s` or `HH:MM:SS.s`; DURATION is seconds with an optional `s`. A time shows the frame on screen then (the last frame at or before it). Image commands print JSON with `directory`, `manifest`, the frame count and `sheets` (omitted for a single-time `frame`); `manifest.json` maps each frame `file` to its requested `time` and `timecode`. Report observations with manifest times.
21
+
22
+ Output goes to a fresh `.agvid/runs/<video>-<command>[-N]/` under the git root, or `--output DIR` (new or empty). If agvid reports a directory in use or holding a killed run's work, use another `--output`, or delete its `.agvid.lock` and `.agvid.lock.work-*` only when no agvid run is using it.
23
+
24
+ Read [references/advanced.md](references/advanced.md) for exact change-detection settings and truncation, `--video-stream` and multi-file `probe`, MPEG-TS/AVI/raw streams, timeline and overlap rules, and crop/aspect details.
@@ -0,0 +1,59 @@
1
+ # agvid advanced reference
2
+
3
+ Read this when the core workflow in `SKILL.md` is not enough: tuning `changes`, multi-stream files, unusual formats, exact timeline semantics, or a refused output directory.
4
+
5
+ ## Change detection
6
+
7
+ `changes` decodes the range once and keeps source timestamps without resampling. It thins frames to at most `--analysis-fps` per second, scales them to `--analysis-width` wide with the area capped at 512×512 (`detection.analysis` holds the actual size), and compares RGB. A pixel counts as changed when any channel differs by more than 16/255 from the last detected candidate; the first reference is the frame on screen at the range start. A frame becomes a candidate when its changed-pixel share exceeds `--threshold` and it is at least `--min-gap` after the previous candidate.
8
+
9
+ | Option | Default | Range | Effect |
10
+ | --- | --- | --- | --- |
11
+ | `--threshold` | `0.002` | above 0, up to 1 | changed-pixel share needed |
12
+ | `--min-gap` | `0.5s` | 0–3600 s | spacing between candidates |
13
+ | `--max` | `48` | 1–239 | changes kept (plus the range-start baseline) |
14
+ | `--analysis-fps` | `30` | 1–60 | higher catches shorter changes, costs decode time |
15
+ | `--analysis-width` | `256` | 64–512 | higher catches smaller changes |
16
+ | `--analysis-budget` | `300` | 1–1000000 s | source seconds one decode attempt may cover |
17
+
18
+ - **Small changes:** a cursor-sized box stays under the default threshold. `--crop` to the region (detection then runs on the crop) or lower `--threshold`.
19
+ - **Truncation:** with at most `--max` candidates, all are kept. Otherwise the range is split into `--max` equal time slices, the strongest candidate of each slice is kept, and slots left by empty slices go to the strongest remaining candidates (earlier wins ties). A quiet late change is not crowded out by a busy stretch. Stdout reports `changes`, `candidates` and `truncated`; the manifest's `detection` adds `max`, `candidates`, `truncated` and `selection` (`method`, and when truncated `slices`, `sliceDuration`, `fromSlices`, `byScore`). Each frame has a `score` (`null` for the baseline).
20
+ - **Budget:** `--analysis-budget DURATION` (default 300) limits the source seconds one decode attempt covers, from where it starts decoding to the range end. That start is the range start, or the video start for MPEG-TS, for a range starting within 0.5 s of the video start, and for the fallback taken when seeking finds no frame at the range start. An over-budget plan, including that fallback, fails before FFmpeg runs and says whether `--start/--end` or only `--end` narrows it. The budget is not wall-clock time, is not summed across attempts, and excludes decoding back to the keyframe before the range start. The manifest's `detection.scan` (`from`, `to`, `seconds`, `budget`) records the attempt that succeeded.
21
+ - **Progress:** every 5 s a running scan writes `agvid changes: scanned T of END (P%), N candidates, Ss elapsed` to stderr. Stdout holds only the final JSON.
22
+
23
+ ## Timeline and evidence
24
+
25
+ - Times are seconds on the container timeline, as players and `ffmpeg -ss` show them. The selected video stream spans probe `start` to `end`; `start` is above 0 when, for example, audio begins first.
26
+ - `overview` and `changes` default to that span. `--start/--end` are clamped to it and the start must come before the end. `--at`/`--around` outside it fail. `--start/--end` replace `--around/--window` on `inspect`; combining them fails.
27
+ - `--window` is the total centered duration, clipped to the span: `--around 7.5 --window 2s` covers 6.5–8.5 s.
28
+ - A time shows the frame on screen then: the last frame at or before it. Manifest times are the requested times at full precision; `changes` times are exact source frame times. Filenames and sheet labels round to milliseconds. Report findings with manifest times. Seeking may land on a nearby frame for some codecs, so treat a frame as evidence for a moment.
29
+ - `--at` values are deduped and sorted. Each `--around` keeps its own window (`windows[]`: `around`, `start`, `end`, the `frames` index range and `sheets`) and sheets, so overlapping windows repeat a moment as separate entries and files. Times that show the same source frame stay separate entries.
30
+
31
+ ## Crop and size
32
+
33
+ - `--crop x,y,w,h` uses fractions of the displayed frame, after rotation and sample aspect ratio (SAR). It is cut at source resolution before `--width` scaling, never enlarged, and must cover at least 16×16 source pixels.
34
+ - Output never exceeds the displayed width of the frame or crop in square pixels: SAR is applied to the coded horizontal axis, then rotation. A 160×90 source with SAR 1:2 gives at most 80×90, or 90×80 rotated 90 degrees.
35
+ - The manifest's `crop` holds the requested fractions and `pixels`: the even stored-pixel `x`, `y`, `width`, `height` and the `displayed` size.
36
+
37
+ ## Streams and probe
38
+
39
+ - `--video-stream N` picks the zero-based video stream, as in FFmpeg's `0:v:N` (default 0). This is not the container's absolute stream index: use 1 when, for example, stream 0 is cover art. `probe` reports `videoStream` and `streamIndex`; manifests record both in `source`.
40
+ - `probe` prints the stream's `start`, `end`, `duration` (with `durationSource`), `containerStart`, displayed and coded size, `sar`, `rotation`, `fps`, `frameCount`, `codec`, `pixelFormat`, `bitDepth` and `hasAudio`.
41
+ - `probe a.mov b.mov` prints an array in argument order. A failed file, including one without the requested stream, becomes `{ "video": "/abs/path", "error": "..." }`, and the exit code is 1.
42
+
43
+ ## Unusual formats
44
+
45
+ - **MPEG-PS/TS:** spans come from a packet scan, because FFmpeg's estimate can stop short. MPEG-TS always decodes from the video start, so only `--end` reduces a `changes` budget.
46
+ - **Seek fallback:** other formats retry from the video start when seeking finds no frame, so late times in sparse or oddly muxed files can be slow.
47
+ - **Raw elementary streams** (`.h264`, `.m2v`) have no timestamps and are rejected. Wrap them first: `ffmpeg -r FPS -i video.h264 video.mp4`.
48
+ - **AVI with B-frames:** FFmpeg rebuilds missing timestamps, so a cut at 1 s can read 1.04 s. Extraction uses the same timestamps, so that time still shows the changed frame.
49
+ - **Variable frame rate and long holds:** a time inside a hold shows the held frame.
50
+
51
+ ## Output directory and locks
52
+
53
+ - Each run writes a fresh `.agvid/runs/<video>-<command>[-N]/` under the git root (else cwd). `--output DIR` must be new or empty.
54
+ - A run holds `DIR/.agvid.lock` (pid and host) and stages files in a private `DIR/.agvid.lock.work-*` directory. A concurrent run on the same directory fails. Errors, Ctrl-C and SIGTERM stop FFmpeg and remove the run's files and any directory it created.
55
+ - A run killed outright (SIGKILL, crash) leaves its lock and staging directory. agvid never removes them itself. When it reports the directory in use or holding a killed run's work, first make sure no agvid run (check the pid in the lock) is still writing there, then delete `.agvid.lock` and `.agvid.lock.work-*` by hand, or use a new `--output`.
56
+
57
+ ## Requirements
58
+
59
+ Node.js 20+ and FFmpeg/FFprobe 5.1+ on `PATH`: extraction and `changes` use `-fps_mode`. Run size is capped at 240 frames; `overview` takes 1–64 frames.
package/src/changes.js ADDED
@@ -0,0 +1,215 @@
1
+ import { run, start } from './process.js';
2
+ import { TIME_TOLERANCE, attempts, seekArgs, trimEnd } from './seek.js';
3
+ import { checkBudget } from './plan.js';
4
+ import { formatTimecode } from './time.js';
5
+
6
+ const PIXEL_DELTA = 16;
7
+ // Bounds one RGB analysis frame to 768 KiB however tall a crop is, and frames queued for their times to about 16 MiB.
8
+ const ANALYSIS_PIXELS = 512 * 512;
9
+ const PENDING_BYTES = 16 * 1024 * 1024;
10
+
11
+ // One decode pass over the range: RGB source frames thinned to at most analysisFps/s (select keeps source pts;
12
+ // no fps resampling), each scored as the share of pixels with any channel differing by more than 16/255 from the last reported frame
13
+ // (initially the one on screen at the range start). -copyts keeps source pts; times come from showinfo's integer
14
+ // pts and time base, since its pts_time can be rounded (%.6g in FFmpeg 5.1). Passing such a time to -ss selects that frame:
15
+ // FFmpeg truncates -ss to microseconds, at or before the pts, then rounds it to the nearest tick of the stream time base.
16
+ // Streams rawvideo; keeps only the reference frame and frames still waiting for their pts line.
17
+ // changes uses -fps_mode (FFmpeg 5.1+). Unparseable versions, such as git builds, are let through.
18
+ export async function requireFfmpeg51() {
19
+ const first = (await run('ffmpeg', ['-version'])).split('\n')[0];
20
+ const match = /^ffmpeg version n?(\d+)\.(\d+)/.exec(first);
21
+ if (match && (Number(match[1]) < 5 || (Number(match[1]) === 5 && Number(match[2]) < 1))) {
22
+ throw new Error(`changes needs FFmpeg 5.1 or newer, found ${match[1]}.${match[2]}; upgrade FFmpeg or use overview/inspect instead`);
23
+ }
24
+ }
25
+
26
+ // Even analysis size at most `analysisWidth` wide and ANALYSIS_PIXELS in area, keeping the source aspect ratio.
27
+ function analysisSize(analysisWidth, sourceWidth, sourceHeight) {
28
+ const aspect = sourceHeight / sourceWidth;
29
+ const width = Math.max(2, Math.min(analysisWidth, Math.floor(Math.sqrt(ANALYSIS_PIXELS / aspect) / 2) * 2));
30
+ const height = Math.max(2, Math.min(Math.round(width * aspect / 2) * 2, Math.floor(ANALYSIS_PIXELS / width / 2) * 2));
31
+ return { width, height };
32
+ }
33
+
34
+ // Whether event a is kept over b: higher score, then the earlier time.
35
+ const stronger = (a, b) => a.score > b.score || (a.score === b.score && a.time < b.time);
36
+
37
+ // `progress` gets the attempt's decode start and span, each decoded frame's source time and the candidate count, for
38
+ // progress reports. An attempt falling back to the video start must fit the budget too: it fails before it starts.
39
+ function detectChanges(video, info, range, crop, settings, progress, attempt = 0) {
40
+ const { threshold, minGap, max, analysisFps, analysisWidth } = settings;
41
+ const { width, height } = analysisSize(analysisWidth, crop?.pixels.width ?? info.width, crop?.pixels.height ?? info.height);
42
+ const pixels = width * height;
43
+ const size = pixels * 3;
44
+ // Frames normally wait at most a pipe read for their showinfo time; a longer queue means pairing broke.
45
+ const maxPending = Math.max(8, Math.min(64, Math.floor(PENDING_BYTES / size)));
46
+ const cut = crop ? `crop=${crop.pixels.width}:${crop.pixels.height}:${crop.pixels.x}:${crop.pixels.y},` : '';
47
+ const plan = attempts(info, range.start);
48
+ const { slow, window } = plan[attempt];
49
+ const retry = attempt + 1 < plan.length;
50
+ // The baseline is the frame on screen at the range start, so frames up to it pass (from `window` before it);
51
+ // the last becomes the reference. Later frames are thinned to analysisFps. With copyts, trimming must use absolute
52
+ // source PTS and precede select/showinfo to keep frame/time pairing intact. Thread counts stay automatic: an output
53
+ // -threads:v 1 also made the filter graph single-threaded, scanning 1.4-1.8x slower with identical events.
54
+ const startPts = range.start + info.containerStart + TIME_TOLERANCE;
55
+ const before = Number.isFinite(window) ? `gte(t,${startPts - window})` : '1';
56
+ const select = `if(lte(t,${startPts}),${before},isnan(prev_selected_t)+gte(t-prev_selected_t,${1 / analysisFps - 1e-6}))`;
57
+ const args = ['-hide_banner', '-nostats', '-loglevel', 'info', ...seekArgs(slow, range.start), '-i', video,
58
+ '-map', `0:v:${info.videoStream}`, '-vf', `${trimEnd(info, range.end + info.containerStart)},${cut}select='${select}',scale=${width}:${height},format=rgb24,showinfo`,
59
+ '-fps_mode', 'passthrough', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'];
60
+ const from = slow ? info.start : range.start;
61
+ return new Promise((resolve, reject) => {
62
+ if (slow && !plan[0].slow) {
63
+ checkBudget(from, range.end, settings.budget, 'input seeking found no frame at the range start, so it is decoded from the video start');
64
+ }
65
+ Object.assign(progress, { from, seconds: range.end - from, time: from, candidates: 0 });
66
+ const child = start('ffmpeg', args);
67
+ const pendingFrames = [];
68
+ const pendingTimes = [];
69
+ // The `max` strongest candidates, strongest first, and the strongest in each of `max` equal slices of the range.
70
+ // Together they hold every event a truncated selection can keep (see findChanges).
71
+ const events = [];
72
+ const slices = new Array(max).fill(null);
73
+ let candidates = 0;
74
+ const errors = [];
75
+ // Set when this attempt found no frame at or before the range start; the next attempt replaces it.
76
+ let missed = false;
77
+ let failed;
78
+ let reference;
79
+ let lastReport = range.start;
80
+ let timeBase;
81
+ let partial = Buffer.alloc(size);
82
+ let filled = 0;
83
+ let line = '';
84
+ const score = (frame) => {
85
+ let changed = 0;
86
+ for (let i = 0; i < size; i += 3) {
87
+ if (Math.abs(frame[i] - reference[i]) > PIXEL_DELTA ||
88
+ Math.abs(frame[i + 1] - reference[i + 1]) > PIXEL_DELTA ||
89
+ Math.abs(frame[i + 2] - reference[i + 2]) > PIXEL_DELTA) changed++;
90
+ }
91
+ return changed / pixels;
92
+ };
93
+ const drain = () => {
94
+ while (!missed && pendingFrames.length && pendingTimes.length) {
95
+ const frame = pendingFrames.shift();
96
+ const time = pendingTimes.shift();
97
+ if (time <= range.start + TIME_TOLERANCE) { reference = frame; continue; }
98
+ if (!reference) {
99
+ if (retry) { missed = true; child.kill(); return; }
100
+ reference = frame;
101
+ continue;
102
+ }
103
+ if (!(time < range.end) || time - lastReport < minGap - 1e-9) continue;
104
+ const value = score(frame);
105
+ if (value > threshold) {
106
+ const event = { time, score: Math.round(value * 1e6) / 1e6 };
107
+ progress.candidates = ++candidates;
108
+ const index = events.findIndex((kept) => stronger(event, kept));
109
+ if (index >= 0) events.splice(index, 0, event);
110
+ else if (events.length < max) events.push(event);
111
+ if (events.length > max) events.pop();
112
+ const slice = Math.min(max - 1, Math.floor((time - range.start) / (range.end - range.start) * max));
113
+ if (!slices[slice] || stronger(event, slices[slice])) slices[slice] = event;
114
+ reference = frame;
115
+ lastReport = time;
116
+ }
117
+ }
118
+ };
119
+ child.stdout.on('data', (chunk) => {
120
+ if (missed) return;
121
+ for (let offset = 0; offset < chunk.length;) {
122
+ const count = Math.min(size - filled, chunk.length - offset);
123
+ chunk.copy(partial, filled, offset, offset + count);
124
+ filled += count;
125
+ offset += count;
126
+ if (filled === size) { pendingFrames.push(partial); partial = Buffer.alloc(size); filled = 0; }
127
+ }
128
+ drain();
129
+ if (pendingFrames.length > maxPending && !failed) {
130
+ failed = new Error('ffmpeg frame/pts mismatch: frames arrived without showinfo times');
131
+ child.kill();
132
+ }
133
+ });
134
+ child.stderr.on('data', (chunk) => {
135
+ const lines = (line + chunk).split('\n');
136
+ line = lines.pop();
137
+ for (const text of lines) {
138
+ if (/Parsed_showinfo/.test(text)) {
139
+ const base = /\bconfig in time_base:\s*(\d+)\/(\d+)/.exec(text);
140
+ if (base) timeBase = [Number(base[1]), Number(base[2])];
141
+ const match = /\bn:\s*\d+\s+pts:\s*(-?\d+)\s/.exec(text);
142
+ if (match && !timeBase && !failed) {
143
+ failed = new Error('ffmpeg showinfo logged no time base');
144
+ child.kill();
145
+ }
146
+ if (match && timeBase) {
147
+ progress.time = Number(match[1]) * timeBase[0] / timeBase[1] - info.containerStart;
148
+ pendingTimes.push(progress.time);
149
+ }
150
+ if (pendingTimes.length > 4096 && !failed) {
151
+ failed = new Error('ffmpeg frame/pts mismatch: showinfo times arrived without frames');
152
+ child.kill();
153
+ }
154
+ } else if (text.trim()) {
155
+ errors.push(text.trim());
156
+ if (errors.length > 20) errors.shift();
157
+ }
158
+ }
159
+ drain();
160
+ });
161
+ child.on('error', (error) => reject(new Error(`ffmpeg: ${error.message}`)));
162
+ child.on('close', (code) => {
163
+ if (missed) return resolve(detectChanges(video, info, range, crop, settings, progress, attempt + 1));
164
+ if (failed) return reject(failed);
165
+ if (code !== 0) return reject(new Error(`ffmpeg exited ${code}: ${errors.join('\n')}`));
166
+ drain();
167
+ if (missed) return resolve(detectChanges(video, info, range, crop, settings, progress, attempt + 1));
168
+ if (pendingFrames.length || pendingTimes.length || filled) {
169
+ return reject(new Error(`ffmpeg frame/pts mismatch: ${pendingFrames.length} frames and ${pendingTimes.length} times unpaired, ${filled} trailing bytes`));
170
+ }
171
+ if (!reference && retry) return resolve(detectChanges(video, info, range, crop, settings, progress, attempt + 1));
172
+ // A valid sparse range may contain no new PTS. Baseline extraction still validates its displayed frame.
173
+ resolve({ events, slices, candidates, from, width, height });
174
+ });
175
+ });
176
+ }
177
+
178
+ // Reports scan progress on stderr every PROGRESS_INTERVAL ms, so short scans stay quiet and stdout keeps only JSON.
179
+ const PROGRESS_INTERVAL = 5000;
180
+
181
+ // Detects changes in `range` per `settings` ({ threshold, minGap, max, analysisFps, analysisWidth, budget }) and
182
+ // returns the kept { time, score } events in time order with the manifest's detection summary. Up to `max` candidates
183
+ // are all kept. Beyond that, the strongest candidate of each of `max` equal slices of the range is kept, so quiet
184
+ // stretches stay represented however strong one busy stretch is, and slots left by empty slices go to the strongest
185
+ // remaining candidates.
186
+ export async function findChanges({ video, info, range, crop, settings }) {
187
+ const { threshold, minGap, max, analysisFps, budget } = settings;
188
+ const began = Date.now();
189
+ const progress = {};
190
+ const timer = setInterval(() => {
191
+ const { from, seconds, time } = progress;
192
+ const share = Math.min(1, Math.max(0, (time - from) / seconds));
193
+ process.stderr.write(`agvid changes: scanned ${formatTimecode(Math.max(from, Math.min(range.end, time)))} of ${formatTimecode(range.end)}`
194
+ + ` (${Math.floor(share * 100)}%), ${progress.candidates} candidates, ${Math.round((Date.now() - began) / 1000)}s elapsed\n`);
195
+ }, PROGRESS_INTERVAL);
196
+ let result;
197
+ try { result = await detectChanges(video, info, range, crop, settings, progress); }
198
+ finally { clearInterval(timer); }
199
+ const { events, slices, candidates, from, ...analysis } = result;
200
+ const truncated = candidates > max;
201
+ let kept = events;
202
+ let selection = { method: 'all candidates' };
203
+ if (truncated) {
204
+ kept = slices.filter(Boolean);
205
+ const fromSlices = kept.length;
206
+ const chosen = new Set(kept);
207
+ for (const event of events) if (kept.length < max && !chosen.has(event)) kept.push(event);
208
+ selection = { method: 'strongest per time slice, then strongest remaining', slices: max, sliceDuration: (range.end - range.start) / max,
209
+ fromSlices, byScore: kept.length - fromSlices };
210
+ }
211
+ const detection = { metric: `RGB any-channel-diff>${PIXEL_DELTA}@${analysis.width}x${analysis.height}px,${analysisFps}fps vs last detected candidate`,
212
+ analysis: { ...analysis, fps: analysisFps }, threshold, minGap, max, candidates, truncated, selection,
213
+ scan: { from, to: range.end, seconds: range.end - from, budget } };
214
+ return { events: kept.sort((a, b) => a.time - b.time), detection };
215
+ }