imgtune 0.1.0 → 0.3.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 +172 -0
- package/README.md +109 -5
- package/dist/animated.js +176 -0
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +42 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +56 -6
- package/dist/check.js +26 -5
- package/dist/cli.js +86 -8
- package/dist/egress-proxy.js +2 -1
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +530 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +704 -16
- package/dist/image-file.js +154 -34
- package/dist/image-open.js +9 -0
- package/dist/images.js +142 -8
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +1054 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +251 -0
- package/dist/media-phase.js +675 -0
- package/dist/media-prepare.js +31 -0
- package/dist/px.js +11 -0
- package/dist/recheck.js +12 -4
- package/dist/remote.js +1065 -107
- package/dist/replay.js +20 -0
- package/dist/resources/audio.js +248 -0
- package/dist/resources/brotli.js +22 -0
- package/dist/resources/cache.js +59 -0
- package/dist/resources/capture.js +225 -0
- package/dist/resources/compression.js +122 -0
- package/dist/resources/favicon.js +130 -0
- package/dist/resources/font.js +440 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/lottie.js +491 -0
- package/dist/resources/model.js +478 -0
- package/dist/resources/phase.js +171 -0
- package/dist/resources/pool.js +47 -0
- package/dist/resources/report.js +57 -0
- package/dist/resources/sfnt.js +305 -0
- package/dist/resources/svg.js +178 -0
- package/dist/resources/types.js +1 -0
- package/dist/resources/zip.js +67 -0
- package/dist/result-cache.js +56 -3
- package/dist/run.js +32 -8
- package/dist/sizes.js +7 -5
- package/dist/summary.js +69 -3
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +371 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +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
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// sharp over a downloaded file, with the pixel limit every scan applies. Kept apart from remote.ts so the image helpers
|
|
2
|
+
// (vector-art.ts, the media code) can open a file without importing the scan.
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
/** The most input pixels sharp accepts for a downloaded file. */
|
|
5
|
+
export const MAX_INPUT_PIXELS = 100_000_000;
|
|
6
|
+
/** sharp over a downloaded file, refusing inputs above `limit` pixels. */
|
|
7
|
+
export function openImage(buffer, limit = MAX_INPUT_PIXELS) {
|
|
8
|
+
return sharp(buffer, { limitInputPixels: limit });
|
|
9
|
+
}
|
package/dist/images.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import { sha1 } from "./atomic.js";
|
|
5
5
|
import { deriveSizesFromLayout, sameStructure, slotAt, } from "./layout.js";
|
|
6
6
|
import { COMPONENT_NAME, loaderName, sanitizeText, urlPattern } from "./locate.js";
|
|
7
|
-
import { undersizedProposal } from "./candidates.js";
|
|
7
|
+
import { srcsetWidthsOf, 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,77 @@ 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
|
+
}
|
|
54
|
+
/** The hidden fix of an element from its entries (one per viewport it was measured at), or undefined when it is shown on load everywhere. */
|
|
55
|
+
export function hiddenFixCore(entries, viewportCount) {
|
|
56
|
+
const hidden = entries.filter((e) => e.hidden !== undefined);
|
|
57
|
+
if (hidden.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const reasons = [...new Set(hidden.map((e) => e.hidden).filter((r) => r !== undefined))];
|
|
60
|
+
const inPanel = hidden.some((e) => e.inPanel);
|
|
61
|
+
const hover = hidden.some((e) => e.hover);
|
|
62
|
+
const unstable = hidden.some((e) => e.unstable === true);
|
|
63
|
+
const shownIn = entries.filter((e) => e.hidden === undefined).map((e) => e.viewport.name);
|
|
64
|
+
const alreadyLazy = hidden.some((e) => e.lazy === true);
|
|
65
|
+
const onlyClipped = reasons.every((r) => r === "clipped");
|
|
66
|
+
// A responsive twin: shown on load somewhere, and hidden by a rule that is not a carousel's clipping.
|
|
67
|
+
const action = shownIn.length > 0 && !onlyClipped ? "responsive" : reasons.includes("clipped") || inPanel || hover ? "lazy" : "never-shown-on-load";
|
|
68
|
+
return {
|
|
69
|
+
reasons,
|
|
70
|
+
inPanel,
|
|
71
|
+
...(hover ? { hover: true } : {}),
|
|
72
|
+
...(unstable ? { unstable: "unknown" } : {}),
|
|
73
|
+
viewports: hidden.map((e) => e.viewport.name),
|
|
74
|
+
of: viewportCount,
|
|
75
|
+
widths: Object.fromEntries(entries.map((e) => [e.viewport.name, e.viewport.width])),
|
|
76
|
+
shownIn,
|
|
77
|
+
action,
|
|
78
|
+
...(alreadyLazy ? { alreadyLazy: true } : {}),
|
|
79
|
+
perViewBytes: hidden.reduce((sum, e) => sum + e.bytes, 0) / viewportCount,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The hidden fix of an image element from its instances (one per viewport). */
|
|
83
|
+
export function hiddenFixOf(members, viewportCount) {
|
|
84
|
+
return hiddenFixCore(members.map((m) => ({
|
|
85
|
+
viewport: { name: m.viewport.name, width: m.viewport.width },
|
|
86
|
+
bytes: m.bytes,
|
|
87
|
+
hidden: m.kind === "hidden" ? m.img.hidden : undefined,
|
|
88
|
+
inPanel: m.img.inPanel === true,
|
|
89
|
+
hover: m.img.hover === true,
|
|
90
|
+
lazy: m.img.lazy === true,
|
|
91
|
+
unstable: m.img.unstable === "unknown",
|
|
92
|
+
})), viewportCount);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The page's loaded-but-not-shown totals, counted over the records (one record per element or background layer, so the
|
|
96
|
+
* count and the cases are the same unit). Undefined when no record is hidden.
|
|
97
|
+
*/
|
|
98
|
+
export function hiddenTotalsOf(records, viewportCount) {
|
|
99
|
+
const hidden = records.flatMap((r) => (r.hidden !== undefined ? [r] : []));
|
|
100
|
+
if (hidden.length === 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
const cases = { responsive: 0, lazy: 0, "never-shown-on-load": 0 };
|
|
103
|
+
for (const r of hidden)
|
|
104
|
+
cases[r.hidden.action]++;
|
|
105
|
+
// A file several records share (background layers of one image, or a repeated element) is one file: its bytes count once,
|
|
106
|
+
// at the largest of its records. A record with no URL is its own file.
|
|
107
|
+
const perFile = new Map();
|
|
108
|
+
hidden.forEach((r, index) => {
|
|
109
|
+
const key = r.current.src ?? `#${index}`;
|
|
110
|
+
perFile.set(key, Math.max(perFile.get(key) ?? 0, r.hidden.perViewBytes));
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
images: hidden.length,
|
|
114
|
+
bytesPerView: [...perFile.values()].reduce((sum, bytes) => sum + bytes, 0),
|
|
115
|
+
cases,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
46
118
|
const round1 = (value) => Math.round(value * 10) / 10;
|
|
47
119
|
const round2 = (value) => Math.round(value * 100) / 100;
|
|
48
120
|
/**
|
|
@@ -110,7 +182,7 @@ function tunableOf(member, pageUrl, loader) {
|
|
|
110
182
|
/** The measured viewports of a group as the proposal check reads them. */
|
|
111
183
|
function measuredPoints(members) {
|
|
112
184
|
return members
|
|
113
|
-
.filter((m) => m.renderedWidth >= 1)
|
|
185
|
+
.filter((m) => m.renderedWidth >= 1 && m.kind !== "hidden")
|
|
114
186
|
.map((m) => ({ viewport: m.viewport.name, viewportWidth: m.viewport.width, dpr: m.viewport.dpr, renderedWidth: m.renderedWidth, srcset: m.img.srcset }));
|
|
115
187
|
}
|
|
116
188
|
function sizesOf(measured) {
|
|
@@ -125,6 +197,17 @@ function sizesOf(measured) {
|
|
|
125
197
|
export function hasFix(record) {
|
|
126
198
|
return record.fix.bytes.saved > 0 || (record.fix.sizes !== undefined && record.fix.sizes.value !== record.current.sizes);
|
|
127
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* A plain image with no srcset at all, from a same-origin file or a CDN host no adapter knows: its saving is a resize, and
|
|
202
|
+
* its fix is variant files with a srcset (see srcsetWidthsOf). A known loader (next/image, a named CDN adapter) keeps its own
|
|
203
|
+
* advice, and so does a page with Next.js detected.
|
|
204
|
+
*/
|
|
205
|
+
export function isPlainNoSrcset(kind, srcset, loader, framework) {
|
|
206
|
+
if (kind !== "plain" || framework || srcset.trim() !== "")
|
|
207
|
+
return false;
|
|
208
|
+
// "generic" is the adapter name of an origin no adapter matched (Instance.loader); the locator names it plain-img or cdn:<host>.
|
|
209
|
+
return loader === "plain-img" || loader === "unknown" || loader === "generic" || loader.startsWith("cdn:");
|
|
210
|
+
}
|
|
128
211
|
/** Builds the image records of one page from its analysed instances. */
|
|
129
212
|
export function buildImageRecords(input) {
|
|
130
213
|
const { pageUrl, observations, instances, refName, resolved } = input;
|
|
@@ -184,6 +267,8 @@ export function buildImageRecords(input) {
|
|
|
184
267
|
loaded: inst.url !== "" && obs.responses.get(inst.url)?.buffer !== undefined,
|
|
185
268
|
lcp: obs.lcp?.kind === "image" && obs.lcp.index === inst.element,
|
|
186
269
|
};
|
|
270
|
+
if (inst.kind === "hidden" && inst.img.hidden !== undefined)
|
|
271
|
+
entry.hidden = inst.img.hidden;
|
|
187
272
|
if (inst.pixelWidth > 0)
|
|
188
273
|
entry.servedPx = inst.pixelWidth;
|
|
189
274
|
if (inst.pixelWidth > 0 && inst.needed > 0)
|
|
@@ -211,7 +296,7 @@ export function buildImageRecords(input) {
|
|
|
211
296
|
};
|
|
212
297
|
}
|
|
213
298
|
const measured = members
|
|
214
|
-
.filter((m) => m.renderedWidth >= 1)
|
|
299
|
+
.filter((m) => m.renderedWidth >= 1 && m.kind !== "hidden")
|
|
215
300
|
.map((m) => ({
|
|
216
301
|
viewportWidth: m.viewport.width,
|
|
217
302
|
renderedWidth: m.renderedWidth,
|
|
@@ -228,23 +313,55 @@ export function buildImageRecords(input) {
|
|
|
228
313
|
const keepSizes = sizes !== undefined && left === undefined;
|
|
229
314
|
const savedEff = keepSizes ? saved : saved - savedSizes;
|
|
230
315
|
const savedSizesEff = keepSizes ? savedSizes : 0;
|
|
316
|
+
// A plain image with no srcset: the sizes attribute does nothing without one, so no sizes proposal; its saving is the
|
|
317
|
+
// resize (est.), not a re-encode.
|
|
318
|
+
const plainNoSrcset = isPlainNoSrcset(member.kind, member.img.srcset, locator.loader, input.nextjs === true);
|
|
231
319
|
const served = member.format;
|
|
232
|
-
const
|
|
233
|
-
const
|
|
320
|
+
const animatedMember = members.find((m) => m.animated !== undefined);
|
|
321
|
+
const lossless = losslessServed(served, animatedMember !== undefined);
|
|
322
|
+
const best = lossless ? (saved > 0 ? "avif" : served) : served;
|
|
323
|
+
// A hidden fix first: an element never shown on load gets no animated encode either (its bytes are not a saving).
|
|
324
|
+
const hiddenFix = hiddenFixOf(members, viewportCount);
|
|
325
|
+
const fix = {
|
|
326
|
+
bytes: plainNoSrcset
|
|
327
|
+
? { saved: savedEff, addedForSharpness, sizes: 0, resize: savedEff, quality: 0 }
|
|
328
|
+
: { saved: savedEff, addedForSharpness, sizes: savedSizesEff, quality: savedEff - savedSizesEff },
|
|
329
|
+
};
|
|
234
330
|
if (member.cdnQuality !== undefined)
|
|
235
331
|
fix.cdnQuality = { ...member.cdnQuality };
|
|
236
|
-
if (member.formatAuto === true &&
|
|
332
|
+
if (member.formatAuto === true && lossless)
|
|
237
333
|
fix.formatHandled = true;
|
|
334
|
+
if (animatedMember?.animated !== undefined && hiddenFix?.shownIn.length !== 0) {
|
|
335
|
+
fix.animated = {
|
|
336
|
+
info: animatedMember.animated,
|
|
337
|
+
context: "img",
|
|
338
|
+
target: animatedTarget("img", undefined),
|
|
339
|
+
perViewBytes: members.reduce((sum, m) => sum + m.bytes, 0) / viewportCount,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
238
342
|
if (sizes && left !== undefined) {
|
|
239
343
|
fix.noSaving = `sizes proposal left out: ${left}`;
|
|
240
344
|
notes.push(`sizes proposal left out: ${left}`);
|
|
241
345
|
}
|
|
242
|
-
if (
|
|
346
|
+
if (plainNoSrcset && savedEff > 0) {
|
|
347
|
+
const naturalWidth = member.pixelWidth > 0 ? member.pixelWidth : undefined;
|
|
348
|
+
const needs = members.map((m) => m.needed);
|
|
349
|
+
const widths = srcsetWidthsOf(needs, naturalWidth);
|
|
350
|
+
if (widths.length > 0) {
|
|
351
|
+
fix.srcset = { widths, largest: Math.max(...needs), ...(keepSizes && sizes !== undefined ? { sizes: sizes.value } : {}) };
|
|
352
|
+
notes.push(`srcset: no srcset today; variants at ${widths.join("w, ")}w (the saving is a resize)`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (keepSizes && !plainNoSrcset) {
|
|
243
356
|
fix.sizes = { value: sizes.value, basis: sizes.basis, rung, branches: sizes.branches };
|
|
244
357
|
const sized = sizes.branches.map((b) => b.basis);
|
|
245
358
|
const formulas = sized.filter((b) => b === "layout-formula").length;
|
|
246
359
|
notes.push(`sizes: ${sizes.basis} (${formulas} of ${sized.length} media branch(es) from the layout formula, checked at ${measured.length} of ${viewportCount} viewports)`);
|
|
247
360
|
}
|
|
361
|
+
const flatMember = members.find((m) => m.flatness?.flat === true);
|
|
362
|
+
// Never on a hidden element: the vector check and the hidden fix do not stack on one record.
|
|
363
|
+
if (flatMember?.flatness !== undefined && hiddenFix === undefined)
|
|
364
|
+
fix.vector = { flatness: { ...flatMember.flatness } };
|
|
248
365
|
if (member.kind === "plain")
|
|
249
366
|
fix.width = member.needed;
|
|
250
367
|
if (best !== undefined)
|
|
@@ -252,6 +369,7 @@ export function buildImageRecords(input) {
|
|
|
252
369
|
const record = {
|
|
253
370
|
id: sha1(`${pageUrl}\n${key}`).slice(0, 12),
|
|
254
371
|
page: pageUrl,
|
|
372
|
+
...(member.img.posterOf !== undefined ? { poster: true } : {}),
|
|
255
373
|
locator,
|
|
256
374
|
tunable: tunableOf(member, pageUrl, locator.loader),
|
|
257
375
|
current: {
|
|
@@ -286,6 +404,12 @@ export function buildImageRecords(input) {
|
|
|
286
404
|
notes.push(`quality handled by ${locator.loader} (q_auto): its est. quality saving is not counted`);
|
|
287
405
|
if (fix.formatHandled === true)
|
|
288
406
|
notes.push(`format handled by ${locator.loader} (f_auto): no format change proposed`);
|
|
407
|
+
if (member.img.posterOf !== undefined)
|
|
408
|
+
notes.push(`poster of video ${sanitizeText(member.img.posterOf, 120)} (sized to its box)`);
|
|
409
|
+
if (fix.animated !== undefined) {
|
|
410
|
+
const { frames, durationMs, loop } = fix.animated.info;
|
|
411
|
+
notes.push(`animated ${fix.animated.info.format}: ${frames} frame(s), ${Math.round(durationMs / 100) / 10} s per loop, loops ${loop === 0 ? "forever" : loop}`);
|
|
412
|
+
}
|
|
289
413
|
if (layoutChain?.truncated)
|
|
290
414
|
notes.push("layout chain cut at the depth cap");
|
|
291
415
|
if (layoutChain && layoutChain.skipped.length > 0)
|
|
@@ -296,6 +420,15 @@ export function buildImageRecords(input) {
|
|
|
296
420
|
continue;
|
|
297
421
|
own.set(m.format, Math.max(own.get(m.format) ?? -Infinity, m.qualityInfo.target));
|
|
298
422
|
}
|
|
423
|
+
// A loaded-but-not-shown element has no vector claim (the vector check and the hidden fix never stack), and a copy that
|
|
424
|
+
// is never shown on load has no sizes claim either: its bytes are the hidden fix's. A responsive twin keeps the sizes of
|
|
425
|
+
// the viewports that show it (see the hidden-images tests).
|
|
426
|
+
if (hiddenFix !== undefined) {
|
|
427
|
+
record.hidden = hiddenFix;
|
|
428
|
+
delete record.fix.vector;
|
|
429
|
+
if (hiddenFix.shownIn.length === 0)
|
|
430
|
+
delete record.fix.sizes;
|
|
431
|
+
}
|
|
299
432
|
targets.set(record, own);
|
|
300
433
|
records.push(record);
|
|
301
434
|
}
|
|
@@ -319,7 +452,8 @@ export function buildImageRecords(input) {
|
|
|
319
452
|
for (const [format, target] of entry.max)
|
|
320
453
|
perFormat[format] = target;
|
|
321
454
|
for (const record of entry.records) {
|
|
322
|
-
|
|
455
|
+
// An image never shown has no quality claim: its file is not on screen at any viewport.
|
|
456
|
+
if (record.current.quality === undefined || record.hidden?.shownIn.length === 0)
|
|
323
457
|
continue;
|
|
324
458
|
record.fix.quality = {
|
|
325
459
|
perFormat,
|
package/dist/layout.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// a chain per image and viewport; this module walks the chain down from the viewport to the image and states the image's
|
|
3
|
-
// width as a linear function of the viewport width (a * vw + b). The function is checked against the widths the browser
|
|
4
|
-
// rendered, branch by branch, and printed as CSS.
|
|
1
|
+
import { offsetPx, wholePx } from "./px.js";
|
|
5
2
|
/** Rounding of the coefficients: the printed value is the value the check uses. */
|
|
6
3
|
const round = (value, digits) => {
|
|
7
4
|
const scale = 10 ** digits;
|
|
@@ -95,16 +92,19 @@ export function formatLinear(l) {
|
|
|
95
92
|
const vwPercent = round(l.a * 100, 2);
|
|
96
93
|
const px = round(l.b, 2);
|
|
97
94
|
if (vwPercent === 0)
|
|
98
|
-
return `${Math.max(0, px)}px`;
|
|
95
|
+
return `${Math.max(0, wholePx(px))}px`;
|
|
99
96
|
if (px === 0)
|
|
100
97
|
return `${vwPercent}vw`;
|
|
101
|
-
|
|
98
|
+
const offset = offsetPx(px);
|
|
99
|
+
if (offset === 0)
|
|
100
|
+
return `${vwPercent}vw`;
|
|
101
|
+
return `calc(${vwPercent}vw ${offset < 0 ? "-" : "+"} ${Math.abs(offset)}px)`;
|
|
102
102
|
}
|
|
103
103
|
/** A fitted line held outside the range it was measured in: the viewport is clamped to [lo, hi] (CSS clamp()). */
|
|
104
104
|
export function formatHeldFit(l, lo, hi) {
|
|
105
105
|
if (Math.abs(l.a) < 1e-9)
|
|
106
106
|
return formatLinear({ a: 0, b: l.b });
|
|
107
|
-
const b = round(l.b, 2);
|
|
107
|
+
const b = offsetPx(round(l.b, 2));
|
|
108
108
|
return `calc(${round(l.a, 4)} * clamp(${Math.round(lo)}px, 100vw, ${Math.round(hi)}px) ${b < 0 ? "-" : "+"} ${Math.abs(b)}px)`;
|
|
109
109
|
}
|
|
110
110
|
/** The value of a held fit at a viewport: the line at the viewport clamped to [lo, hi]. */
|
package/dist/lcp.js
CHANGED
|
@@ -32,7 +32,8 @@ export function lcpImagesOf(result) {
|
|
|
32
32
|
servedBytes: served.reduce((sum, p) => sum + p.servedBytes, 0) / served.length,
|
|
33
33
|
savedBytes: record.fix.bytes.saved,
|
|
34
34
|
exactBytes: record.fix.bytes.sizes ?? 0,
|
|
35
|
-
|
|
35
|
+
// The resize of a plain image with no srcset is an estimate too (see ImageFix.bytes).
|
|
36
|
+
estBytes: (record.fix.bytes.quality ?? 0) + (record.fix.bytes.resize ?? 0),
|
|
36
37
|
...(skipped !== undefined ? { skipped } : {}),
|
|
37
38
|
});
|
|
38
39
|
}
|