imgtune 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-11-14)
4
+
5
+ - Animated media and video. `scan` measures animated GIFs (in an `<img>` or a CSS background), animated WebP and APNG, and
6
+ self-hosted `<video>` elements with a plain file source. Each page gets a Video section with the bytes per view and per
7
+ play, and the levers (audio, width, codec, preload, poster). Adaptive streams (HLS, DASH, Mux, Cloudflare Stream) and
8
+ third-party iframes (YouTube, Vimeo) are not reported. A video hosted on another site follows the third-party rules.
9
+ - Hints for animated media. A large animated GIF gets `<video autoplay muted loop playsinline>` with AV1 first and an H.264
10
+ fallback. The H.264 fallback is a recipe (libx264, CRF 23, yuv420p, faststart), marked not measured. The animated-image
11
+ alternative is shown with its size. A small `<img>` GIF or a CSS background gets an animated AVIF or WebP, and only its
12
+ `src` changes. A `<video>` hint names each lever: the width per breakpoint (`<source media>`), the CRF, the codec, the
13
+ audio track of a muted video, and `preload`. A poster is audited through the image pipeline. Duration and frame rate are
14
+ informational notes only, never part of the claim.
15
+ - Static GIFs (one frame) get the normal image hints. Animated images stay in the image totals.
16
+ - Bytes per view and per play. Per view is what the first view fetches: an autoplay video counts its full file,
17
+ `preload="auto"` counts the bytes the scan saw (labelled browser-dependent), and `preload="metadata"` or `"none"` counts 0.
18
+ Per play is the full file, shown for every video, so a large click-to-play file is not missed. A URL's bytes are the union
19
+ of the byte ranges read, capped at the file length, and a length probe reads the size when the server sends no
20
+ Content-Length, so a looping video is not counted once per loop. Posters count as images.
21
+ - Quality bars. A candidate must reach VMAF 93 (`--video-bar`) against the served file at its display size. A still-frame
22
+ guard (SSIMULACRA2 on the stillest frames) catches blurred text in screen recordings. Its bar is `--video-still-bar`
23
+ (default 65), lowered to at most 10 points below the best the same codec reaches on that content, and never below 20. The
24
+ relative bar catches the loss the encode adds, not what a format can never reproduce, such as GIF dither. For GIF sources
25
+ both frames of the guard are blurred slightly (Gaussian, sigma 1) before scoring, so dither the eye averages out does not
26
+ fail it.
27
+ - Encodes and budget. Encodes run on the heaviest animated assets first (by per-view bytes), at most `--media-budget` per run
28
+ (default 20) and within `--media-time` seconds (default 600). Assets over the budget keep their cheap hints and are marked
29
+ "over --media-budget". The CRF search runs on 2 s sample clips: one for a video of up to 6 s, three for a longer one. The
30
+ full-file bytes are extrapolated from the samples and labelled as an estimate; nothing is encoded in full. Each asset is
31
+ encoded once per run, keyed by URL and content hash in the result cache. Widths are snapped to a few buckets first.
32
+ - Video widths. `scan` and `check` serve each video at most `--max-video-widths` widths (default 2): each viewport gets the
33
+ smallest kept width that covers its display width times its device pixel ratio. `audit` does not choose: it reports each
34
+ viewport's needed width and the bytes per view at one, two and three widths.
35
+ - ffmpeg. The system ffmpeg on `PATH` is used; ImgTune does not bundle it. Without it, animated media is still detected, and
36
+ its bytes and needed width are still reported, with one note: `install ffmpeg for verified video and GIF hints`. ffmpeg and
37
+ ffprobe read only the protocols they need, and a URL input goes through the egress proxy (the scan's own policy for
38
+ private addresses), so a scanned page cannot reach a private host through a video.
39
+ - `imgtune image` accepts GIF, animated WebP and APNG (candidates: animated AVIF, animated WebP and AV1 MP4 at `--width`),
40
+ and MP4 and WebM. `--reference <original>` checks a candidate against the original with the same VMAF and still-frame bars.
41
+ It prints one line, `VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail`, and exits 0 or 1.
42
+ - `audit` locates `<video>` and GIF elements at `file:line`, as it locates images. `hints` prints the animated and video hints.
43
+ - Network idle. A streaming media request does not hold the wait for network idle, so an autoplay video does not hold each
44
+ resize up to `--idle-timeout`.
45
+ - Scan JSON. Each page gains `videos` and `mediaNote`, and an animated image record gains `fix.animated`. The resume record
46
+ gains the media settings (`mediaBudget`, `mediaTime`, `videoBar`, `videoStillBar`, `maxVideoWidths`, `ffmpeg`).
47
+ - New flags, with their defaults: `--media-budget <N>` (20, 0 to 1000), `--media-time <s>` (600, 1 to 3600), `--video-bar <n>`
48
+ (93), `--video-still-bar <n>` (65), `--max-video-widths <n>` (2, 1 to 3). `--survey` and `--no-quality` make no encodes.
49
+ `image` takes `--reference`, `--video-bar` and `--video-still-bar`.
50
+ - `--gpu-encode` (experimental, off by default). The media encodes run on the GPU with NVENC (`av1_nvenc`, `h264_nvenc`), and
51
+ VMAF runs on the GPU with `libvmaf_cuda` when the ffmpeg on `PATH` has it. The NVENC bytes are scaled by 1 / 1.4 to a CPU
52
+ encode and labelled `est. (GPU-encoded, scaled to a CPU encode ÷1.4)`. Each GPU is proven by a small test encode first, and
53
+ a width under 144 px is encoded on the CPU. In our measurements it was slower than the CPU on 2 s samples (CUDA starts once
54
+ per ffmpeg process), so the CPU stays the default.
55
+ - `--gpu-device` takes a comma list (`0,1`). The first GPU scores. With `--gpu-encode`, each listed GPU gets its own encode
56
+ workers beside the CPU ones. `--gpu` alone is unchanged.
57
+ - Ctrl-C and SIGTERM stop the whole run: the media phase is aborted, the pages already written are kept, and the process
58
+ exits 130 (SIGINT) or 143 (SIGTERM) within 2 s.
59
+ - Media fetches and the ffmpeg reads of a page's videos go through the egress proxy. ffmpeg's `httpproxy` tunnel is allowed,
60
+ so `https` videos are read through it.
61
+ - Animated WebP and APNG are decoded one frame at a time, only for the frames of each 2 s sample window, and streamed to
62
+ ffmpeg. A long animation does not fill memory, and the pixel limit applies to each frame.
63
+ - A measured page's JSON is written after its image estimates and before the media phase, so a long media phase does not
64
+ hold back the results.
65
+ - Cache: `CACHE_VERSION` is 3. Entries from 0.1 are not read, so the first scan on 0.2 is cold.
66
+ - Changes for 0.1 users:
67
+ - A scan that encodes media takes longer. The encodes are bounded by `--media-time` (600 s) and `--media-budget` (20
68
+ assets); `--no-quality` or `--survey` keeps the 0.1 timing.
69
+ - Without ffmpeg on `PATH`, `scan` prints one note and gives no encode hints.
70
+ - `--resume` scans a 0.1 page again when the new run encodes media, because its record has no media settings. A run with
71
+ no encodes (`--no-quality`, `--survey`, or no ffmpeg) resumes 0.1 pages as before.
72
+ - The JSON gains fields (`videos`, `mediaNote`, `fix.animated`, the resume media settings); existing fields are unchanged.
73
+ Animated GIFs, WebP and APNG keep their place in the image totals, but their hint is now a video or animated-image hint.
74
+ - `<video>` elements are not image records: their bytes are in the Video section, and their posters are images.
75
+ - The cache is not reused across the version change (see above).
76
+
3
77
  ## 0.1.0 (2026-11-03)
