imgtune 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +20 -0
  4. package/README.md +350 -2
  5. package/THIRD_PARTY_LICENSES +295 -0
  6. package/dist/address.js +221 -0
  7. package/dist/animated.js +176 -0
  8. package/dist/app-routes.js +211 -0
  9. package/dist/atomic.js +63 -0
  10. package/dist/audit.js +372 -0
  11. package/dist/backgrounds.js +363 -0
  12. package/dist/broken.js +106 -0
  13. package/dist/browser.js +30 -0
  14. package/dist/calibrate.js +221 -0
  15. package/dist/candidates.js +151 -0
  16. package/dist/check-report.js +302 -0
  17. package/dist/check.js +416 -0
  18. package/dist/cli.js +531 -0
  19. package/dist/compare.js +398 -0
  20. package/dist/concurrency.js +37 -0
  21. package/dist/config.js +10 -0
  22. package/dist/crawl.js +186 -0
  23. package/dist/deep.js +76 -0
  24. package/dist/egress-proxy.js +151 -0
  25. package/dist/encode.js +101 -0
  26. package/dist/estimate-jobs.js +114 -0
  27. package/dist/fetch.js +179 -0
  28. package/dist/ffmpeg.js +495 -0
  29. package/dist/gpu.js +353 -0
  30. package/dist/group-sizes.js +120 -0
  31. package/dist/hints.js +1102 -0
  32. package/dist/image-file.js +553 -0
  33. package/dist/images.js +361 -0
  34. package/dist/layout.js +240 -0
  35. package/dist/lcp.js +66 -0
  36. package/dist/loaders.js +267 -0
  37. package/dist/loads.js +29 -0
  38. package/dist/locate-join.js +511 -0
  39. package/dist/locate-only.js +178 -0
  40. package/dist/locate.js +382 -0
  41. package/dist/lru.js +34 -0
  42. package/dist/measure.js +238 -0
  43. package/dist/media-encode.js +1050 -0
  44. package/dist/media-file.js +284 -0
  45. package/dist/media-limits.js +31 -0
  46. package/dist/media-model.js +247 -0
  47. package/dist/media-phase.js +652 -0
  48. package/dist/media-prepare.js +31 -0
  49. package/dist/metric.js +227 -0
  50. package/dist/next-config.js +87 -0
  51. package/dist/qualities-cap.js +141 -0
  52. package/dist/recheck-rows.js +40 -0
  53. package/dist/recheck.js +376 -0
  54. package/dist/remote.js +3582 -0
  55. package/dist/report.js +253 -0
  56. package/dist/result-cache.js +183 -0
  57. package/dist/routes.js +280 -0
  58. package/dist/run.js +445 -0
  59. package/dist/search.js +88 -0
  60. package/dist/site.js +31 -0
  61. package/dist/sizes-outcome.js +81 -0
  62. package/dist/sizes-recheck.js +332 -0
  63. package/dist/sizes.js +148 -0
  64. package/dist/source-root.js +77 -0
  65. package/dist/srcset-parse.js +98 -0
  66. package/dist/srcset.js +44 -0
  67. package/dist/ssim.js +196 -0
  68. package/dist/stage.js +51 -0
  69. package/dist/summary.js +495 -0
  70. package/dist/survey.js +13 -0
  71. package/dist/text.js +72 -0
  72. package/dist/third-party.js +127 -0
  73. package/dist/timing.js +60 -0
  74. package/dist/types.js +1 -0
  75. package/dist/urls.js +300 -0
  76. package/dist/version.js +3 -0
  77. package/dist/video-analysis.js +273 -0
  78. package/dist/webp-animation.js +283 -0
  79. package/dist/worst.js +42 -0
  80. package/package.json +70 -4
  81. package/skills/imgtune/AGENTS.snippet.md +47 -0
  82. package/skills/imgtune/SKILL.md +344 -0
  83. package/skills/imgtune/reference.md +339 -0
  84. package/vendor/ssimulacra2/ssim_wasm.cjs +113 -0
  85. package/vendor/ssimulacra2/ssim_wasm.d.ts +8 -0
  86. package/vendor/ssimulacra2/ssim_wasm_bg.wasm +0 -0
  87. package/vendor/ssimulacra2/ssim_wasm_bg.wasm.d.ts +6 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,322 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (2026-11-14)
