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/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, parseGpuDevice, startGpuScorer } from "./gpu.js";
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 ??= { device: 0 };
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 = { device: parseGpuDevice(value) };
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
- if (input.format === "jpeg" || input.format === "png") {
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
- return { nextjs, images, backgrounds };
1011
- }, { depth: LAYOUT_DEPTH, maxOwners: MAX_OWNERS, maxBackgrounds: MAX_BACKGROUND_ELEMENTS, maxValue: MAX_BACKGROUND_VALUE, light });
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
- const cdp = viewports.length > 1 ? await context.newCDPSession(page) : undefined;
1431
- if (cdp !== undefined) {
1432
- cdp.on("Network.requestWillBeSent", (event) => busy.add(event.requestId));
1433
- cdp.on("Network.loadingFinished", (event) => busy.delete(event.requestId));
1434
- cdp.on("Network.loadingFailed", (event) => busy.delete(event.requestId));
1435
- await cdp.send("Network.enable");
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, step === 0 ? undefined : () => waitNetworkQuiet(() => busy.size, idleTimeoutMs), !light && step === 0);
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
- const owners = img.locator?.owners ?? [];
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
- if (ctx.estimates !== false && served.width > PLAIN_RESIZE_RATIO * needed && served.format) {
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
- if (inst.formatAuto === true && inst.kind !== "skipped" && (inst.format === "jpeg" || inst.format === "png")) {
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
- if (inst.kind !== "srcset" || !inst.right)
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, observations, base) {
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
- const options = recordOptionsOf(opts);
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
- const result = withBroken({ ...analysed, complete: true, options, scorer: scorerOf(run.gpu, tally.fallbacks) }, opts.expectImages?.(url));
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
- if (lcp.length === 0 && result.groups.length === 0)
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 === 0)
3002
- continue;
3003
- lines.push(mdRow(["current sizes", "suggested", "saves KB/view", "loader", "hint"], 5));
3004
- lines.push(mdRow(["---", "---", "---:", "---", "---"], 5));
3005
- for (const g of result.groups.slice(0, SUMMARY_GROUPS)) {
3006
- const current = g.sizes === undefined ? "no sizes" : mdCode(g.sizes);
3007
- const suggested = g.suggestedSizes === undefined ? "-" : mdCode(g.suggestedSizes);
3008
- const loader = g.loader ? mdCode(g.loader) : "-";
3009
- const hint = [g.hint, g.qualityHint].filter((h) => h !== undefined).map((h) => mdCode(h)).join("; ");
3010
- lines.push(mdRow([current, suggested, `${kbNumber(g.savedBytes)}${g.estApprox === true ? APPROX_MARK : ""}`, loader, hint || "-"], 5));
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,