imgtune 0.2.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 +98 -0
- package/README.md +69 -6
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +31 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +37 -4
- package/dist/check.js +18 -1
- package/dist/cli.js +20 -0
- package/dist/egress-proxy.js +2 -1
- package/dist/ffmpeg.js +35 -0
- package/dist/hints.js +294 -18
- package/dist/image-open.js +9 -0
- package/dist/images.js +116 -7
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +5 -1
- package/dist/media-model.js +8 -4
- package/dist/media-phase.js +25 -2
- package/dist/px.js +11 -0
- package/dist/recheck.js +9 -1
- package/dist/remote.js +616 -63
- 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/run.js +24 -4
- package/dist/sizes.js +7 -5
- package/dist/summary.js +47 -1
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +119 -21
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +1 -1
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,8 @@ const progress = (message) => {
|
|
|
21
21
|
const OPTION_HELP = {
|
|
22
22
|
out: ["--out <dir>", "directory for the JSON files (default: .imgtune/scan)"],
|
|
23
23
|
noCache: ["--no-cache", "do not read or write the result cache in .imgtune/cache (the numbers are computed again)"],
|
|
24
|
+
noResources: ["--no-resources", "skip the resource phase: no capture of the non-image responses, so none of the resource findings (compression, favicons, fonts, SVG, Lottie, 3D, audio)"],
|
|
25
|
+
resourceTime: ["--resource-time <s>", "time limit of the heavy resource work (3D, fonts, SVG, audio, lottie re-encodes), in seconds, 0 to 3600 (default 60); items over it are reported as not measured"],
|
|
24
26
|
gpuEncode: [
|
|
25
27
|
"--gpu-encode",
|
|
26
28
|
"EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF); off by default (CPU encodes). Bytes are scaled to a CPU encode (÷1.4)",
|
|
@@ -66,6 +68,10 @@ const OPTION_HELP = {
|
|
|
66
68
|
"--media-budget <N>",
|
|
67
69
|
"sample-encode at most N animated images and videos per run, heaviest first, 0 to 1000 (default 20); the rest keep their cheap hints",
|
|
68
70
|
],
|
|
71
|
+
svgBudget: [
|
|
72
|
+
"--svg-budget <N>",
|
|
73
|
+
"trace at most N flat images (logos, icons, flat illustrations) to SVG and check them, heaviest first, 0 to 1000 (default 50; 0 traces none)",
|
|
74
|
+
],
|
|
69
75
|
mediaTime: ["--media-time <s>", "time limit of the media encodes, in seconds, 1 to 3600 (default 600)"],
|
|
70
76
|
videoBar: ["--video-bar <n>", "minimum VMAF of a video or animated encode, 0 to 100 (default 93)"],
|
|
71
77
|
videoStillBar: ["--video-still-bar <n>", "minimum SSIMULACRA2 of the still-frame guard, 0 to 100 (default 65)"],
|
|
@@ -149,6 +155,8 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
|
|
|
149
155
|
"out",
|
|
150
156
|
"root",
|
|
151
157
|
"noCache",
|
|
158
|
+
"noResources",
|
|
159
|
+
"resourceTime",
|
|
152
160
|
"gpuEncode",
|
|
153
161
|
"aggressive",
|
|
154
162
|
"viewports",
|
|
@@ -163,6 +171,7 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
|
|
|
163
171
|
"maxPages",
|
|
164
172
|
"maxImages",
|
|
165
173
|
"mediaBudget",
|
|
174
|
+
"svgBudget",
|
|
166
175
|
"mediaTime",
|
|
167
176
|
"videoBar",
|
|
168
177
|
"videoStillBar",
|
|
@@ -211,9 +220,12 @@ Options:
|
|
|
211
220
|
--gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
|
|
212
221
|
--page-concurrency <N> pages at once in the deep phase (default 1)
|
|
213
222
|
--idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
|
|
223
|
+
--svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
|
|
214
224
|
--block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
|
|
215
225
|
--allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
|
|
216
226
|
--no-cache do not read or write the result cache
|
|
227
|
+
--no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
|
|
228
|
+
--resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
|
|
217
229
|
--ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
|
|
218
230
|
--storage-state <file> a logged-in session (holds cookies: never commit it)
|
|
219
231
|
-h, --help show this help
|
|
@@ -247,9 +259,12 @@ Options:
|
|
|
247
259
|
--gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
|
|
248
260
|
--page-concurrency <N> pages at once in the deep phase (default 1)
|
|
249
261
|
--idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
|
|
262
|
+
--svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
|
|
250
263
|
--block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
|
|
251
264
|
--allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
|
|
252
265
|
--no-cache do not read or write the result cache
|
|
266
|
+
--no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
|
|
267
|
+
--resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
|
|
253
268
|
--ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
|
|
254
269
|
--storage-state <file> a logged-in session (holds cookies: never commit it)
|
|
255
270
|
-h, --help show this help
|
|
@@ -278,7 +293,10 @@ Options:
|
|
|
278
293
|
--gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
|
|
279
294
|
--page-concurrency <N> pages at once in the survey (default 4) and in the deep phase (default 1)
|
|
280
295
|
--idle-timeout <ms> cap on the network-idle wait (default 3000 in the survey, 10000 in the deep phase)
|
|
296
|
+
--svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
|
|
281
297
|
--no-cache do not read or write the result cache
|
|
298
|
+
--no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
|
|
299
|
+
--resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
|
|
282
300
|
--root <dir> the source folder for --dev (a monorepo sub-folder)
|
|
283
301
|
--storage-state <file> a logged-in session (holds cookies: never commit it)
|
|
284
302
|
--ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
|
|
@@ -374,6 +392,8 @@ Options:
|
|
|
374
392
|
`;
|
|
375
393
|
const USAGE = `Usage: imgtune <command> [options]
|
|
376
394
|
|
|
395
|
+
imgtune measures page weight on live pages (images, video, hidden downloads, fonts, compression, 3D, audio, Lottie, SVG) and prints the fix for each.
|
|
396
|
+
|
|
377
397
|
Commands:
|
|
378
398
|
scan measure live pages by URL, with no source, config or dev server (writes JSON to --out)
|
|
379
399
|
hints print a batch of the per-image fix hints of a scan file
|
package/dist/egress-proxy.js
CHANGED
|
@@ -38,8 +38,9 @@ function connectionFor(hostname, policy, onRefused) {
|
|
|
38
38
|
const checked = guardedLookup(policy.resolver, policy.allowPrivate, policy.connectAddress);
|
|
39
39
|
const lookup = ((name, options, callback) => {
|
|
40
40
|
checked(name, options, (err, ...rest) => {
|
|
41
|
+
// The name a lookup could not resolve: the page may still load it in a browser (see the scan's unresolved hosts).
|
|
41
42
|
if (err)
|
|
42
|
-
onRefused();
|
|
43
|
+
onRefused(name);
|
|
43
44
|
callback(err, ...rest);
|
|
44
45
|
});
|
|
45
46
|
});
|
package/dist/ffmpeg.js
CHANGED
|
@@ -493,3 +493,38 @@ export function parseRate(rate) {
|
|
|
493
493
|
const fps = n / d;
|
|
494
494
|
return fps > 0 ? fps : undefined;
|
|
495
495
|
}
|
|
496
|
+
/** Turns ffprobe's JSON into an AudioProbe, or undefined when there is no audio stream with a codec. */
|
|
497
|
+
export function parseAudioProbeJson(json) {
|
|
498
|
+
const audio = (json.streams ?? []).find((s) => s.codec_type === "audio");
|
|
499
|
+
if (!audio?.codec_name)
|
|
500
|
+
return undefined;
|
|
501
|
+
const bitrate = toNumber(json.format?.bit_rate) ?? toNumber(audio.bit_rate);
|
|
502
|
+
const probe = {
|
|
503
|
+
codec: audio.codec_name,
|
|
504
|
+
durationS: toNumber(json.format?.duration) ?? toNumber(audio.duration) ?? 0,
|
|
505
|
+
channels: typeof audio.channels === "number" && audio.channels > 0 ? audio.channels : 0,
|
|
506
|
+
};
|
|
507
|
+
if (bitrate !== undefined && bitrate > 0)
|
|
508
|
+
probe.bitrate = bitrate;
|
|
509
|
+
const sampleRate = toNumber(audio.sample_rate);
|
|
510
|
+
if (sampleRate !== undefined && sampleRate > 0)
|
|
511
|
+
probe.sampleRate = sampleRate;
|
|
512
|
+
return probe;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Reads an audio file with ffprobe. `input` is an absolute file path or an http(s) URL (see inputOptions). Resolves
|
|
516
|
+
* undefined when ffprobe fails, times out or finds no audio stream. Never throws for a bad file.
|
|
517
|
+
*/
|
|
518
|
+
export async function probeAudio(input, timeoutMs, opts = {}) {
|
|
519
|
+
if (/^https?:\/\//i.test(input) && opts.proxy === undefined)
|
|
520
|
+
return undefined;
|
|
521
|
+
try {
|
|
522
|
+
const result = await runBinary(ffprobeBin(), ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", ...inputOptions(input, opts.proxy), "-i", input], { timeoutMs });
|
|
523
|
+
if (result.code !== 0)
|
|
524
|
+
return undefined;
|
|
525
|
+
return parseAudioProbeJson(JSON.parse(result.stdout.toString("utf8")));
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
}
|
package/dist/hints.js
CHANGED
|
@@ -7,8 +7,13 @@ import { safeText } from "./text.js";
|
|
|
7
7
|
import { cleanLocate } from "./locate-join.js";
|
|
8
8
|
import { DEFAULT_VIEWPORTS } from "./config.js";
|
|
9
9
|
import { fitSizes, maxPerWidth } from "./group-sizes.js";
|
|
10
|
+
import { srcsetWidthLabel, srcsetWidthsOf } from "./candidates.js";
|
|
10
11
|
import { MAX_MAX_QUALITIES, MIN_MAX_QUALITIES, planQualityCap, qualitiesLine } from "./qualities-cap.js";
|
|
11
12
|
import { reasonText } from "./media-model.js";
|
|
13
|
+
import { NEVER_SHOWN } from "./media-phase.js";
|
|
14
|
+
import { resourceLinesOf } from "./resources/report.js";
|
|
15
|
+
import { VECTOR_BAR, VECTOR_LOOKS_MIN_BYTES } from "./vector-art.js";
|
|
16
|
+
import { perViewBytesOf } from "./vector-phase.js";
|
|
12
17
|
export const DEFAULT_HINT_LIMIT = 10;
|
|
13
18
|
const usage = "usage: imgtune hints <scan.json> [<scan.json> ...] [--page <text>] [--component <name>] [--limit N] [--offset N] [--min-kb N] [--format text|json] [--flat] [--qualities 75,85,100] [--max-qualities N]";
|
|
14
19
|
function nonNegativeInteger(name, text) {
|
|
@@ -99,8 +104,9 @@ export function parseHintsArgs(argv) {
|
|
|
99
104
|
* planQualityCap); it applies only when no --qualities list was given (that list is the framework's own).
|
|
100
105
|
*/
|
|
101
106
|
export function hintsOf(result, qualities, plan) {
|
|
102
|
-
// An animated image is a hint even without a sizes or byte saving: its saving is an encode (see animatedLines).
|
|
103
|
-
|
|
107
|
+
// An animated image is a hint even without a sizes or byte saving: its saving is an encode (see animatedLines). So is a
|
|
108
|
+
// loaded-but-not-shown image: its bytes are the fix's (see hiddenLines).
|
|
109
|
+
return (result.images ?? []).filter((record) => hasFix(record) || record.fix.animated !== undefined || vectorHinted(record) || record.hidden !== undefined).map((record) => {
|
|
104
110
|
const served = record.current.format;
|
|
105
111
|
const rawTarget = served !== undefined ? record.fix.quality?.perFormat[served] : undefined;
|
|
106
112
|
// With the framework's allowed qualities, the target goes up to one of them. The quality saving was estimated at the
|
|
@@ -119,7 +125,7 @@ export function hintsOf(result, qualities, plan) {
|
|
|
119
125
|
sizes: { current: record.current.sizes, proposed: record.fix.sizes?.value, basis: record.fix.sizes?.basis },
|
|
120
126
|
// A raise (the list has no lower value for this usage) adds bytes: its quality saving is then none, not negative.
|
|
121
127
|
bytes: capped !== undefined
|
|
122
|
-
? { ...record.fix.bytes, saved: (record.fix.bytes.sizes ?? 0) + Math.max(0, capped.saved), quality: Math.max(0, capped.saved) }
|
|
128
|
+
? { ...record.fix.bytes, saved: (record.fix.bytes.sizes ?? 0) + (record.fix.bytes.resize ?? 0) + Math.max(0, capped.saved), quality: Math.max(0, capped.saved) }
|
|
123
129
|
: { ...record.fix.bytes },
|
|
124
130
|
rung: record.evidence.rung,
|
|
125
131
|
notes: record.evidence.notes,
|
|
@@ -171,6 +177,8 @@ export function hintsOf(result, qualities, plan) {
|
|
|
171
177
|
hint.measured = measured;
|
|
172
178
|
if (record.fix.width !== undefined)
|
|
173
179
|
hint.width = record.fix.width;
|
|
180
|
+
if (record.fix.srcset !== undefined)
|
|
181
|
+
hint.srcset = { ...record.fix.srcset };
|
|
174
182
|
const cdn = record.fix.cdnQuality;
|
|
175
183
|
if (cdn !== undefined) {
|
|
176
184
|
hint.qualityNote = cdn.handled
|
|
@@ -204,12 +212,16 @@ export function hintsOf(result, qualities, plan) {
|
|
|
204
212
|
quality.snapFrom = snapFrom;
|
|
205
213
|
quality.void = true;
|
|
206
214
|
if (hint.bytes.sizes !== undefined)
|
|
207
|
-
hint.bytes = { ...hint.bytes, saved: hint.bytes.sizes, quality: 0 };
|
|
215
|
+
hint.bytes = { ...hint.bytes, saved: hint.bytes.sizes + (hint.bytes.resize ?? 0), quality: 0 };
|
|
208
216
|
}
|
|
209
217
|
hint.quality = quality;
|
|
210
218
|
}
|
|
211
219
|
if (record.fix.format !== undefined || served !== undefined)
|
|
212
220
|
hint.format = { current: served, best: record.fix.format };
|
|
221
|
+
if (record.fix.vector !== undefined)
|
|
222
|
+
hint.vector = { ...record.fix.vector, servedPerView: Math.round(perViewBytesOf(record)) };
|
|
223
|
+
if (record.hidden !== undefined)
|
|
224
|
+
hint.hidden = record.hidden;
|
|
213
225
|
if (record.fix.animated !== undefined) {
|
|
214
226
|
hint.animated = record.fix.animated;
|
|
215
227
|
if (record.current.src !== undefined)
|
|
@@ -223,6 +235,40 @@ export function hintsOf(result, qualities, plan) {
|
|
|
223
235
|
return hint;
|
|
224
236
|
});
|
|
225
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* An image gets a vector hint when its SVG passed the check (a claim), or when it is flat, was not traced to a passing SVG,
|
|
240
|
+
* and is big enough to matter (an informational note with no byte claim).
|
|
241
|
+
*/
|
|
242
|
+
export function vectorHinted(record) {
|
|
243
|
+
const vector = record.fix.vector;
|
|
244
|
+
if (vector === undefined)
|
|
245
|
+
return false;
|
|
246
|
+
if (vector.passed === true)
|
|
247
|
+
return true;
|
|
248
|
+
return perViewBytesOf(record) >= VECTOR_LOOKS_MIN_BYTES;
|
|
249
|
+
}
|
|
250
|
+
/** The vector check's lines of a hint: the SVG that passed, or the note on a flat image with no passing SVG. */
|
|
251
|
+
export function vectorLines(vector) {
|
|
252
|
+
if (vector.passed === true && vector.svgBytes !== undefined && vector.score !== undefined) {
|
|
253
|
+
const traced = vector.tracedFile !== undefined ? `, traced one: ${safeText(vector.tracedFile)}` : "";
|
|
254
|
+
return [
|
|
255
|
+
` vector art: serve SVG, ~${kb1(vector.svgBytes)} (br) vs ${kb1(vector.servedPerView)} now, sharp at every DPR; fidelity ${Math.round(vector.score)} at half size (bar ${VECTOR_BAR}); prefer your source SVG${traced}`,
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
if (vector.servedPerView >= VECTOR_LOOKS_MIN_BYTES)
|
|
259
|
+
return [" looks like vector art (flat colors); if a source SVG exists, serve it"];
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The one-line summary of a page's loaded-but-not-shown images: the count, bytes per view and the cases, as the text, Markdown
|
|
264
|
+
* and check outputs all print it. Undefined when there are none.
|
|
265
|
+
*/
|
|
266
|
+
export function hiddenSummaryLine(hidden) {
|
|
267
|
+
if (hidden === undefined || hidden.images === 0)
|
|
268
|
+
return undefined;
|
|
269
|
+
const { responsive, lazy, "never-shown-on-load": never } = hidden.cases;
|
|
270
|
+
return `loaded but not shown: ${hidden.images} image(s), ${kb(hidden.bytesPerView)}/view (responsive ${responsive}, lazy ${lazy}, never shown on load ${never}); not in the savings`;
|
|
271
|
+
}
|
|
226
272
|
/** The hints an argument set asks for: filtered, sorted by bytes saved (largest first), then offset and limited. */
|
|
227
273
|
export function selectHints(hints, args) {
|
|
228
274
|
const page = args.page?.toLowerCase();
|
|
@@ -239,6 +285,7 @@ export function selectHints(hints, args) {
|
|
|
239
285
|
.slice(args.offset, args.offset + args.limit);
|
|
240
286
|
}
|
|
241
287
|
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
|
|
288
|
+
const kb1 = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
242
289
|
/** The mark after a hint's file:line: an owner call site, or an unverified source. */
|
|
243
290
|
export function locationNote(hint) {
|
|
244
291
|
if (hint.kind === "owner")
|
|
@@ -254,17 +301,118 @@ export function qualityTarget(quality) {
|
|
|
254
301
|
const allowed = quality.allowed !== undefined ? ` (allowed: ${quality.allowed.join(",")})` : "";
|
|
255
302
|
return quality.snapFrom !== undefined ? ` q${quality.snapFrom} -> ${quality.target}${allowed}` : ` -> ${quality.target}${allowed}`;
|
|
256
303
|
}
|
|
304
|
+
/** The srcset advice of a plain image with no srcset: the variants to serve, and the srcset and sizes to add. */
|
|
305
|
+
export function srcsetLineOf(srcset) {
|
|
306
|
+
const candidates = srcset.widths.map((w) => `… ${w}w`).join(", ");
|
|
307
|
+
const sizes = srcset.sizes !== undefined ? safeText(srcset.sizes) : "the rendered CSS width per viewport";
|
|
308
|
+
return `srcset: no srcset today; serve variants at ${srcsetWidthLabel(srcset.widths)} (largest needed ${srcset.largest} px) and add srcset="${candidates}" sizes="${sizes}"`;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The srcset advice of a call site, when every image in it is a plain image with no srcset: the union of their widths (at
|
|
312
|
+
* most the variant limit), the largest need, and the first measured sizes value. Undefined for any other group.
|
|
313
|
+
*/
|
|
314
|
+
export function groupSrcsetOf(group) {
|
|
315
|
+
const all = group.hints.flatMap((h) => (h.srcset !== undefined ? [h.srcset] : []));
|
|
316
|
+
if (all.length === 0 || all.length !== group.hints.length)
|
|
317
|
+
return undefined;
|
|
318
|
+
const widths = srcsetWidthsOf(all.flatMap((s) => s.widths), undefined);
|
|
319
|
+
const sizes = all.find((s) => s.sizes !== undefined)?.sizes;
|
|
320
|
+
return { widths, largest: Math.max(...all.map((s) => s.largest)), ...(sizes !== undefined ? { sizes } : {}) };
|
|
321
|
+
}
|
|
257
322
|
/**
|
|
258
323
|
* The saving of one image or one call site, per view, split into its parts when the scan gave them: "saves 15 KB/view =
|
|
259
324
|
* sizes 12 KB + quality 3 KB". A quality part that a snap voided is printed as void, not as a number.
|
|
260
325
|
*/
|
|
261
326
|
export function savesText(hint) {
|
|
262
|
-
const { saved, sizes, quality } = hint.bytes;
|
|
327
|
+
const { saved, sizes, quality, resize } = hint.bytes;
|
|
328
|
+
if (resize !== undefined) {
|
|
329
|
+
const parts = [sizes !== undefined && sizes > 0 ? `sizes ${kb(sizes)}` : undefined, `resize ${kb(resize)}`, (quality ?? 0) > 0 ? `quality ${kb(quality ?? 0)}` : undefined];
|
|
330
|
+
return `saves ${kb(saved)}/view = ${parts.filter((p) => p !== undefined).join(" + ")}`;
|
|
331
|
+
}
|
|
263
332
|
if (sizes === undefined)
|
|
264
333
|
return `saves ${kb(saved)}/view`;
|
|
265
334
|
const qualityPart = hint.quality?.void === true ? "quality void after snap" : `quality ${kb(quality ?? 0)}`;
|
|
266
335
|
return `saves ${kb(saved)}/view = sizes ${kb(sizes)} + ${qualityPart}`;
|
|
267
336
|
}
|
|
337
|
+
/** The media condition that splits a responsive twin's viewports: a min-width or max-width at the default widths. */
|
|
338
|
+
function twinMediaOf(hidden) {
|
|
339
|
+
const hiddenAt = hidden.viewports.map((name) => hidden.widths[name]);
|
|
340
|
+
const shownAt = hidden.shownIn.map((name) => hidden.widths[name]);
|
|
341
|
+
if (hiddenAt.some((w) => !(w > 0)) || shownAt.some((w) => !(w > 0)))
|
|
342
|
+
return "(min-width: <breakpoint>px)";
|
|
343
|
+
// Shown at the wider viewports: the file goes from the breakpoint up, at the narrowest shown width.
|
|
344
|
+
if (Math.max(...hiddenAt) < Math.min(...shownAt))
|
|
345
|
+
return `(min-width: ${Math.min(...shownAt)}px)`;
|
|
346
|
+
// Shown at the narrower viewports: the file goes up to the widest shown width.
|
|
347
|
+
if (Math.min(...hiddenAt) > Math.max(...shownAt))
|
|
348
|
+
return `(max-width: ${Math.max(...shownAt)}px)`;
|
|
349
|
+
return "(min-width: <breakpoint>px)";
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The lines of a loaded-but-not-shown image: where it is hidden, its bytes per view (kept apart from the saving), and the fix
|
|
353
|
+
* for its case (see HiddenFix.action). Page-derived viewport names are printed as JSON strings.
|
|
354
|
+
*/
|
|
355
|
+
export function hiddenLines(hidden) {
|
|
356
|
+
const names = (list) => list.map((v) => JSON.stringify(safeText(v))).join(", ");
|
|
357
|
+
const reasons = [
|
|
358
|
+
...hidden.reasons,
|
|
359
|
+
...(hidden.inPanel ? ["in a tab panel, a hidden subtree or a closed details"] : []),
|
|
360
|
+
...(hidden.hover === true ? ["shown on hover"] : []),
|
|
361
|
+
].join(", ");
|
|
362
|
+
const lines = [
|
|
363
|
+
` loaded but not shown on load: ${kb(hidden.perViewBytes)}/view, hidden at ${hidden.viewports.length} of ${hidden.of} viewport(s) (${names(hidden.viewports)}): ${safeText(reasons)}; not in the saving`,
|
|
364
|
+
];
|
|
365
|
+
if (hidden.unstable === "unknown")
|
|
366
|
+
lines.push(" unstable: unknown (not verified: a name on the page did not resolve, or the candidate cap was reached): the reason is the first read's; check it in a browser");
|
|
367
|
+
if (hidden.action === "responsive") {
|
|
368
|
+
lines.push(` responsive twin, shown on load at ${names(hidden.shownIn)}: serve one file per viewport. Replace the two elements with <picture><source media="${twinMediaOf(hidden)}" srcset="<the file shown there>"><img …></picture>, or delete the hidden copy if the design does not need it.`);
|
|
369
|
+
}
|
|
370
|
+
else if (hidden.action === "lazy") {
|
|
371
|
+
const what = hidden.hover === true ? "a hover swap" : hidden.inPanel ? "a tab panel, a hidden subtree or a closed details" : "a carousel slide or other clipped box";
|
|
372
|
+
if (hidden.alreadyLazy === true) {
|
|
373
|
+
// loading="lazy" is already there: the browser still loads it because it is inside its lazy-load distance, so adding it again changes nothing.
|
|
374
|
+
lines.push(` ${what} (${names(hidden.viewports)}): already lazy (loading="lazy") and still loaded, as it is within the browser's lazy-load distance of the viewport. Render it on activation (when the tab, details or hover opens), do not add lazy again.`);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
lines.push(` ${what} (${names(hidden.viewports)}): add loading="lazy", or load it on demand (when the tab, details or hover opens).`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
lines.push(` never shown on load at any of ${hidden.of} viewport(s): remove it, or load it on demand (when the menu, modal or hover opens).`);
|
|
382
|
+
}
|
|
383
|
+
return lines;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The loaded-but-not-shown lines of one video (see HiddenFix): its bytes per view, the case and the fix. The responsive case
|
|
387
|
+
* names the one element with <source media>; the lazy one preload none, no autoplay until shown; the never-shown one removal
|
|
388
|
+
* or an on-demand load. The bytes are not in the saving, as an image's are not.
|
|
389
|
+
*/
|
|
390
|
+
export function videoHiddenLines(hidden) {
|
|
391
|
+
const names = (list) => list.map((v) => JSON.stringify(safeText(v))).join(", ");
|
|
392
|
+
const reasons = [
|
|
393
|
+
...hidden.reasons,
|
|
394
|
+
...(hidden.inPanel ? ["in a tab panel, a hidden subtree or a closed details"] : []),
|
|
395
|
+
...(hidden.hover === true ? ["shown on hover"] : []),
|
|
396
|
+
].join(", ");
|
|
397
|
+
const lines = [
|
|
398
|
+
` loaded but not shown on load: ${kb(hidden.perViewBytes)}/view, hidden at ${hidden.viewports.length} of ${hidden.of} viewport(s) (${names(hidden.viewports)}): ${safeText(reasons)}; not in the savings`,
|
|
399
|
+
];
|
|
400
|
+
if (hidden.unstable === "unknown")
|
|
401
|
+
lines.push(" unstable: unknown (not verified: a name on the page did not resolve, or the candidate cap was reached): the reason is the first read's; check it in a browser");
|
|
402
|
+
if (hidden.action === "responsive") {
|
|
403
|
+
const media = twinMediaOf(hidden);
|
|
404
|
+
lines.push(` responsive twin, shown on load at ${names(hidden.shownIn)}: one <video> for both viewports, not two: <video autoplay muted playsinline poster="<first frame>"> with <source media="${media}" src="<the file shown at ${names(hidden.viewports)}>" type="video/mp4"> first, then <source src="<the file shown at ${names(hidden.shownIn)}>" type="video/mp4"> with no media. Or delete the hidden copy if the design does not need it.`);
|
|
405
|
+
lines.push(` <source media> on video: Chrome and Edge 120+ and Firefox 120+ restored support; Safari has always supported it. A browser that ignores it takes the first source, so the fallback is a script that sets src from matchMedia("${media}") and reloads on change.`);
|
|
406
|
+
}
|
|
407
|
+
else if (hidden.action === "lazy") {
|
|
408
|
+
const what = hidden.hover === true ? "a hover swap" : hidden.inPanel ? "a tab panel, a hidden subtree or a closed details" : "a carousel slide or other clipped box";
|
|
409
|
+
lines.push(` ${what} (${names(hidden.viewports)}): preload="none", no autoplay until it is shown (an IntersectionObserver, or the tab, details or hover opens), and the poster only.`);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
lines.push(` never shown on load at any of ${hidden.of} viewport(s): remove it, or load it on demand (when the modal, menu or hover opens).`);
|
|
413
|
+
}
|
|
414
|
+
return lines;
|
|
415
|
+
}
|
|
268
416
|
/** One hint as four short lines: where, sizes, quality and format, bytes and rung. */
|
|
269
417
|
export function renderHint(hint, rank) {
|
|
270
418
|
// Page-derived names and attributes are printed as JSON strings, so a newline or an instruction inside them stays data.
|
|
@@ -281,6 +429,10 @@ export function renderHint(hint, rank) {
|
|
|
281
429
|
].filter((a) => a !== undefined);
|
|
282
430
|
if (attributes.length > 0)
|
|
283
431
|
lines.push(` ${attributes.join(" ")}`);
|
|
432
|
+
// A vector check that wins leads the hint; otherwise it follows the raster fixes (see below).
|
|
433
|
+
const vector = hint.vector !== undefined ? vectorLines(hint.vector) : [];
|
|
434
|
+
if (hint.vector?.wins === true)
|
|
435
|
+
lines.push(...vector);
|
|
284
436
|
// The name is an identifier (COMPONENT_NAME), so it prints bare; the glob is built from the same name.
|
|
285
437
|
if (hint.kind === "owner") {
|
|
286
438
|
const name = hint.component[0];
|
|
@@ -290,9 +442,20 @@ export function renderHint(hint, rank) {
|
|
|
290
442
|
}
|
|
291
443
|
if (hint.find)
|
|
292
444
|
lines.push(` find: ${hint.find.component} in ${hint.find.glob}`);
|
|
445
|
+
if (hint.hidden !== undefined)
|
|
446
|
+
lines.push(...hiddenLines(hint.hidden));
|
|
447
|
+
// An image never shown has no sizes, quality or format change to print: its fix is the hidden block above.
|
|
448
|
+
if (hint.hidden !== undefined && hint.hidden.shownIn.length === 0) {
|
|
449
|
+
if (hint.file === undefined && hint.find === undefined && hint.kind !== "owner")
|
|
450
|
+
lines.push(" owner unknown");
|
|
451
|
+
lines.push(` rung ${hint.rung}`);
|
|
452
|
+
return lines;
|
|
453
|
+
}
|
|
293
454
|
if (hint.width !== undefined)
|
|
294
455
|
lines.push(` width ${hint.width}px (device pixels needed)`);
|
|
295
|
-
|
|
456
|
+
if (hint.srcset !== undefined)
|
|
457
|
+
lines.push(` ${srcsetLineOf(hint.srcset)}`);
|
|
458
|
+
else if (hint.width === undefined && (hint.animated === undefined || hint.sizes.proposed !== undefined)) {
|
|
296
459
|
lines.push(` sizes ${safeText(hint.sizes.current ?? "none")} -> ${safeText(hint.sizes.proposed ?? "-")} (${hint.sizes.basis ?? "-"})`);
|
|
297
460
|
}
|
|
298
461
|
if (hint.sizesAuto !== undefined)
|
|
@@ -319,6 +482,8 @@ export function renderHint(hint, rank) {
|
|
|
319
482
|
// An animated hint's saving is an estimate from the sample encodes, and its rung is that estimate, not the image measurement.
|
|
320
483
|
const animated = hint.animated !== undefined;
|
|
321
484
|
lines.push(` ${savesText(hint)}${animated ? " est." : ""}, +${kb(hint.bytes.addedForSharpness)} sharpness (separate) rung ${animated ? "estimate" : hint.rung}`);
|
|
485
|
+
if (hint.vector !== undefined && hint.vector.wins !== true)
|
|
486
|
+
lines.push(...vector);
|
|
322
487
|
return lines;
|
|
323
488
|
}
|
|
324
489
|
/** The group a hint belongs to: its file:line, else its nearest component, else its selector. */
|
|
@@ -429,6 +594,8 @@ export function groupSizesOf(group) {
|
|
|
429
594
|
* otherwise the pages' values: when they differ, the component is shared and each caller passes its own sizes.
|
|
430
595
|
*/
|
|
431
596
|
export function combinedSizes(group) {
|
|
597
|
+
if (groupSrcsetOf(group) !== undefined)
|
|
598
|
+
return "no sizes: the images have no srcset (see srcset)";
|
|
432
599
|
const fitted = groupSizesOf(group);
|
|
433
600
|
if (fitted?.mode === "one") {
|
|
434
601
|
return `${fitted.value} (one formula over ${group.hints.length} image(s), checked at ${fitted.views} viewport(s): never below a measured width)`;
|
|
@@ -453,7 +620,14 @@ export function groupHeadLine(group, rank) {
|
|
|
453
620
|
: JSON.stringify(first.component[0] ?? first.selector);
|
|
454
621
|
const name = first.file !== undefined && first.component[0] !== undefined ? ` (${JSON.stringify(first.component[0])})` : "";
|
|
455
622
|
const sizes = group.hints.every((h) => h.bytes.sizes !== undefined) ? group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) : undefined;
|
|
456
|
-
const
|
|
623
|
+
const resize = group.hints.every((h) => h.bytes.resize !== undefined) ? group.hints.reduce((sum, h) => sum + (h.bytes.resize ?? 0), 0) : undefined;
|
|
624
|
+
let split = sizes !== undefined ? ` = sizes ${kb(sizes)} + quality ${kb(group.saved - sizes)}` : "";
|
|
625
|
+
if (resize !== undefined) {
|
|
626
|
+
const sized = sizes ?? 0;
|
|
627
|
+
const quality = Math.max(0, group.saved - sized - resize);
|
|
628
|
+
const parts = [sized > 0 ? `sizes ${kb(sized)}` : undefined, `resize ${kb(resize)}`, quality > 0 ? `quality ${kb(quality)}` : undefined];
|
|
629
|
+
split = ` = ${parts.filter((p) => p !== undefined).join(" + ")}`;
|
|
630
|
+
}
|
|
457
631
|
const lcp = group.hints.find((h) => h.lcp === true);
|
|
458
632
|
const lcpPrefix = lcp !== undefined ? `LCP image (${(lcp.lcpViewports ?? []).map((v) => safeText(v)).join(", ")}): ` : "";
|
|
459
633
|
return `${rank}. ${lcpPrefix}${where}${name} ${group.hints.length} image(s), ${group.pages.length} page(s) saves ${kb(group.saved)}/view${split}`;
|
|
@@ -485,7 +659,8 @@ export function renderGroup(group, rank) {
|
|
|
485
659
|
}
|
|
486
660
|
}
|
|
487
661
|
const fitted = groupSizesOf(group);
|
|
488
|
-
|
|
662
|
+
const srcset = groupSrcsetOf(group);
|
|
663
|
+
lines.push(srcset !== undefined ? ` ${srcsetLineOf(srcset)}` : ` sizes: ${safeText(combinedSizes(group))}`);
|
|
489
664
|
for (const page of group.pages) {
|
|
490
665
|
const mine = group.hints.filter((h) => h.page === page);
|
|
491
666
|
const current = [...new Set(mine.map((h) => h.sizes.current ?? "none"))].map((v) => JSON.stringify(safeText(v))).join(", ");
|
|
@@ -495,7 +670,11 @@ export function renderGroup(group, rank) {
|
|
|
495
670
|
: [...new Set(mine.map((h) => h.sizes.proposed ?? "-"))].map((v) => JSON.stringify(safeText(v))).join(", ");
|
|
496
671
|
const widths = mine.flatMap((h) => (h.rendered ? [h.rendered.min, h.rendered.max] : []));
|
|
497
672
|
const rendered = widths.length > 0 ? `${Math.min(...widths)}-${Math.max(...widths)} px` : "not rendered";
|
|
498
|
-
|
|
673
|
+
// A srcset group has no sizes change to print: its page line keeps the images and the rendered widths only.
|
|
674
|
+
if (srcset !== undefined)
|
|
675
|
+
lines.push(` ${JSON.stringify(pathOf(page))} ${mine.length} image(s) rendered ${rendered}`);
|
|
676
|
+
else
|
|
677
|
+
lines.push(` ${JSON.stringify(pathOf(page))} ${mine.length} image(s) current ${current} proposed ${proposed} rendered ${rendered}`);
|
|
499
678
|
}
|
|
500
679
|
const targets = group.hints.flatMap((h) => (h.quality ? [h.quality] : []));
|
|
501
680
|
if (targets.length > 0) {
|
|
@@ -550,7 +729,8 @@ const FORMAT_LABEL = { gif: "GIF", webp: "WebP", apng: "APNG" };
|
|
|
550
729
|
* says so: performance before precision, but the number is never presented as a measured CPU encode.
|
|
551
730
|
*/
|
|
552
731
|
export function estLabel(c) {
|
|
553
|
-
|
|
732
|
+
const base = c.encoder === "nvenc" ? "est. (GPU-encoded, scaled to a CPU encode ÷1.4)" : "est.";
|
|
733
|
+
return c.timeLimited === true ? `${base}, search stopped at the time limit` : base;
|
|
554
734
|
}
|
|
555
735
|
/**
|
|
556
736
|
* The quality mark of a candidate: an NVENC candidate's cq is read as the CPU CRF (the same number, a fixed offset of 0), so
|
|
@@ -749,7 +929,63 @@ export function videoTotalsLine(videos) {
|
|
|
749
929
|
const sum = (values) => values.reduce((total, v) => total + v, 0);
|
|
750
930
|
const savings = files.map(videoSavingOf);
|
|
751
931
|
const est = files.some((v) => v.steps !== undefined && v.estimate !== undefined) ? " est." : "";
|
|
752
|
-
|
|
932
|
+
const hidden = hiddenVideoTotalOf(videos);
|
|
933
|
+
const loaded = hidden === undefined ? "" : `; loaded but not shown ${kb(hidden.bytes)}/view in ${hidden.count} video(s), not in the savings`;
|
|
934
|
+
return `video: ${kb(sum(files.map((v) => v.bytes.perView)))}/view, ${kb(sum(files.map((v) => v.bytes.perPlay)))}/play, saveable ${kb(sum(savings.map((s) => s.view)))}/view (${kb(sum(savings.map((s) => s.play)))}/play)${est}${loaded}`;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* The loaded-but-not-shown total of some videos: the records that have a hidden fix, their count and bytes per view. A file
|
|
938
|
+
* several hidden records share (the same src) counts its bytes once, at its largest record, as the images do (hiddenTotalsOf).
|
|
939
|
+
*/
|
|
940
|
+
function hiddenVideoTotalOf(videos) {
|
|
941
|
+
const hidden = videos.flatMap((v, index) => (v.hidden !== undefined ? [{ key: v.src || `#${index}`, fix: v.hidden }] : []));
|
|
942
|
+
if (hidden.length === 0)
|
|
943
|
+
return undefined;
|
|
944
|
+
const cases = { responsive: 0, lazy: 0, "never-shown-on-load": 0 };
|
|
945
|
+
const perFile = new Map();
|
|
946
|
+
for (const { key, fix } of hidden) {
|
|
947
|
+
cases[fix.action]++;
|
|
948
|
+
perFile.set(key, Math.max(perFile.get(key) ?? 0, fix.perViewBytes));
|
|
949
|
+
}
|
|
950
|
+
return { count: hidden.length, bytes: [...perFile.values()].reduce((sum, bytes) => sum + bytes, 0), cases };
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* The one-line summary of a page's loaded-but-not-shown videos, as the text, Markdown and check outputs print it. Undefined
|
|
954
|
+
* when no video has a hidden fix.
|
|
955
|
+
*/
|
|
956
|
+
export function hiddenVideoSummaryLine(videos) {
|
|
957
|
+
const total = hiddenVideoTotalOf(videos);
|
|
958
|
+
return total === undefined ? undefined : hiddenVideoLine(total);
|
|
959
|
+
}
|
|
960
|
+
/** The one-line text of a video total (the page's line, or the PR comment's over all pages). */
|
|
961
|
+
function hiddenVideoLine(total) {
|
|
962
|
+
const { responsive, lazy, "never-shown-on-load": never } = total.cases;
|
|
963
|
+
return `loaded but not shown: ${total.count} video(s), ${kb(total.bytes)}/view (responsive ${responsive}, lazy ${lazy}, never shown on load ${never}); not in the savings`;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The loaded-but-not-shown line of the PR comment for videos: all ok pages together. Each page counts its own files (a file
|
|
967
|
+
* shared between two pages is two files' bytes), as hiddenPagesLine does for images.
|
|
968
|
+
*/
|
|
969
|
+
export function hiddenVideoPagesLine(results) {
|
|
970
|
+
const totals = {
|
|
971
|
+
count: 0,
|
|
972
|
+
bytes: 0,
|
|
973
|
+
cases: { responsive: 0, lazy: 0, "never-shown-on-load": 0 },
|
|
974
|
+
};
|
|
975
|
+
for (const page of results) {
|
|
976
|
+
if (page.status !== "ok")
|
|
977
|
+
continue;
|
|
978
|
+
const total = hiddenVideoTotalOf(page.videos ?? []);
|
|
979
|
+
if (total === undefined)
|
|
980
|
+
continue;
|
|
981
|
+
totals.count += total.count;
|
|
982
|
+
totals.bytes += total.bytes;
|
|
983
|
+
for (const key of Object.keys(totals.cases))
|
|
984
|
+
totals.cases[key] += total.cases[key];
|
|
985
|
+
}
|
|
986
|
+
if (totals.count === 0)
|
|
987
|
+
return undefined;
|
|
988
|
+
return hiddenVideoLine(totals);
|
|
753
989
|
}
|
|
754
990
|
/** A video's encode in one phrase: the encode at its width with its bytes, or why it was not encoded. */
|
|
755
991
|
export function videoStatusOf(video) {
|
|
@@ -858,6 +1094,13 @@ function targetLines(video, plan) {
|
|
|
858
1094
|
export function videoLines(video, rank = 1) {
|
|
859
1095
|
const lines = [`${rank}. ${safeText(video.id)} ${JSON.stringify(safeText(videoWhere(video)))} ${safeText(video.page)}`];
|
|
860
1096
|
lines.push(` bytes: ${kb(video.bytes.perView)}/view (${viewBasisOf(video)}), ${kb(video.bytes.perPlay)}/play`);
|
|
1097
|
+
if (video.hidden !== undefined)
|
|
1098
|
+
lines.push(...videoHiddenLines(video.hidden));
|
|
1099
|
+
// A video no viewport shows has no claim: no encode, source, width, loading or codec line, only its hidden fix.
|
|
1100
|
+
if (video.hidden !== undefined && video.hidden.shownIn.length === 0) {
|
|
1101
|
+
lines.push(` not encoded: ${reasonText(NEVER_SHOWN)}`);
|
|
1102
|
+
return lines;
|
|
1103
|
+
}
|
|
861
1104
|
const est = video.estimate;
|
|
862
1105
|
if (est !== undefined && video.steps !== undefined) {
|
|
863
1106
|
const s = video.steps;
|
|
@@ -947,6 +1190,18 @@ function videosOf(results, args) {
|
|
|
947
1190
|
const page = args.page?.toLowerCase();
|
|
948
1191
|
return results.flatMap((r) => r.videos ?? []).filter((v) => page === undefined || v.page.toLowerCase().includes(page));
|
|
949
1192
|
}
|
|
1193
|
+
/** The resource findings of the selected pages, one row per finding with its page (the JSON's `resources`). */
|
|
1194
|
+
function resourceRowsOf(results) {
|
|
1195
|
+
return results.flatMap((r) => (r.resources ?? []).map((f) => ({ page: r.url, kind: f.kind, url: f.url, bytes: f.bytes, savedBytes: f.savedBytes, estimate: f.estimate, title: f.title, fix: f.fix })));
|
|
1196
|
+
}
|
|
1197
|
+
/** The text block of the resource findings, one heading per page that has any (none when no page has any). */
|
|
1198
|
+
function renderResourceHints(results) {
|
|
1199
|
+
const pages = results.filter((r) => (r.resources ?? []).length > 0);
|
|
1200
|
+
if (pages.length === 0)
|
|
1201
|
+
return "";
|
|
1202
|
+
const body = pages.flatMap((r) => [`${pathOf(r.url)} (resources)`, ...resourceLinesOf(r.resources ?? [], (text) => safeText(text))]);
|
|
1203
|
+
return `${body.join("\n")}\n`;
|
|
1204
|
+
}
|
|
950
1205
|
/** `imgtune hints <scan.json> [<scan.json> ...] ...`: reads the pages' scan files, returns the text or JSON to print. */
|
|
951
1206
|
export async function hintsCommand(argv, cwd) {
|
|
952
1207
|
const args = parseHintsArgs(argv);
|
|
@@ -978,12 +1233,13 @@ export async function hintsCommand(argv, cwd) {
|
|
|
978
1233
|
notes.push(`${dropped} sizes proposal(s) left out: no saving (their predicted outcome adds bytes or changes nothing)`);
|
|
979
1234
|
const filtered = selectHints(all, { ...args, limit: Number.MAX_SAFE_INTEGER, offset: 0 });
|
|
980
1235
|
const videos = videosOf(results, args);
|
|
1236
|
+
const resourceRows = resourceRowsOf(results);
|
|
981
1237
|
if (args.top !== undefined) {
|
|
982
1238
|
const top = groupHints(filtered).slice(0, args.top);
|
|
983
1239
|
if (args.format === "json") {
|
|
984
|
-
return { output: `${JSON.stringify({ top: top.map((g, i) => ({ rank: i + 1, key: g.key, saved: g.saved, images: g.hints.length, pages: g.pages.map((p) => pathOf(p)), file: g.hints[0].file ?? null, line: g.hints[0].line ?? null, component: g.hints[0].component, sizes: combinedSizes(g) })), qualities: planJson(plan) }, null, 2)}\n`, exitCode: 0 };
|
|
1240
|
+
return { output: `${JSON.stringify({ top: top.map((g, i) => ({ rank: i + 1, key: g.key, saved: g.saved, images: g.hints.length, pages: g.pages.map((p) => pathOf(p)), file: g.hints[0].file ?? null, line: g.hints[0].line ?? null, component: g.hints[0].component, sizes: combinedSizes(g), srcset: groupSrcsetOf(g) ?? null })), qualities: planJson(plan) }, null, 2)}\n`, exitCode: 0 };
|
|
985
1241
|
}
|
|
986
|
-
return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}`, exitCode: 0 };
|
|
1242
|
+
return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}${renderResourceHints(results)}`, exitCode: 0 };
|
|
987
1243
|
}
|
|
988
1244
|
// JSON is not cut to the text default: it is all of it unless --limit is passed, and `truncated` says whether it is.
|
|
989
1245
|
const jsonLimit = args.limitGiven ? args.limit : Number.MAX_SAFE_INTEGER;
|
|
@@ -1012,15 +1268,17 @@ export async function hintsCommand(argv, cwd) {
|
|
|
1012
1268
|
saved: group.saved,
|
|
1013
1269
|
sizes: combinedSizes(group),
|
|
1014
1270
|
sizesFitted: groupSizesOf(group) ?? null,
|
|
1271
|
+
srcset: groupSrcsetOf(group) ?? null,
|
|
1015
1272
|
callers: callerMapOf(group) ?? null,
|
|
1016
1273
|
ids: group.hints.map((h) => h.id),
|
|
1017
1274
|
hints: group.hints,
|
|
1018
1275
|
})),
|
|
1019
1276
|
...(videos.length > 0 ? { videos } : {}),
|
|
1277
|
+
...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
|
|
1020
1278
|
};
|
|
1021
1279
|
return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
|
|
1022
1280
|
}
|
|
1023
|
-
const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}`;
|
|
1281
|
+
const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
|
|
1024
1282
|
return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
|
|
1025
1283
|
}
|
|
1026
1284
|
const selected = selectHints(all, args.format === "json" ? { ...args, limit: jsonLimit } : args);
|
|
@@ -1036,10 +1294,11 @@ export async function hintsCommand(argv, cwd) {
|
|
|
1036
1294
|
qualities: planJson(plan),
|
|
1037
1295
|
hints: selected,
|
|
1038
1296
|
...(videos.length > 0 ? { videos } : {}),
|
|
1297
|
+
...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
|
|
1039
1298
|
};
|
|
1040
1299
|
return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
|
|
1041
1300
|
}
|
|
1042
|
-
const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}`;
|
|
1301
|
+
const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
|
|
1043
1302
|
return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
|
|
1044
1303
|
}
|
|
1045
1304
|
/** How many hints the PR comment lists. */
|
|
@@ -1056,6 +1315,21 @@ export function commentHints(results, limit = COMMENT_HINTS, maxQualities) {
|
|
|
1056
1315
|
const top = selectHints(all, { limit, offset: 0 });
|
|
1057
1316
|
return { total: all.length, lines: top.map((hint, i) => `${i + 1}. ${commentLine(hint)}`) };
|
|
1058
1317
|
}
|
|
1318
|
+
/** The loaded-but-not-shown line of the PR comment: all ok pages together (the same numbers as the text output). */
|
|
1319
|
+
export function hiddenPagesLine(results) {
|
|
1320
|
+
const pages = results.filter((r) => r.status === "ok" && r.hidden !== undefined);
|
|
1321
|
+
if (pages.length === 0)
|
|
1322
|
+
return undefined;
|
|
1323
|
+
const totals = { images: 0, bytesPerView: 0, cases: { responsive: 0, lazy: 0, "never-shown-on-load": 0 } };
|
|
1324
|
+
for (const page of pages) {
|
|
1325
|
+
const hidden = page.hidden;
|
|
1326
|
+
totals.images += hidden.images;
|
|
1327
|
+
totals.bytesPerView += hidden.bytesPerView;
|
|
1328
|
+
for (const key of Object.keys(totals.cases))
|
|
1329
|
+
totals.cases[key] += hidden.cases[key];
|
|
1330
|
+
}
|
|
1331
|
+
return hiddenSummaryLine(totals);
|
|
1332
|
+
}
|
|
1059
1333
|
/**
|
|
1060
1334
|
* A page-derived value in the comment: capped at COMMENT_FIELD characters, then JSON-quoted the way the CLI text prints it
|
|
1061
1335
|
* (so an instruction inside a name stays a quoted string). The block is fenced, and GitHub does not decode entities inside
|
|
@@ -1071,9 +1345,11 @@ function commentLine(hint) {
|
|
|
1071
1345
|
? `${quoted(`${hint.file}:${hint.line ?? 0}`)}${locationNote(hint)}`
|
|
1072
1346
|
: quoted(hint.component[0] ?? hint.selector);
|
|
1073
1347
|
const component = hint.file !== undefined && hint.component[0] !== undefined ? ` (${quoted(hint.component[0])})` : "";
|
|
1074
|
-
const sizes = hint.
|
|
1075
|
-
? `
|
|
1076
|
-
:
|
|
1348
|
+
const sizes = hint.srcset !== undefined
|
|
1349
|
+
? `serve variants at ${srcsetWidthLabel(hint.srcset.widths)}`
|
|
1350
|
+
: hint.width !== undefined
|
|
1351
|
+
? `width ${hint.width}px`
|
|
1352
|
+
: `sizes ${quoted(hint.sizes.current ?? "none")} -> ${quoted(hint.sizes.proposed ?? "-")}`;
|
|
1077
1353
|
const parts = [
|
|
1078
1354
|
hint.lcp === true ? `LCP image (${(hint.lcpViewports ?? []).map((v) => quoted(v)).join(", ")})` : undefined,
|
|
1079
1355
|
`${savesText(hint)}, +${kb(hint.bytes.addedForSharpness)} sharpness`,
|