4
78
 
5
79
  - LCP first. Each scanned page reads the largest contentful paint before its first scroll, per viewport. A page's report
package/README.md CHANGED
@@ -143,6 +143,46 @@ Every fix hint is labelled with the strength of its evidence, the rung it stands
143
143
  A ceiling is never derived from the existing `sizes` value. When a usage is measured, its `sizes` comes from the layout
144
144
  the browser resolved, not from a guess.
145
145
 
146
+ ## GIFs and video
147
+
148
+ `scan` also measures animated media: animated GIFs in an `<img>` or a CSS background, animated WebP and APNG, and
149
+ self-hosted `<video>` elements with a plain file source. Static GIFs get the normal image hints.
150
+
151
+ - **Out of scope, not reported:** adaptive streams (HLS, DASH, Mux, Cloudflare Stream) and third-party iframes (YouTube,
152
+ Vimeo). A video hosted on another site follows the third-party rules (`--block-third-party`, `--allow-host`).
153
+ - **ffmpeg** is used from `PATH`; ImgTune does not bundle it. Without it, `scan` still reports each animated image's and
154
+ video's bytes and the width it needs, and prints one note: `install ffmpeg for verified video and GIF hints`. The encodes,
155
+ and so the fix hints with a size, need it.
156
+ - **Encodes run on the CPU by default.** `--gpu-encode` is **experimental**: it moves the sample encodes to the GPU (NVENC
157
+ `av1_nvenc` / `h264_nvenc`, preset p4, constant quality in VBR; `libvmaf_cuda` for the VMAF). It uses the `--gpu-device`
158
+ list, else GPU 0, in workers beside the CPU ones. Each GPU is proven by a tiny test encode first; a width under 144 px or
159
+ an NVENC failure is encoded on the CPU for that candidate. NVENC is larger at equal quality, so its bytes are reported as
160
+ NVENC bytes ÷ 1.4, labelled `est. (GPU-encoded, scaled to a CPU encode ÷1.4)`. In our measurements `--gpu-encode` was
161
+ slower than the CPU (per-process CUDA start-up); see the report before using it. `--gpu` and `--gpu-device` only pick the
162
+ SSIMULACRA2 GPU scorer (the first device) unless `--gpu-encode` is given. The hint's recipe is the CPU one (`libsvtav1` / `libx264`), and an NVENC
163
+ quality is marked `approx.` (CRF = cq).
164
+ - **Hints.** A large animated GIF gets `<video autoplay muted loop playsinline>` with AV1 first and an H.264 fallback, and
165
+ the animated-image alternative with its size. A small `<img>` GIF or a CSS background gets an animated AVIF or WebP. A
166
+ `<video>` hint names each lever: the width per breakpoint (`<source media>`), the CRF, the codec, the audio track of a
167
+ muted video, and `preload`. A poster is audited as an image. Duration and frame rate are informational only.
168
+ - **Bytes: per view and per play.** Per view is what the first view fetches. An autoplay video counts its full file,
169
+ `preload="auto"` counts the bytes the scan saw (browser-dependent), and `preload="metadata"` or `"none"` counts 0. Per
170
+ play is the full file. Both are shown for every video, so a large click-to-play file is not missed. Animated images stay
171
+ in the image totals; `<video>` has its own Video section, and posters count as images.
172
+ - **Estimates come from 2 s samples.** One sample for a video of up to 6 s, three for a longer one. The full-file bytes
173
+ are extrapolated from the samples and labelled as an estimate; nothing is encoded in full.
174
+ - **Quality bars.** VMAF must reach `--video-bar` (default 93) against the served file at its display size. A still-frame
175
+ guard (SSIMULACRA2 on the stillest frames) catches blurred text in screen recordings and must reach `--video-still-bar`
176
+ (default 65), lowered to at most 10 points below the best the codec reaches on that content, and never below 20. The
177
+ relative bar catches the loss the encode adds, not what the format can never reproduce, such as GIF dither.
178
+ - **Budget and widths.** Encodes run on the heaviest animated assets first, at most `--media-budget` per run (default 20)
179
+ and within `--media-time` seconds (default 600). Assets over the budget keep their cheap hints and are marked "over
180
+ --media-budget". A video is served at most `--max-video-widths` widths (default 2): each viewport gets the smallest
181
+ kept width that covers its display width times its device pixel ratio. `audit` reports the bytes at one, two and three
182
+ widths instead, and the agent chooses. `--no-quality` and `--survey` make no encodes.
183
+ - **Check an agent's output** with `imgtune image <candidate> --reference <original>`: the same VMAF and still-frame bar,
184
+ exit 0 on pass and 1 on fail (see the table below).
185
+
146
186
  ## Commands
