imgtune 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +69 -6
  3. package/dist/audit.js +1 -0
  4. package/dist/backgrounds.js +31 -7
  5. package/dist/broken.js +5 -1
  6. package/dist/browser.js +4 -4
  7. package/dist/candidates.js +25 -0
  8. package/dist/check-report.js +37 -4
  9. package/dist/check.js +18 -1
  10. package/dist/cli.js +20 -0
  11. package/dist/egress-proxy.js +2 -1
  12. package/dist/ffmpeg.js +35 -0
  13. package/dist/hints.js +294 -18
  14. package/dist/image-open.js +9 -0
  15. package/dist/images.js +116 -7
  16. package/dist/layout.js +7 -7
  17. package/dist/lcp.js +2 -1
  18. package/dist/measure.js +39 -1
  19. package/dist/media-encode.js +5 -1
  20. package/dist/media-model.js +8 -4
  21. package/dist/media-phase.js +25 -2
  22. package/dist/px.js +11 -0
  23. package/dist/recheck.js +9 -1
  24. package/dist/remote.js +616 -63
  25. package/dist/replay.js +20 -0
  26. package/dist/resources/audio.js +248 -0
  27. package/dist/resources/brotli.js +22 -0
  28. package/dist/resources/cache.js +59 -0
  29. package/dist/resources/capture.js +225 -0
  30. package/dist/resources/compression.js +122 -0
  31. package/dist/resources/favicon.js +130 -0
  32. package/dist/resources/font.js +440 -0
  33. package/dist/resources/index.js +8 -0
  34. package/dist/resources/lottie.js +491 -0
  35. package/dist/resources/model.js +478 -0
  36. package/dist/resources/phase.js +171 -0
  37. package/dist/resources/pool.js +47 -0
  38. package/dist/resources/report.js +57 -0
  39. package/dist/resources/sfnt.js +305 -0
  40. package/dist/resources/svg.js +178 -0
  41. package/dist/resources/types.js +1 -0
  42. package/dist/resources/zip.js +67 -0
  43. package/dist/run.js +24 -4
  44. package/dist/sizes.js +7 -5
  45. package/dist/summary.js +47 -1
  46. package/dist/vector-art.js +294 -0
  47. package/dist/vector-phase.js +222 -0
  48. package/dist/video-analysis.js +119 -21
  49. package/package.json +25 -4
  50. package/skills/imgtune/SKILL.md +8 -4
  51. package/skills/imgtune/reference.md +1 -1
@@ -0,0 +1,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,7 +4,7 @@
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
10
  import { animatedTarget } from "./media-model.js";
