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
@@ -0,0 +1,222 @@
1
+ // The vector phase of a scan: after every page is measured, the flat still rasters of all pages (fix.vector, see
2
+ // vector-art.ts) are ranked by their bytes per view, and the heaviest ones within --svg-budget are traced and checked.
3
+ // Each unique URL is traced once per run. Its numbers are kept in the result cache, keyed by the content hash, so a
4
+ // rescan does not score it again. A passing SVG is written to the scan's svg/ folder. Nothing here throws: a failure
5
+ // becomes the record's notChecked reason, and the scan always finishes.
6
+ import { mkdir } from "node:fs/promises";
7
+ import { join, relative } from "node:path";
8
+ import { writeFileAtomic } from "./atomic.js";
9
+ import { mapLimit } from "./concurrency.js";
10
+ import { CACHE_VERSION, contentHash } from "./result-cache.js";
11
+ import { measure } from "./timing.js";
12
+ import { VECTOR_BAR, VECTOR_CAP_MS, VECTOR_MIN_SAVING, VECTORIZER_VERSION, loadVectorizer, svgBytes, svgFidelity, traceSvg, } from "./vector-art.js";
13
+ /** The note printed once when the vectorizer is missing: what installing it adds. */
14
+ export const VECTOR_NOTE = "install @neplex/vectorizer to check vector art";
15
+ /** Images traced at once. */
16
+ export const VECTOR_CONCURRENCY = 4;
17
+ /** The folder of the traced SVGs, under the scan's folder. */
18
+ export const VECTOR_SVG_DIR = "svg";
19
+ /** Bumped when the trace or the scoring changes (input size, render, flat rule), so the cached numbers of the old ones are not read. */
20
+ const TRACE_VERSION = "poster-3";
21
+ const TIMED_OUT = Symbol("timed out");
22
+ /**
23
+ * The served bytes of the flat images, as the browser got them, for the vector check: the check traces the file the page
24
+ * used, not a fresh download. Bounded: it keeps the `capacity` largest files seen (the ones the budget would trace), so
25
+ * the memory is the budget times a file, whatever the site has. A URL it does not keep is fetched instead.
26
+ */
27
+ export class FlatBuffers {
28
+ capacity;
29
+ entries = new Map();
30
+ constructor(capacity) {
31
+ this.capacity = capacity;
32
+ }
33
+ /** Keeps `buffer` for `url` when it is among the `capacity` largest seen so far. */
34
+ offer(url, buffer, bytes) {
35
+ if (this.capacity <= 0 || buffer === undefined || this.entries.has(url))
36
+ return;
37
+ if (this.entries.size < this.capacity) {
38
+ this.entries.set(url, { buffer, bytes });
39
+ return;
40
+ }
41
+ let smallest;
42
+ for (const [key, entry] of this.entries) {
43
+ if (smallest === undefined || entry.bytes < (this.entries.get(smallest)?.bytes ?? Infinity))
44
+ smallest = key;
45
+ }
46
+ if (smallest !== undefined && bytes > (this.entries.get(smallest)?.bytes ?? 0)) {
47
+ this.entries.delete(smallest);
48
+ this.entries.set(url, { buffer, bytes });
49
+ }
50
+ }
51
+ get(url) {
52
+ return this.entries.get(url)?.buffer;
53
+ }
54
+ }
55
+ /** The bytes per page view of an image: the mean over its viewports. */
56
+ export function perViewBytesOf(record) {
57
+ return record.perViewport.reduce((sum, p) => sum + p.servedBytes, 0) / Math.max(1, record.perViewport.length);
58
+ }
59
+ /** The flat images of the pages, one per URL, heaviest first. A record without a URL cannot be traced and is left out. */
60
+ function candidatesOf(pages) {
61
+ const byUrl = new Map();
62
+ for (const page of pages) {
63
+ for (const record of page.images ?? []) {
64
+ if (record.fix.vector === undefined || record.current.src === undefined)
65
+ continue;
66
+ const url = record.current.src;
67
+ const perView = perViewBytesOf(record);
68
+ const known = byUrl.get(url);
69
+ if (known === undefined || perView > known.perView)
70
+ byUrl.set(url, { url, id: known?.id ?? record.id, perView });
71
+ }
72
+ }
73
+ return [...byUrl.values()].sort((a, b) => b.perView - a.perView || a.url.localeCompare(b.url));
74
+ }
75
+ /** Runs the vector check over the flat images of the pages (see the file comment). */
76
+ export async function runVectorPhase(pages, opts, deps) {
77
+ const byUrl = new Map();
78
+ const candidates = candidatesOf(pages);
79
+ if (candidates.length === 0)
80
+ return { byUrl };
81
+ const skip = opts.survey ? "survey" : !opts.quality ? "quality off" : opts.budget === 0 ? "svg-budget 0" : undefined;
82
+ if (skip !== undefined) {
83
+ for (const c of candidates)
84
+ byUrl.set(c.url, { notChecked: skip });
85
+ return { byUrl };
86
+ }
87
+ if ((await loadVectorizer()) === undefined) {
88
+ for (const c of candidates)
89
+ byUrl.set(c.url, { notChecked: "vectorizer not installed" });
90
+ return { byUrl, note: VECTOR_NOTE };
91
+ }
92
+ const chosen = candidates.slice(0, opts.budget);
93
+ for (const c of candidates.slice(opts.budget))
94
+ byUrl.set(c.url, { notChecked: "over --svg-budget" });
95
+ opts.log(`vector: tracing ${chosen.length} of ${candidates.length} flat image(s) (--svg-budget ${opts.budget})`);
96
+ const traced = await mapLimit(chosen, VECTOR_CONCURRENCY, async (c) => [c.url, await traceOne(c.url, c.id, opts, deps)]);
97
+ for (const [url, outcome] of traced)
98
+ byUrl.set(url, outcome);
99
+ const passed = traced.filter(([, o]) => o.passed === true).length;
100
+ opts.log(`vector: ${passed} of ${traced.length} traced image(s) pass at SSIMULACRA2 ${VECTOR_BAR} (half size)`);
101
+ return { byUrl };
102
+ }
103
+ /** One URL: its numbers from the cache, or traced and scored within VECTOR_CAP_MS. Never throws. */
104
+ async function traceOne(url, id, opts, deps) {
105
+ const buffer = await deps.fetch(url).catch(() => undefined);
106
+ if (buffer === undefined)
107
+ return { notChecked: "trace failed" };
108
+ const key = `vector|${CACHE_VERSION}|${contentHash(buffer)}|${VECTORIZER_VERSION}|${TRACE_VERSION}|bar${VECTOR_BAR}`;
109
+ const cached = await opts.cache?.getJson(key);
110
+ let numbers = isCachedNumbers(cached) ? cached : undefined;
111
+ let svg;
112
+ if (numbers === undefined) {
113
+ // The cap aborts the trace itself (the vectorizer stops, and stops using CPU); the score is dropped with it.
114
+ const controller = new AbortController();
115
+ const traced = await within(measure("vector: trace and score", () => measureOne(buffer, controller.signal)), VECTOR_CAP_MS, controller);
116
+ if (traced === TIMED_OUT)
117
+ return { notChecked: "timeout" };
118
+ if (traced === undefined)
119
+ return { notChecked: "trace failed" };
120
+ numbers = traced.numbers;
121
+ svg = traced.svg;
122
+ await opts.cache?.putJson(key, numbers);
123
+ }
124
+ else if (numbers.passed) {
125
+ // The numbers say it passes, but its file is written from a trace: a trace is cheap, the score is not repeated.
126
+ const controller = new AbortController();
127
+ const traced = await within(traceSvg(buffer, controller.signal), VECTOR_CAP_MS, controller);
128
+ if (traced === TIMED_OUT)
129
+ return { notChecked: "timeout" };
130
+ if (traced === undefined)
131
+ return { notChecked: "trace failed" };
132
+ svg = traced;
133
+ }
134
+ const outcome = { svgBytes: numbers.svgBytes, score: numbers.score, badPixelShare: numbers.badPixelShare, passed: numbers.passed };
135
+ if (!numbers.passed)
136
+ return outcome;
137
+ if (svg === undefined)
138
+ return { notChecked: "trace failed" };
139
+ try {
140
+ const dir = join(opts.outDir, VECTOR_SVG_DIR);
141
+ await mkdir(dir, { recursive: true });
142
+ const file = join(dir, `${id}.svg`);
143
+ await writeFileAtomic(file, svg, { root: opts.outDir });
144
+ // Relative to the project folder when the file is inside it; absolute otherwise (a scan into another folder).
145
+ const named = relative(opts.cwd, file);
146
+ return { ...outcome, tracedFile: named.startsWith("..") ? file : named };
147
+ }
148
+ catch {
149
+ return { notChecked: "trace failed" };
150
+ }
151
+ }
152
+ /** The trace of an image and its numbers: the SVG, its brotli size, and the fidelity against the served bytes. */
153
+ async function measureOne(buffer, signal) {
154
+ const svg = await traceSvg(buffer, signal);
155
+ if (svg === undefined || signal.aborted)
156
+ return undefined;
157
+ const bytes = svgBytes(svg);
158
+ const fidelity = await svgFidelity(buffer, svg);
159
+ const passed = fidelity.score >= VECTOR_BAR && bytes <= buffer.length * (1 - VECTOR_MIN_SAVING);
160
+ return { svg, numbers: { svgBytes: bytes, score: Math.round(fidelity.score * 10) / 10, badPixelShare: fidelity.badPixelShare, passed } };
161
+ }
162
+ /**
163
+ * The value, or TIMED_OUT when it is not ready in `ms` (the controller is then aborted, so the work stops), or undefined when
164
+ * it fails.
165
+ */
166
+ async function within(work, ms, controller) {
167
+ let timer;
168
+ const timeout = new Promise((resolve) => {
169
+ timer = setTimeout(() => {
170
+ controller.abort();
171
+ resolve(TIMED_OUT);
172
+ }, ms);
173
+ });
174
+ try {
175
+ return await Promise.race([work.catch(() => undefined), timeout]);
176
+ }
177
+ finally {
178
+ clearTimeout(timer);
179
+ }
180
+ }
181
+ function isCachedNumbers(value) {
182
+ if (typeof value !== "object" || value === null)
183
+ return false;
184
+ const v = value;
185
+ return (Number.isFinite(v.svgBytes) &&
186
+ Number.isFinite(v.score) &&
187
+ Number.isFinite(v.badPixelShare) &&
188
+ typeof v.passed === "boolean");
189
+ }
190
+ /**
191
+ * A page with the phase's outcomes written into its flat images' fix.vector. The saving of a passing SVG is the served bytes
192
+ * per view minus the SVG's, averaged over the page's viewports (as the other savings are). The record's claimed saving is
193
+ * the larger of the raster fix and the SVG; the SVG wins only when it is larger, and the raster fix stays as the fallback.
194
+ */
195
+ export function applyVector(page, run) {
196
+ const flat = (page.images ?? []).some((record) => record.fix.vector !== undefined);
197
+ if (!flat)
198
+ return page;
199
+ const viewportCount = Math.max(1, page.viewports.length);
200
+ const images = (page.images ?? []).map((record) => {
201
+ const vector = record.fix.vector;
202
+ if (vector === undefined)
203
+ return record;
204
+ const outcome = record.current.src !== undefined ? run.byUrl.get(record.current.src) : undefined;
205
+ const next = { ...vector, ...(outcome ?? { notChecked: "trace failed" }) };
206
+ if (outcome?.passed === true && outcome.svgBytes !== undefined) {
207
+ const svg = outcome.svgBytes;
208
+ const saved = record.perViewport.reduce((sum, p) => sum + Math.max(0, p.servedBytes - svg), 0) / viewportCount;
209
+ next.savedPerView = Math.round(saved);
210
+ if (saved > record.fix.bytes.saved) {
211
+ next.wins = true;
212
+ return {
213
+ ...record,
214
+ fix: { ...record.fix, vector: next, bytes: { saved: Math.round(saved), addedForSharpness: record.fix.bytes.addedForSharpness } },
215
+ };
216
+ }
217
+ }
218
+ return { ...record, fix: { ...record.fix, vector: next } };
219
+ });
220
+ const hasNote = images.some((record) => record.fix.vector !== undefined);
221
+ return { ...page, images, ...(run.note !== undefined && hasNote ? { vectorNote: run.note } : {}) };
222
+ }
@@ -0,0 +1,371 @@
1
+ // Records for animated images and `<video>` elements: the cheap levers only (no ffmpeg; the encodes come later). An
2
+ // animated image gets an `animated` fix on its ImageRecord (images.ts), and a video gets its own VideoRecord with its
3
+ // bytes per view and per play, its widths and the levers that need no probe. A fetched poster is audited as an image,
4
+ // so it is added to the page's images here, before the image pipeline runs.
5
+ import { sha1 } from "./atomic.js";
6
+ import { COMPONENT_NAME, sanitizeText } from "./locate.js";
7
+ import { hiddenFixCore } from "./images.js";
8
+ import { adapterFor, formatAutoOf } from "./loaders.js";
9
+ import { clusterVideoWidths, videoBytes, widthOptions, } from "./media-model.js";
10
+ /** Widths kept per video in scan mode (`--max-video-widths`, before the flag is wired). */
11
+ export const SCAN_MAX_VIDEO_WIDTHS = 2;
12
+ /**
13
+ * The first index a poster takes as an image element. A poster is not an <img>, so its index is placed far above any
14
+ * real one (the page's <img> indexes), keeping its element identity apart in the image pipeline.
15
+ */
16
+ export const POSTER_INDEX_BASE = 1_000_000;
17
+ /**
18
+ * The id of a video. Without a source file it is the group key, as before. With one it is the file step (the rule of the
19
+ * image ids, images.ts baseKey), with the ordinal of the video among those of its file in document order, so two videos
20
+ * of one file keep distinct ids. The id is `video:` and 12 hex digits of the hash.
21
+ */
22
+ function videoIdOf(page, key, file, perFile) {
23
+ if (file === undefined)
24
+ return `video:${sha1(`${page}\n${key}`).slice(0, 12)}`;
25
+ const n = perFile.get(file) ?? 0;
26
+ perFile.set(file, n + 1);
27
+ return `video:${sha1(`${page}\nfile:${file}#${n}`).slice(0, 12)}`;
28
+ }
29
+ /**
30
+ * Components above a video, nearest first: the same rule as remote.ts (componentNamesOf), capped at its MAX_OWNERS (8).
31
+ * Written out here, not imported, so that this module does not import remote.ts at run time.
32
+ */
33
+ function componentsOf(owners) {
34
+ return owners
35
+ .flatMap((o) => (o.name !== undefined && COMPONENT_NAME.test(o.name) && o.name.length > 2 ? [o.name] : []))
36
+ .slice(0, 8);
37
+ }
38
+ /** The `preload` kind of a video, as the byte rules read it: the autoplay attribute wins over preload. */
39
+ export function preloadKindOf(video) {
40
+ if (video.autoplay)
41
+ return "autoplay";
42
+ switch (video.preload) {
43
+ case "auto":
44
+ return "auto";
45
+ case "metadata":
46
+ return "metadata";
47
+ case "none":
48
+ return "none";
49
+ default:
50
+ return "default";
51
+ }
52
+ }
53
+ /**
54
+ * The full-file bytes of each media URL: the largest bytes the scan saw for it in any viewport. A URL's bytes are the
55
+ * bytes the page moved, so a partial (range) read can be smaller than the file; the largest read is the closest the
56
+ * headers give without a range total.
57
+ */
58
+ export function fullBytesOf(observations) {
59
+ const full = new Map();
60
+ for (const obs of observations) {
61
+ for (const [url, record] of obs.media ?? [])
62
+ full.set(url, Math.max(full.get(url) ?? 0, record.bytes));
63
+ }
64
+ return full;
65
+ }
66
+ /**
67
+ * The observations with each fetched poster added to its viewport as an image, sized to the video's box. A poster that
68
+ * was not fetched, or a hidden video's, is not added. The added image carries `posterOf` (the video's selector), so its
69
+ * record says it is a poster. Returns new observations; the ones given are not changed.
70
+ */
71
+ export function withPosterImages(observations) {
72
+ return observations.map((obs) => {
73
+ const posters = [];
74
+ for (const video of obs.videos ?? []) {
75
+ if (video.poster === undefined || video.hidden || video.poster.startsWith("data:"))
76
+ continue;
77
+ if (!obs.responses.get(video.poster)?.buffer)
78
+ continue;
79
+ posters.push({
80
+ index: POSTER_INDEX_BASE + video.index,
81
+ currentSrc: video.poster,
82
+ renderedWidth: video.renderedWidth,
83
+ renderedHeight: video.renderedHeight,
84
+ srcset: "",
85
+ classes: "",
86
+ nextImage: false,
87
+ inFirstViewport: video.inFirstViewport,
88
+ locator: video.locator,
89
+ posterOf: video.locator.selector,
90
+ });
91
+ }
92
+ return posters.length > 0 ? { ...obs, images: [...obs.images, ...posters] } : obs;
93
+ });
94
+ }
95
+ /** The key that finds a video across viewports: its components, else its test id, else its DOM path (as the images). */
96
+ function baseKeyOf(video) {
97
+ const components = componentsOf(video.locator.owners);
98
+ if (components.length > 0)
99
+ return `component:${components.join(">")}`;
100
+ if (video.locator.testid)
101
+ return `testid:${video.locator.testid}`;
102
+ return `css:${video.locator.selector}`;
103
+ }
104
+ /** The videos of the page, grouped by element across viewports, in document order. */
105
+ function groupVideos(observations) {
106
+ const groups = new Map();
107
+ for (const obs of observations) {
108
+ const ordinals = new Map();
109
+ for (const video of obs.videos ?? []) {
110
+ const base = baseKeyOf(video);
111
+ const n = ordinals.get(base) ?? 0;
112
+ ordinals.set(base, n + 1);
113
+ const key = `${base}#${n}`;
114
+ const list = groups.get(key);
115
+ if (list)
116
+ list.push({ obs, video });
117
+ else
118
+ groups.set(key, [{ obs, video }]);
119
+ }
120
+ }
121
+ return groups;
122
+ }
123
+ /** The bytes the scan saw for a media URL in one viewport (0 when the page did not load it there). */
124
+ function observedBytesOf(obs, url) {
125
+ return obs.media?.get(url)?.bytes ?? 0;
126
+ }
127
+ /**
128
+ * True when a Cloudinary video URL (`/<cloud>/video/upload/<transforms>/<file>`) has `f_auto` among its transforms. The
129
+ * image loader (loaders.ts) reads `image/upload` only, so the video path is read here.
130
+ */
131
+ function cloudinaryVideoFormatAuto(url) {
132
+ if (url.hostname !== "res.cloudinary.com")
133
+ return false;
134
+ const upload = /^\/[^/]+\/video\/upload\/(.+)$/.exec(url.pathname);
135
+ if (!upload)
136
+ return false;
137
+ return upload[1].split("/").slice(0, -1).some((segment) => segment.split(",").includes("f_auto"));
138
+ }
139
+ /**
140
+ * The info note of a video whose URL the CDN already serves in the format the browser takes (Cloudinary `f_auto`, imgix
141
+ * `auto=format`): the codec lever does not apply, because the CDN negotiates the format per request. Undefined otherwise.
142
+ */
143
+ export function formatHandledNote(src, page) {
144
+ let url;
145
+ try {
146
+ url = new URL(src, page);
147
+ }
148
+ catch {
149
+ return undefined;
150
+ }
151
+ if (cloudinaryVideoFormatAuto(url))
152
+ return "format handled by Cloudinary (f_auto): the CDN serves AV1 or H.264 per browser, no codec change";
153
+ if (!formatAutoOf(src, page))
154
+ return undefined;
155
+ const name = adapterFor(src, page).adapter.name;
156
+ if (name === "imgix")
157
+ return "format handled by imgix (auto=format): the CDN serves the format per browser, no codec change";
158
+ return "format handled by the CDN (f_auto): the CDN serves the format per browser, no codec change";
159
+ }
160
+ /** True when the video's sources name an AV1 stream, by type or by an `.av1.mp4` file name. */
161
+ function hasAv1(video) {
162
+ if (video.sources.some((s) => /av01|av1/i.test(s.type ?? "")))
163
+ return true;
164
+ const urls = [video.currentSrc, ...video.sources.map((s) => s.src)];
165
+ return urls.some((u) => /\.av1\.mp4(?:[?#]|$)/i.test(u));
166
+ }
167
+ /**
168
+ * The fresh load's media bytes behind each observation. A resized viewport shares its group's page load, so its media map
169
+ * holds the bytes of every earlier step too. A hidden copy's bytes at a resized viewport are taken from the fresh load's map
170
+ * (see hiddenBytesOf), so a file fetched only for another viewport is not counted there.
171
+ */
172
+ function freshMediaOf(observations) {
173
+ const fresh = new Map();
174
+ let current;
175
+ for (const obs of observations) {
176
+ if (obs.load === "fresh" || current === undefined)
177
+ current = obs;
178
+ fresh.set(obs, current);
179
+ }
180
+ return fresh;
181
+ }
182
+ /** The poster URL of a video that is a file the page can fetch (not a data: URI), or undefined. */
183
+ function fetchablePosterOf(video) {
184
+ return video.poster !== undefined && !video.poster.startsWith("data:") ? video.poster : undefined;
185
+ }
186
+ /**
187
+ * The bytes a hidden copy costs per view at its viewport: the file the browser fetched for it (the same preload rules as a
188
+ * shown video, see videoBytes), plus its poster when the poster was fetched in the fresh load and no shown image of the
189
+ * viewport uses that URL. Zero when the copy's file is also shown at the viewport (one fetch, counted there), or when a
190
+ * resized viewport's copy does not fetch on load (its bytes are not the fresh load's).
191
+ */
192
+ function hiddenBytesOf(member, shownUrls, freshOf) {
193
+ const { obs, video } = member;
194
+ const url = video.currentSrc;
195
+ if (shownUrls.has(url))
196
+ return 0;
197
+ const kind = preloadKindOf(video);
198
+ const fresh = freshOf.get(obs) ?? obs;
199
+ // A copy that does not fetch on load (preload none or metadata) has nothing of the page load to count at a resized viewport.
200
+ const fetchesOnLoad = kind === "autoplay" || kind === "auto" || kind === "default";
201
+ if (obs.load !== "fresh" && !fetchesOnLoad)
202
+ return 0;
203
+ const observed = observedBytesOf(obs.load === "fresh" ? obs : fresh, url);
204
+ const bytes = videoBytes({
205
+ kind,
206
+ playing: !video.paused || video.errorCode === 4,
207
+ observedBytes: observed,
208
+ fullBytes: observed,
209
+ visible: true,
210
+ }).perView;
211
+ const poster = fetchablePosterOf(video);
212
+ const posterBytes = poster !== undefined && obs.load === "fresh" && !obs.images.some((img) => img.currentSrc === poster && img.hidden === undefined)
213
+ ? (obs.responses.get(poster)?.bytes ?? 0)
214
+ : 0;
215
+ return bytes + posterBytes;
216
+ }
217
+ /**
218
+ * The VideoRecords of a page, from its observations. Each `<video>` is one record across the viewports it appears in.
219
+ *
220
+ * Bytes: the claim is the shown copies only. The record takes the viewport where a shown copy costs most per view, so the
221
+ * claim is never low. The full file is `fullBytes` (see fullBytesOf) and is shown per play for every video.
222
+ *
223
+ * Hidden: a copy that is not shown at a viewport (see ObservedVideo.hidden) is a loaded-but-not-shown fix when the browser
224
+ * fetched bytes for it (hiddenBytesOf). Its bytes are the fix's, apart from the claim. A video shown at no viewport is never
225
+ * shown on load: it has no width, no levers and no claim.
226
+ *
227
+ * Widths: the viewports the video is shown in (hidden ones are left out) are clustered to at most `maxWidths` widths in
228
+ * scan mode, and the plans for one, two and three widths are kept in audit mode, where the agent chooses.
229
+ *
230
+ * Levers (no probe here): `loading` when a video costs bytes on load and is below the fold (or preloads), `codec` when
231
+ * no source is AV1, and `posterMissing` when there is no poster.
232
+ */
233
+ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, sources) {
234
+ const records = [];
235
+ // The records of each file so far: a file's videos are told apart by their document order (see videoIdOf).
236
+ const perFile = new Map();
237
+ const freshOf = freshMediaOf(observations);
238
+ // The file each viewport shows in full, over every video of the page: a hidden copy of one of them adds no bytes there.
239
+ const shownAt = new Map();
240
+ for (const obs of observations) {
241
+ const urls = shownAt.get(obs.viewport.name) ?? new Set();
242
+ for (const video of obs.videos ?? [])
243
+ if (!video.hidden)
244
+ urls.add(video.currentSrc);
245
+ shownAt.set(obs.viewport.name, urls);
246
+ }
247
+ for (const [key, members] of groupVideos(observations)) {
248
+ const shown = members.filter((m) => !m.video.hidden);
249
+ // The record's own file and attributes come from a shown copy when there is one.
250
+ const ref = (shown[0] ?? members[0]).video;
251
+ const source = sources?.get(ref);
252
+ const src = ref.currentSrc;
253
+ const observedMax = Math.max(0, ...members.map((m) => observedBytesOf(m.obs, m.video.currentSrc)));
254
+ const full = fullBytes.get(src) ?? observedMax;
255
+ // The claim: the shown copies. A video shown nowhere costs nothing in the claim.
256
+ const views = shown.map((m) => videoBytes({
257
+ kind: preloadKindOf(m.video),
258
+ playing: !m.video.paused || m.video.errorCode === 4,
259
+ observedBytes: observedBytesOf(m.obs, m.video.currentSrc),
260
+ fullBytes: full,
261
+ visible: true,
262
+ }));
263
+ const none = videoBytes({ kind: "none", playing: false, observedBytes: 0, fullBytes: full, visible: false });
264
+ const heaviest = views.length > 0 ? views.reduce((best, cur) => (cur.perView > best.perView ? cur : best)) : none;
265
+ // The claim is per viewport, averaged over the viewports (as an image's is): each viewport loads its own shown copy, so a
266
+ // twin pair counts once, not twice. Bounded by the file's own length (perPlay), as the single-copy figure is.
267
+ const shownBytesAt = new Map();
268
+ shown.forEach((m, i) => shownBytesAt.set(m, views[i].perView));
269
+ const claimPerView = Math.min(heaviest.perPlay, observations.length > 0 ? views.reduce((sum, v) => sum + v.perView, 0) / observations.length : 0);
270
+ // The hidden fix: one entry per viewport the video is in, shown or hidden. A hidden copy with no bytes is not reported.
271
+ const entries = [];
272
+ const copies = [];
273
+ for (const m of members) {
274
+ const viewport = { name: m.obs.viewport.name, width: m.obs.viewport.width };
275
+ if (!m.video.hidden) {
276
+ entries.push({ viewport, bytes: 0, inPanel: false, hover: false });
277
+ continue;
278
+ }
279
+ const bytes = hiddenBytesOf(m, shownAt.get(m.obs.viewport.name) ?? new Set(), freshOf);
280
+ shownBytesAt.set(m, bytes);
281
+ if (bytes <= 0)
282
+ continue;
283
+ const reason = m.video.hiddenReason ?? "display";
284
+ entries.push({ viewport, bytes, hidden: reason, inPanel: m.video.inPanel === true, hover: m.video.hover === true, unstable: m.video.unstable === "unknown" });
285
+ copies.push({ viewport: viewport.name, src: m.video.currentSrc, bytes, reason });
286
+ }
287
+ const hiddenBase = hiddenFixCore(entries, observations.length);
288
+ const hidden = hiddenBase !== undefined ? { ...hiddenBase, copies } : undefined;
289
+ const shownNow = shown.length > 0;
290
+ const info = [];
291
+ const handled = formatHandledNote(src, page);
292
+ if (handled !== undefined)
293
+ info.push(handled);
294
+ const knownWidth = shown.map((m) => m.video.videoWidth).find((w) => w !== undefined && w > 0);
295
+ const needs = shown.map((m) => ({
296
+ viewport: m.obs.viewport.name,
297
+ viewportWidth: m.obs.viewport.width,
298
+ boxWidth: m.video.renderedWidth,
299
+ dpr: m.obs.viewport.dpr,
300
+ }));
301
+ let sourceWidth = knownWidth;
302
+ if (sourceWidth === undefined) {
303
+ // Without the source width the widths are capped at the largest display need, which is all the page shows.
304
+ sourceWidth = Math.max(1, ...shown.map((m) => Math.ceil(m.video.renderedWidth * m.obs.viewport.dpr)));
305
+ if (shownNow)
306
+ info.push("source width not read: widths are capped at the largest display need");
307
+ }
308
+ const belowFold = shown.every((m) => !m.video.inFirstViewport);
309
+ const levers = { info: shownNow ? info : [] };
310
+ if (shownNow) {
311
+ if (claimPerView > 0 && (belowFold || ref.preload === "auto")) {
312
+ levers.loading = { perViewBytes: claimPerView, belowFold };
313
+ }
314
+ if (handled === undefined && !hasAv1(ref))
315
+ levers.codec = { missing: "av1" };
316
+ if (ref.poster === undefined)
317
+ levers.posterMissing = true;
318
+ }
319
+ const locator = {
320
+ component: source?.component.length ? source.component : componentsOf(ref.locator.owners),
321
+ selector: sanitizeText(ref.locator.selector, 300),
322
+ classes: "",
323
+ loader: "video",
324
+ };
325
+ if (ref.locator.testid !== undefined)
326
+ locator.testid = sanitizeText(ref.locator.testid, 120);
327
+ if (source?.file !== undefined) {
328
+ // The project file:line of the element, when the owner resolved (see remote.ts resolveVideoSources).
329
+ locator.file = source.file;
330
+ locator.line = source.line;
331
+ if (source.via !== undefined)
332
+ locator.via = source.via;
333
+ locator.kind = source.check ?? "unchecked";
334
+ locator.verified = locator.kind === "image";
335
+ if (source.ownerChain?.length)
336
+ locator.ownerChain = source.ownerChain;
337
+ }
338
+ const record = {
339
+ id: videoIdOf(page, key, source?.file, perFile),
340
+ page,
341
+ locator,
342
+ src,
343
+ sources: ref.sources,
344
+ attributes: {
345
+ autoplay: ref.autoplay,
346
+ muted: ref.muted,
347
+ loop: ref.loop,
348
+ playsinline: ref.playsinline,
349
+ preload: ref.preload,
350
+ ...(ref.poster !== undefined ? { poster: ref.poster } : {}),
351
+ },
352
+ bytes: { ...heaviest, perView: claimPerView, fullBytesKnown: false },
353
+ perViewport: members.map((m) => ({
354
+ viewport: m.obs.viewport.name,
355
+ boxWidth: m.video.renderedWidth,
356
+ dpr: m.obs.viewport.dpr,
357
+ inFirstViewport: m.video.inFirstViewport,
358
+ ...(m.video.hidden ? { hidden: m.video.hiddenReason ?? "display" } : {}),
359
+ bytes: shownBytesAt.get(m) ?? 0,
360
+ })),
361
+ levers,
362
+ ...(hidden !== undefined ? { hidden } : {}),
363
+ };
364
+ if (mode === "audit")
365
+ record.widthOptions = widthOptions(needs, sourceWidth);
366
+ else
367
+ record.widths = clusterVideoWidths(needs, maxWidths, sourceWidth);
368
+ records.push(record);
369
+ }
370
+ return records;
371
+ }