147
187
 
148
188
  | command | what it does |
@@ -155,6 +195,7 @@ the browser resolved, not from a guess.
155
195
  | `imgtune summary <run dir \| scan.json ...>` | the one-screen summary of a run directory (`run.json`) or of scan files |
156
196
  | `imgtune scan --recheck <scan.json> [--ids a,b,c]` | rescans only the named images at the viewports where they rendered; prints before and after; exits 1 on a failed check |
157
197
  | `imgtune image <file\|url\|glob>` | measures image files without a page: bytes now, the lowest quality per format that meets the bar, and at a `--width` the bytes at each needed width; `--min-score` sets the bar (default 90), `--write <dir>` writes optimised copies |
198
+ | `imgtune image <animated.gif\|.webp\|.png\|video> --reference <original>` | checks a re-encoded video or animated file against the original: `VMAF … still p10 …: pass\|fail` with exit 0 or 1; `--video-bar` (default 93) and `--video-still-bar` (default 65) set the bars; without `--reference` an animated file gets its candidates (needs ffmpeg) |
158
199
  | `imgtune compare <before.json> <after.json>` | compares two scans of one page image by image: the bytes saved and the sharpness bytes added, as two numbers; drift (a changed layout, server variance, a view not loaded) is excluded and its totals printed; `--format json`, `--limit` |
159
200
  | `imgtune report` | a Markdown run report from the agent's ledger (`.imgtune/ledger.jsonl`); `--compare <file>` adds the bytes from `imgtune compare --format json`; `--out <file>` writes it |
160
201
 
