imgtune 0.1.0 → 0.2.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 +74 -0
- package/README.md +41 -0
- package/dist/animated.js +176 -0
- package/dist/backgrounds.js +13 -2
- package/dist/check-report.js +20 -3
- package/dist/check.js +8 -4
- package/dist/cli.js +66 -8
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +495 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +418 -6
- package/dist/image-file.js +154 -34
- package/dist/images.js +27 -2
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/media-encode.js +1050 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +247 -0
- package/dist/media-phase.js +652 -0
- package/dist/media-prepare.js +31 -0
- package/dist/recheck.js +3 -3
- package/dist/remote.js +474 -69
- package/dist/result-cache.js +56 -3
- package/dist/run.js +8 -4
- package/dist/summary.js +23 -3
- package/dist/video-analysis.js +273 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +1 -1
package/dist/result-cache.js
CHANGED
|
@@ -15,13 +15,22 @@ export const CACHE_DIR = join(".imgtune", "cache");
|
|
|
15
15
|
/** The most the cache may hold; the oldest entries go first. */
|
|
16
16
|
export const CACHE_MAX_BYTES = 500 * 1024 * 1024;
|
|
17
17
|
/** Bumped when what an entry means changes, so the old entries are never read again. */
|
|
18
|
-
export const CACHE_VERSION =
|
|
18
|
+
export const CACHE_VERSION = 3;
|
|
19
19
|
const ENTRY_NAME = /^[0-9a-f]{64}\.json$/;
|
|
20
20
|
/** An entry is a key and a number: anything larger is not one of ours. */
|
|
21
21
|
const MAX_ENTRY_BYTES = 1024;
|
|
22
22
|
/** Keys are short and built here; a longer one is refused rather than hashed or written. */
|
|
23
23
|
const MAX_KEY_LENGTH = 4096;
|
|
24
|
+
/**
|
|
25
|
+
* A key longer than this is stored by its hash, `sha256:<hex>`, in the entry's `k` field (its file name is the hash already).
|
|
26
|
+
* Signed and CDN video URLs are often longer than an entry can hold, and their encodes must still be cached.
|
|
27
|
+
*/
|
|
28
|
+
const LONG_KEY = 512;
|
|
24
29
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
30
|
+
/** The `k` an entry of `key` carries: the key itself, or its hash when the key is long (see LONG_KEY). */
|
|
31
|
+
function keyTagOf(key) {
|
|
32
|
+
return key.length > LONG_KEY ? `sha256:${sha256(key)}` : key;
|
|
33
|
+
}
|
|
25
34
|
/** The scope every key of one reference and scorer carries: its original's content hash, width, scorer, encoder and tool. */
|
|
26
35
|
export function cacheScope(original, width, scorerId, encoderId) {
|
|
27
36
|
return [sha256(original), `w${width}`, scorerId, encoderId, `imgtune ${IMGTUNE_VERSION}`, `cache ${CACHE_VERSION}`].join("|");
|
|
@@ -60,7 +69,7 @@ export class ResultCache {
|
|
|
60
69
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
61
70
|
if (parsed === null || typeof parsed !== "object")
|
|
62
71
|
return undefined;
|
|
63
|
-
if (parsed.k !== key || typeof parsed.v !== "number" || !Number.isFinite(parsed.v))
|
|
72
|
+
if (parsed.k !== keyTagOf(key) || typeof parsed.v !== "number" || !Number.isFinite(parsed.v))
|
|
64
73
|
return undefined;
|
|
65
74
|
return parsed.v;
|
|
66
75
|
}
|
|
@@ -77,12 +86,56 @@ export class ResultCache {
|
|
|
77
86
|
await mkdir(this.dir, { recursive: true });
|
|
78
87
|
this.dirReady = true;
|
|
79
88
|
}
|
|
80
|
-
await writeFileAtomic(this.fileOf(key), JSON.stringify({ k: key, v: value }), { root: this.dir });
|
|
89
|
+
await writeFileAtomic(this.fileOf(key), JSON.stringify({ k: keyTagOf(key), v: value }), { root: this.dir });
|
|
81
90
|
}
|
|
82
91
|
catch {
|
|
83
92
|
// Ignored: the number is recomputed the next time.
|
|
84
93
|
}
|
|
85
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The JSON value stored under `key` (see putJson), or undefined: absent, corrupt, of another key, a number entry, or
|
|
97
|
+
* a value that is null. The value is untrusted data: callers check its shape before use.
|
|
98
|
+
*/
|
|
99
|
+
async getJson(key) {
|
|
100
|
+
if (key.length > MAX_KEY_LENGTH)
|
|
101
|
+
return undefined;
|
|
102
|
+
const file = this.fileOf(key);
|
|
103
|
+
try {
|
|
104
|
+
const info = await stat(file);
|
|
105
|
+
if (!info.isFile() || info.size > MAX_ENTRY_BYTES)
|
|
106
|
+
return undefined;
|
|
107
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
108
|
+
if (parsed === null || typeof parsed !== "object")
|
|
109
|
+
return undefined;
|
|
110
|
+
if (parsed.k !== keyTagOf(key) || parsed.j === undefined || parsed.j === null)
|
|
111
|
+
return undefined;
|
|
112
|
+
return parsed.j;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Stores a JSON value under `key`, for small results such as a sample encode's outcome. A value that does not fit an
|
|
120
|
+
* entry (MAX_ENTRY_BYTES) or a key that is too long is not stored, and a failed write is ignored, as for put.
|
|
121
|
+
*/
|
|
122
|
+
async putJson(key, value) {
|
|
123
|
+
if (key.length > MAX_KEY_LENGTH || value === undefined)
|
|
124
|
+
return;
|
|
125
|
+
const text = JSON.stringify({ k: keyTagOf(key), j: value });
|
|
126
|
+
if (text === undefined || Buffer.byteLength(text, "utf8") > MAX_ENTRY_BYTES)
|
|
127
|
+
return;
|
|
128
|
+
try {
|
|
129
|
+
if (!this.dirReady) {
|
|
130
|
+
await mkdir(this.dir, { recursive: true });
|
|
131
|
+
this.dirReady = true;
|
|
132
|
+
}
|
|
133
|
+
await writeFileAtomic(this.fileOf(key), text, { root: this.dir });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Ignored: the value is computed again the next time.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
86
139
|
/** Removes the oldest entries until the folder holds at most 90% of the limit. Only files named like entries are touched. */
|
|
87
140
|
async trim() {
|
|
88
141
|
return trimCache(this.dir, this.maxBytes);
|
package/dist/run.js
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { DEFAULT_VIEWPORTS } from "./config.js";
|
|
7
7
|
import { writeFileAtomic } from "./atomic.js";
|
|
8
|
-
import {
|
|
8
|
+
import { gpuSettingOf, gpuSettingOne } from "./gpu.js";
|
|
9
9
|
import { hintsCommand, parseQualities } from "./hints.js";
|
|
10
10
|
import { locateCommand } from "./locate-only.js";
|
|
11
11
|
import { surveyStop, BROKEN_EXIT } from "./broken.js";
|
|
@@ -43,6 +43,8 @@ export function parseRunArgs(argv) {
|
|
|
43
43
|
const arg = argv[i];
|
|
44
44
|
if (arg === "--no-cache")
|
|
45
45
|
args.noCache = true;
|
|
46
|
+
else if (arg === "--gpu-encode")
|
|
47
|
+
args.gpuEncode = true;
|
|
46
48
|
else if (arg === "--ignore-robots")
|
|
47
49
|
args.ignoreRobots = true;
|
|
48
50
|
else if (arg === "--block-third-party")
|
|
@@ -51,11 +53,11 @@ export function parseRunArgs(argv) {
|
|
|
51
53
|
else if (arg === "--allow-third-party")
|
|
52
54
|
continue;
|
|
53
55
|
else if (arg === "--gpu")
|
|
54
|
-
args.gpu ??=
|
|
56
|
+
args.gpu ??= gpuSettingOne(0);
|
|
55
57
|
else if (arg === "--gpu-device") {
|
|
56
58
|
const value = argValue(argv, i, arg);
|
|
57
59
|
i++;
|
|
58
|
-
args.gpu =
|
|
60
|
+
args.gpu = gpuSettingOf(value);
|
|
59
61
|
}
|
|
60
62
|
else if (arg === "--out" ||
|
|
61
63
|
arg === "--urls-from" ||
|
|
@@ -338,6 +340,8 @@ function runFlags(args) {
|
|
|
338
340
|
common.push("--ignore-robots");
|
|
339
341
|
if (args.noCache)
|
|
340
342
|
common.push("--no-cache");
|
|
343
|
+
if (args.gpuEncode)
|
|
344
|
+
common.push("--gpu-encode");
|
|
341
345
|
if (args.pageConcurrency !== undefined)
|
|
342
346
|
common.push("--page-concurrency", String(args.pageConcurrency));
|
|
343
347
|
if (args.idleTimeoutMs !== undefined)
|
|
@@ -348,7 +352,7 @@ function runFlags(args) {
|
|
|
348
352
|
common.push("--allow-host", shellWord(args.thirdParty.allowHosts.join(",")));
|
|
349
353
|
const deep = [...common];
|
|
350
354
|
if (args.gpu !== undefined)
|
|
351
|
-
deep.push("--gpu-device",
|
|
355
|
+
deep.push("--gpu-device", args.gpu.devices.join(","));
|
|
352
356
|
return { survey: common, deep };
|
|
353
357
|
}
|
|
354
358
|
function recordBroken(broken, phase, page) {
|
package/dist/summary.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// MAX_SUMMARY_LINES lines, so an agent decides the next step from it without opening the JSON.
|
|
3
3
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import { hintsCommand } from "./hints.js";
|
|
5
|
+
import { hintsCommand, videoTotalsLine } from "./hints.js";
|
|
6
6
|
import { safeText, shellWord } from "./text.js";
|
|
7
7
|
import { APPROX_MARK, APPROX_NOTE, pageFileName, siteNumbers, surveyLabelOf } from "./remote.js";
|
|
8
8
|
import { candidatesOf, patternRanking } from "./worst.js";
|
|
@@ -57,6 +57,10 @@ export function renderSummary(input) {
|
|
|
57
57
|
const parts = input.timings.map((t) => `${safeText(t.name)} ${t.seconds.toFixed(0)} s`).join(", ");
|
|
58
58
|
lines.push(`time: ${total.toFixed(0)} s total (${parts})`);
|
|
59
59
|
}
|
|
60
|
+
// The media phase's note is printed once, at the top (the same note is on each page that has media).
|
|
61
|
+
const media = [...(input.survey ?? []), ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
|
|
62
|
+
if (media !== undefined)
|
|
63
|
+
lines.push(safeText(media));
|
|
60
64
|
lines.push("");
|
|
61
65
|
if (input.stop !== undefined) {
|
|
62
66
|
lines.push(`STOPPED before the deep phase: ${safeText(input.stop.reason)}`, ` fix: ${safeText(input.stop.fix)}`, "");
|
|
@@ -67,6 +71,9 @@ export function renderSummary(input) {
|
|
|
67
71
|
lines.push(safeText(input.surveyLabel), "");
|
|
68
72
|
if (input.deep.length > 0)
|
|
69
73
|
lines.push(...deepBlock(input.deep), "");
|
|
74
|
+
const withVideo = input.deep.filter((p) => p.status === "ok" && (p.videos ?? []).length > 0);
|
|
75
|
+
if (withVideo.length > 0)
|
|
76
|
+
lines.push(...videoBlock(withVideo), "");
|
|
70
77
|
if (input.callSites !== undefined)
|
|
71
78
|
lines.push(...callSiteBlock(input.callSites), "");
|
|
72
79
|
if (input.warnings.length > 0)
|
|
@@ -128,6 +135,15 @@ const exactOf = (page) => {
|
|
|
128
135
|
const n = page.page;
|
|
129
136
|
return (n.meanImageBytes * n.exactPct) / 100;
|
|
130
137
|
};
|
|
138
|
+
/** The deep pages with a `<video>`: one line each, the page's video totals (per view, per play, saveable est.). */
|
|
139
|
+
function videoBlock(pages) {
|
|
140
|
+
const shown = Math.min(DEEP_ROWS, pages.length);
|
|
141
|
+
const lines = [`videos: ${pages.length} deep page(s) with <video> (${shown} of ${pages.length} shown)`];
|
|
142
|
+
for (const page of pages.slice(0, DEEP_ROWS)) {
|
|
143
|
+
lines.push(` ${trim(labelOf(page.url), LABEL_WIDTH).padEnd(LABEL_WIDTH)} ${videoTotalsLine(page.videos ?? []) ?? ""}`);
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
131
147
|
function callSiteBlock(sites) {
|
|
132
148
|
if (sites.length === 0)
|
|
133
149
|
return ["call sites by saving per view: none (no deep page has a fix hint)"];
|
|
@@ -159,8 +175,12 @@ export async function readPages(dir) {
|
|
|
159
175
|
return [];
|
|
160
176
|
}
|
|
161
177
|
const pages = [];
|
|
162
|
-
for (const name of names)
|
|
163
|
-
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const page = await readPageFile(join(dir, name));
|
|
180
|
+
// A page without the completeness marker is an interrupted run's: its media results are not in yet (see remote.ts).
|
|
181
|
+
if (page.complete === true)
|
|
182
|
+
pages.push(page);
|
|
183
|
+
}
|
|
164
184
|
return pages;
|
|
165
185
|
}
|
|
166
186
|
async function readPageFile(file) {
|
|
@@ -0,0 +1,273 @@
|
|
|
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 { adapterFor, formatAutoOf } from "./loaders.js";
|
|
8
|
+
import { clusterVideoWidths, videoBytes, widthOptions, } from "./media-model.js";
|
|
9
|
+
/** Widths kept per video in scan mode (`--max-video-widths`, before the flag is wired). */
|
|
10
|
+
export const SCAN_MAX_VIDEO_WIDTHS = 2;
|
|
11
|
+
/**
|
|
12
|
+
* The first index a poster takes as an image element. A poster is not an <img>, so its index is placed far above any
|
|
13
|
+
* real one (the page's <img> indexes), keeping its element identity apart in the image pipeline.
|
|
14
|
+
*/
|
|
15
|
+
export const POSTER_INDEX_BASE = 1_000_000;
|
|
16
|
+
/**
|
|
17
|
+
* 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
|
|
18
|
+
* image ids, images.ts baseKey), with the ordinal of the video among those of its file in document order, so two videos
|
|
19
|
+
* of one file keep distinct ids. The id is `video:` and 12 hex digits of the hash.
|
|
20
|
+
*/
|
|
21
|
+
function videoIdOf(page, key, file, perFile) {
|
|
22
|
+
if (file === undefined)
|
|
23
|
+
return `video:${sha1(`${page}\n${key}`).slice(0, 12)}`;
|
|
24
|
+
const n = perFile.get(file) ?? 0;
|
|
25
|
+
perFile.set(file, n + 1);
|
|
26
|
+
return `video:${sha1(`${page}\nfile:${file}#${n}`).slice(0, 12)}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Components above a video, nearest first: the same rule as remote.ts (componentNamesOf), capped at its MAX_OWNERS (8).
|
|
30
|
+
* Written out here, not imported, so that this module does not import remote.ts at run time.
|
|
31
|
+
*/
|
|
32
|
+
function componentsOf(owners) {
|
|
33
|
+
return owners
|
|
34
|
+
.flatMap((o) => (o.name !== undefined && COMPONENT_NAME.test(o.name) && o.name.length > 2 ? [o.name] : []))
|
|
35
|
+
.slice(0, 8);
|
|
36
|
+
}
|
|
37
|
+
/** The `preload` kind of a video, as the byte rules read it: the autoplay attribute wins over preload. */
|
|
38
|
+
export function preloadKindOf(video) {
|
|
39
|
+
if (video.autoplay)
|
|
40
|
+
return "autoplay";
|
|
41
|
+
switch (video.preload) {
|
|
42
|
+
case "auto":
|
|
43
|
+
return "auto";
|
|
44
|
+
case "metadata":
|
|
45
|
+
return "metadata";
|
|
46
|
+
case "none":
|
|
47
|
+
return "none";
|
|
48
|
+
default:
|
|
49
|
+
return "default";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 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
|
|
54
|
+
* bytes the page moved, so a partial (range) read can be smaller than the file; the largest read is the closest the
|
|
55
|
+
* headers give without a range total.
|
|
56
|
+
*/
|
|
57
|
+
export function fullBytesOf(observations) {
|
|
58
|
+
const full = new Map();
|
|
59
|
+
for (const obs of observations) {
|
|
60
|
+
for (const [url, record] of obs.media ?? [])
|
|
61
|
+
full.set(url, Math.max(full.get(url) ?? 0, record.bytes));
|
|
62
|
+
}
|
|
63
|
+
return full;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The observations with each fetched poster added to its viewport as an image, sized to the video's box. A poster that
|
|
67
|
+
* was not fetched, or a hidden video's, is not added. The added image carries `posterOf` (the video's selector), so its
|
|
68
|
+
* record says it is a poster. Returns new observations; the ones given are not changed.
|
|
69
|
+
*/
|
|
70
|
+
export function withPosterImages(observations) {
|
|
71
|
+
return observations.map((obs) => {
|
|
72
|
+
const posters = [];
|
|
73
|
+
for (const video of obs.videos ?? []) {
|
|
74
|
+
if (video.poster === undefined || video.hidden || video.poster.startsWith("data:"))
|
|
75
|
+
continue;
|
|
76
|
+
if (!obs.responses.get(video.poster)?.buffer)
|
|
77
|
+
continue;
|
|
78
|
+
posters.push({
|
|
79
|
+
index: POSTER_INDEX_BASE + video.index,
|
|
80
|
+
currentSrc: video.poster,
|
|
81
|
+
renderedWidth: video.renderedWidth,
|
|
82
|
+
renderedHeight: video.renderedHeight,
|
|
83
|
+
srcset: "",
|
|
84
|
+
classes: "",
|
|
85
|
+
nextImage: false,
|
|
86
|
+
inFirstViewport: video.inFirstViewport,
|
|
87
|
+
locator: video.locator,
|
|
88
|
+
posterOf: video.locator.selector,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return posters.length > 0 ? { ...obs, images: [...obs.images, ...posters] } : obs;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** The key that finds a video across viewports: its components, else its test id, else its DOM path (as the images). */
|
|
95
|
+
function baseKeyOf(video) {
|
|
96
|
+
const components = componentsOf(video.locator.owners);
|
|
97
|
+
if (components.length > 0)
|
|
98
|
+
return `component:${components.join(">")}`;
|
|
99
|
+
if (video.locator.testid)
|
|
100
|
+
return `testid:${video.locator.testid}`;
|
|
101
|
+
return `css:${video.locator.selector}`;
|
|
102
|
+
}
|
|
103
|
+
/** The videos of the page, grouped by element across viewports, in document order. */
|
|
104
|
+
function groupVideos(observations) {
|
|
105
|
+
const groups = new Map();
|
|
106
|
+
for (const obs of observations) {
|
|
107
|
+
const ordinals = new Map();
|
|
108
|
+
for (const video of obs.videos ?? []) {
|
|
109
|
+
const base = baseKeyOf(video);
|
|
110
|
+
const n = ordinals.get(base) ?? 0;
|
|
111
|
+
ordinals.set(base, n + 1);
|
|
112
|
+
const key = `${base}#${n}`;
|
|
113
|
+
const list = groups.get(key);
|
|
114
|
+
if (list)
|
|
115
|
+
list.push({ obs, video });
|
|
116
|
+
else
|
|
117
|
+
groups.set(key, [{ obs, video }]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return groups;
|
|
121
|
+
}
|
|
122
|
+
/** The bytes the scan saw for a media URL in one viewport (0 when the page did not load it there). */
|
|
123
|
+
function observedBytesOf(obs, url) {
|
|
124
|
+
return obs.media?.get(url)?.bytes ?? 0;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* True when a Cloudinary video URL (`/<cloud>/video/upload/<transforms>/<file>`) has `f_auto` among its transforms. The
|
|
128
|
+
* image loader (loaders.ts) reads `image/upload` only, so the video path is read here.
|
|
129
|
+
*/
|
|
130
|
+
function cloudinaryVideoFormatAuto(url) {
|
|
131
|
+
if (url.hostname !== "res.cloudinary.com")
|
|
132
|
+
return false;
|
|
133
|
+
const upload = /^\/[^/]+\/video\/upload\/(.+)$/.exec(url.pathname);
|
|
134
|
+
if (!upload)
|
|
135
|
+
return false;
|
|
136
|
+
return upload[1].split("/").slice(0, -1).some((segment) => segment.split(",").includes("f_auto"));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The info note of a video whose URL the CDN already serves in the format the browser takes (Cloudinary `f_auto`, imgix
|
|
140
|
+
* `auto=format`): the codec lever does not apply, because the CDN negotiates the format per request. Undefined otherwise.
|
|
141
|
+
*/
|
|
142
|
+
export function formatHandledNote(src, page) {
|
|
143
|
+
let url;
|
|
144
|
+
try {
|
|
145
|
+
url = new URL(src, page);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (cloudinaryVideoFormatAuto(url))
|
|
151
|
+
return "format handled by Cloudinary (f_auto): the CDN serves AV1 or H.264 per browser, no codec change";
|
|
152
|
+
if (!formatAutoOf(src, page))
|
|
153
|
+
return undefined;
|
|
154
|
+
const name = adapterFor(src, page).adapter.name;
|
|
155
|
+
if (name === "imgix")
|
|
156
|
+
return "format handled by imgix (auto=format): the CDN serves the format per browser, no codec change";
|
|
157
|
+
return "format handled by the CDN (f_auto): the CDN serves the format per browser, no codec change";
|
|
158
|
+
}
|
|
159
|
+
/** True when the video's sources name an AV1 stream, by type or by an `.av1.mp4` file name. */
|
|
160
|
+
function hasAv1(video) {
|
|
161
|
+
if (video.sources.some((s) => /av01|av1/i.test(s.type ?? "")))
|
|
162
|
+
return true;
|
|
163
|
+
const urls = [video.currentSrc, ...video.sources.map((s) => s.src)];
|
|
164
|
+
return urls.some((u) => /\.av1\.mp4(?:[?#]|$)/i.test(u));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The VideoRecords of a page, from its observations. Each `<video>` is one record across the viewports it appears in.
|
|
168
|
+
*
|
|
169
|
+
* Bytes: the record takes the viewport where the video costs most per view, so the claim is never low. The full file is
|
|
170
|
+
* `fullBytes` (see fullBytesOf) and is shown per play for every video.
|
|
171
|
+
*
|
|
172
|
+
* Widths: the viewports the video is shown in (hidden ones are left out) are clustered to at most `maxWidths` widths in
|
|
173
|
+
* scan mode, and the plans for one, two and three widths are kept in audit mode, where the agent chooses.
|
|
174
|
+
*
|
|
175
|
+
* Levers (no probe here): `loading` when a video costs bytes on load and is below the fold (or preloads), `codec` when
|
|
176
|
+
* no source is AV1, and `posterMissing` when there is no poster.
|
|
177
|
+
*/
|
|
178
|
+
export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, sources) {
|
|
179
|
+
const records = [];
|
|
180
|
+
// The records of each file so far: a file's videos are told apart by their document order (see videoIdOf).
|
|
181
|
+
const perFile = new Map();
|
|
182
|
+
for (const [key, members] of groupVideos(observations)) {
|
|
183
|
+
const ref = members[0].video;
|
|
184
|
+
const source = sources?.get(ref);
|
|
185
|
+
const src = ref.currentSrc;
|
|
186
|
+
const observedMax = Math.max(0, ...members.map((m) => observedBytesOf(m.obs, m.video.currentSrc)));
|
|
187
|
+
const full = fullBytes.get(src) ?? observedMax;
|
|
188
|
+
const views = members.map((m) => videoBytes({
|
|
189
|
+
kind: preloadKindOf(m.video),
|
|
190
|
+
playing: !m.video.paused || m.video.errorCode === 4,
|
|
191
|
+
observedBytes: observedBytesOf(m.obs, m.video.currentSrc),
|
|
192
|
+
fullBytes: full,
|
|
193
|
+
visible: !m.video.hidden,
|
|
194
|
+
}));
|
|
195
|
+
const heaviest = views.reduce((best, cur) => (cur.perView > best.perView ? cur : best));
|
|
196
|
+
const shown = members.filter((m) => !m.video.hidden);
|
|
197
|
+
const knownWidth = shown.map((m) => m.video.videoWidth).find((w) => w !== undefined && w > 0);
|
|
198
|
+
const needs = shown.map((m) => ({
|
|
199
|
+
viewport: m.obs.viewport.name,
|
|
200
|
+
viewportWidth: m.obs.viewport.width,
|
|
201
|
+
boxWidth: m.video.renderedWidth,
|
|
202
|
+
dpr: m.obs.viewport.dpr,
|
|
203
|
+
}));
|
|
204
|
+
const info = [];
|
|
205
|
+
const handled = formatHandledNote(src, page);
|
|
206
|
+
if (handled !== undefined)
|
|
207
|
+
info.push(handled);
|
|
208
|
+
let sourceWidth = knownWidth;
|
|
209
|
+
if (sourceWidth === undefined) {
|
|
210
|
+
// Without the source width the widths are capped at the largest display need, which is all the page shows.
|
|
211
|
+
sourceWidth = Math.max(1, ...shown.map((m) => Math.ceil(m.video.renderedWidth * m.obs.viewport.dpr)));
|
|
212
|
+
if (shown.length > 0)
|
|
213
|
+
info.push("source width not read: widths are capped at the largest display need");
|
|
214
|
+
}
|
|
215
|
+
const belowFold = members.every((m) => !m.video.inFirstViewport);
|
|
216
|
+
const levers = { info };
|
|
217
|
+
if (heaviest.perView > 0 && (belowFold || ref.preload === "auto")) {
|
|
218
|
+
levers.loading = { perViewBytes: heaviest.perView, belowFold };
|
|
219
|
+
}
|
|
220
|
+
if (handled === undefined && !hasAv1(ref))
|
|
221
|
+
levers.codec = { missing: "av1" };
|
|
222
|
+
if (ref.poster === undefined)
|
|
223
|
+
levers.posterMissing = true;
|
|
224
|
+
const locator = {
|
|
225
|
+
component: source?.component.length ? source.component : componentsOf(ref.locator.owners),
|
|
226
|
+
selector: sanitizeText(ref.locator.selector, 300),
|
|
227
|
+
classes: "",
|
|
228
|
+
loader: "video",
|
|
229
|
+
};
|
|
230
|
+
if (ref.locator.testid !== undefined)
|
|
231
|
+
locator.testid = sanitizeText(ref.locator.testid, 120);
|
|
232
|
+
if (source?.file !== undefined) {
|
|
233
|
+
// The project file:line of the element, when the owner resolved (see remote.ts resolveVideoSources).
|
|
234
|
+
locator.file = source.file;
|
|
235
|
+
locator.line = source.line;
|
|
236
|
+
if (source.via !== undefined)
|
|
237
|
+
locator.via = source.via;
|
|
238
|
+
locator.kind = source.check ?? "unchecked";
|
|
239
|
+
locator.verified = locator.kind === "image";
|
|
240
|
+
if (source.ownerChain?.length)
|
|
241
|
+
locator.ownerChain = source.ownerChain;
|
|
242
|
+
}
|
|
243
|
+
const record = {
|
|
244
|
+
id: videoIdOf(page, key, source?.file, perFile),
|
|
245
|
+
page,
|
|
246
|
+
locator,
|
|
247
|
+
src,
|
|
248
|
+
sources: ref.sources,
|
|
249
|
+
attributes: {
|
|
250
|
+
autoplay: ref.autoplay,
|
|
251
|
+
muted: ref.muted,
|
|
252
|
+
loop: ref.loop,
|
|
253
|
+
playsinline: ref.playsinline,
|
|
254
|
+
preload: ref.preload,
|
|
255
|
+
...(ref.poster !== undefined ? { poster: ref.poster } : {}),
|
|
256
|
+
},
|
|
257
|
+
bytes: { ...heaviest, fullBytesKnown: false },
|
|
258
|
+
perViewport: members.map((m) => ({
|
|
259
|
+
viewport: m.obs.viewport.name,
|
|
260
|
+
boxWidth: m.video.renderedWidth,
|
|
261
|
+
dpr: m.obs.viewport.dpr,
|
|
262
|
+
inFirstViewport: m.video.inFirstViewport,
|
|
263
|
+
})),
|
|
264
|
+
levers,
|
|
265
|
+
};
|
|
266
|
+
if (mode === "audit")
|
|
267
|
+
record.widthOptions = widthOptions(needs, sourceWidth);
|
|
268
|
+
else
|
|
269
|
+
record.widths = clusterVideoWidths(needs, maxWidths, sourceWidth);
|
|
270
|
+
records.push(record);
|
|
271
|
+
}
|
|
272
|
+
return records;
|
|
273
|
+
}
|