imgtune 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +69 -6
  3. package/dist/audit.js +1 -0
  4. package/dist/backgrounds.js +31 -7
  5. package/dist/broken.js +5 -1
  6. package/dist/browser.js +4 -4
  7. package/dist/candidates.js +25 -0
  8. package/dist/check-report.js +37 -4
  9. package/dist/check.js +18 -1
  10. package/dist/cli.js +20 -0
  11. package/dist/egress-proxy.js +2 -1
  12. package/dist/ffmpeg.js +35 -0
  13. package/dist/hints.js +294 -18
  14. package/dist/image-open.js +9 -0
  15. package/dist/images.js +116 -7
  16. package/dist/layout.js +7 -7
  17. package/dist/lcp.js +2 -1
  18. package/dist/measure.js +39 -1
  19. package/dist/media-encode.js +5 -1
  20. package/dist/media-model.js +8 -4
  21. package/dist/media-phase.js +25 -2
  22. package/dist/px.js +11 -0
  23. package/dist/recheck.js +9 -1
  24. package/dist/remote.js +616 -63
  25. package/dist/replay.js +20 -0
  26. package/dist/resources/audio.js +248 -0
  27. package/dist/resources/brotli.js +22 -0
  28. package/dist/resources/cache.js +59 -0
  29. package/dist/resources/capture.js +225 -0
  30. package/dist/resources/compression.js +122 -0
  31. package/dist/resources/favicon.js +130 -0
  32. package/dist/resources/font.js +440 -0
  33. package/dist/resources/index.js +8 -0
  34. package/dist/resources/lottie.js +491 -0
  35. package/dist/resources/model.js +478 -0
  36. package/dist/resources/phase.js +171 -0
  37. package/dist/resources/pool.js +47 -0
  38. package/dist/resources/report.js +57 -0
  39. package/dist/resources/sfnt.js +305 -0
  40. package/dist/resources/svg.js +178 -0
  41. package/dist/resources/types.js +1 -0
  42. package/dist/resources/zip.js +67 -0
  43. package/dist/run.js +24 -4
  44. package/dist/sizes.js +7 -5
  45. package/dist/summary.js +47 -1
  46. package/dist/vector-art.js +294 -0
  47. package/dist/vector-phase.js +222 -0
  48. package/dist/video-analysis.js +119 -21
  49. package/package.json +25 -4
  50. package/skills/imgtune/SKILL.md +8 -4
  51. package/skills/imgtune/reference.md +1 -1