@@ -0,0 +1,176 @@
1
+ // Animation detection by byte parsing: GIF, animated WebP and APNG. It needs no decoder and no sharp, so a scan can
2
+ // tell an animated file from a still one from the bytes it already downloaded. Only the container structure is read
3
+ // (block headers, chunk lengths and the timing fields); pixels are never decoded.
4
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
5
+ /**
6
+ * Returns the animation in a file, or undefined when the file is not animated (one frame, another format, or a
7
+ * format this does not read). A truncated or corrupt file never throws: it reports what was readable before the
8
+ * damage, or undefined. Used by the scan to decide whether an image is a GIF or animated WebP/APNG that a video
9
+ * encode could replace.
10
+ */
11
+ export function animationOf(buffer) {
12
+ try {
13
+ const info = detect(buffer);
14
+ return info && info.frames > 1 ? info : undefined;
15
+ }
16
+ catch {
17
+ // Every read below is bounds-checked, so this is a last guard for a malformed buffer, not a normal path.
18
+ return undefined;
19
+ }
20
+ }
21
+ function detect(b) {
22
+ const head6 = b.toString("latin1", 0, 6);
23
+ if (head6 === "GIF87a" || head6 === "GIF89a")
24
+ return parseGif(b);
25
+ if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP") {
26
+ return parseWebp(b);
27
+ }
28
+ if (b.length >= 8 && b.subarray(0, 8).equals(PNG_SIGNATURE))
29
+ return parseApng(b);
30
+ return undefined;
31
+ }
32
+ /** Colour-table size in bytes for a packed-flags byte whose table flag is set (3 bytes per entry, 2^(n+1) entries). */
33
+ function colourTableBytes(packed) {
34
+ return 3 * 2 ** ((packed & 0x07) + 1);
35
+ }
36
+ /**
37
+ * Skips a run of GIF data sub-blocks (a size byte, then that many bytes, ending with a zero-size block) starting at
38
+ * p, and returns the offset just after the terminator. A run that runs past the end returns an offset past the end,
39
+ * which the caller's loop treats as the end of the file.
40
+ */
41
+ function skipSubBlocks(b, p) {
42
+ while (p < b.length) {
43
+ const size = b[p];
44
+ if (size === 0)
45
+ return p + 1;
46
+ p += size + 1;
47
+ }
48
+ return p;
49
+ }
50
+ /**
51
+ * Walks GIF blocks. Frames are counted as image descriptors (0x2C); each frame's delay comes from the Graphic
52
+ * Control Extension (0x21 0xF9) just before it, in hundredths of a second. The loop count comes from the NETSCAPE2.0
53
+ * application extension.
54
+ */
55
+ function parseGif(b) {
56
+ if (b.length < 13)
57
+ return undefined;
58
+ let pos = 13;
59
+ if (b[10] & 0x80)
60
+ pos += colourTableBytes(b[10]);
61
+ let frames = 0;
62
+ let durationMs = 0;
63
+ let pendingDelayCs = 0;
64
+ let loop;
65
+ while (pos < b.length) {
66
+ const introducer = b[pos];
67
+ if (introducer === 0x3b)
68
+ break; // trailer: end of the GIF
69
+ if (introducer === 0x21) {
70
+ // Extension: label, then sub-blocks. Application extensions start with an 11-byte identifier sub-block.
71
+ if (pos + 2 > b.length)
72
+ break;
73
+ const label = b[pos + 1];
74
+ if (label === 0xf9 && pos + 6 <= b.length) {
75
+ pendingDelayCs = b.readUInt16LE(pos + 4);
76
+ }
77
+ else if (label === 0xff && pos + 3 + 11 <= b.length) {
78
+ const identifier = b.toString("latin1", pos + 3, pos + 14);
79
+ if (identifier === "NETSCAPE2.0" || identifier === "ANIMEXTS1.0") {
80
+ const loopBlock = pos + 14;
81
+ if (loopBlock + 5 <= b.length && b[loopBlock] === 3 && b[loopBlock + 1] === 1) {
82
+ loop = b.readUInt16LE(loopBlock + 2);
83
+ }
84
+ }
85
+ }
86
+ pos = skipSubBlocks(b, pos + 2);
87
+ }
88
+ else if (introducer === 0x2c) {
89
+ // Image descriptor: separator, 4 x u16 position and size, packed flags, then LZW code size and sub-blocks.
90
+ if (pos + 10 > b.length)
91
+ break;
92
+ const packed = b[pos + 9];
93
+ let p = pos + 10;
94
+ if (packed & 0x80)
95
+ p += colourTableBytes(packed);
96
+ p += 1; // LZW minimum code size
97
+ frames += 1;
98
+ // A delay of 0 or 1 cs is shown as 10 cs (100 ms), as browsers show it, so the length is not understated.
99
+ durationMs += (pendingDelayCs <= 1 ? 10 : pendingDelayCs) * 10;
100
+ pendingDelayCs = 0;
101
+ pos = skipSubBlocks(b, p);
102
+ }
103
+ else {
104
+ break; // not a block introducer: the file is corrupt from here on
105
+ }
106
+ }
107
+ return { format: "gif", frames, durationMs, loop: loop ?? 1 };
108
+ }
109
+ /**
110
+ * Walks RIFF chunks of a WebP. Animation needs the VP8X animation flag (bit 1 of its first byte). ANIM holds the loop
111
+ * count (u16 at data offset 4). Each ANMF chunk is one frame, and its 24-bit duration is at data offset 12.
112
+ */
113
+ function parseWebp(b) {
114
+ let pos = 12;
115
+ let animated = false;
116
+ let loop = 0;
117
+ let frames = 0;
118
+ let durationMs = 0;
119
+ while (pos + 8 <= b.length) {
120
+ const type = b.toString("latin1", pos, pos + 4);
121
+ const size = b.readUInt32LE(pos + 4);
122
+ const data = pos + 8;
123
+ if (type === "VP8X" && data + 1 <= b.length) {
124
+ animated = (b[data] & 0x02) !== 0;
125
+ }
126
+ else if (type === "ANIM" && data + 6 <= b.length) {
127
+ loop = b.readUInt16LE(data + 4);
128
+ }
129
+ else if (type === "ANMF" && data + 15 <= b.length) {
130
+ frames += 1;
131
+ durationMs += b.readUIntLE(data + 12, 3);
132
+ }
133
+ pos = data + size + (size & 1); // chunks are padded to an even length
134
+ }
135
+ if (!animated)
136
+ return undefined;
137
+ return { format: "webp", frames, durationMs, loop };
138
+ }
139
+ /**
140
+ * Walks PNG chunks of an APNG. The acTL chunk must come before the first IDAT; it holds num_frames and num_plays (0
141
+ * means loop forever). Each fcTL chunk is one frame, and its delay is delay_num / delay_den seconds (a zero
142
+ * delay_den means 100).
143
+ */
144
+ function parseApng(b) {
145
+ let pos = 8;
146
+ let frames;
147
+ let loop = 0;
148
+ let durationMs = 0;
149
+ let sawIdat = false;
150
+ while (pos + 8 <= b.length) {
151
+ const length = b.readUInt32BE(pos);
152
+ const type = b.toString("latin1", pos + 4, pos + 8);
153
+ const data = pos + 8;
154
+ if (data + length > b.length)
155
+ break; // a chunk cut short by truncation
156
+ if (type === "IDAT") {
157
+ sawIdat = true;
158
+ }
159
+ else if (type === "acTL" && !sawIdat && length >= 8) {
160
+ frames = b.readUInt32BE(data);
161
+ loop = b.readUInt32BE(data + 4);
162
+ }
163
+ else if (type === "fcTL" && length >= 26) {
164
+ const delayNum = b.readUInt16BE(data + 20);
165
+ const delayDen = b.readUInt16BE(data + 22) || 100;
166
+ durationMs += (delayNum * 1000) / delayDen;
167
+ }
168
+ else if (type === "IEND") {
169
+ break;
170
+ }
171
+ pos = data + length + 4; // skip the CRC
172
+ }
173
+ if (frames === undefined)
174
+ return undefined;
175
+ return { format: "apng", frames, durationMs: Math.round(durationMs), loop };
176
+ }
@@ -6,7 +6,8 @@
6
6
  // Pure parsing and sizing here; the measurement (fetches, decodes, resize estimates) is in remote.ts, and the records are
7
7
  // built in buildBackgroundRecords below.
