imgtune 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +69 -6
  3. package/dist/audit.js +1 -0
  4. package/dist/backgrounds.js +31 -7
  5. package/dist/broken.js +5 -1
  6. package/dist/browser.js +4 -4
  7. package/dist/candidates.js +25 -0
  8. package/dist/check-report.js +37 -4
  9. package/dist/check.js +18 -1
  10. package/dist/cli.js +20 -0
  11. package/dist/egress-proxy.js +2 -1
  12. package/dist/ffmpeg.js +35 -0
  13. package/dist/hints.js +294 -18
  14. package/dist/image-open.js +9 -0
  15. package/dist/images.js +116 -7
  16. package/dist/layout.js +7 -7
  17. package/dist/lcp.js +2 -1
  18. package/dist/measure.js +39 -1
  19. package/dist/media-encode.js +5 -1
  20. package/dist/media-model.js +8 -4
  21. package/dist/media-phase.js +25 -2
  22. package/dist/px.js +11 -0
  23. package/dist/recheck.js +9 -1
  24. package/dist/remote.js +616 -63
  25. package/dist/replay.js +20 -0
  26. package/dist/resources/audio.js +248 -0
  27. package/dist/resources/brotli.js +22 -0
  28. package/dist/resources/cache.js +59 -0
  29. package/dist/resources/capture.js +225 -0
  30. package/dist/resources/compression.js +122 -0
  31. package/dist/resources/favicon.js +130 -0
  32. package/dist/resources/font.js +440 -0
  33. package/dist/resources/index.js +8 -0
  34. package/dist/resources/lottie.js +491 -0
  35. package/dist/resources/model.js +478 -0
  36. package/dist/resources/phase.js +171 -0
  37. package/dist/resources/pool.js +47 -0
  38. package/dist/resources/report.js +57 -0
  39. package/dist/resources/sfnt.js +305 -0
  40. package/dist/resources/svg.js +178 -0
  41. package/dist/resources/types.js +1 -0
  42. package/dist/resources/zip.js +67 -0
  43. package/dist/run.js +24 -4
  44. package/dist/sizes.js +7 -5
  45. package/dist/summary.js +47 -1
  46. package/dist/vector-art.js +294 -0
  47. package/dist/vector-phase.js +222 -0
  48. package/dist/video-analysis.js +119 -21
  49. package/package.json +25 -4
  50. package/skills/imgtune/SKILL.md +8 -4
  51. package/skills/imgtune/reference.md +1 -1