package/dist/cli.js CHANGED
@@ -21,6 +21,8 @@ const progress = (message) => {
21
21
  const OPTION_HELP = {
22
22
  out: ["--out <dir>", "directory for the JSON files (default: .imgtune/scan)"],
23
23
  noCache: ["--no-cache", "do not read or write the result cache in .imgtune/cache (the numbers are computed again)"],
24
+ noResources: ["--no-resources", "skip the resource phase: no capture of the non-image responses, so none of the resource findings (compression, favicons, fonts, SVG, Lottie, 3D, audio)"],
25
+ resourceTime: ["--resource-time <s>", "time limit of the heavy resource work (3D, fonts, SVG, audio, lottie re-encodes), in seconds, 0 to 3600 (default 60); items over it are reported as not measured"],
24
26
  gpuEncode: [
25
27
  "--gpu-encode",
26
28
  "EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF); off by default (CPU encodes). Bytes are scaled to a CPU encode (÷1.4)",
@@ -66,6 +68,10 @@ const OPTION_HELP = {
66
68
  "--media-budget <N>",
67
69
  "sample-encode at most N animated images and videos per run, heaviest first, 0 to 1000 (default 20); the rest keep their cheap hints",
68
70
  ],
71
+ svgBudget: [
72
+ "--svg-budget <N>",
73
+ "trace at most N flat images (logos, icons, flat illustrations) to SVG and check them, heaviest first, 0 to 1000 (default 50; 0 traces none)",
74
+ ],
69
75
  mediaTime: ["--media-time <s>", "time limit of the media encodes, in seconds, 1 to 3600 (default 600)"],
70
76
  videoBar: ["--video-bar <n>", "minimum VMAF of a video or animated encode, 0 to 100 (default 93)"],
71
77
  videoStillBar: ["--video-still-bar <n>", "minimum SSIMULACRA2 of the still-frame guard, 0 to 100 (default 65)"],
@@ -149,6 +155,8 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
149
155
  "out",
150
156
  "root",
151
157
  "noCache",
158
+ "noResources",
159
+ "resourceTime",
152
160
  "gpuEncode",
153
161
  "aggressive",
154
162
  "viewports",
@@ -163,6 +171,7 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
163
171
  "maxPages",
164
172
  "maxImages",
165
173
  "mediaBudget",
174
+ "svgBudget",
166
175
  "mediaTime",
167
176
  "videoBar",
168
177
  "videoStillBar",
@@ -211,9 +220,12 @@ Options:
211
220
  --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
212
221
  --page-concurrency <N> pages at once in the deep phase (default 1)
213
222
  --idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
223
+ --svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
214
224
  --block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
215
225
  --allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
216
226
  --no-cache do not read or write the result cache
227
+ --no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
228
+ --resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
217
229
  --ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
218
230
  --storage-state <file> a logged-in session (holds cookies: never commit it)
219
231
  -h, --help show this help
@@ -247,9 +259,12 @@ Options:
247
259
  --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
248
260
  --page-concurrency <N> pages at once in the deep phase (default 1)
249
261
  --idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
262
+ --svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
250
263
  --block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
251
264
  --allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
252
265
  --no-cache do not read or write the result cache
266
+ --no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
267
+ --resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
253
268
  --ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
254
269
  --storage-state <file> a logged-in session (holds cookies: never commit it)
255
270
  -h, --help show this help
@@ -278,7 +293,10 @@ Options:
278
293
  --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
279
294
  --page-concurrency <N> pages at once in the survey (default 4) and in the deep phase (default 1)
280
295
  --idle-timeout <ms> cap on the network-idle wait (default 3000 in the survey, 10000 in the deep phase)
296
+ --svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
281
297
  --no-cache do not read or write the result cache
298
+ --no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
299
+ --resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
282
300
  --root <dir> the source folder for --dev (a monorepo sub-folder)
283
301
  --storage-state <file> a logged-in session (holds cookies: never commit it)
284
302
  --ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
@@ -374,6 +392,8 @@ Options:
374
392
  `;
375
393
  const USAGE = `Usage: imgtune <command> [options]
376
394
 
395
+ imgtune measures page weight on live pages (images, video, hidden downloads, fonts, compression, 3D, audio, Lottie, SVG) and prints the fix for each.
396
+
377
397
  Commands:
378
398
  scan measure live pages by URL, with no source, config or dev server (writes JSON to --out)
379
399
  hints print a batch of the per-image fix hints of a scan file
@@ -38,8 +38,9 @@ function connectionFor(hostname, policy, onRefused) {
38
38
  const checked = guardedLookup(policy.resolver, policy.allowPrivate, policy.connectAddress);
39
39
  const lookup = ((name, options, callback) => {
40
40
  checked(name, options, (err, ...rest) => {
41
+ // The name a lookup could not resolve: the page may still load it in a browser (see the scan's unresolved hosts).
41
42
  if (err)
42
- onRefused();
43
+ onRefused(name);
43
44
  callback(err, ...rest);
44
45
  });
45
46
  });
package/dist/ffmpeg.js CHANGED
@@ -493,3 +493,38 @@ export function parseRate(rate) {
493
493
  const fps = n / d;
494
494
  return fps > 0 ? fps : undefined;
495
495
  }
496
+ /** Turns ffprobe's JSON into an AudioProbe, or undefined when there is no audio stream with a codec. */
497
+ export function parseAudioProbeJson(json) {
498
+ const audio = (json.streams ?? []).find((s) => s.codec_type === "audio");
499
+ if (!audio?.codec_name)
500
+ return undefined;
501
+ const bitrate = toNumber(json.format?.bit_rate) ?? toNumber(audio.bit_rate);
502
+ const probe = {
503
+ codec: audio.codec_name,
504
+ durationS: toNumber(json.format?.duration) ?? toNumber(audio.duration) ?? 0,
505
+ channels: typeof audio.channels === "number" && audio.channels > 0 ? audio.channels : 0,
506
+ };
507
+ if (bitrate !== undefined && bitrate > 0)
508
+ probe.bitrate = bitrate;
509
+ const sampleRate = toNumber(audio.sample_rate);
510
+ if (sampleRate !== undefined && sampleRate > 0)
511
+ probe.sampleRate = sampleRate;
512
+ return probe;
513
+ }
514
+ /**
515
+ * Reads an audio file with ffprobe. `input` is an absolute file path or an http(s) URL (see inputOptions). Resolves
516
+ * undefined when ffprobe fails, times out or finds no audio stream. Never throws for a bad file.
517
+ */
518
+ export async function probeAudio(input, timeoutMs, opts = {}) {
519
+ if (/^https?:\/\//i.test(input) && opts.proxy === undefined)
520
+ return undefined;
521
+ try {
522
+ const result = await runBinary(ffprobeBin(), ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", ...inputOptions(input, opts.proxy), "-i", input], { timeoutMs });
523
+ if (result.code !== 0)
524
+ return undefined;
525
+ return parseAudioProbeJson(JSON.parse(result.stdout.toString("utf8")));
526
+ }
527
+ catch {
528
+ return undefined;
529
+ }
530
+ }
package/dist/hints.js CHANGED
@@ -7,8 +7,13 @@ import { safeText } from "./text.js";
7
7
  import { cleanLocate } from "./locate-join.js";
8
8
  import { DEFAULT_VIEWPORTS } from "./config.js";
9
9
  import { fitSizes, maxPerWidth } from "./group-sizes.js";
10
+ import { srcsetWidthLabel, srcsetWidthsOf } from "./candidates.js";
10
11
  import { MAX_MAX_QUALITIES, MIN_MAX_QUALITIES, planQualityCap, qualitiesLine } from "./qualities-cap.js";
11
12
  import { reasonText } from "./media-model.js";
13
+ import { NEVER_SHOWN } from "./media-phase.js";
14
+ import { resourceLinesOf } from "./resources/report.js";
15
+ import { VECTOR_BAR, VECTOR_LOOKS_MIN_BYTES } from "./vector-art.js";
16
+ import { perViewBytesOf } from "./vector-phase.js";
12
17
  export const DEFAULT_HINT_LIMIT = 10;
13
18
  const usage = "usage: imgtune hints <scan.json> [<scan.json> ...] [--page <text>] [--component <name>] [--limit N] [--offset N] [--min-kb N] [--format text|json] [--flat] [--qualities 75,85,100] [--max-qualities N]";
14
19
  function nonNegativeInteger(name, text) {
@@ -99,8 +104,9 @@ export function parseHintsArgs(argv) {
99
104
  * planQualityCap); it applies only when no --qualities list was given (that list is the framework's own).
100
105
  */
101
106
  export function hintsOf(result, qualities, plan) {
102
- // An animated image is a hint even without a sizes or byte saving: its saving is an encode (see animatedLines).
103
- return (result.images ?? []).filter((record) => hasFix(record) || record.fix.animated !== undefined).map((record) => {
107
+ // An animated image is a hint even without a sizes or byte saving: its saving is an encode (see animatedLines). So is a
108
+ // loaded-but-not-shown image: its bytes are the fix's (see hiddenLines).
109
+ return (result.images ?? []).filter((record) => hasFix(record) || record.fix.animated !== undefined || vectorHinted(record) || record.hidden !== undefined).map((record) => {
104
110
  const served = record.current.format;
105
111
  const rawTarget = served !== undefined ? record.fix.quality?.perFormat[served] : undefined;
106
112
  // With the framework's allowed qualities, the target goes up to one of them. The quality saving was estimated at the
@@ -119,7 +125,7 @@ export function hintsOf(result, qualities, plan) {
119
125
  sizes: { current: record.current.sizes, proposed: record.fix.sizes?.value, basis: record.fix.sizes?.basis },
120
126
  // A raise (the list has no lower value for this usage) adds bytes: its quality saving is then none, not negative.
121
127
  bytes: capped !== undefined
122
- ? { ...record.fix.bytes, saved: (record.fix.bytes.sizes ?? 0) + Math.max(0, capped.saved), quality: Math.max(0, capped.saved) }
128
+ ? { ...record.fix.bytes, saved: (record.fix.bytes.sizes ?? 0) + (record.fix.bytes.resize ?? 0) + Math.max(0, capped.saved), quality: Math.max(0, capped.saved) }
123
129
  : { ...record.fix.bytes },
124
130
  rung: record.evidence.rung,
125
131
  notes: record.evidence.notes,
@@ -171,6 +177,8 @@ export function hintsOf(result, qualities, plan) {
171
177
  hint.measured = measured;
172
178
  if (record.fix.width !== undefined)
173
179
  hint.width = record.fix.width;
180
+ if (record.fix.srcset !== undefined)
181
+ hint.srcset = { ...record.fix.srcset };
174
182
  const cdn = record.fix.cdnQuality;
175
183
  if (cdn !== undefined) {
176
184
  hint.qualityNote = cdn.handled
@@ -204,12 +212,16 @@ export function hintsOf(result, qualities, plan) {
204
212
  quality.snapFrom = snapFrom;
205
213
  quality.void = true;
206
214
  if (hint.bytes.sizes !== undefined)
207
- hint.bytes = { ...hint.bytes, saved: hint.bytes.sizes, quality: 0 };
215
+ hint.bytes = { ...hint.bytes, saved: hint.bytes.sizes + (hint.bytes.resize ?? 0), quality: 0 };
208
216
  }
209
217
  hint.quality = quality;
210
218
  }
211
219
  if (record.fix.format !== undefined || served !== undefined)
212
220
  hint.format = { current: served, best: record.fix.format };
221
+ if (record.fix.vector !== undefined)
222
+ hint.vector = { ...record.fix.vector, servedPerView: Math.round(perViewBytesOf(record)) };
223
+ if (record.hidden !== undefined)
224
+ hint.hidden = record.hidden;
213
225
  if (record.fix.animated !== undefined) {
214
226
  hint.animated = record.fix.animated;
215
227
  if (record.current.src !== undefined)
@@ -223,6 +235,40 @@ export function hintsOf(result, qualities, plan) {
223
235
  return hint;
224
236
  });
225
237
  }
238
+ /**
239
+ * An image gets a vector hint when its SVG passed the check (a claim), or when it is flat, was not traced to a passing SVG,
240
+ * and is big enough to matter (an informational note with no byte claim).
241
+ */
242
+ export function vectorHinted(record) {
243
+ const vector = record.fix.vector;
244
+ if (vector === undefined)
245
+ return false;
246
+ if (vector.passed === true)
247
+ return true;
248
+ return perViewBytesOf(record) >= VECTOR_LOOKS_MIN_BYTES;
249
+ }
250
+ /** The vector check's lines of a hint: the SVG that passed, or the note on a flat image with no passing SVG. */
251
+ export function vectorLines(vector) {
252
+ if (vector.passed === true && vector.svgBytes !== undefined && vector.score !== undefined) {
253
+ const traced = vector.tracedFile !== undefined ? `, traced one: ${safeText(vector.tracedFile)}` : "";
254
+ return [
255
+ ` vector art: serve SVG, ~${kb1(vector.svgBytes)} (br) vs ${kb1(vector.servedPerView)} now, sharp at every DPR; fidelity ${Math.round(vector.score)} at half size (bar ${VECTOR_BAR}); prefer your source SVG${traced}`,
256
+ ];
257
+ }
258
+ if (vector.servedPerView >= VECTOR_LOOKS_MIN_BYTES)
259
+ return [" looks like vector art (flat colors); if a source SVG exists, serve it"];
260
+ return [];
261
+ }
262
+ /**
263
+ * The one-line summary of a page's loaded-but-not-shown images: the count, bytes per view and the cases, as the text, Markdown
264
+ * and check outputs all print it. Undefined when there are none.
265
+ */
266
+ export function hiddenSummaryLine(hidden) {
267
+ if (hidden === undefined || hidden.images === 0)
268
+ return undefined;
269
+ const { responsive, lazy, "never-shown-on-load": never } = hidden.cases;
270
+ return `loaded but not shown: ${hidden.images} image(s), ${kb(hidden.bytesPerView)}/view (responsive ${responsive}, lazy ${lazy}, never shown on load ${never}); not in the savings`;
271
+ }
226
272
  /** The hints an argument set asks for: filtered, sorted by bytes saved (largest first), then offset and limited. */
227
273
  export function selectHints(hints, args) {
228
274
  const page = args.page?.toLowerCase();
@@ -239,6 +285,7 @@ export function selectHints(hints, args) {
239
285
  .slice(args.offset, args.offset + args.limit);
240
286
  }
241
287
  const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
288
+ const kb1 = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
242
289
  /** The mark after a hint's file:line: an owner call site, or an unverified source. */
243
290
  export function locationNote(hint) {
244
291
  if (hint.kind === "owner")
@@ -254,17 +301,118 @@ export function qualityTarget(quality) {
254
301
  const allowed = quality.allowed !== undefined ? ` (allowed: ${quality.allowed.join(",")})` : "";
255
302
  return quality.snapFrom !== undefined ? ` q${quality.snapFrom} -> ${quality.target}${allowed}` : ` -> ${quality.target}${allowed}`;
256
303
  }
304
+ /** The srcset advice of a plain image with no srcset: the variants to serve, and the srcset and sizes to add. */
305
+ export function srcsetLineOf(srcset) {
306
+ const candidates = srcset.widths.map((w) => `… ${w}w`).join(", ");
307
+ const sizes = srcset.sizes !== undefined ? safeText(srcset.sizes) : "the rendered CSS width per viewport";
308
+ return `srcset: no srcset today; serve variants at ${srcsetWidthLabel(srcset.widths)} (largest needed ${srcset.largest} px) and add srcset="${candidates}" sizes="${sizes}"`;
309
+ }
310
+ /**
311
+ * The srcset advice of a call site, when every image in it is a plain image with no srcset: the union of their widths (at
312
+ * most the variant limit), the largest need, and the first measured sizes value. Undefined for any other group.
313
+ */
314
+ export function groupSrcsetOf(group) {
315
+ const all = group.hints.flatMap((h) => (h.srcset !== undefined ? [h.srcset] : []));
316
+ if (all.length === 0 || all.length !== group.hints.length)
317
+ return undefined;
318
+ const widths = srcsetWidthsOf(all.flatMap((s) => s.widths), undefined);
319
+ const sizes = all.find((s) => s.sizes !== undefined)?.sizes;
320
+ return { widths, largest: Math.max(...all.map((s) => s.largest)), ...(sizes !== undefined ? { sizes } : {}) };
321
+ }
257
322
  /**
258
323
  * The saving of one image or one call site, per view, split into its parts when the scan gave them: "saves 15 KB/view =
259
324
  * sizes 12 KB + quality 3 KB". A quality part that a snap voided is printed as void, not as a number.
260
325
  */
261
326
  export function savesText(hint) {
262
- const { saved, sizes, quality } = hint.bytes;
327
+ const { saved, sizes, quality, resize } = hint.bytes;
328
+ if (resize !== undefined) {
329
+ const parts = [sizes !== undefined && sizes > 0 ? `sizes ${kb(sizes)}` : undefined, `resize ${kb(resize)}`, (quality ?? 0) > 0 ? `quality ${kb(quality ?? 0)}` : undefined];
330
+ return `saves ${kb(saved)}/view = ${parts.filter((p) => p !== undefined).join(" + ")}`;
331
+ }
263
332
  if (sizes === undefined)
264
333
  return `saves ${kb(saved)}/view`;
265
334
  const qualityPart = hint.quality?.void === true ? "quality void after snap" : `quality ${kb(quality ?? 0)}`;
266
335
  return `saves ${kb(saved)}/view = sizes ${kb(sizes)} + ${qualityPart}`;
267
336
  }
337
+ /** The media condition that splits a responsive twin's viewports: a min-width or max-width at the default widths. */
338
+ function twinMediaOf(hidden) {
339
+ const hiddenAt = hidden.viewports.map((name) => hidden.widths[name]);
340
+ const shownAt = hidden.shownIn.map((name) => hidden.widths[name]);
341
+ if (hiddenAt.some((w) => !(w > 0)) || shownAt.some((w) => !(w > 0)))
342
+ return "(min-width: <breakpoint>px)";
343
+ // Shown at the wider viewports: the file goes from the breakpoint up, at the narrowest shown width.
344
+ if (Math.max(...hiddenAt) < Math.min(...shownAt))
345
+ return `(min-width: ${Math.min(...shownAt)}px)`;
346
+ // Shown at the narrower viewports: the file goes up to the widest shown width.
347
+ if (Math.min(...hiddenAt) > Math.max(...shownAt))
348
+ return `(max-width: ${Math.max(...shownAt)}px)`;
349
+ return "(min-width: <breakpoint>px)";
350
+ }
351
+ /**
352
+ * The lines of a loaded-but-not-shown image: where it is hidden, its bytes per view (kept apart from the saving), and the fix
353
+ * for its case (see HiddenFix.action). Page-derived viewport names are printed as JSON strings.
354
+ */
355
+ export function hiddenLines(hidden) {
356
+ const names = (list) => list.map((v) => JSON.stringify(safeText(v))).join(", ");
357
+ const reasons = [
358
+ ...hidden.reasons,
359
+ ...(hidden.inPanel ? ["in a tab panel, a hidden subtree or a closed details"] : []),
360
+ ...(hidden.hover === true ? ["shown on hover"] : []),
361
+ ].join(", ");
362
+ const lines = [
363
+ ` loaded but not shown on load: ${kb(hidden.perViewBytes)}/view, hidden at ${hidden.viewports.length} of ${hidden.of} viewport(s) (${names(hidden.viewports)}): ${safeText(reasons)}; not in the saving`,
364
+ ];
365
+ if (hidden.unstable === "unknown")
366
+ lines.push(" unstable: unknown (not verified: a name on the page did not resolve, or the candidate cap was reached): the reason is the first read's; check it in a browser");
367
+ if (hidden.action === "responsive") {
368
+ lines.push(` responsive twin, shown on load at ${names(hidden.shownIn)}: serve one file per viewport. Replace the two elements with <picture><source media="${twinMediaOf(hidden)}" srcset="<the file shown there>"><img …></picture>, or delete the hidden copy if the design does not need it.`);
369
+ }
370
+ else if (hidden.action === "lazy") {
371
+ const what = hidden.hover === true ? "a hover swap" : hidden.inPanel ? "a tab panel, a hidden subtree or a closed details" : "a carousel slide or other clipped box";
372
+ if (hidden.alreadyLazy === true) {
373
+ // loading="lazy" is already there: the browser still loads it because it is inside its lazy-load distance, so adding it again changes nothing.
374
+ lines.push(` ${what} (${names(hidden.viewports)}): already lazy (loading="lazy") and still loaded, as it is within the browser's lazy-load distance of the viewport. Render it on activation (when the tab, details or hover opens), do not add lazy again.`);
375
+ }
376
+ else {
377
+ lines.push(` ${what} (${names(hidden.viewports)}): add loading="lazy", or load it on demand (when the tab, details or hover opens).`);
378
+ }
379
+ }
380
+ else {
381
+ lines.push(` never shown on load at any of ${hidden.of} viewport(s): remove it, or load it on demand (when the menu, modal or hover opens).`);
382
+ }
383
+ return lines;
384
+ }
385
+ /**
386
+ * The loaded-but-not-shown lines of one video (see HiddenFix): its bytes per view, the case and the fix. The responsive case
387
+ * names the one element with <source media>; the lazy one preload none, no autoplay until shown; the never-shown one removal
388
+ * or an on-demand load. The bytes are not in the saving, as an image's are not.
389
+ */
390
+ export function videoHiddenLines(hidden) {
391
+ const names = (list) => list.map((v) => JSON.stringify(safeText(v))).join(", ");
392
+ const reasons = [
393
+ ...hidden.reasons,
394
+ ...(hidden.inPanel ? ["in a tab panel, a hidden subtree or a closed details"] : []),
395
+ ...(hidden.hover === true ? ["shown on hover"] : []),
396
+ ].join(", ");
397
+ const lines = [
398
+ ` loaded but not shown on load: ${kb(hidden.perViewBytes)}/view, hidden at ${hidden.viewports.length} of ${hidden.of} viewport(s) (${names(hidden.viewports)}): ${safeText(reasons)}; not in the savings`,
399
+ ];
400
+ if (hidden.unstable === "unknown")
401
+ lines.push(" unstable: unknown (not verified: a name on the page did not resolve, or the candidate cap was reached): the reason is the first read's; check it in a browser");
402
+ if (hidden.action === "responsive") {
403
+ const media = twinMediaOf(hidden);
404
+ lines.push(` responsive twin, shown on load at ${names(hidden.shownIn)}: one <video> for both viewports, not two: <video autoplay muted playsinline poster="<first frame>"> with <source media="${media}" src="<the file shown at ${names(hidden.viewports)}>" type="video/mp4"> first, then <source src="<the file shown at ${names(hidden.shownIn)}>" type="video/mp4"> with no media. Or delete the hidden copy if the design does not need it.`);
405
+ lines.push(` <source media> on video: Chrome and Edge 120+ and Firefox 120+ restored support; Safari has always supported it. A browser that ignores it takes the first source, so the fallback is a script that sets src from matchMedia("${media}") and reloads on change.`);
406
+ }
407
+ else if (hidden.action === "lazy") {
408
+ const what = hidden.hover === true ? "a hover swap" : hidden.inPanel ? "a tab panel, a hidden subtree or a closed details" : "a carousel slide or other clipped box";
409
+ lines.push(` ${what} (${names(hidden.viewports)}): preload="none", no autoplay until it is shown (an IntersectionObserver, or the tab, details or hover opens), and the poster only.`);
410
+ }
411
+ else {
412
+ lines.push(` never shown on load at any of ${hidden.of} viewport(s): remove it, or load it on demand (when the modal, menu or hover opens).`);
413
+ }
414
+ return lines;
415
+ }
268
416
  /** One hint as four short lines: where, sizes, quality and format, bytes and rung. */
269
417
  export function renderHint(hint, rank) {
270
418
  // Page-derived names and attributes are printed as JSON strings, so a newline or an instruction inside them stays data.
@@ -281,6 +429,10 @@ export function renderHint(hint, rank) {
281
429
  ].filter((a) => a !== undefined);
282
430
  if (attributes.length > 0)
283
431
  lines.push(` ${attributes.join(" ")}`);
432
+ // A vector check that wins leads the hint; otherwise it follows the raster fixes (see below).
433
+ const vector = hint.vector !== undefined ? vectorLines(hint.vector) : [];
434
+ if (hint.vector?.wins === true)
435
+ lines.push(...vector);
284
436
  // The name is an identifier (COMPONENT_NAME), so it prints bare; the glob is built from the same name.
285
437
  if (hint.kind === "owner") {
286
438
  const name = hint.component[0];
@@ -290,9 +442,20 @@ export function renderHint(hint, rank) {
290
442
  }
291
443
  if (hint.find)
292
444
  lines.push(` find: ${hint.find.component} in ${hint.find.glob}`);
445
+ if (hint.hidden !== undefined)
446
+ lines.push(...hiddenLines(hint.hidden));
447
+ // An image never shown has no sizes, quality or format change to print: its fix is the hidden block above.
448
+ if (hint.hidden !== undefined && hint.hidden.shownIn.length === 0) {
449
+ if (hint.file === undefined && hint.find === undefined && hint.kind !== "owner")
450
+ lines.push(" owner unknown");
451
+ lines.push(` rung ${hint.rung}`);
452
+ return lines;
453
+ }
293
454
  if (hint.width !== undefined)
294
455
  lines.push(` width ${hint.width}px (device pixels needed)`);
295
- else if (hint.animated === undefined || hint.sizes.proposed !== undefined) {
456
+ if (hint.srcset !== undefined)
457
+ lines.push(` ${srcsetLineOf(hint.srcset)}`);
458
+ else if (hint.width === undefined && (hint.animated === undefined || hint.sizes.proposed !== undefined)) {
296
459
  lines.push(` sizes ${safeText(hint.sizes.current ?? "none")} -> ${safeText(hint.sizes.proposed ?? "-")} (${hint.sizes.basis ?? "-"})`);
297
460
  }
298
461
  if (hint.sizesAuto !== undefined)
@@ -319,6 +482,8 @@ export function renderHint(hint, rank) {
319
482
  // An animated hint's saving is an estimate from the sample encodes, and its rung is that estimate, not the image measurement.
320
483
  const animated = hint.animated !== undefined;
321
484
  lines.push(` ${savesText(hint)}${animated ? " est." : ""}, +${kb(hint.bytes.addedForSharpness)} sharpness (separate) rung ${animated ? "estimate" : hint.rung}`);
485
+ if (hint.vector !== undefined && hint.vector.wins !== true)
486
+ lines.push(...vector);
322
487
  return lines;
323
488
  }
324
489
  /** The group a hint belongs to: its file:line, else its nearest component, else its selector. */
@@ -429,6 +594,8 @@ export function groupSizesOf(group) {
429
594
  * otherwise the pages' values: when they differ, the component is shared and each caller passes its own sizes.
430
595
  */
431
596
  export function combinedSizes(group) {
597
+ if (groupSrcsetOf(group) !== undefined)
598
+ return "no sizes: the images have no srcset (see srcset)";
432
599
  const fitted = groupSizesOf(group);
433
600
  if (fitted?.mode === "one") {
434
601
  return `${fitted.value} (one formula over ${group.hints.length} image(s), checked at ${fitted.views} viewport(s): never below a measured width)`;
@@ -453,7 +620,14 @@ export function groupHeadLine(group, rank) {
453
620
  : JSON.stringify(first.component[0] ?? first.selector);
454
621
  const name = first.file !== undefined && first.component[0] !== undefined ? ` (${JSON.stringify(first.component[0])})` : "";
455
622
  const sizes = group.hints.every((h) => h.bytes.sizes !== undefined) ? group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) : undefined;
456
- const split = sizes !== undefined ? ` = sizes ${kb(sizes)} + quality ${kb(group.saved - sizes)}` : "";
623
+ const resize = group.hints.every((h) => h.bytes.resize !== undefined) ? group.hints.reduce((sum, h) => sum + (h.bytes.resize ?? 0), 0) : undefined;
624
+ let split = sizes !== undefined ? ` = sizes ${kb(sizes)} + quality ${kb(group.saved - sizes)}` : "";
625
+ if (resize !== undefined) {
626
+ const sized = sizes ?? 0;
627
+ const quality = Math.max(0, group.saved - sized - resize);
628
+ const parts = [sized > 0 ? `sizes ${kb(sized)}` : undefined, `resize ${kb(resize)}`, quality > 0 ? `quality ${kb(quality)}` : undefined];
629
+ split = ` = ${parts.filter((p) => p !== undefined).join(" + ")}`;
630
+ }
457
631
  const lcp = group.hints.find((h) => h.lcp === true);
458
632
  const lcpPrefix = lcp !== undefined ? `LCP image (${(lcp.lcpViewports ?? []).map((v) => safeText(v)).join(", ")}): ` : "";
459
633
  return `${rank}. ${lcpPrefix}${where}${name} ${group.hints.length} image(s), ${group.pages.length} page(s) saves ${kb(group.saved)}/view${split}`;
@@ -485,7 +659,8 @@ export function renderGroup(group, rank) {
485
659
  }
486
660
  }
487
661
  const fitted = groupSizesOf(group);
488
- lines.push(` sizes: ${safeText(combinedSizes(group))}`);
662
+ const srcset = groupSrcsetOf(group);
663
+ lines.push(srcset !== undefined ? ` ${srcsetLineOf(srcset)}` : ` sizes: ${safeText(combinedSizes(group))}`);
489
664
  for (const page of group.pages) {
490
665
  const mine = group.hints.filter((h) => h.page === page);
491
666
  const current = [...new Set(mine.map((h) => h.sizes.current ?? "none"))].map((v) => JSON.stringify(safeText(v))).join(", ");
@@ -495,7 +670,11 @@ export function renderGroup(group, rank) {
495
670
  : [...new Set(mine.map((h) => h.sizes.proposed ?? "-"))].map((v) => JSON.stringify(safeText(v))).join(", ");
496
671
  const widths = mine.flatMap((h) => (h.rendered ? [h.rendered.min, h.rendered.max] : []));
497
672
  const rendered = widths.length > 0 ? `${Math.min(...widths)}-${Math.max(...widths)} px` : "not rendered";
498
- lines.push(` ${JSON.stringify(pathOf(page))} ${mine.length} image(s) current ${current} proposed ${proposed} rendered ${rendered}`);
673
+ // A srcset group has no sizes change to print: its page line keeps the images and the rendered widths only.
674
+ if (srcset !== undefined)
675
+ lines.push(` ${JSON.stringify(pathOf(page))} ${mine.length} image(s) rendered ${rendered}`);
676
+ else
677
+ lines.push(` ${JSON.stringify(pathOf(page))} ${mine.length} image(s) current ${current} proposed ${proposed} rendered ${rendered}`);
499
678
  }
500
679
  const targets = group.hints.flatMap((h) => (h.quality ? [h.quality] : []));
501
680
  if (targets.length > 0) {
@@ -550,7 +729,8 @@ const FORMAT_LABEL = { gif: "GIF", webp: "WebP", apng: "APNG" };
550
729
  * says so: performance before precision, but the number is never presented as a measured CPU encode.
551
730
  */
552
731
  export function estLabel(c) {
553
- return c.encoder === "nvenc" ? "est. (GPU-encoded, scaled to a CPU encode ÷1.4)" : "est.";
732
+ const base = c.encoder === "nvenc" ? "est. (GPU-encoded, scaled to a CPU encode ÷1.4)" : "est.";
733
+ return c.timeLimited === true ? `${base}, search stopped at the time limit` : base;
554
734
  }
555
735
  /**
556
736
  * The quality mark of a candidate: an NVENC candidate's cq is read as the CPU CRF (the same number, a fixed offset of 0), so
@@ -749,7 +929,63 @@ export function videoTotalsLine(videos) {
749
929
  const sum = (values) => values.reduce((total, v) => total + v, 0);
750
930
  const savings = files.map(videoSavingOf);
751
931
  const est = files.some((v) => v.steps !== undefined && v.estimate !== undefined) ? " est." : "";
752
- return `video: ${kb(sum(files.map((v) => v.bytes.perView)))}/view, ${kb(sum(files.map((v) => v.bytes.perPlay)))}/play, saveable ${kb(sum(savings.map((s) => s.view)))}/view (${kb(sum(savings.map((s) => s.play)))}/play)${est}`;
932
+ const hidden = hiddenVideoTotalOf(videos);
933
+ const loaded = hidden === undefined ? "" : `; loaded but not shown ${kb(hidden.bytes)}/view in ${hidden.count} video(s), not in the savings`;
934
+ return `video: ${kb(sum(files.map((v) => v.bytes.perView)))}/view, ${kb(sum(files.map((v) => v.bytes.perPlay)))}/play, saveable ${kb(sum(savings.map((s) => s.view)))}/view (${kb(sum(savings.map((s) => s.play)))}/play)${est}${loaded}`;
935
+ }
936
+ /**
937
+ * The loaded-but-not-shown total of some videos: the records that have a hidden fix, their count and bytes per view. A file
938
+ * several hidden records share (the same src) counts its bytes once, at its largest record, as the images do (hiddenTotalsOf).
939
+ */
940
+ function hiddenVideoTotalOf(videos) {
941
+ const hidden = videos.flatMap((v, index) => (v.hidden !== undefined ? [{ key: v.src || `#${index}`, fix: v.hidden }] : []));
942
+ if (hidden.length === 0)
943
+ return undefined;
944
+ const cases = { responsive: 0, lazy: 0, "never-shown-on-load": 0 };
945
+ const perFile = new Map();
946
+ for (const { key, fix } of hidden) {
947
+ cases[fix.action]++;
948
+ perFile.set(key, Math.max(perFile.get(key) ?? 0, fix.perViewBytes));
949
+ }
950
+ return { count: hidden.length, bytes: [...perFile.values()].reduce((sum, bytes) => sum + bytes, 0), cases };
951
+ }
952
+ /**
953
+ * The one-line summary of a page's loaded-but-not-shown videos, as the text, Markdown and check outputs print it. Undefined
954
+ * when no video has a hidden fix.
955
+ */
956
+ export function hiddenVideoSummaryLine(videos) {
957
+ const total = hiddenVideoTotalOf(videos);
958
+ return total === undefined ? undefined : hiddenVideoLine(total);
959
+ }
960
+ /** The one-line text of a video total (the page's line, or the PR comment's over all pages). */
961
+ function hiddenVideoLine(total) {
962
+ const { responsive, lazy, "never-shown-on-load": never } = total.cases;
963
+ return `loaded but not shown: ${total.count} video(s), ${kb(total.bytes)}/view (responsive ${responsive}, lazy ${lazy}, never shown on load ${never}); not in the savings`;
964
+ }
965
+ /**
966
+ * The loaded-but-not-shown line of the PR comment for videos: all ok pages together. Each page counts its own files (a file
967
+ * shared between two pages is two files' bytes), as hiddenPagesLine does for images.
968
+ */
969
+ export function hiddenVideoPagesLine(results) {
970
+ const totals = {
971
+ count: 0,
972
+ bytes: 0,
973
+ cases: { responsive: 0, lazy: 0, "never-shown-on-load": 0 },
974
+ };
975
+ for (const page of results) {
976
+ if (page.status !== "ok")
977
+ continue;
978
+ const total = hiddenVideoTotalOf(page.videos ?? []);
979
+ if (total === undefined)
980
+ continue;
981
+ totals.count += total.count;
982
+ totals.bytes += total.bytes;
983
+ for (const key of Object.keys(totals.cases))
984
+ totals.cases[key] += total.cases[key];
985
+ }
986
+ if (totals.count === 0)
987
+ return undefined;
988
+ return hiddenVideoLine(totals);
753
989
  }
754
990
  /** A video's encode in one phrase: the encode at its width with its bytes, or why it was not encoded. */
755
991
  export function videoStatusOf(video) {
@@ -858,6 +1094,13 @@ function targetLines(video, plan) {
858
1094
  export function videoLines(video, rank = 1) {
859
1095
  const lines = [`${rank}. ${safeText(video.id)} ${JSON.stringify(safeText(videoWhere(video)))} ${safeText(video.page)}`];
860
1096
  lines.push(` bytes: ${kb(video.bytes.perView)}/view (${viewBasisOf(video)}), ${kb(video.bytes.perPlay)}/play`);
1097
+ if (video.hidden !== undefined)
1098
+ lines.push(...videoHiddenLines(video.hidden));
1099
+ // A video no viewport shows has no claim: no encode, source, width, loading or codec line, only its hidden fix.
1100
+ if (video.hidden !== undefined && video.hidden.shownIn.length === 0) {
1101
+ lines.push(` not encoded: ${reasonText(NEVER_SHOWN)}`);
1102
+ return lines;
1103
+ }
861
1104
  const est = video.estimate;
862
1105
  if (est !== undefined && video.steps !== undefined) {
863
1106
  const s = video.steps;
@@ -947,6 +1190,18 @@ function videosOf(results, args) {
947
1190
  const page = args.page?.toLowerCase();
948
1191
  return results.flatMap((r) => r.videos ?? []).filter((v) => page === undefined || v.page.toLowerCase().includes(page));
949
1192
  }
1193
+ /** The resource findings of the selected pages, one row per finding with its page (the JSON's `resources`). */
1194
+ function resourceRowsOf(results) {
1195
+ return results.flatMap((r) => (r.resources ?? []).map((f) => ({ page: r.url, kind: f.kind, url: f.url, bytes: f.bytes, savedBytes: f.savedBytes, estimate: f.estimate, title: f.title, fix: f.fix })));
1196
+ }
1197
+ /** The text block of the resource findings, one heading per page that has any (none when no page has any). */
1198
+ function renderResourceHints(results) {
1199
+ const pages = results.filter((r) => (r.resources ?? []).length > 0);
1200
+ if (pages.length === 0)
1201
+ return "";
1202
+ const body = pages.flatMap((r) => [`${pathOf(r.url)} (resources)`, ...resourceLinesOf(r.resources ?? [], (text) => safeText(text))]);
1203
+ return `${body.join("\n")}\n`;
1204
+ }
950
1205
  /** `imgtune hints <scan.json> [<scan.json> ...] ...`: reads the pages' scan files, returns the text or JSON to print. */
951
1206
  export async function hintsCommand(argv, cwd) {
952
1207
  const args = parseHintsArgs(argv);
@@ -978,12 +1233,13 @@ export async function hintsCommand(argv, cwd) {
978
1233
  notes.push(`${dropped} sizes proposal(s) left out: no saving (their predicted outcome adds bytes or changes nothing)`);
979
1234
  const filtered = selectHints(all, { ...args, limit: Number.MAX_SAFE_INTEGER, offset: 0 });
980
1235
  const videos = videosOf(results, args);
1236
+ const resourceRows = resourceRowsOf(results);
981
1237
  if (args.top !== undefined) {
982
1238
  const top = groupHints(filtered).slice(0, args.top);
983
1239
  if (args.format === "json") {
984
- return { output: `${JSON.stringify({ top: top.map((g, i) => ({ rank: i + 1, key: g.key, saved: g.saved, images: g.hints.length, pages: g.pages.map((p) => pathOf(p)), file: g.hints[0].file ?? null, line: g.hints[0].line ?? null, component: g.hints[0].component, sizes: combinedSizes(g) })), qualities: planJson(plan) }, null, 2)}\n`, exitCode: 0 };
1240
+ return { output: `${JSON.stringify({ top: top.map((g, i) => ({ rank: i + 1, key: g.key, saved: g.saved, images: g.hints.length, pages: g.pages.map((p) => pathOf(p)), file: g.hints[0].file ?? null, line: g.hints[0].line ?? null, component: g.hints[0].component, sizes: combinedSizes(g), srcset: groupSrcsetOf(g) ?? null })), qualities: planJson(plan) }, null, 2)}\n`, exitCode: 0 };
985
1241
  }
986
- return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}`, exitCode: 0 };
1242
+ return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}${renderResourceHints(results)}`, exitCode: 0 };
987
1243
  }
988
1244
  // JSON is not cut to the text default: it is all of it unless --limit is passed, and `truncated` says whether it is.
989
1245
  const jsonLimit = args.limitGiven ? args.limit : Number.MAX_SAFE_INTEGER;
@@ -1012,15 +1268,17 @@ export async function hintsCommand(argv, cwd) {
1012
1268
  saved: group.saved,
1013
1269
  sizes: combinedSizes(group),
1014
1270
  sizesFitted: groupSizesOf(group) ?? null,
1271
+ srcset: groupSrcsetOf(group) ?? null,
1015
1272
  callers: callerMapOf(group) ?? null,
1016
1273
  ids: group.hints.map((h) => h.id),
1017
1274
  hints: group.hints,
1018
1275
  })),
1019
1276
  ...(videos.length > 0 ? { videos } : {}),
1277
+ ...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
1020
1278
  };
1021
1279
  return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
1022
1280
  }
1023
- const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}`;
1281
+ const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
1024
1282
  return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
1025
1283
  }
1026
1284
  const selected = selectHints(all, args.format === "json" ? { ...args, limit: jsonLimit } : args);
@@ -1036,10 +1294,11 @@ export async function hintsCommand(argv, cwd) {
1036
1294
  qualities: planJson(plan),
1037
1295
  hints: selected,
1038
1296
  ...(videos.length > 0 ? { videos } : {}),
1297
+ ...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
1039
1298
  };
1040
1299
  return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
1041
1300
  }
1042
- const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}`;
1301
+ const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
1043
1302
  return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
1044
1303
  }
1045
1304
  /** How many hints the PR comment lists. */
@@ -1056,6 +1315,21 @@ export function commentHints(results, limit = COMMENT_HINTS, maxQualities) {
1056
1315
  const top = selectHints(all, { limit, offset: 0 });
1057
1316
  return { total: all.length, lines: top.map((hint, i) => `${i + 1}. ${commentLine(hint)}`) };
1058
1317
  }
1318
+ /** The loaded-but-not-shown line of the PR comment: all ok pages together (the same numbers as the text output). */
1319
+ export function hiddenPagesLine(results) {
1320
+ const pages = results.filter((r) => r.status === "ok" && r.hidden !== undefined);
1321
+ if (pages.length === 0)
1322
+ return undefined;
1323
+ const totals = { images: 0, bytesPerView: 0, cases: { responsive: 0, lazy: 0, "never-shown-on-load": 0 } };
1324
+ for (const page of pages) {
1325
+ const hidden = page.hidden;
1326
+ totals.images += hidden.images;
1327
+ totals.bytesPerView += hidden.bytesPerView;
1328
+ for (const key of Object.keys(totals.cases))
1329
+ totals.cases[key] += hidden.cases[key];
1330
+ }
1331
+ return hiddenSummaryLine(totals);
1332
+ }
1059
1333
  /**
1060
1334
  * A page-derived value in the comment: capped at COMMENT_FIELD characters, then JSON-quoted the way the CLI text prints it
1061
1335
  * (so an instruction inside a name stays a quoted string). The block is fenced, and GitHub does not decode entities inside
@@ -1071,9 +1345,11 @@ function commentLine(hint) {
1071
1345
  ? `${quoted(`${hint.file}:${hint.line ?? 0}`)}${locationNote(hint)}`
1072
1346
  : quoted(hint.component[0] ?? hint.selector);
1073
1347
  const component = hint.file !== undefined && hint.component[0] !== undefined ? ` (${quoted(hint.component[0])})` : "";
1074
- const sizes = hint.width !== undefined
1075
- ? `width ${hint.width}px`
1076
- : `sizes ${quoted(hint.sizes.current ?? "none")} -> ${quoted(hint.sizes.proposed ?? "-")}`;
1348
+ const sizes = hint.srcset !== undefined
1349
+ ? `serve variants at ${srcsetWidthLabel(hint.srcset.widths)}`
1350
+ : hint.width !== undefined
1351
+ ? `width ${hint.width}px`
1352
+ : `sizes ${quoted(hint.sizes.current ?? "none")} -> ${quoted(hint.sizes.proposed ?? "-")}`;
1077
1353
  const parts = [
1078
1354
  hint.lcp === true ? `LCP image (${(hint.lcpViewports ?? []).map((v) => quoted(v)).join(", ")})` : undefined,
1079
1355
  `${savesText(hint)}, +${kb(hint.bytes.addedForSharpness)} sharpness`,