imgtune 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +248 -0
- package/LICENSE +21 -0
- package/NOTICE +20 -0
- package/README.md +309 -2
- package/THIRD_PARTY_LICENSES +295 -0
- package/dist/address.js +221 -0
- package/dist/app-routes.js +211 -0
- package/dist/atomic.js +63 -0
- package/dist/audit.js +372 -0
- package/dist/backgrounds.js +352 -0
- package/dist/broken.js +106 -0
- package/dist/browser.js +30 -0
- package/dist/calibrate.js +221 -0
- package/dist/candidates.js +151 -0
- package/dist/check-report.js +285 -0
- package/dist/check.js +412 -0
- package/dist/cli.js +473 -0
- package/dist/compare.js +398 -0
- package/dist/concurrency.js +37 -0
- package/dist/config.js +10 -0
- package/dist/crawl.js +186 -0
- package/dist/deep.js +76 -0
- package/dist/egress-proxy.js +151 -0
- package/dist/encode.js +101 -0
- package/dist/estimate-jobs.js +114 -0
- package/dist/fetch.js +174 -0
- package/dist/gpu.js +341 -0
- package/dist/group-sizes.js +120 -0
- package/dist/hints.js +690 -0
- package/dist/image-file.js +433 -0
- package/dist/images.js +336 -0
- package/dist/layout.js +240 -0
- package/dist/lcp.js +66 -0
- package/dist/loaders.js +267 -0
- package/dist/loads.js +29 -0
- package/dist/locate-join.js +425 -0
- package/dist/locate-only.js +167 -0
- package/dist/locate.js +378 -0
- package/dist/lru.js +34 -0
- package/dist/measure.js +238 -0
- package/dist/metric.js +227 -0
- package/dist/next-config.js +87 -0
- package/dist/qualities-cap.js +141 -0
- package/dist/recheck-rows.js +40 -0
- package/dist/recheck.js +376 -0
- package/dist/remote.js +3177 -0
- package/dist/report.js +253 -0
- package/dist/result-cache.js +130 -0
- package/dist/routes.js +280 -0
- package/dist/run.js +441 -0
- package/dist/search.js +88 -0
- package/dist/site.js +31 -0
- package/dist/sizes-outcome.js +81 -0
- package/dist/sizes-recheck.js +332 -0
- package/dist/sizes.js +148 -0
- package/dist/source-root.js +77 -0
- package/dist/srcset-parse.js +98 -0
- package/dist/srcset.js +44 -0
- package/dist/ssim.js +196 -0
- package/dist/stage.js +51 -0
- package/dist/summary.js +475 -0
- package/dist/survey.js +13 -0
- package/dist/text.js +72 -0
- package/dist/third-party.js +127 -0
- package/dist/timing.js +60 -0
- package/dist/types.js +1 -0
- package/dist/urls.js +300 -0
- package/dist/version.js +3 -0
- package/dist/worst.js +42 -0
- package/package.json +70 -4
- package/skills/imgtune/AGENTS.snippet.md +47 -0
- package/skills/imgtune/SKILL.md +344 -0
- package/skills/imgtune/reference.md +339 -0
- package/vendor/ssimulacra2/ssim_wasm.cjs +113 -0
- package/vendor/ssimulacra2/ssim_wasm.d.ts +8 -0
- package/vendor/ssimulacra2/ssim_wasm_bg.wasm +0 -0
- package/vendor/ssimulacra2/ssim_wasm_bg.wasm.d.ts +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- LCP first. Each scanned page reads the largest contentful paint before its first scroll, per viewport. A page's report
|
|
6
|
+
and the PR comment show one line above its numbers: `LCP image (<viewports>): <file:line>, <KB> served, <KB> fixable`,
|
|
7
|
+
or `LCP is not an image`. `hints` sorts the LCP image first and prefixes its line with `LCP image (<viewports>):`. The
|
|
8
|
+
scan JSON gains `lcp` and `lcpViewports` on images, `lcp` on each viewport, and the hints JSON gains `lcp` and `lcpViewports`.
|
|
9
|
+
- `sizes="auto"` awareness. A lazy image whose only fix is its sizes gets an alternative line,
|
|
10
|
+
`sizes="auto, <proposed>"` (Chrome, Edge, Firefox 150 or later; Safari falls back to the proposed value). The explicit
|
|
11
|
+
value stays the fix. Eager and LCP images never get it.
|
|
12
|
+
- CDN auto quality. A Cloudinary `q_auto` group whose estimated quality saving is below a tenth of its served bytes is
|
|
13
|
+
reported as `quality handled by <loader> (q_auto)`, and that saving is not counted. Above it, the hint names the explicit
|
|
14
|
+
`q_<N>`. A jpeg or png whose format the CDN negotiates (`f_auto`, imgix `auto=format`) gets no format change. The
|
|
15
|
+
fix record gains `cdnQuality` and `formatHandled`.
|
|
16
|
+
- `images.qualities` cap. The next/image quality targets of a scan (across its pages) are clustered into at most
|
|
17
|
+
`--max-qualities N` values (default 2, 1 to 4). Each target is raised to the nearest chosen value, so the SSIMULACRA2 bar
|
|
18
|
+
still holds. The site-level line `next.config: images.qualities: [...]` is printed by `hints`, `scan` and the reports;
|
|
19
|
+
a usage's estimated quality saving is scaled to its capped value and marked approximate. `--qualities` turns the cap off.
|
|
20
|
+
- The agent skill has a "Fix the images" section: the hints in batches, the LCP image first, the `images.qualities` line
|
|
21
|
+
once, then a scan again with before and after reported apart. Its description names "fix image sizes", "next/image
|
|
22
|
+
sizes" and "LCP image".
|
|
23
|
+
- Third-party requests are allowed by default. `--block-third-party` turns the blocking on (scans, rechecks, locate-only,
|
|
24
|
+
sizes-only, `run` and `check`). Blocking can drop images that a page loads through its own API on another host: on a
|
|
25
|
+
local store build, 0 images were recorded with blocking and 50 without it. `--allow-host <h1,h2>` now needs
|
|
26
|
+
`--block-third-party`. `--allow-third-party` is accepted for one release as a no-op with a deprecation note. The
|
|
27
|
+
recorded setting is `allowed` by default, so a scan from before this change (recorded as `blocked`, or with no record)
|
|
28
|
+
does not match on `--resume` or `recheck` where the setting differs.
|
|
29
|
+
- `check`: failed pages are counted. A deep page the browser could not load (status `failed`, for example a navigation
|
|
30
|
+
timeout) gets one retry: the next-heaviest measured page of its route type is scanned in its place, and the report names
|
|
31
|
+
both. `run.json` has a `failed` count per phase and a `deepRetries` list, `imgtune summary` prints the failed count in its
|
|
32
|
+
survey and deep lines, and the report's header and "broken, not measured" section count the failed pages.
|
|
33
|
+
- Browser errors in the report and the terminal are one line: ANSI escape sequences are removed whole (`stripControls`), and
|
|
34
|
+
the Playwright "Call log:" block after the first line is dropped (`errorLine`). The full message stays in the page JSON.
|
|
35
|
+
- Route types (`src/routes.ts`): a version segment after the first segment (`/docs/15/app/...`, `/docs/v2.3/...`) is a
|
|
36
|
+
dimension when the same section has unversioned pages, so the versions merge with them. Dated archives and numeric ids
|
|
37
|
+
are not read as versions (see the tests).
|
|
38
|
+
- Route types (`src/routes.ts`): the URLs of a site group into the templates they are instances of. A leading locale or store
|
|
39
|
+
segment (`ae-en`, `sa_AR`) and a `desktop` or `mobile` variant are dimensions, not part of the type. Product slugs, ids
|
|
40
|
+
and listing families collapse to `*`, and query strings keep their parameter names (`/search?q=*`). Used by the survey
|
|
41
|
+
sampler (`--sample-per-pattern`), `run` and `check`. `--route-map <file>` names the URLs that match a pattern (`pattern =>
|
|
42
|
+
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.
|
|
43
|
+
- Deep selection (`src/deep.ts`): the heaviest page of each route type that has images, by image KB per view (exact KB as the
|
|
44
|
+
tie-break), with the types ordered by their total KB per view. `--deep <N>` defaults to 20 (was 5). The types the cap leaves
|
|
45
|
+
out, and the types with no images, are named in the output.
|
|
46
|
+
- `imgtune check <origin | sitemap-url | page-url> | --urls-from <file|sitemap>`: the production check, in a browser only.
|
|
47
|
+
It surveys at most `--sample-per-pattern N` URLs per route type (default 3), full-scans the heaviest page of each type, and writes
|
|
48
|
+
`report.md` (header, summary numbers with sharpness as its own number, ranked fixes merged across the deep pages, heavy
|
|
49
|
+
pages with little to save, sharpness images, a route-type table, what was not measured, and how to read the numbers),
|
|
50
|
+
`run.json` (the manifest: `imgtune summary` reads it), `survey/`, `deep/` and `urls.txt`. It passes through `--gpu`,
|
|
51
|
+
`--gpu-device`, `--page-concurrency`, `--qualities`, `--block-third-party`, `--allow-host`, `--idle-timeout`, `--no-cache`,
|
|
52
|
+
`--ignore-robots` and `--storage-state`. `run` is kept: it also locates on a dev server (`--dev`) and writes the hints.
|
|
53
|
+
- `imgtune audit --app <dir> --prod <origin> [--dev <origin>]`: the deep mode for your own Next.js app. It reads the route
|
|
54
|
+
files (`app/`, `src/app/`, `pages/`, `src/pages/`) into route patterns, and `images.qualities` from `next.config.{js,mjs,ts}`
|
|
55
|
+
(a static read; Next's `[75]` is assumed and reported when the list is missing). It surveys `--prod` and full-scans the
|
|
56
|
+
heaviest page of each route type, as `check` does. With `--dev` it locates those pages' images in the sources, and its
|
|
57
|
+
`report.md` has the file:line of every located fix and the route file of every route type. A preflight stops the audit
|
|
58
|
+
when the production home's stylesheets do not load (a CDN host the machine cannot reach: set the CDN variable empty for
|
|
59
|
+
both `next build` and `next start`). URLs come from `--urls-from`, else the `--prod` sitemap, else a same-origin crawl of
|
|
60
|
+
the home page two links deep (at most 500 pages, plain HTTP, no browser). See the audit discovery entry below.
|
|
61
|
+
- `imgtune audit` discovery: the crawl starts from the home page and from the app's seeds (its static routes, and each route under a
|
|
62
|
+
dynamic first segment such as `/[store]/about-us` once per learned value). The values are where the home page redirects, then the
|
|
63
|
+
other first segments of its links and its `hreflang` alternates, capped by `--max-locales N` (default 3). The crawl takes the groups
|
|
64
|
+
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
|
|
65
|
+
`--prod` (a redirect to a sibling subdomain is refused). A sitemap or alternate that names another host is read on a local `--prod`
|
|
66
|
+
instead (path and query kept, the other host never fetched), and the audit says how many URLs it moved. `--urls-from` replaces the
|
|
67
|
+
discovery and adds no seeds.
|
|
68
|
+
- `imgtune audit` route types are URL templates (`routes.ts`), not route files. The first segment of a URL that the app takes as a
|
|
69
|
+
dynamic segment, and a `desktop` or `mobile` variant after it, are dimensions. The route file of each type is an attribute and a
|
|
70
|
+
column of the report's route-type table. A route file that renders several templates gives several types.
|
|
71
|
+
- Scan: the extra fetches (originals, srcset candidates, source maps, robots) are shared across the whole run as before, but a
|
|
72
|
+
failed fetch, or a body with `Cache-Control: no-store`, is no longer kept for later pages: a later page asks again. No
|
|
73
|
+
response of the 8-URL nextjs.org run changed.
|
|
74
|
+
- Scan: a lossy image over 2 MP (MATCH_MAX_PIXELS) keeps its resize estimate. Its quality match runs at the fast AVIF effort
|
|
75
|
+
with at most 5 probes and no effort-4 confirmation, so the estimate is approximate: its record says `estimate: "approx"`
|
|
76
|
+
and the scan summary, the Markdown report and `imgtune summary` show it with ≈. On nextjs.org/conf the exact + est. share
|
|
77
|
+
is 94.6% ≈ (the 3840-px images, about 6.5 MB per view of est. saving).
|
|
78
|
+
- Scan: each measured page is written to `<out>/.staged` as soon as it is measured, with its bodies as blobs, and is
|
|
79
|
+
complete only once its analysis is written. `--resume` after an interruption measures no staged page again and runs
|
|
80
|
+
only the analysis; the stage folder is removed when the run finishes.
|
|
81
|
+
- `imgtune run <url...> | --urls-from <file|sitemap>` is the whole agent flow in one command: survey every URL, rank the pages
|
|
82
|
+
by image KB per view per route type, full-scan the heaviest page of each type (`--deep <N>`, default 20), locate
|
|
83
|
+
them on a dev server (`--dev <origin>`), and group the fix hints by call site (`--qualities`). It writes a run directory
|
|
84
|
+
(`survey/`, `deep/`, `located/`, `hints.json`, and `run.json`, a manifest that lists every file and every command it ran) and
|
|
85
|
+
prints a summary of at most 40 lines. The scan passthroughs are `--gpu`, `--gpu-device`, `--page-concurrency`,
|
|
86
|
+
`--idle-timeout` and `--no-cache`, plus `--root`, `--storage-state` and `--ignore-robots`.
|
|
87
|
+
- `imgtune summary <run dir | scan.json ...>` prints the same one-screen summary again, from a run directory or from scan files.
|
|
88
|
+
- Broken pages: a page whose document answered an HTTP error, whose linked stylesheet did not load, or that finds no images
|
|
89
|
+
where the survey found some is `broken`, with its reason and the likely fix. A broken page has no page numbers and counts in
|
|
90
|
+
no total. `scan --fail-on-broken` exits 4. `run` stops before the deep phase when more than half of the survey is broken.
|
|
91
|
+
A page now records `documentStatus` and `stylesheetHosts` when they apply.
|
|
92
|
+
- Scan: `--survey` is a fast site-wide pass (viewports `iphone-15` and `desktop`, no quality estimate, four pages at once,
|
|
93
|
+
a 3 s network-idle cap). Its numbers are exact savings only and are labelled so in the scan's output, the Markdown and
|
|
94
|
+
the report. A survey is recorded in each page's options, so `--resume` and `compare` never mix it with a full scan.
|
|
95
|
+
- Scan: `--page-concurrency <N>` (1 to 6, default 1) scans up to N pages at once on one browser. Results keep the input
|
|
96
|
+
order and each page's JSON is written as it finishes; `--max-pages` and `--max-images` stop new pages and keep those in
|
|
97
|
+
flight. The GPU scorer's CPU fallbacks are counted per page.
|
|
98
|
+
- Scan: `--idle-timeout <ms>` (0 to 600000, default 10000) caps the wait for network idle after scroll; 0 skips it.
|
|
99
|
+
- Faster AVIF quality search, same numbers: the search encodes AVIF at effort 2 (`cdn-fast`) and confirms the answer at
|
|
100
|
+
effort 4 from the qualities that decided it (two or three effort-4 probes). A confirmation that fails moves the fast bar by the
|
|
101
|
+
score difference seen at that quality and searches again (twice at most); when none confirms, the search runs at effort 4 as
|
|
102
|
+
before. The reported target and bytes are the effort-4 ones. On the Next.js guard scan the per-image targets and estimated
|
|
103
|
+
bytes are identical to the previous build, and the scan's CPU time fell from about 247 s to 155 s (shared machine).
|
|
104
|
+
- Scans can block the requests to other sites that cannot change an image or the layout (scripts, fetch and xhr,
|
|
105
|
+
websockets, event streams, media, beacons and pings, manifests, iframes, other), with `--block-third-party`. Documents,
|
|
106
|
+
stylesheets, fonts and images are never blocked, from any host. Blocking makes pages reach `load` and network idle sooner.
|
|
107
|
+
`--allow-host <h1,h2>` keeps named hosts while blocking (for a third-party script that renders images). The page JSON
|
|
108
|
+
counts the aborted requests (`thirdPartyBlocked`) and records the setting, so `--resume` does not mix settings; a recheck
|
|
109
|
+
refuses a scan file recorded with the other setting.
|
|
110
|
+
- Scan: `--shared-loads` (opt-in) loads each viewport class (mobile, desktop) once and resizes the open page for the other
|
|
111
|
+
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
|
|
112
|
+
each viewport fresh, as before. The mode is recorded in each page's options (`loads`): `--resume` rescans pages of the other
|
|
113
|
+
mode and `compare` warns about it.
|
|
114
|
+
- Caller map: a call site whose component is rendered for several callers lists each caller at the first owner level above
|
|
115
|
+
the image that differs between usages (`callers:` in the grouped `hints`, `callers` in the JSON), with its file:line,
|
|
116
|
+
pages and its own sizes formula. The dev owner chain is read at `--locate-only` and kept on each located record
|
|
117
|
+
(`locator.ownerChain`, sanitised on read).
|
|
118
|
+
- Docs: for Next.js, the asset-prefix env must be set the same for `next build` and `next start`; the skill says to check
|
|
119
|
+
the stylesheets load before a scan.
|
|
120
|
+
- Fix: the GPU scorer no longer overwrites the user's `CUDA_VISIBLE_DEVICES` (a GPU the user excluded could run the scorer).
|
|
121
|
+
`--gpu-device` is an index within the user's visible list; imgtune never sets `CUDA_DEVICE_ORDER`. The scorer prints the
|
|
122
|
+
device it opened (name, index, order, visible list) and the scan JSON records `deviceOrder` and `visibleDevices`.
|
|
123
|
+
- Fix: the sizes-only check judges undersize on the measured server's width, computes the chosen candidate from the dev
|
|
124
|
+
page's `sizes` (and checks it is the file the browser loaded), and reports a dev/measured layout difference as `LAYOUT`
|
|
125
|
+
(not a pass). It stops with no verdict when the dev page has a stylesheet that did not load.
|
|
126
|
+
- Scans warn when a linked stylesheet did not load (`stylesheetsFailed` on the page): the widths measured on such a page
|
|
127
|
+
are not the app's. Earlier numbers measured on a page whose utility stylesheet was refused are void.
|
|
128
|
+
- A sizes proposal undersized at any measured viewport is not a saving (the per-image records and the group report).
|
|
129
|
+
The candidate evaluation moves to `src/candidates.ts`, with a reader for the calc, min, max and clamp forms.
|
|
130
|
+
- `hints --format json` is not cut to the text default of 10: JSON is unlimited unless `--limit` is passed, and carries
|
|
131
|
+
`truncated`.
|
|
132
|
+
- `imgtune report` accepts `--compare` once per page; the byte totals are summed.
|
|
133
|
+
- A PASS that adds bytes is labelled `PASS (sharpness fix)`; a batch that mixes them with savings prints a `mixed:` line.
|
|
134
|
+
- Security: page-derived text in the sizes output is cleaned of bidi overrides and zero-width marks, and capped.
|
|
135
|
+
|
|
136
|
+
- CSS background images are measured (`kind: "css-background"` records): every layer of a computed `background-image` that
|
|
137
|
+
names a file, with its box, `background-size`, the file the browser loaded, the device pixels it needs, undersized, and a
|
|
138
|
+
local resize estimate. Gradients, inline `data:` images and hidden or tiny elements are skipped with a reason. The headline
|
|
139
|
+
numbers stay `<img>`-only (the page's `backgrounds` line reports the layers apart). Page-controlled values are capped
|
|
140
|
+
(4096 characters, 8 layers) and parsed in one pass.
|
|
141
|
+
- A harness route (`/__imgtune/<component>`, or any URL scanned with `--harness`) is measured-harness, not measured-page:
|
|
142
|
+
its records and report say the component was measured in isolation. The skill's harness step is fixed for App Router
|
|
143
|
+
(a `%5F_` folder: a plain `_` folder is private) and says to verify with recheck on the harness URL.
|
|
144
|
+
- `scan --locate-only` works on a built page that ships source maps: the owner components' definitions, read through the
|
|
145
|
+
debugger and mapped by the chunk's source map, give `file:line` with `via: prod-sourcemap` (never verified: the definition
|
|
146
|
+
is not an image line). The same caps apply. Dev data stays the first choice.
|
|
147
|
+
- 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
|
|
148
|
+
cache key. The cache version moves to 2, so entries written before are not read.
|
|
149
|
+
- `imgtune compare <before.json> <after.json>`: the per-image before and after of two scans of one page, paired by URL
|
|
150
|
+
pattern and alt, and by rendered width where one key has several elements. Saved and added-for-sharpness bytes are two
|
|
151
|
+
numbers; unpaired images are listed, not counted. Drift is excluded and its totals printed: an image whose rendered
|
|
152
|
+
width changed (layout changed), the same file served with different bytes (server variance), and a view one scan did
|
|
153
|
+
not load. Two scans of one build compare to 0 KB saved.
|
|
154
|
+
- Faster scans, same numbers: the quality samples of a scan are estimated two at a time (each encode already uses four
|
|
155
|
+
threads), and a served file under 2 KB is not swept (`quality.sweep: "skipped-small"` in the JSON). On the nextjs.org
|
|
156
|
+
guard the JSON is identical to the previous build. Cold CPU scan about 18% faster (median 57.5 s over four runs before,
|
|
157
|
+
46.9 s over five after).
|
|
158
|
+
- `imgtune scan --recheck` with several pages starts one browser for all of them (it started one per page).
|
|
159
|
+
- `imgtune hints --help` lists `--top`, `--flat` and `--qualities`, and accepts several scan files.
|
|
160
|
+
- Shared and varied call sites get a fitted `sizes`: a linear `vw + px` per band of viewports where the measurements allow,
|
|
161
|
+
a ceiling constant elsewhere, never below a measured width at any measured viewport. When pages need different widths,
|
|
162
|
+
the call site says `shared component: pass sizes per caller` and each page gets its own formula. `hints` takes several
|
|
163
|
+
scan files, so call sites group across pages.
|
|
164
|
+
- A sizes proposal whose predicted outcome adds bytes (or saves none) is not a saving: the scan leaves it out with a
|
|
165
|
+
`no saving` reason, and `hints` counts it. The prediction uses the candidate each proposed value would pick, per viewport.
|
|
166
|
+
- `imgtune hints --top N`: the N call sites that save the most per view, one line each.
|
|
167
|
+
- Recheck (full and `--sizes-only`) prints one line per image (`PASS`, `FAIL` or `GONE`, predicted and measured bytes) and a
|
|
168
|
+
batch line; the detail follows only for images that did not pass.
|
|
169
|
+
- `imgtune report`: a Markdown run report from `.imgtune/ledger.jsonl` and the compare JSON. Ledger values are escaped as
|
|
170
|
+
code in pipe-free table cells.
|
|
171
|
+
|
|
172
|
+
- Faster scans, same numbers. The AVIF and WebP calibration no longer encodes all 25 local qualities of each match: a
|
|
173
|
+
parallel search over the coarse grid and then the refine window encodes about half as many, and any probe that shows
|
|
174
|
+
bytes falling as quality rises falls back to the full scan. Targets and ratios are identical to the previous build on
|
|
175
|
+
the nextjs.org guard, on its five quality samples and on 25 generated images, with the CPU and with `--gpu`.
|
|
176
|
+
- Result cache in `.imgtune/cache` (bounded to 500 MB, oldest removed first): the local encode sizes and each probe's
|
|
177
|
+
score, keyed by the original's content hash, width, scorer, encoder, quality, profile and tool version. Used by `scan`,
|
|
178
|
+
`scan --recheck` and `image`; a warm run reuses everything the cold run computed. `--no-cache` turns it off. Corrupt or
|
|
179
|
+
foreign entries are ignored.
|
|
180
|
+
- Settle waits only for real document navigations. Same-document URL changes (`history.pushState`, `replaceState`, hash
|
|
181
|
+
changes) no longer repeat the settle and its 10 s network-idle wait; the wait itself is unchanged.
|
|
182
|
+
- `--root <dir>` (`scan`, `--locate-only`): the folder project sources are read from. By default each file is read from the
|
|
183
|
+
nearest folder above the cwd that holds it, so a scan run from a sub-folder of a monorepo no longer marks every source
|
|
184
|
+
`unread`. Unread sources are named in the output with the folder they were looked up in.
|
|
185
|
+
- `scan --recheck` takes several pages (repeat `--recheck`, at most three browser contexts in all) and `--viewports <names>`
|
|
186
|
+
to measure other viewports than the ones where the images rendered. Its default is unchanged.
|
|
187
|
+
- Security: the `locate` block and the located records of a scan file are checked as a whole when read. A dev URL that is
|
|
188
|
+
not http or https, an unknown viewport, a count that is not a non-negative integer, or a short label that is not plain
|
|
189
|
+
text drops the block (the rest of the scan is kept, and the drop is reported). The sizes recheck refuses a non-http dev
|
|
190
|
+
URL from the file or the command line. `hints` prints the located line only from a checked block.
|
|
191
|
+
|
|
192
|
+
- Split measure and locate. `scan <built-url>` measures the bytes on a built server or a preview (no source needed).
|
|
193
|
+
`scan <dev-url> --locate-only --from <scan.json>` reads the React source of the flagged images from a dev page, at one
|
|
194
|
+
viewport (default `iphone-15`), with no image bytes read and nothing scored. It writes `<scan>.located.json`: the measured
|
|
195
|
+
file with `file:line` and component joined in, and a `locate` summary (joined, not joined by reason). Elements join on
|
|
196
|
+
the whole srcset as URL patterns (origin, widths and qualities taken out) and alt, in document order, only when the counts
|
|
197
|
+
on both sides are equal; a count that differs joins nothing. Scans from before this version (no `domIndex`) cannot be
|
|
198
|
+
located: scan again.
|
|
199
|
+
- `scan --recheck <file> --sizes-only [--ids ...] [--url <dev-url>]`: the per-batch check on dev, in seconds. Per image and
|
|
200
|
+
viewport: the `sizes` attribute, the rendered width, the DPR, the candidate chosen and its bytes (Content-Length), undersized,
|
|
201
|
+
the hint's target, and a verdict. Fails an undersized choice, a choice bigger than the hint's sizes need, and bytes that rose
|
|
202
|
+
without a sharpness fix. No bytes decoded, nothing scored. The full `--recheck` is unchanged.
|
|
203
|
+
- `hints` on a located file prints where the sources came from, and says how many hinted images still have no `file:line`.
|
|
204
|
+
- The agent skill, its reference and the AGENTS snippet use the four-step flow: measure (built), locate (dev), check each
|
|
205
|
+
batch (dev, sizes only), measure again (built, the real before and after).
|
|
206
|
+
- `--gpu` and `--gpu-device <n>` (`scan`, `scan --recheck`, `image`): SSIMULACRA2 scoring on an NVIDIA GPU, opt-in. It runs
|
|
207
|
+
through `imgtune-ssimu2-gpu`, an external program built from `native/ssimulacra2-gpu/` against libvship (Vship). A missing
|
|
208
|
+
or failing program prints a warning and the run scores on the CPU. Encoding stays on the CPU. On nextjs.org scoring took
|
|
209
|
+
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
|
|
210
|
+
records of the nextjs.org scan were identical; scores differ by up to 1.2 points on its re-encode pairs.
|
|
211
|
+
- Scan JSON records the scorer: `scorer` on each page and on the summary, and in `resume` options. `scan --recheck` warns when
|
|
212
|
+
its scorer differs from the scan's.
|
|
213
|
+
- Locator check: a resolved `file:line` is read from the project (local pages only) and must render an image. A call
|
|
214
|
+
site that only uses the component which renders the image is `kind: owner`, `verified: false`, and hints say which
|
|
215
|
+
component to search. `kind` is `image`, `owner`, `unread` or `unchecked`. Scan from the project root.
|
|
216
|
+
- 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
|
|
217
|
+
`owner unknown`.
|
|
218
|
+
- Recheck pairing: an image's id no longer includes its line, so an edit above it keeps the id. A changed id is paired
|
|
219
|
+
by source file and URL pattern, and `gone` lists what was looked for and asks for a pristine rescan first. The cause of
|
|
220
|
+
the earlier false `gone` results was the line in the id, not the `sizes` value.
|
|
221
|
+
- `hints` groups by call site across pages (default; `--flat` for one entry per image). Shared components print each
|
|
222
|
+
caller's sizes and the rule to follow.
|
|
223
|
+
- `hints --qualities <list>` snaps quality targets to the framework's allowed values and marks the quality saving void
|
|
224
|
+
after a snap.
|
|
225
|
+
- Savings are split into `sizes` (exact) and `quality` (est.) in the scan records, hints and call sites. Recheck prints the
|
|
226
|
+
predicted saving beside the measured served bytes.
|
|
227
|
+
- `basis: fit` is kept: it is emitted when the layout formula does not match the measured widths (for example with no
|
|
228
|
+
layout chain). It is rare, and documented as such.
|
|
229
|
+
|
|
230
|
+
- `scan` measures the images of live pages in headless Chromium, per view and per viewport, at six viewports. It reports
|
|
231
|
+
exact and est. byte savings, sharpness growth apart from them, and a SSIMULACRA2 quality check.
|
|
232
|
+
- Per-image fix hints. For each image: its locator (component, `file:line` in dev builds, `data-testid`, `alt`, loader
|
|
233
|
+
URL pattern), the `sizes` the browser resolved at each viewport, the quality and format targets, and two byte numbers,
|
|
234
|
+
bytes saved and bytes added for sharpness, which are never netted.
|
|
235
|
+
- `hints <scan.json>` prints the fix hints in batches: `--limit`, `--offset`, filters, and `--format json`.
|
|
236
|
+
- `scan --recheck <scan.json> [--ids a,b,c]` rescans the named images and prints before and after. It exits 1 when an
|
|
237
|
+
image became undersized, or when bytes rose without a sharpness fix.
|
|
238
|
+
- `scan` at scale: `--urls-from` (a URL list or a sitemap), `--sample-per-pattern`, `--storage-state` for a logged-in
|
|
239
|
+
session, `--resume`, `--max-pages` and `--max-images`. `--ignore-robots` is for sites the user owns or is authorised to
|
|
240
|
+
test, and is recorded in the JSON.
|
|
241
|
+
- `image <file|url|glob>` measures image files without a page: their bytes, the lowest quality per format that meets the
|
|
242
|
+
bar (SSIMULACRA2 90 by default, `--min-score` sets another), and at a `--width` the bytes at each needed width.
|
|
243
|
+
`--write` writes optimised copies into a folder and never overwrites a file.
|
|
244
|
+
- The `skills/imgtune` agent skill (SKILL.md and reference.md), and `skills/imgtune/AGENTS.snippet.md` for AGENTS.md,
|
|
245
|
+
CLAUDE.md, Cursor, Codex or Copilot.
|
|
246
|
+
- The composite GitHub Action in `action.yml` scans the pages it is given and comments on the pull request. The comment
|
|
247
|
+
keeps the summary tables and adds a collapsed "Fix hints for your AI agent" section.
|
|
248
|
+
- 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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,310 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ImgTune
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/imgtune)
|
|
4
|
+
|
|
5
|
+
**ImgTune measures image waste on any live site.**
|
|
6
|
+
|
|
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
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
**Works with your image CDN.** Cloudinary, imgix and `next/image` resize to whatever width `sizes` asks for, so a wrong
|
|
14
|
+
`sizes` is waste that no CDN removes. ImgTune works on top of a CDN, not instead of one. Already on Cloudinary auto
|
|
15
|
+
quality (`q_auto`)? ImgTune focuses on `sizes`, which no CDN can fix for you. A format the CDN picks per request
|
|
16
|
+
(Cloudinary `f_auto`, imgix `auto=format`) is not proposed as a change.
|
|
17
|
+
|
|
18
|
+
**Built for image-heavy sites:** e-commerce catalogs, media, marketplaces.
|
|
19
|
+
|
|
20
|
+
## Two ways to measure
|
|
21
|
+
|
|
22
|
+
- **`npx imgtune check https://your-site`** measures any live site, in a browser, with no source. It reads the site's
|
|
23
|
+
sitemap, groups the URLs into route types (templates such as product or listing pages), deep-scans the heaviest page of
|
|
24
|
+
each type at six viewports, and writes a Markdown report, `report.md`.
|
|
25
|
+
- **`npx imgtune audit --app . --prod http://localhost:3000 --dev http://localhost:3001`** is the deep mode for your own
|
|
26
|
+
Next.js app. It reads the route files and `next.config`, measures the production build, and with `--dev` locates each
|
|
27
|
+
fix at its `file:line` in the source.
|
|
28
|
+
|
|
29
|
+
Each page's report shows one line above its numbers for its LCP image (the largest contentful paint element, when it is an
|
|
30
|
+
image). Where `next.config` lists several `images.qualities`, ImgTune clusters them into at most
|
|
31
|
+
`--max-qualities` values (default 2), and each target is raised to the nearest value kept, so the SSIMULACRA2 bar still holds.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm i -D imgtune
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then run it with `npx imgtune`. Without installing, `npx imgtune` fetches it for one run. ImgTune needs Chromium for
|
|
40
|
+
Playwright; see [Requirements](#requirements).
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
Measure a live site, with no access to its source:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npx imgtune scan https://your-site.example/
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The scan writes one JSON file per page to `.imgtune/scan/`, for example `.imgtune/scan/your-site.example-index.json`, and
|
|
51
|
+
prints a count of the images with fix hints. Read the hints in batches of ten:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npx imgtune hints .imgtune/scan/your-site.example-index.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Each hint names the image, its component and `file:line` when they resolve, the `sizes` and quality to use, and the bytes
|
|
58
|
+
the fix saves per view, with the bytes it adds for sharpness shown separately. Run `npx imgtune --help`, or
|
|
59
|
+
`npx imgtune scan --help` for every option.
|
|
60
|
+
|
|
61
|
+
For the whole site in one report, run `npx imgtune check https://your-site.example/` instead. It writes `report.md` to
|
|
62
|
+
`.imgtune/check/`, and `imgtune summary` prints its one-screen summary.
|
|
63
|
+
|
|
64
|
+
## Why one lever is not enough
|
|
65
|
+
|
|
66
|
+
It looks at three levers for each image: **resolution** (the pixels the browser actually needs), **quality** (the
|
|
67
|
+
encoder setting), and **format** (for example AVIF or WebP instead of JPEG). Measurements are per page view, per
|
|
68
|
+
viewport, and quality is scored with SSIMULACRA2.
|
|
69
|
+
|
|
70
|
+
- **Compressors** squeeze the file you hand them, but never learn how wide it is shown. A 6,000 px logo in a 150 px
|
|
71
|
+
slot stays 6,000 px, only better compressed.
|
|
72
|
+
- **Image CDNs and `next/image`** resize on request, but only to the widths your `sizes` attribute asks for. A wrong or
|
|
73
|
+
missing `sizes` makes every phone fetch a file several times too wide.
|
|
74
|
+
- **Lighthouse** flags oversized, poorly encoded and old-format images, at one viewport per run, with a fixed estimate.
|
|
75
|
+
It tells you something is wrong, not which `sizes` or quality to set.
|
|
76
|
+
- **ImgTune** measures size, quality and format together, per image, on the live page at six viewports, accepts a
|
|
77
|
+
saving only when SSIMULACRA2 says nobody can see it.
|
|
78
|
+
|
|
79
|
+
## Fix it with your agent
|
|
80
|
+
|
|
81
|
+
ImgTune measures; it does not edit your project. The agent skill does the editing: it scans, matches each hint to the
|
|
82
|
+
source, fixes it, verifies with a rescan, and reports from a ledger it keeps in the project.
|
|
83
|
+
|
|
84
|
+
- **Claude Code**: copy the skill folder into your project. From the project root:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
mkdir -p .claude/skills && cp -r node_modules/imgtune/skills/imgtune .claude/skills/
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then ask Claude Code to optimise the images of the site. The skill is `.claude/skills/imgtune/SKILL.md`, with a
|
|
91
|
+
`reference.md` beside it.
|
|
92
|
+
- **Cursor, Codex, Copilot, and other agents**: paste `node_modules/imgtune/skills/imgtune/AGENTS.snippet.md` into your
|
|
93
|
+
`AGENTS.md` or `CLAUDE.md`. It is the short form of the same loop and rules, and points to `npx imgtune --help`.
|
|
94
|
+
|
|
95
|
+
The skill works in batches of about ten hints, starting with the LCP image, and writes `.imgtune/ledger.jsonl` (one line
|
|
96
|
+
per image usage). When its context fills, it writes `.imgtune/HANDOFF.md`, so a new session can resume. Use a built or
|
|
97
|
+
preview server for the before and after numbers, and the dev server for the edit loop: source locations only resolve in
|
|
98
|
+
dev builds, or in a built page that ships source maps.
|
|
99
|
+
|
|
100
|
+
## What `scan` measures
|
|
101
|
+
|
|
102
|
+
`scan` loads each page in headless Chromium at six viewports (four phones, a 1440 px desktop at 2x, and a 1280 px
|
|
103
|
+
desktop at 1x), scrolls it to the end, and records every image.
|
|
104
|
+
|
|
105
|
+
- **Exact** savings come from the site's own smaller `srcset` candidates. Each one is fetched and measured, so the
|
|
106
|
+
number is the real byte difference.
|
|
107
|
+
- **Estimated** savings come from a local re-encode of the image: a different quality, a different format, or a resize of
|
|
108
|
+
a plain `<img>`. They are estimates.
|
|
109
|
+
- The CLI prints `exact` and `exact + est.`. The `exact + est.` figure is the estimated saving: the exact part plus the
|
|
110
|
+
estimate.
|
|
111
|
+
- Quality is judged with SSIMULACRA2 at a delta of 2 points. `--aggressive` adds a delta 4 column, which is reported
|
|
112
|
+
beside the main numbers and never used for the headline.
|
|
113
|
+
- Undersized images (a file smaller than the slot it fills) are reported as sharpness growth, separately. Growth from
|
|
114
|
+
undersized images is never subtracted from other images' savings, and an undersized image itself contributes no
|
|
115
|
+
saving.
|
|
116
|
+
- For each image, the scan records its locator (the component and `file:line` in dev builds, `data-testid`, `alt`, and the
|
|
117
|
+
loader URL pattern), the `sizes` the browser resolved at each viewport, the layout chain above it, and a fix hint with
|
|
118
|
+
two numbers: bytes saved, and bytes added for sharpness. The two are never netted.
|
|
119
|
+
- Pages are scanned after their `robots.txt` is checked for the `*` user agent. A disallowed page is skipped and
|
|
120
|
+
recorded as `robots`. Pages that return a challenge or bot wall are recorded as `blocked` and not retried. The
|
|
121
|
+
scanner identifies itself with the default Chromium user agent, uses no stealth plugins, and fetches each image URL
|
|
122
|
+
at most once per scan.
|
|
123
|
+
- Besides the page itself, scan fetches the page's own srcset candidates and, for quality estimates, the original of a
|
|
124
|
+
sampled image (derived from the loader URL), each at most once per scan. It never requests new transformations from
|
|
125
|
+
the CDN.
|
|
126
|
+
- Large sites: `--urls-from` reads a URL list or a sitemap, `--sample-per-pattern` samples each route type, and
|
|
127
|
+
`--resume`, `--max-pages` and `--max-images` split a long run into parts. `--survey` is a fast pass over every URL
|
|
128
|
+
(two viewports, exact savings only, labelled as such); full-scan the heaviest page of each route type after it.
|
|
129
|
+
- Production check: `imgtune check` (see [Two ways to measure](#two-ways-to-measure)) and `imgtune audit` do the same
|
|
130
|
+
survey and deep scan for a whole site, with no source needed for `check`.
|
|
131
|
+
|
|
132
|
+
## The evidence ladder
|
|
133
|
+
|
|
134
|
+
Every fix hint is labelled with the strength of its evidence, the rung it stands on:
|
|
135
|
+
|
|
136
|
+
1. **measured-page** (exact): the usage was measured on the live page, at each viewport. This is what `scan` produces.
|
|
137
|
+
2. **measured-harness**: the usage was measured in isolation, through a temporary route such as `/__imgtune/<component>`
|
|
138
|
+
that renders each state, or through Storybook. The agent removes the route afterwards.
|
|
139
|
+
3. **ceiling-code**: derived from the resolved classes and the layout constraints (container width, grid columns,
|
|
140
|
+
padding). It is an upper bound on the width needed, never a measured saving.
|
|
141
|
+
4. **not-measured**: no number, with the reason (hidden, behind a login, not rendered at scan time).
|
|
142
|
+
|
|
143
|
+
A ceiling is never derived from the existing `sizes` value. When a usage is measured, its `sizes` comes from the layout
|
|
144
|
+
the browser resolved, not from a guess.
|
|
145
|
+
|
|
146
|
+
## Commands
|
|
147
|
+
|
|
148
|
+
| command | what it does |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `imgtune check <origin \| sitemap-url \| page-url>` | the production check, in a browser only: surveys the URLs, deep-scans the heaviest page of each route type, writes `report.md` and `run.json` to `--out` (default `.imgtune/check/<timestamp>`); `--urls-from <file\|sitemap>` instead of the URL; `--sample-per-pattern` (default 3), `--deep` (default 20), `--route-map` |
|
|
151
|
+
| `imgtune audit --app <dir> --prod <origin> [--dev <origin>]` | the deep mode for a Next.js app: the check, plus the route files and `images.qualities` from `next.config`; with `--dev`, the `file:line` of each located fix; `--urls-from <file\|sitemap>`, `--max-locales` (default 3, how many values of a dynamic first segment the crawl expands) |
|
|
152
|
+
| `imgtune run <url> [<url> ...] \| --urls-from <file\|sitemap>` | the whole flow in one command: survey, deep scan, locate (`--dev`), hints, and one run directory with a summary |
|
|
153
|
+
| `imgtune scan <url> [<url> ...]` | measures live pages, one JSON file per page in `--out` (default `.imgtune/scan`); `--urls-from`, `--max-qualities <N>` (1 to 4, default 2) |
|
|
154
|
+
| `imgtune hints <scan.json>` | prints a batch of fix hints: `--limit` (default 10), `--offset`, `--page`, `--component`, `--min-kb`, `--top`, `--flat`, `--qualities`, `--max-qualities`, `--format json` |
|
|
155
|
+
| `imgtune summary <run dir \| scan.json ...>` | the one-screen summary of a run directory (`run.json`) or of scan files |
|
|
156
|
+
| `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
|
+
| `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 |
|
|
158
|
+
| `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
|
+
| `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
|
+
|
|
161
|
+
The scan options that matter most: `--viewports <names>` (a subset of the six), `--markdown <file>` (the PR comment),
|
|
162
|
+
`--summary-json <file>` (one JSON of every page's numbers), `--fail-over <pct>` (exit 3 above a budget),
|
|
163
|
+
`--storage-state <file>` (a logged-in session), and `--ignore-robots` (see Security). `npx imgtune scan --help` lists them all.
|
|
164
|
+
`imgtune check --help`, `imgtune audit --help` and `imgtune run --help` list the options of each command.
|
|
165
|
+
|
|
166
|
+
Two flows for a large app. One measures on a built server and then checks the edits on a dev server:
|
|
167
|
+
`scan <built-url>`, then `scan <dev-url> --locate-only --from <scan.json>` (reads the React source of the flagged images),
|
|
168
|
+
then `scan --recheck <located.json> --sizes-only --ids a,b` (judges the sizes choice on dev, no bytes), then `scan` on the
|
|
169
|
+
built server again. The other runs the score on an NVIDIA GPU: `--gpu --gpu-device <n>` (a CUDA index; set `CUDA_DEVICE_ORDER=PCI_BUS_ID` to match `nvidia-smi`). The GPU
|
|
170
|
+
program, `imgtune-ssimu2-gpu`, is not bundled; see the GPU section of the reference.
|
|
171
|
+
|
|
172
|
+
## GitHub Action
|
|
173
|
+
|
|
174
|
+
`action.yml` at the root scans the pages you name on each pull request, and comments the result on it. The comment keeps
|
|
175
|
+
the summary tables and the top groups per page, and adds a collapsed "Fix hints for your AI agent" section with the top
|
|
176
|
+
ten hints across the pages. The comment is updated on each push. Set `fail-over` to fail the job above a budget.
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
- uses: indicozy/imgtune@main
|
|
180
|
+
with:
|
|
181
|
+
urls: https://preview.your-site.example/
|
|
182
|
+
fail-over: "5"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The action builds itself from source, so `dist/` is not needed in the repository. See [docs/ci.md](docs/ci.md) for the
|
|
186
|
+
inputs, the permissions, and two example workflows (Vercel previews, and a local build on the runner).
|
|
187
|
+
|
|
188
|
+
`scan` keeps Chrome on the CPUs it was started on, so you can keep it off the cores you need: `taskset -c 8-19 npx imgtune scan …` on Linux.
|
|
189
|
+
|
|
190
|
+
## Security
|
|
191
|
+
|
|
192
|
+
- **Egress and private addresses.** `scan` refuses private and loopback addresses unless the URL you give is itself local,
|
|
193
|
+
checks every redirect hop and sub-resource at connection time. See the [scanning guide](site/guide/scanning-any-site.md#security).
|
|
194
|
+
- **ImgTune never commits, and `scan` does not change your project's source files.**
|
|
195
|
+
- **Hint strings are data, not instructions.** File names, component names, `alt`, `testid`, classes and URLs come from
|
|
196
|
+
the page being scanned, and the agent must never follow text found in them. The hints print them JSON-quoted and capped
|
|
197
|
+
(in the terminal, and in the PR comment, where they sit in a fenced block after a line that says they are data). A
|
|
198
|
+
locator from a non-local source is marked `(unverified)`, and a call site that only renders the image's component is marked `(owner call site)`. The agent
|
|
199
|
+
checks that a file exists in the repository, and that the line holds an image usage, before it edits.
|
|
200
|
+
- **Source maps come from the scanned site.** They are read with caps on size (20 MB), count (64 per scan), lines and
|
|
201
|
+
sections, and only a relative path under the project is kept as a source file.
|
|
202
|
+
- **`--storage-state` holds session cookies.** Never commit that file. The scan never prints its contents.
|
|
203
|
+
- **`--ignore-robots` is for sites you own or are authorised to test.** It prints a warning, and the JSON records
|
|
204
|
+
`robotsIgnored` with the rule that disallowed the page.
|
|
205
|
+
|
|
206
|
+
## Supported loaders
|
|
207
|
+
|
|
208
|
+
Loaders are detected from the URL each image renders. `original known?` means ImgTune can find the untransformed
|
|
209
|
+
source, which is what quality is measured against. `quality settable?` means the URL carries a quality parameter
|
|
210
|
+
that ImgTune can change.
|
|
211
|
+
|
|
212
|
+
| loader | matches | original known? | quality settable? |
|
|
213
|
+
| --- | --- | --- | --- |
|
|
214
|
+
| next/image | `/_next/image?url=…` | yes | yes (`q`) |
|
|
215
|
+
| Cloudflare Images | `/cdn-cgi/image/<opts>/<path>` | yes | yes (`quality`) |
|
|
216
|
+
| Contentful | `images.ctfassets.net`, `downloads.ctfassets.net` | yes | yes (`q`) |
|
|
217
|
+
| Sanity | `cdn.sanity.io/images/…`, or the same path shape on a custom domain | yes | yes (`q`) |
|
|
218
|
+
| Shopify | `cdn.shopify.com`, `/cdn/shop/…` | yes (size parameters and legacy size suffixes removed) | yes (`quality`) |
|
|
219
|
+
| Cloudinary | `res.cloudinary.com/<cloud>/image/upload/…`, or transformation paths on a custom domain | yes | yes (`q_<n>`); `q_auto` is left to the CDN |
|
|
220
|
+
| imgix (incl. DatoCMS, Prismic) | `*.imgix.net`, `datocms-assets.com`, `images.prismic.io` | yes | yes (`q`) |
|
|
221
|
+
| generic | any other URL | no | only when the URL has `q=` or `quality=` |
|
|
222
|
+
|
|
223
|
+
Where the original is unknown, ImgTune still measures resolution, and reports the count of images it could not score
|
|
224
|
+
for quality.
|
|
225
|
+
|
|
226
|
+
## Demand study (2026-10-28)
|
|
227
|
+
|
|
228
|
+
Before packaging, ImgTune scanned two groups of sites: production sites, which decided whether it was worth building,
|
|
229
|
+
and open-source apps, for comparison. The site list was fixed before the first scan. Each run scans every site once:
|
|
230
|
+
2 pages × 6 viewports, SSIMULACRA2 delta 2. Saving is the share of all image bytes per page view, with the page fully
|
|
231
|
+
scrolled, averaged over viewports and then over the two pages. When the tool changed, every site was scanned again
|
|
232
|
+
with the same version; v1 is the first run, v4 the current tool (loader adapters and the fixes found since).
|
|
233
|
+
|
|
234
|
+
The `exact + est.` columns are the estimated saving: the exact saving plus the local re-encode estimate.
|
|
235
|
+
|
|
236
|
+
### Production sites (decide)
|
|
237
|
+
|
|
238
|
+
| site | image KB / view | exact | exact + est. (v4) | exact + est. (v1) |
|
|
239
|
+
| --- | --- | --- | --- | --- |
|
|
240
|
+
| A1 fashion retail, UK | 3,250 | 0.0% | 33.2% | 33.0% |
|
|
241
|
+
| A2 home audio, DK | 1,263 | 5.7% | 53.1% | 47.8% |
|
|
242
|
+
| A3 home audio, US | 3,355 | 13.1% | 38.2% | 13.1% |
|
|
243
|
+
| A4 sportswear, UK | 9,254 | 26.8% | 35.9% | 26.8% |
|
|
244
|
+
| A5 home/rugs, US | 7,714 | 12.7% | 16.5% | 12.7% |
|
|
245
|
+
| A6 outdoor stays, US | 9,078 | 20.7% | 27.7% | 20.8% |
|
|
246
|
+
|
|
247
|
+
Sharpness growth: about 2 KB per view on A4, 0 elsewhere.
|
|
248
|
+
|
|
249
|
+
Kill criterion: a pass needs at least 2 of 6 sites above 15%.
|
|
250
|
+
|
|
251
|
+
- exact only: 2 of 6 (v1 and v4), a pass at the bar
|
|
252
|
+
- exact + est.: 6 of 6 in v4 (4 of 6 in v1), a pass
|
|
253
|
+
|
|
254
|
+
### Open-source apps (do not decide)
|
|
255
|
+
|
|
256
|
+
| site | image KB / view | exact | exact + est. | of fixable |
|
|
257
|
+
| --- | --- | --- | --- | --- |
|
|
258
|
+
| tailwindcss.com | 4,508 | 0.0% | 4.2% | 9.6% |
|
|
259
|
+
| ui.shadcn.com | 238 | 0.0% | 33.7% | 42.0% |
|
|
260
|
+
| supabase.com | 671 | 1.9% | 20.8% | 48.0% |
|
|
261
|
+
| nextjs.org | 162 | 9.1% | 19.2% | 21.4% |
|
|
262
|
+
| turborepo.com | 77 | 12.0% | 21.4% | 21.4% |
|
|
263
|
+
| langfuse.com | 3,149 | 0.2% | 1.8% | 21.7% |
|
|
264
|
+
| docs.sentry.io | 85 | 0.0% | 0.0% | 0.0% |
|
|
265
|
+
| www.openstatus.dev | 152 | 0.0% | 4.2% | 31.7% |
|
|
266
|
+
|
|
267
|
+
v4: 4 of 8 above 15% (exact + est.), 0 of 8 on exact alone. Developer sites carry few image bytes; the waste sits in
|
|
268
|
+
image-heavy commerce.
|
|
269
|
+
|
|
270
|
+
### Limits
|
|
271
|
+
|
|
272
|
+
- **Six deciders.** A signal, not proof; the sample is biased to sites without bot walls.
|
|
273
|
+
- **Exact is measured, est. is estimated.** On measured savings alone, 2 of 6 pass. The est. part
|
|
274
|
+
is a local re-encode scored with SSIMULACRA2 against the original; it is a floor where images could not be scored
|
|
275
|
+
(counted per page in the JSON as `qualityNA`, e.g. images that appear only at viewports where no sample was scored).
|
|
276
|
+
- **Plain `<img>` resize** (A1, A2): local resize at the quality that matches the served bytes, so it doesn't add a
|
|
277
|
+
hidden quality saving.
|
|
278
|
+
- A3's jump from v1 to v4 comes from Sanity images on a custom domain served at `q=100`, which v1 could not score.
|
|
279
|
+
- tailwindcss.com/blog rendered no `<img>` at scan time (0 images); counted as 0%.
|
|
280
|
+
|
|
281
|
+
## Requirements
|
|
282
|
+
|
|
283
|
+
- Node.js 20.9 or later.
|
|
284
|
+
- Chromium for Playwright. Install it once with `npx playwright install chromium`. If it is missing, ImgTune prints
|
|
285
|
+
that command and exits with status 2.
|
|
286
|
+
- GPU scoring is optional. `--gpu` runs SSIMULACRA2 on an NVIDIA GPU through `imgtune-ssimu2-gpu`, an external program
|
|
287
|
+
that is not bundled with the package. If it is missing or fails, the run prints a warning and scores on the CPU.
|
|
288
|
+
|
|
289
|
+
## Limitations
|
|
290
|
+
|
|
291
|
+
- **Only `<img>`, `srcset`, `<picture>` and CSS background images are measured.** Background layers are kept in their own
|
|
292
|
+
records (`kind: css-background`), and the headline numbers stay `<img>`-only. Their fixes are `not-measured`, or a
|
|
293
|
+
`ceiling-code` from the layout.
|
|
294
|
+
- **Source locations need dev builds.** A production build has no component names or `file:line` in its scan. A built
|
|
295
|
+
page that ships source maps gives the owner component's definition (`via: prod-sourcemap`, never verified as the image's
|
|
296
|
+
own line). The agent matches the other hints by `find` clues, `alt`, `testid` and the loader URL, and marks the
|
|
297
|
+
unmatched usages.
|
|
298
|
+
- **Savings are measured on the pages and viewports scanned.** A usage that does not render on them (a hidden tab, a
|
|
299
|
+
modal, a logged-out view) is not measured. A lazy image that never loads is counted as not loaded.
|
|
300
|
+
- **Est. numbers are estimates.** They come from a local re-encode judged by SSIMULACRA2. Quality is `qualityNA` where the
|
|
301
|
+
original cannot be found. Only exact numbers come from the site's own candidates.
|
|
302
|
+
- **Bot walls and challenges stop a scan.** A page that returns one is recorded as `blocked`, and is not retried.
|
|
303
|
+
- **Quality targets follow the loader.** Only the loaders in the table below expose a quality parameter, so only they get
|
|
304
|
+
quality proposals.
|
|
305
|
+
- **ImgTune does not edit code.** The fixes are made by you, or by the agent skill, and checked by `scan --recheck`.
|
|
306
|
+
|
|
307
|
+
## License
|
|
308
|
+
|
|
309
|
+
MIT. See [LICENSE](LICENSE). Third-party attributions, including the Optiq-derived SSIM code and the vendored
|
|
310
|
+
SSIMULACRA2 WebAssembly module, are in [NOTICE](NOTICE).
|