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.
@@ -3,17 +3,24 @@
3
3
  // (SSIMULACRA2 90 by default, visually lossless; --min-score sets another). The lowest quality per format that meets the
4
4
  // bar is reported. With --width the file is also measured at the width it is needed at (width x dpr). Files are read,
5
5
  // never written, unless --write names a directory for optimised copies.
6
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
6
+ //
7
+ // Media (animated GIF, WebP and APNG, MP4, MOV and WebM) is read from local files only (media-file.ts): an animated file
8
+ // gets the candidates of the animated codecs, and `--reference <original>` checks a candidate against the original with
9
+ // the video bars (VMAF and the still-frame guard), exiting 0 on pass and 1 on fail.
10
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
7
12
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
8
13
  import sharp from "sharp";
9
14
  import { isLocalTarget } from "./address.js";
10
15
  import { encode, ENCODER_ID, loadReference } from "./encode.js";
11
16
  import { makeFetcher, MAX_IMAGE_BYTES } from "./fetch.js";
12
- import { gpuNote, parseGpuDevice, startGpuScorer } from "./gpu.js";
17
+ import { gpuNote, startGpuScorer, gpuSettingOf, gpuSettingOne } from "./gpu.js";
18
+ import { probeFfmpeg, withGpu } from "./ffmpeg.js";
13
19
  import { makeScorer } from "./metric.js";
14
20
  import { minPassingQuality } from "./search.js";
15
21
  import { cacheScope, CACHE_DIR, ResultCache } from "./result-cache.js";
16
- import { decodeRaw, pixelSize, safeText, SCAN_TOLERANCE, scorerOf } from "./remote.js";
22
+ import { compareReferenceFile, describeVideo, measureAnimated, mediaKindOf, renderAnimated, renderReference, renderVideo } from "./media-file.js";
23
+ import { DEFAULT_VIDEO_BAR, DEFAULT_VIDEO_STILL_BAR, decodeRaw, pixelSize, safeText, SCAN_TOLERANCE, scorerOf } from "./remote.js";
17
24
  import { IMGTUNE_VERSION } from "./version.js";
18
25
  const ALL_FORMATS = ["avif", "webp", "jpeg"];
19
26
  /** Most inputs (files, globs and URLs together) one run measures. */
@@ -24,12 +31,18 @@ const MAX_GLOB_MATCHES = 1000;
24
31
  const START_QUALITY = 90;
25
32
  /** The SSIMULACRA2 bar a file must meet against itself, when --min-score is not given: visually lossless. */
26
33
  export const DEFAULT_FILE_BAR = 90;
27
- const USAGE = "usage: imgtune image <file|url|glob> [...] [--width <css px>] [--dpr 1,2] [--formats avif,webp,jpeg] [--min-score <n>] [--write <dir>] [--format text|json] [--gpu] [--gpu-device <n>]";
34
+ const USAGE = "usage: imgtune image <file|url|glob> [...] [--width <css px>] [--dpr 1,2] [--formats avif,webp,jpeg] [--min-score <n>] [--write <dir>] [--format text|json|--json] [--gpu] [--gpu-device <n>] [--reference <original file>] [--video-bar <n>] [--video-still-bar <n>]";
28
35
  function positiveNumber(name, text) {
29
36
  if (!/^\d+(\.\d+)?$/.test(text) || Number(text) <= 0)
30
37
  throw new Error(`${name} needs a number above 0, not ${safeText(text)}`);
31
38
  return Number(text);
32
39
  }
40
+ /** A bar from 0 to 100 (a VMAF or still-guard bar). Throws with the message the CLI prints. */
41
+ function barNumber(name, text) {
42
+ if (!/^\d+(\.\d+)?$/.test(text) || Number(text) > 100)
43
+ throw new Error(`${name} needs a number from 0 to 100, not ${safeText(text)}`);
44
+ return Number(text);
45
+ }
33
46
  /** The arguments after `image`. Throws with the message the CLI prints. */
