imgtune 0.1.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 (63) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +109 -5
  3. package/dist/animated.js +176 -0
  4. package/dist/audit.js +1 -0
  5. package/dist/backgrounds.js +42 -7
  6. package/dist/broken.js +5 -1
  7. package/dist/browser.js +4 -4
  8. package/dist/candidates.js +25 -0
  9. package/dist/check-report.js +56 -6
  10. package/dist/check.js +26 -5
  11. package/dist/cli.js +86 -8
  12. package/dist/egress-proxy.js +2 -1
  13. package/dist/fetch.js +9 -4
  14. package/dist/ffmpeg.js +530 -0
  15. package/dist/gpu.js +12 -0
  16. package/dist/hints.js +704 -16
  17. package/dist/image-file.js +154 -34
  18. package/dist/image-open.js +9 -0
  19. package/dist/images.js +142 -8
  20. package/dist/layout.js +7 -7
  21. package/dist/lcp.js +2 -1
  22. package/dist/locate-join.js +86 -0
  23. package/dist/locate-only.js +14 -3
  24. package/dist/locate.js +6 -2
  25. package/dist/measure.js +39 -1
  26. package/dist/media-encode.js +1054 -0
  27. package/dist/media-file.js +284 -0
  28. package/dist/media-limits.js +31 -0
  29. package/dist/media-model.js +251 -0
  30. package/dist/media-phase.js +675 -0
  31. package/dist/media-prepare.js +31 -0
  32. package/dist/px.js +11 -0
  33. package/dist/recheck.js +12 -4
  34. package/dist/remote.js +1065 -107
  35. package/dist/replay.js +20 -0
  36. package/dist/resources/audio.js +248 -0
  37. package/dist/resources/brotli.js +22 -0
  38. package/dist/resources/cache.js +59 -0
  39. package/dist/resources/capture.js +225 -0
  40. package/dist/resources/compression.js +122 -0
  41. package/dist/resources/favicon.js +130 -0
  42. package/dist/resources/font.js +440 -0
  43. package/dist/resources/index.js +8 -0
  44. package/dist/resources/lottie.js +491 -0
  45. package/dist/resources/model.js +478 -0
  46. package/dist/resources/phase.js +171 -0
  47. package/dist/resources/pool.js +47 -0
  48. package/dist/resources/report.js +57 -0
  49. package/dist/resources/sfnt.js +305 -0
  50. package/dist/resources/svg.js +178 -0
  51. package/dist/resources/types.js +1 -0
  52. package/dist/resources/zip.js +67 -0
  53. package/dist/result-cache.js +56 -3
  54. package/dist/run.js +32 -8
  55. package/dist/sizes.js +7 -5
  56. package/dist/summary.js +69 -3
  57. package/dist/vector-art.js +294 -0
  58. package/dist/vector-phase.js +222 -0
  59. package/dist/video-analysis.js +371 -0
  60. package/dist/webp-animation.js +283 -0
  61. package/package.json +25 -4
  62. package/skills/imgtune/SKILL.md +8 -4
  63. package/skills/imgtune/reference.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,177 @@
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
+
101
+ ## 0.2.0 (2026-11-14)
102
+
103
+ - Animated media and video. `scan` measures animated GIFs (in an `<img>` or a CSS background), animated WebP and APNG, and
104
+ self-hosted `<video>` elements with a plain file source. Each page gets a Video section with the bytes per view and per
105
+ play, and the levers (audio, width, codec, preload, poster). Adaptive streams (HLS, DASH, Mux, Cloudflare Stream) and
106
+ third-party iframes (YouTube, Vimeo) are not reported. A video hosted on another site follows the third-party rules.
107
+ - Hints for animated media. A large animated GIF gets `<video autoplay muted loop playsinline>` with AV1 first and an H.264
108
+ fallback. The H.264 fallback is a recipe (libx264, CRF 23, yuv420p, faststart), marked not measured. The animated-image
109
+ alternative is shown with its size. A small `<img>` GIF or a CSS background gets an animated AVIF or WebP, and only its
110
+ `src` changes. A `<video>` hint names each lever: the width per breakpoint (`<source media>`), the CRF, the codec, the
111
+ audio track of a muted video, and `preload`. A poster is audited through the image pipeline. Duration and frame rate are
112
+ informational notes only, never part of the claim.
113
+ - Static GIFs (one frame) get the normal image hints. Animated images stay in the image totals.
114
+ - Bytes per view and per play. Per view is what the first view fetches: an autoplay video counts its full file,
115
+ `preload="auto"` counts the bytes the scan saw (labelled browser-dependent), and `preload="metadata"` or `"none"` counts 0.
116
+ 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
117
+ of the byte ranges read, capped at the file length, and a length probe reads the size when the server sends no
118
+ Content-Length, so a looping video is not counted once per loop. Posters count as images.
119
+ - Quality bars. A candidate must reach VMAF 93 (`--video-bar`) against the served file at its display size. A still-frame
120
+ guard (SSIMULACRA2 on the stillest frames) catches blurred text in screen recordings. Its bar is `--video-still-bar`
121
+ (default 65), lowered to at most 10 points below the best the same codec reaches on that content, and never below 20. The
122
+ relative bar catches the loss the encode adds, not what a format can never reproduce, such as GIF dither. For GIF sources
123
+ both frames of the guard are blurred slightly (Gaussian, sigma 1) before scoring, so dither the eye averages out does not
124
+ fail it.
125
+ - Encodes and budget. Encodes run on the heaviest animated assets first (by per-view bytes), at most `--media-budget` per run
126
+ (default 20) and within `--media-time` seconds (default 600). Assets over the budget keep their cheap hints and are marked
127
+ "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
128
+ full-file bytes are extrapolated from the samples and labelled as an estimate; nothing is encoded in full. Each asset is
129
+ encoded once per run, keyed by URL and content hash in the result cache. Widths are snapped to a few buckets first.
130
+ - Video widths. `scan` and `check` serve each video at most `--max-video-widths` widths (default 2): each viewport gets the
131
+ smallest kept width that covers its display width times its device pixel ratio. `audit` does not choose: it reports each
132
+ viewport's needed width and the bytes per view at one, two and three widths.
133
+ - ffmpeg. The system ffmpeg on `PATH` is used; ImgTune does not bundle it. Without it, animated media is still detected, and
134
+ its bytes and needed width are still reported, with one note: `install ffmpeg for verified video and GIF hints`. ffmpeg and
135
+ ffprobe read only the protocols they need, and a URL input goes through the egress proxy (the scan's own policy for
136
+ private addresses), so a scanned page cannot reach a private host through a video.
137
+ - `imgtune image` accepts GIF, animated WebP and APNG (candidates: animated AVIF, animated WebP and AV1 MP4 at `--width`),
138
+ and MP4 and WebM. `--reference <original>` checks a candidate against the original with the same VMAF and still-frame bars.
139
+ It prints one line, `VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail`, and exits 0 or 1.
140
+ - `audit` locates `<video>` and GIF elements at `file:line`, as it locates images. `hints` prints the animated and video hints.
141
+ - Network idle. A streaming media request does not hold the wait for network idle, so an autoplay video does not hold each
142
+ resize up to `--idle-timeout`.
143
+ - Scan JSON. Each page gains `videos` and `mediaNote`, and an animated image record gains `fix.animated`. The resume record
144
+ gains the media settings (`mediaBudget`, `mediaTime`, `videoBar`, `videoStillBar`, `maxVideoWidths`, `ffmpeg`).
145
+ - New flags, with their defaults: `--media-budget <N>` (20, 0 to 1000), `--media-time <s>` (600, 1 to 3600), `--video-bar <n>`
146
+ (93), `--video-still-bar <n>` (65), `--max-video-widths <n>` (2, 1 to 3). `--survey` and `--no-quality` make no encodes.
147
+ `image` takes `--reference`, `--video-bar` and `--video-still-bar`.
148
+ - `--gpu-encode` (experimental, off by default). The media encodes run on the GPU with NVENC (`av1_nvenc`, `h264_nvenc`), and
149
+ 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
150
+ encode and labelled `est. (GPU-encoded, scaled to a CPU encode ÷1.4)`. Each GPU is proven by a small test encode first, and
151
+ 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
152
+ per ffmpeg process), so the CPU stays the default.
153
+ - `--gpu-device` takes a comma list (`0,1`). The first GPU scores. With `--gpu-encode`, each listed GPU gets its own encode
154
+ workers beside the CPU ones. `--gpu` alone is unchanged.
155
+ - Ctrl-C and SIGTERM stop the whole run: the media phase is aborted, the pages already written are kept, and the process
156
+ exits 130 (SIGINT) or 143 (SIGTERM) within 2 s.
157
+ - Media fetches and the ffmpeg reads of a page's videos go through the egress proxy. ffmpeg's `httpproxy` tunnel is allowed,
158
+ so `https` videos are read through it.
159
+ - Animated WebP and APNG are decoded one frame at a time, only for the frames of each 2 s sample window, and streamed to
160
+ ffmpeg. A long animation does not fill memory, and the pixel limit applies to each frame.
161
+ - A measured page's JSON is written after its image estimates and before the media phase, so a long media phase does not
162
+ hold back the results.
163
+ - Cache: `CACHE_VERSION` is 3. Entries from 0.1 are not read, so the first scan on 0.2 is cold.
164
+ - Changes for 0.1 users:
165
+ - A scan that encodes media takes longer. The encodes are bounded by `--media-time` (600 s) and `--media-budget` (20
166
+ assets); `--no-quality` or `--survey` keeps the 0.1 timing.
167
+ - Without ffmpeg on `PATH`, `scan` prints one note and gives no encode hints.
168
+ - `--resume` scans a 0.1 page again when the new run encodes media, because its record has no media settings. A run with
169
+ no encodes (`--no-quality`, `--survey`, or no ffmpeg) resumes 0.1 pages as before.
170
+ - The JSON gains fields (`videos`, `mediaNote`, `fix.animated`, the resume media settings); existing fields are unchanged.
171
+ Animated GIFs, WebP and APNG keep their place in the image totals, but their hint is now a video or animated-image hint.
172
+ - `<video>` elements are not image records: their bytes are in the Video section, and their posters are images.
173
+ - The cache is not reused across the version change (see above).
174
+
3
175
  ## 0.1.0 (2026-11-03)