@@ -0,0 +1,222 @@
1
+ // The vector phase of a scan: after every page is measured, the flat still rasters of all pages (fix.vector, see
2
+ // vector-art.ts) are ranked by their bytes per view, and the heaviest ones within --svg-budget are traced and checked.
3
+ // Each unique URL is traced once per run. Its numbers are kept in the result cache, keyed by the content hash, so a
4
+ // rescan does not score it again. A passing SVG is written to the scan's svg/ folder. Nothing here throws: a failure
5
+ // becomes the record's notChecked reason, and the scan always finishes.
6
+ import { mkdir } from "node:fs/promises";
7
+ import { join, relative } from "node:path";
8
+ import { writeFileAtomic } from "./atomic.js";
9
+ import { mapLimit } from "./concurrency.js";
10
+ import { CACHE_VERSION, contentHash } from "./result-cache.js";
11
+ import { measure } from "./timing.js";
12
+ import { VECTOR_BAR, VECTOR_CAP_MS, VECTOR_MIN_SAVING, VECTORIZER_VERSION, loadVectorizer, svgBytes, svgFidelity, traceSvg, } from "./vector-art.js";
13
+ /** The note printed once when the vectorizer is missing: what installing it adds. */
14
+ export const VECTOR_NOTE = "install @neplex/vectorizer to check vector art";
15
+ /** Images traced at once. */
16
+ export const VECTOR_CONCURRENCY = 4;
17
+ /** The folder of the traced SVGs, under the scan's folder. */
18
+ export const VECTOR_SVG_DIR = "svg";
19
+ /** Bumped when the trace or the scoring changes (input size, render, flat rule), so the cached numbers of the old ones are not read. */
20
+ const TRACE_VERSION = "poster-3";
21
+ const TIMED_OUT = Symbol("timed out");
22
+ /**
23
+ * The served bytes of the flat images, as the browser got them, for the vector check: the check traces the file the page
24
+ * used, not a fresh download. Bounded: it keeps the `capacity` largest files seen (the ones the budget would trace), so
25
+ * the memory is the budget times a file, whatever the site has. A URL it does not keep is fetched instead.
26
+ */
27
+ export class FlatBuffers {
28
+ capacity;
29
+ entries = new Map();
30
+ constructor(capacity) {
31
+ this.capacity = capacity;
32
+ }
33
+ /** Keeps `buffer` for `url` when it is among the `capacity` largest seen so far. */
34
+ offer(url, buffer, bytes) {
35
+ if (this.capacity <= 0 || buffer === undefined || this.entries.has(url))
36
+ return;
37
+ if (this.entries.size < this.capacity) {
38
+ this.entries.set(url, { buffer, bytes });
39
+ return;
40
+ }
41
+ let smallest;
42
+ for (const [key, entry] of this.entries) {
43
+ if (smallest === undefined || entry.bytes < (this.entries.get(smallest)?.bytes ?? Infinity))
44
+ smallest = key;
45
+ }
46
+ if (smallest !== undefined && bytes > (this.entries.get(smallest)?.bytes ?? 0)) {
47
+ this.entries.delete(smallest);
48
+ this.entries.set(url, { buffer, bytes });
49
+ }
50
+ }
51
+ get(url) {
52
+ return this.entries.get(url)?.buffer;
53
+ }
54
+ }
55
+ /** The bytes per page view of an image: the mean over its viewports. */
56
+ export function perViewBytesOf(record) {
57
+ return record.perViewport.reduce((sum, p) => sum + p.servedBytes, 0) / Math.max(1, record.perViewport.length);
58
+ }
59
+ /** The flat images of the pages, one per URL, heaviest first. A record without a URL cannot be traced and is left out. */
60
+ function candidatesOf(pages) {
61
+ const byUrl = new Map();
62
+ for (const page of pages) {
63
+ for (const record of page.images ?? []) {
64
+ if (record.fix.vector === undefined || record.current.src === undefined)
65
+ continue;
66
+ const url = record.current.src;
67
+ const perView = perViewBytesOf(record);
68
+ const known = byUrl.get(url);
69
+ if (known === undefined || perView > known.perView)
70
+ byUrl.set(url, { url, id: known?.id ?? record.id, perView });
71
+ }
72
+ }
73
+ return [...byUrl.values()].sort((a, b) => b.perView - a.perView || a.url.localeCompare(b.url));
74
+ }
75
+ /** Runs the vector check over the flat images of the pages (see the file comment). */
76
+ export async function runVectorPhase(pages, opts, deps) {
77
+ const byUrl = new Map();
78
+ const candidates = candidatesOf(pages);
79
+ if (candidates.length === 0)
80
+ return { byUrl };
81
+ const skip = opts.survey ? "survey" : !opts.quality ? "quality off" : opts.budget === 0 ? "svg-budget 0" : undefined;
82
+ if (skip !== undefined) {
83
+ for (const c of candidates)
84
+ byUrl.set(c.url, { notChecked: skip });
85
+ return { byUrl };
86
+ }
87
+ if ((await loadVectorizer()) === undefined) {
88
+ for (const c of candidates)
89
+ byUrl.set(c.url, { notChecked: "vectorizer not installed" });
90
+ return { byUrl, note: VECTOR_NOTE };
91
+ }
92
+ const chosen = candidates.slice(0, opts.budget);
93
+ for (const c of candidates.slice(opts.budget))
94
+ byUrl.set(c.url, { notChecked: "over --svg-budget" });
95
+ opts.log(`vector: tracing ${chosen.length} of ${candidates.length} flat image(s) (--svg-budget ${opts.budget})`);
96
+ const traced = await mapLimit(chosen, VECTOR_CONCURRENCY, async (c) => [c.url, await traceOne(c.url, c.id, opts, deps)]);
97
+ for (const [url, outcome] of traced)
98
+ byUrl.set(url, outcome);
99
+ const passed = traced.filter(([, o]) => o.passed === true).length;
100
+ opts.log(`vector: ${passed} of ${traced.length} traced image(s) pass at SSIMULACRA2 ${VECTOR_BAR} (half size)`);
101
+ return { byUrl };
102
+ }
103
+ /** One URL: its numbers from the cache, or traced and scored within VECTOR_CAP_MS. Never throws. */
104
+ async function traceOne(url, id, opts, deps) {
105
+ const buffer = await deps.fetch(url).catch(() => undefined);
106
+ if (buffer === undefined)
107
+ return { notChecked: "trace failed" };
108
+ const key = `vector|${CACHE_VERSION}|${contentHash(buffer)}|${VECTORIZER_VERSION}|${TRACE_VERSION}|bar${VECTOR_BAR}`;
109
+ const cached = await opts.cache?.getJson(key);
110
+ let numbers = isCachedNumbers(cached) ? cached : undefined;
111
+ let svg;
112
+ if (numbers === undefined) {
113
+ // The cap aborts the trace itself (the vectorizer stops, and stops using CPU); the score is dropped with it.
114
+ const controller = new AbortController();
115
+ const traced = await within(measure("vector: trace and score", () => measureOne(buffer, controller.signal)), VECTOR_CAP_MS, controller);
116
+ if (traced === TIMED_OUT)
117
+ return { notChecked: "timeout" };
118
+ if (traced === undefined)
119
+ return { notChecked: "trace failed" };
120
+ numbers = traced.numbers;
121
+ svg = traced.svg;
122
+ await opts.cache?.putJson(key, numbers);
123
+ }
124
+ else if (numbers.passed) {
125
+ // The numbers say it passes, but its file is written from a trace: a trace is cheap, the score is not repeated.
126
+ const controller = new AbortController();
127
+ const traced = await within(traceSvg(buffer, controller.signal), VECTOR_CAP_MS, controller);
128
+ if (traced === TIMED_OUT)
129
+ return { notChecked: "timeout" };
130
+ if (traced === undefined)
131
+ return { notChecked: "trace failed" };
132
+ svg = traced;
133
+ }
134
+ const outcome = { svgBytes: numbers.svgBytes, score: numbers.score, badPixelShare: numbers.badPixelShare, passed: numbers.passed };
135
+ if (!numbers.passed)
136
+ return outcome;
137
+ if (svg === undefined)
138
+ return { notChecked: "trace failed" };
139
+ try {
140
+ const dir = join(opts.outDir, VECTOR_SVG_DIR);
141
+ await mkdir(dir, { recursive: true });
142
+ const file = join(dir, `${id}.svg`);
143
+ await writeFileAtomic(file, svg, { root: opts.outDir });
144
+ // Relative to the project folder when the file is inside it; absolute otherwise (a scan into another folder).
145
+ const named = relative(opts.cwd, file);
146
+ return { ...outcome, tracedFile: named.startsWith("..") ? file : named };
147
+ }
148
+ catch {
149
+ return { notChecked: "trace failed" };
150
+ }
151
+ }
152
+ /** The trace of an image and its numbers: the SVG, its brotli size, and the fidelity against the served bytes. */
153
+ async function measureOne(buffer, signal) {
154
+ const svg = await traceSvg(buffer, signal);
155
+ if (svg === undefined || signal.aborted)
156
+ return undefined;
157
+ const bytes = svgBytes(svg);
158
+ const fidelity = await svgFidelity(buffer, svg);
159
+ const passed = fidelity.score >= VECTOR_BAR && bytes <= buffer.length * (1 - VECTOR_MIN_SAVING);
160
+ return { svg, numbers: { svgBytes: bytes, score: Math.round(fidelity.score * 10) / 10, badPixelShare: fidelity.badPixelShare, passed } };
161
+ }
162
+ /**
163
+ * The value, or TIMED_OUT when it is not ready in `ms` (the controller is then aborted, so the work stops), or undefined when
164
+ * it fails.
165
+ */
166
+ async function within(work, ms, controller) {
167
+ let timer;
168
+ const timeout = new Promise((resolve) => {
169
+ timer = setTimeout(() => {
170
+ controller.abort();
171
+ resolve(TIMED_OUT);
172
+ }, ms);
173
+ });
174
+ try {
175
+ return await Promise.race([work.catch(() => undefined), timeout]);
176
+ }
177
+ finally {
178
+ clearTimeout(timer);
179
+ }
180
+ }
181
+ function isCachedNumbers(value) {
182
+ if (typeof value !== "object" || value === null)
183
+ return false;
184
+ const v = value;
185
+ return (Number.isFinite(v.svgBytes) &&
186
+ Number.isFinite(v.score) &&
187
+ Number.isFinite(v.badPixelShare) &&
188
+ typeof v.passed === "boolean");
189
+ }
190
+ /**
191
+ * A page with the phase's outcomes written into its flat images' fix.vector. The saving of a passing SVG is the served bytes
192
+ * per view minus the SVG's, averaged over the page's viewports (as the other savings are). The record's claimed saving is
193
+ * the larger of the raster fix and the SVG; the SVG wins only when it is larger, and the raster fix stays as the fallback.
194
+ */
195
+ export function applyVector(page, run) {
196
+ const flat = (page.images ?? []).some((record) => record.fix.vector !== undefined);
197
+ if (!flat)
198
+ return page;
199
+ const viewportCount = Math.max(1, page.viewports.length);
200
+ const images = (page.images ?? []).map((record) => {
201
+ const vector = record.fix.vector;
202
+ if (vector === undefined)
203
+ return record;
204
+ const outcome = record.current.src !== undefined ? run.byUrl.get(record.current.src) : undefined;
205
+ const next = { ...vector, ...(outcome ?? { notChecked: "trace failed" }) };
206
+ if (outcome?.passed === true && outcome.svgBytes !== undefined) {
207
+ const svg = outcome.svgBytes;
208
+ const saved = record.perViewport.reduce((sum, p) => sum + Math.max(0, p.servedBytes - svg), 0) / viewportCount;
209
+ next.savedPerView = Math.round(saved);
210
+ if (saved > record.fix.bytes.saved) {
211
+ next.wins = true;
212
+ return {
213
+ ...record,
214
+ fix: { ...record.fix, vector: next, bytes: { saved: Math.round(saved), addedForSharpness: record.fix.bytes.addedForSharpness } },
215
+ };
216
+ }
217
+ }
218
+ return { ...record, fix: { ...record.fix, vector: next } };
219
+ });
220
+ const hasNote = images.some((record) => record.fix.vector !== undefined);
221
+ return { ...page, images, ...(run.note !== undefined && hasNote ? { vectorNote: run.note } : {}) };
222
+ }
@@ -4,6 +4,7 @@
4
4
  // so it is added to the page's images here, before the image pipeline runs.