8
8
  import { sha1 } from "./atomic.js";
9
- import { rungOfPage, searchHintsOf } from "./images.js";
9
+ import { losslessServed, rungOfPage, searchHintsOf } from "./images.js";
10
+ import { animatedTarget } from "./media-model.js";
10
11
  import { loaderName, sanitizeText, urlPattern } from "./locate.js";
11
12
  /** The most background elements read per viewport: a page with more keeps the first of them in document order. */
12
13
  export const MAX_BACKGROUND_ELEMENTS = 1000;
@@ -290,7 +291,9 @@ export function buildBackgroundRecords(input) {
290
291
  }
291
292
  const saved = members.reduce((sum, m) => sum + m.est, 0) / viewportCount;
292
293
  const served = member.format;
293
- const best = served === "jpeg" || served === "png" ? (saved > 0 ? "avif" : served) : served;
294
+ const animatedMember = members.find((m) => m.animated !== undefined);
295
+ const lossless = losslessServed(served, animatedMember !== undefined);
296
+ const best = lossless ? (saved > 0 ? "avif" : served) : served;
294
297
  const locator = locatorOfBackground(pageUrl, url, member.bg, source);
295
298
  const notes = [
296
299
  `css background: layer ${member.layer + 1}, background-size "${sanitizeText(member.size, 60)}", drawn ${Math.round(member.displayedCss)} CSS px at the reference viewport`,
@@ -328,6 +331,14 @@ export function buildBackgroundRecords(input) {
328
331
  };
329
332
  if (best !== undefined)
330
333
  record.fix.format = best;
334
+ if (animatedMember?.animated !== undefined) {
335
+ record.fix.animated = {
336
+ info: animatedMember.animated,
337
+ context: "css-background",
338
+ target: animatedTarget("css-background", undefined),
339
+ perViewBytes: members.reduce((sum, m) => sum + m.bytes, 0) / viewportCount,
340
+ };
341
+ }
331
342
  records.push(record);
332
343
  }
333
344
  return records.filter((r) => r.perViewport.some((p) => p.loaded || p.skipped !== undefined));
@@ -1,12 +1,12 @@
1
1
  // The Markdown report of `imgtune check`: a pure builder over the survey results, the deep results, the route grouping and
2
2
  // the deep selection. No browser, no files. The audit mode extends it with source-aware sections; the sections below keep
3
3
  // their order so the two stay comparable. Page-derived text goes through mdText and mdCode (see text.ts).
4
- import { hintsOf } from "./hints.js";
4
+ import { hintsOf, videoRowOf, videoSavingOf, videoTotalsLine } from "./hints.js";
5
5
  import { lcpLinesOf } from "./lcp.js";
6
6
  import { planQualityCap, qualitiesLine } from "./qualities-cap.js";
7
7
  import { hasFix } from "./images.js";
8
8
  import { APPROX_MARK, siteNumbers } from "./remote.js";
9
- import { errorLine, mdCode, mdText } from "./text.js";
9
+ import { errorLine, mdCode, mdText, safeText } from "./text.js";
10
10
  /** How many fixes, heavy pages, sharpness images and survey route types the report lists in full. */
11
11
  export const REPORT_FIXES = 15;
12
12
  export const REPORT_SHARPNESS = 15;