@@ -51,6 +51,70 @@ export function searchHintsOf(locator) {
51
51
  export function losslessServed(format, animated) {
52
52
  return format === "jpeg" || format === "png" || (format === "gif" && !animated);
53
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
+ }
54
118
  const round1 = (value) => Math.round(value * 10) / 10;
55
119
  const round2 = (value) => Math.round(value * 100) / 100;
56
120
  /**
@@ -118,7 +182,7 @@ function tunableOf(member, pageUrl, loader) {
118
182
  /** The measured viewports of a group as the proposal check reads them. */
119
183
  function measuredPoints(members) {
120
184
  return members
121
- .filter((m) => m.renderedWidth >= 1)
185
+ .filter((m) => m.renderedWidth >= 1 && m.kind !== "hidden")
122
186
  .map((m) => ({ viewport: m.viewport.name, viewportWidth: m.viewport.width, dpr: m.viewport.dpr, renderedWidth: m.renderedWidth, srcset: m.img.srcset }));
123
187
  }
124
188
  function sizesOf(measured) {
@@ -133,6 +197,17 @@ function sizesOf(measured) {
133
197
  export function hasFix(record) {
134
198
  return record.fix.bytes.saved > 0 || (record.fix.sizes !== undefined && record.fix.sizes.value !== record.current.sizes);
135
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
+ }
136
211
  /** Builds the image records of one page from its analysed instances. */
137
212
  export function buildImageRecords(input) {
138
213
  const { pageUrl, observations, instances, refName, resolved } = input;
@@ -192,6 +267,8 @@ export function buildImageRecords(input) {
192
267
  loaded: inst.url !== "" && obs.responses.get(inst.url)?.buffer !== undefined,
193
268
  lcp: obs.lcp?.kind === "image" && obs.lcp.index === inst.element,
194
269
  };
270
+ if (inst.kind === "hidden" && inst.img.hidden !== undefined)
271
+ entry.hidden = inst.img.hidden;
195
272
  if (inst.pixelWidth > 0)
196
273
  entry.servedPx = inst.pixelWidth;
197
274
  if (inst.pixelWidth > 0 && inst.needed > 0)
@@ -219,7 +296,7 @@ export function buildImageRecords(input) {
219
296
  };
220
297
  }
221
298
  const measured = members
222
- .filter((m) => m.renderedWidth >= 1)
299
+ .filter((m) => m.renderedWidth >= 1 && m.kind !== "hidden")
223
300
  .map((m) => ({
224
301
  viewportWidth: m.viewport.width,
225
302
  renderedWidth: m.renderedWidth,
@@ -236,16 +313,25 @@ export function buildImageRecords(input) {
236
313
  const keepSizes = sizes !== undefined && left === undefined;
237
314
  const savedEff = keepSizes ? saved : saved - savedSizes;
238
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);
239
319
  const served = member.format;
240
320
  const animatedMember = members.find((m) => m.animated !== undefined);
241
321
  const lossless = losslessServed(served, animatedMember !== undefined);
242
322
  const best = lossless ? (saved > 0 ? "avif" : served) : served;
243
- const fix = { bytes: { saved: savedEff, addedForSharpness, sizes: savedSizesEff, quality: savedEff - savedSizesEff } };
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
+ };
244
330
  if (member.cdnQuality !== undefined)
245
331
  fix.cdnQuality = { ...member.cdnQuality };
246
332
  if (member.formatAuto === true && lossless)
247
333
  fix.formatHandled = true;
248
- if (animatedMember?.animated !== undefined) {
334
+ if (animatedMember?.animated !== undefined && hiddenFix?.shownIn.length !== 0) {
249
335
  fix.animated = {
250
336
  info: animatedMember.animated,
251
337
  context: "img",
@@ -257,12 +343,25 @@ export function buildImageRecords(input) {
257
343
  fix.noSaving = `sizes proposal left out: ${left}`;
258
344
  notes.push(`sizes proposal left out: ${left}`);
259
345
  }
260
- 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) {
261
356
  fix.sizes = { value: sizes.value, basis: sizes.basis, rung, branches: sizes.branches };
262
357
  const sized = sizes.branches.map((b) => b.basis);
263
358
  const formulas = sized.filter((b) => b === "layout-formula").length;
264
359
  notes.push(`sizes: ${sizes.basis} (${formulas} of ${sized.length} media branch(es) from the layout formula, checked at ${measured.length} of ${viewportCount} viewports)`);
265
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 } };
266
365
  if (member.kind === "plain")
267
366
  fix.width = member.needed;
268
367
  if (best !== undefined)
@@ -321,6 +420,15 @@ export function buildImageRecords(input) {
321
420
  continue;
322
421
  own.set(m.format, Math.max(own.get(m.format) ?? -Infinity, m.qualityInfo.target));
323
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
+ }
324
432
  targets.set(record, own);
325
433
  records.push(record);
326
434
  }
@@ -344,7 +452,8 @@ export function buildImageRecords(input) {
344
452
  for (const [format, target] of entry.max)
345
453
  perFormat[format] = target;
346
454
  for (const record of entry.records) {
347
- 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)
348
457
  continue;
349
458
  record.fix.quality = {
350
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
  }
package/dist/measure.js CHANGED
@@ -184,14 +184,52 @@ export async function settleWithin(promises, ms) {
184
184
  await Promise.race([Promise.all(promises), timeout]);
185
185
  clearTimeout(timer);
186
186
  }
187
- /** Scrolls one viewport height at a time until the bottom, so lazy images start loading. */
187
+ /**
188
+ * Marks, on the page, every image and video that is visible at this scroll position: not display:none, not
189
+ * visibility hidden, opacity above 0, a box inside the viewport, and not outside an overflow-hidden ancestor (a carousel slide
190
+ * off its track is not visible). The marks live in window.__imgtuneSeen (a WeakSet of elements) for the page's read (see
191
+ * readView): a copy seen visible at any scroll step of the scan's own pass is shown, whatever its state at the read.
192
+ */
193
+ export const MARK_VISIBLE_SCRIPT = `(() => {
194
+ const seen = window.__imgtuneSeen || (window.__imgtuneSeen = new WeakSet());
195
+ const vw = window.innerWidth;
196
+ const vh = window.innerHeight;
197
+ for (const el of document.querySelectorAll("img, video")) {
198
+ const cs = getComputedStyle(el);
199
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") continue;
200
+ if (!(parseFloat(cs.opacity) > 0)) continue;
201
+ const r = el.getBoundingClientRect();
202
+ if (r.width <= 0 || r.height <= 0 || r.right <= 0 || r.bottom <= 0 || r.left >= vw || r.top >= vh) continue;
203
+ // The same rules as the hidden check (readView): content-visibility hidden, a closed details (except its summary), a
204
+ // display:none ancestor, and an overflow-hidden ancestor the box lies outside of.
205
+ let clipped = false;
206
+ for (let a = el.parentElement; a && !clipped; a = a.parentElement) {
207
+ const s = getComputedStyle(a);
208
+ if (s.display === "none" || s.getPropertyValue("content-visibility") === "hidden") clipped = true;
209
+ else if (a.tagName === "DETAILS" && !a.hasAttribute("open") && el.closest("summary") === null) clipped = true;
210
+ else if (/hidden|clip/.test(s.overflowX + " " + s.overflowY)) {
211
+ const b = a.getBoundingClientRect();
212
+ if (r.right <= b.left || r.left >= b.right || r.bottom <= b.top || r.top >= b.bottom) clipped = true;
213
+ }
214
+ }
215
+ if (!clipped) seen.add(el);
216
+ }
217
+ })()`;
218
+ /**
219
+ * Scrolls one viewport height at a time until the bottom, so lazy images start loading. After each step the copies visible at
220
+ * that position are marked (see MARK_VISIBLE_SCRIPT): a copy shown at any step of the pass is not a hidden copy.
221
+ */
188
222
  async function scrollThrough(page) {
223
+ await page.evaluate(MARK_VISIBLE_SCRIPT);
189
224
  for (let step = 0; step < MAX_SCROLL_STEPS; step++) {
190
225
  const atBottom = await page.evaluate(() => {
191
226
  window.scrollBy(0, window.innerHeight);
192
227
  return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1;
193
228
  });
194
229
  count("scroll steps");
230
+ // Two frames: the scroll events the step fired have run, so a state a scroll listener sets is the one marked.
231
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
232
+ await page.evaluate(MARK_VISIBLE_SCRIPT);
195
233
  await new Promise((resolve) => setTimeout(resolve, SCROLL_STEP_WAIT_MS));
196
234
  if (atBottom)
197
235
  return;
@@ -972,8 +972,12 @@ export async function searchQuality(samples, probe, codec, width, opts) {
972
972
  // A broken scorer is reported even when an earlier quality passed: the passes before it were not checked by it.
973
973
  if (err instanceof StageError && err.reason === "score-failed")
974
974
  return { ok: false, reason: "score-failed" };
975
- if (best)
975
+ if (best) {
976
+ // A search the deadline ended keeps its best passing quality, labelled as cut short (never silently a finished search).
977
+ if (isDeadline(err))
978
+ return { ok: true, best: { ...best, timeLimited: true }, tried: ctx.cache.size };
976
979
  return { ok: true, best, tried: ctx.cache.size };
980
+ }
977
981
  if (err instanceof StageError)
978
982
  return { ok: false, reason: err.reason };
979
983
  return { ok: false, reason: isDeadline(err) ? "time-limit" : "probe-failed" };
@@ -17,6 +17,7 @@ const REASON_TEXT = {
17
17
  "probe-failed": "ffprobe could not read the file",
18
18
  "score-failed": "the quality scorer failed",
19
19
  aborted: "aborted",
20
+ "never-shown": "never shown on load",
20
21
  };
21
22
  /** The text of a not-encoded reason (see REASON_TEXT). */
22
23
  export function reasonText(reason) {
@@ -228,12 +229,15 @@ export function sampleOffsets(durationS) {
228
229
  return [1 / 6, 1 / 2, 5 / 6].map((f) => Math.min(Math.max(0, durationS * f - 1), durationS - 2));
229
230
  }
230
231
  /**
231
- * Orders assets for the encode budget: heaviest per-view bytes first, then per-play bytes, then key. Duplicate keys
232
- * are dropped, keeping the first (heaviest) copy. The first `budget` assets are encoded; the rest are skipped and
233
- * still get their cheap hints. The input is not mutated.
232
+ * Orders assets for the encode budget: heaviest per-view bytes first, then per-play bytes, then key. The per-view bytes are
233
+ * `rankView` when the asset has one (the largest per-view bytes of one viewport), else `perView`: the claim is averaged over
234
+ * the viewports, and an average would rank a video shown at one viewport of two at half its weight. Duplicate keys are
235
+ * dropped, keeping the first (heaviest) copy. The first `budget` assets are encoded; the rest are skipped and still get
236
+ * their cheap hints. The input is not mutated.
234
237
  */
235
238
  export function rankForBudget(assets, budget) {
236
- const sorted = [...assets].sort((a, b) => b.perView - a.perView || b.perPlay - a.perPlay || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
239
+ const rank = (a) => a.rankView ?? a.perView;
240
+ const sorted = [...assets].sort((a, b) => rank(b) - rank(a) || b.perPlay - a.perPlay || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
237
241
  const seen = new Set();
238
242
  const unique = [];
239
243
  for (const a of sorted) {
@@ -62,9 +62,23 @@ export function partitionByPolicy(assets, policy) {
62
62
  export function mediaKeyOf(kind, context, url, widths) {
63
63
  return `${kind}|${context}|${widths.join(",")}|${url}`;
64
64
  }
65
+ /** The notEncoded reason of a video no viewport shows on load (its text is in media-model.ts reasonText). */
66
+ export const NEVER_SHOWN = "never-shown";
67
+ /**
68
+ * The assets to encode, and the ones that are never shown on load. A never-shown video has no claim to encode (no viewport
69
+ * displays it), so it keeps its probe (per-play bytes) and is marked NEVER_SHOWN instead of taking an encode budget slot.
70
+ */
71
+ export function splitNeverShown(assets) {
72
+ const encode = [];
73
+ const neverShown = [];
74
+ for (const asset of assets)
75
+ (asset.neverShown === true ? neverShown : encode).push(asset);
76
+ return { encode, neverShown };
77
+ }
65
78
  /** The asset of a `<video>` record: its widths are the kept widths of the scan. */
66
79
  export function videoAssetOf(record) {
67
80
  const widths = record.widths?.widths ?? [];
81
+ const neverShown = record.hidden !== undefined && record.hidden.shownIn.length === 0;
68
82
  return {
69
83
  key: mediaKeyOf("video", "video", record.src, widths),
70
84
  url: record.src,
@@ -75,6 +89,12 @@ export function videoAssetOf(record) {
75
89
  perView: record.bytes.perView,
76
90
  perPlay: record.bytes.perPlay,
77
91
  perViewBasis: record.bytes.perViewBasis,
92
+ // A video shown at one viewport of two has half the averaged perView, but its one viewport loads it in full: the budget
93
+ // ranks it by that, so the averaging does not push it out of the encode budget.
94
+ ...(record.perViewport !== undefined && record.perViewport.length > 0
95
+ ? { rankView: Math.max(...record.perViewport.map((p) => p.bytes)) }
96
+ : {}),
97
+ ...(neverShown ? { neverShown: true } : {}),
78
98
  };
79
99
  }
80
100
  /** The redirect-hop check of a video's page: the third-party policy as the browser's route guard applies it. */
@@ -289,8 +309,11 @@ async function runJobs(jobs, identity, probe, samplesOf, dir, deadline, env, rea
289
309
  workDir: join(dir, `${job.codec}-${job.width}`),
290
310
  signal: opts.signal,
291
311
  });
292
- if (outcome.ok)
293
- await opts.cache?.putJson(cacheKey, { ok: true, best: outcome.best });
312
+ // A search cut short by its deadline is not cached: a later run with more time may find a cheaper passing quality.
313
+ if (outcome.ok) {
314
+ if (outcome.best.timeLimited !== true)
315
+ await opts.cache?.putJson(cacheKey, { ok: true, best: outcome.best });
316
+ }
294
317
  else if (outcome.reason === "no-passing-quality")
295
318
  await opts.cache?.putJson(cacheKey, { ok: false, reason: "no-passing-quality" });
296
319
  }
package/dist/px.js ADDED
@@ -0,0 +1,11 @@
1
+ // Whole-px rounding of a proposed sizes value. A fitted length is rounded in the direction that keeps the chosen srcset
2
+ // candidate covering the need: a length up, a plus offset up, and a minus offset down in magnitude (so the value is not
3
+ // below the fit). A `sizes` value like 190.04px is never printed.
4
+ /** A px length as whole px, rounded up (190.04 -> 191). */
5
+ export function wholePx(px) {
6
+ return Math.ceil(px - 1e-9);
7
+ }
8
+ /** A signed px offset of a calc(): a plus offset rounded up, a minus offset rounded down in magnitude. */
9
+ export function offsetPx(b) {
10
+ return b < 0 ? -Math.floor(-b + 1e-9) : Math.ceil(b - 1e-9);
11
+ }
package/dist/recheck.js CHANGED
@@ -11,6 +11,7 @@ import { parseViewports, safeText, scanOnce, scorerNote } from "./remote.js";
11
11
  import { settleLimit } from "./concurrency.js";
12
12
  import { cleanRecord } from "./locate-join.js";
13
13
  import { batchLine, rowLine } from "./recheck-rows.js";
14
+ import { hiddenSummaryLine } from "./hints.js";
14
15
  import { parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
15
16
  /** Browser contexts (one per viewport loaded) a recheck keeps in flight over all its pages together. */
16
17
  export const RECHECK_CONTEXTS = 3;
@@ -210,7 +211,11 @@ export function compareImage(before, after, unchangedSrc, how = { ambiguous: 0 }
210
211
  const reused = unchangedSrc ? " (src unchanged: estimate reused)" : "";
211
212
  lines.push(` hint sizes ${sizesBefore} -> ${sizesAfter} saves ${kb(savedBefore)} -> ${kb(savedAfter)}${reused}`);
212
213
  // Predicted (the hint, per view, averaged over viewports) next to measured (served bytes at the same viewports).
213
- const split = before.fix.bytes.sizes !== undefined ? ` (sizes ${kb(before.fix.bytes.sizes)}, quality ${kb(before.fix.bytes.quality ?? 0)})` : "";
214
+ const split = before.fix.bytes.resize !== undefined
215
+ ? ` (sizes ${kb(before.fix.bytes.sizes ?? 0)}, resize ${kb(before.fix.bytes.resize)}, quality ${kb(before.fix.bytes.quality ?? 0)})`
216
+ : before.fix.bytes.sizes !== undefined
217
+ ? ` (sizes ${kb(before.fix.bytes.sizes)}, quality ${kb(before.fix.bytes.quality ?? 0)})`
218
+ : "";
214
219
  const predicted = `predicted ${saving(savedBefore)}/view${split}`;
215
220
  const mean = measured.length > 0 ? measured.reduce((sum, b) => sum + b, 0) / measured.length : undefined;
216
221
  if (mean !== undefined)
@@ -369,6 +374,9 @@ async function recheckPage(file, args, cwd, log, options) {
369
374
  if (details.length > 0)
370
375
  out.push("", "not passed:", ...details);
371
376
  out.push(batchLine(rows, seconds) + ` on ${safeText(target.href)} at ${viewports.length} viewport(s)`);
377
+ const hiddenLine = hiddenSummaryLine(fresh.hidden);
378
+ if (hiddenLine !== undefined)
379
+ out.push(hiddenLine);
372
380
  out.push(failures.length === 0 ? "ok: no image became undersized, and no bytes rose without a sharpness fix" : `FAILED: ${failures.length} check(s)`);
373
381
  for (const failure of failures)
374
382
  out.push(` ${safeText(failure)}`);