5
5
  import { sha1 } from "./atomic.js";
6
6
  import { COMPONENT_NAME, sanitizeText } from "./locate.js";
7
+ import { hiddenFixCore } from "./images.js";
7
8
  import { adapterFor, formatAutoOf } from "./loaders.js";
8
9
  import { clusterVideoWidths, videoBytes, widthOptions, } from "./media-model.js";
9
10
  /** Widths kept per video in scan mode (`--max-video-widths`, before the flag is wired). */
@@ -163,11 +164,65 @@ function hasAv1(video) {
163
164
  const urls = [video.currentSrc, ...video.sources.map((s) => s.src)];
164
165
  return urls.some((u) => /\.av1\.mp4(?:[?#]|$)/i.test(u));
165
166
  }
167
+ /**
168
+ * The fresh load's media bytes behind each observation. A resized viewport shares its group's page load, so its media map
169
+ * holds the bytes of every earlier step too. A hidden copy's bytes at a resized viewport are taken from the fresh load's map
170
+ * (see hiddenBytesOf), so a file fetched only for another viewport is not counted there.
171
+ */
172
+ function freshMediaOf(observations) {
173
+ const fresh = new Map();
174
+ let current;
175
+ for (const obs of observations) {
176
+ if (obs.load === "fresh" || current === undefined)
177
+ current = obs;
178
+ fresh.set(obs, current);
179
+ }
180
+ return fresh;
181
+ }
182
+ /** The poster URL of a video that is a file the page can fetch (not a data: URI), or undefined. */
183
+ function fetchablePosterOf(video) {
184
+ return video.poster !== undefined && !video.poster.startsWith("data:") ? video.poster : undefined;
185
+ }
186
+ /**
187
+ * The bytes a hidden copy costs per view at its viewport: the file the browser fetched for it (the same preload rules as a
188
+ * shown video, see videoBytes), plus its poster when the poster was fetched in the fresh load and no shown image of the
189
+ * viewport uses that URL. Zero when the copy's file is also shown at the viewport (one fetch, counted there), or when a
190
+ * resized viewport's copy does not fetch on load (its bytes are not the fresh load's).
191
+ */
192
+ function hiddenBytesOf(member, shownUrls, freshOf) {
193
+ const { obs, video } = member;
194
+ const url = video.currentSrc;
195
+ if (shownUrls.has(url))
196
+ return 0;
197
+ const kind = preloadKindOf(video);
198
+ const fresh = freshOf.get(obs) ?? obs;
199
+ // A copy that does not fetch on load (preload none or metadata) has nothing of the page load to count at a resized viewport.
200
+ const fetchesOnLoad = kind === "autoplay" || kind === "auto" || kind === "default";
201
+ if (obs.load !== "fresh" && !fetchesOnLoad)
202
+ return 0;
203
+ const observed = observedBytesOf(obs.load === "fresh" ? obs : fresh, url);
204
+ const bytes = videoBytes({
205
+ kind,
206
+ playing: !video.paused || video.errorCode === 4,
207
+ observedBytes: observed,
208
+ fullBytes: observed,
209
+ visible: true,
210
+ }).perView;
211
+ const poster = fetchablePosterOf(video);
212
+ const posterBytes = poster !== undefined && obs.load === "fresh" && !obs.images.some((img) => img.currentSrc === poster && img.hidden === undefined)
213
+ ? (obs.responses.get(poster)?.bytes ?? 0)
214
+ : 0;
215
+ return bytes + posterBytes;
216
+ }
166
217
  /**
167
218
  * The VideoRecords of a page, from its observations. Each `<video>` is one record across the viewports it appears in.
168
219
  *
169
- * Bytes: the record takes the viewport where the video costs most per view, so the claim is never low. The full file is
170
- * `fullBytes` (see fullBytesOf) and is shown per play for every video.
220
+ * Bytes: the claim is the shown copies only. The record takes the viewport where a shown copy costs most per view, so the
221
+ * claim is never low. The full file is `fullBytes` (see fullBytesOf) and is shown per play for every video.
222
+ *
223
+ * Hidden: a copy that is not shown at a viewport (see ObservedVideo.hidden) is a loaded-but-not-shown fix when the browser
224
+ * fetched bytes for it (hiddenBytesOf). Its bytes are the fix's, apart from the claim. A video shown at no viewport is never
225
+ * shown on load: it has no width, no levers and no claim.
171
226
  *
172
227
  * Widths: the viewports the video is shown in (hidden ones are left out) are clustered to at most `maxWidths` widths in
173
228
  * scan mode, and the plans for one, two and three widths are kept in audit mode, where the agent chooses.
@@ -179,21 +234,63 @@ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, s
179
234
  const records = [];
180
235
  // The records of each file so far: a file's videos are told apart by their document order (see videoIdOf).
181
236
  const perFile = new Map();
237
+ const freshOf = freshMediaOf(observations);
238
+ // The file each viewport shows in full, over every video of the page: a hidden copy of one of them adds no bytes there.
239
+ const shownAt = new Map();
240
+ for (const obs of observations) {
241
+ const urls = shownAt.get(obs.viewport.name) ?? new Set();
242
+ for (const video of obs.videos ?? [])
243
+ if (!video.hidden)
244
+ urls.add(video.currentSrc);
245
+ shownAt.set(obs.viewport.name, urls);
246
+ }
182
247
  for (const [key, members] of groupVideos(observations)) {
183
- const ref = members[0].video;
248
+ const shown = members.filter((m) => !m.video.hidden);
249
+ // The record's own file and attributes come from a shown copy when there is one.
250
+ const ref = (shown[0] ?? members[0]).video;
184
251
  const source = sources?.get(ref);
185
252
  const src = ref.currentSrc;
186
253
  const observedMax = Math.max(0, ...members.map((m) => observedBytesOf(m.obs, m.video.currentSrc)));
187
254
  const full = fullBytes.get(src) ?? observedMax;
188
- const views = members.map((m) => videoBytes({
255
+ // The claim: the shown copies. A video shown nowhere costs nothing in the claim.
256
+ const views = shown.map((m) => videoBytes({
189
257
  kind: preloadKindOf(m.video),
190
258
  playing: !m.video.paused || m.video.errorCode === 4,
191
259
  observedBytes: observedBytesOf(m.obs, m.video.currentSrc),
192
260
  fullBytes: full,
193
- visible: !m.video.hidden,
261
+ visible: true,
194
262
  }));
195
- const heaviest = views.reduce((best, cur) => (cur.perView > best.perView ? cur : best));
196
- const shown = members.filter((m) => !m.video.hidden);
263
+ const none = videoBytes({ kind: "none", playing: false, observedBytes: 0, fullBytes: full, visible: false });
264
+ const heaviest = views.length > 0 ? views.reduce((best, cur) => (cur.perView > best.perView ? cur : best)) : none;
265
+ // The claim is per viewport, averaged over the viewports (as an image's is): each viewport loads its own shown copy, so a
266
+ // twin pair counts once, not twice. Bounded by the file's own length (perPlay), as the single-copy figure is.
267
+ const shownBytesAt = new Map();
268
+ shown.forEach((m, i) => shownBytesAt.set(m, views[i].perView));
269
+ const claimPerView = Math.min(heaviest.perPlay, observations.length > 0 ? views.reduce((sum, v) => sum + v.perView, 0) / observations.length : 0);
270
+ // The hidden fix: one entry per viewport the video is in, shown or hidden. A hidden copy with no bytes is not reported.
271
+ const entries = [];
272
+ const copies = [];
273
+ for (const m of members) {
274
+ const viewport = { name: m.obs.viewport.name, width: m.obs.viewport.width };
275
+ if (!m.video.hidden) {
276
+ entries.push({ viewport, bytes: 0, inPanel: false, hover: false });
277
+ continue;
278
+ }
279
+ const bytes = hiddenBytesOf(m, shownAt.get(m.obs.viewport.name) ?? new Set(), freshOf);
280
+ shownBytesAt.set(m, bytes);
281
+ if (bytes <= 0)
282
+ continue;
283
+ const reason = m.video.hiddenReason ?? "display";
284
+ entries.push({ viewport, bytes, hidden: reason, inPanel: m.video.inPanel === true, hover: m.video.hover === true, unstable: m.video.unstable === "unknown" });
285
+ copies.push({ viewport: viewport.name, src: m.video.currentSrc, bytes, reason });
286
+ }
287
+ const hiddenBase = hiddenFixCore(entries, observations.length);
288
+ const hidden = hiddenBase !== undefined ? { ...hiddenBase, copies } : undefined;
289
+ const shownNow = shown.length > 0;
290
+ const info = [];
291
+ const handled = formatHandledNote(src, page);
292
+ if (handled !== undefined)
293
+ info.push(handled);
197
294
  const knownWidth = shown.map((m) => m.video.videoWidth).find((w) => w !== undefined && w > 0);
198
295
  const needs = shown.map((m) => ({
199
296
  viewport: m.obs.viewport.name,
@@ -201,26 +298,24 @@ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, s
201
298
  boxWidth: m.video.renderedWidth,
202
299
  dpr: m.obs.viewport.dpr,
203
300
  }));
204
- const info = [];
205
- const handled = formatHandledNote(src, page);
206
- if (handled !== undefined)
207
- info.push(handled);
208
301
  let sourceWidth = knownWidth;
209
302
  if (sourceWidth === undefined) {
210
303
  // Without the source width the widths are capped at the largest display need, which is all the page shows.
211
304
  sourceWidth = Math.max(1, ...shown.map((m) => Math.ceil(m.video.renderedWidth * m.obs.viewport.dpr)));
212
- if (shown.length > 0)
305
+ if (shownNow)
213
306
  info.push("source width not read: widths are capped at the largest display need");
214
307
  }
215
- const belowFold = members.every((m) => !m.video.inFirstViewport);
216
- const levers = { info };
217
- if (heaviest.perView > 0 && (belowFold || ref.preload === "auto")) {
218
- levers.loading = { perViewBytes: heaviest.perView, belowFold };
308
+ const belowFold = shown.every((m) => !m.video.inFirstViewport);
309
+ const levers = { info: shownNow ? info : [] };
310
+ if (shownNow) {
311
+ if (claimPerView > 0 && (belowFold || ref.preload === "auto")) {
312
+ levers.loading = { perViewBytes: claimPerView, belowFold };
313
+ }
314
+ if (handled === undefined && !hasAv1(ref))
315
+ levers.codec = { missing: "av1" };
316
+ if (ref.poster === undefined)
317
+ levers.posterMissing = true;
219
318
  }
220
- if (handled === undefined && !hasAv1(ref))
221
- levers.codec = { missing: "av1" };
222
- if (ref.poster === undefined)
223
- levers.posterMissing = true;
224
319
  const locator = {
225
320
  component: source?.component.length ? source.component : componentsOf(ref.locator.owners),
226
321
  selector: sanitizeText(ref.locator.selector, 300),
@@ -254,14 +349,17 @@ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, s
254
349
  preload: ref.preload,
255
350
  ...(ref.poster !== undefined ? { poster: ref.poster } : {}),
256
351
  },
257
- bytes: { ...heaviest, fullBytesKnown: false },
352
+ bytes: { ...heaviest, perView: claimPerView, fullBytesKnown: false },
258
353
  perViewport: members.map((m) => ({
259
354
  viewport: m.obs.viewport.name,
260
355
  boxWidth: m.video.renderedWidth,
261
356
  dpr: m.obs.viewport.dpr,
262
357
  inFirstViewport: m.video.inFirstViewport,
358
+ ...(m.video.hidden ? { hidden: m.video.hiddenReason ?? "display" } : {}),
359
+ bytes: shownBytesAt.get(m) ?? 0,
263
360
  })),
264
361
  levers,
362
+ ...(hidden !== undefined ? { hidden } : {}),
265
363
  };
266
364
  if (mode === "audit")
267
365
  record.widthOptions = widthOptions(needs, sourceWidth);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "imgtune",
3
- "version": "0.2.0",
4
- "description": "Finds the image waste on live pages: the sizes, quality and format for each image, checked with SSIMULACRA2, and the line of code that renders it.",
3
+ "version": "0.3.0",
4
+ "description": "Finds the wasted bytes on live pages (images, video, hidden downloads, fonts, compression, 3D and audio) and gives the fix for each, checked with SSIMULACRA2 and VMAF. Image and video fixes name the file and line when they resolve.",
5
5
  "license": "MIT",
6
6
  "author": "indicozy",
7
7
  "keywords": [
@@ -18,7 +18,18 @@
18
18
  "cli",
19
19
  "next/image",
20
20
  "sizes",
21
- "lcp"
21
+ "lcp",
22
+ "page-weight",
23
+ "web-performance",
24
+ "video",
25
+ "fonts",
26
+ "woff2",
27
+ "compression",
28
+ "brotli",
29
+ "lottie",
30
+ "svg",
31
+ "gltf",
32
+ "lazy-loading"
22
33
  ],
23
34
  "homepage": "https://indicozy.github.io/imgtune/",
24
35
  "repository": {
@@ -62,11 +73,21 @@
62
73
  "packageManager": "pnpm@10.8.0",
63
74
  "dependencies": {
64
75
  "playwright": "^1.64.1",
65
- "sharp": "^0.35.5"
76
+ "sharp": "^0.35.5",
77
+ "subset-font": "^2.9.0",
78
+ "svgo": "^4.1.0",
79
+ "wawoff2": "^2.0.1"
66
80
  },
67
81
  "devDependencies": {
68
82
  "@types/node": "^26.6.5",
69
83
  "typescript": "^5.9.3",
70
84
  "vitest": "^5.0.5"
85
+ },
86
+ "optionalDependencies": {
87
+ "@neplex/vectorizer": "0.1.0",
88
+ "@gltf-transform/core": "^4.5.1",
89
+ "@gltf-transform/extensions": "^4.5.1",
90
+ "@gltf-transform/functions": "^4.5.1",
91
+ "meshoptimizer": "^1.3.0"
71
92
  }
72
93
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: imgtune
3
- description: Reduce image weight and fix slow pages in any web framework. Use when asked to optimise or optimize images, cut page weight, improve LCP, fix image sizes, next/image sizes, the LCP image, images Lighthouse flags as oversized, badly encoded or old-format, or make a live page send fewer image bytes. imgtune measures each image on the live page at six viewports and gives per-image fix hints (sizes, quality, format) with the component and line that renders each one, which you apply in the source.
3
+ description: Cut page weight and fix slow pages in any web framework. Use when asked to optimise or optimize images or video, cut page weight, improve LCP, fix image sizes or next/image sizes, shrink fonts or subset them, find files served without compression, lazy-load hidden images or videos, or shrink 3D models, audio, Lottie or SVG. imgtune measures the live page at six viewports and gives per-item fix hints with the expected bytes and the component and line that renders each one, which you apply in the source.
4
4
  ---
5
5
 
6
6
  # imgtune
@@ -11,9 +11,13 @@ format) with two byte numbers. Run `npx imgtune --help`, or `<command> --help` f
11
11
  ## When to use
12
12
 
13
13
  The user asks to cut image weight or improve LCP; Lighthouse flags images as oversized, badly encoded or old-format; or
14
- pages are heavy and the images are most of the bytes. Not for scripts, fonts or video. Do not use it on sites the user
14
+ pages are heavy and the images are most of the bytes. It also covers video, hidden downloads, fonts, compression, 3D, audio, Lottie, SVG and favicons (below). It does not edit JavaScript or CSS: those get only the compression check. Do not use it on sites the user
15
15
  does not own or run, unless they say they are authorised.
16
16
 
17
+ ## Other page weight
18
+
19
+ `resources` in each scan JSON lists the findings for compression, favicons, fonts, SVG, Lottie, 3D models and audio, with their own bytes; they are not in the image totals. Images and videos the browser downloads but no viewport shows are in the hidden group (`images[].hidden`, `videos[].hidden`): their fix is lazy loading, a responsive `<picture>`, or removal, not a size change. `npx imgtune hints <scan.json> --format json` gives the resource findings as a top-level `resources` array, each with its page.
20
+
17
21
  ## Fix the images
18
22
 
19
23
  Use this when the user asks to fix image sizes, next/image sizes, or the LCP image, or after a scan has hints to apply.
@@ -21,8 +25,8 @@ Use this when the user asks to fix image sizes, next/image sizes, or the LCP ima
21
25
  1. **Scan** the pages on a built server into a folder: `npx imgtune scan <url> [<url> ...] --out .imgtune/scan`.
22
26
  2. **Read the hints in batches**: `npx imgtune hints .imgtune/scan/*.json --limit 10`. The LCP image comes first, and its line
23
27
  starts with `LCP image`. Take it first in every batch.
24
- 3. **Open each hint's file and line**, check that it renders that image, then apply the hint's `sizes` (or `width` for a
25
- plain image), its quality and its format, as `reference.md` says. A hint marked `(unverified)`, or with no file and line,
28
+ 3. **Open each hint's file and line**, check that it renders that image, then apply the hint's `sizes` (or, for a plain
29
+ `<img>` with no srcset, its `srcset` variants and the `sizes`), its quality and its format, as `reference.md` says. A hint marked `(unverified)`, or with no file and line,
26
30
  is not edited: say so in the report.
27
31
  4. **Apply the site-level `next.config: images.qualities: [...]` line once**, in `next.config`, and keep it as printed. Never
28
32
  lower a quality below the hint's target.
@@ -336,4 +336,4 @@ failed, skipped and unmatched usages).
336
336
  ## Top call sites
337
337
 
338
338
  `imgtune hints <scan.json> --top 10` prints the ten call sites with the most saving per view, one line each (where, the
339
- images and pages, the saving split into sizes and quality). Several scan files group across pages.
339
+ images and pages, the saving split into sizes, resize and quality). Several scan files group across pages.