imgtune 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +109 -5
  3. package/dist/animated.js +176 -0
  4. package/dist/audit.js +1 -0
  5. package/dist/backgrounds.js +42 -7
  6. package/dist/broken.js +5 -1
  7. package/dist/browser.js +4 -4
  8. package/dist/candidates.js +25 -0
  9. package/dist/check-report.js +56 -6
  10. package/dist/check.js +26 -5
  11. package/dist/cli.js +86 -8
  12. package/dist/egress-proxy.js +2 -1
  13. package/dist/fetch.js +9 -4
  14. package/dist/ffmpeg.js +530 -0
  15. package/dist/gpu.js +12 -0
  16. package/dist/hints.js +704 -16
  17. package/dist/image-file.js +154 -34
  18. package/dist/image-open.js +9 -0
  19. package/dist/images.js +142 -8
  20. package/dist/layout.js +7 -7
  21. package/dist/lcp.js +2 -1
  22. package/dist/locate-join.js +86 -0
  23. package/dist/locate-only.js +14 -3
  24. package/dist/locate.js +6 -2
  25. package/dist/measure.js +39 -1
  26. package/dist/media-encode.js +1054 -0
  27. package/dist/media-file.js +284 -0
  28. package/dist/media-limits.js +31 -0
  29. package/dist/media-model.js +251 -0
  30. package/dist/media-phase.js +675 -0
  31. package/dist/media-prepare.js +31 -0
  32. package/dist/px.js +11 -0
  33. package/dist/recheck.js +12 -4
  34. package/dist/remote.js +1065 -107
  35. package/dist/replay.js +20 -0
  36. package/dist/resources/audio.js +248 -0
  37. package/dist/resources/brotli.js +22 -0
  38. package/dist/resources/cache.js +59 -0
  39. package/dist/resources/capture.js +225 -0
  40. package/dist/resources/compression.js +122 -0
  41. package/dist/resources/favicon.js +130 -0
  42. package/dist/resources/font.js +440 -0
  43. package/dist/resources/index.js +8 -0
  44. package/dist/resources/lottie.js +491 -0
  45. package/dist/resources/model.js +478 -0
  46. package/dist/resources/phase.js +171 -0
  47. package/dist/resources/pool.js +47 -0
  48. package/dist/resources/report.js +57 -0
  49. package/dist/resources/sfnt.js +305 -0
  50. package/dist/resources/svg.js +178 -0
  51. package/dist/resources/types.js +1 -0
  52. package/dist/resources/zip.js +67 -0
  53. package/dist/result-cache.js +56 -3
  54. package/dist/run.js +32 -8
  55. package/dist/sizes.js +7 -5
  56. package/dist/summary.js +69 -3
  57. package/dist/vector-art.js +294 -0
  58. package/dist/vector-phase.js +222 -0
  59. package/dist/video-analysis.js +371 -0
  60. package/dist/webp-animation.js +283 -0
  61. package/package.json +25 -4
  62. package/skills/imgtune/SKILL.md +8 -4
  63. package/skills/imgtune/reference.md +1 -1
package/dist/hints.js CHANGED
@@ -7,7 +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";
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";
11
17
  export const DEFAULT_HINT_LIMIT = 10;
12
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]";
13
19
  function nonNegativeInteger(name, text) {
@@ -98,7 +104,9 @@ export function parseHintsArgs(argv) {
98
104
  * planQualityCap); it applies only when no --qualities list was given (that list is the framework's own).
99
105
  */
100
106
  export function hintsOf(result, qualities, plan) {
101
- return (result.images ?? []).filter(hasFix).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) => {
102
110
  const served = record.current.format;
103
111
  const rawTarget = served !== undefined ? record.fix.quality?.perFormat[served] : undefined;
104
112
  // With the framework's allowed qualities, the target goes up to one of them. The quality saving was estimated at the
@@ -117,7 +125,7 @@ export function hintsOf(result, qualities, plan) {
117
125
  sizes: { current: record.current.sizes, proposed: record.fix.sizes?.value, basis: record.fix.sizes?.basis },
118
126
  // A raise (the list has no lower value for this usage) adds bytes: its quality saving is then none, not negative.
119
127
  bytes: capped !== undefined
120
- ? { ...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) }
121
129
  : { ...record.fix.bytes },
122
130
  rung: record.evidence.rung,
123
131
  notes: record.evidence.notes,
@@ -169,6 +177,8 @@ export function hintsOf(result, qualities, plan) {
169
177
  hint.measured = measured;
170
178
  if (record.fix.width !== undefined)
171
179
  hint.width = record.fix.width;
180
+ if (record.fix.srcset !== undefined)
181
+ hint.srcset = { ...record.fix.srcset };
172
182
  const cdn = record.fix.cdnQuality;
173
183
  if (cdn !== undefined) {
174
184
  hint.qualityNote = cdn.handled
@@ -202,15 +212,63 @@ export function hintsOf(result, qualities, plan) {
202
212
  quality.snapFrom = snapFrom;
203
213
  quality.void = true;
204
214
  if (hint.bytes.sizes !== undefined)
205
- 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 };
206
216
  }
207
217
  hint.quality = quality;
208
218
  }
209
219
  if (record.fix.format !== undefined || served !== undefined)
210
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;
225
+ if (record.fix.animated !== undefined) {
226
+ hint.animated = record.fix.animated;
227
+ if (record.current.src !== undefined)
228
+ hint.src = record.current.src;
229
+ // The animated saving is the encode's alone (est.): the target is encoded at the needed width, so perViewBytes less the
230
+ // target's bytes already holds the width change. Adding the record's sizes part would count it twice. A real sizes
231
+ // proposal is still printed as information (renderHint), but it adds nothing to the saving.
232
+ const encoded = animatedSavingOf(record.fix.animated);
233
+ hint.bytes = { saved: encoded, addedForSharpness: record.fix.bytes.addedForSharpness, quality: encoded };
234
+ }
211
235
  return hint;
212
236
  });
213
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
+ }
214
272
  /** The hints an argument set asks for: filtered, sorted by bytes saved (largest first), then offset and limited. */
