imgtune 0.2.0 → 0.3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +69 -6
  3. package/dist/audit.js +1 -0
  4. package/dist/backgrounds.js +31 -7
  5. package/dist/broken.js +5 -1
  6. package/dist/browser.js +4 -4
  7. package/dist/candidates.js +25 -0
  8. package/dist/check-report.js +37 -4
  9. package/dist/check.js +18 -1
  10. package/dist/cli.js +20 -0
  11. package/dist/egress-proxy.js +2 -1
  12. package/dist/ffmpeg.js +35 -0
  13. package/dist/hints.js +294 -18
  14. package/dist/image-open.js +9 -0
  15. package/dist/images.js +116 -7
  16. package/dist/layout.js +7 -7
  17. package/dist/lcp.js +2 -1
  18. package/dist/measure.js +39 -1
  19. package/dist/media-encode.js +5 -1
  20. package/dist/media-model.js +8 -4
  21. package/dist/media-phase.js +25 -2
  22. package/dist/px.js +11 -0
  23. package/dist/recheck.js +9 -1
  24. package/dist/remote.js +616 -63
  25. package/dist/replay.js +20 -0
  26. package/dist/resources/audio.js +248 -0
  27. package/dist/resources/brotli.js +22 -0
  28. package/dist/resources/cache.js +59 -0
  29. package/dist/resources/capture.js +225 -0
  30. package/dist/resources/compression.js +122 -0
  31. package/dist/resources/favicon.js +130 -0
  32. package/dist/resources/font.js +440 -0
  33. package/dist/resources/index.js +8 -0
  34. package/dist/resources/lottie.js +491 -0
  35. package/dist/resources/model.js +478 -0
  36. package/dist/resources/phase.js +171 -0
  37. package/dist/resources/pool.js +47 -0
  38. package/dist/resources/report.js +57 -0
  39. package/dist/resources/sfnt.js +305 -0
  40. package/dist/resources/svg.js +178 -0
  41. package/dist/resources/types.js +1 -0
  42. package/dist/resources/zip.js +67 -0
  43. package/dist/run.js +24 -4
  44. package/dist/sizes.js +7 -5
  45. package/dist/summary.js +47 -1
  46. package/dist/vector-art.js +294 -0
  47. package/dist/vector-phase.js +222 -0
  48. package/dist/video-analysis.js +119 -21
  49. package/package.json +25 -4
  50. package/skills/imgtune/SKILL.md +8 -4
  51. package/skills/imgtune/reference.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (date TBD)
