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 +74 -0
- package/README.md +41 -0
- package/dist/animated.js +176 -0
- package/dist/backgrounds.js +13 -2
- package/dist/check-report.js +20 -3
- package/dist/check.js +8 -4
- package/dist/cli.js +66 -8
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +495 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +418 -6
- package/dist/image-file.js +154 -34
- package/dist/images.js +27 -2
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/media-encode.js +1050 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +247 -0
- package/dist/media-phase.js +652 -0
- package/dist/media-prepare.js +31 -0
- package/dist/recheck.js +3 -3
- package/dist/remote.js +474 -69
- package/dist/result-cache.js +56 -3
- package/dist/run.js +8 -4
- package/dist/summary.js +23 -3
- package/dist/video-analysis.js +273 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +1 -1
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
|
|
package/dist/animated.js
ADDED
|
@@ -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
|
+
}
|
package/dist/backgrounds.js
CHANGED
|
@@ -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
|
|
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));
|
package/dist/check-report.js
CHANGED
|
@@ -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.`,
|
|
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 {
|
|
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 ??=
|
|
49
|
+
args.gpu ??= gpuSettingOne(0);
|
|
48
50
|
else if (arg === "--gpu-device") {
|
|
49
|
-
args.gpu =
|
|
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",
|
|
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: [
|
|
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
|
-
--
|
|
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).
|
|
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) {
|