4
176
 
5
177
  - LCP first. Each scanned page reads the largest contentful paint before its first scroll, per viewport. A page's report
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,89 @@ 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
+
195
+ ## GIFs and video
196
+
197
+ `scan` also measures animated media: animated GIFs in an `<img>` or a CSS background, animated WebP and APNG, and
198
+ self-hosted `<video>` elements with a plain file source. Static GIFs get the normal image hints.
199
+
200
+ - **Out of scope, not reported:** adaptive streams (HLS, DASH, Mux, Cloudflare Stream) and third-party iframes (YouTube,
201
+ Vimeo). A video hosted on another site follows the third-party rules (`--block-third-party`, `--allow-host`).
202
+ - **ffmpeg** is used from `PATH`; ImgTune does not bundle it. Without it, `scan` still reports each animated image's and
203
+ video's bytes and the width it needs, and prints one note: `install ffmpeg for verified video and GIF hints`. The encodes,
204
+ and so the fix hints with a size, need it.
205
+ - **Encodes run on the CPU by default.** `--gpu-encode` is **experimental**: it moves the sample encodes to the GPU (NVENC
206
+ `av1_nvenc` / `h264_nvenc`, preset p4, constant quality in VBR; `libvmaf_cuda` for the VMAF). It uses the `--gpu-device`
207
+ 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
208
+ an NVENC failure is encoded on the CPU for that candidate. NVENC is larger at equal quality, so its bytes are reported as
209
+ NVENC bytes ÷ 1.4, labelled `est. (GPU-encoded, scaled to a CPU encode ÷1.4)`. In our measurements `--gpu-encode` was
210
+ slower than the CPU (per-process CUDA start-up); see the report before using it. `--gpu` and `--gpu-device` only pick the
211
+ SSIMULACRA2 GPU scorer (the first device) unless `--gpu-encode` is given. The hint's recipe is the CPU one (`libsvtav1` / `libx264`), and an NVENC
212
+ quality is marked `approx.` (CRF = cq).
213
+ - **Hints.** A large animated GIF gets `<video autoplay muted loop playsinline>` with AV1 first and an H.264 fallback, and
214
+ the animated-image alternative with its size. A small `<img>` GIF or a CSS background gets an animated AVIF or WebP. A
215
+ `<video>` hint names each lever: the width per breakpoint (`<source media>`), the CRF, the codec, the audio track of a
216
+ muted video, and `preload`. A poster is audited as an image. Duration and frame rate are informational only.
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,
219
+ `preload="auto"` counts the bytes the scan saw (browser-dependent), and `preload="metadata"` or `"none"` counts 0. Per
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
221
+ in the image totals; `<video>` has its own Video section, and posters count as images.
222
+ - **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
223
+ are extrapolated from the samples and labelled as an estimate; nothing is encoded in full.
224
+ - **Quality bars.** VMAF must reach `--video-bar` (default 93) against the served file at its display size. A still-frame
225
+ guard (SSIMULACRA2 on the stillest frames) catches blurred text in screen recordings and must reach `--video-still-bar`
226
+ (default 65), lowered to at most 10 points below the best the codec reaches on that content, and never below 20. The
227
+ relative bar catches the loss the encode adds, not what the format can never reproduce, such as GIF dither.
228
+ - **Budget and widths.** Encodes run on the heaviest animated assets first, at most `--media-budget` per run (default 20)
229
+ and within `--media-time` seconds (default 600). Assets over the budget keep their cheap hints and are marked "over
230
+ --media-budget". A video is served at most `--max-video-widths` widths (default 2): each viewport gets the smallest
231
+ kept width that covers its display width times its device pixel ratio. `audit` reports the bytes at one, two and three
232
+ widths instead, and the agent chooses. `--no-quality` and `--survey` make no encodes.
233
+ - **Check an agent's output** with `imgtune image <candidate> --reference <original>`: the same VMAF and still-frame bar,
234
+ exit 0 on pass and 1 on fail (see the table below).
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
+
146
249
  ## Commands
