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/remote.js
CHANGED
|
@@ -6,13 +6,13 @@ import { launchChromium } from "./browser.js";
|
|
|
6
6
|
import sharp from "sharp";
|
|
7
7
|
import { writeFileAtomic, sha1 } from "./atomic.js";
|
|
8
8
|
import { errorLine, mdCode, mdText, safeText } from "./text.js";
|
|
9
|
-
import { commentHints } from "./hints.js";
|
|
9
|
+
import { commentHints, videoRowOf, videoTotalsLine } from "./hints.js";
|
|
10
10
|
import { lcpLinesOf } from "./lcp.js";
|
|
11
11
|
import { MAX_MAX_QUALITIES, MIN_MAX_QUALITIES, planQualityCap, qualitiesLine } from "./qualities-cap.js";
|
|
12
12
|
export { mdCode, mdText, safeText };
|
|
13
13
|
import { isLocalTarget, systemResolver } from "./address.js";
|
|
14
14
|
import { startEgressProxy } from "./egress-proxy.js";
|
|
15
|
-
import { makeFetcher } from "./fetch.js";
|
|
15
|
+
import { makeFetcher, MAX_IMAGE_BYTES } from "./fetch.js";
|
|
16
16
|
import { sameSite } from "./site.js";
|
|
17
17
|
import { confirmMatch, matchLocalQuality } from "./calibrate.js";
|
|
18
18
|
import { DEFAULT_THIRD_PARTY, isThirdPartyBlocked, parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
|
|
@@ -21,16 +21,19 @@ import { encode, encodedBytes, ENCODE_THREADS, ENCODER_ID, FAST_AVIF_EFFORT } fr
|
|
|
21
21
|
import { adapterFor, formatAutoOf, originalOf } from "./loaders.js";
|
|
22
22
|
import { COMPONENT_NAME, MAX_SOURCE_READ, imageAtLine, parseSourceMap, resolveFunctionLocations, resolveOwnerChain, resolveOwners } from "./locate.js";
|
|
23
23
|
import { buildImageRecords, hasFix, rungOfPage } from "./images.js";
|
|
24
|
+
import { animationOf } from "./animated.js";
|
|
25
|
+
import { fullBytesOf, SCAN_MAX_VIDEO_WIDTHS, videoRecordsOf, withPosterImages } from "./video-analysis.js";
|
|
24
26
|
import { backgroundPage, backgroundTotals, buildBackgroundRecords, displayedCssWidth, MAX_BACKGROUND_ELEMENTS, MAX_BACKGROUND_VALUE, backgroundTooLarge, neededDevicePixels, parseBackgroundImage, parseBackgroundSize, pickLoaded, } from "./backgrounds.js";
|
|
25
27
|
import { applySizesOutcome } from "./sizes-outcome.js";
|
|
26
28
|
import { sampleByPattern, urlsFromSource } from "./urls.js";
|
|
27
29
|
import { groupRoutes, parseRouteMap } from "./routes.js";
|
|
28
30
|
import { countDocumentNavigations, gotoParsed, NETWORK_IDLE_TIMEOUT_MS, settlePage, settleWithin, waitForLoadWithin, BODY_READ_TIMEOUT_MS } from "./measure.js";
|
|
29
31
|
import { makeScorer } from "./metric.js";
|
|
30
|
-
import { gpuNote,
|
|
32
|
+
import { gpuNote, startGpuScorer, gpuSettingOf, gpuSettingOne } from "./gpu.js";
|
|
31
33
|
import { bisectQuality, confirmBisect, minPassingQuality } from "./search.js";
|
|
32
34
|
import { SURVEY_IDLE_TIMEOUT_MS, SURVEY_PAGE_CONCURRENCY, SURVEY_VIEWPORTS, surveyNote } from "./survey.js";
|
|
33
35
|
import { cacheScope, CACHE_DIR, CACHE_VERSION, contentHash, ResultCache, resizeScope } from "./result-cache.js";
|
|
36
|
+
import { attachMediaResults, mediaAssetsOf, partitionByPolicy, probeLengths, refineAssetLength, runMediaPhase, skippedMedia, } from "./media-phase.js";
|
|
34
37
|
import { BROKEN_EXIT, withBroken } from "./broken.js";
|
|
35
38
|
import { EstimateRegistry, hashOf, withEstimates } from "./estimate-jobs.js";
|
|
36
39
|
import { isFolder, sourceRootOf, unreadSourceNote } from "./source-root.js";
|
|
@@ -41,6 +44,7 @@ import { count, measure, renderTiming, resetTiming, TIMING_ENABLED } from "./tim
|
|
|
41
44
|
import { mapLimit, settleLimit } from "./concurrency.js";
|
|
42
45
|
import { getBlob, putBlob, readStagedJson, removeStage, stageDirOf, writeStagedJson } from "./stage.js";
|
|
43
46
|
import { loadGroups } from "./loads.js";
|
|
47
|
+
import { probeFfmpeg } from "./ffmpeg.js";
|
|
44
48
|
export { ipv6Bytes, isBlockedAddress, isLocalTarget, isPrivateAddress, refusedAddress, guardedLookup } from "./address.js";
|
|
45
49
|
export { makeFetcher, MAX_IMAGE_BYTES } from "./fetch.js";
|
|
46
50
|
export { registrableDomain, sameSite } from "./site.js";
|
|
@@ -369,6 +373,11 @@ const VALUE_OPTIONS = [
|
|
|
369
373
|
"--max-qualities",
|
|
370
374
|
"--page-concurrency",
|
|
371
375
|
"--idle-timeout",
|
|
376
|
+
"--media-budget",
|
|
377
|
+
"--media-time",
|
|
378
|
+
"--video-bar",
|
|
379
|
+
"--video-still-bar",
|
|
380
|
+
"--max-video-widths",
|
|
372
381
|
"--urls-from",
|
|
373
382
|
"--sample-per-pattern",
|
|
374
383
|
"--route-map",
|
|
@@ -390,6 +399,13 @@ export function parseRange(name, text, min, max) {
|
|
|
390
399
|
}
|
|
391
400
|
return value;
|
|
392
401
|
}
|
|
402
|
+
/** The media phase's limits and defaults (see media-phase.ts and the --media-* flags). */
|
|
403
|
+
export const DEFAULT_MEDIA_BUDGET = 20;
|
|
404
|
+
const MAX_MEDIA_BUDGET = 1000;
|
|
405
|
+
export const DEFAULT_MEDIA_TIME = 600;
|
|
406
|
+
const MAX_MEDIA_TIME = 3600;
|
|
407
|
+
export const DEFAULT_VIDEO_BAR = 93;
|
|
408
|
+
export const DEFAULT_VIDEO_STILL_BAR = 65;
|
|
393
409
|
/** The arguments after `scan`: one or more page URLs and the options. Throws with a message the CLI prints. */
|
|
394
410
|
export function parseScanArgs(argv) {
|
|
395
411
|
const args = { urls: [], out: DEFAULT_OUT, aggressive: false, quality: true, thirdParty: DEFAULT_THIRD_PARTY };
|
|
@@ -411,9 +427,11 @@ export function parseScanArgs(argv) {
|
|
|
411
427
|
else if (arg === "--resume")
|
|
412
428
|
args.resume = true;
|
|
413
429
|
else if (arg === "--gpu")
|
|
414
|
-
args.gpu ??=
|
|
430
|
+
args.gpu ??= gpuSettingOne(0);
|
|
415
431
|
else if (arg === "--no-cache")
|
|
416
432
|
args.noCache = true;
|
|
433
|
+
else if (arg === "--gpu-encode")
|
|
434
|
+
args.gpuEncode = true;
|
|
417
435
|
else if (arg === "--harness")
|
|
418
436
|
args.harness = true;
|
|
419
437
|
else if (arg === "--survey")
|
|
@@ -447,6 +465,16 @@ export function parseScanArgs(argv) {
|
|
|
447
465
|
args.pageConcurrency = parseRange(arg, value, 1, MAX_PAGE_CONCURRENCY);
|
|
448
466
|
else if (arg === "--idle-timeout")
|
|
449
467
|
args.idleTimeoutMs = parseRange(arg, value, 0, MAX_IDLE_TIMEOUT_MS);
|
|
468
|
+
else if (arg === "--media-budget")
|
|
469
|
+
args.mediaBudget = parseRange(arg, value, 0, MAX_MEDIA_BUDGET);
|
|
470
|
+
else if (arg === "--media-time")
|
|
471
|
+
args.mediaTime = parseRange(arg, value, 1, MAX_MEDIA_TIME);
|
|
472
|
+
else if (arg === "--video-bar")
|
|
473
|
+
args.videoBar = parseRange(arg, value, 0, 100);
|
|
474
|
+
else if (arg === "--video-still-bar")
|
|
475
|
+
args.videoStillBar = parseRange(arg, value, 0, 100);
|
|
476
|
+
else if (arg === "--max-video-widths")
|
|
477
|
+
args.maxVideoWidths = parseRange(arg, value, 1, 3);
|
|
450
478
|
else if (arg === "--urls-from")
|
|
451
479
|
args.urlsFrom = value;
|
|
452
480
|
else if (arg === "--sample-per-pattern")
|
|
@@ -456,7 +484,7 @@ export function parseScanArgs(argv) {
|
|
|
456
484
|
else if (arg === "--storage-state")
|
|
457
485
|
args.storageState = value;
|
|
458
486
|
else if (arg === "--gpu-device")
|
|
459
|
-
args.gpu =
|
|
487
|
+
args.gpu = gpuSettingOf(value);
|
|
460
488
|
else if (arg === "--allow-host")
|
|
461
489
|
allowHosts = parseAllowHosts(value);
|
|
462
490
|
else
|
|
@@ -510,6 +538,26 @@ export async function pixelSize(buffer) {
|
|
|
510
538
|
return undefined;
|
|
511
539
|
}
|
|
512
540
|
}
|
|
541
|
+
/** The animation of a response's bytes, read once per buffer (see animated.ts). Undefined for a still image. */
|
|
542
|
+
const animationMemo = new WeakMap();
|
|
543
|
+
export function animationOfBuffer(buffer) {
|
|
544
|
+
let info = animationMemo.get(buffer);
|
|
545
|
+
if (info === undefined) {
|
|
546
|
+
info = animationOf(buffer) ?? null;
|
|
547
|
+
animationMemo.set(buffer, info);
|
|
548
|
+
}
|
|
549
|
+
return info ?? undefined;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The format the pixel rules judge a file by. A static GIF is a lossless palette file, so it takes the PNG rules (a
|
|
553
|
+
* local resize, and the AVIF that meets the file's own score). An animated GIF takes none of them: its saving is an
|
|
554
|
+
* encode to video or animated AVIF (see video-analysis.ts), so it keeps its own name and gets no estimate here. The
|
|
555
|
+
* bytes must really be a GIF (its signature), so a label alone never routes a file to the PNG rules.
|
|
556
|
+
*/
|
|
557
|
+
export function pixelRuleFormat(format, buffer) {
|
|
558
|
+
const isGif = buffer.toString("latin1", 0, 4) === "GIF8";
|
|
559
|
+
return format === "gif" && isGif && animationOfBuffer(buffer) === undefined ? "png" : format;
|
|
560
|
+
}
|
|
513
561
|
/** The file decoded to 8-bit RGBA; resized to `size` when given and different (the scorer needs equal dimensions). */
|
|
514
562
|
export async function decodeRaw(buffer, size) {
|
|
515
563
|
const { data, info } = await openImage(buffer).rotate().ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
@@ -613,7 +661,7 @@ export async function matchedQualityOf(buffer, format) {
|
|
|
613
661
|
*/
|
|
614
662
|
export async function resizedEstimate(buffer, format, requestedWidth, quality) {
|
|
615
663
|
const targetWidth = clampWidth(requestedWidth);
|
|
616
|
-
if (format === "png") {
|
|
664
|
+
if (pixelRuleFormat(format, buffer) === "png") {
|
|
617
665
|
const out = await openImage(buffer).rotate().resize({ width: targetWidth, withoutEnlargement: true }).png().toBuffer();
|
|
618
666
|
return { bytes: out.length, buffer: out };
|
|
619
667
|
}
|
|
@@ -784,7 +832,8 @@ export async function estimateQuality(input, deltas, deps) {
|
|
|
784
832
|
}
|
|
785
833
|
return { ratio, target };
|
|
786
834
|
}
|
|
787
|
-
|
|
835
|
+
const lossless = pixelRuleFormat(input.format, input.served);
|
|
836
|
+
if (lossless === "jpeg" || lossless === "png") {
|
|
788
837
|
const servedRaw = await measure("decode: served", () => decodeRaw(input.served, { width: reference.width, height: reference.height }));
|
|
789
838
|
const ownScore = await score(reference, servedRaw);
|
|
790
839
|
for (const delta of deltas) {
|
|
@@ -806,9 +855,75 @@ function isNavigationError(err) {
|
|
|
806
855
|
export const LAYOUT_DEPTH = 15;
|
|
807
856
|
/** React owners read per image, nearest first. */
|
|
808
857
|
export const MAX_OWNERS = 8;
|
|
858
|
+
/**
|
|
859
|
+
* Whether a <video> source is out of scope: a blob or media-stream URL (made by the page, not a file), an adaptive stream
|
|
860
|
+
* (HLS .m3u8 or DASH .mpd, with or without a query string), or no source at all. readView applies the same rule in the
|
|
861
|
+
* browser, before a video leaves the page, so the rule is written there too.
|
|
862
|
+
*/
|
|
863
|
+
export function isOutOfScopeVideo(src) {
|
|
864
|
+
return src.trim() === "" || /^(blob|mediastream):/i.test(src) || /\.(m3u8|mpd)(?:[?#]|$)/i.test(src);
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* The bytes a media response carries, from its headers: Content-Length, else the span of Content-Range (`bytes a-b/total`
|
|
868
|
+
* is b - a + 1). Undefined when neither gives a length. The body is never read.
|
|
869
|
+
*/
|
|
870
|
+
export function mediaBytesOf(headers) {
|
|
871
|
+
const length = headers["content-length"];
|
|
872
|
+
if (length !== undefined && /^\d+$/.test(length.trim()))
|
|
873
|
+
return Number(length);
|
|
874
|
+
const range = /^bytes (\d+)-(\d+)\/(\d+|\*)$/i.exec((headers["content-range"] ?? "").trim());
|
|
875
|
+
if (range && Number(range[2]) >= Number(range[1]))
|
|
876
|
+
return Number(range[2]) - Number(range[1]) + 1;
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The start and the total of a Content-Range header (`bytes a-b/total`), when it has them. Undefined fields when the
|
|
881
|
+
* header is absent or does not parse (a 200 has none, so its bytes start at 0).
|
|
882
|
+
*/
|
|
883
|
+
export function contentRangeOf(headers) {
|
|
884
|
+
const range = /^bytes (\d+)-(\d+)\/(\d+|\*)$/i.exec((headers["content-range"] ?? "").trim());
|
|
885
|
+
if (!range)
|
|
886
|
+
return {};
|
|
887
|
+
const total = range[3] === "*" ? undefined : Number(range[3]);
|
|
888
|
+
return { start: Number(range[1]), ...(total !== undefined ? { total } : {}) };
|
|
889
|
+
}
|
|
890
|
+
/** Merges [start, end) ranges into the ascending, disjoint list that covers the same bytes. */
|
|
891
|
+
export function mergeRanges(ranges) {
|
|
892
|
+
const sorted = ranges.filter(([a, b]) => b > a).sort((x, y) => x[0] - y[0] || x[1] - y[1]);
|
|
893
|
+
const merged = [];
|
|
894
|
+
for (const [start, end] of sorted) {
|
|
895
|
+
const last = merged[merged.length - 1];
|
|
896
|
+
if (last && start <= last[1])
|
|
897
|
+
last[1] = Math.max(last[1], end);
|
|
898
|
+
else
|
|
899
|
+
merged.push([start, end]);
|
|
900
|
+
}
|
|
901
|
+
return merged;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Adds one media response's bytes to the page load's map (see readGroup). The response covers [start, start + bytes) (a
|
|
905
|
+
* 200 starts at 0), so a re-request of bytes already received adds nothing: the figure is the union of the ranges, capped
|
|
906
|
+
* at the file's total when one is known. The status and content type are the last ones seen. Every media response counts,
|
|
907
|
+
* a 200 as well as a 206 (a server that ignores Range sends the whole file, and the bytes the page moved are the union).
|
|
908
|
+
*/
|
|
909
|
+
export function addMediaResponse(media, response) {
|
|
910
|
+
const prev = media.get(response.url);
|
|
911
|
+
const start = response.start ?? 0;
|
|
912
|
+
const ranges = mergeRanges([...(prev?.ranges ?? []), [start, start + response.bytes]]);
|
|
913
|
+
const total = response.total ?? prev?.total;
|
|
914
|
+
const union = ranges.reduce((sum, [a, b]) => sum + (b - a), 0);
|
|
915
|
+
media.set(response.url, {
|
|
916
|
+
url: response.url,
|
|
917
|
+
contentType: response.contentType ?? prev?.contentType,
|
|
918
|
+
bytes: total !== undefined ? Math.min(union, total) : union,
|
|
919
|
+
status: response.status,
|
|
920
|
+
ranges,
|
|
921
|
+
...(total !== undefined ? { total } : {}),
|
|
922
|
+
});
|
|
923
|
+
}
|
|
809
924
|
/** One read of the page as it stands: its images and whether it is a Next.js app. */
|
|
810
|
-
async function readView(page, light = false) {
|
|
811
|
-
const seen = await page.evaluate(({ depth, maxOwners, maxBackgrounds, maxValue, light }) => {
|
|
925
|
+
async function readView(page, light = false, withVideos = false) {
|
|
926
|
+
const seen = await page.evaluate(({ depth, maxOwners, maxBackgrounds, maxValue, light, withVideos }) => {
|
|
812
927
|
const num = (value) => {
|
|
813
928
|
const n = parseFloat(value);
|
|
814
929
|
return Number.isFinite(n) ? n : 0;
|
|
@@ -1007,8 +1122,61 @@ async function readView(page, light = false) {
|
|
|
1007
1122
|
});
|
|
1008
1123
|
}
|
|
1009
1124
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1125
|
+
// <video> elements with a file source. Blob, media-stream and adaptive sources, and an empty src, are dropped here
|
|
1126
|
+
// (the rule of isOutOfScopeVideo, written out because this function cannot import it). A data: URI is replaced
|
|
1127
|
+
// before it leaves the browser. A light read takes none unless it asks (a locate joins the dev page's videos: the DOM
|
|
1128
|
+
// read is cheap, and no media is read). Only the top document is read: a video in an iframe is not.
|
|
1129
|
+
const videos = [];
|
|
1130
|
+
if (!light || withVideos) {
|
|
1131
|
+
const OUT_OF_SCOPE = (src) => src.trim() === "" || /^(blob|mediastream):/i.test(src) || /\.(m3u8|mpd)(?:[?#]|$)/i.test(src);
|
|
1132
|
+
const shortUrl = (url) => (url.startsWith("data:") ? "data:" : url);
|
|
1133
|
+
document.querySelectorAll("video").forEach((video, index) => {
|
|
1134
|
+
// A src attribute wins: the browser ignores the <source> children then, so they are not read at all.
|
|
1135
|
+
const hasSrcAttribute = (video.getAttribute("src") ?? "").trim() !== "";
|
|
1136
|
+
const sourceEls = hasSrcAttribute ? [] : Array.from(video.querySelectorAll("source"));
|
|
1137
|
+
// The source the browser plays. When it chose none (every source refused, so currentSrc is ""), the src attribute,
|
|
1138
|
+
// else the first in-scope <source>. Dropped only when none is in scope.
|
|
1139
|
+
const inScope = sourceEls.map((source) => source.src).filter((src) => !OUT_OF_SCOPE(src));
|
|
1140
|
+
const playing = video.currentSrc !== "" ? video.currentSrc : hasSrcAttribute ? video.src : (inScope[0] ?? video.src);
|
|
1141
|
+
if (OUT_OF_SCOPE(playing))
|
|
1142
|
+
return;
|
|
1143
|
+
const cs = getComputedStyle(video);
|
|
1144
|
+
const rect = video.getBoundingClientRect();
|
|
1145
|
+
videos.push({
|
|
1146
|
+
index,
|
|
1147
|
+
currentSrc: shortUrl(playing),
|
|
1148
|
+
// The out-of-scope <source> children are left out, as the video itself is when its source is out of scope.
|
|
1149
|
+
sources: sourceEls
|
|
1150
|
+
.filter((source) => !OUT_OF_SCOPE(source.src))
|
|
1151
|
+
.map((source) => ({
|
|
1152
|
+
src: shortUrl(source.src),
|
|
1153
|
+
type: source.getAttribute("type") ?? undefined,
|
|
1154
|
+
media: source.getAttribute("media") ?? undefined,
|
|
1155
|
+
})),
|
|
1156
|
+
autoplay: video.autoplay,
|
|
1157
|
+
muted: video.muted,
|
|
1158
|
+
loop: video.loop,
|
|
1159
|
+
playsinline: video.hasAttribute("playsinline"),
|
|
1160
|
+
controls: video.controls,
|
|
1161
|
+
preload: video.getAttribute("preload") ?? "",
|
|
1162
|
+
poster: video.poster ? shortUrl(video.poster) : undefined,
|
|
1163
|
+
paused: video.paused,
|
|
1164
|
+
...(video.error ? { errorCode: video.error.code } : {}),
|
|
1165
|
+
renderedWidth: rect.width,
|
|
1166
|
+
renderedHeight: rect.height,
|
|
1167
|
+
...(video.videoWidth > 0 ? { videoWidth: video.videoWidth } : {}),
|
|
1168
|
+
inFirstViewport: rect.bottom > 0 && rect.top < window.innerHeight,
|
|
1169
|
+
hidden: cs.display === "none" || cs.visibility === "hidden" || rect.width === 0 || rect.height === 0,
|
|
1170
|
+
locator: {
|
|
1171
|
+
testid: video.closest("[data-testid]")?.getAttribute("data-testid") ?? undefined,
|
|
1172
|
+
selector: cssPath(video),
|
|
1173
|
+
owners: ownersOf(fiberOf(video)),
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
return { nextjs, images, backgrounds, videos };
|
|
1179
|
+
}, { depth: LAYOUT_DEPTH, maxOwners: MAX_OWNERS, maxBackgrounds: MAX_BACKGROUND_ELEMENTS, maxValue: MAX_BACKGROUND_VALUE, light, withVideos });
|
|
1012
1180
|
const html = seen.images.length === 0 ? await page.content() : "";
|
|
1013
1181
|
return { seen, html };
|
|
1014
1182
|
}
|
|
@@ -1093,7 +1261,7 @@ export async function readLcp(page) {
|
|
|
1093
1261
|
* location change after load). At most MAX_POST_LOAD_NAVIGATIONS repeats; past that, the last complete read is used.
|
|
1094
1262
|
* `readLcpFirst`: the LCP is read before the first scroll, and kept with the read it belongs to (fresh loads only).
|
|
1095
1263
|
*/
|
|
1096
|
-
async function settledView(page, navigations, loadCapMs, light = false, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS, afterSettle, readLcpFirst = false) {
|
|
1264
|
+
async function settledView(page, navigations, loadCapMs, light = false, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS, afterSettle, readLcpFirst = false, withVideos = false) {
|
|
1097
1265
|
let last;
|
|
1098
1266
|
for (let repeat = 0;; repeat++) {
|
|
1099
1267
|
const start = navigations();
|
|
@@ -1112,7 +1280,7 @@ async function settledView(page, navigations, loadCapMs, light = false, idleTime
|
|
|
1112
1280
|
});
|
|
1113
1281
|
if (afterSettle !== undefined)
|
|
1114
1282
|
await afterSettle();
|
|
1115
|
-
view = await measure("read view", () => readView(page, light));
|
|
1283
|
+
view = await measure("read view", () => readView(page, light, withVideos));
|
|
1116
1284
|
view.lcp = lcp;
|
|
1117
1285
|
}
|
|
1118
1286
|
catch (err) {
|
|
@@ -1295,13 +1463,31 @@ function responsesOfStep(fetched, answered, seen, dpr) {
|
|
|
1295
1463
|
*
|
|
1296
1464
|
* `light` (the locate and sizes-only paths) reads no image bodies and no layout chain, and is always a single fresh load.
|
|
1297
1465
|
*/
|
|
1298
|
-
async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs) {
|
|
1466
|
+
async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs, withVideos = false) {
|
|
1299
1467
|
const first = viewports[0];
|
|
1300
1468
|
const outcomes = [];
|
|
1301
1469
|
let fresh = [];
|
|
1302
1470
|
// Every image response of the document, by URL (the latest one for a URL fetched twice).
|
|
1303
1471
|
const fetched = new Map();
|
|
1472
|
+
// Every media (video) response of the page load, by URL: its bytes from the headers, never from the body.
|
|
1473
|
+
const media = new Map();
|
|
1304
1474
|
const bodyReads = [];
|
|
1475
|
+
// The media bodies, awaited on their own: they share one deadline for the whole page load (see mediaDeadline below).
|
|
1476
|
+
const mediaReads = [];
|
|
1477
|
+
// Per media response: counts the bytes that arrived, for a response still streaming when the media reads settle.
|
|
1478
|
+
const mediaFallbacks = [];
|
|
1479
|
+
// The media requests in flight, by request id: the bytes received so far (CDP dataReceived), for the fallback above.
|
|
1480
|
+
const inFlightMedia = new Map();
|
|
1481
|
+
const streamedBytesOf = (url) => {
|
|
1482
|
+
let bytes = 0;
|
|
1483
|
+
for (const entry of inFlightMedia.values())
|
|
1484
|
+
if (entry.url === url)
|
|
1485
|
+
bytes += entry.bytes;
|
|
1486
|
+
return bytes;
|
|
1487
|
+
};
|
|
1488
|
+
// One deadline for the media reads of the whole page load: the viewport steps share it, so a stream that never ends
|
|
1489
|
+
// costs BODY_READ_TIMEOUT_MS once per page, not once per viewport.
|
|
1490
|
+
let mediaDeadline;
|
|
1305
1491
|
let blockedRequests = 0;
|
|
1306
1492
|
let thirdPartyBlocked = 0;
|
|
1307
1493
|
// Stylesheet URLs the browser could not apply (refused, failed or blocked): one entry per URL.
|
|
@@ -1389,6 +1575,40 @@ async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageS
|
|
|
1389
1575
|
if (response.request().resourceType() === "stylesheet" && response.status() >= 400)
|
|
1390
1576
|
failedSheets.add(response.url());
|
|
1391
1577
|
const contentType = response.headers()["content-type"];
|
|
1578
|
+
// A media response is sized once it has finished, from the browser's count of its body (sizes): the body is never
|
|
1579
|
+
// read, since a video can be far too big to buffer. Chunked answers have no Content-Length, so the headers are the
|
|
1580
|
+
// fallback only: when sizes fails or reports 0, or when the response is still streaming at the settle (see below).
|
|
1581
|
+
// A light read reads no media at all: it waits on none of it.
|
|
1582
|
+
if (response.request().resourceType() === "media" || (contentType ?? "").toLowerCase().startsWith("video/")) {
|
|
1583
|
+
if (light)
|
|
1584
|
+
return;
|
|
1585
|
+
const url = response.url();
|
|
1586
|
+
const status = response.status();
|
|
1587
|
+
const headers = response.headers();
|
|
1588
|
+
const headerBytes = mediaBytesOf(headers) ?? 0;
|
|
1589
|
+
const { start, total } = contentRangeOf(headers);
|
|
1590
|
+
let counted = false;
|
|
1591
|
+
const count = (bytes) => {
|
|
1592
|
+
if (counted)
|
|
1593
|
+
return;
|
|
1594
|
+
counted = true;
|
|
1595
|
+
addMediaResponse(media, { url, status, contentType, bytes, start, total });
|
|
1596
|
+
};
|
|
1597
|
+
mediaReads.push((async () => {
|
|
1598
|
+
try {
|
|
1599
|
+
// An aborted range request also finishes (with an error): its sizes still count what was received.
|
|
1600
|
+
await response.finished();
|
|
1601
|
+
const { responseBodySize } = await response.request().sizes();
|
|
1602
|
+
count(responseBodySize > 0 ? responseBodySize : headerBytes);
|
|
1603
|
+
}
|
|
1604
|
+
catch {
|
|
1605
|
+
count(headerBytes);
|
|
1606
|
+
}
|
|
1607
|
+
})());
|
|
1608
|
+
// Still streaming at the settle: the bytes that have arrived so far (or the header bytes, when those are more).
|
|
1609
|
+
mediaFallbacks.push(() => count(Math.max(headerBytes, streamedBytesOf(url))));
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1392
1612
|
const isImage = response.request().resourceType() === "image" || (contentType ?? "").toLowerCase().startsWith("image/");
|
|
1393
1613
|
if (!isImage)
|
|
1394
1614
|
return;
|
|
@@ -1427,13 +1647,28 @@ async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageS
|
|
|
1427
1647
|
navigations = await countDocumentNavigations(context, page);
|
|
1428
1648
|
}
|
|
1429
1649
|
// The requests in flight, for the network-idle wait after a resize (the browser's own events).
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1650
|
+
// Always opened: the media bytes that arrive before a settle are read from its data events (see inFlightMedia).
|
|
1651
|
+
const cdp = await context.newCDPSession(page);
|
|
1652
|
+
// A media request is not counted as busy: a streaming autoplay video never finishes, and would hold every resize's
|
|
1653
|
+
// network-idle wait to its cap. Its bytes are still counted per response (see the media branch above).
|
|
1654
|
+
cdp.on("Network.requestWillBeSent", (event) => {
|
|
1655
|
+
if (event.type === "Media")
|
|
1656
|
+
inFlightMedia.set(event.requestId, { url: event.request?.url ?? "", bytes: 0 });
|
|
1657
|
+
else
|
|
1658
|
+
busy.add(event.requestId);
|
|
1659
|
+
});
|
|
1660
|
+
cdp.on("Network.dataReceived", (event) => {
|
|
1661
|
+
const entry = inFlightMedia.get(event.requestId);
|
|
1662
|
+
if (entry !== undefined)
|
|
1663
|
+
entry.bytes += event.dataLength ?? 0;
|
|
1664
|
+
});
|
|
1665
|
+
const done = (event) => {
|
|
1666
|
+
busy.delete(event.requestId);
|
|
1667
|
+
inFlightMedia.delete(event.requestId);
|
|
1668
|
+
};
|
|
1669
|
+
cdp.on("Network.loadingFinished", done);
|
|
1670
|
+
cdp.on("Network.loadingFailed", done);
|
|
1671
|
+
await cdp.send("Network.enable");
|
|
1437
1672
|
const resizeState = { userAgent: first.userAgent };
|
|
1438
1673
|
let loaded;
|
|
1439
1674
|
for (const [step, viewport] of viewports.entries()) {
|
|
@@ -1449,7 +1684,10 @@ async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageS
|
|
|
1449
1684
|
await measure(`resize ${viewport.name}`, () => resizeTo(cdp, page, viewport, resizeState));
|
|
1450
1685
|
}
|
|
1451
1686
|
// The LCP belongs to the page's own load: a fresh load's viewport reads it (a resized one shares that load's LCP).
|
|
1452
|
-
const { seen, html, lcp } = await settledView(page, navigations, loadCapMs, light, idleTimeoutMs,
|
|
1687
|
+
const { seen, html, lcp } = await settledView(page, navigations, loadCapMs, light, idleTimeoutMs,
|
|
1688
|
+
// Every full read waits on the busy set, fresh load included: Playwright's networkidle counts the media requests
|
|
1689
|
+
// too, so a streaming video would hold a fresh load to the cap. A light fresh load waits for nothing, as before.
|
|
1690
|
+
step === 0 && light ? undefined : () => waitNetworkQuiet(() => busy.size, idleTimeoutMs), !light && step === 0, withVideos);
|
|
1453
1691
|
if (step > 0 && navigations() !== navigatedBefore) {
|
|
1454
1692
|
fresh = viewports.slice(step);
|
|
1455
1693
|
break;
|
|
@@ -1466,6 +1704,11 @@ async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageS
|
|
|
1466
1704
|
await debug.session.detach().catch(() => undefined);
|
|
1467
1705
|
}
|
|
1468
1706
|
await measure("body reads", () => settleWithin(bodyReads, BODY_READ_TIMEOUT_MS));
|
|
1707
|
+
mediaDeadline ??= Date.now() + BODY_READ_TIMEOUT_MS;
|
|
1708
|
+
await measure("media reads", () => settleWithin(mediaReads, Math.max(0, mediaDeadline - Date.now())));
|
|
1709
|
+
// A media response still streaming at the settle counts the bytes that arrived; it is not waited for.
|
|
1710
|
+
for (const fallback of mediaFallbacks)
|
|
1711
|
+
fallback();
|
|
1469
1712
|
const responses = step === 0 ? new Map(fetched) : responsesOfStep(fetched, answered, seen, viewport.dpr);
|
|
1470
1713
|
count("image responses", responses.size);
|
|
1471
1714
|
for (const record of responses.values()) {
|
|
@@ -1489,6 +1732,9 @@ async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageS
|
|
|
1489
1732
|
nextjs: seen.nextjs,
|
|
1490
1733
|
images: seen.images,
|
|
1491
1734
|
backgrounds: seen.backgrounds,
|
|
1735
|
+
videos: seen.videos,
|
|
1736
|
+
// A copy: the page load's records keep growing for the viewports after this one.
|
|
1737
|
+
media: new Map(media),
|
|
1492
1738
|
stylesheetsFailed: failedSheets.size,
|
|
1493
1739
|
stylesheetHosts: hostsOf(failedSheets),
|
|
1494
1740
|
responses,
|
|
@@ -1516,17 +1762,17 @@ async function readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageS
|
|
|
1516
1762
|
* The observations of one load group (see readGroup), one outcome per viewport in the order given. A group of one
|
|
1517
1763
|
* viewport is a fresh load: the default. `--shared-loads` groups the viewports of a class into one load.
|
|
1518
1764
|
*/
|
|
1519
|
-
export async function observeLoads(browser, pageUrl, viewports, guard, loadCapMs = LOAD_EVENT_CAP_MS, storageState, light = false, functionsWanted, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS) {
|
|
1520
|
-
const { outcomes, fresh } = await readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs);
|
|
1765
|
+
export async function observeLoads(browser, pageUrl, viewports, guard, loadCapMs = LOAD_EVENT_CAP_MS, storageState, light = false, functionsWanted, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS, withVideos = false) {
|
|
1766
|
+
const { outcomes, fresh } = await readGroup(browser, pageUrl, viewports, guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs, withVideos);
|
|
1521
1767
|
for (const viewport of fresh) {
|
|
1522
|
-
const [outcome] = await observeLoads(browser, pageUrl, [viewport], guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs);
|
|
1768
|
+
const [outcome] = await observeLoads(browser, pageUrl, [viewport], guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs, withVideos);
|
|
1523
1769
|
outcomes.push(outcome);
|
|
1524
1770
|
}
|
|
1525
1771
|
return outcomes;
|
|
1526
1772
|
}
|
|
1527
1773
|
/** One viewport's observation by a fresh load (the locate and sizes-only paths). */
|
|
1528
|
-
async function observeViewport(browser, pageUrl, viewport, guard, loadCapMs = LOAD_EVENT_CAP_MS, storageState, light = false, functionsWanted, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS) {
|
|
1529
|
-
const [outcome] = await observeLoads(browser, pageUrl, [viewport], guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs);
|
|
1774
|
+
async function observeViewport(browser, pageUrl, viewport, guard, loadCapMs = LOAD_EVENT_CAP_MS, storageState, light = false, functionsWanted, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS, withVideos = false) {
|
|
1775
|
+
const [outcome] = await observeLoads(browser, pageUrl, [viewport], guard, loadCapMs, storageState, light, functionsWanted, idleTimeoutMs, withVideos);
|
|
1530
1776
|
if (!outcome.ok)
|
|
1531
1777
|
throw outcome.error;
|
|
1532
1778
|
return outcome.value;
|
|
@@ -1612,22 +1858,43 @@ async function resolveSources(ctx, observations, wanted) {
|
|
|
1612
1858
|
for (const img of obs.images) {
|
|
1613
1859
|
if (wanted && !wanted(img))
|
|
1614
1860
|
continue;
|
|
1615
|
-
|
|
1616
|
-
const chain = owners.length > 0 ? await resolveOwnerChain(owners, (chunk) => loadSourceMap(ctx, chunk)) : [];
|
|
1617
|
-
const dev = chain[0] !== undefined ? { file: chain[0].file, line: chain[0].line } : undefined;
|
|
1618
|
-
// A production build has no owner source: the function locations of the owners stand in (prod-sourcemap).
|
|
1619
|
-
const prod = dev === undefined && img.locator?.functionLocations?.length
|
|
1620
|
-
? await resolveFunctionLocations(img.locator.functionLocations, (chunk) => loadSourceMap(ctx, chunk))
|
|
1621
|
-
: undefined;
|
|
1622
|
-
const found = dev ?? prod;
|
|
1623
|
-
const via = dev ? "react-dev" : prod ? "prod-sourcemap" : undefined;
|
|
1624
|
-
const component = componentNamesOf(owners);
|
|
1625
|
-
const check = found ? await checkSource(ctx, texts, found.file, found.line, component[0]) : undefined;
|
|
1626
|
-
resolved.set(img, { file: found?.file, line: found?.line, component, check, via, ownerChain: chain.length > 0 ? chain : undefined });
|
|
1861
|
+
resolved.set(img, await resolveLocator(ctx, texts, img.locator));
|
|
1627
1862
|
}
|
|
1628
1863
|
}
|
|
1629
1864
|
return resolved;
|
|
1630
1865
|
}
|
|
1866
|
+
/**
|
|
1867
|
+
* The owner source of each observed <video> (the same rule as an image's, see resolveLocator): its file:line on a dev build,
|
|
1868
|
+
* or the function location on a production build, and the check of that line. Used by the scan's video records and by a
|
|
1869
|
+
* locate, which joins the dev page's videos to the measured ones.
|
|
1870
|
+
*/
|
|
1871
|
+
async function resolveVideoSources(ctx, observations) {
|
|
1872
|
+
const resolved = new Map();
|
|
1873
|
+
const texts = new Map();
|
|
1874
|
+
for (const obs of observations) {
|
|
1875
|
+
for (const video of obs.videos ?? [])
|
|
1876
|
+
resolved.set(video, await resolveLocator(ctx, texts, video.locator));
|
|
1877
|
+
}
|
|
1878
|
+
return resolved;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* The source of one observed element from its locator: the React owner chain (dev builds, through the source map), else the
|
|
1882
|
+
* owner's function location (production builds), and the check of the file:line against the project file.
|
|
1883
|
+
*/
|
|
1884
|
+
async function resolveLocator(ctx, texts, locator) {
|
|
1885
|
+
const owners = locator?.owners ?? [];
|
|
1886
|
+
const chain = owners.length > 0 ? await resolveOwnerChain(owners, (chunk) => loadSourceMap(ctx, chunk)) : [];
|
|
1887
|
+
const dev = chain[0] !== undefined ? { file: chain[0].file, line: chain[0].line } : undefined;
|
|
1888
|
+
// A production build has no owner source: the function locations of the owners stand in (prod-sourcemap).
|
|
1889
|
+
const prod = dev === undefined && locator?.functionLocations?.length
|
|
1890
|
+
? await resolveFunctionLocations(locator.functionLocations, (chunk) => loadSourceMap(ctx, chunk))
|
|
1891
|
+
: undefined;
|
|
1892
|
+
const found = dev ?? prod;
|
|
1893
|
+
const via = dev ? "react-dev" : prod ? "prod-sourcemap" : undefined;
|
|
1894
|
+
const component = componentNamesOf(owners);
|
|
1895
|
+
const check = found ? await checkSource(ctx, texts, found.file, found.line, component[0]) : undefined;
|
|
1896
|
+
return { file: found?.file, line: found?.line, component, check, via, ownerChain: chain.length > 0 ? chain : undefined };
|
|
1897
|
+
}
|
|
1631
1898
|
const formatName = (format) => (format === "jpg" ? "jpeg" : format);
|
|
1632
1899
|
/**
|
|
1633
1900
|
* The owner of each CSS background element, by its index (the first viewport that has it). Owners give the component
|
|
@@ -1708,7 +1975,10 @@ async function measureBackground(ctx, obs, bg, layerIndex, layer, size) {
|
|
|
1708
1975
|
needed,
|
|
1709
1976
|
undersized: served.width < UNDERSIZED_RATIO * needed,
|
|
1710
1977
|
});
|
|
1711
|
-
|
|
1978
|
+
const animated = animationOfBuffer(response.buffer);
|
|
1979
|
+
if (animated !== undefined)
|
|
1980
|
+
instance.animated = animated;
|
|
1981
|
+
if (ctx.estimates !== false && animated === undefined && served.width > PLAIN_RESIZE_RATIO * needed && served.format) {
|
|
1712
1982
|
const resized = await measure("background resize estimate", () => resizeEstimateOf(ctx, response.buffer, served.format, needed));
|
|
1713
1983
|
if (resized)
|
|
1714
1984
|
Object.assign(instance, approxEst(Math.max(0, response.bytes - resized.bytes), resized.approx));
|
|
@@ -1753,6 +2023,9 @@ async function baseline(ctx, obs, img) {
|
|
|
1753
2023
|
if (!served)
|
|
1754
2024
|
return skip("not decodable");
|
|
1755
2025
|
Object.assign(instance, { bytes: response.bytes, pixelWidth: served.width, format: formatName(served.format) });
|
|
2026
|
+
const animated = animationOfBuffer(response.buffer);
|
|
2027
|
+
if (animated !== undefined)
|
|
2028
|
+
instance.animated = animated;
|
|
1756
2029
|
const candidates = parseSrcset(img.srcset, ctx.pageUrl);
|
|
1757
2030
|
if (candidates.length > 0) {
|
|
1758
2031
|
const largest = Math.max(...candidates.map((c) => c.width));
|
|
@@ -1790,9 +2063,10 @@ async function baseline(ctx, obs, img) {
|
|
|
1790
2063
|
}
|
|
1791
2064
|
return instance;
|
|
1792
2065
|
}
|
|
1793
|
-
// No w-descriptor srcset: a wider file than needed is estimated by a local resize at its own format (est.).
|
|
2066
|
+
// No w-descriptor srcset: a wider file than needed is estimated by a local resize at its own format (est.). An animated
|
|
2067
|
+
// file gets no resize estimate: its saving is an encode.
|
|
1794
2068
|
instance.kind = "plain";
|
|
1795
|
-
if (ctx.estimates !== false && served.width > PLAIN_RESIZE_RATIO * needed && served.format) {
|
|
2069
|
+
if (ctx.estimates !== false && instance.animated === undefined && served.width > PLAIN_RESIZE_RATIO * needed && served.format) {
|
|
1796
2070
|
const resized = await measure("plain resize estimate", () => resizeEstimateOf(ctx, response.buffer, served.format, needed));
|
|
1797
2071
|
if (resized)
|
|
1798
2072
|
Object.assign(instance, approxEst(Math.max(0, response.bytes - resized.bytes), resized.approx));
|
|
@@ -1839,7 +2113,8 @@ export const CDN_AUTO_QUALITY_SHARE = 0.1;
|
|
|
1839
2113
|
*/
|
|
1840
2114
|
export function applyCdnPolicies(instances, viewportCount) {
|
|
1841
2115
|
for (const inst of instances) {
|
|
1842
|
-
|
|
2116
|
+
const lossless = inst.format === "jpeg" || inst.format === "png" || (inst.format === "gif" && inst.animated === undefined);
|
|
2117
|
+
if (inst.formatAuto === true && inst.kind !== "skipped" && lossless) {
|
|
1843
2118
|
inst.est = 0;
|
|
1844
2119
|
inst.estAggressive = 0;
|
|
1845
2120
|
}
|
|
@@ -1881,7 +2156,8 @@ function median(values) {
|
|
|
1881
2156
|
async function estimateQualities(ctx, all, refName) {
|
|
1882
2157
|
const groups = new Map();
|
|
1883
2158
|
for (const inst of all) {
|
|
1884
|
-
|
|
2159
|
+
// An animated file has no quality sweep: its saving is an encode (see video-analysis.ts), not a re-encode of the still.
|
|
2160
|
+
if (inst.kind !== "srcset" || !inst.right || inst.animated !== undefined)
|
|
1885
2161
|
continue;
|
|
1886
2162
|
if (ctx.qualityFor && !ctx.qualityFor(inst))
|
|
1887
2163
|
continue;
|
|
@@ -2018,9 +2294,23 @@ export function applyQuality(inst) {
|
|
|
2018
2294
|
/** The `robots` value recorded for a page on a loopback or private host: its owner's own server is not checked. */
|
|
2019
2295
|
export const ROBOTS_SKIPPED_LOCAL = "skipped (local host)";
|
|
2020
2296
|
/** The options of one page's output, for the completeness check of --resume. */
|
|
2021
|
-
export function recordOptionsOf(opts) {
|
|
2297
|
+
export function recordOptionsOf(opts, ffmpeg) {
|
|
2022
2298
|
const thirdParty = opts.thirdParty ?? DEFAULT_THIRD_PARTY;
|
|
2299
|
+
// The media settings are recorded when encodes can run, so a page scanned under other settings (or without ffmpeg) is
|
|
2300
|
+
// not reused by --resume. Without encodes they are left out, and the record keeps its shape.
|
|
2301
|
+
const encodes = opts.quality === true && opts.survey !== true && ffmpeg !== undefined;
|
|
2302
|
+
const media = encodes
|
|
2303
|
+
? {
|
|
2304
|
+
mediaBudget: opts.mediaBudget ?? DEFAULT_MEDIA_BUDGET,
|
|
2305
|
+
mediaTime: opts.mediaTime ?? DEFAULT_MEDIA_TIME,
|
|
2306
|
+
videoBar: opts.videoBar ?? DEFAULT_VIDEO_BAR,
|
|
2307
|
+
videoStillBar: opts.videoStillBar ?? DEFAULT_VIDEO_STILL_BAR,
|
|
2308
|
+
maxVideoWidths: opts.maxVideoWidths ?? SCAN_MAX_VIDEO_WIDTHS,
|
|
2309
|
+
ffmpeg,
|
|
2310
|
+
}
|
|
2311
|
+
: {};
|
|
2023
2312
|
return {
|
|
2313
|
+
...media,
|
|
2024
2314
|
imgtuneVersion: IMGTUNE_VERSION,
|
|
2025
2315
|
aggressive: opts.aggressive,
|
|
2026
2316
|
quality: opts.quality,
|
|
@@ -2156,7 +2446,9 @@ function referenceViewportName(viewports) {
|
|
|
2156
2446
|
return viewports.find((v) => v.name === "iphone-15")?.name ?? viewports.find((v) => v.mobile)?.name ?? viewports[0].name;
|
|
2157
2447
|
}
|
|
2158
2448
|
/** Turns the observations of one page into the report. Fetches and local encodes happen here. */
|
|
2159
|
-
export async function analysePage(ctx,
|
|
2449
|
+
export async function analysePage(ctx, input, base) {
|
|
2450
|
+
// A fetched video poster is measured as an image, sized to its video's box (see video-analysis.ts).
|
|
2451
|
+
const observations = withPosterImages(input);
|
|
2160
2452
|
const refName = referenceViewportName(observations.map((o) => o.viewport));
|
|
2161
2453
|
const instances = [];
|
|
2162
2454
|
for (const obs of observations) {
|
|
@@ -2281,9 +2573,12 @@ export async function analysePage(ctx, observations, base) {
|
|
|
2281
2573
|
refName,
|
|
2282
2574
|
sources: bgSources,
|
|
2283
2575
|
});
|
|
2576
|
+
const videoSources = await resolveVideoSources(ctx, input);
|
|
2577
|
+
const videos = videoRecordsOf(ctx.pageUrl, input, fullBytesOf(input), "scan", ctx.maxVideoWidths ?? SCAN_MAX_VIDEO_WIDTHS, videoSources);
|
|
2284
2578
|
return {
|
|
2285
2579
|
...base,
|
|
2286
2580
|
nextjs: observations.some((o) => o.nextjs),
|
|
2581
|
+
...(videos.length > 0 ? { videos } : {}),
|
|
2287
2582
|
perViewport,
|
|
2288
2583
|
page: ctx.aggressive ? { ...withoutAggressive, aggressiveExactEstPct } : withoutAggressive,
|
|
2289
2584
|
groups: measure("aggregate", () => groupReports(instances, observations.length)),
|
|
@@ -2455,6 +2750,7 @@ async function scanPage(browser, run, pageUrl, robotsFor) {
|
|
|
2455
2750
|
function analysisContextOf(run, pageUrl) {
|
|
2456
2751
|
return {
|
|
2457
2752
|
pageUrl,
|
|
2753
|
+
maxVideoWidths: run.maxVideoWidths,
|
|
2458
2754
|
fetcher: run.fetcher,
|
|
2459
2755
|
allowPrivate: isLocalTarget(pageUrl),
|
|
2460
2756
|
deps: run.deps,
|
|
@@ -2482,13 +2778,13 @@ function stagedMeasure(run, stage, pageUrl, described, live) {
|
|
|
2482
2778
|
return;
|
|
2483
2779
|
const staged = [];
|
|
2484
2780
|
for (const obs of pending) {
|
|
2485
|
-
const { responses, ...rest } = obs;
|
|
2781
|
+
const { responses, media, ...rest } = obs;
|
|
2486
2782
|
const records = [];
|
|
2487
2783
|
for (const [url, record] of responses) {
|
|
2488
2784
|
const { buffer, ...meta } = record;
|
|
2489
2785
|
records.push({ url, record: meta, blob: buffer === undefined ? undefined : await putBlob(stage.dir, buffer) });
|
|
2490
2786
|
}
|
|
2491
|
-
staged.push({ ...rest, responses: records });
|
|
2787
|
+
staged.push({ ...rest, responses: records, ...(media !== undefined ? { media: [...media] } : {}) });
|
|
2492
2788
|
}
|
|
2493
2789
|
const state = { version: 1, url: pageUrl, options: stage.options, described, observations: staged };
|
|
2494
2790
|
await writeStagedJson(stage.dir, file, state);
|
|
@@ -2515,12 +2811,12 @@ async function unstagedObservations(stageDir, file) {
|
|
|
2515
2811
|
throw new Error(`staged page is missing or unreadable: ${safeText(file)}`);
|
|
2516
2812
|
const observations = [];
|
|
2517
2813
|
for (const staged of state.observations) {
|
|
2518
|
-
const { responses: records, ...rest } = staged;
|
|
2814
|
+
const { responses: records, media, ...rest } = staged;
|
|
2519
2815
|
const responses = new Map();
|
|
2520
2816
|
for (const { url, record, blob } of records) {
|
|
2521
2817
|
responses.set(url, blob === undefined ? { ...record } : { ...record, buffer: await getBlob(stageDir, blob) });
|
|
2522
2818
|
}
|
|
2523
|
-
observations.push({ ...rest, responses });
|
|
2819
|
+
observations.push({ ...rest, responses, ...(media !== undefined ? { media: new Map(media) } : {}) });
|
|
2524
2820
|
}
|
|
2525
2821
|
return observations;
|
|
2526
2822
|
}
|
|
@@ -2592,6 +2888,7 @@ async function withBrowser(opts, gpu, body) {
|
|
|
2592
2888
|
storageState: opts.storageState,
|
|
2593
2889
|
thirdParty: opts.thirdParty ?? DEFAULT_THIRD_PARTY,
|
|
2594
2890
|
qualityFor: opts.qualityFor,
|
|
2891
|
+
maxVideoWidths: opts.maxVideoWidths,
|
|
2595
2892
|
projectDir: opts.cwd,
|
|
2596
2893
|
projectRoot: opts.root,
|
|
2597
2894
|
viewportConcurrency: opts.viewportConcurrency ?? VIEWPORT_CONCURRENCY,
|
|
@@ -2650,6 +2947,67 @@ export function progressLine(done, total) {
|
|
|
2650
2947
|
const status = last.status === "ok" ? "" : ` (${last.status})`;
|
|
2651
2948
|
return `page ${done.length}/${total} done${status} · ${(measured / (1024 * 1024)).toFixed(1)} MB measured · est. ${estimated.toFixed(0)}%`;
|
|
2652
2949
|
}
|
|
2950
|
+
/**
|
|
2951
|
+
* The media phase of a run (see media-phase.ts), run once after every page is measured. Each asset is first checked against
|
|
2952
|
+
* the third-party policy from the page it was found on: a blocked one is never fetched, read or proxied. The rest go to
|
|
2953
|
+
* runMediaPhase (see mediaPhaseFor).
|
|
2954
|
+
*/
|
|
2955
|
+
async function runMediaFor(assets, run, opts, urls) {
|
|
2956
|
+
const { allowed, blocked } = partitionByPolicy(assets, run.thirdParty);
|
|
2957
|
+
const results = await mediaPhaseFor(allowed, run, opts, urls);
|
|
2958
|
+
return blocked.size > 0 ? { ...results, blocked } : results;
|
|
2959
|
+
}
|
|
2960
|
+
/**
|
|
2961
|
+
* The media phase for the assets the policy allows. The videos are read through an egress proxy started for the phase,
|
|
2962
|
+
* which allows private destinations only when every scanned URL is local. The length of every video URL is probed first,
|
|
2963
|
+
* whatever the settings (a click-to-play video's per-play bytes need it, and no ffmpeg); the budget ranks the videos by
|
|
2964
|
+
* that length. A survey or a scan with quality off encodes nothing, and each asset says so. Never throws.
|
|
2965
|
+
*/
|
|
2966
|
+
async function mediaPhaseFor(assets, run, opts, urls) {
|
|
2967
|
+
if (assets.length === 0)
|
|
2968
|
+
return { byKey: new Map() };
|
|
2969
|
+
const allowPrivate = urls.length > 0 && urls.every((url) => isLocalTarget(url));
|
|
2970
|
+
let proxy;
|
|
2971
|
+
// Declared outside the try: a media error after the probe keeps the lengths, so a click-to-play video's per-play bytes stay.
|
|
2972
|
+
let lengths = new Map();
|
|
2973
|
+
try {
|
|
2974
|
+
const videoUrls = assets.filter((asset) => asset.kind === "video").map((asset) => asset.url);
|
|
2975
|
+
if (videoUrls.length > 0) {
|
|
2976
|
+
proxy = await startEgressProxy({ resolver: run.resolver, allowPrivate, connectAddress: run.connectAddress });
|
|
2977
|
+
lengths = await probeLengths(assets.filter((asset) => asset.kind === "video").map((asset) => ({ url: asset.url, page: asset.page })), proxy.url, { thirdParty: run.thirdParty });
|
|
2978
|
+
}
|
|
2979
|
+
const sized = assets.map((asset) => refineAssetLength(asset, lengths.get(asset.url)));
|
|
2980
|
+
if (opts.quality !== true || opts.survey === true) {
|
|
2981
|
+
return { ...skippedMedia(sized, opts.survey === true ? "survey" : "quality off"), lengths };
|
|
2982
|
+
}
|
|
2983
|
+
return await runMediaPhase(sized, {
|
|
2984
|
+
budget: opts.mediaBudget ?? DEFAULT_MEDIA_BUDGET,
|
|
2985
|
+
timeS: opts.mediaTime ?? DEFAULT_MEDIA_TIME,
|
|
2986
|
+
vmafBar: opts.videoBar ?? DEFAULT_VIDEO_BAR,
|
|
2987
|
+
stillBar: opts.videoStillBar ?? DEFAULT_VIDEO_STILL_BAR,
|
|
2988
|
+
survey: false,
|
|
2989
|
+
workDir: join(resolve(opts.outDir), ".media-work"),
|
|
2990
|
+
cache: run.deps.cache,
|
|
2991
|
+
log: opts.log,
|
|
2992
|
+
proxy: proxy?.url,
|
|
2993
|
+
score: run.deps.score,
|
|
2994
|
+
scorerId: run.deps.scorerId,
|
|
2995
|
+
// The media is encoded on the CPU unless --gpu-encode asks for the GPUs: the --gpu-device list, else GPU 0.
|
|
2996
|
+
gpus: opts.gpuEncode === true ? (opts.gpu?.devices ?? [0]) : undefined,
|
|
2997
|
+
signal: opts.signal,
|
|
2998
|
+
thirdParty: run.thirdParty,
|
|
2999
|
+
}, {
|
|
3000
|
+
fetch: (url, maxBytes, hop) => run.fetcher.get(url, { maxBytes: maxBytes ?? MAX_IMAGE_BYTES, allowPrivate, allowHop: hop?.allowHop, allowHopKey: hop?.page }),
|
|
3001
|
+
}).then((results) => ({ ...results, lengths }));
|
|
3002
|
+
}
|
|
3003
|
+
catch (err) {
|
|
3004
|
+
const message = (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " ").slice(0, 200);
|
|
3005
|
+
return { ...skippedMedia(assets, `media error: ${message}`), lengths };
|
|
3006
|
+
}
|
|
3007
|
+
finally {
|
|
3008
|
+
await proxy?.close();
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
2653
3011
|
/**
|
|
2654
3012
|
* Scans the pages in two phases, with a progress line after each. Phase 1 measures every page (up to `pageConcurrency` in
|
|
2655
3013
|
* flight at once on the shared browser; pages start in input order) and collects the estimate jobs they need, so no
|
|
@@ -2665,7 +3023,8 @@ export function progressLine(done, total) {
|
|
|
2665
3023
|
export async function runScan(urls, opts) {
|
|
2666
3024
|
return withScanRun(opts, async (browser, run, robotsFor) => {
|
|
2667
3025
|
await mkdir(opts.outDir, { recursive: true });
|
|
2668
|
-
|
|
3026
|
+
// Whether ffmpeg is installed decides whether the media encodes run, so it is part of the record (see recordOptionsOf).
|
|
3027
|
+
const options = recordOptionsOf(opts, (await probeFfmpeg()).ffmpeg);
|
|
2669
3028
|
run.stage = { dir: stageDirOf(opts.outDir), options };
|
|
2670
3029
|
const results = new Array(urls.length);
|
|
2671
3030
|
// The finished pages in finishing order: the progress line counts them.
|
|
@@ -2673,6 +3032,8 @@ export async function runScan(urls, opts) {
|
|
|
2673
3032
|
const registry = new EstimateRegistry();
|
|
2674
3033
|
const collector = registry.collector();
|
|
2675
3034
|
const measured = [];
|
|
3035
|
+
// The media assets (videos and animated images) of every measured page, from its collecting pass (see media-phase.ts).
|
|
3036
|
+
const mediaAssets = [];
|
|
2676
3037
|
let started = 0;
|
|
2677
3038
|
let images = 0;
|
|
2678
3039
|
let measuredCount = 0;
|
|
@@ -2697,7 +3058,8 @@ export async function runScan(urls, opts) {
|
|
|
2697
3058
|
// A page measured by an interrupted run: its estimates only. Its requests are collected as a measured page's are.
|
|
2698
3059
|
const staged = opts.resume ? await resumeStaged(run, url) : undefined;
|
|
2699
3060
|
if (staged !== undefined) {
|
|
2700
|
-
await staged.page.analyse(collector);
|
|
3061
|
+
const stagedProvisional = await staged.page.analyse(collector);
|
|
3062
|
+
mediaAssets.push(...mediaAssetsOf(stagedProvisional));
|
|
2701
3063
|
measured.push({ index, url, page: staged.page });
|
|
2702
3064
|
images += staged.images;
|
|
2703
3065
|
measuredCount++;
|
|
@@ -2719,6 +3081,7 @@ export async function runScan(urls, opts) {
|
|
|
2719
3081
|
// Staged as soon as it is measured: the collecting pass reads it back from the stage, so no bytes are held meanwhile.
|
|
2720
3082
|
await outcome.stage?.();
|
|
2721
3083
|
const provisional = await outcome.analyse(collector);
|
|
3084
|
+
mediaAssets.push(...mediaAssetsOf(provisional));
|
|
2722
3085
|
await outcome.countImages?.(provisional.counts?.images ?? 0);
|
|
2723
3086
|
measured.push({ index, url, page: outcome });
|
|
2724
3087
|
images += provisional.counts?.images ?? 0;
|
|
@@ -2731,15 +3094,25 @@ export async function runScan(urls, opts) {
|
|
|
2731
3094
|
const counts = registry.counts();
|
|
2732
3095
|
opts.log(`estimates: ${counts.unique} unique of ${counts.requests}, ${counts.skipped} skipped`);
|
|
2733
3096
|
await registry.runJobs(QUALITY_PARALLEL);
|
|
3097
|
+
// Each page's image analysis is written as soon as its estimates are in, with no media results and no completeness
|
|
3098
|
+
// marker: a crash or Ctrl-C during the long media phase keeps it (--resume scans it again and readers skip it).
|
|
3099
|
+
const analysedPages = [];
|
|
2734
3100
|
for (const { index, url, page } of measured) {
|
|
2735
3101
|
const tally = { fallbacks: 0 };
|
|
2736
3102
|
const analysed = await page.analyse(registry.reader(tally));
|
|
2737
|
-
|
|
3103
|
+
await writePage(url, withBroken({ ...analysed, options, scorer: scorerOf(run.gpu, tally.fallbacks) }, opts.expectImages?.(url)));
|
|
3104
|
+
analysedPages.push({ index, url, page, analysed, fallbacks: tally.fallbacks });
|
|
3105
|
+
opts.log(progressLine([...finished, ...analysedPages.map((p) => p.analysed)], urls.length));
|
|
3106
|
+
}
|
|
3107
|
+
// The media phase runs once for every page's assets (see runMediaFor); each page is then written again, complete.
|
|
3108
|
+
const media = await runMediaFor(mediaAssets, run, opts, urls);
|
|
3109
|
+
for (const { index, url, page, analysed, fallbacks } of analysedPages) {
|
|
3110
|
+
const withMedia = attachMediaResults(analysed, media, { quality: opts.quality, survey: opts.survey === true });
|
|
3111
|
+
const result = withBroken({ ...withMedia, complete: true, options, scorer: scorerOf(run.gpu, fallbacks) }, opts.expectImages?.(url));
|
|
2738
3112
|
results[index] = result;
|
|
2739
3113
|
finished.push(result);
|
|
2740
3114
|
await writePage(url, result);
|
|
2741
3115
|
await page.finish?.();
|
|
2742
|
-
opts.log(progressLine(finished, urls.length));
|
|
2743
3116
|
}
|
|
2744
3117
|
}
|
|
2745
3118
|
// Every measured page is complete now: the stage's blobs go with it (an interrupted run never gets here).
|
|
@@ -2791,7 +3164,7 @@ export async function observeLight(pageUrl, opts) {
|
|
|
2791
3164
|
}, async (browser, run, robotsFor) => {
|
|
2792
3165
|
const url = new URL(pageUrl);
|
|
2793
3166
|
const allowPrivate = isLocalTarget(pageUrl);
|
|
2794
|
-
const empty = (status, error) => ({ status, error, observations: [], resolved: new Map() });
|
|
3167
|
+
const empty = (status, error) => ({ status, error, observations: [], resolved: new Map(), resolvedVideos: new Map() });
|
|
2795
3168
|
const robots = await checkRobots(pageUrl, allowPrivate, () => robotsFor(url.origin, false), run.ignoreRobots);
|
|
2796
3169
|
if (!robots.allowed)
|
|
2797
3170
|
return empty("robots", "robots.txt disallows the page");
|
|
@@ -2799,7 +3172,7 @@ export async function observeLight(pageUrl, opts) {
|
|
|
2799
3172
|
run.log(robotsIgnoredWarning(pageUrl));
|
|
2800
3173
|
const policy = { resolver: run.resolver, allowPrivate, connectAddress: run.connectAddress };
|
|
2801
3174
|
const guard = { isBlocked: (u) => run.fetcher.hostIsBlocked(u, allowPrivate), policy, thirdParty: run.thirdParty };
|
|
2802
|
-
const outcomes = await settleLimit(run.viewports, VIEWPORT_CONCURRENCY, (viewport) => observeViewport(browser, pageUrl, viewport, guard, run.loadCapMs, undefined, true, (img) => opts.wanted(img, pageUrl)));
|
|
3175
|
+
const outcomes = await settleLimit(run.viewports, VIEWPORT_CONCURRENCY, (viewport) => observeViewport(browser, pageUrl, viewport, guard, run.loadCapMs, undefined, true, (img) => opts.wanted(img, pageUrl), undefined, opts.videos === true));
|
|
2803
3176
|
const observations = [];
|
|
2804
3177
|
for (const [index, outcome] of outcomes.entries()) {
|
|
2805
3178
|
const viewport = run.viewports[index];
|
|
@@ -2826,12 +3199,15 @@ export async function observeLight(pageUrl, opts) {
|
|
|
2826
3199
|
projectRoot: run.projectRoot,
|
|
2827
3200
|
};
|
|
2828
3201
|
const resolved = new Map();
|
|
3202
|
+
const resolvedVideos = new Map();
|
|
2829
3203
|
for (const obs of observations) {
|
|
2830
3204
|
const part = await resolveSources(ctx, [obs], (img) => opts.wanted(img, pageUrl));
|
|
2831
3205
|
for (const [img, source] of part)
|
|
2832
3206
|
resolved.set(img, source);
|
|
3207
|
+
for (const [video, source] of await resolveVideoSources(ctx, [obs]))
|
|
3208
|
+
resolvedVideos.set(video, source);
|
|
2833
3209
|
}
|
|
2834
|
-
return { status: "ok", observations, resolved };
|
|
3210
|
+
return { status: "ok", observations, resolved, resolvedVideos };
|
|
2835
3211
|
});
|
|
2836
3212
|
}
|
|
2837
3213
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -2883,6 +3259,14 @@ export function renderPage(result, aggressive, hintsFile) {
|
|
|
2883
3259
|
lines.push(` ${sizes} -> ${suggested} saves ${kb(g.savedBytes)}/view${loader}${hint}${qualityHint}`);
|
|
2884
3260
|
}
|
|
2885
3261
|
}
|
|
3262
|
+
// The Video section: the page's <video> files, per view and per play, with each one's encode or the reason it has none.
|
|
3263
|
+
const videos = result.videos ?? [];
|
|
3264
|
+
const totals = videoTotalsLine(videos);
|
|
3265
|
+
if (totals !== undefined) {
|
|
3266
|
+
lines.push(` ${totals}`);
|
|
3267
|
+
for (const video of videos)
|
|
3268
|
+
lines.push(` ${videoRowOf(video, (text) => JSON.stringify(safeText(text)))}`);
|
|
3269
|
+
}
|
|
2886
3270
|
return lines;
|
|
2887
3271
|
}
|
|
2888
3272
|
/** Per-site line for hosts with two or more scanned pages. */
|
|
@@ -2894,6 +3278,10 @@ export function renderScan(results, aggressive, hintsFile, maxQualities) {
|
|
|
2894
3278
|
const note = scorerNote(results);
|
|
2895
3279
|
if (note !== undefined)
|
|
2896
3280
|
lines.push(note, "");
|
|
3281
|
+
// The media phase's note (no ffmpeg, a failed encode run) is printed once, at the top, not once per page.
|
|
3282
|
+
const media = results.find((r) => r.mediaNote !== undefined)?.mediaNote;
|
|
3283
|
+
if (media !== undefined)
|
|
3284
|
+
lines.push(safeText(media), "");
|
|
2897
3285
|
for (const result of results)
|
|
2898
3286
|
lines.push(...renderPage(result, aggressive, hintsFile?.(result)), "");
|
|
2899
3287
|
const byHost = new Map();
|
|
@@ -2974,6 +3362,9 @@ function markdownBody(results, aggressive, withHints, maxQualities) {
|
|
|
2974
3362
|
const note = scorerNote(results);
|
|
2975
3363
|
if (note !== undefined)
|
|
2976
3364
|
lines.push(note, "");
|
|
3365
|
+
const media = results.find((r) => r.mediaNote !== undefined)?.mediaNote;
|
|
3366
|
+
if (media !== undefined)
|
|
3367
|
+
lines.push(mdText(media), "");
|
|
2977
3368
|
lines.push(mdRow(head, width), mdRow(align, width));
|
|
2978
3369
|
for (const result of results) {
|
|
2979
3370
|
const label = pageLabel(result.url);
|
|
@@ -2993,21 +3384,28 @@ function markdownBody(results, aggressive, withHints, maxQualities) {
|
|
|
2993
3384
|
continue;
|
|
2994
3385
|
// The LCP line goes above the page's table: the image that the first paint waits for comes first.
|
|
2995
3386
|
const lcp = lcpLinesOf(result, mdCode);
|
|
2996
|
-
|
|
3387
|
+
const videos = result.videos ?? [];
|
|
3388
|
+
if (lcp.length === 0 && result.groups.length === 0 && videos.length === 0)
|
|
2997
3389
|
continue;
|
|
2998
3390
|
lines.push("", `### ${pageLabel(result.url)}`, "");
|
|
2999
3391
|
for (const line of lcp)
|
|
3000
3392
|
lines.push(line, "");
|
|
3001
|
-
if (result.groups.length
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3393
|
+
if (result.groups.length > 0) {
|
|
3394
|
+
lines.push(mdRow(["current sizes", "suggested", "saves KB/view", "loader", "hint"], 5));
|
|
3395
|
+
lines.push(mdRow(["---", "---", "---:", "---", "---"], 5));
|
|
3396
|
+
for (const g of result.groups.slice(0, SUMMARY_GROUPS)) {
|
|
3397
|
+
const current = g.sizes === undefined ? "no sizes" : mdCode(g.sizes);
|
|
3398
|
+
const suggested = g.suggestedSizes === undefined ? "-" : mdCode(g.suggestedSizes);
|
|
3399
|
+
const loader = g.loader ? mdCode(g.loader) : "-";
|
|
3400
|
+
const hint = [g.hint, g.qualityHint].filter((h) => h !== undefined).map((h) => mdCode(h)).join("; ");
|
|
3401
|
+
lines.push(mdRow([current, suggested, `${kbNumber(g.savedBytes)}${g.estApprox === true ? APPROX_MARK : ""}`, loader, hint || "-"], 5));
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
const totals = videoTotalsLine(videos);
|
|
3405
|
+
if (totals !== undefined) {
|
|
3406
|
+
lines.push("", totals, "");
|
|
3407
|
+
for (const video of videos)
|
|
3408
|
+
lines.push(`- ${mdCode(videoRowOf(video, (text) => JSON.stringify(safeText(text))))}`);
|
|
3011
3409
|
}
|
|
3012
3410
|
}
|
|
3013
3411
|
const plan = planQualityCap(results, maxQualities);
|
|
@@ -3093,7 +3491,7 @@ export async function rootOf(root, cwd) {
|
|
|
3093
3491
|
return absolute;
|
|
3094
3492
|
}
|
|
3095
3493
|
/** `imgtune scan`: the arguments after `scan`. The JSON files are written to --out; --markdown and --summary-json too. */
|
|
3096
|
-
export async function scanCommand(argv, cwd, log) {
|
|
3494
|
+
export async function scanCommand(argv, cwd, log, signal) {
|
|
3097
3495
|
const args = parseScanArgs(argv);
|
|
3098
3496
|
const plan = scanPlan(args);
|
|
3099
3497
|
const viewports = DEFAULT_VIEWPORTS.filter((v) => !plan.viewports || plan.viewports.includes(v.name)).map((v) => ({ ...v }));
|
|
@@ -3120,6 +3518,7 @@ export async function scanCommand(argv, cwd, log) {
|
|
|
3120
3518
|
outDir: resolve(cwd, args.out),
|
|
3121
3519
|
cwd,
|
|
3122
3520
|
root,
|
|
3521
|
+
signal,
|
|
3123
3522
|
aggressive: args.aggressive,
|
|
3124
3523
|
quality: plan.quality,
|
|
3125
3524
|
viewports,
|
|
@@ -3130,11 +3529,17 @@ export async function scanCommand(argv, cwd, log) {
|
|
|
3130
3529
|
resume: args.resume === true,
|
|
3131
3530
|
maxPages: args.maxPages,
|
|
3132
3531
|
maxImages: args.maxImages,
|
|
3532
|
+
mediaBudget: args.mediaBudget,
|
|
3533
|
+
mediaTime: args.mediaTime,
|
|
3534
|
+
videoBar: args.videoBar,
|
|
3535
|
+
videoStillBar: args.videoStillBar,
|
|
3536
|
+
maxVideoWidths: args.maxVideoWidths,
|
|
3133
3537
|
pageConcurrency: plan.pageConcurrency,
|
|
3134
3538
|
idleTimeoutMs: plan.idleTimeoutMs,
|
|
3135
3539
|
sharedLoads: args.sharedLoads === true,
|
|
3136
3540
|
storageState,
|
|
3137
3541
|
gpu: args.gpu,
|
|
3542
|
+
gpuEncode: args.gpuEncode,
|
|
3138
3543
|
cache: args.noCache !== true,
|
|
3139
3544
|
report,
|
|
3140
3545
|
thirdParty: args.thirdParty,
|