4
+
5
+ - Animated media and video. `scan` measures animated GIFs (in an `<img>` or a CSS background), animated WebP and APNG, and
6
+ self-hosted `<video>` elements with a plain file source. Each page gets a Video section with the bytes per view and per
7
+ play, and the levers (audio, width, codec, preload, poster). Adaptive streams (HLS, DASH, Mux, Cloudflare Stream) and
8
+ third-party iframes (YouTube, Vimeo) are not reported. A video hosted on another site follows the third-party rules.
9
+ - Hints for animated media. A large animated GIF gets `<video autoplay muted loop playsinline>` with AV1 first and an H.264
10
+ fallback. The H.264 fallback is a recipe (libx264, CRF 23, yuv420p, faststart), marked not measured. The animated-image
11
+ alternative is shown with its size. A small `<img>` GIF or a CSS background gets an animated AVIF or WebP, and only its
12
+ `src` changes. A `<video>` hint names each lever: the width per breakpoint (`<source media>`), the CRF, the codec, the
13
+ audio track of a muted video, and `preload`. A poster is audited through the image pipeline. Duration and frame rate are
14
+ informational notes only, never part of the claim.
15
+ - Static GIFs (one frame) get the normal image hints. Animated images stay in the image totals.
16
+ - Bytes per view and per play. Per view is what the first view fetches: an autoplay video counts its full file,
17
+ `preload="auto"` counts the bytes the scan saw (labelled browser-dependent), and `preload="metadata"` or `"none"` counts 0.
18
+ Per play is the full file, shown for every video, so a large click-to-play file is not missed. A URL's bytes are the union
19
+ of the byte ranges read, capped at the file length, and a length probe reads the size when the server sends no
20
+ Content-Length, so a looping video is not counted once per loop. Posters count as images.
21
+ - Quality bars. A candidate must reach VMAF 93 (`--video-bar`) against the served file at its display size. A still-frame
22
+ guard (SSIMULACRA2 on the stillest frames) catches blurred text in screen recordings. Its bar is `--video-still-bar`
23
+ (default 65), lowered to at most 10 points below the best the same codec reaches on that content, and never below 20. The
24
+ relative bar catches the loss the encode adds, not what a format can never reproduce, such as GIF dither. For GIF sources
25
+ both frames of the guard are blurred slightly (Gaussian, sigma 1) before scoring, so dither the eye averages out does not
26
+ fail it.
27
+ - Encodes and budget. Encodes run on the heaviest animated assets first (by per-view bytes), at most `--media-budget` per run
28
+ (default 20) and within `--media-time` seconds (default 600). Assets over the budget keep their cheap hints and are marked
29
+ "over --media-budget". The CRF search runs on 2 s sample clips: one for a video of up to 6 s, three for a longer one. The
30
+ full-file bytes are extrapolated from the samples and labelled as an estimate; nothing is encoded in full. Each asset is
31
+ encoded once per run, keyed by URL and content hash in the result cache. Widths are snapped to a few buckets first.
32
+ - Video widths. `scan` and `check` serve each video at most `--max-video-widths` widths (default 2): each viewport gets the
33
+ smallest kept width that covers its display width times its device pixel ratio. `audit` does not choose: it reports each
34
+ viewport's needed width and the bytes per view at one, two and three widths.
35
+ - ffmpeg. The system ffmpeg on `PATH` is used; ImgTune does not bundle it. Without it, animated media is still detected, and
36
+ its bytes and needed width are still reported, with one note: `install ffmpeg for verified video and GIF hints`. ffmpeg and
37
+ ffprobe read only the protocols they need, and a URL input goes through the egress proxy (the scan's own policy for
38
+ private addresses), so a scanned page cannot reach a private host through a video.
39
+ - `imgtune image` accepts GIF, animated WebP and APNG (candidates: animated AVIF, animated WebP and AV1 MP4 at `--width`),
40
+ and MP4 and WebM. `--reference <original>` checks a candidate against the original with the same VMAF and still-frame bars.
41
+ It prints one line, `VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail`, and exits 0 or 1.
42
+ - `audit` locates `<video>` and GIF elements at `file:line`, as it locates images. `hints` prints the animated and video hints.
43
+ - Network idle. A streaming media request does not hold the wait for network idle, so an autoplay video does not hold each
44
+ resize up to `--idle-timeout`.
45
+ - Scan JSON. Each page gains `videos` and `mediaNote`, and an animated image record gains `fix.animated`. The resume record
46
+ gains the media settings (`mediaBudget`, `mediaTime`, `videoBar`, `videoStillBar`, `maxVideoWidths`, `ffmpeg`).
47
+ - New flags, with their defaults: `--media-budget <N>` (20, 0 to 1000), `--media-time <s>` (600, 1 to 3600), `--video-bar <n>`
48
+ (93), `--video-still-bar <n>` (65), `--max-video-widths <n>` (2, 1 to 3). `--survey` and `--no-quality` make no encodes.
49
+ `image` takes `--reference`, `--video-bar` and `--video-still-bar`.
50
+ - `--gpu-encode` (experimental, off by default). The media encodes run on the GPU with NVENC (`av1_nvenc`, `h264_nvenc`), and
51
+ VMAF runs on the GPU with `libvmaf_cuda` when the ffmpeg on `PATH` has it. The NVENC bytes are scaled by 1 / 1.4 to a CPU
52
+ encode and labelled `est. (GPU-encoded, scaled to a CPU encode ÷1.4)`. Each GPU is proven by a small test encode first, and
53
+ a width under 144 px is encoded on the CPU. In our measurements it was slower than the CPU on 2 s samples (CUDA starts once
54
+ per ffmpeg process), so the CPU stays the default.
55
+ - `--gpu-device` takes a comma list (`0,1`). The first GPU scores. With `--gpu-encode`, each listed GPU gets its own encode
56
+ workers beside the CPU ones. `--gpu` alone is unchanged.
57
+ - Ctrl-C and SIGTERM stop the whole run: the media phase is aborted, the pages already written are kept, and the process
58
+ exits 130 (SIGINT) or 143 (SIGTERM) within 2 s.
59
+ - Media fetches and the ffmpeg reads of a page's videos go through the egress proxy. ffmpeg's `httpproxy` tunnel is allowed,
60
+ so `https` videos are read through it.
61
+ - Animated WebP and APNG are decoded one frame at a time, only for the frames of each 2 s sample window, and streamed to
62
+ ffmpeg. A long animation does not fill memory, and the pixel limit applies to each frame.
63
+ - A measured page's JSON is written after its image estimates and before the media phase, so a long media phase does not
64
+ hold back the results.
65
+ - Cache: `CACHE_VERSION` is 3. Entries from 0.1 are not read, so the first scan on 0.2 is cold.
66
+ - Changes for 0.1 users:
67
+ - A scan that encodes media takes longer. The encodes are bounded by `--media-time` (600 s) and `--media-budget` (20
68
+ assets); `--no-quality` or `--survey` keeps the 0.1 timing.
69
+ - Without ffmpeg on `PATH`, `scan` prints one note and gives no encode hints.
70
+ - `--resume` scans a 0.1 page again when the new run encodes media, because its record has no media settings. A run with
71
+ no encodes (`--no-quality`, `--survey`, or no ffmpeg) resumes 0.1 pages as before.
72
+ - The JSON gains fields (`videos`, `mediaNote`, `fix.animated`, the resume media settings); existing fields are unchanged.
73
+ Animated GIFs, WebP and APNG keep their place in the image totals, but their hint is now a video or animated-image hint.
74
+ - `<video>` elements are not image records: their bytes are in the Video section, and their posters are images.
75
+ - The cache is not reused across the version change (see above).
76
+
77
+ ## 0.1.0 (2026-11-03)
78
+
79
+ - LCP first. Each scanned page reads the largest contentful paint before its first scroll, per viewport. A page's report
80
+ and the PR comment show one line above its numbers: `LCP image (<viewports>): <file:line>, <KB> served, <KB> fixable`,
81
+ or `LCP is not an image`. `hints` sorts the LCP image first and prefixes its line with `LCP image (<viewports>):`. The
82
+ scan JSON gains `lcp` and `lcpViewports` on images, `lcp` on each viewport, and the hints JSON gains `lcp` and `lcpViewports`.
83
+ - `sizes="auto"` awareness. A lazy image whose only fix is its sizes gets an alternative line,
84
+ `sizes="auto, <proposed>"` (Chrome, Edge, Firefox 150 or later; Safari falls back to the proposed value). The explicit
85
+ value stays the fix. Eager and LCP images never get it.
86
+ - CDN auto quality. A Cloudinary `q_auto` group whose estimated quality saving is below a tenth of its served bytes is
87
+ reported as `quality handled by <loader> (q_auto)`, and that saving is not counted. Above it, the hint names the explicit
88
+ `q_<N>`. A jpeg or png whose format the CDN negotiates (`f_auto`, imgix `auto=format`) gets no format change. The
89
+ fix record gains `cdnQuality` and `formatHandled`.
90
+ - `images.qualities` cap. The next/image quality targets of a scan (across its pages) are clustered into at most
91
+ `--max-qualities N` values (default 2, 1 to 4). Each target is raised to the nearest chosen value, so the SSIMULACRA2 bar
92
+ still holds. The site-level line `next.config: images.qualities: [...]` is printed by `hints`, `scan` and the reports;
93
+ a usage's estimated quality saving is scaled to its capped value and marked approximate. `--qualities` turns the cap off.
94
+ - The agent skill has a "Fix the images" section: the hints in batches, the LCP image first, the `images.qualities` line
95
+ once, then a scan again with before and after reported apart. Its description names "fix image sizes", "next/image
96
+ sizes" and "LCP image".
97
+ - Third-party requests are allowed by default. `--block-third-party` turns the blocking on (scans, rechecks, locate-only,
98
+ sizes-only, `run` and `check`). Blocking can drop images that a page loads through its own API on another host: on a
99
+ local store build, 0 images were recorded with blocking and 50 without it. `--allow-host <h1,h2>` now needs
100
+ `--block-third-party`. `--allow-third-party` is accepted for one release as a no-op with a deprecation note. The
101
+ recorded setting is `allowed` by default, so a scan from before this change (recorded as `blocked`, or with no record)
102
+ does not match on `--resume` or `recheck` where the setting differs.
103
+ - `check`: failed pages are counted. A deep page the browser could not load (status `failed`, for example a navigation
104
+ timeout) gets one retry: the next-heaviest measured page of its route type is scanned in its place, and the report names
105
+ both. `run.json` has a `failed` count per phase and a `deepRetries` list, `imgtune summary` prints the failed count in its
106
+ survey and deep lines, and the report's header and "broken, not measured" section count the failed pages.
107
+ - Browser errors in the report and the terminal are one line: ANSI escape sequences are removed whole (`stripControls`), and
108
+ the Playwright "Call log:" block after the first line is dropped (`errorLine`). The full message stays in the page JSON.
109
+ - Route types (`src/routes.ts`): a version segment after the first segment (`/docs/15/app/...`, `/docs/v2.3/...`) is a
110
+ dimension when the same section has unversioned pages, so the versions merge with them. Dated archives and numeric ids
111
+ are not read as versions (see the tests).
112
+ - Route types (`src/routes.ts`): the URLs of a site group into the templates they are instances of. A leading locale or store
113
+ segment (`ae-en`, `sa_AR`) and a `desktop` or `mobile` variant are dimensions, not part of the type. Product slugs, ids
114
+ and listing families collapse to `*`, and query strings keep their parameter names (`/search?q=*`). Used by the survey
115
+ sampler (`--sample-per-pattern`), `run` and `check`. `--route-map <file>` names the URLs that match a pattern (`pattern =>
116
+ type`, or JSON). A store set of 299 URLs gave 289 patterns with the old grouping; the new grouping is not yet measured on it.
117
+ - Deep selection (`src/deep.ts`): the heaviest page of each route type that has images, by image KB per view (exact KB as the
118
+ tie-break), with the types ordered by their total KB per view. `--deep <N>` defaults to 20 (was 5). The types the cap leaves
119
+ out, and the types with no images, are named in the output.
120
+ - `imgtune check <origin | sitemap-url | page-url> | --urls-from <file|sitemap>`: the production check, in a browser only.
121
+ It surveys at most `--sample-per-pattern N` URLs per route type (default 3), full-scans the heaviest page of each type, and writes
122
+ `report.md` (header, summary numbers with sharpness as its own number, ranked fixes merged across the deep pages, heavy
123
+ pages with little to save, sharpness images, a route-type table, what was not measured, and how to read the numbers),
124
+ `run.json` (the manifest: `imgtune summary` reads it), `survey/`, `deep/` and `urls.txt`. It passes through `--gpu`,
125
+ `--gpu-device`, `--page-concurrency`, `--qualities`, `--block-third-party`, `--allow-host`, `--idle-timeout`, `--no-cache`,
126
+ `--ignore-robots` and `--storage-state`. `run` is kept: it also locates on a dev server (`--dev`) and writes the hints.
127
+ - `imgtune audit --app <dir> --prod <origin> [--dev <origin>]`: the deep mode for your own Next.js app. It reads the route
128
+ files (`app/`, `src/app/`, `pages/`, `src/pages/`) into route patterns, and `images.qualities` from `next.config.{js,mjs,ts}`
129
+ (a static read; Next's `[75]` is assumed and reported when the list is missing). It surveys `--prod` and full-scans the
130
+ heaviest page of each route type, as `check` does. With `--dev` it locates those pages' images in the sources, and its
131
+ `report.md` has the file:line of every located fix and the route file of every route type. A preflight stops the audit
132
+ when the production home's stylesheets do not load (a CDN host the machine cannot reach: set the CDN variable empty for
133
+ both `next build` and `next start`). URLs come from `--urls-from`, else the `--prod` sitemap, else a same-origin crawl of
134
+ the home page two links deep (at most 500 pages, plain HTTP, no browser). See the audit discovery entry below.
135
+ - `imgtune audit` discovery: the crawl starts from the home page and from the app's seeds (its static routes, and each route under a
136
+ dynamic first segment such as `/[store]/about-us` once per learned value). The values are where the home page redirects, then the
137
+ other first segments of its links and its `hreflang` alternates, capped by `--max-locales N` (default 3). The crawl takes the groups
138
+ of first segments in turn, so one locale cannot use up the page budget. A redirect or link is followed only on the exact origin of
139
+ `--prod` (a redirect to a sibling subdomain is refused). A sitemap or alternate that names another host is read on a local `--prod`
140
+ instead (path and query kept, the other host never fetched), and the audit says how many URLs it moved. `--urls-from` replaces the
141
+ discovery and adds no seeds.
142
+ - `imgtune audit` route types are URL templates (`routes.ts`), not route files. The first segment of a URL that the app takes as a
143
+ dynamic segment, and a `desktop` or `mobile` variant after it, are dimensions. The route file of each type is an attribute and a
144
+ column of the report's route-type table. A route file that renders several templates gives several types.
145
+ - Scan: the extra fetches (originals, srcset candidates, source maps, robots) are shared across the whole run as before, but a
146
+ failed fetch, or a body with `Cache-Control: no-store`, is no longer kept for later pages: a later page asks again. No
147
+ response of the 8-URL nextjs.org run changed.
148
+ - Scan: a lossy image over 2 MP (MATCH_MAX_PIXELS) keeps its resize estimate. Its quality match runs at the fast AVIF effort
149
+ with at most 5 probes and no effort-4 confirmation, so the estimate is approximate: its record says `estimate: "approx"`
150
+ and the scan summary, the Markdown report and `imgtune summary` show it with ≈. On nextjs.org/conf the exact + est. share
151
+ is 94.6% ≈ (the 3840-px images, about 6.5 MB per view of est. saving).
152
+ - Scan: each measured page is written to `<out>/.staged` as soon as it is measured, with its bodies as blobs, and is
153
+ complete only once its analysis is written. `--resume` after an interruption measures no staged page again and runs
154
+ only the analysis; the stage folder is removed when the run finishes.
155
+ - `imgtune run <url...> | --urls-from <file|sitemap>` is the whole agent flow in one command: survey every URL, rank the pages
156
+ by image KB per view per route type, full-scan the heaviest page of each type (`--deep <N>`, default 20), locate
157
+ them on a dev server (`--dev <origin>`), and group the fix hints by call site (`--qualities`). It writes a run directory
158
+ (`survey/`, `deep/`, `located/`, `hints.json`, and `run.json`, a manifest that lists every file and every command it ran) and
159
+ prints a summary of at most 40 lines. The scan passthroughs are `--gpu`, `--gpu-device`, `--page-concurrency`,
160
+ `--idle-timeout` and `--no-cache`, plus `--root`, `--storage-state` and `--ignore-robots`.
161
+ - `imgtune summary <run dir | scan.json ...>` prints the same one-screen summary again, from a run directory or from scan files.
162
+ - Broken pages: a page whose document answered an HTTP error, whose linked stylesheet did not load, or that finds no images
163
+ where the survey found some is `broken`, with its reason and the likely fix. A broken page has no page numbers and counts in
164
+ no total. `scan --fail-on-broken` exits 4. `run` stops before the deep phase when more than half of the survey is broken.
165
+ A page now records `documentStatus` and `stylesheetHosts` when they apply.
166
+ - Scan: `--survey` is a fast site-wide pass (viewports `iphone-15` and `desktop`, no quality estimate, four pages at once,
167
+ a 3 s network-idle cap). Its numbers are exact savings only and are labelled so in the scan's output, the Markdown and
168
+ the report. A survey is recorded in each page's options, so `--resume` and `compare` never mix it with a full scan.
169
+ - Scan: `--page-concurrency <N>` (1 to 6, default 1) scans up to N pages at once on one browser. Results keep the input
170
+ order and each page's JSON is written as it finishes; `--max-pages` and `--max-images` stop new pages and keep those in
171
+ flight. The GPU scorer's CPU fallbacks are counted per page.
172
+ - Scan: `--idle-timeout <ms>` (0 to 600000, default 10000) caps the wait for network idle after scroll; 0 skips it.
173
+ - Faster AVIF quality search, same numbers: the search encodes AVIF at effort 2 (`cdn-fast`) and confirms the answer at
174
+ effort 4 from the qualities that decided it (two or three effort-4 probes). A confirmation that fails moves the fast bar by the
175
+ score difference seen at that quality and searches again (twice at most); when none confirms, the search runs at effort 4 as
176
+ before. The reported target and bytes are the effort-4 ones. On the Next.js guard scan the per-image targets and estimated
177
+ bytes are identical to the previous build, and the scan's CPU time fell from about 247 s to 155 s (shared machine).
178
+ - Scans can block the requests to other sites that cannot change an image or the layout (scripts, fetch and xhr,
179
+ websockets, event streams, media, beacons and pings, manifests, iframes, other), with `--block-third-party`. Documents,
180
+ stylesheets, fonts and images are never blocked, from any host. Blocking makes pages reach `load` and network idle sooner.
181
+ `--allow-host <h1,h2>` keeps named hosts while blocking (for a third-party script that renders images). The page JSON
182
+ counts the aborted requests (`thirdPartyBlocked`) and records the setting, so `--resume` does not mix settings; a recheck
183
+ refuses a scan file recorded with the other setting.
184
+ - Scan: `--shared-loads` (opt-in) loads each viewport class (mobile, desktop) once and resizes the open page for the other
185
+ viewports of the class. It saves CPU but costs wall time (survey: 98.7 s shared against 69.1 s fresh), so the default loads
186
+ each viewport fresh, as before. The mode is recorded in each page's options (`loads`): `--resume` rescans pages of the other
187
+ mode and `compare` warns about it.
188
+ - Caller map: a call site whose component is rendered for several callers lists each caller at the first owner level above
189
+ the image that differs between usages (`callers:` in the grouped `hints`, `callers` in the JSON), with its file:line,
190
+ pages and its own sizes formula. The dev owner chain is read at `--locate-only` and kept on each located record
191
+ (`locator.ownerChain`, sanitised on read).
192
+ - Docs: for Next.js, the asset-prefix env must be set the same for `next build` and `next start`; the skill says to check
193
+ the stylesheets load before a scan.
194
+ - Fix: the GPU scorer no longer overwrites the user's `CUDA_VISIBLE_DEVICES` (a GPU the user excluded could run the scorer).
195
+ `--gpu-device` is an index within the user's visible list; imgtune never sets `CUDA_DEVICE_ORDER`. The scorer prints the
196
+ device it opened (name, index, order, visible list) and the scan JSON records `deviceOrder` and `visibleDevices`.
197
+ - Fix: the sizes-only check judges undersize on the measured server's width, computes the chosen candidate from the dev
198
+ page's `sizes` (and checks it is the file the browser loaded), and reports a dev/measured layout difference as `LAYOUT`
199
+ (not a pass). It stops with no verdict when the dev page has a stylesheet that did not load.
200
+ - Scans warn when a linked stylesheet did not load (`stylesheetsFailed` on the page): the widths measured on such a page
201
+ are not the app's. Earlier numbers measured on a page whose utility stylesheet was refused are void.
202
+ - A sizes proposal undersized at any measured viewport is not a saving (the per-image records and the group report).
203
+ The candidate evaluation moves to `src/candidates.ts`, with a reader for the calc, min, max and clamp forms.
204
+ - `hints --format json` is not cut to the text default of 10: JSON is unlimited unless `--limit` is passed, and carries
205
+ `truncated`.
206
+ - `imgtune report` accepts `--compare` once per page; the byte totals are summed.
207
+ - A PASS that adds bytes is labelled `PASS (sharpness fix)`; a batch that mixes them with savings prints a `mixed:` line.
208
+ - Security: page-derived text in the sizes output is cleaned of bidi overrides and zero-width marks, and capped.
209
+
210
+ - CSS background images are measured (`kind: "css-background"` records): every layer of a computed `background-image` that
211
+ names a file, with its box, `background-size`, the file the browser loaded, the device pixels it needs, undersized, and a
212
+ local resize estimate. Gradients, inline `data:` images and hidden or tiny elements are skipped with a reason. The headline
213
+ numbers stay `<img>`-only (the page's `backgrounds` line reports the layers apart). Page-controlled values are capped
214
+ (4096 characters, 8 layers) and parsed in one pass.
215
+ - A harness route (`/__imgtune/<component>`, or any URL scanned with `--harness`) is measured-harness, not measured-page:
216
+ its records and report say the component was measured in isolation. The skill's harness step is fixed for App Router
217
+ (a `%5F_` folder: a plain `_` folder is private) and says to verify with recheck on the harness URL.
218
+ - `scan --locate-only` works on a built page that ships source maps: the owner components' definitions, read through the
219
+ debugger and mapped by the chunk's source map, give `file:line` with `via: prod-sourcemap` (never verified: the definition
220
+ is not an image line). The same caps apply. Dev data stays the first choice.
221
+ - Fix: a score the CPU made (a GPU scorer's fallback, or a pair too small for the GPU) is no longer stored under the GPU's
222
+ cache key. The cache version moves to 2, so entries written before are not read.
223
+ - `imgtune compare <before.json> <after.json>`: the per-image before and after of two scans of one page, paired by URL
224
+ pattern and alt, and by rendered width where one key has several elements. Saved and added-for-sharpness bytes are two
225
+ numbers; unpaired images are listed, not counted. Drift is excluded and its totals printed: an image whose rendered
226
+ width changed (layout changed), the same file served with different bytes (server variance), and a view one scan did
227
+ not load. Two scans of one build compare to 0 KB saved.
228
+ - Faster scans, same numbers: the quality samples of a scan are estimated two at a time (each encode already uses four
229
+ threads), and a served file under 2 KB is not swept (`quality.sweep: "skipped-small"` in the JSON). On the nextjs.org
230
+ guard the JSON is identical to the previous build. Cold CPU scan about 18% faster (median 57.5 s over four runs before,
231
+ 46.9 s over five after).
232
+ - `imgtune scan --recheck` with several pages starts one browser for all of them (it started one per page).
233
+ - `imgtune hints --help` lists `--top`, `--flat` and `--qualities`, and accepts several scan files.
234
+ - Shared and varied call sites get a fitted `sizes`: a linear `vw + px` per band of viewports where the measurements allow,
235
+ a ceiling constant elsewhere, never below a measured width at any measured viewport. When pages need different widths,
236
+ the call site says `shared component: pass sizes per caller` and each page gets its own formula. `hints` takes several
237
+ scan files, so call sites group across pages.
238
+ - A sizes proposal whose predicted outcome adds bytes (or saves none) is not a saving: the scan leaves it out with a
239
+ `no saving` reason, and `hints` counts it. The prediction uses the candidate each proposed value would pick, per viewport.
240
+ - `imgtune hints --top N`: the N call sites that save the most per view, one line each.
241
+ - Recheck (full and `--sizes-only`) prints one line per image (`PASS`, `FAIL` or `GONE`, predicted and measured bytes) and a
242
+ batch line; the detail follows only for images that did not pass.
243
+ - `imgtune report`: a Markdown run report from `.imgtune/ledger.jsonl` and the compare JSON. Ledger values are escaped as
244
+ code in pipe-free table cells.
245
+
246
+ - Faster scans, same numbers. The AVIF and WebP calibration no longer encodes all 25 local qualities of each match: a
247
+ parallel search over the coarse grid and then the refine window encodes about half as many, and any probe that shows
248
+ bytes falling as quality rises falls back to the full scan. Targets and ratios are identical to the previous build on
249
+ the nextjs.org guard, on its five quality samples and on 25 generated images, with the CPU and with `--gpu`.
250
+ - Result cache in `.imgtune/cache` (bounded to 500 MB, oldest removed first): the local encode sizes and each probe's
251
+ score, keyed by the original's content hash, width, scorer, encoder, quality, profile and tool version. Used by `scan`,
252
+ `scan --recheck` and `image`; a warm run reuses everything the cold run computed. `--no-cache` turns it off. Corrupt or
253
+ foreign entries are ignored.
254
+ - Settle waits only for real document navigations. Same-document URL changes (`history.pushState`, `replaceState`, hash
255
+ changes) no longer repeat the settle and its 10 s network-idle wait; the wait itself is unchanged.
256
+ - `--root <dir>` (`scan`, `--locate-only`): the folder project sources are read from. By default each file is read from the
257
+ nearest folder above the cwd that holds it, so a scan run from a sub-folder of a monorepo no longer marks every source
258
+ `unread`. Unread sources are named in the output with the folder they were looked up in.
259
+ - `scan --recheck` takes several pages (repeat `--recheck`, at most three browser contexts in all) and `--viewports <names>`
260
+ to measure other viewports than the ones where the images rendered. Its default is unchanged.
261
+ - Security: the `locate` block and the located records of a scan file are checked as a whole when read. A dev URL that is
262
+ not http or https, an unknown viewport, a count that is not a non-negative integer, or a short label that is not plain
263
+ text drops the block (the rest of the scan is kept, and the drop is reported). The sizes recheck refuses a non-http dev
264
+ URL from the file or the command line. `hints` prints the located line only from a checked block.
265
+
266
+ - Split measure and locate. `scan <built-url>` measures the bytes on a built server or a preview (no source needed).
267
+ `scan <dev-url> --locate-only --from <scan.json>` reads the React source of the flagged images from a dev page, at one
268
+ viewport (default `iphone-15`), with no image bytes read and nothing scored. It writes `<scan>.located.json`: the measured
269
+ file with `file:line` and component joined in, and a `locate` summary (joined, not joined by reason). Elements join on
270
+ the whole srcset as URL patterns (origin, widths and qualities taken out) and alt, in document order, only when the counts
271
+ on both sides are equal; a count that differs joins nothing. Scans from before this version (no `domIndex`) cannot be
272
+ located: scan again.
273
+ - `scan --recheck <file> --sizes-only [--ids ...] [--url <dev-url>]`: the per-batch check on dev, in seconds. Per image and
274
+ viewport: the `sizes` attribute, the rendered width, the DPR, the candidate chosen and its bytes (Content-Length), undersized,
275
+ the hint's target, and a verdict. Fails an undersized choice, a choice bigger than the hint's sizes need, and bytes that rose
276
+ without a sharpness fix. No bytes decoded, nothing scored. The full `--recheck` is unchanged.
277
+ - `hints` on a located file prints where the sources came from, and says how many hinted images still have no `file:line`.
278
+ - The agent skill, its reference and the AGENTS snippet use the four-step flow: measure (built), locate (dev), check each
279
+ batch (dev, sizes only), measure again (built, the real before and after).
280
+ - `--gpu` and `--gpu-device <n>` (`scan`, `scan --recheck`, `image`): SSIMULACRA2 scoring on an NVIDIA GPU, opt-in. It runs
281
+ through `imgtune-ssimu2-gpu`, an external program built from `native/ssimulacra2-gpu/` against libvship (Vship). A missing
282
+ or failing program prints a warning and the run scores on the CPU. Encoding stays on the CPU. On nextjs.org scoring took
283
+ 19 s on the CPU and 1 s on the GPU (a timed scan: 110 s to 89 s on a loaded machine). The quality targets and per-image
284
+ records of the nextjs.org scan were identical; scores differ by up to 1.2 points on its re-encode pairs.
285
+ - Scan JSON records the scorer: `scorer` on each page and on the summary, and in `resume` options. `scan --recheck` warns when
286
+ its scorer differs from the scan's.
287
+ - Locator check: a resolved `file:line` is read from the project (local pages only) and must render an image. A call
288
+ site that only uses the component which renders the image is `kind: owner`, `verified: false`, and hints say which
289
+ component to search. `kind` is `image`, `owner`, `unread` or `unchecked`. Scan from the project root.
290
+ - Placeholder owner names (such as the app's fallback name) are never searched for or printed as a component; a hint with no clue says
291
+ `owner unknown`.
292
+ - Recheck pairing: an image's id no longer includes its line, so an edit above it keeps the id. A changed id is paired
293
+ by source file and URL pattern, and `gone` lists what was looked for and asks for a pristine rescan first. The cause of
294
+ the earlier false `gone` results was the line in the id, not the `sizes` value.
295
+ - `hints` groups by call site across pages (default; `--flat` for one entry per image). Shared components print each
296
+ caller's sizes and the rule to follow.
297
+ - `hints --qualities <list>` snaps quality targets to the framework's allowed values and marks the quality saving void
298
+ after a snap.
299
+ - Savings are split into `sizes` (exact) and `quality` (est.) in the scan records, hints and call sites. Recheck prints the
300
+ predicted saving beside the measured served bytes.
301
+ - `basis: fit` is kept: it is emitted when the layout formula does not match the measured widths (for example with no
302
+ layout chain). It is rare, and documented as such.
303
+
304
+ - `scan` measures the images of live pages in headless Chromium, per view and per viewport, at six viewports. It reports
305
+ exact and est. byte savings, sharpness growth apart from them, and a SSIMULACRA2 quality check.
306
+ - Per-image fix hints. For each image: its locator (component, `file:line` in dev builds, `data-testid`, `alt`, loader
307
+ URL pattern), the `sizes` the browser resolved at each viewport, the quality and format targets, and two byte numbers,
308
+ bytes saved and bytes added for sharpness, which are never netted.
309
+ - `hints <scan.json>` prints the fix hints in batches: `--limit`, `--offset`, filters, and `--format json`.
310
+ - `scan --recheck <scan.json> [--ids a,b,c]` rescans the named images and prints before and after. It exits 1 when an
311
+ image became undersized, or when bytes rose without a sharpness fix.
312
+ - `scan` at scale: `--urls-from` (a URL list or a sitemap), `--sample-per-pattern`, `--storage-state` for a logged-in
313
+ session, `--resume`, `--max-pages` and `--max-images`. `--ignore-robots` is for sites the user owns or is authorised to
314
+ test, and is recorded in the JSON.
315
+ - `image <file|url|glob>` measures image files without a page: their bytes, the lowest quality per format that meets the
316
+ bar (SSIMULACRA2 90 by default, `--min-score` sets another), and at a `--width` the bytes at each needed width.
317
+ `--write` writes optimised copies into a folder and never overwrites a file.
318
+ - The `skills/imgtune` agent skill (SKILL.md and reference.md), and `skills/imgtune/AGENTS.snippet.md` for AGENTS.md,
319
+ CLAUDE.md, Cursor, Codex or Copilot.
320
+ - The composite GitHub Action in `action.yml` scans the pages it is given and comments on the pull request. The comment
321
+ keeps the summary tables and adds a collapsed "Fix hints for your AI agent" section.
322
+ - Requires Node.js 20.9 or later and Chromium for Playwright.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 indicozy and imgtune contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,20 @@
1
+ imgtune
2
+
3
+ The SSIM quality measurement (src/ssim.ts) is adapted from Optiq:
4
+
5
+ Optiq
6
+ Copyright 2026 Optiq contributors
7
+ Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
8
+
9
+ The adapted code keeps the original attribution header. Modifications are made for imgtune.
10
+
11
+ The SSIMULACRA2 WebAssembly module in vendor/ssimulacra2 is built from native/ssimulacra2-wasm, which uses:
12
+
13
+ ssimulacra2 0.5.0 (BSD-2-Clause), Copyright (c) 2022-2022, the rav1e contributors
14
+ The scoring loop (native/ssimulacra2-wasm/src/scorer.rs) and the blur (native/ssimulacra2-wasm/src/blur/) are
15
+ vendored from it and changed to use less memory. Their file headers carry the full license text. The upstream
16
+ crate is used only by the tests in native/ssimulacra2-wasm/tests/.
17
+ yuvxyb 0.4.0 (MIT)
18
+ wasm-bindgen 0.2.129 (MIT OR Apache-2.0)
19
+
20
+ Full license texts for all of the above are in THIRD_PARTY_LICENSES.