@@ -153,6 +153,10 @@ export function buildCheckReport(input) {
153
153
  const notMeasured = (pages) => pages.length - measured(pages).length - count(pages, "broken") - count(pages, "failed");
154
154
  out(`survey ${input.urls.surveyed} page(s): ${survey.length} ok, ${count(input.survey, "broken")} broken, ${count(input.survey, "failed")} failed, ${notMeasured(input.survey)} not measured · deep ${input.deep.length} page(s)${retried > 0 ? ` (${retried} retried)` : ""}: ${deepOk.length} ok, ${count(input.deep, "failed")} failed, ${notMeasured(input.deep)} not measured`);
155
155
  out(`time: survey ${input.timings.survey.toFixed(1)} s, deep ${input.timings.deep.toFixed(1)} s, total ${(input.timings.survey + input.timings.deep).toFixed(1)} s`, "");
156
+ // The media phase's note is printed once, at the top (the same note is on each page that has media).
157
+ const media = [...input.survey, ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
158
+ if (media !== undefined)
159
+ out(mdText(media), "");
156
160
  if (input.stop !== undefined) {
157
161
  out("## Stopped before the deep phase", "", `${mdText(input.stop.reason)}`, `Fix: ${mdText(input.stop.fix)}`, "");
158
162
  }
@@ -192,6 +196,17 @@ export function buildCheckReport(input) {
192
196
  out(`Merged across the deep pages: one file:line when located, else one image pattern with the same proposed sizes. Ranked by the total saving over the pages. Top ${Math.min(REPORT_FIXES, fixes.length)} of ${fixes.length}.`, "");
193
197
  fixes.slice(0, REPORT_FIXES).forEach((fix, i) => out(...fixLines(fix, i + 1), ""));
194
198
  }
199
+ // 3b. Video: the self-hosted <video> files of the deep pages, per view and per play, largest saveable first.
200
+ const videos = deepOk.flatMap((p) => (p.videos ?? []).map((video) => ({ page: p.url, video })));
201
+ if (videos.length > 0) {
202
+ out("## Video", "");
203
+ out(`${videoTotalsLine(videos.map((v) => v.video))} (${videos.length} video(s) on ${deepOk.filter((p) => (p.videos ?? []).length > 0).length} deep page(s))`, "");
204
+ const ranked = [...videos].sort((a, b) => videoSavingOf(b.video).view - videoSavingOf(a.video).view || a.page.localeCompare(b.page));
205
+ for (const row of ranked.slice(0, REPORT_FIXES)) {
206
+ out(`- ${mdCode(videoRowOf(row.video, (text) => JSON.stringify(safeText(text))))} on ${mdCode(pathOf(row.page))}`);
207
+ }
208
+ out("");
209
+ }
195
210
  // 4. Heavy but not wasteful.
196
211
  out("## Heavy but not wasteful", "");
197
212
  const heavyCut = percentile(deepTotals.map((t) => t.kb), 0.75);
@@ -280,6 +295,8 @@ export function buildCheckReport(input) {
280
295
  ? `- Route types group URLs by template (locale and device variant stripped), as check does; the route file column names the file(s) each type renders from. Only the heaviest page of each type is deep-scanned; ${input.selection.leftOut.length} type(s) were not (see the table).`
281
296
  : `- Route types group URLs by template (locale and device variant stripped). Only the heaviest page of each type is deep-scanned; ${input.selection.leftOut.length} type(s) were not (see the table).`, input.options.qualities !== undefined
282
297
  ? `- Quality targets are snapped up to the allowed qualities ${input.options.qualities.join(", ")}; a snapped target's quality saving is void and counts as 0.${input.qualitiesNote !== undefined ? ` (${mdText(input.qualitiesNote)})` : ""}`
283
- : "- Quality targets are not snapped (no --qualities given): a target the framework cannot serve is not the saving it looks like.", `- Third-party requests: ${input.options.thirdParty}${input.options.allowHosts ? ` (hosts kept: ${input.options.allowHosts.map(mdText).join(", ")})` : ""}. Blocking can drop images a page loads through its own API on another host.`, `- Pages with images still loading at settle show the images that loaded; the rest are not measured.`, input.options.gpu !== undefined ? `- Quality was scored on the GPU ${input.options.gpu} (with the CPU fallback noted in the page JSON).` : "- Quality was scored on the CPU.", "");
298
+ : "- Quality targets are not snapped (no --qualities given): a target the framework cannot serve is not the saving it looks like.", `- Third-party requests: ${input.options.thirdParty}${input.options.allowHosts ? ` (hosts kept: ${input.options.allowHosts.map(mdText).join(", ")})` : ""}. Blocking can drop images a page loads through its own API on another host.`, `- Pages with images still loading at settle show the images that loaded; the rest are not measured.`, ...(videos.length > 0
299
+ ? [`- Video bytes: per view as the page loads them (autoplay: the whole file). The saveable bytes come from 2 s sample encodes: est.`]
300
+ : []), input.options.gpu !== undefined ? `- Quality was scored on the GPU ${input.options.gpu} (with the CPU fallback noted in the page JSON).` : "- Quality was scored on the CPU.", "");
284
301
  return `${lines.join("\n")}\n`;
285
302
  }
package/dist/check.js CHANGED
@@ -7,7 +7,7 @@ import { mkdir, readFile } from "node:fs/promises";
7
7
  import { join, resolve } from "node:path";
8
8
  import { DEFAULT_VIEWPORTS } from "./config.js";
9
9
  import { writeFileAtomic } from "./atomic.js";
10
- import { parseGpuDevice } from "./gpu.js";
10
+ import { gpuSettingOf, gpuSettingOne } from "./gpu.js";
11
11
  import { parseQualities } from "./hints.js";
12
12
  import { BROKEN_EXIT, surveyStop } from "./broken.js";
