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
@@ -3,17 +3,24 @@
3
3
  // (SSIMULACRA2 90 by default, visually lossless; --min-score sets another). The lowest quality per format that meets the
4
4
  // bar is reported. With --width the file is also measured at the width it is needed at (width x dpr). Files are read,
5
5
  // never written, unless --write names a directory for optimised copies.
6
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
6
+ //
7
+ // Media (animated GIF, WebP and APNG, MP4, MOV and WebM) is read from local files only (media-file.ts): an animated file
8
+ // gets the candidates of the animated codecs, and `--reference <original>` checks a candidate against the original with
9
+ // the video bars (VMAF and the still-frame guard), exiting 0 on pass and 1 on fail.
10
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
7
12
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
8
13
  import sharp from "sharp";
9
14
  import { isLocalTarget } from "./address.js";
10
15
  import { encode, ENCODER_ID, loadReference } from "./encode.js";
11
16
  import { makeFetcher, MAX_IMAGE_BYTES } from "./fetch.js";
12
- import { gpuNote, parseGpuDevice, startGpuScorer } from "./gpu.js";
17
+ import { gpuNote, startGpuScorer, gpuSettingOf, gpuSettingOne } from "./gpu.js";
18
+ import { probeFfmpeg, withGpu } from "./ffmpeg.js";
13
19
  import { makeScorer } from "./metric.js";
14
20
  import { minPassingQuality } from "./search.js";
15
21
  import { cacheScope, CACHE_DIR, ResultCache } from "./result-cache.js";
16
- import { decodeRaw, pixelSize, safeText, SCAN_TOLERANCE, scorerOf } from "./remote.js";
22
+ import { compareReferenceFile, describeVideo, measureAnimated, mediaKindOf, renderAnimated, renderReference, renderVideo } from "./media-file.js";
23
+ import { DEFAULT_VIDEO_BAR, DEFAULT_VIDEO_STILL_BAR, decodeRaw, pixelSize, safeText, SCAN_TOLERANCE, scorerOf } from "./remote.js";
17
24
  import { IMGTUNE_VERSION } from "./version.js";
18
25
  const ALL_FORMATS = ["avif", "webp", "jpeg"];
19
26
  /** Most inputs (files, globs and URLs together) one run measures. */
@@ -24,12 +31,18 @@ const MAX_GLOB_MATCHES = 1000;
24
31
  const START_QUALITY = 90;
25
32
  /** The SSIMULACRA2 bar a file must meet against itself, when --min-score is not given: visually lossless. */
26
33
  export const DEFAULT_FILE_BAR = 90;
27
- const USAGE = "usage: imgtune image <file|url|glob> [...] [--width <css px>] [--dpr 1,2] [--formats avif,webp,jpeg] [--min-score <n>] [--write <dir>] [--format text|json] [--gpu] [--gpu-device <n>]";
34
+ const USAGE = "usage: imgtune image <file|url|glob> [...] [--width <css px>] [--dpr 1,2] [--formats avif,webp,jpeg] [--min-score <n>] [--write <dir>] [--format text|json|--json] [--gpu] [--gpu-device <n>] [--reference <original file>] [--video-bar <n>] [--video-still-bar <n>]";
28
35
  function positiveNumber(name, text) {
29
36
  if (!/^\d+(\.\d+)?$/.test(text) || Number(text) <= 0)
30
37
  throw new Error(`${name} needs a number above 0, not ${safeText(text)}`);
31
38
  return Number(text);
32
39
  }
40
+ /** A bar from 0 to 100 (a VMAF or still-guard bar). Throws with the message the CLI prints. */
41
+ function barNumber(name, text) {
42
+ if (!/^\d+(\.\d+)?$/.test(text) || Number(text) > 100)
43
+ throw new Error(`${name} needs a number from 0 to 100, not ${safeText(text)}`);
44
+ return Number(text);
45
+ }
33
46
  /** The arguments after `image`. Throws with the message the CLI prints. */
