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/image-file.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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 {
|
|
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 ??=
|
|
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 =
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
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 &&
|
|
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)
|
package/dist/locate-join.js
CHANGED
|
@@ -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))
|
package/dist/locate-only.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
/**
|
|
351
|
-
|
|
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
|