215
273
  export function selectHints(hints, args) {
216
274
  const page = args.page?.toLowerCase();
@@ -227,6 +285,7 @@ export function selectHints(hints, args) {
227
285
  .slice(args.offset, args.offset + args.limit);
228
286
  }
229
287
  const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
288
+ const kb1 = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
230
289
  /** The mark after a hint's file:line: an owner call site, or an unverified source. */
231
290
  export function locationNote(hint) {
232
291
  if (hint.kind === "owner")
@@ -242,17 +301,118 @@ export function qualityTarget(quality) {
242
301
  const allowed = quality.allowed !== undefined ? ` (allowed: ${quality.allowed.join(",")})` : "";
243
302
  return quality.snapFrom !== undefined ? ` q${quality.snapFrom} -> ${quality.target}${allowed}` : ` -> ${quality.target}${allowed}`;
244
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
+ }
245
322
  /**
246
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 =
247
324
  * sizes 12 KB + quality 3 KB". A quality part that a snap voided is printed as void, not as a number.
248
325
  */
249
326
  export function savesText(hint) {
250
- 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
+ }
251
332
  if (sizes === undefined)
252
333
  return `saves ${kb(saved)}/view`;
253
334
  const qualityPart = hint.quality?.void === true ? "quality void after snap" : `quality ${kb(quality ?? 0)}`;
254
335
  return `saves ${kb(saved)}/view = sizes ${kb(sizes)} + ${qualityPart}`;
255
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
+ }
256
416
  /** One hint as four short lines: where, sizes, quality and format, bytes and rung. */
257
417
  export function renderHint(hint, rank) {
258
418
  // Page-derived names and attributes are printed as JSON strings, so a newline or an instruction inside them stays data.
@@ -269,6 +429,10 @@ export function renderHint(hint, rank) {
269
429
  ].filter((a) => a !== undefined);
270
430
  if (attributes.length > 0)
271
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);
272
436
  // The name is an identifier (COMPONENT_NAME), so it prints bare; the glob is built from the same name.
273
437
  if (hint.kind === "owner") {
274
438
  const name = hint.component[0];
@@ -278,10 +442,22 @@ export function renderHint(hint, rank) {
278
442
  }
279
443
  if (hint.find)
280
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
+ }
281
454
  if (hint.width !== undefined)
282
455
  lines.push(` width ${hint.width}px (device pixels needed)`);
283
- else
456
+ if (hint.srcset !== undefined)
457
+ lines.push(` ${srcsetLineOf(hint.srcset)}`);
458
+ else if (hint.width === undefined && (hint.animated === undefined || hint.sizes.proposed !== undefined)) {
284
459
  lines.push(` sizes ${safeText(hint.sizes.current ?? "none")} -> ${safeText(hint.sizes.proposed ?? "-")} (${hint.sizes.basis ?? "-"})`);
460
+ }
285
461
  if (hint.sizesAuto !== undefined)
286
462
  lines.push(` alternative: sizes="${safeText(hint.sizesAuto)}" (Chrome, Edge, Firefox 150+; Safari falls back to ${safeText(hint.sizes.proposed ?? "-")})`);
287
463
  if (hint.via === "prod-sourcemap") {
@@ -292,6 +468,8 @@ export function renderHint(hint, rank) {
292
468
  }
293
469
  else if (hint.file === undefined && hint.find === undefined && hint.kind !== "owner")
294
470
  lines.push(" owner unknown");
471
+ if (hint.animated !== undefined)
472
+ lines.push(...animatedLines(hint.animated, hint.src, hint.alt));
295
473
  const parts = [];
296
474
  if (hint.quality)
297
475
  parts.push(`quality ${hint.quality.current}${qualityTarget(hint.quality)} (max of ${hint.quality.samples}, ${safeText(hint.quality.group)})`);
@@ -301,7 +479,11 @@ export function renderHint(hint, rank) {
301
479
  lines.push(` ${parts.join(" ")}`);
302
480
  if (hint.qualityNote !== undefined)
303
481
  lines.push(` ${safeText(hint.qualityNote)}`);
304
- lines.push(` ${savesText(hint)}, +${kb(hint.bytes.addedForSharpness)} sharpness (separate) rung ${hint.rung}`);
482
+ // An animated hint's saving is an estimate from the sample encodes, and its rung is that estimate, not the image measurement.
483
+ const animated = hint.animated !== undefined;
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);
305
487
  return lines;
306
488
  }
307
489
  /** The group a hint belongs to: its file:line, else its nearest component, else its selector. */
@@ -412,6 +594,8 @@ export function groupSizesOf(group) {
412
594
  * otherwise the pages' values: when they differ, the component is shared and each caller passes its own sizes.
413
595
  */
414
596
  export function combinedSizes(group) {
597
+ if (groupSrcsetOf(group) !== undefined)
598
+ return "no sizes: the images have no srcset (see srcset)";
415
599
  const fitted = groupSizesOf(group);
416
600
  if (fitted?.mode === "one") {
417
601
  return `${fitted.value} (one formula over ${group.hints.length} image(s), checked at ${fitted.views} viewport(s): never below a measured width)`;
@@ -436,7 +620,14 @@ export function groupHeadLine(group, rank) {
436
620
  : JSON.stringify(first.component[0] ?? first.selector);
437
621
  const name = first.file !== undefined && first.component[0] !== undefined ? ` (${JSON.stringify(first.component[0])})` : "";
438
622
  const sizes = group.hints.every((h) => h.bytes.sizes !== undefined) ? group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) : undefined;
439
- 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
+ }
440
631
  const lcp = group.hints.find((h) => h.lcp === true);
441
632
  const lcpPrefix = lcp !== undefined ? `LCP image (${(lcp.lcpViewports ?? []).map((v) => safeText(v)).join(", ")}): ` : "";
442
633
  return `${rank}. ${lcpPrefix}${where}${name} ${group.hints.length} image(s), ${group.pages.length} page(s) saves ${kb(group.saved)}/view${split}`;
@@ -468,7 +659,8 @@ export function renderGroup(group, rank) {
468
659
  }
469
660
  }
470
661
  const fitted = groupSizesOf(group);
471
- lines.push(` sizes: ${safeText(combinedSizes(group))}`);
662
+ const srcset = groupSrcsetOf(group);
663
+ lines.push(srcset !== undefined ? ` ${srcsetLineOf(srcset)}` : ` sizes: ${safeText(combinedSizes(group))}`);
472
664
  for (const page of group.pages) {
473
665
  const mine = group.hints.filter((h) => h.page === page);
474
666
  const current = [...new Set(mine.map((h) => h.sizes.current ?? "none"))].map((v) => JSON.stringify(safeText(v))).join(", ");
@@ -478,7 +670,11 @@ export function renderGroup(group, rank) {
478
670
  : [...new Set(mine.map((h) => h.sizes.proposed ?? "-"))].map((v) => JSON.stringify(safeText(v))).join(", ");
479
671
  const widths = mine.flatMap((h) => (h.rendered ? [h.rendered.min, h.rendered.max] : []));
480
672
  const rendered = widths.length > 0 ? `${Math.min(...widths)}-${Math.max(...widths)} px` : "not rendered";
481
- 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}`);
482
678
  }
483
679
  const targets = group.hints.flatMap((h) => (h.quality ? [h.quality] : []));
484
680
  if (targets.length > 0) {
@@ -493,6 +689,11 @@ export function renderGroup(group, rank) {
493
689
  const ids = group.hints.map((h) => h.id);
494
690
  const shown = ids.slice(0, 8).join(" ");
495
691
  lines.push(` ids ${shown}${ids.length > 8 ? ` (+${ids.length - 8} more; --flat lists each image)` : ""}`);
692
+ // An animated image's hint is its own (its target and bytes are not a sizes or quality change), so it is printed here.
693
+ for (const hint of group.hints) {
694
+ if (hint.animated !== undefined)
695
+ lines.push(` ${safeText(hint.id)}`, ...animatedLines(hint.animated, hint.src));
696
+ }
496
697
  return lines;
497
698
  }
498
699
  /** The text of a grouped selection: a count line, then each group. */
@@ -511,6 +712,450 @@ export function renderHints(hints, total, offset, page) {
511
712
  const body = hints.flatMap((hint, i) => renderHint(hint, offset + i + 1));
512
713
  return `${head}${body.join("\n")}\n`;
513
714
  }
715
+ // ---------------------------------------------------------------------------------------------------------------------
716
+ // Animated images and `<video>` (Task 8): the text of their hints and of the Video section of a page. Every estimated byte
717
+ // figure is marked "est."; a hint names the sample encodes once. Page-derived names and attributes are JSON strings.
718
+ // ---------------------------------------------------------------------------------------------------------------------
719
+ /** The note that marks a figure as an estimate from the 2-second sample encodes. A hint says it once. */
720
+ export const SAMPLE_NOTE = "(estimate from 2 s samples)";
721
+ /** The `type` of the two sources a video hint names: AV1 first, H.264 as the fallback. */
722
+ const AV1_TYPE = 'video/mp4; codecs=av01.0.08M.08';
723
+ const H264_TYPE = 'video/mp4; codecs=avc1.640028';
724
+ /** The H.264 fallback recipe an animated GIF's video hint gives. It is not encoded by imgtune (performance first). */
725
+ const H264_RECIPE = "libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart -an";
726
+ const FORMAT_LABEL = { gif: "GIF", webp: "WebP", apng: "APNG" };
727
+ /**
728
+ * The label of a candidate's bytes. An NVENC candidate's bytes are its GPU encode scaled to a CPU encode (÷1.4), so the label
729
+ * says so: performance before precision, but the number is never presented as a measured CPU encode.
730
+ */
731
+ export function estLabel(c) {
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;
734
+ }
735
+ /**
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
737
+ * the recipe's CRF is approximate. Empty for a CPU encode.
738
+ */
739
+ export function qualityApprox(c) {
740
+ return c.encoder === "nvenc" ? " (approx.)" : "";
741
+ }
742
+ /** The smallest candidate of one codec, or undefined. The first of equal bytes wins. */
743
+ export function smallestCandidate(candidates, codec) {
744
+ let best;
745
+ for (const c of candidates ?? []) {
746
+ if (c.codec === codec && (best === undefined || c.bytes < best.bytes))
747
+ best = c;
748
+ }
749
+ return best;
750
+ }
751
+ /**
752
+ * The quality evidence of an encode: VMAF with its bar, and the still-frame guard with its bar. A bar the candidate does not
753
+ * carry is said so, not guessed: the run's flag is not in the scan file.
754
+ */
755
+ export function evidenceOf(c) {
756
+ const bar = (value) => (value === undefined ? "bar not recorded" : `bar ${value}`);
757
+ return `VMAF ${c.vmaf.toFixed(1)} (${bar(c.vmafBar)}), still ${c.still.toFixed(1)} (${bar(c.stillBar)})`;
758
+ }
759
+ /** The codec as a hint names it: "av1", "h264", or the codec's own name. */
760
+ function codecName(codec) {
761
+ return codec === "av1-mp4" ? "av1" : codec === "h264-mp4" ? "h264" : codec;
762
+ }
763
+ /** The targets of an animated image's encode (see AnimatedTargets). The main one's bytes are the saving's basis. */
764
+ export function animatedTargetsOf(animated) {
765
+ const avif = smallestCandidate(animated.estimate, "animated-avif");
766
+ const webp = smallestCandidate(animated.estimate, "animated-webp");
767
+ const av1 = smallestCandidate(animated.estimate, "av1-mp4");
768
+ const h264 = smallestCandidate(animated.estimate, "h264-mp4");
769
+ const video = av1 !== undefined && (animated.target?.main === "video" || (avif === undefined && webp === undefined));
770
+ return { avif, webp, av1, h264, video, main: video ? av1 : (avif ?? webp) };
771
+ }
772
+ /** The bytes per view an animated image saves: its bytes per view less the main target's encode, floored at 0 (est.). */
773
+ export function animatedSavingOf(animated) {
774
+ const main = animatedTargetsOf(animated).main;
775
+ return main === undefined ? 0 : Math.max(0, animated.perViewBytes - main.bytes);
776
+ }
777
+ /**
778
+ * The fallback of an image-target hint, built only from the candidates that exist: the `<picture>` with an AVIF source (for
779
+ * AVIF, with or without WebP), a plain `src` swap for WebP alone (WebP animates everywhere it shows an image), and for a CSS
780
+ * background an `image-set()` with the existing types.
781
+ */
782
+ function imageFallbackOf(context, targets, src) {
783
+ if (context === "css-background") {
784
+ const types = [
785
+ targets.avif !== undefined ? 'url(<avif>) type("image/avif")' : undefined,
786
+ targets.webp !== undefined ? 'url(<webp>) type("image/webp")' : undefined,
787
+ ].filter((t) => t !== undefined);
788
+ return `fallback: image-set(${types.join(", ")})`;
789
+ }
790
+ if (targets.avif !== undefined) {
791
+ return `fallback: <picture><source type="image/avif" srcset="…"><img src=${JSON.stringify(src ?? "")}></picture>`;
792
+ }
793
+ return "plain src swap to the animated WebP (WebP animates in every browser that shows the image)";
794
+ }
795
+ /** Browser coverage of each option, as the hint states it (the caniuse data is not in the repo: these are the stated facts). */
796
+ const COVERAGE = {
797
+ webp: "animated WebP: all current browsers",
798
+ avif: "animated AVIF: Chrome 85+, Firefox 113+, Safari 16.4+",
799
+ av1: "AV1 video: Chrome, Firefox, Edge; Safari only on devices with AV1 hardware decode, so keep the H.264 fallback",
800
+ };
801
+ /**
802
+ * The exact encoder settings of a candidate, as an agent runs them: the encoder, its preset and CRF (or q), and the pixel
803
+ * format and container options that follow. An AV1 CRF is a CRF, never a q (see the quality label in animatedLines).
804
+ */
805
+ export function encoderRecipe(codec, c) {
806
+ const av1 = c.av1Encoder === "libaom-av1" ? `libaom-av1 -cpu-used 8 -row-mt 1 -crf ${c.quality} -b:v 0` : `libsvtav1 -preset 10 -crf ${c.quality}`;
807
+ switch (codec) {
808
+ case "av1-mp4":
809
+ return `${av1} -pix_fmt yuv420p -movflags +faststart`;
810
+ case "animated-avif":
811
+ return `${av1}, -f avif`;
812
+ case "animated-webp":
813
+ return `libwebp_anim -q:v ${c.quality} -loop 0`;
814
+ case "h264-mp4":
815
+ return `libx264 -preset veryfast -crf ${c.quality} -pix_fmt yuv420p -movflags +faststart -an`;
816
+ }
817
+ }
818
+ /**
819
+ * The line that says a target is capped at the file's own width: the device width needs more than the file has, so the
820
+ * target is the file's width and a wider one would add bytes, not detail. Undefined when the target is not capped.
821
+ */
822
+ export function sourceCapLine(need, sourceWidth, served) {
823
+ if (need === undefined || sourceWidth === undefined || !(need > sourceWidth) || served !== sourceWidth)
824
+ return undefined;
825
+ return ` source is ${sourceWidth}px wide; needed ${need}px: serve ${sourceWidth}px (upscaling adds bytes, not detail)`;
826
+ }
827
+ /**
828
+ * The lines of an animated image's hint. An image target (AVIF and/or WebP, with a fallback built from the candidates that
829
+ * exist) when the encode offers one; the `<video>` replacement with its AV1 source and the H.264 recipe when the target is
830
+ * video. With no encode, the bytes per view and the reason. `src` is the served file's URL, printed as a JSON string; `alt`
831
+ * is the image's alt text, which the `<video>` keeps as its accessible name.
832
+ */
833
+ export function animatedLines(animated, src, alt) {
834
+ const { info, perViewBytes } = animated;
835
+ const head = `animated ${FORMAT_LABEL[info.format]} ${(info.durationMs / 1000).toFixed(1)} s, ${info.frames} frames`;
836
+ const targets = animatedTargetsOf(animated);
837
+ const { avif, webp, av1, h264, main } = targets;
838
+ if (main === undefined) {
839
+ return [` ${head}: ${kb(perViewBytes)}/view; not encoded: ${safeText(reasonText(animated.notEncoded ?? "no passing quality"))}`];
840
+ }
841
+ const saves = animatedSavingOf(animated);
842
+ const offered = [
843
+ avif !== undefined ? `animated AVIF CRF ${avif.quality}${qualityApprox(avif)} at ${avif.width}px (${encoderRecipe("animated-avif", avif)}): ${kb(avif.bytes)} ${estLabel(avif)}` : undefined,
844
+ webp !== undefined ? `animated WebP q${webp.quality} at ${webp.width}px (${encoderRecipe("animated-webp", webp)}): ${kb(webp.bytes)} ${estLabel(webp)}` : undefined,
845
+ ].filter((part) => part !== undefined);
846
+ const coverage = [];
847
+ if (avif !== undefined)
848
+ coverage.push(COVERAGE.avif);
849
+ if (webp !== undefined)
850
+ coverage.push(COVERAGE.webp);
851
+ const cap = sourceCapLine(animated.need, animated.sourceWidth, main.width);
852
+ if (targets.video && av1 !== undefined) {
853
+ // An empty alt is a decorative image: the <video> is hidden from assistive technology, with no label. Otherwise the alt is
854
+ // kept as the accessible name (role="img", aria-label).
855
+ const decorative = alt !== undefined && alt.trim() === "";
856
+ const altPart = decorative
857
+ ? ' aria-hidden="true"'
858
+ : alt !== undefined
859
+ ? ` role="img" aria-label=${JSON.stringify(safeText(alt))}`
860
+ : "";
861
+ const lines = [` ${head}: replace with <video autoplay muted loop playsinline poster="<first frame>"${altPart}>`];
862
+ if (decorative)
863
+ lines.push(' the image is decorative (alt=""): the <video> is aria-hidden, with no label');
864
+ else if (alt !== undefined)
865
+ lines.push(" the image's alt text is kept as the <video>'s aria-label (role=\"img\"): the accessible name does not change");
866
+ lines.push(` <source src="…-${av1.width}.av1.mp4" type="${AV1_TYPE}"> AV1 MP4 (${encoderRecipe("av1-mp4", av1)}), CRF ${av1.quality}${qualityApprox(av1)}, ${av1.width}px, ${kb(av1.bytes)} ${estLabel(av1)}`);
867
+ lines.push(h264 !== undefined
868
+ ? ` <source src="…-${h264.width}.h264.mp4" type="${H264_TYPE}"> H.264 MP4 (${encoderRecipe("h264-mp4", h264)}), ${h264.width}px, ${kb(h264.bytes)} ${estLabel(h264)}`
869
+ : ` <source src="…-${av1.width}.h264.mp4" type="${H264_TYPE}"> H.264 fallback, not measured: ${H264_RECIPE} at ${av1.width}px wide`);
870
+ if (cap !== undefined)
871
+ lines.push(cap);
872
+ lines.push(` evidence: ${evidenceOf(av1)}`);
873
+ lines.push(` browsers: ${COVERAGE.av1}`);
874
+ if (offered.length > 0)
875
+ lines.push(` alternative: ${offered.join(", ")}`);
876
+ if (offered.length > 0)
877
+ lines.push(...coverage.map((line) => ` browsers: ${line}`));
878
+ lines.push(` saves ${kb(saves)}/view est. ${SAMPLE_NOTE}`);
879
+ return lines;
880
+ }
881
+ const fallback = imageFallbackOf(animated.context, targets, src);
882
+ const lines = [` ${head}: serve ${offered.join(", ")}; ${fallback}; saves ${kb(saves)}/view est. ${SAMPLE_NOTE}`];
883
+ if (cap !== undefined)
884
+ lines.push(cap);
885
+ lines.push(` evidence: ${evidenceOf(main)}`);
886
+ lines.push(...coverage.map((line) => ` browsers: ${line}`));
887
+ return lines;
888
+ }
889
+ /** The name a video records by: where it is in the page, a file or component, else its selector. */
890
+ export function videoWhere(video) {
891
+ if (video.locator.file !== undefined)
892
+ return `${video.locator.file}:${video.locator.line ?? 0}`;
893
+ return video.locator.component[0] ?? video.locator.selector;
894
+ }
895
+ /**
896
+ * The bytes a view and a play save, est. The lever steps are per play (the full file less the encode); the view's share is
897
+ * the same fraction of the bytes the page loads per view.
898
+ */
899
+ export function videoSavingOf(video) {
900
+ if (video.steps === undefined || video.estimate === undefined)
901
+ return { view: 0, play: 0 };
902
+ const { audio, resolution, codec, quality } = video.steps;
903
+ const play = audio + resolution + codec + quality;
904
+ const { perView, perPlay } = video.bytes;
905
+ return { view: perPlay > 0 ? (play * perView) / perPlay : 0, play };
906
+ }
907
+ /**
908
+ * One record per file of each page: a URL that two `<video>` elements of a page use is one file, fetched once per page view.
909
+ * Of the records sharing a URL, the one with the most bytes per view stands for it (the page's own elements keep their rows).
910
+ */
911
+ export function onePerFile(videos) {
912
+ const byFile = new Map();
913
+ for (const video of videos) {
914
+ const key = `${video.page}\n${video.src}`;
915
+ const current = byFile.get(key);
916
+ if (current === undefined || video.bytes.perView > current.bytes.perView)
917
+ byFile.set(key, video);
918
+ }
919
+ return [...byFile.values()];
920
+ }
921
+ /**
922
+ * The Video section's totals: bytes per view and per play over the files (each URL once per page, see onePerFile), and the
923
+ * saveable bytes (an estimate, marked when any video has steps). Undefined with no video.
924
+ */
925
+ export function videoTotalsLine(videos) {
926
+ if (videos.length === 0)
927
+ return undefined;
928
+ const files = onePerFile(videos);
929
+ const sum = (values) => values.reduce((total, v) => total + v, 0);
930
+ const savings = files.map(videoSavingOf);
931
+ const est = files.some((v) => v.steps !== undefined && v.estimate !== undefined) ? " 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);
989
+ }
990
+ /** A video's encode in one phrase: the encode at its width with its bytes, or why it was not encoded. */
991
+ export function videoStatusOf(video) {
992
+ const e = video.estimate;
993
+ if (e === undefined)
994
+ return `not encoded: ${safeText(reasonText(video.notEncoded ?? "no passing quality"))}`;
995
+ return `${codecName(e.codec)} CRF ${e.quality}${qualityApprox(e)}, ${e.width}px, ${kb(e.bytes)} ${estLabel(e)}`;
996
+ }
997
+ /** One video as a row of a page's Video section: its place (quoted by the caller), its bytes, and its encode or reason. */
998
+ export function videoRowOf(video, quote) {
999
+ return `${quote(videoWhere(video))}: ${kb(video.bytes.perView)}/view, ${kb(video.bytes.perPlay)}/play; ${videoStatusOf(video)}`;
1000
+ }
1001
+ /** What the bytes per view are, as a hint says it: the autoplay full file, the bytes seen in the scan, or none. */
1002
+ function viewBasisOf(video) {
1003
+ const b = video.bytes;
1004
+ if (b.perViewBasis === "full-file")
1005
+ return "autoplay: full file";
1006
+ if (b.perViewBasis === "none")
1007
+ return "not loaded before play";
1008
+ return b.browserDependent ? "preload=auto: browser-dependent, bytes seen in the scan" : "bytes seen in the scan";
1009
+ }
1010
+ /**
1011
+ * The bytes per view of a width plan (audit), est. (scaled): each viewport's bytes at its width are the encode's bytes times
1012
+ * (w / w_encoded)², the pixel ratio, and the per-view share of the file. They are summed over the viewports and divided by
1013
+ * their count, the same average the image savings use. Without an encode, "not encoded" and the reason.
1014
+ */
1015
+ function bytesAtPlan(video, plan) {
1016
+ const est = video.estimate;
1017
+ if (est === undefined || video.bytes.perPlay <= 0) {
1018
+ return `not encoded${video.notEncoded !== undefined ? `: ${safeText(reasonText(video.notEncoded))}` : ""}`;
1019
+ }
1020
+ if (plan.perViewport.length === 0)
1021
+ return "-";
1022
+ const ratio = video.bytes.perView / video.bytes.perPlay;
1023
+ const total = plan.perViewport.reduce((sum, v) => sum + est.bytes * (v.width / est.width) ** 2 * ratio, 0);
1024
+ return `${kb(total / plan.perViewport.length)}/view est. (scaled)`;
1025
+ }
1026
+ /** Audit: each viewport's need, and the bytes per view at one, two and three widths. No pick: the agent chooses. */
1027
+ function widthOptionLines(options) {
1028
+ const lines = [];
1029
+ const first = options[0];
1030
+ if (first !== undefined && first.perViewport.length > 0) {
1031
+ lines.push(` viewports: ${first.perViewport.map((v) => `${JSON.stringify(safeText(v.viewport))} needs ${v.need}px`).join(", ")}`);
1032
+ }
1033
+ lines.push(" widths → bytes/view (audit: no pick)");
1034
+ return lines;
1035
+ }
1036
+ /**
1037
+ * The line that says a video's served width is capped at its file's own: a viewport needs more than the file has, and the
1038
+ * plan serves the file's width. Undefined when no viewport is capped (or the file's width is not known).
1039
+ */
1040
+ export function videoSourceCapLine(video) {
1041
+ const source = video.probe?.width;
1042
+ const plan = video.widths;
1043
+ if (plan === undefined || source === undefined || !(source > 0))
1044
+ return undefined;
1045
+ // The plan's needs are already capped at the file's width, so the need is taken from the boxes: what the page shows.
1046
+ const need = Math.max(0, ...video.perViewport.filter((v) => v.boxWidth > 0).map((v) => Math.ceil(v.boxWidth * v.dpr)));
1047
+ if (!(need > source) || !plan.perViewport.some((row) => row.width === source))
1048
+ return undefined;
1049
+ return sourceCapLine(need, source, source);
1050
+ }
1051
+ /** Scan: the `<source>` list of the planned widths, AV1 then H.264 for each, with the encode's CRF and bytes at its width. */
1052
+ function targetLines(video, plan) {
1053
+ if (plan.containerDriven) {
1054
+ return plan.perViewport.map((row) => {
1055
+ const box = video.perViewport.find((v) => v.viewport === row.viewport);
1056
+ const boxText = box !== undefined ? `box ${box.boxWidth} px at ${box.dpr}x` : "box not read";
1057
+ return ` viewport ${JSON.stringify(safeText(row.viewport))}: ${boxText}, serve ${row.width}px`;
1058
+ });
1059
+ }
1060
+ const est = video.estimate;
1061
+ const lines = [];
1062
+ for (const source of plan.sources) {
1063
+ const media = source.media !== undefined ? `media="${source.media}" ` : "";
1064
+ for (const kind of ["av1", "h264"]) {
1065
+ const codec = kind === "av1" ? "av1-mp4" : "h264-mp4";
1066
+ const label = kind === "av1" ? "AV1" : "H.264";
1067
+ // Every source names the exact encoder settings an agent runs: the estimate's codec, preset and CRF. At the estimate's
1068
+ // own width that is measured; at another width the same settings apply, which is not measured there.
1069
+ const same = est !== undefined && est.codec === codec ? est : undefined;
1070
+ let at;
1071
+ if (same !== undefined && same.width === source.width) {
1072
+ at = ` ${label} MP4 (${encoderRecipe(codec, same)}), CRF ${same.quality}${qualityApprox(same)}, ${same.width}px, ${kb(same.bytes)} ${estLabel(same)}`;
1073
+ }
1074
+ else if (same !== undefined) {
1075
+ at = ` ${label} MP4 (${encoderRecipe(codec, same)}), CRF ${same.quality}${qualityApprox(same)}, ${source.width}px: same settings, not measured at this width`;
1076
+ }
1077
+ else if (kind === "h264") {
1078
+ at = ` H.264 MP4 (${H264_RECIPE}), ${source.width}px: not measured (no H.264 encode for this video)`;
1079
+ }
1080
+ else {
1081
+ at = ` AV1 MP4 (libsvtav1 -preset 10 -pix_fmt yuv420p -movflags +faststart), CRF not measured: no AV1 encode passed for this video`;
1082
+ }
1083
+ lines.push(` <source ${media}src="…-${source.width}.${kind}.mp4" type="${kind === "av1" ? AV1_TYPE : H264_TYPE}">${at}`);
1084
+ }
1085
+ }
1086
+ return lines;
1087
+ }
1088
+ /**
1089
+ * The lines of one video's hint: where and the bytes; the levers of the encode (audio, resolution, re-encode, one line each
1090
+ * with its bytes per play; never a quality line, as the encode's quality is the re-encode); the loading, codec and poster
1091
+ * levers; the target sources (or the widths table in audit); the evidence; and the saving. Without steps, the target and its
1092
+ * bytes only. Without an encode, the reason.
1093
+ */
1094
+ export function videoLines(video, rank = 1) {
1095
+ const lines = [`${rank}. ${safeText(video.id)} ${JSON.stringify(safeText(videoWhere(video)))} ${safeText(video.page)}`];
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
+ }
1104
+ const est = video.estimate;
1105
+ if (est !== undefined && video.steps !== undefined) {
1106
+ const s = video.steps;
1107
+ lines.push(` audio: drop the track (muted video): saves ${kb(s.audio)}/play est.`);
1108
+ lines.push(` resolution: serve ${est.width}px: saves ${kb(s.resolution)}/play est.`);
1109
+ lines.push(` re-encode (${codecName(est.codec)} CRF ${est.quality}): saves ${kb(s.codec)}/play est.`);
1110
+ }
1111
+ if (video.levers.loading !== undefined) {
1112
+ const loading = video.levers.loading;
1113
+ const attr = video.attributes.preload === "" ? "no preload attribute" : `preload=${JSON.stringify(safeText(video.attributes.preload))}`;
1114
+ const action = video.attributes.autoplay
1115
+ ? "autoplay fetches the file on load: lazy-load the video until it is in view"
1116
+ : loading.belowFold
1117
+ ? 'below the fold: set preload="none"'
1118
+ : 'set preload="metadata"';
1119
+ lines.push(` loading: ${attr} loads ${kb(loading.perViewBytes)}/view before play: ${action}`);
1120
+ }
1121
+ if (video.levers.codec !== undefined)
1122
+ lines.push(" codec: no AV1 source: serve AV1 first, H.264 fallback");
1123
+ if (video.levers.posterMissing === true)
1124
+ lines.push(' poster: none: add poster="<first frame>" (audited as an image)');
1125
+ for (const note of video.levers.info)
1126
+ lines.push(` info: ${safeText(note)}`);
1127
+ if (video.widthOptions !== undefined) {
1128
+ lines.push(...widthOptionLines(video.widthOptions));
1129
+ for (const plan of video.widthOptions) {
1130
+ lines.push(` ${plan.widths.length} width(s): ${plan.widths.map((w) => `${w}px`).join(", ")} → ${bytesAtPlan(video, plan)}`);
1131
+ }
1132
+ }
1133
+ else if (video.widths !== undefined) {
1134
+ lines.push(...targetLines(video, video.widths));
1135
+ const cap = videoSourceCapLine(video);
1136
+ if (cap !== undefined)
1137
+ lines.push(cap);
1138
+ }
1139
+ if (est !== undefined)
1140
+ lines.push(` evidence: ${evidenceOf(est)}`);
1141
+ else if (video.notEncoded !== undefined)
1142
+ lines.push(` not encoded: ${safeText(reasonText(video.notEncoded))}`);
1143
+ if (est !== undefined && video.steps !== undefined) {
1144
+ const s = videoSavingOf(video);
1145
+ lines.push(` saves ${kb(s.view)}/view est., ${kb(s.play)}/play est. ${SAMPLE_NOTE}`);
1146
+ }
1147
+ else if (est !== undefined) {
1148
+ lines.push(` ${SAMPLE_NOTE}`);
1149
+ }
1150
+ return lines;
1151
+ }
1152
+ /** The hints command's Video part: each video's hint, numbered. Empty with no video. */
1153
+ export function renderVideoHints(videos) {
1154
+ if (videos.length === 0)
1155
+ return "";
1156
+ const body = videos.flatMap((video, i) => videoLines(video, i + 1));
1157
+ return `${videos.length} video(s) on the page(s), self-hosted files:\n${body.join("\n")}\n`;
1158
+ }
514
1159
  /**
515
1160
  * The line that says where the sources of a file came from: the dev page it was located on, or, when hinted images still
516
1161
  * have no file:line, the command that locates them. Undefined when every hint has its source.
@@ -538,6 +1183,25 @@ function planJson(plan) {
538
1183
  estTotalBytes: Math.round(plan.total),
539
1184
  };
540
1185
  }
1186
+ /** The videos of the selected pages, for the hints command (none when a --component filter is given: videos have no sizes). */
1187
+ function videosOf(results, args) {
1188
+ if (args.component !== undefined)
1189
+ return [];
1190
+ const page = args.page?.toLowerCase();
1191
+ return results.flatMap((r) => r.videos ?? []).filter((v) => page === undefined || v.page.toLowerCase().includes(page));
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
+ }
541
1205
  /** `imgtune hints <scan.json> [<scan.json> ...] ...`: reads the pages' scan files, returns the text or JSON to print. */
542
1206
  export async function hintsCommand(argv, cwd) {
543
1207
  const args = parseHintsArgs(argv);
@@ -568,12 +1232,14 @@ export async function hintsCommand(argv, cwd) {
568
1232
  if (dropped > 0)
569
1233
  notes.push(`${dropped} sizes proposal(s) left out: no saving (their predicted outcome adds bytes or changes nothing)`);
570
1234
  const filtered = selectHints(all, { ...args, limit: Number.MAX_SAFE_INTEGER, offset: 0 });
1235
+ const videos = videosOf(results, args);
1236
+ const resourceRows = resourceRowsOf(results);
571
1237
  if (args.top !== undefined) {
572
1238
  const top = groupHints(filtered).slice(0, args.top);
573
1239
  if (args.format === "json") {
574
- 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 };
575
1241
  }
576
- return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}`, exitCode: 0 };
1242
+ return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}${renderResourceHints(results)}`, exitCode: 0 };
577
1243
  }
578
1244
  // JSON is not cut to the text default: it is all of it unless --limit is passed, and `truncated` says whether it is.
579
1245
  const jsonLimit = args.limitGiven ? args.limit : Number.MAX_SAFE_INTEGER;
@@ -602,14 +1268,17 @@ export async function hintsCommand(argv, cwd) {
602
1268
  saved: group.saved,
603
1269
  sizes: combinedSizes(group),
604
1270
  sizesFitted: groupSizesOf(group) ?? null,
1271
+ srcset: groupSrcsetOf(group) ?? null,
605
1272
  callers: callerMapOf(group) ?? null,
606
1273
  ids: group.hints.map((h) => h.id),
607
1274
  hints: group.hints,
608
1275
  })),
1276
+ ...(videos.length > 0 ? { videos } : {}),
1277
+ ...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
609
1278
  };
610
1279
  return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
611
1280
  }
612
- const text = renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page);
1281
+ const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
613
1282
  return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
614
1283
  }
615
1284
  const selected = selectHints(all, args.format === "json" ? { ...args, limit: jsonLimit } : args);
@@ -624,10 +1293,12 @@ export async function hintsCommand(argv, cwd) {
624
1293
  truncated: args.offset + selected.length < filtered.length,
625
1294
  qualities: planJson(plan),
626
1295
  hints: selected,
1296
+ ...(videos.length > 0 ? { videos } : {}),
1297
+ ...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
627
1298
  };
628
1299
  return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
629
1300
  }
630
- const text = renderHints(selected, filtered.length, args.offset, args.page);
1301
+ const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
631
1302
  return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
632
1303
  }
633
1304
  /** How many hints the PR comment lists. */
@@ -644,6 +1315,21 @@ export function commentHints(results, limit = COMMENT_HINTS, maxQualities) {
644
1315
  const top = selectHints(all, { limit, offset: 0 });
645
1316
  return { total: all.length, lines: top.map((hint, i) => `${i + 1}. ${commentLine(hint)}`) };
646
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
+ }
647
1333
  /**
648
1334
  * A page-derived value in the comment: capped at COMMENT_FIELD characters, then JSON-quoted the way the CLI text prints it
649
1335
  * (so an instruction inside a name stays a quoted string). The block is fenced, and GitHub does not decode entities inside
@@ -659,9 +1345,11 @@ function commentLine(hint) {
659
1345
  ? `${quoted(`${hint.file}:${hint.line ?? 0}`)}${locationNote(hint)}`
660
1346
  : quoted(hint.component[0] ?? hint.selector);
661
1347
  const component = hint.file !== undefined && hint.component[0] !== undefined ? ` (${quoted(hint.component[0])})` : "";
662
- const sizes = hint.width !== undefined
663
- ? `width ${hint.width}px`
664
- : `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 ?? "-")}`;
665
1353
  const parts = [
666
1354
  hint.lcp === true ? `LCP image (${(hint.lcpViewports ?? []).map((v) => quoted(v)).join(", ")})` : undefined,
667
1355
  `${savesText(hint)}, +${kb(hint.bytes.addedForSharpness)} sharpness`,