34
47
  export function parseImageArgs(argv) {
35
48
  const args = { inputs: [], dprs: [1, 2], formats: [...ALL_FORMATS], format: "text" };
@@ -40,13 +53,21 @@ export function parseImageArgs(argv) {
40
53
  continue;
41
54
  }
42
55
  if (arg === "--gpu") {
43
- args.gpu ??= { device: 0 };
56
+ args.gpu ??= gpuSettingOne(0);
44
57
  continue;
45
58
  }
46
59
  if (arg === "--no-cache") {
47
60
  args.noCache = true;
48
61
  continue;
49
62
  }
63
+ if (arg === "--gpu-encode") {
64
+ args.gpuEncode = true;
65
+ continue;
66
+ }
67
+ if (arg === "--json") {
68
+ args.format = "json";
69
+ continue;
70
+ }
50
71
  const value = argv[i + 1];
51
72
  if (value === undefined || value.startsWith("--"))
52
73
  throw new Error(`${arg} needs a value (${USAGE})`);
@@ -73,9 +94,18 @@ export function parseImageArgs(argv) {
73
94
  args.minScore = score;
74
95
  }
75
96
  else if (arg === "--gpu-device")
76
- args.gpu = { device: parseGpuDevice(value) };
97
+ args.gpu = gpuSettingOf(value);
77
98
  else if (arg === "--write")
78
99
  args.write = value;
100
+ else if (arg === "--reference") {
101
+ if (/^https?:\/\//i.test(value))
102
+ throw new Error(`--reference is a local file: media is not read from a URL here (local files only, not ${safeText(value)})`);
103
+ args.reference = value;
104
+ }
105
+ else if (arg === "--video-bar")
106
+ args.videoBar = barNumber(arg, value);
107
+ else if (arg === "--video-still-bar")
108
+ args.videoStillBar = barNumber(arg, value);
79
109
  else if (arg === "--format") {
80
110
  if (value !== "text" && value !== "json")
81
111
  throw new Error(`--format is text or json, not ${safeText(value)}`);
@@ -198,6 +228,8 @@ async function readInput(input, cwd, userAgent) {
198
228
  const body = await fetcher.get(input.url, { maxBytes: MAX_IMAGE_BYTES, allowPrivate: isLocalTarget(input.url) });
199
229
  if (body === undefined)
200
230
  throw new Error(`${safeText(input.url)} was not fetched (blocked, failed, or larger than ${MAX_IMAGE_BYTES / (1024 * 1024)} MB)`);
231
+ if (mediaKindOf(body) !== undefined)
232
+ throw new Error(`${safeText(input.url)} is a video or animated file: media is local files only (download it and pass the file)`);
201
233
  return body;
202
234
  }
203
235
  const path = resolve(cwd, input.path);
@@ -287,8 +319,9 @@ export async function measureFile(input, buffer, args, score, cached) {
287
319
  const placeholder = { bar, sizes: [] };
288
320
  if (!info)
289
321
  return { ...base, width: { source: args.width ? "user" : "file", css: args.width }, ...placeholder, error: "not a decodable image" };
290
- if (info.format === "svg" || info.format === "gif") {
291
- return { ...base, width: { source: args.width ? "user" : "file", css: args.width }, ...placeholder, error: `${info.format} is not measured (not a raster format the encoders take)` };
322
+ // A static GIF is a raster image like a PNG (sharp reads its first frame). An animated one never reaches here: it is media.
323
+ if (info.format === "svg") {
324
+ return { ...base, width: { source: args.width ? "user" : "file", css: args.width }, ...placeholder, error: "svg is not measured (not a raster format the encoders take)" };
292
325
  }
293
326
  const targets = args.width === undefined ? [{ px: info.width }] : args.dprs.map((dpr) => ({ px: Math.min(Math.ceil(args.width * dpr), info.width), dpr }));
294
327
  const sizes = [];
@@ -378,7 +411,7 @@ export async function writeCopies(results, inputs, cwd, dir, buffers) {
378
411
  return notes;
379
412
  }
380
413
  /** `imgtune image`: measures each input and returns the text or JSON to print. */
381
- export async function imageCommand(argv, cwd, userAgent = `imgtune/${IMGTUNE_VERSION}`, log = (message) => process.stderr.write(`imgtune: ${message}\n`)) {
414
+ export async function imageCommand(argv, cwd, userAgent = `imgtune/${IMGTUNE_VERSION}`, log = (message) => process.stderr.write(`imgtune: ${message}\n`), signal) {
382
415
  const args = parseImageArgs(argv);
383
416
  const gpu = args.gpu !== undefined ? await startGpuScorer(args.gpu.device, log) : undefined;
384
417
  const store = args.noCache === true ? undefined : new ResultCache(join(cwd, CACHE_DIR));
@@ -387,47 +420,134 @@ export async function imageCommand(argv, cwd, userAgent = `imgtune/${IMGTUNE_VER
387
420
  ? undefined
388
421
  : { store, scorerId: gpu !== undefined ? `gpu:${gpu.deviceName}` : "cpu", cpuScored: gpu ? () => gpu.cpuScores : undefined };
389
422
  try {
390
- return await measureInputs(args, cwd, userAgent, gpu, cache);
423
+ return await measureInputs(args, cwd, userAgent, gpu, cache, signal);
391
424
  }
392
425
  finally {
393
426
  gpu?.close();
394
427
  }
395
428
  }
396
- async function measureInputs(args, cwd, userAgent, gpu, cache) {
429
+ async function measureInputs(args, cwd, userAgent, gpu, cache, signal) {
397
430
  const inputs = await resolveInputs(args.inputs, cwd);
398
431
  const score = makeScorer("ssimulacra2", { projectDir: cwd, gpu });
432
+ const scorer = gpu !== undefined ? scorerOf(gpu, gpu.fallbackScores) : { name: "cpu" };
433
+ if (args.reference !== undefined)
434
+ return compareMode(args, inputs, cwd, score, scorer, signal);
399
435
  const results = [];
436
+ const media = [];
400
437
  const buffers = new Map();
401
- for (const input of inputs) {
402
- let buffer;
403
- try {
404
- buffer = await readInput(input, cwd, userAgent);
405
- }
406
- catch (err) {
407
- results.push({
408
- label: input.label,
409
- evidence: "file-mode",
410
- imgtuneVersion: IMGTUNE_VERSION,
411
- current: { width: 0, height: 0, bytes: 0 },
412
- width: { source: args.width ? "user" : "file", css: args.width },
413
- bar: barOf(args.minScore),
414
- sizes: [],
415
- error: err.message,
416
- });
417
- continue;
438
+ // Scratch for the media encodes of this run, removed when the run ends.
439
+ let scratch;
440
+ const mediaContext = async () => {
441
+ scratch ??= await mkdtemp(join(tmpdir(), "imgtune-media-file-"));
442
+ // The media is encoded on the CPU unless --gpu-encode asks for the GPU; its encoders are proven on that GPU first.
443
+ const device = args.gpuEncode === true ? (args.gpu?.device ?? 0) : undefined;
444
+ const caps = await withGpu(await probeFfmpeg(), device);
445
+ return {
446
+ caps,
447
+ gpu: caps.av1Nvenc || caps.h264Nvenc ? device : undefined,
448
+ score,
449
+ dir: scratch,
450
+ videoBar: args.videoBar ?? DEFAULT_VIDEO_BAR,
451
+ videoStillBar: args.videoStillBar ?? DEFAULT_VIDEO_STILL_BAR,
452
+ signal,
453
+ };
454
+ };
455
+ try {
456
+ for (const input of inputs) {
457
+ let buffer;
458
+ try {
459
+ buffer = await readInput(input, cwd, userAgent);
460
+ }
461
+ catch (err) {
462
+ results.push({
463
+ label: input.label,
464
+ evidence: "file-mode",
465
+ imgtuneVersion: IMGTUNE_VERSION,
466
+ current: { width: 0, height: 0, bytes: 0 },
467
+ width: { source: args.width ? "user" : "file", css: args.width },
468
+ bar: barOf(args.minScore),
469
+ sizes: [],
470
+ error: err.message,
471
+ });
472
+ continue;
473
+ }
474
+ buffers.set(input.label, buffer);
475
+ const kind = mediaKindOf(buffer);
476
+ if (kind !== undefined && input.path !== undefined) {
477
+ const file = { label: input.label, path: resolve(cwd, input.path), buffer };
478
+ const ctx = await mediaContext();
479
+ const dir = join(ctx.dir, `file-${media.length}`);
480
+ media.push(kind === "animated" ? await measureAnimated(file, args.width, ctx, dir) : await describeVideo(file, ctx, dir));
481
+ continue;
482
+ }
483
+ results.push(await measureFile(input, buffer, args, score, cache && { ...cache, original: buffer }));
418
484
  }
419
- buffers.set(input.label, buffer);
420
- results.push(await measureFile(input, buffer, args, score, cache && { ...cache, original: buffer }));
485
+ }
486
+ finally {
487
+ if (scratch !== undefined)
488
+ await rm(scratch, { recursive: true, force: true });
421
489
  }
422
490
  const notes = args.write !== undefined ? await writeCopies(results, inputs, cwd, args.write, buffers) : [];
423
- const failed = results.filter((r) => r.error !== undefined).length;
424
- const scorer = gpu !== undefined ? scorerOf(gpu, gpu.fallbackScores) : { name: "cpu" };
491
+ const failed = results.filter((r) => r.error !== undefined).length + media.filter((m) => m.error !== undefined).length;
425
492
  if (args.format === "json") {
426
- const body = { evidence: "file-mode", scorer, files: results, copies: notes };
493
+ const body = { evidence: "file-mode", scorer, files: results, media, copies: notes };
427
494
  return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: failed > 0 ? 1 : 0 };
428
495
  }
429
496
  const head = gpu !== undefined ? `${gpuNote(gpu.device, gpu.deviceName, gpu.fallbackScores)}\n\n` : "";
430
497
  const text = results.map(renderFile).join("\n");
498
+ const mediaText = media.map((m) => (m.kind === "animated" ? renderAnimated(m) : renderVideo(m))).join("\n");
431
499
  const tail = notes.length > 0 ? `copies:\n${notes.map((n) => ` ${n}`).join("\n")}\n` : "";
432
- return { output: `${head}${text}${tail}`, exitCode: failed > 0 ? 1 : 0 };
500
+ return { output: `${head}${text}${mediaText}${tail}`, exitCode: failed > 0 ? 1 : 0 };
501
+ }
502
+ /**
503
+ * `--reference <original>`: one candidate (a local media file) compared with the original. The verdict is the exit code:
504
+ * 0 on pass, 1 on fail or on any error. Input that cannot be compared is an error, not a fail verdict on the file.
505
+ */
506
+ async function compareMode(args, inputs, cwd, score, scorer, signal) {
507
+ const reference = args.reference;
508
+ const fail = (message) => {
509
+ if (args.format === "json")
510
+ return { output: `${JSON.stringify({ evidence: "media-reference", scorer, error: message }, null, 2)}\n`, exitCode: 1 };
511
+ return { output: `${safeText(message)}\n`, exitCode: 1 };
512
+ };
513
+ if (inputs.length !== 1)
514
+ return fail(`--reference compares one file: give one candidate (${inputs.length} match)`);
515
+ const [candidate] = inputs;
516
+ if (candidate.url !== undefined)
517
+ return fail("--reference takes local files only: the candidate is a URL, and media is not read from a URL here");
518
+ const refPath = resolve(cwd, reference);
519
+ const refInfo = await stat(refPath).catch(() => undefined);
520
+ if (!refInfo?.isFile())
521
+ return fail(`--reference ${safeText(reference)} is not a file`);
522
+ if (refInfo.size > MAX_IMAGE_BYTES)
523
+ return fail(`--reference ${safeText(reference)} is larger than ${MAX_IMAGE_BYTES / (1024 * 1024)} MB`);
524
+ const refBuffer = await readFile(refPath);
525
+ if (mediaKindOf(refBuffer) === undefined)
526
+ return fail(`--reference ${safeText(reference)} is not a video or animated file`);
527
+ const candBuffer = await readInput(candidate, cwd, "");
528
+ if (mediaKindOf(candBuffer) === undefined)
529
+ return fail(`${safeText(candidate.label)} is not a video or animated file: --reference compares media`);
530
+ const caps = await probeFfmpeg();
531
+ if (!caps.ffmpeg)
532
+ return fail("ffmpeg is not on PATH: --reference needs it (ffmpeg enables the full hints)");
533
+ const dir = await mkdtemp(join(tmpdir(), "imgtune-compare-"));
534
+ let result;
535
+ try {
536
+ const ctx = {
537
+ caps,
538
+ score,
539
+ dir,
540
+ videoBar: args.videoBar ?? DEFAULT_VIDEO_BAR,
541
+ videoStillBar: args.videoStillBar ?? DEFAULT_VIDEO_STILL_BAR,
542
+ signal,
543
+ };
544
+ result = await compareReferenceFile({ label: candidate.label, path: resolve(cwd, candidate.path), buffer: candBuffer }, { label: relative(cwd, refPath) || reference, path: refPath, buffer: refBuffer }, args.width, ctx, dir);
545
+ }
546
+ finally {
547
+ await rm(dir, { recursive: true, force: true });
548
+ }
549
+ const exitCode = result.error === undefined && result.pass ? 0 : 1;
550
+ if (args.format === "json")
551
+ return { output: `${JSON.stringify(result, null, 2)}\n`, exitCode };
552
+ return { output: renderReference(result), exitCode };
433
553
  }
@@ -0,0 +1,9 @@
1
+ // sharp over a downloaded file, with the pixel limit every scan applies. Kept apart from remote.ts so the image helpers
2
+ // (vector-art.ts, the media code) can open a file without importing the scan.
3
+ import sharp from "sharp";
4
+ /** The most input pixels sharp accepts for a downloaded file. */
5
+ export const MAX_INPUT_PIXELS = 100_000_000;
6
+ /** sharp over a downloaded file, refusing inputs above `limit` pixels. */
7
+ export function openImage(buffer, limit = MAX_INPUT_PIXELS) {
8
+ return sharp(buffer, { limitInputPixels: limit });
9
+ }
package/dist/images.js CHANGED
@@ -4,9 +4,10 @@
4
4
  import { sha1 } from "./atomic.js";
5
5
  import { deriveSizesFromLayout, sameStructure, slotAt, } from "./layout.js";
6
6
  import { COMPONENT_NAME, loaderName, sanitizeText, urlPattern } from "./locate.js";
7
- import { undersizedProposal } from "./candidates.js";
7
+ import { srcsetWidthsOf, undersizedProposal } from "./candidates.js";
8
8
  import { sizesBranchAt, sizesSlotAt } from "./sizes.js";
9
9
  import { parseSrcset } from "./srcset-parse.js";
10
+ import { animatedTarget } from "./media-model.js";
10
11
  /** The first path segment of a harness route: a page the agent adds to measure a component in isolation. */
11
12
  export const HARNESS_SEGMENT = "__imgtune";
12
13
  /**
@@ -43,6 +44,77 @@ export function searchHintsOf(locator) {
43
44
  hints.urlPattern = locator.urlPattern;
44
45
  return Object.keys(hints).length > 0 ? hints : undefined;
45
46
  }
47
+ /**
48
+ * Whether a served format takes the lossless rules (a local resize and the AVIF proposal): a jpeg or png, or a static GIF
49
+ * (a palette file, judged as a PNG). An animated GIF does not: its saving is an encode.
50
+ */
51
+ export function losslessServed(format, animated) {
52
+ return format === "jpeg" || format === "png" || (format === "gif" && !animated);
53
+ }
54
+ /** The hidden fix of an element from its entries (one per viewport it was measured at), or undefined when it is shown on load everywhere. */
55
+ export function hiddenFixCore(entries, viewportCount) {
56
+ const hidden = entries.filter((e) => e.hidden !== undefined);
57
+ if (hidden.length === 0)
58
+ return undefined;
59
+ const reasons = [...new Set(hidden.map((e) => e.hidden).filter((r) => r !== undefined))];
60
+ const inPanel = hidden.some((e) => e.inPanel);
61
+ const hover = hidden.some((e) => e.hover);
62
+ const unstable = hidden.some((e) => e.unstable === true);
63
+ const shownIn = entries.filter((e) => e.hidden === undefined).map((e) => e.viewport.name);
64
+ const alreadyLazy = hidden.some((e) => e.lazy === true);
65
+ const onlyClipped = reasons.every((r) => r === "clipped");
66
+ // A responsive twin: shown on load somewhere, and hidden by a rule that is not a carousel's clipping.
67
+ const action = shownIn.length > 0 && !onlyClipped ? "responsive" : reasons.includes("clipped") || inPanel || hover ? "lazy" : "never-shown-on-load";
68
+ return {
69
+ reasons,
70
+ inPanel,
71
+ ...(hover ? { hover: true } : {}),
72
+ ...(unstable ? { unstable: "unknown" } : {}),
73
+ viewports: hidden.map((e) => e.viewport.name),
74
+ of: viewportCount,
75
+ widths: Object.fromEntries(entries.map((e) => [e.viewport.name, e.viewport.width])),
76
+ shownIn,
77
+ action,
78
+ ...(alreadyLazy ? { alreadyLazy: true } : {}),
79
+ perViewBytes: hidden.reduce((sum, e) => sum + e.bytes, 0) / viewportCount,
80
+ };
81
+ }
82
+ /** The hidden fix of an image element from its instances (one per viewport). */
83
+ export function hiddenFixOf(members, viewportCount) {
84
+ return hiddenFixCore(members.map((m) => ({
85
+ viewport: { name: m.viewport.name, width: m.viewport.width },
86
+ bytes: m.bytes,
87
+ hidden: m.kind === "hidden" ? m.img.hidden : undefined,
88
+ inPanel: m.img.inPanel === true,
89
+ hover: m.img.hover === true,
90
+ lazy: m.img.lazy === true,
91
+ unstable: m.img.unstable === "unknown",
92
+ })), viewportCount);
93
+ }
94
+ /**
95
+ * The page's loaded-but-not-shown totals, counted over the records (one record per element or background layer, so the
96
+ * count and the cases are the same unit). Undefined when no record is hidden.
97
+ */
98
+ export function hiddenTotalsOf(records, viewportCount) {
99
+ const hidden = records.flatMap((r) => (r.hidden !== undefined ? [r] : []));
100
+ if (hidden.length === 0)
101
+ return undefined;
102
+ const cases = { responsive: 0, lazy: 0, "never-shown-on-load": 0 };
103
+ for (const r of hidden)
104
+ cases[r.hidden.action]++;
105
+ // A file several records share (background layers of one image, or a repeated element) is one file: its bytes count once,
106
+ // at the largest of its records. A record with no URL is its own file.
107
+ const perFile = new Map();
108
+ hidden.forEach((r, index) => {
109
+ const key = r.current.src ?? `#${index}`;
110
+ perFile.set(key, Math.max(perFile.get(key) ?? 0, r.hidden.perViewBytes));
111
+ });
112
+ return {
113
+ images: hidden.length,
114
+ bytesPerView: [...perFile.values()].reduce((sum, bytes) => sum + bytes, 0),
115
+ cases,
116
+ };
117
+ }
46
118
  const round1 = (value) => Math.round(value * 10) / 10;
47
119
  const round2 = (value) => Math.round(value * 100) / 100;
48
120
  /**
@@ -110,7 +182,7 @@ function tunableOf(member, pageUrl, loader) {
110
182
  /** The measured viewports of a group as the proposal check reads them. */
111
183
  function measuredPoints(members) {
112
184
  return members
113
- .filter((m) => m.renderedWidth >= 1)
185
+ .filter((m) => m.renderedWidth >= 1 && m.kind !== "hidden")
114
186
  .map((m) => ({ viewport: m.viewport.name, viewportWidth: m.viewport.width, dpr: m.viewport.dpr, renderedWidth: m.renderedWidth, srcset: m.img.srcset }));
115
187
  }
116
188
  function sizesOf(measured) {
@@ -125,6 +197,17 @@ function sizesOf(measured) {
125
197
  export function hasFix(record) {
126
198
  return record.fix.bytes.saved > 0 || (record.fix.sizes !== undefined && record.fix.sizes.value !== record.current.sizes);
127
199
  }
200
+ /**
201
+ * A plain image with no srcset at all, from a same-origin file or a CDN host no adapter knows: its saving is a resize, and
202
+ * its fix is variant files with a srcset (see srcsetWidthsOf). A known loader (next/image, a named CDN adapter) keeps its own
203
+ * advice, and so does a page with Next.js detected.
204
+ */
205
+ export function isPlainNoSrcset(kind, srcset, loader, framework) {
206
+ if (kind !== "plain" || framework || srcset.trim() !== "")
207
+ return false;
208
+ // "generic" is the adapter name of an origin no adapter matched (Instance.loader); the locator names it plain-img or cdn:<host>.
209
+ return loader === "plain-img" || loader === "unknown" || loader === "generic" || loader.startsWith("cdn:");
210
+ }
128
211
  /** Builds the image records of one page from its analysed instances. */
129
212
  export function buildImageRecords(input) {
130
213
  const { pageUrl, observations, instances, refName, resolved } = input;
@@ -184,6 +267,8 @@ export function buildImageRecords(input) {
184
267
  loaded: inst.url !== "" && obs.responses.get(inst.url)?.buffer !== undefined,
185
268
  lcp: obs.lcp?.kind === "image" && obs.lcp.index === inst.element,
186
269
  };
270
+ if (inst.kind === "hidden" && inst.img.hidden !== undefined)
271
+ entry.hidden = inst.img.hidden;
187
272
  if (inst.pixelWidth > 0)
188
273
  entry.servedPx = inst.pixelWidth;
189
274
  if (inst.pixelWidth > 0 && inst.needed > 0)
@@ -211,7 +296,7 @@ export function buildImageRecords(input) {
211
296
  };
212
297
  }
213
298
  const measured = members
214
- .filter((m) => m.renderedWidth >= 1)
299
+ .filter((m) => m.renderedWidth >= 1 && m.kind !== "hidden")
215
300
  .map((m) => ({
216
301
  viewportWidth: m.viewport.width,
217
302
  renderedWidth: m.renderedWidth,
@@ -228,23 +313,55 @@ export function buildImageRecords(input) {
228
313
  const keepSizes = sizes !== undefined && left === undefined;
229
314
  const savedEff = keepSizes ? saved : saved - savedSizes;
230
315
  const savedSizesEff = keepSizes ? savedSizes : 0;
316
+ // A plain image with no srcset: the sizes attribute does nothing without one, so no sizes proposal; its saving is the
317
+ // resize (est.), not a re-encode.
318
+ const plainNoSrcset = isPlainNoSrcset(member.kind, member.img.srcset, locator.loader, input.nextjs === true);
231
319
  const served = member.format;
232
- const best = served === "jpeg" || served === "png" ? (saved > 0 ? "avif" : served) : served;
233
- const fix = { bytes: { saved: savedEff, addedForSharpness, sizes: savedSizesEff, quality: savedEff - savedSizesEff } };
320
+ const animatedMember = members.find((m) => m.animated !== undefined);
321
+ const lossless = losslessServed(served, animatedMember !== undefined);
322
+ const best = lossless ? (saved > 0 ? "avif" : served) : served;
323
+ // A hidden fix first: an element never shown on load gets no animated encode either (its bytes are not a saving).
324
+ const hiddenFix = hiddenFixOf(members, viewportCount);
325
+ const fix = {
326
+ bytes: plainNoSrcset
327
+ ? { saved: savedEff, addedForSharpness, sizes: 0, resize: savedEff, quality: 0 }
328
+ : { saved: savedEff, addedForSharpness, sizes: savedSizesEff, quality: savedEff - savedSizesEff },
329
+ };
234
330
  if (member.cdnQuality !== undefined)
235
331
  fix.cdnQuality = { ...member.cdnQuality };
236
- if (member.formatAuto === true && (served === "jpeg" || served === "png"))
332
+ if (member.formatAuto === true && lossless)
237
333
  fix.formatHandled = true;
334
+ if (animatedMember?.animated !== undefined && hiddenFix?.shownIn.length !== 0) {
335
+ fix.animated = {
336
+ info: animatedMember.animated,
337
+ context: "img",
338
+ target: animatedTarget("img", undefined),
339
+ perViewBytes: members.reduce((sum, m) => sum + m.bytes, 0) / viewportCount,
340
+ };
341
+ }
238
342
  if (sizes && left !== undefined) {
239
343
  fix.noSaving = `sizes proposal left out: ${left}`;
240
344
  notes.push(`sizes proposal left out: ${left}`);
241
345
  }
242
- if (keepSizes) {
346
+ if (plainNoSrcset && savedEff > 0) {
347
+ const naturalWidth = member.pixelWidth > 0 ? member.pixelWidth : undefined;
348
+ const needs = members.map((m) => m.needed);
349
+ const widths = srcsetWidthsOf(needs, naturalWidth);
350
+ if (widths.length > 0) {
351
+ fix.srcset = { widths, largest: Math.max(...needs), ...(keepSizes && sizes !== undefined ? { sizes: sizes.value } : {}) };
352
+ notes.push(`srcset: no srcset today; variants at ${widths.join("w, ")}w (the saving is a resize)`);
353
+ }
354
+ }
355
+ else if (keepSizes && !plainNoSrcset) {
243
356
  fix.sizes = { value: sizes.value, basis: sizes.basis, rung, branches: sizes.branches };
244
357
  const sized = sizes.branches.map((b) => b.basis);
245
358
  const formulas = sized.filter((b) => b === "layout-formula").length;
246
359
  notes.push(`sizes: ${sizes.basis} (${formulas} of ${sized.length} media branch(es) from the layout formula, checked at ${measured.length} of ${viewportCount} viewports)`);
247
360
  }
361
+ const flatMember = members.find((m) => m.flatness?.flat === true);
362
+ // Never on a hidden element: the vector check and the hidden fix do not stack on one record.
363
+ if (flatMember?.flatness !== undefined && hiddenFix === undefined)
364
+ fix.vector = { flatness: { ...flatMember.flatness } };
248
365
  if (member.kind === "plain")
249
366
  fix.width = member.needed;
250
367
  if (best !== undefined)
@@ -252,6 +369,7 @@ export function buildImageRecords(input) {
252
369
  const record = {
253
370
  id: sha1(`${pageUrl}\n${key}`).slice(0, 12),
254
371
  page: pageUrl,
372
+ ...(member.img.posterOf !== undefined ? { poster: true } : {}),
255
373
  locator,
256
374
  tunable: tunableOf(member, pageUrl, locator.loader),
257
375
  current: {
@@ -286,6 +404,12 @@ export function buildImageRecords(input) {
286
404
  notes.push(`quality handled by ${locator.loader} (q_auto): its est. quality saving is not counted`);
287
405
  if (fix.formatHandled === true)
288
406
  notes.push(`format handled by ${locator.loader} (f_auto): no format change proposed`);
407
+ if (member.img.posterOf !== undefined)
408
+ notes.push(`poster of video ${sanitizeText(member.img.posterOf, 120)} (sized to its box)`);
409
+ if (fix.animated !== undefined) {
410
+ const { frames, durationMs, loop } = fix.animated.info;
411
+ notes.push(`animated ${fix.animated.info.format}: ${frames} frame(s), ${Math.round(durationMs / 100) / 10} s per loop, loops ${loop === 0 ? "forever" : loop}`);
412
+ }
289
413
  if (layoutChain?.truncated)
290
414
  notes.push("layout chain cut at the depth cap");
291
415
  if (layoutChain && layoutChain.skipped.length > 0)
@@ -296,6 +420,15 @@ export function buildImageRecords(input) {
296
420
  continue;
297
421
  own.set(m.format, Math.max(own.get(m.format) ?? -Infinity, m.qualityInfo.target));
298
422
  }
423
+ // A loaded-but-not-shown element has no vector claim (the vector check and the hidden fix never stack), and a copy that
424
+ // is never shown on load has no sizes claim either: its bytes are the hidden fix's. A responsive twin keeps the sizes of
425
+ // the viewports that show it (see the hidden-images tests).
426
+ if (hiddenFix !== undefined) {
427
+ record.hidden = hiddenFix;
428
+ delete record.fix.vector;
429
+ if (hiddenFix.shownIn.length === 0)
430
+ delete record.fix.sizes;
431
+ }
299
432
  targets.set(record, own);
300
433
  records.push(record);
301
434
  }
@@ -319,7 +452,8 @@ export function buildImageRecords(input) {
319
452
  for (const [format, target] of entry.max)
320
453
  perFormat[format] = target;
321
454
  for (const record of entry.records) {
322
- if (record.current.quality === undefined)
455
+ // An image never shown has no quality claim: its file is not on screen at any viewport.
456
+ if (record.current.quality === undefined || record.hidden?.shownIn.length === 0)
323
457
  continue;
324
458
  record.fix.quality = {
325
459
  perFormat,
package/dist/layout.js CHANGED
@@ -1,7 +1,4 @@
1
- // Layout chains and the CSS `sizes` value derived from them. Pure: no DOM, no network. The browser side (remote.ts) records
2
- // a chain per image and viewport; this module walks the chain down from the viewport to the image and states the image's
3
- // width as a linear function of the viewport width (a * vw + b). The function is checked against the widths the browser
4
- // rendered, branch by branch, and printed as CSS.
1
+ import { offsetPx, wholePx } from "./px.js";
5
2
  /** Rounding of the coefficients: the printed value is the value the check uses. */
6
3
  const round = (value, digits) => {
7
4
  const scale = 10 ** digits;
@@ -95,16 +92,19 @@ export function formatLinear(l) {
95
92
  const vwPercent = round(l.a * 100, 2);
96
93
  const px = round(l.b, 2);
97
94
  if (vwPercent === 0)
98
- return `${Math.max(0, px)}px`;
95
+ return `${Math.max(0, wholePx(px))}px`;
99
96
  if (px === 0)
100
97
  return `${vwPercent}vw`;
101
- return `calc(${vwPercent}vw ${px < 0 ? "-" : "+"} ${Math.abs(px)}px)`;
98
+ const offset = offsetPx(px);
99
+ if (offset === 0)
100
+ return `${vwPercent}vw`;
101
+ return `calc(${vwPercent}vw ${offset < 0 ? "-" : "+"} ${Math.abs(offset)}px)`;
102
102
  }
103
103
  /** A fitted line held outside the range it was measured in: the viewport is clamped to [lo, hi] (CSS clamp()). */
104
104
  export function formatHeldFit(l, lo, hi) {
105
105
  if (Math.abs(l.a) < 1e-9)
106
106
  return formatLinear({ a: 0, b: l.b });
107
- const b = round(l.b, 2);
107
+ const b = offsetPx(round(l.b, 2));
108
108
  return `calc(${round(l.a, 4)} * clamp(${Math.round(lo)}px, 100vw, ${Math.round(hi)}px) ${b < 0 ? "-" : "+"} ${Math.abs(b)}px)`;
109
109
  }
110
110
  /** The value of a held fit at a viewport: the line at the viewport clamped to [lo, hi]. */
package/dist/lcp.js CHANGED
@@ -32,7 +32,8 @@ export function lcpImagesOf(result) {
32
32
  servedBytes: served.reduce((sum, p) => sum + p.servedBytes, 0) / served.length,
33
33
  savedBytes: record.fix.bytes.saved,
34
34
  exactBytes: record.fix.bytes.sizes ?? 0,
35
- estBytes: record.fix.bytes.quality ?? 0,
35
+ // The resize of a plain image with no srcset is an estimate too (see ImageFix.bytes).
36
+ estBytes: (record.fix.bytes.quality ?? 0) + (record.fix.bytes.resize ?? 0),
36
37
  ...(skipped !== undefined ? { skipped } : {}),
37
38
  });
38
39
  }