34
47
  export function parseImageArgs(argv) {
35
48
  const args = { inputs: [], dprs: [1, 2], formats: [...ALL_FORMATS], format: "text" };
@@ -40,13 +53,21 @@ export function parseImageArgs(argv) {
40
53
  continue;
41
54
  }
42
55
  if (arg === "--gpu") {
43
- args.gpu ??= { device: 0 };
56
+ args.gpu ??= gpuSettingOne(0);
44
57
  continue;
45
58
  }
46
59
  if (arg === "--no-cache") {
47
60
  args.noCache = true;
48
61
  continue;
49
62
  }
63
+ if (arg === "--gpu-encode") {
64
+ args.gpuEncode = true;
65
+ continue;
66
+ }
67
+ if (arg === "--json") {
68
+ args.format = "json";
69
+ continue;
70
+ }
50
71
  const value = argv[i + 1];
51
72
  if (value === undefined || value.startsWith("--"))
52
73
  throw new Error(`${arg} needs a value (${USAGE})`);
@@ -73,9 +94,18 @@ export function parseImageArgs(argv) {
73
94
  args.minScore = score;
74
95
  }
75
96
  else if (arg === "--gpu-device")
76
- args.gpu = { device: parseGpuDevice(value) };
97
+ args.gpu = gpuSettingOf(value);
77
98
  else if (arg === "--write")
78
99
  args.write = value;
100
+ else if (arg === "--reference") {
101
+ if (/^https?:\/\//i.test(value))
102
+ throw new Error(`--reference is a local file: media is not read from a URL here (local files only, not ${safeText(value)})`);
103
+ args.reference = value;
104
+ }
105
+ else if (arg === "--video-bar")
106
+ args.videoBar = barNumber(arg, value);
107
+ else if (arg === "--video-still-bar")
108
+ args.videoStillBar = barNumber(arg, value);
79
109
  else if (arg === "--format") {
80
110
  if (value !== "text" && value !== "json")
81
111
  throw new Error(`--format is text or json, not ${safeText(value)}`);
@@ -198,6 +228,8 @@ async function readInput(input, cwd, userAgent) {
198
228
  const body = await fetcher.get(input.url, { maxBytes: MAX_IMAGE_BYTES, allowPrivate: isLocalTarget(input.url) });
199
229
  if (body === undefined)
200
230
  throw new Error(`${safeText(input.url)} was not fetched (blocked, failed, or larger than ${MAX_IMAGE_BYTES / (1024 * 1024)} MB)`);
231
+ if (mediaKindOf(body) !== undefined)
232
+ throw new Error(`${safeText(input.url)} is a video or animated file: media is local files only (download it and pass the file)`);
201
233
  return body;
202
234
  }
203
235
  const path = resolve(cwd, input.path);
@@ -287,8 +319,9 @@ export async function measureFile(input, buffer, args, score, cached) {
287
319
  const placeholder = { bar, sizes: [] };
288
320
  if (!info)
289
321
  return { ...base, width: { source: args.width ? "user" : "file", css: args.width }, ...placeholder, error: "not a decodable image" };
290
- if (info.format === "svg" || info.format === "gif") {
291
- return { ...base, width: { source: args.width ? "user" : "file", css: args.width }, ...placeholder, error: `${info.format} is not measured (not a raster format the encoders take)` };
322
+ // A static GIF is a raster image like a PNG (sharp reads its first frame). An animated one never reaches here: it is media.
323
+ if (info.format === "svg") {
324
+ return { ...base, width: { source: args.width ? "user" : "file", css: args.width }, ...placeholder, error: "svg is not measured (not a raster format the encoders take)" };
292
325
  }
293
326
  const targets = args.width === undefined ? [{ px: info.width }] : args.dprs.map((dpr) => ({ px: Math.min(Math.ceil(args.width * dpr), info.width), dpr }));
294
327
  const sizes = [];
@@ -378,7 +411,7 @@ export async function writeCopies(results, inputs, cwd, dir, buffers) {
378
411
  return notes;
379
412
  }
380
413
  /** `imgtune image`: measures each input and returns the text or JSON to print. */
381
- export async function imageCommand(argv, cwd, userAgent = `imgtune/${IMGTUNE_VERSION}`, log = (message) => process.stderr.write(`imgtune: ${message}\n`)) {
414
+ export async function imageCommand(argv, cwd, userAgent = `imgtune/${IMGTUNE_VERSION}`, log = (message) => process.stderr.write(`imgtune: ${message}\n`), signal) {
382
415
  const args = parseImageArgs(argv);
383
416
  const gpu = args.gpu !== undefined ? await startGpuScorer(args.gpu.device, log) : undefined;
384
417
  const store = args.noCache === true ? undefined : new ResultCache(join(cwd, CACHE_DIR));
@@ -387,47 +420,134 @@ export async function imageCommand(argv, cwd, userAgent = `imgtune/${IMGTUNE_VER
387
420
  ? undefined
388
421
  : { store, scorerId: gpu !== undefined ? `gpu:${gpu.deviceName}` : "cpu", cpuScored: gpu ? () => gpu.cpuScores : undefined };
389
422
  try {
390
- return await measureInputs(args, cwd, userAgent, gpu, cache);
423
+ return await measureInputs(args, cwd, userAgent, gpu, cache, signal);
391
424
  }
392
425
  finally {
393
426
  gpu?.close();
394
427
  }
395
428
  }
396
- async function measureInputs(args, cwd, userAgent, gpu, cache) {
429
+ async function measureInputs(args, cwd, userAgent, gpu, cache, signal) {
397
430
  const inputs = await resolveInputs(args.inputs, cwd);
398
431
  const score = makeScorer("ssimulacra2", { projectDir: cwd, gpu });
432
+ const scorer = gpu !== undefined ? scorerOf(gpu, gpu.fallbackScores) : { name: "cpu" };
433
+ if (args.reference !== undefined)
434
+ return compareMode(args, inputs, cwd, score, scorer, signal);
399
435
  const results = [];
436
+ const media = [];
400
437
  const buffers = new Map();
401
- for (const input of inputs) {
402
- let buffer;
403
- try {
404
- buffer = await readInput(input, cwd, userAgent);
405
- }
406
- catch (err) {
407
- results.push({
408
- label: input.label,
409
- evidence: "file-mode",
410
- imgtuneVersion: IMGTUNE_VERSION,
411
- current: { width: 0, height: 0, bytes: 0 },
412
- width: { source: args.width ? "user" : "file", css: args.width },
413
- bar: barOf(args.minScore),
414
- sizes: [],
415
- error: err.message,
416
- });
417
- continue;
438
+ // Scratch for the media encodes of this run, removed when the run ends.
439
+ let scratch;
440
+ const mediaContext = async () => {
441
+ scratch ??= await mkdtemp(join(tmpdir(), "imgtune-media-file-"));
442
+ // The media is encoded on the CPU unless --gpu-encode asks for the GPU; its encoders are proven on that GPU first.
443
+ const device = args.gpuEncode === true ? (args.gpu?.device ?? 0) : undefined;
444
+ const caps = await withGpu(await probeFfmpeg(), device);
445
+ return {
446
+ caps,
447
+ gpu: caps.av1Nvenc || caps.h264Nvenc ? device : undefined,
448
+ score,
449
+ dir: scratch,
450
+ videoBar: args.videoBar ?? DEFAULT_VIDEO_BAR,
451
+ videoStillBar: args.videoStillBar ?? DEFAULT_VIDEO_STILL_BAR,
452
+ signal,
453
+ };
454
+ };
455
+ try {
456
+ for (const input of inputs) {
457
+ let buffer;
458
+ try {
459
+ buffer = await readInput(input, cwd, userAgent);
460
+ }
461
+ catch (err) {
462
+ results.push({
463
+ label: input.label,
464
+ evidence: "file-mode",
465
+ imgtuneVersion: IMGTUNE_VERSION,
466
+ current: { width: 0, height: 0, bytes: 0 },
467
+ width: { source: args.width ? "user" : "file", css: args.width },
468
+ bar: barOf(args.minScore),
469
+ sizes: [],
470
+ error: err.message,
471
+ });
472
+ continue;
473
+ }
474
+ buffers.set(input.label, buffer);
475
+ const kind = mediaKindOf(buffer);
476
+ if (kind !== undefined && input.path !== undefined) {
477
+ const file = { label: input.label, path: resolve(cwd, input.path), buffer };
478
+ const ctx = await mediaContext();
479
+ const dir = join(ctx.dir, `file-${media.length}`);
480
+ media.push(kind === "animated" ? await measureAnimated(file, args.width, ctx, dir) : await describeVideo(file, ctx, dir));
481
+ continue;
482
+ }
483
+ results.push(await measureFile(input, buffer, args, score, cache && { ...cache, original: buffer }));
418
484
  }
419
- buffers.set(input.label, buffer);
420
- results.push(await measureFile(input, buffer, args, score, cache && { ...cache, original: buffer }));
485
+ }
486
+ finally {
487
+ if (scratch !== undefined)
488
+ await rm(scratch, { recursive: true, force: true });
421
489
  }
422
490
  const notes = args.write !== undefined ? await writeCopies(results, inputs, cwd, args.write, buffers) : [];
423
- const failed = results.filter((r) => r.error !== undefined).length;
424
- const scorer = gpu !== undefined ? scorerOf(gpu, gpu.fallbackScores) : { name: "cpu" };
491
+ const failed = results.filter((r) => r.error !== undefined).length + media.filter((m) => m.error !== undefined).length;
425
492
  if (args.format === "json") {
426
- const body = { evidence: "file-mode", scorer, files: results, copies: notes };
493
+ const body = { evidence: "file-mode", scorer, files: results, media, copies: notes };
427
494
  return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: failed > 0 ? 1 : 0 };
428
495
  }
429
496
  const head = gpu !== undefined ? `${gpuNote(gpu.device, gpu.deviceName, gpu.fallbackScores)}\n\n` : "";
430
497
  const text = results.map(renderFile).join("\n");
498
+ const mediaText = media.map((m) => (m.kind === "animated" ? renderAnimated(m) : renderVideo(m))).join("\n");
431
499
  const tail = notes.length > 0 ? `copies:\n${notes.map((n) => ` ${n}`).join("\n")}\n` : "";
432
- return { output: `${head}${text}${tail}`, exitCode: failed > 0 ? 1 : 0 };
500
+ return { output: `${head}${text}${mediaText}${tail}`, exitCode: failed > 0 ? 1 : 0 };
501
+ }
502
+ /**
503
+ * `--reference <original>`: one candidate (a local media file) compared with the original. The verdict is the exit code:
504
+ * 0 on pass, 1 on fail or on any error. Input that cannot be compared is an error, not a fail verdict on the file.
505
+ */
506
+ async function compareMode(args, inputs, cwd, score, scorer, signal) {
507
+ const reference = args.reference;
508
+ const fail = (message) => {
509
+ if (args.format === "json")
510
+ return { output: `${JSON.stringify({ evidence: "media-reference", scorer, error: message }, null, 2)}\n`, exitCode: 1 };
511
+ return { output: `${safeText(message)}\n`, exitCode: 1 };
512
+ };
513
+ if (inputs.length !== 1)
514
+ return fail(`--reference compares one file: give one candidate (${inputs.length} match)`);
515
+ const [candidate] = inputs;
516
+ if (candidate.url !== undefined)
517
+ return fail("--reference takes local files only: the candidate is a URL, and media is not read from a URL here");
518
+ const refPath = resolve(cwd, reference);
519
+ const refInfo = await stat(refPath).catch(() => undefined);
520
+ if (!refInfo?.isFile())
521
+ return fail(`--reference ${safeText(reference)} is not a file`);
522
+ if (refInfo.size > MAX_IMAGE_BYTES)
523
+ return fail(`--reference ${safeText(reference)} is larger than ${MAX_IMAGE_BYTES / (1024 * 1024)} MB`);
524
+ const refBuffer = await readFile(refPath);
525
+ if (mediaKindOf(refBuffer) === undefined)
526
+ return fail(`--reference ${safeText(reference)} is not a video or animated file`);
527
+ const candBuffer = await readInput(candidate, cwd, "");
528
+ if (mediaKindOf(candBuffer) === undefined)
529
+ return fail(`${safeText(candidate.label)} is not a video or animated file: --reference compares media`);
530
+ const caps = await probeFfmpeg();
531
+ if (!caps.ffmpeg)
532
+ return fail("ffmpeg is not on PATH: --reference needs it (ffmpeg enables the full hints)");
533
+ const dir = await mkdtemp(join(tmpdir(), "imgtune-compare-"));
534
+ let result;
535
+ try {
536
+ const ctx = {
537
+ caps,
538
+ score,
539
+ dir,
540
+ videoBar: args.videoBar ?? DEFAULT_VIDEO_BAR,
541
+ videoStillBar: args.videoStillBar ?? DEFAULT_VIDEO_STILL_BAR,
542
+ signal,
543
+ };
544
+ result = await compareReferenceFile({ label: candidate.label, path: resolve(cwd, candidate.path), buffer: candBuffer }, { label: relative(cwd, refPath) || reference, path: refPath, buffer: refBuffer }, args.width, ctx, dir);
545
+ }
546
+ finally {
547
+ await rm(dir, { recursive: true, force: true });
548
+ }
549
+ const exitCode = result.error === undefined && result.pass ? 0 : 1;
550
+ if (args.format === "json")
551
+ return { output: `${JSON.stringify(result, null, 2)}\n`, exitCode };
552
+ return { output: renderReference(result), exitCode };
433
553
  }
package/dist/images.js CHANGED
@@ -7,6 +7,7 @@ import { COMPONENT_NAME, loaderName, sanitizeText, urlPattern } from "./locate.j
7
7
  import { undersizedProposal } from "./candidates.js";
8
8
  import { sizesBranchAt, sizesSlotAt } from "./sizes.js";
9
9
  import { parseSrcset } from "./srcset-parse.js";
10
+ import { animatedTarget } from "./media-model.js";
10
11
  /** The first path segment of a harness route: a page the agent adds to measure a component in isolation. */
11
12
  export const HARNESS_SEGMENT = "__imgtune";
12
13
  /**
@@ -43,6 +44,13 @@ export function searchHintsOf(locator) {
43
44
  hints.urlPattern = locator.urlPattern;
44
45
  return Object.keys(hints).length > 0 ? hints : undefined;
45
46
  }
47
+ /**
48
+ * Whether a served format takes the lossless rules (a local resize and the AVIF proposal): a jpeg or png, or a static GIF
49
+ * (a palette file, judged as a PNG). An animated GIF does not: its saving is an encode.
50
+ */
51
+ export function losslessServed(format, animated) {
52
+ return format === "jpeg" || format === "png" || (format === "gif" && !animated);
53
+ }
46
54
  const round1 = (value) => Math.round(value * 10) / 10;
47
55
  const round2 = (value) => Math.round(value * 100) / 100;
48
56
  /**
@@ -229,12 +237,22 @@ export function buildImageRecords(input) {
229
237
  const savedEff = keepSizes ? saved : saved - savedSizes;
230
238
  const savedSizesEff = keepSizes ? savedSizes : 0;
231
239
  const served = member.format;
232
- const best = served === "jpeg" || served === "png" ? (saved > 0 ? "avif" : served) : served;
240
+ const animatedMember = members.find((m) => m.animated !== undefined);
241
+ const lossless = losslessServed(served, animatedMember !== undefined);
242
+ const best = lossless ? (saved > 0 ? "avif" : served) : served;
233
243
  const fix = { bytes: { saved: savedEff, addedForSharpness, sizes: savedSizesEff, quality: savedEff - savedSizesEff } };
234
244
  if (member.cdnQuality !== undefined)
235
245
  fix.cdnQuality = { ...member.cdnQuality };
236
- if (member.formatAuto === true && (served === "jpeg" || served === "png"))
246
+ if (member.formatAuto === true && lossless)
237
247
  fix.formatHandled = true;
248
+ if (animatedMember?.animated !== undefined) {
249
+ fix.animated = {
250
+ info: animatedMember.animated,
251
+ context: "img",
252
+ target: animatedTarget("img", undefined),
253
+ perViewBytes: members.reduce((sum, m) => sum + m.bytes, 0) / viewportCount,
254
+ };
255
+ }
238
256
  if (sizes && left !== undefined) {
239
257
  fix.noSaving = `sizes proposal left out: ${left}`;
240
258
  notes.push(`sizes proposal left out: ${left}`);
@@ -252,6 +270,7 @@ export function buildImageRecords(input) {
252
270
  const record = {
253
271
  id: sha1(`${pageUrl}\n${key}`).slice(0, 12),
254
272
  page: pageUrl,
273
+ ...(member.img.posterOf !== undefined ? { poster: true } : {}),
255
274
  locator,
256
275
  tunable: tunableOf(member, pageUrl, locator.loader),
257
276
  current: {
@@ -286,6 +305,12 @@ export function buildImageRecords(input) {
286
305
  notes.push(`quality handled by ${locator.loader} (q_auto): its est. quality saving is not counted`);
287
306
  if (fix.formatHandled === true)
288
307
  notes.push(`format handled by ${locator.loader} (f_auto): no format change proposed`);
308
+ if (member.img.posterOf !== undefined)
309
+ notes.push(`poster of video ${sanitizeText(member.img.posterOf, 120)} (sized to its box)`);
310
+ if (fix.animated !== undefined) {
311
+ const { frames, durationMs, loop } = fix.animated.info;
312
+ notes.push(`animated ${fix.animated.info.format}: ${frames} frame(s), ${Math.round(durationMs / 100) / 10} s per loop, loops ${loop === 0 ? "forever" : loop}`);
313
+ }
289
314
  if (layoutChain?.truncated)
290
315
  notes.push("layout chain cut at the depth cap");
291
316
  if (layoutChain && layoutChain.skipped.length > 0)
@@ -18,8 +18,91 @@ export function isFlagged(record) {
18
18
  // A CSS background is not an <img>: the dev page's images cannot join it, so it is never located.
19
19
  if (record.kind === "css-background")
20
20
  return false;
21
+ // A poster is a video's image: no <img> of the dev page is its element, so it is never located (its evidence names the video).
22
+ if (record.poster === true)
23
+ return false;
21
24
  return record.fix.bytes.saved > 0 || record.fix.bytes.addedForSharpness > 0;
22
25
  }
26
+ /** A video costs bytes per view, so a locate asks for it. Posters are image records and follow isFlagged. */
27
+ export function isFlaggedVideo(record) {
28
+ return record.bytes.perView > 0;
29
+ }
30
+ /**
31
+ * Joins the flagged measured videos to the dev page's videos by URL (each side's URLs are keyed against its own page, see
32
+ * devVideosOf). A pair is made only when the URL is on exactly one measured video and exactly one dev video; a URL on
33
+ * several is not guessed at (the videos count as not joined). Videos have no element order to fall back on, so there is
34
+ * no second rule.
35
+ */
36
+ export function joinVideos(records, devVideos, flagged) {
37
+ const measured = records.filter(flagged).map((record) => ({ record, key: joinKeyOfUrls([record.src], record.page) }));
38
+ const measuredCount = new Map();
39
+ for (const { key } of measured)
40
+ measuredCount.set(key, (measuredCount.get(key) ?? 0) + 1);
41
+ const devCount = new Map();
42
+ for (const dev of devVideos) {
43
+ const list = devCount.get(dev.pattern);
44
+ if (list)
45
+ list.push(dev);
46
+ else
47
+ devCount.set(dev.pattern, [dev]);
48
+ }
49
+ const pairs = new Map();
50
+ let notJoined = 0;
51
+ for (const { record, key } of measured) {
52
+ const devs = devCount.get(key) ?? [];
53
+ if (key !== "" && measuredCount.get(key) === 1 && devs.length === 1)
54
+ pairs.set(record, devs[0]);
55
+ else
56
+ notJoined++;
57
+ }
58
+ return { pairs, notJoined, devLeft: devVideos.length - pairs.size };
59
+ }
60
+ /** The videos of a dev page's viewport, keyed by URL against that page, with the source the dev build resolved for each. */
61
+ export function devVideosOf(observation, pageUrl, resolved) {
62
+ return (observation.videos ?? []).map((video) => {
63
+ const source = resolved.get(video);
64
+ const dev = { index: video.index, pattern: joinKeyOfUrls([video.currentSrc], pageUrl), currentSrc: video.currentSrc, component: source?.component ?? [] };
65
+ if (source?.file !== undefined) {
66
+ dev.file = source.file;
67
+ dev.line = source.line;
68
+ dev.kind = source.check;
69
+ dev.via = source.via;
70
+ dev.ownerChain = source.ownerChain;
71
+ }
72
+ return dev;
73
+ });
74
+ }
75
+ /**
76
+ * The located videos: each paired record's locator takes the dev video's file:line and its source check, and its component.
77
+ * The record's id and bytes do not change: a locate only says where the element is in the source.
78
+ */
79
+ export function applyLocateVideos(records, join) {
80
+ return records.map((record) => {
81
+ const dev = join.pairs.get(record);
82
+ if (dev === undefined)
83
+ return record;
84
+ const locator = { ...record.locator };
85
+ delete locator.file;
86
+ delete locator.line;
87
+ delete locator.kind;
88
+ delete locator.verified;
89
+ delete locator.via;
90
+ delete locator.ownerChain;
91
+ if (dev.file !== undefined && dev.line !== undefined) {
92
+ locator.file = dev.file;
93
+ locator.line = dev.line;
94
+ if (dev.via !== undefined)
95
+ locator.via = dev.via;
96
+ locator.kind = dev.kind ?? "unchecked";
97
+ locator.verified = locator.kind === "image";
98
+ if (dev.ownerChain?.length)
99
+ locator.ownerChain = dev.ownerChain;
100
+ }
101
+ if (dev.component.length > 0)
102
+ locator.component = dev.component;
103
+ return { ...record, locator };
104
+ });
105
+ }
23
106
  /** A URL with the page's own origin taken out, so a built server and a dev server give the same pattern. */
24
107
  export function joinUrl(url, pageUrl) {
25
108
  let origin;
@@ -102,6 +185,9 @@ export function joinLocate(records, viewport, devImages, flagged) {
102
185
  const notMeasured = [];
103
186
  let noOrder = 0;
104
187
  for (const record of records) {
188
+ // A poster never joins an <img> (see isFlagged): skipped, so it can never take another element's file:line.
189
+ if (record.poster === true)
190
+ continue;
105
191
  const entry = record.perViewport.find((p) => p.viewport === viewport);
106
192
  if (!entry) {
107
193
  if (flagged(record))
@@ -5,7 +5,7 @@ import { mkdir } from "node:fs/promises";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { writeFileAtomic } from "./atomic.js";
7
7
  import { DEFAULT_VIEWPORTS } from "./config.js";
8
- import { applyLocate, devImagesOf, isFlagged, joinLocate, joinKeyOfImage, joinKeyOfRecord, readMeasuredScan, reasonCounts } from "./locate-join.js";
8
+ import { applyLocate, applyLocateVideos, devImagesOf, devVideosOf, isFlagged, isFlaggedVideo, joinLocate, joinKeyOfImage, joinKeyOfRecord, joinVideos, readMeasuredScan, reasonCounts } from "./locate-join.js";
9
9
  import { observeLight, rootOf } from "./remote.js";
10
10
  import { parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
11
11
  import { unreadSourceNote } from "./source-root.js";
@@ -119,6 +119,7 @@ export async function locateCommand(argv, cwd, log) {
119
119
  ignoreRobots: args.ignoreRobots,
120
120
  wanted: (img, pageUrl) => flaggedPatterns.has(joinKeyOfImage(img, pageUrl)),
121
121
  thirdParty: args.thirdParty,
122
+ videos: true,
122
123
  });
123
124
  if (light.status !== "ok") {
124
125
  return { output: `locate: the dev page is ${light.status}${light.error ? `: ${safeText(light.error)}` : ""}\n`, exitCode: 1 };
@@ -127,6 +128,9 @@ export async function locateCommand(argv, cwd, log) {
127
128
  const devImages = devImagesOf(observation, args.url, light.resolved);
128
129
  const unread = await unreadSourceNote(devImages.flatMap((d) => (d.kind === "unread" && d.file !== undefined ? [d.file] : [])), cwd, root);
129
130
  const join = joinLocate(measured.images, viewport, devImages, (record) => flaggedSet.has(record));
131
+ // Videos are joined by URL (no element order): a flagged video (one that costs bytes per view) gets its dev file:line.
132
+ const videoJoin = joinVideos(measured.videos ?? [], devVideosOf(observation, args.url, light.resolvedVideos), isFlaggedVideo);
133
+ const flaggedVideos = (measured.videos ?? []).filter(isFlaggedVideo).length;
130
134
  const seconds = (Date.now() - started) / 1000;
131
135
  const summary = {
132
136
  url: args.url,
@@ -141,7 +145,12 @@ export async function locateCommand(argv, cwd, log) {
141
145
  noOrder: join.noOrder,
142
146
  reasons: reasonCounts(join),
143
147
  };
144
- const located = { ...measured, locate: summary, images: applyLocate(measured.images, join, viewport) };
148
+ const located = {
149
+ ...measured,
150
+ locate: summary,
151
+ images: applyLocate(measured.images, join, viewport),
152
+ ...(measured.videos !== undefined ? { videos: applyLocateVideos(measured.videos, videoJoin) } : {}),
153
+ };
145
154
  const outPath = resolve(cwd, args.out ?? defaultLocateOut(args.from));
146
155
  if (outPath === resolve(cwd, args.from))
147
156
  throw new Error("--locate-out must not be the measured scan itself");
@@ -153,12 +162,14 @@ export async function locateCommand(argv, cwd, log) {
153
162
  ];
154
163
  for (const [code, count] of Object.entries(summary.reasons))
155
164
  lines.push(` not located, ${count}: ${REASON_TEXT[code] ?? code}`);
165
+ if (flaggedVideos > 0)
166
+ lines.push(`flagged videos: ${flaggedVideos}; located: ${videoJoin.pairs.size}; not located: ${videoJoin.notJoined}`);
156
167
  if (summary.devLeft > 0)
157
168
  lines.push(`dev images of a flagged URL with no measured partner: ${summary.devLeft}`);
158
169
  if (summary.noOrder > 0)
159
170
  lines.push(`measured elements without an element order: ${summary.noOrder} (the scan is older: scan again)`);
160
171
  lines.push(`wrote ${safeText(outPath)}`);
161
- const exitCode = flagged.length > 0 && summary.joined === 0 ? 1 : 0;
172
+ const exitCode = flagged.length + flaggedVideos > 0 && summary.joined + videoJoin.pairs.size === 0 ? 1 : 0;
162
173
  if (exitCode !== 0)
163
174
  lines.push("nothing located: is this the dev page of the measured scan, with the same images?");
164
175
  else
package/dist/locate.js CHANGED
@@ -347,8 +347,12 @@ export const SOURCE_WINDOW = 3;
347
347
  const OPENING_SEARCH = 30;
348
348
  /** Largest project file read for the check. */
349
349
  export const MAX_SOURCE_READ = 2 * 1024 * 1024;
350
- /** An opening image tag or a srcset prop. */
351
- const IMAGE_TOKEN = /<(?:img|Image|picture|source)(?=[\s/>]|$)|\bsrc[sS]et\s*[={]/;
350
+ /**
351
+ * An opening image tag or a srcset prop. A <video> opening and a <source> child count too: a video's poster is an image
352
+ * record that carries the video's locator, so its owner line is the <video> element (and the sources sit on their own
353
+ * lines). Lowercase only, so a component such as <VideoPlayer> is not taken for the element.
354
+ */
355
+ const IMAGE_TOKEN = /<(?:img|Image|picture|source|video)(?=[\s/>]|$)|\bsrc[sS]et\s*[={]/;
352
356
  /**
353
357
  * Whether a project source line (1-based) renders an image: an image token within SOURCE_WINDOW lines of it, an opening
354
358
  * image tag above it with no closing tag between, or the definition (`function Name`, `const Name`) of the component