147
250
 
148
251
  | command | what it does |
@@ -155,6 +258,7 @@ the browser resolved, not from a guess.
155
258
  | `imgtune summary <run dir \| scan.json ...>` | the one-screen summary of a run directory (`run.json`) or of scan files |
156
259
  | `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
260
  | `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 |
261
+ | `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
262
  | `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
263
  | `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
264
 
@@ -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/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,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 { hiddenFixCore, 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;
@@ -263,7 +264,8 @@ export function buildBackgroundRecords(input) {
263
264
  }
264
265
  const records = [];
265
266
  for (const [key, members] of byKey) {
266
- 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];
267
269
  const source = sources.get(member.bg.index);
268
270
  const url = member.url;
269
271
  const perViewport = [];
@@ -286,11 +288,27 @@ export function buildBackgroundRecords(input) {
286
288
  entry.overFetchRatio = Math.round((inst.pixelWidth / inst.needed) * 100) / 100;
287
289
  if (inst.skipped !== undefined)
288
290
  entry.skipped = inst.skipped;
291
+ if (inst.hidden !== undefined)
292
+ entry.hidden = inst.hidden;
289
293
  perViewport.push(entry);
290
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);
291
307
  const saved = members.reduce((sum, m) => sum + m.est, 0) / viewportCount;
292
308
  const served = member.format;
293
- const best = served === "jpeg" || served === "png" ? (saved > 0 ? "avif" : served) : served;
309
+ const animatedMember = members.find((m) => m.animated !== undefined);
310
+ const lossless = losslessServed(served, animatedMember !== undefined);
311
+ const best = lossless ? (saved > 0 ? "avif" : served) : served;
294
312
  const locator = locatorOfBackground(pageUrl, url, member.bg, source);
295
313
  const notes = [
296
314
  `css background: layer ${member.layer + 1}, background-size "${sanitizeText(member.size, 60)}", drawn ${Math.round(member.displayedCss)} CSS px at the reference viewport`,
@@ -321,19 +339,36 @@ export function buildBackgroundRecords(input) {
321
339
  },
322
340
  perViewport,
323
341
  fix: {
324
- width: member.needed > 0 ? member.needed : undefined,
325
- 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 },
326
345
  },
327
346
  evidence: { rung: rungOfPage(pageUrl, input.harness), notes },
328
347
  };
329
348
  if (best !== undefined)
330
349
  record.fix.format = best;
350
+ if (animatedMember?.animated !== undefined && hiddenFix?.shownIn.length !== 0) {
351
+ record.fix.animated = {
352
+ info: animatedMember.animated,
353
+ context: "css-background",
354
+ target: animatedTarget("css-background", undefined),
355
+ perViewBytes: members.reduce((sum, m) => sum + m.bytes, 0) / viewportCount,
356
+ };
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
+ }
331
366
  records.push(record);
332
367
  }
333
368
  return records.filter((r) => r.perViewport.some((p) => p.loaded || p.skipped !== undefined));
334
369
  }
335
370
  export function backgroundTotals(instances) {
336
- const measured = instances.filter((i) => i.skipped === undefined);
371
+ const measured = instances.filter((i) => i.skipped === undefined && i.hidden === undefined);
337
372
  return {
338
373
  measured: measured.length,
339
374
  servedBytes: measured.reduce((sum, i) => sum + i.bytes, 0),
@@ -342,7 +377,7 @@ export function backgroundTotals(instances) {
342
377
  };
343
378
  }
344
379
  export function backgroundPage(instances, viewportCount, records) {
345
- const measured = instances.filter((i) => i.skipped === undefined);
380
+ const measured = instances.filter((i) => i.skipped === undefined && i.hidden === undefined);
346
381
  return {
347
382
  layers: records.filter((r) => r.perViewport.some((p) => p.loaded)).length,
348
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));