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.
- package/CHANGELOG.md +98 -0
- package/README.md +69 -6
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +31 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +37 -4
- package/dist/check.js +18 -1
- package/dist/cli.js +20 -0
- package/dist/egress-proxy.js +2 -1
- package/dist/ffmpeg.js +35 -0
- package/dist/hints.js +294 -18
- package/dist/image-open.js +9 -0
- package/dist/images.js +116 -7
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +5 -1
- package/dist/media-model.js +8 -4
- package/dist/media-phase.js +25 -2
- package/dist/px.js +11 -0
- package/dist/recheck.js +9 -1
- package/dist/remote.js +616 -63
- package/dist/replay.js +20 -0
- package/dist/resources/audio.js +248 -0
- package/dist/resources/brotli.js +22 -0
- package/dist/resources/cache.js +59 -0
- package/dist/resources/capture.js +225 -0
- package/dist/resources/compression.js +122 -0
- package/dist/resources/favicon.js +130 -0
- package/dist/resources/font.js +440 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/lottie.js +491 -0
- package/dist/resources/model.js +478 -0
- package/dist/resources/phase.js +171 -0
- package/dist/resources/pool.js +47 -0
- package/dist/resources/report.js +57 -0
- package/dist/resources/sfnt.js +305 -0
- package/dist/resources/svg.js +178 -0
- package/dist/resources/types.js +1 -0
- package/dist/resources/zip.js +67 -0
- package/dist/run.js +24 -4
- package/dist/sizes.js +7 -5
- package/dist/summary.js +47 -1
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +119 -21
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|
package/dist/media-encode.js
CHANGED
|
@@ -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" };
|
package/dist/media-model.js
CHANGED
|
@@ -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.
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
|
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) {
|
package/dist/media-phase.js
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
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.
|
|
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)}`);
|