4
+
5
+ - Page weight, not only images. `scan` now reports what else a page downloads, each with its bytes per view and a fix an
6
+ agent can apply: images and video that are loaded but never shown, fonts, compression, favicons, vector art, SVG, Lottie
7
+ files, 3D models and audio. Images and video stay the headline. The positioning is in the README, the site and the skill.
8
+ - Loaded but not shown: images and videos. `scan` reports the images and `<video>` elements the browser downloads but no
9
+ viewport shows: `display:none`, `visibility:hidden`, `opacity: 0`, a zero-size box, an offscreen slide of a track, a closed
10
+ `<details>`, `content-visibility: hidden`, a hover swap, a tab panel, and a CSS background layer whose file a hidden element
11
+ loaded. A copy counts as hidden only if it reads hidden on two reads at least 1 s apart and again after a scroll; anything
12
+ that toggles (a carousel, a crossfade, a reveal) counts as shown, and a copy seen visible at any step of the scroll pass is
13
+ shown. Each case gets its own fix: a `<picture>` with a media condition for a responsive twin, `loading="lazy"` or render on
14
+ activation for a carousel, tab or hover copy, and removal or an on-demand load for an image no viewport shows on load. A
15
+ hidden copy's bytes are kept apart from the savings, and it gets no sizes, quality or encode claim at the viewports that hide
16
+ it. An image that already has `loading="lazy"` is no longer advised to get it. The JSON gains `images[].hidden` and
17
+ `videos[].hidden`. See the Hidden images guide.
18
+ - Video: the claim is per viewport. A `<video>`'s per-view bytes are counted at each viewport (what that viewport loads) and
19
+ averaged over the viewports, the same as images. A desktop and a mobile copy of one video count once. A video that only some
20
+ viewports load therefore claims less than it did in 0.2. A video no viewport shows is never encoded (`never-shown`).
21
+ - `<source media>` support is stated as reported in the hints: Chrome and Edge 120 or later, Firefox 120 or later, Safari
22
+ (version not verified).
23
+ - Fonts. `scan` reports each web font file's bytes per view and its savings: a re-encode to WOFF2 (measured for small files,
24
+ estimated for large ones), a subset to the Unicode scripts the page's text uses (the subset keeps Basic Latin, Latin-1,
25
+ punctuation and currency), and faces the browser downloaded but no rendered text uses. Icon fonts are not subset, and a
26
+ family of several static weights is noted as a candidate for one variable font, with no saving. Each finding names the
27
+ command and the expected bytes. See the Fonts guide.
28
+ - Compression. Text served with no `Content-Encoding` (JavaScript, CSS, HTML, JSON, XML, SVG, WASM, TTF and OTF, at least 1 KB)
29
+ is reported with its brotli-11 saving, grouped per host and content type. The fix is the server or CDN setting. See the
30
+ Compression guide.
31
+ - Favicons. A favicon over 10 KB, or an apple-touch-icon over 30 KB, is reported with the saving of a re-encode to 32 px or
32
+ 180 px. Each icon is fetched once per run. See the Favicons guide.
33
+ - Vector art. A still raster that is flat (a few colours cover most of it: logos, icons, flat illustrations) gets a vector
34
+ check. `scan` traces the heaviest such images (`--svg-budget`, default 50, 0 to 1000; 0 traces none) with vtracer, through
35
+ the optional `@neplex/vectorizer` dependency, and renders each SVG back at the served size. The fidelity is SSIMULACRA2 at
36
+ half size against the served image (bar 70). A passing SVG must be at least 30% smaller than the served file, and its saving
37
+ is the record's claim when it is the larger one; the raster fix stays as the fallback. The traced file is written to
38
+ `svg/<id>.svg` in the scan folder. Without the vectorizer, a flat image gets one note and no byte claim. `fix.vector` in the
39
+ JSON, a `vector` field on hints, and the summary and the check report count the flat and traced images. See the Vector art
40
+ guide.
41
+ - SVG files. Each SVG of 2 KB to 5 MB is optimised with SVGO (floatPrecision 2) and reported when the brotli size drops by at
42
+ least 20% and the render still scores 70 or more on SSIMULACRA2 at half size. SVGs with embedded `data:` rasters are listed.
43
+ Inline `<svg>` is not checked.
44
+ - Lottie animations. `scan` reads each Lottie JSON and `.lottie` file the page loads: the size, layers, frame rate and duration,
45
+ and the savings: embedded rasters re-encoded to WebP at the display size, numbers rounded to three decimals, and a `.lottie`
46
+ repack (an estimate). A huge animation shown small gets a hint to export at the display size. See the Lottie guide.
47
+ - 3D models. `scan` checks glTF and GLB files: the geometry saving with meshopt is measured (the file is repacked with
48
+ gltf-transform), base64 buffers get the GLB repack, and PNG or JPEG textures get a WebP estimate. Draco and meshopt files are
49
+ reported as compressed. USDZ, OBJ, FBX, KTX2, splats and PLY are informational. See the 3D models guide.
50
+ - Audio. `scan` probes each audio file with ffprobe. WAV and FLAC, lossy files above 128 kbps stereo (64 kbps mono), and stereo
51
+ files with identical channels get an Opus re-encode, measured on a 10 s sample. A file the page loads on page load gets the
52
+ `preload="none"` fix. Without ffmpeg only the bytes are reported. See the Audio guide.
53
+ - Resources, as one group. The non-image checks above run on every response the page makes, and their findings are reported in
54
+ a Resources section (after Video), in the JSON as `resources`, and in a Resources block of the PR comment. They are not in
55
+ the image totals. A file on another site is labelled "third-party (ask the vendor)" and totalled apart from first-party
56
+ savings; a small saving of a kind that is weak for its cost (SVG, Lottie, favicons) is a "lead" and sorts after the real
57
+ savings. The heavy steps (brotli-11 sizes, SVGO and render, WOFF2 and subset encodes, Lottie re-encodes, 3D meshopt, audio
58
+ sample encodes) are computed once per run for each distinct content, not once per viewport and page. `--resource-time <s>`
59
+ (default 60) caps the heavy work of a run; a file over the budget keeps its cheap finding, marked as an estimate or not
60
+ measured. The resources overview is in the guide.
61
+ - A plain `<img>` with no `srcset`, served much wider than it is shown (from a same-origin file or a CDN host no adapter knows),
62
+ gets resized variants instead of a `sizes` change. `sizes` does nothing without `srcset`. The hint says to serve the variants
63
+ at concrete widths (1 to 3, from the device pixels each viewport needs) and to add the `srcset` and `sizes`. The summary's top
64
+ group reads `no srcset -> serve <widths> + srcset/sizes`, and the saving is booked as `resize`, not `quality`. Known loaders and
65
+ pages with Next.js detected keep their advice. The same resize booking applies to CSS background layers. The JSON gains
66
+ `bytes.resize` and `srcset`.
67
+ - Measuring browser: `--disable-software-rasterizer` is no longer passed to Chromium (`--disable-gpu` stays). With it WebGL was
68
+ off, so a page with a 3D viewer never requested its model. Only WebGL pages pay the extra CPU.
69
+ - Flags. `--no-resources` skips the resource checks and their capture (`scan`, `check`, `run` and `audit`). A page scanned that
70
+ way is recorded with `resources: false`. `--resource-time <s>` (`scan`, `check`, `run`, `audit`; default 60, 0 to 3600) caps
71
+ the heavy resource work. `--svg-budget <N>` (`scan`, `check`, `run`, `audit`; default 50, 0 to 1000) sets how many flat images
72
+ are traced.
73
+ - Optional dependencies. `@neplex/vectorizer` (0.1.0) is used for the vector trace. `@gltf-transform/core`,
74
+ `@gltf-transform/extensions`, `@gltf-transform/functions` and `meshoptimizer` are used for the 3D measurements. Each is loaded
75
+ when a check needs it. If one is missing, its check gives one note and no measured saving; the rest of the scan runs.
76
+ - Smaller fixes: a hidden copy's hints carry its URL and its bytes per viewport (`revealCapped` marks candidates past the
77
+ per-viewport limit that were not re-read after a scroll); a `<details>` closed on load is not counted as shown. A failed
78
+ linked stylesheet makes a page `broken` only when no linked stylesheet applied (otherwise it is a warning); a challenge page
79
+ (WAF or Cloudflare) is `blocked` whatever its images; a search the time limit cut short is labelled "est., search stopped at
80
+ the time limit"; a page that reloads itself is read between its reloads.
81
+
82
+ ### Changed for 0.2 users
83
+
84
+ - The JSON gains `resources` (a page file's findings, present unless the scan ran with `--no-resources`), `images[].hidden` and
85
+ `videos[].hidden`, `fix.vector` on a still raster that looks like vector art, and `bytes.resize` (with `srcset`) on a plain
86
+ image with no `srcset`. Existing fields keep their meaning, except as noted below.
87
+ - Video numbers change: the per-view claim is averaged over the viewports (see above), so a video loaded at some viewports only
88
+ claims less than in 0.2. Compare the `videos[].bytes.perView` of a 0.2 run with a 0.3 run only with that in mind.
89
+ - Plain images with no `srcset`: the saving moves from `quality` (and `sizes`) to `resize`, so the quality total of a page can
90
+ fall by the same amount. The hint asks for resized variants, not a `sizes` change.
91
+ - Hidden images and videos used to be dropped silently. They are now reported, in their own lines and in the JSON, and kept out
92
+ of the savings, so a page's totals can differ from 0.2 even when no image changed.
93
+ - The Chromium flags changed: WebGL is on for the scan (see above). A page with a WebGL viewer may now load more bytes than it
94
+ did in 0.2, and those bytes are reported.
95
+ - The result cache: `CACHE_VERSION` is still 3, but the cache keys include the imgtune version, so 0.2 entries are not reused and
96
+ the first 0.3 scan is cold.
97
+ - New flags: `--no-resources`, `--resource-time` and `--svg-budget`. See the CLI reference.
98
+ - New optional dependencies: `@neplex/vectorizer`, `@gltf-transform/core`, `@gltf-transform/extensions`, `@gltf-transform/functions`
99
+ and `meshoptimizer`. They install with the package as optional dependencies; none is required.
100
+
3
101
  ## 0.2.0 (2026-11-14)
4
102
 
5
103
  - Animated media and video. `scan` measures animated GIFs (in an `<img>` or a CSS background), animated WebP and APNG, and
package/README.md CHANGED
@@ -2,20 +2,32 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/imgtune.svg)](https://www.npmjs.com/package/imgtune)
4
4
 
5
- **ImgTune measures image waste on any live site.**
5
+ **imgtune measures page weight on any live site: images first, and everything else the page downloads.**
6
6
 
7
7
  Website: https://indicozy.github.io/imgtune/ · [Try the demo](https://indicozy.github.io/imgtune/demo) · [Docs](https://indicozy.github.io/imgtune/guide/getting-started)
8
8
 
9
- Lighthouse tells you your images are too big. ImgTune tells you the `sizes`, quality and format to set for each image,
10
- points at the line of code that renders it (when your dev server is running), and checks each quality target with
11
- SSIMULACRA2. Your coding agent applies the change, and a rescan checks it.
9
+ Lighthouse tells you your page is heavy. imgtune tells you which bytes to cut and how: the `sizes`, quality and format for each image, the codec and width for each video, images and videos that load but are never shown, fonts to subset or drop, files served without compression, and 3D models, audio, Lottie and SVG that could be smaller. Each fix comes with the expected bytes and, for images and video with your dev server running, the line of code to change. Your coding agent applies it, and a rescan checks it.
12
10
 
13
11
  **Works with your image CDN.** Cloudinary, imgix and `next/image` resize to whatever width `sizes` asks for, so a wrong
14
12
  `sizes` is waste that no CDN removes. ImgTune works on top of a CDN, not instead of one. Already on Cloudinary auto
15
13
  quality (`q_auto`)? ImgTune focuses on `sizes`, which no CDN can fix for you. A format the CDN picks per request
16
14
  (Cloudinary `f_auto`, imgix `auto=format`) is not proposed as a change.
17
15
 
18
- **Built for image-heavy sites:** e-commerce catalogs, media, marketplaces.
16
+ **Built for heavy sites:** e-commerce catalogs, media, marketplaces, and marketing pages with video and 3D.
17
+
18
+ **What it checks**, one guide each:
19
+
20
+ - [Images](https://indicozy.github.io/imgtune/guide/how-it-measures)
21
+ - [Video and animated images](https://indicozy.github.io/imgtune/guide/video-and-gifs)
22
+ - [Loaded but not shown](https://indicozy.github.io/imgtune/guide/hidden-images)
23
+ - [Fonts](https://indicozy.github.io/imgtune/guide/fonts)
24
+ - [Compression](https://indicozy.github.io/imgtune/guide/compression)
25
+ - [Vector art (raster to SVG)](https://indicozy.github.io/imgtune/guide/vector-art)
26
+ - [SVG](https://indicozy.github.io/imgtune/guide/svg)
27
+ - [Lottie](https://indicozy.github.io/imgtune/guide/lottie)
28
+ - [3D models](https://indicozy.github.io/imgtune/guide/3d-models)
29
+ - [Audio](https://indicozy.github.io/imgtune/guide/audio)
30
+ - [Favicons](https://indicozy.github.io/imgtune/guide/favicons)
19
31
 
20
32
  ## Two ways to measure
21
33
 
@@ -129,6 +141,14 @@ desktop at 1x), scrolls it to the end, and records every image.
129
141
  - Production check: `imgtune check` (see [Two ways to measure](#two-ways-to-measure)) and `imgtune audit` do the same
130
142
  survey and deep scan for a whole site, with no source needed for `check`.
131
143
 
144
+ ## Resources
145
+
146
+ `scan` also reads the page's other responses (not images) and runs seven checks on them: compression, favicons, fonts,
147
+ SVG, Lottie, 3D models and audio. They are reported on their own, not in the image totals. Guides:
148
+ [compression](https://indicozy.github.io/imgtune/guide/compression), [favicons](https://indicozy.github.io/imgtune/guide/favicons), [fonts](https://indicozy.github.io/imgtune/guide/fonts), [SVG](https://indicozy.github.io/imgtune/guide/svg), [Lottie](https://indicozy.github.io/imgtune/guide/lottie),
149
+ [3D models](https://indicozy.github.io/imgtune/guide/3d-models) and [audio](https://indicozy.github.io/imgtune/guide/audio). The [resources overview](https://indicozy.github.io/imgtune/guide/resources) covers the labels, the budget
150
+ and the flags: `--no-resources` turns all seven off, and `--resource-time` caps the heavy work.
151
+
132
152
  ## The evidence ladder
133
153
 
134
154
  Every fix hint is labelled with the strength of its evidence, the rung it stands on:
@@ -143,6 +163,35 @@ Every fix hint is labelled with the strength of its evidence, the rung it stands
143
163
  A ceiling is never derived from the existing `sizes` value. When a usage is measured, its `sizes` comes from the layout
144
164
  the browser resolved, not from a guess.
145
165
 
166
+ ## Lottie and SVG
167
+
168
+ `scan` also reads the Lottie animations and the SVG files a page loads. A Lottie report gives the size, layers, frame rate and
169
+ duration, and its savings: embedded rasters re-encoded to WebP at the display size, a `.lottie` repack (an estimate), and
170
+ numbers rounded to three decimals. An SVG is optimised with SVGO and reported when the brotli size drops by 20% or more and
171
+ the render still scores 70 or more on SSIMULACRA2 at half size. Guides: [Lottie](https://indicozy.github.io/imgtune/guide/lottie) and [SVG](https://indicozy.github.io/imgtune/guide/svg).
172
+
173
+ ## 3D models and audio
174
+
175
+ `scan` also reads the 3D models and audio files a page loads. A glTF or GLB gets a measured meshopt saving (the file is
176
+ repacked with gltf-transform), base64 buffers get the GLB repack, and PNG or JPEG textures get a WebP estimate. An audio
177
+ file that is lossless, above 128 kbps stereo, or stereo with identical channels gets an Opus re-encode, measured on a 10 s
178
+ sample with ffmpeg. Guides: [3D models](https://indicozy.github.io/imgtune/guide/3d-models) and [audio](https://indicozy.github.io/imgtune/guide/audio).
179
+
180
+ ## Vector art
181
+
182
+ A flat logo, icon or illustration served as PNG, WebP or JPEG can often be an SVG: smaller, and sharp at every density.
183
+ `scan` traces the heaviest flat images (`--svg-budget`, default 50) with an optional dependency, `@neplex/vectorizer`,
184
+ renders each SVG back, and keeps it only when it scores at least 70 at half size and is at least 30% smaller. A passing
185
+ trace is written to `svg/` in the scan folder. Without the dependency, a flat image gets one note and no byte claim. See [the guide](https://indicozy.github.io/imgtune/guide/vector-art).
186
+
187
+ ## Loaded but not shown
188
+
189
+ `scan` also finds the images the browser downloads without showing: a desktop image hidden on phones (or the reverse), an
190
+ offscreen slide of a carousel, a tab panel, an element with `opacity: 0` or `visibility: hidden`, and a zero-size box. Their
191
+ bytes per view are reported on their own line ("loaded but not shown") and kept out of the savings. Each one gets the fix
192
+ for its case: a `<picture>` with a media condition for a responsive twin, `loading="lazy"` or render-on-activation for a
193
+ carousel, tab or hover copy, and an on-demand load or removal for an image no viewport shows on load. See [the guide](https://indicozy.github.io/imgtune/guide/hidden-images).
194
+
146
195
  ## GIFs and video
147
196
 
148
197
  `scan` also measures animated media: animated GIFs in an `<img>` or a CSS background, animated WebP and APNG, and
@@ -165,7 +214,8 @@ self-hosted `<video>` elements with a plain file source. Static GIFs get the nor
165
214
  the animated-image alternative with its size. A small `<img>` GIF or a CSS background gets an animated AVIF or WebP. A
166
215
  `<video>` hint names each lever: the width per breakpoint (`<source media>`), the CRF, the codec, the audio track of a
167
216
  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,
217
+ - **Bytes: per view and per play.** Per view is counted at each viewport and averaged over the viewports, as for images. An
218
+ autoplay video counts its full file,
169
219
  `preload="auto"` counts the bytes the scan saw (browser-dependent), and `preload="metadata"` or `"none"` counts 0. Per
170
220
  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
221
  in the image totals; `<video>` has its own Video section, and posters count as images.
@@ -183,6 +233,19 @@ self-hosted `<video>` elements with a plain file source. Static GIFs get the nor
183
233
  - **Check an agent's output** with `imgtune image <candidate> --reference <original>`: the same VMAF and still-frame bar,
184
234
  exit 0 on pass and 1 on fail (see the table below).
185
235
 
236
+ ## Fonts
237
+
238
+ `scan` reports each web font file the page downloads: its bytes per view, and what it could save.
239
+
240
+ - **WOFF2:** a TTF, OTF or WOFF that is not WOFF2 is re-encoded (measured for small files, estimated for large ones).
241
+ - **Subsetting:** a font with far more glyphs than the page's scripts need (the Unicode blocks its text uses, plus Basic
242
+ Latin, Latin-1, punctuation and currency) is subset to them. The fix gives the `pyftsubset` command.
243
+ - **Unused faces:** a face the browser downloaded but no rendered text uses is wasted bytes. Icon fonts, and faces the
244
+ browser rendered with (even as a fallback), are never called unused.
245
+ - **Static weights:** three or more static faces of one family are noted as a candidate for one variable font (no saving).
246
+
247
+ See [the Fonts guide](https://indicozy.github.io/imgtune/guide/fonts).
248
+
186
249
  ## Commands
187
250
 
188
251
  | command | what it does |
package/dist/audit.js CHANGED
@@ -232,6 +232,7 @@ async function preflight(prod, settings, outRoot, cwd, log, storageState) {
232
232
  ignoreRobots: settings.ignoreRobots,
233
233
  aggressive: false,
234
234
  quality: false,
235
+ resources: false,
235
236
  thirdParty: settings.thirdParty,
236
237
  outDir: join(outRoot, "preflight"),
237
238
  viewports: viewportsOf(plan.viewports),
@@ -6,7 +6,7 @@
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 { losslessServed, rungOfPage, searchHintsOf } from "./images.js";
9
+ import { hiddenFixCore, losslessServed, rungOfPage, searchHintsOf } from "./images.js";
10
10
  import { animatedTarget } from "./media-model.js";
11
11
  import { loaderName, sanitizeText, urlPattern } from "./locate.js";
12
12
  /** The most background elements read per viewport: a page with more keeps the first of them in document order. */
@@ -264,7 +264,8 @@ export function buildBackgroundRecords(input) {
264
264
  }
265
265
  const records = [];
266
266
  for (const [key, members] of byKey) {
267
- const member = members.find((m) => m.viewport.name === refName) ?? members[0];
267
+ // The reference member is shown when some viewport shows the layer: a hidden copy names no size.
268
+ const member = members.find((m) => m.viewport.name === refName && m.hidden === undefined) ?? members.find((m) => m.hidden === undefined) ?? members[0];
268
269
  const source = sources.get(member.bg.index);
269
270
  const url = member.url;
270
271
  const perViewport = [];
@@ -287,8 +288,22 @@ export function buildBackgroundRecords(input) {
287
288
  entry.overFetchRatio = Math.round((inst.pixelWidth / inst.needed) * 100) / 100;
288
289
  if (inst.skipped !== undefined)
289
290
  entry.skipped = inst.skipped;
291
+ if (inst.hidden !== undefined)
292
+ entry.hidden = inst.hidden;
290
293
  perViewport.push(entry);
291
294
  }
295
+ // A layer's entries: the viewports it is shown at, and those it is hidden at with its file downloaded (a hidden layer that
296
+ // was not downloaded is not an entry: there are no bytes to report).
297
+ const entries = members
298
+ .filter((m) => m.hidden !== undefined || m.skipped !== "hidden")
299
+ .map((m) => ({
300
+ viewport: { name: m.viewport.name, width: m.viewport.width },
301
+ bytes: m.bytes,
302
+ hidden: m.hidden,
303
+ inPanel: m.inPanel === true,
304
+ hover: m.hover === true,
305
+ }));
306
+ const hiddenFix = hiddenFixCore(entries, input.viewports.length);
292
307
  const saved = members.reduce((sum, m) => sum + m.est, 0) / viewportCount;
293
308
  const served = member.format;
294
309
  const animatedMember = members.find((m) => m.animated !== undefined);
@@ -324,14 +339,15 @@ export function buildBackgroundRecords(input) {
324
339
  },
325
340
  perViewport,
326
341
  fix: {
327
- width: member.needed > 0 ? member.needed : undefined,
328
- bytes: { saved, addedForSharpness: 0, sizes: 0, quality: saved },
342
+ width: member.hidden === undefined && member.needed > 0 ? member.needed : undefined,
343
+ // A layer's saving is a resize to its needed width (est.), not a re-encode: booked as resize.
344
+ bytes: { saved, addedForSharpness: 0, sizes: 0, resize: saved, quality: 0 },
329
345
  },
330
346
  evidence: { rung: rungOfPage(pageUrl, input.harness), notes },
331
347
  };
332
348
  if (best !== undefined)
333
349
  record.fix.format = best;
334
- if (animatedMember?.animated !== undefined) {
350
+ if (animatedMember?.animated !== undefined && hiddenFix?.shownIn.length !== 0) {
335
351
  record.fix.animated = {
336
352
  info: animatedMember.animated,
337
353
  context: "css-background",
@@ -339,12 +355,20 @@ export function buildBackgroundRecords(input) {
339
355
  perViewBytes: members.reduce((sum, m) => sum + m.bytes, 0) / viewportCount,
340
356
  };
341
357
  }
358
+ // A hidden layer is not a vector claim: the vector check and the hidden fix never stack on one record.
359
+ if (hiddenFix !== undefined)
360
+ record.hidden = hiddenFix;
361
+ else {
362
+ const flatMember = members.find((m) => m.flatness?.flat === true);
363
+ if (flatMember?.flatness !== undefined)
364
+ record.fix.vector = { flatness: { ...flatMember.flatness } };
365
+ }
342
366
  records.push(record);
343
367
  }
344
368
  return records.filter((r) => r.perViewport.some((p) => p.loaded || p.skipped !== undefined));
345
369
  }
346
370
  export function backgroundTotals(instances) {
347
- const measured = instances.filter((i) => i.skipped === undefined);
371
+ const measured = instances.filter((i) => i.skipped === undefined && i.hidden === undefined);
348
372
  return {
349
373
  measured: measured.length,
350
374
  servedBytes: measured.reduce((sum, i) => sum + i.bytes, 0),
@@ -353,7 +377,7 @@ export function backgroundTotals(instances) {
353
377
  };
354
378
  }
355
379
  export function backgroundPage(instances, viewportCount, records) {
356
- const measured = instances.filter((i) => i.skipped === undefined);
380
+ const measured = instances.filter((i) => i.skipped === undefined && i.hidden === undefined);
357
381
  return {
358
382
  layers: records.filter((r) => r.perViewport.some((p) => p.loaded)).length,
359
383
  servedBytes: measured.reduce((sum, i) => sum + i.bytes, 0) / viewportCount,
package/dist/broken.js CHANGED
@@ -25,7 +25,11 @@ export function brokenOf(result, expectedImages) {
25
25
  fix: "the URL does not serve this page: check the path, and that the server being scanned serves this route",
26
26
  };
27
27
  }
28
- if (result.stylesheetsFailed !== undefined && Object.keys(result.stylesheetsFailed).length > 0) {
28
+ // A failed stylesheet breaks the page only when the page is unstyled (no linked stylesheet applied) or empty (no images).
29
+ // A partly styled page with a failed sheet, usually a third-party CDN's, is measured and warned about (the scan prints it).
30
+ const unstyled = (result.stylesheetsLoaded ?? 0) === 0;
31
+ const empty = result.counts.images === 0;
32
+ if (result.stylesheetsFailed !== undefined && Object.keys(result.stylesheetsFailed).length > 0 && (unstyled || empty)) {
29
33
  const hosts = result.stylesheetHosts ?? [];
30
34
  // The most failed in any one view: the same sheet at two viewports is one sheet, not two.
31
35
  const count = Math.max(...Object.values(result.stylesheetsFailed));
package/dist/browser.js CHANGED
@@ -14,11 +14,11 @@ export function mapLaunchError(err) {
14
14
  return /Executable doesn['’]t exist/.test(text) ? new ChromiumMissingError() : err;
15
15
  }
16
16
  /**
17
- * Chromium flags for measuring. `--disable-software-rasterizer` stops Chrome from starting SwiftShader. Its worker
18
- * threads get a CPU set of their own from Chrome, which can fall outside the CPUs the CLI was started on.
19
- * Measurements (layout, rendered sizes, transferred bytes, image decoding) do not depend on the rasterizer.
17
+ * Chromium flags for measuring. `--disable-gpu` keeps the GPU process out. The software rasterizer (SwiftShader) is left
18
+ * on: with `--disable-software-rasterizer` WebGL is off, and a page with a 3D viewer never requests its model (a census
19
+ * site made 5 model requests with the default flags and none without). Only WebGL pages pay the extra CPU for it.
20
20
  */
21
- export const CHROMIUM_ARGS = ["--disable-gpu", "--disable-software-rasterizer"];
21
+ export const CHROMIUM_ARGS = ["--disable-gpu"];
22
22
  /** The headless Chromium every measuring command uses. */
23
23
  export async function launchChromium() {
24
24
  try {
@@ -13,6 +13,31 @@ export function rightCandidate(candidates, needed) {
13
13
  const sorted = [...candidates].sort((a, b) => a.width - b.width);
14
14
  return sorted.find((c) => c.width >= needed) ?? sorted[sorted.length - 1];
15
15
  }
16
+ /** The device-pixel step a plain image's variant widths are rounded up to. */
17
+ export const SRCSET_WIDTH_STEP = 10;
18
+ /** A plain image gets at most this many variant widths, one file each. */
19
+ export const SRCSET_MAX_WIDTHS = 3;
20
+ /** The widths of a srcset as printed: "190w, 270w, 380w". */
21
+ export function srcsetWidthLabel(widths) {
22
+ return widths.map((w) => `${w}w`).join(", ");
23
+ }
24
+ /**
25
+ * The widths the variants of a plain image (no srcset today) need: each device-pixel need rounded up to SRCSET_WIDTH_STEP
26
+ * and capped at the file's own width. More than SRCSET_MAX_WIDTHS are merged by joining the closest pair and keeping the
27
+ * larger, so every need is still covered by a file at least as wide. Sorted ascending; empty when no need is known.
28
+ */
29
+ export function srcsetWidthsOf(needs, naturalWidth) {
30
+ const cap = naturalWidth !== undefined && naturalWidth > 0 ? naturalWidth : Infinity;
31
+ const widths = [...new Set(needs.filter((n) => n > 0).map((n) => Math.min(Math.ceil(n / SRCSET_WIDTH_STEP) * SRCSET_WIDTH_STEP, cap)))].sort((a, b) => a - b);
32
+ while (widths.length > SRCSET_MAX_WIDTHS) {
33
+ let at = 0;
34
+ for (let i = 1; i < widths.length - 1; i++)
35
+ if (widths[i + 1] - widths[i] < widths[at + 1] - widths[at])
36
+ at = i;
37
+ widths.splice(at, 1);
38
+ }
39
+ return widths;
40
+ }
16
41
  /** The served file is undersized: its pixels are below 0.9 x need, and a larger candidate exists. */
17
42
  export function isUndersized(servedWidth, needed, largest) {
18
43
  return servedWidth < UNDERSIZED_RATIO * needed && servedWidth < largest;
@@ -1,12 +1,14 @@
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, videoRowOf, videoSavingOf, videoTotalsLine } from "./hints.js";
4
+ import { groupSrcsetOf, hiddenPagesLine, hiddenVideoSummaryLine, hintsOf, videoRowOf, videoSavingOf, videoTotalsLine } from "./hints.js";
5
+ import { srcsetWidthLabel } from "./candidates.js";
5
6
  import { lcpLinesOf } from "./lcp.js";
6
7
  import { planQualityCap, qualitiesLine } from "./qualities-cap.js";
7
8
  import { hasFix } from "./images.js";
8
9
  import { APPROX_MARK, siteNumbers } from "./remote.js";
9
10
  import { errorLine, mdCode, mdText, safeText } from "./text.js";
11
+ import { resourceTotalsLine } from "./resources/report.js";
10
12
  /** How many fixes, heavy pages, sharpness images and survey route types the report lists in full. */
11
13
  export const REPORT_FIXES = 15;
12
14
  export const REPORT_SHARPNESS = 15;
@@ -66,6 +68,7 @@ export function fixGroupsOf(deep, qualities) {
66
68
  const pages = [...new Set(group.hints.map((h) => h.page))];
67
69
  const saved = group.hints.reduce((sum, h) => sum + h.bytes.saved, 0) / 1024;
68
70
  const sizes = group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) / 1024;
71
+ const resize = group.hints.reduce((sum, h) => sum + (h.bytes.resize ?? 0), 0) / 1024;
69
72
  const sharp = group.hints.reduce((sum, h) => sum + h.bytes.addedForSharpness, 0) / 1024;
70
73
  const targets = group.hints.map((h) => h.quality?.target).filter((t) => t !== undefined);
71
74
  const quality = group.hints.find((h) => h.quality !== undefined)?.quality;
@@ -82,6 +85,7 @@ export function fixGroupsOf(deep, qualities) {
82
85
  current,
83
86
  proposed,
84
87
  basis: first.sizes.basis,
88
+ srcset: groupSrcsetOf(group),
85
89
  format: best !== undefined && best !== first.format?.current ? best : undefined,
86
90
  ...(quality !== undefined
87
91
  ? {
@@ -99,7 +103,8 @@ export function fixGroupsOf(deep, qualities) {
99
103
  savedKBSum: saved,
100
104
  savedKBPerPage: perPage(saved),
101
105
  sizesKBPerPage: perPage(sizes),
102
- qualityKBPerPage: perPage(saved - sizes),
106
+ resizeKBPerPage: perPage(resize),
107
+ qualityKBPerPage: perPage(saved - sizes - resize),
103
108
  sharpnessKBPerPage: perPage(sharp),
104
109
  });
105
110
  }
@@ -108,7 +113,11 @@ export function fixGroupsOf(deep, qualities) {
108
113
  /** The line of one fix: where, the sizes and quality change, the saving per view and its pages. */
109
114
  function fixLines(fix, rank) {
110
115
  const where = fix.located ? mdCode(fix.where) : `${mdCode(fix.where)} (not located)`;
111
- const sizes = fix.current === fix.proposed ? "sizes unchanged" : `sizes ${mdCode(fix.current)} -> ${mdCode(fix.proposed)}${fix.basis ? ` (${mdText(fix.basis)})` : ""}`;
116
+ const sizes = fix.srcset !== undefined
117
+ ? `no srcset -> serve ${srcsetWidthLabel(fix.srcset.widths)} + srcset/sizes`
118
+ : fix.current === fix.proposed
119
+ ? "sizes unchanged"
120
+ : `sizes ${mdCode(fix.current)} -> ${mdCode(fix.proposed)}${fix.basis ? ` (${mdText(fix.basis)})` : ""}`;
112
121
  const quality = fix.quality === undefined
113
122
  ? "quality -"
114
123
  : fix.quality.target === undefined
@@ -116,7 +125,8 @@ function fixLines(fix, rank) {
116
125
  : fix.quality.void
117
126
  ? `quality ${fix.quality.current} -> ${fix.quality.target} (snapped: void, counted as 0)`
118
127
  : `quality ${fix.quality.current} -> ${fix.quality.target}${fix.quality.snapFrom !== undefined ? ` (snapped from ${fix.quality.snapFrom}${fix.quality.allowed ? `, allowed ${fix.quality.allowed.join(",")}` : ""})` : ""}`;
119
- const saving = `saves ${kb(fix.savedKBPerPage)} KB/view per page (sizes ${kb(fix.sizesKBPerPage)} + quality ${kb(fix.qualityKBPerPage)})`;
128
+ const split = fix.resizeKBPerPage > 0 ? `sizes ${kb(fix.sizesKBPerPage)} + resize ${kb(fix.resizeKBPerPage)} + quality ${kb(fix.qualityKBPerPage)}` : `sizes ${kb(fix.sizesKBPerPage)} + quality ${kb(fix.qualityKBPerPage)}`;
129
+ const saving = `saves ${kb(fix.savedKBPerPage)} KB/view per page (${split})`;
120
130
  const source = fix.pattern !== undefined ? `, src ${mdCode(fix.pattern)}` : "";
121
131
  const where2 = `${fix.pages.length} page(s), ${fix.images} image(s), loader ${mdText(fix.loader)}${source}`;
122
132
  return [
@@ -157,6 +167,9 @@ export function buildCheckReport(input) {
157
167
  const media = [...input.survey, ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
158
168
  if (media !== undefined)
159
169
  out(mdText(media), "");
170
+ const vectorNote = input.deep.find((p) => p.vectorNote !== undefined)?.vectorNote;
171
+ if (vectorNote !== undefined)
172
+ out(mdText(vectorNote), "");
160
173
  if (input.stop !== undefined) {
161
174
  out("## Stopped before the deep phase", "", `${mdText(input.stop.reason)}`, `Fix: ${mdText(input.stop.fix)}`, "");
162
175
  }
@@ -174,6 +187,9 @@ export function buildCheckReport(input) {
174
187
  const exactEst = mean(deepTotals.map((t) => t.exactEstKB));
175
188
  const numbers = siteNumbers(deepOk.map((p) => p.page));
176
189
  out(`- Deep (${deepOk.length} page(s), viewports ${input.options.deepViewports.map(mdText).join(", ")}): mean image ${kb(numbers.meanImageBytes / 1024)} KB/view.`, ` - exact: ${kb(exact)} KB/view (${pct(numbers.exactPct)})`, ` - exact + est.: ${APPROX_MARK.trim()} ${kb(exactEst)} KB/view (${pct(numbers.exactEstPct)})${approx ? " (some estimates are approximate)" : ""}`, ` - sharpness: +${kb(mean(deepTotals.map((t) => t.sharpnessKB)))} KB/view, a separate number: it is never netted against the saving`);
190
+ const hiddenLine = hiddenPagesLine(deepOk);
191
+ if (hiddenLine !== undefined)
192
+ out(` - ${mdText(hiddenLine)}`);
177
193
  // The LCP image is the one the first paint waits for: its line, per page, before the ranked fixes.
178
194
  for (const page of deepOk) {
179
195
  for (const line of lcpLinesOf(page, mdCode))
@@ -201,12 +217,29 @@ export function buildCheckReport(input) {
201
217
  if (videos.length > 0) {
202
218
  out("## Video", "");
203
219
  out(`${videoTotalsLine(videos.map((v) => v.video))} (${videos.length} video(s) on ${deepOk.filter((p) => (p.videos ?? []).length > 0).length} deep page(s))`, "");
220
+ const hiddenVideos = hiddenVideoSummaryLine(videos.map((v) => v.video));
221
+ if (hiddenVideos !== undefined)
222
+ out(`- ${mdText(hiddenVideos)}`);
204
223
  const ranked = [...videos].sort((a, b) => videoSavingOf(b.video).view - videoSavingOf(a.video).view || a.page.localeCompare(b.page));
205
224
  for (const row of ranked.slice(0, REPORT_FIXES)) {
206
225
  out(`- ${mdCode(videoRowOf(row.video, (text) => JSON.stringify(safeText(text))))} on ${mdCode(pathOf(row.page))}`);
207
226
  }
208
227
  out("");
209
228
  }
229
+ // 3c. Resources: the deep pages' non-image findings (compression, favicons), largest saving first (see resources/).
230
+ const resourceRows = deepOk.flatMap((p) => (p.resources ?? []).map((finding) => ({ page: p.url, finding })));
231
+ if (resourceRows.length > 0) {
232
+ out("## Resources", "");
233
+ out(mdText(resourceTotalsLine(resourceRows.map((r) => r.finding)) ?? ""), "");
234
+ const ranked = [...resourceRows].sort((a, b) => b.finding.savedBytes - a.finding.savedBytes || a.page.localeCompare(b.page));
235
+ for (const row of ranked.slice(0, REPORT_FIXES)) {
236
+ const saved = row.finding.savedBytes > 0 ? `, saves ${kb(row.finding.savedBytes / 1024)} KB/view${row.finding.estimate ? " (est.)" : ""}` : "";
237
+ out(`- ${mdText(row.finding.kind)}: ${mdText(row.finding.title)}${saved} on ${mdCode(pathOf(row.page))}`);
238
+ if (row.finding.fix[0] !== undefined)
239
+ out(` - fix: ${mdText(row.finding.fix[0])}`);
240
+ }
241
+ out("");
242
+ }
210
243
  // 4. Heavy but not wasteful.
211
244
  out("## Heavy but not wasteful", "");
212
245
  const heavyCut = percentile(deepTotals.map((t) => t.kb), 0.75);
package/dist/check.js CHANGED
@@ -11,7 +11,7 @@ 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";
14
- import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, parseLimit, parseRange, readStorageState, runScan, scanPlan, } from "./remote.js";
14
+ import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, MAX_RESOURCE_TIME, MAX_SVG_BUDGET, parseLimit, parseRange, readStorageState, runScan, scanPlan, } from "./remote.js";
15
15
  import { restrictRoutes, groupRoutes, parseRouteMap } from "./routes.js";
16
16
  import { sampleByPattern, urlsFromSource } from "./urls.js";
17
17
  import { deepRetries, selectDeepPages } from "./deep.js";
@@ -38,6 +38,8 @@ export function parseCheckFlags(argv) {
38
38
  args.noCache = true;
39
39
  else if (arg === "--gpu-encode")
40
40
  args.gpuEncode = true;
41
+ else if (arg === "--no-resources")
42
+ args.noResources = true;
41
43
  else if (arg === "--ignore-robots")
42
44
  args.ignoreRobots = true;
43
45
  else if (arg === "--block-third-party")
@@ -59,6 +61,8 @@ export function parseCheckFlags(argv) {
59
61
  arg === "--page-concurrency" ||
60
62
  arg === "--qualities" ||
61
63
  arg === "--idle-timeout" ||
64
+ arg === "--svg-budget" ||
65
+ arg === "--resource-time" ||
62
66
  arg === "--storage-state" ||
63
67
  arg === "--allow-host") {
64
68
  const value = valueOf(argv, i, arg);
@@ -79,6 +83,10 @@ export function parseCheckFlags(argv) {
79
83
  args.qualities = parseQualities(value);
80
84
  else if (arg === "--idle-timeout")
81
85
  args.idleTimeoutMs = parseRange(arg, value, 0, MAX_IDLE_TIMEOUT_MS);
86
+ else if (arg === "--svg-budget")
87
+ args.svgBudget = parseRange(arg, value, 0, MAX_SVG_BUDGET);
88
+ else if (arg === "--resource-time")
89
+ args.resourceTime = parseRange(arg, value, 0, MAX_RESOURCE_TIME);
82
90
  else if (arg === "--storage-state")
83
91
  args.storageState = value;
84
92
  else
@@ -184,6 +192,7 @@ export async function scanCheckPages(input) {
184
192
  ignoreRobots: settings.ignoreRobots,
185
193
  aggressive: false,
186
194
  thirdParty: settings.thirdParty,
195
+ resources: settings.noResources !== true,
187
196
  };
188
197
  const base = { urls: [], out: outRoot, aggressive: false, quality: true, thirdParty: settings.thirdParty };
189
198
  const surveyPlan = scanPlan({ ...base, survey: true });
@@ -206,6 +215,8 @@ export async function scanCheckPages(input) {
206
215
  ...(settings.thirdParty.block ? ["--block-third-party"] : []),
207
216
  ...(settings.noCache ? ["--no-cache"] : []),
208
217
  ...(settings.gpuEncode ? ["--gpu-encode"] : []),
218
+ ...(settings.noResources ? ["--no-resources"] : []),
219
+ ...(settings.resourceTime !== undefined ? ["--resource-time", String(settings.resourceTime)] : []),
209
220
  ].join(" "),
210
221
  });
211
222
  log(`survey: ${urls.length} URL(s), ${routes.types.length} route type(s)`);
@@ -244,9 +255,12 @@ export async function scanCheckPages(input) {
244
255
  ...(settings.thirdParty.allowHosts.length > 0 ? ["--allow-host", shellWord(settings.thirdParty.allowHosts.join(","))] : []),
245
256
  ...(settings.noCache ? ["--no-cache"] : []),
246
257
  ...(settings.gpuEncode ? ["--gpu-encode"] : []),
258
+ ...(settings.noResources ? ["--no-resources"] : []),
259
+ ...(settings.resourceTime !== undefined ? ["--resource-time", String(settings.resourceTime)] : []),
247
260
  ...(settings.ignoreRobots ? ["--ignore-robots"] : []),
248
261
  ...(settings.pageConcurrency !== undefined ? ["--page-concurrency", String(settings.pageConcurrency)] : []),
249
262
  ...(settings.idleTimeoutMs !== undefined ? ["--idle-timeout", String(settings.idleTimeoutMs)] : []),
263
+ ...(settings.svgBudget !== undefined ? ["--svg-budget", String(settings.svgBudget)] : []),
250
264
  ];
251
265
  commands.push({ phase: "deep", command: ["imgtune scan", ...deepUrls.map(shellWord), "--out", shellWord(deepDir), ...flags].join(" ") });
252
266
  log(`deep: ${deepUrls.length} page(s), full scan`);
@@ -258,6 +272,7 @@ export async function scanCheckPages(input) {
258
272
  pageConcurrency: fullPlan.pageConcurrency,
259
273
  idleTimeoutMs: fullPlan.idleTimeoutMs,
260
274
  gpu: settings.gpu,
275
+ svgBudget: settings.svgBudget,
261
276
  expectImages: (url) => expected.get(url),
262
277
  };
263
278
  const deepStart = performance.now();
@@ -346,6 +361,8 @@ export async function finishCheck(input) {
346
361
  pageConcurrency: settings.pageConcurrency,
347
362
  idleTimeoutMs: settings.idleTimeoutMs,
348
363
  noCache: settings.noCache,
364
+ ...(settings.noResources ? { noResources: true } : {}),
365
+ ...(settings.resourceTime !== undefined ? { resourceTime: settings.resourceTime } : {}),
349
366
  ignoreRobots: settings.ignoreRobots,
350
367
  storageState: input.storageState,
351
368
  thirdParty: settings.thirdParty.block ? "blocked" : "allowed",