13
13
  import { DEFAULT_THIRD_PARTY, parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
@@ -36,6 +36,8 @@ export function parseCheckFlags(argv) {
36
36
  const arg = argv[i];
37
37
  if (arg === "--no-cache")
38
38
  args.noCache = true;
39
+ else if (arg === "--gpu-encode")
40
+ args.gpuEncode = true;
39
41
  else if (arg === "--ignore-robots")
40
42
  args.ignoreRobots = true;
41
43
  else if (arg === "--block-third-party")
@@ -44,9 +46,9 @@ export function parseCheckFlags(argv) {
44
46
  else if (arg === "--allow-third-party")
45
47
  continue;
46
48
  else if (arg === "--gpu")
47
- args.gpu ??= { device: 0 };
49
+ args.gpu ??= gpuSettingOne(0);
48
50
  else if (arg === "--gpu-device") {
49
- args.gpu = { device: parseGpuDevice(valueOf(argv, i, arg)) };
51
+ args.gpu = gpuSettingOf(valueOf(argv, i, arg));
50
52
  i++;
51
53
  }
52
54
  else if (arg === "--urls-from" ||
@@ -203,6 +205,7 @@ export async function scanCheckPages(input) {
203
205
  shellWord(surveyDir),
204
206
  ...(settings.thirdParty.block ? ["--block-third-party"] : []),
205
207
  ...(settings.noCache ? ["--no-cache"] : []),
208
+ ...(settings.gpuEncode ? ["--gpu-encode"] : []),
206
209
  ].join(" "),
207
210
  });
208
211
  log(`survey: ${urls.length} URL(s), ${routes.types.length} route type(s)`);
@@ -236,10 +239,11 @@ export async function scanCheckPages(input) {
236
239
  if (page.status === "ok")
237
240
  expected.set(page.url, page.counts.images);
238
241
  const flags = [
239
- ...(settings.gpu !== undefined ? ["--gpu-device", String(settings.gpu.device)] : []),
242
+ ...(settings.gpu !== undefined ? ["--gpu-device", settings.gpu.devices.join(",")] : []),
240
243
  ...(settings.thirdParty.block ? ["--block-third-party"] : []),
241
244
  ...(settings.thirdParty.allowHosts.length > 0 ? ["--allow-host", shellWord(settings.thirdParty.allowHosts.join(","))] : []),
242
245
  ...(settings.noCache ? ["--no-cache"] : []),
246
+ ...(settings.gpuEncode ? ["--gpu-encode"] : []),
243
247
  ...(settings.ignoreRobots ? ["--ignore-robots"] : []),
244
248
  ...(settings.pageConcurrency !== undefined ? ["--page-concurrency", String(settings.pageConcurrency)] : []),
245
249
  ...(settings.idleTimeoutMs !== undefined ? ["--idle-timeout", String(settings.idleTimeoutMs)] : []),
package/dist/cli.js CHANGED
@@ -21,6 +21,10 @@ const progress = (message) => {
21
21
  const OPTION_HELP = {
22
22
  out: ["--out <dir>", "directory for the JSON files (default: .imgtune/scan)"],
23
23
  noCache: ["--no-cache", "do not read or write the result cache in .imgtune/cache (the numbers are computed again)"],
24
+ gpuEncode: [
25
+ "--gpu-encode",
26
+ "EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF); off by default (CPU encodes). Bytes are scaled to a CPU encode (÷1.4)",
27
+ ],
24
28
  root: [
25
29
  "--root <dir>",
26
30
  "folder project source files are read from (default: the nearest folder above the cwd that holds the file, else the nearest with a package.json or .git)",
@@ -58,6 +62,14 @@ const OPTION_HELP = {
58
62
  resume: ["--resume", "reuse the complete pages already in --out that were scanned with the same options"],
59
63
  maxPages: ["--max-pages <N>", "stop after N pages (scanned or resumed); run again with --resume to continue"],
60
64
  maxImages: ["--max-images <N>", "stop after the page that brings the images counted to N (checked between pages)"],
65
+ mediaBudget: [
66
+ "--media-budget <N>",
67
+ "sample-encode at most N animated images and videos per run, heaviest first, 0 to 1000 (default 20); the rest keep their cheap hints",
68
+ ],
69
+ mediaTime: ["--media-time <s>", "time limit of the media encodes, in seconds, 1 to 3600 (default 600)"],
70
+ videoBar: ["--video-bar <n>", "minimum VMAF of a video or animated encode, 0 to 100 (default 93)"],
71
+ videoStillBar: ["--video-still-bar <n>", "minimum SSIMULACRA2 of the still-frame guard, 0 to 100 (default 65)"],
72
+ maxVideoWidths: ["--max-video-widths <n>", "the most widths each video is served at, 1 to 3 (default 2)"],
61
73
  maxQualities: [
62
74
  "--max-qualities <N>",
63
75
  "the most values next.config's images.qualities may list, 1 to 4 (default 2): next/image qualities are clustered, each mapped up",
@@ -95,7 +107,10 @@ const OPTION_HELP = {
95
107
  "--gpu",
96
108
  "score with the GPU scorer (imgtune-ssimu2-gpu, see the docs); opt-in. Falls back to the CPU with a warning",
97
109
  ],
98
- gpuDevice: ["--gpu-device <n>", "the GPU to score on, by CUDA index (implies --gpu; default 0; see the GPU notes)"],
110
+ gpuDevice: [
111
+ "--gpu-device <n[,n]>",
112
+ "the GPU to score on, by CUDA index (implies --gpu; default 0; see the GPU notes). A comma list (0,1) gives each GPU its own encode workers with --gpu-encode; the first scores",
113
+ ],
99
114
  locateOnly: [
100
115
  "--locate-only",
101
116
  "read the source of the flagged images from a dev page (React owner data) or a built page with source maps (prod-sourcemap); not a measurement (needs --from)",
@@ -134,6 +149,7 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
134
149
  "out",
135
150
  "root",
136
151
  "noCache",
152
+ "gpuEncode",
137
153
  "aggressive",
138
154
  "viewports",
139
155
  "survey",
@@ -146,6 +162,11 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
146
162
  "resume",
147
163
  "maxPages",
148
164
  "maxImages",
165
+ "mediaBudget",
166
+ "mediaTime",
167
+ "videoBar",
168
+ "videoStillBar",
169
+ "maxVideoWidths",
149
170
  "maxQualities",
150
171
  "pageConcurrency",
151
172
  "idleTimeout",
@@ -187,6 +208,7 @@ Options:
187
208
  --out <dir> the check directory (default ${DEFAULT_CHECK_DIR}/<timestamp>)
188
209
  --qualities <list> the framework's allowed qualities, e.g. 75,85,100: targets are snapped up to them
189
210
  --gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
211
+ --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
190
212
  --page-concurrency <N> pages at once in the deep phase (default 1)
191
213
  --idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
192
214
  --block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
@@ -222,6 +244,7 @@ Options:
222
244
  --qualities <list> the allowed qualities (default: images.qualities of next.config, else Next's [75])
223
245
  --out <dir> the audit directory (default ${DEFAULT_AUDIT_DIR}/<timestamp>)
224
246
  --gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
247
+ --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
225
248
  --page-concurrency <N> pages at once in the deep phase (default 1)
226
249
  --idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
227
250
  --block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
@@ -252,6 +275,7 @@ Options:
252
275
  --dev <origin> locate the deep pages at the same paths on this dev server (a running dev server)
253
276
  --qualities <list> the framework's allowed qualities, e.g. 75,85,100 (passed to hints)
254
277
  --gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
278
+ --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
255
279
  --page-concurrency <N> pages at once in the survey (default 4) and in the deep phase (default 1)
256
280
  --idle-timeout <ms> cap on the network-idle wait (default 3000 in the survey, 10000 in the deep phase)
257
281
  --no-cache do not read or write the result cache
@@ -298,17 +322,29 @@ against the file itself: 90 (visually lossless) unless --min-score sets another.
298
322
  --write names a folder for optimised copies (existing names are never overwritten).
299
323
  Evidence is "file-mode". Globs: ** walks folders (node_modules and dot folders are skipped).
300
324
 
325
+ Media: an animated GIF, animated WebP or APNG gets the candidates of animated AVIF, animated WebP and AV1 MP4 at --width
326
+ (the file's own width by default), each the lowest-bytes quality that meets the video bars. A video (MP4, MOV, WebM) is
327
+ described; with --reference <original> a candidate is checked against the original: VMAF and the still-frame guard, one
328
+ verdict line "VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail", exit 0 on pass and 1 on fail. Media is local files
329
+ only. The encodes need ffmpeg on PATH; without it the sizes are still measured.
330
+
301
331
  Options:
302
- --width <css px> the CSS width the image is shown at (the width is then the user's, not the file's)
332
+ --width <css px> the CSS width the image is shown at (the width is then the user's, not the file's); for media, the width
333
+ compared or searched at (the candidate's own width with --reference)
303
334
  --dpr <list> device pixel ratios for --width (default 1,2)
304
335
  --formats <list> avif, webp, jpeg (default all three)
305
336
  --min-score <n> the SSIMULACRA2 bar, above 0 and at most 100 (default 90)
306
337
  --write <dir> write optimised copies (best format, at the largest needed width) into this folder
307
- --format <text|json> text (default) or JSON
338
+ --reference <file> compare one media candidate with this original (local file): the verdict line and the exit code
339
+ --video-bar <n> the minimum VMAF of a media encode or check, 0 to 100 (default 93)
340
+ --video-still-bar <n> the still-frame guard's bar, 0 to 100 (default 65), lowered for a source whose own best encode scores low
341
+ --format <text|json> text (default) or JSON (--json is the same)
308
342
  --gpu score with the GPU scorer (opt-in; falls back to the CPU with a warning)
309
- --gpu-device <n> the GPU to score on, by CUDA index (implies --gpu; default 0). CUDA numbers the GPUs fastest first
343
+ --gpu-device <n> the GPU to score on, by CUDA index (implies --gpu; default 0). A comma list is accepted: the first GPU
344
+ scores. CUDA numbers the GPUs fastest first
310
345
  unless CUDA_DEVICE_ORDER=PCI_BUS_ID; with CUDA_VISIBLE_DEVICES set, n indexes the devices it lists.
311
346
  The scorer prints the name of the GPU it opened: check it.
347
+ --gpu-encode EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF), off by default
312
348
  --no-cache do not read or write the result cache in .imgtune/cache
313
349
  -h, --help show this help
314
350
  `;
@@ -355,6 +391,28 @@ Options:
355
391
  --version print the version
356
392
  -h, --help show this help
357
393
  `;
394
+ /** Shell exit codes of a run stopped by a signal: 128 + the signal number. */
395
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143 };
396
+ /** The most a first signal waits for the run to clean up (media scratch, ffmpeg children) before the process exits. */
397
+ const CLEANUP_MS = 2000;
398
+ /** Aborted on the first SIGINT or SIGTERM: the media phase and the image command stop, ffmpeg children are killed and their
399
+ * scratch folders removed (the abort is seen in their `finally` blocks). The process then exits within CLEANUP_MS with the
400
+ * signal's exit code, so a Ctrl-C or a supervisor's SIGTERM always ends the run. A second signal exits at once.
401
+ */
402
+ const interrupt = new AbortController();
403
+ let interrupts = 0;
404
+ /** The first signal received, which sets the exit code of an interrupted run. */
405
+ let received;
406
+ for (const signal of ["SIGINT", "SIGTERM"]) {
407
+ process.on(signal, () => {
408
+ interrupts++;
409
+ received ??= signal;
410
+ if (interrupts > 1)
411
+ process.exit(SIGNAL_EXIT[signal]);
412
+ interrupt.abort();
413
+ setTimeout(() => process.exit(SIGNAL_EXIT[signal]), CLEANUP_MS);
414
+ });
415
+ }
358
416
  async function main() {
359
417
  const argv = process.argv.slice(2);
360
418
  if (argv.includes("--version")) {
@@ -415,9 +473,9 @@ async function main() {
415
473
  return;
416
474
  }
417
475
  if (argv[0] === "image") {
418
- const outcome = await imageCommand(argv.slice(1), process.cwd());
476
+ const outcome = await imageCommand(argv.slice(1), process.cwd(), undefined, undefined, interrupt.signal);
419
477
  process.stdout.write(outcome.output);
420
- process.exitCode = outcome.exitCode;
478
+ process.exitCode = received !== undefined ? SIGNAL_EXIT[received] : outcome.exitCode;
421
479
  return;
422
480
  }
423
481
  if (argv[0] === "hints") {
@@ -455,9 +513,9 @@ async function main() {
455
513
  process.exitCode = 2;
456
514
  return;
457
515
  }
458
- const outcome = await scanCommand(argv.slice(1), process.cwd(), progress);
516
+ const outcome = await scanCommand(argv.slice(1), process.cwd(), progress, interrupt.signal);
459
517
  process.stdout.write(outcome.output);
460
- process.exitCode = outcome.exitCode;
518
+ process.exitCode = received !== undefined ? SIGNAL_EXIT[received] : outcome.exitCode;
461
519
  }
462
520
  main().catch((err) => {
463
521
  if (err instanceof ChromiumMissingError) {