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/hints.js
CHANGED
|
@@ -7,7 +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";
|
|
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";
|
|
11
17
|
export const DEFAULT_HINT_LIMIT = 10;
|
|
12
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]";
|
|
13
19
|
function nonNegativeInteger(name, text) {
|
|
@@ -98,7 +104,9 @@ export function parseHintsArgs(argv) {
|
|
|
98
104
|
* planQualityCap); it applies only when no --qualities list was given (that list is the framework's own).
|
|
99
105
|
*/
|
|
100
106
|
export function hintsOf(result, qualities, plan) {
|
|
101
|
-
|
|
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) => {
|
|
102
110
|
const served = record.current.format;
|
|
103
111
|
const rawTarget = served !== undefined ? record.fix.quality?.perFormat[served] : undefined;
|
|
104
112
|
// With the framework's allowed qualities, the target goes up to one of them. The quality saving was estimated at the
|
|
@@ -117,7 +125,7 @@ export function hintsOf(result, qualities, plan) {
|
|
|
117
125
|
sizes: { current: record.current.sizes, proposed: record.fix.sizes?.value, basis: record.fix.sizes?.basis },
|
|
118
126
|
// A raise (the list has no lower value for this usage) adds bytes: its quality saving is then none, not negative.
|
|
119
127
|
bytes: capped !== undefined
|
|
120
|
-
? { ...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) }
|
|
121
129
|
: { ...record.fix.bytes },
|
|
122
130
|
rung: record.evidence.rung,
|
|
123
131
|
notes: record.evidence.notes,
|
|
@@ -169,6 +177,8 @@ export function hintsOf(result, qualities, plan) {
|
|
|
169
177
|
hint.measured = measured;
|
|
170
178
|
if (record.fix.width !== undefined)
|
|
171
179
|
hint.width = record.fix.width;
|
|
180
|
+
if (record.fix.srcset !== undefined)
|
|
181
|
+
hint.srcset = { ...record.fix.srcset };
|
|
172
182
|
const cdn = record.fix.cdnQuality;
|
|
173
183
|
if (cdn !== undefined) {
|
|
174
184
|
hint.qualityNote = cdn.handled
|
|
@@ -202,15 +212,63 @@ export function hintsOf(result, qualities, plan) {
|
|
|
202
212
|
quality.snapFrom = snapFrom;
|
|
203
213
|
quality.void = true;
|
|
204
214
|
if (hint.bytes.sizes !== undefined)
|
|
205
|
-
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 };
|
|
206
216
|
}
|
|
207
217
|
hint.quality = quality;
|
|
208
218
|
}
|
|
209
219
|
if (record.fix.format !== undefined || served !== undefined)
|
|
210
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;
|
|
225
|
+
if (record.fix.animated !== undefined) {
|
|
226
|
+
hint.animated = record.fix.animated;
|
|
227
|
+
if (record.current.src !== undefined)
|
|
228
|
+
hint.src = record.current.src;
|
|
229
|
+
// The animated saving is the encode's alone (est.): the target is encoded at the needed width, so perViewBytes less the
|
|
230
|
+
// target's bytes already holds the width change. Adding the record's sizes part would count it twice. A real sizes
|
|
231
|
+
// proposal is still printed as information (renderHint), but it adds nothing to the saving.
|
|
232
|
+
const encoded = animatedSavingOf(record.fix.animated);
|
|
233
|
+
hint.bytes = { saved: encoded, addedForSharpness: record.fix.bytes.addedForSharpness, quality: encoded };
|
|
234
|
+
}
|
|
211
235
|
return hint;
|
|
212
236
|
});
|
|
213
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
|
+
}
|
|
214
272
|
/** The hints an argument set asks for: filtered, sorted by bytes saved (largest first), then offset and limited. */
|
|
215
273
|
export function selectHints(hints, args) {
|
|
216
274
|
const page = args.page?.toLowerCase();
|
|
@@ -227,6 +285,7 @@ export function selectHints(hints, args) {
|
|
|
227
285
|
.slice(args.offset, args.offset + args.limit);
|
|
228
286
|
}
|
|
229
287
|
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
|
|
288
|
+
const kb1 = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
230
289
|
/** The mark after a hint's file:line: an owner call site, or an unverified source. */
|
|
231
290
|
export function locationNote(hint) {
|
|
232
291
|
if (hint.kind === "owner")
|
|
@@ -242,17 +301,118 @@ export function qualityTarget(quality) {
|
|
|
242
301
|
const allowed = quality.allowed !== undefined ? ` (allowed: ${quality.allowed.join(",")})` : "";
|
|
243
302
|
return quality.snapFrom !== undefined ? ` q${quality.snapFrom} -> ${quality.target}${allowed}` : ` -> ${quality.target}${allowed}`;
|
|
244
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
|
+
}
|
|
245
322
|
/**
|
|
246
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 =
|
|
247
324
|
* sizes 12 KB + quality 3 KB". A quality part that a snap voided is printed as void, not as a number.
|
|
248
325
|
*/
|
|
249
326
|
export function savesText(hint) {
|
|
250
|
-
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
|
+
}
|
|
251
332
|
if (sizes === undefined)
|
|
252
333
|
return `saves ${kb(saved)}/view`;
|
|
253
334
|
const qualityPart = hint.quality?.void === true ? "quality void after snap" : `quality ${kb(quality ?? 0)}`;
|
|
254
335
|
return `saves ${kb(saved)}/view = sizes ${kb(sizes)} + ${qualityPart}`;
|
|
255
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
|
+
}
|
|
256
416
|
/** One hint as four short lines: where, sizes, quality and format, bytes and rung. */
|
|
257
417
|
export function renderHint(hint, rank) {
|
|
258
418
|
// Page-derived names and attributes are printed as JSON strings, so a newline or an instruction inside them stays data.
|
|
@@ -269,6 +429,10 @@ export function renderHint(hint, rank) {
|
|
|
269
429
|
].filter((a) => a !== undefined);
|
|
270
430
|
if (attributes.length > 0)
|
|
271
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);
|
|
272
436
|
// The name is an identifier (COMPONENT_NAME), so it prints bare; the glob is built from the same name.
|
|
273
437
|
if (hint.kind === "owner") {
|
|
274
438
|
const name = hint.component[0];
|
|
@@ -278,10 +442,22 @@ export function renderHint(hint, rank) {
|
|
|
278
442
|
}
|
|
279
443
|
if (hint.find)
|
|
280
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
|
+
}
|
|
281
454
|
if (hint.width !== undefined)
|
|
282
455
|
lines.push(` width ${hint.width}px (device pixels needed)`);
|
|
283
|
-
|
|
456
|
+
if (hint.srcset !== undefined)
|
|
457
|
+
lines.push(` ${srcsetLineOf(hint.srcset)}`);
|
|
458
|
+
else if (hint.width === undefined && (hint.animated === undefined || hint.sizes.proposed !== undefined)) {
|
|
284
459
|
lines.push(` sizes ${safeText(hint.sizes.current ?? "none")} -> ${safeText(hint.sizes.proposed ?? "-")} (${hint.sizes.basis ?? "-"})`);
|
|
460
|
+
}
|
|
285
461
|
if (hint.sizesAuto !== undefined)
|
|
286
462
|
lines.push(` alternative: sizes="${safeText(hint.sizesAuto)}" (Chrome, Edge, Firefox 150+; Safari falls back to ${safeText(hint.sizes.proposed ?? "-")})`);
|
|
287
463
|
if (hint.via === "prod-sourcemap") {
|
|
@@ -292,6 +468,8 @@ export function renderHint(hint, rank) {
|
|
|
292
468
|
}
|
|
293
469
|
else if (hint.file === undefined && hint.find === undefined && hint.kind !== "owner")
|
|
294
470
|
lines.push(" owner unknown");
|
|
471
|
+
if (hint.animated !== undefined)
|
|
472
|
+
lines.push(...animatedLines(hint.animated, hint.src, hint.alt));
|
|
295
473
|
const parts = [];
|
|
296
474
|
if (hint.quality)
|
|
297
475
|
parts.push(`quality ${hint.quality.current}${qualityTarget(hint.quality)} (max of ${hint.quality.samples}, ${safeText(hint.quality.group)})`);
|
|
@@ -301,7 +479,11 @@ export function renderHint(hint, rank) {
|
|
|
301
479
|
lines.push(` ${parts.join(" ")}`);
|
|
302
480
|
if (hint.qualityNote !== undefined)
|
|
303
481
|
lines.push(` ${safeText(hint.qualityNote)}`);
|
|
304
|
-
|
|
482
|
+
// An animated hint's saving is an estimate from the sample encodes, and its rung is that estimate, not the image measurement.
|
|
483
|
+
const animated = hint.animated !== undefined;
|
|
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);
|
|
305
487
|
return lines;
|
|
306
488
|
}
|
|
307
489
|
/** The group a hint belongs to: its file:line, else its nearest component, else its selector. */
|
|
@@ -412,6 +594,8 @@ export function groupSizesOf(group) {
|
|
|
412
594
|
* otherwise the pages' values: when they differ, the component is shared and each caller passes its own sizes.
|
|
413
595
|
*/
|
|
414
596
|
export function combinedSizes(group) {
|
|
597
|
+
if (groupSrcsetOf(group) !== undefined)
|
|
598
|
+
return "no sizes: the images have no srcset (see srcset)";
|
|
415
599
|
const fitted = groupSizesOf(group);
|
|
416
600
|
if (fitted?.mode === "one") {
|
|
417
601
|
return `${fitted.value} (one formula over ${group.hints.length} image(s), checked at ${fitted.views} viewport(s): never below a measured width)`;
|
|
@@ -436,7 +620,14 @@ export function groupHeadLine(group, rank) {
|
|
|
436
620
|
: JSON.stringify(first.component[0] ?? first.selector);
|
|
437
621
|
const name = first.file !== undefined && first.component[0] !== undefined ? ` (${JSON.stringify(first.component[0])})` : "";
|
|
438
622
|
const sizes = group.hints.every((h) => h.bytes.sizes !== undefined) ? group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) : undefined;
|
|
439
|
-
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
|
+
}
|
|
440
631
|
const lcp = group.hints.find((h) => h.lcp === true);
|
|
441
632
|
const lcpPrefix = lcp !== undefined ? `LCP image (${(lcp.lcpViewports ?? []).map((v) => safeText(v)).join(", ")}): ` : "";
|
|
442
633
|
return `${rank}. ${lcpPrefix}${where}${name} ${group.hints.length} image(s), ${group.pages.length} page(s) saves ${kb(group.saved)}/view${split}`;
|
|
@@ -468,7 +659,8 @@ export function renderGroup(group, rank) {
|
|
|
468
659
|
}
|
|
469
660
|
}
|
|
470
661
|
const fitted = groupSizesOf(group);
|
|
471
|
-
|
|
662
|
+
const srcset = groupSrcsetOf(group);
|
|
663
|
+
lines.push(srcset !== undefined ? ` ${srcsetLineOf(srcset)}` : ` sizes: ${safeText(combinedSizes(group))}`);
|
|
472
664
|
for (const page of group.pages) {
|
|
473
665
|
const mine = group.hints.filter((h) => h.page === page);
|
|
474
666
|
const current = [...new Set(mine.map((h) => h.sizes.current ?? "none"))].map((v) => JSON.stringify(safeText(v))).join(", ");
|
|
@@ -478,7 +670,11 @@ export function renderGroup(group, rank) {
|
|
|
478
670
|
: [...new Set(mine.map((h) => h.sizes.proposed ?? "-"))].map((v) => JSON.stringify(safeText(v))).join(", ");
|
|
479
671
|
const widths = mine.flatMap((h) => (h.rendered ? [h.rendered.min, h.rendered.max] : []));
|
|
480
672
|
const rendered = widths.length > 0 ? `${Math.min(...widths)}-${Math.max(...widths)} px` : "not rendered";
|
|
481
|
-
|
|
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}`);
|
|
482
678
|
}
|
|
483
679
|
const targets = group.hints.flatMap((h) => (h.quality ? [h.quality] : []));
|
|
484
680
|
if (targets.length > 0) {
|
|
@@ -493,6 +689,11 @@ export function renderGroup(group, rank) {
|
|
|
493
689
|
const ids = group.hints.map((h) => h.id);
|
|
494
690
|
const shown = ids.slice(0, 8).join(" ");
|
|
495
691
|
lines.push(` ids ${shown}${ids.length > 8 ? ` (+${ids.length - 8} more; --flat lists each image)` : ""}`);
|
|
692
|
+
// An animated image's hint is its own (its target and bytes are not a sizes or quality change), so it is printed here.
|
|
693
|
+
for (const hint of group.hints) {
|
|
694
|
+
if (hint.animated !== undefined)
|
|
695
|
+
lines.push(` ${safeText(hint.id)}`, ...animatedLines(hint.animated, hint.src));
|
|
696
|
+
}
|
|
496
697
|
return lines;
|
|
497
698
|
}
|
|
498
699
|
/** The text of a grouped selection: a count line, then each group. */
|
|
@@ -511,6 +712,450 @@ export function renderHints(hints, total, offset, page) {
|
|
|
511
712
|
const body = hints.flatMap((hint, i) => renderHint(hint, offset + i + 1));
|
|
512
713
|
return `${head}${body.join("\n")}\n`;
|
|
513
714
|
}
|
|
715
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
716
|
+
// Animated images and `<video>` (Task 8): the text of their hints and of the Video section of a page. Every estimated byte
|
|
717
|
+
// figure is marked "est."; a hint names the sample encodes once. Page-derived names and attributes are JSON strings.
|
|
718
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
719
|
+
/** The note that marks a figure as an estimate from the 2-second sample encodes. A hint says it once. */
|
|
720
|
+
export const SAMPLE_NOTE = "(estimate from 2 s samples)";
|
|
721
|
+
/** The `type` of the two sources a video hint names: AV1 first, H.264 as the fallback. */
|
|
722
|
+
const AV1_TYPE = 'video/mp4; codecs=av01.0.08M.08';
|
|
723
|
+
const H264_TYPE = 'video/mp4; codecs=avc1.640028';
|
|
724
|
+
/** The H.264 fallback recipe an animated GIF's video hint gives. It is not encoded by imgtune (performance first). */
|
|
725
|
+
const H264_RECIPE = "libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart -an";
|
|
726
|
+
const FORMAT_LABEL = { gif: "GIF", webp: "WebP", apng: "APNG" };
|
|
727
|
+
/**
|
|
728
|
+
* The label of a candidate's bytes. An NVENC candidate's bytes are its GPU encode scaled to a CPU encode (÷1.4), so the label
|
|
729
|
+
* says so: performance before precision, but the number is never presented as a measured CPU encode.
|
|
730
|
+
*/
|
|
731
|
+
export function estLabel(c) {
|
|
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;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
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
|
|
737
|
+
* the recipe's CRF is approximate. Empty for a CPU encode.
|
|
738
|
+
*/
|
|
739
|
+
export function qualityApprox(c) {
|
|
740
|
+
return c.encoder === "nvenc" ? " (approx.)" : "";
|
|
741
|
+
}
|
|
742
|
+
/** The smallest candidate of one codec, or undefined. The first of equal bytes wins. */
|
|
743
|
+
export function smallestCandidate(candidates, codec) {
|
|
744
|
+
let best;
|
|
745
|
+
for (const c of candidates ?? []) {
|
|
746
|
+
if (c.codec === codec && (best === undefined || c.bytes < best.bytes))
|
|
747
|
+
best = c;
|
|
748
|
+
}
|
|
749
|
+
return best;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* The quality evidence of an encode: VMAF with its bar, and the still-frame guard with its bar. A bar the candidate does not
|
|
753
|
+
* carry is said so, not guessed: the run's flag is not in the scan file.
|
|
754
|
+
*/
|
|
755
|
+
export function evidenceOf(c) {
|
|
756
|
+
const bar = (value) => (value === undefined ? "bar not recorded" : `bar ${value}`);
|
|
757
|
+
return `VMAF ${c.vmaf.toFixed(1)} (${bar(c.vmafBar)}), still ${c.still.toFixed(1)} (${bar(c.stillBar)})`;
|
|
758
|
+
}
|
|
759
|
+
/** The codec as a hint names it: "av1", "h264", or the codec's own name. */
|
|
760
|
+
function codecName(codec) {
|
|
761
|
+
return codec === "av1-mp4" ? "av1" : codec === "h264-mp4" ? "h264" : codec;
|
|
762
|
+
}
|
|
763
|
+
/** The targets of an animated image's encode (see AnimatedTargets). The main one's bytes are the saving's basis. */
|
|
764
|
+
export function animatedTargetsOf(animated) {
|
|
765
|
+
const avif = smallestCandidate(animated.estimate, "animated-avif");
|
|
766
|
+
const webp = smallestCandidate(animated.estimate, "animated-webp");
|
|
767
|
+
const av1 = smallestCandidate(animated.estimate, "av1-mp4");
|
|
768
|
+
const h264 = smallestCandidate(animated.estimate, "h264-mp4");
|
|
769
|
+
const video = av1 !== undefined && (animated.target?.main === "video" || (avif === undefined && webp === undefined));
|
|
770
|
+
return { avif, webp, av1, h264, video, main: video ? av1 : (avif ?? webp) };
|
|
771
|
+
}
|
|
772
|
+
/** The bytes per view an animated image saves: its bytes per view less the main target's encode, floored at 0 (est.). */
|
|
773
|
+
export function animatedSavingOf(animated) {
|
|
774
|
+
const main = animatedTargetsOf(animated).main;
|
|
775
|
+
return main === undefined ? 0 : Math.max(0, animated.perViewBytes - main.bytes);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* The fallback of an image-target hint, built only from the candidates that exist: the `<picture>` with an AVIF source (for
|
|
779
|
+
* AVIF, with or without WebP), a plain `src` swap for WebP alone (WebP animates everywhere it shows an image), and for a CSS
|
|
780
|
+
* background an `image-set()` with the existing types.
|
|
781
|
+
*/
|
|
782
|
+
function imageFallbackOf(context, targets, src) {
|
|
783
|
+
if (context === "css-background") {
|
|
784
|
+
const types = [
|
|
785
|
+
targets.avif !== undefined ? 'url(<avif>) type("image/avif")' : undefined,
|
|
786
|
+
targets.webp !== undefined ? 'url(<webp>) type("image/webp")' : undefined,
|
|
787
|
+
].filter((t) => t !== undefined);
|
|
788
|
+
return `fallback: image-set(${types.join(", ")})`;
|
|
789
|
+
}
|
|
790
|
+
if (targets.avif !== undefined) {
|
|
791
|
+
return `fallback: <picture><source type="image/avif" srcset="…"><img src=${JSON.stringify(src ?? "")}></picture>`;
|
|
792
|
+
}
|
|
793
|
+
return "plain src swap to the animated WebP (WebP animates in every browser that shows the image)";
|
|
794
|
+
}
|
|
795
|
+
/** Browser coverage of each option, as the hint states it (the caniuse data is not in the repo: these are the stated facts). */
|
|
796
|
+
const COVERAGE = {
|
|
797
|
+
webp: "animated WebP: all current browsers",
|
|
798
|
+
avif: "animated AVIF: Chrome 85+, Firefox 113+, Safari 16.4+",
|
|
799
|
+
av1: "AV1 video: Chrome, Firefox, Edge; Safari only on devices with AV1 hardware decode, so keep the H.264 fallback",
|
|
800
|
+
};
|
|
801
|
+
/**
|
|
802
|
+
* The exact encoder settings of a candidate, as an agent runs them: the encoder, its preset and CRF (or q), and the pixel
|
|
803
|
+
* format and container options that follow. An AV1 CRF is a CRF, never a q (see the quality label in animatedLines).
|
|
804
|
+
*/
|
|
805
|
+
export function encoderRecipe(codec, c) {
|
|
806
|
+
const av1 = c.av1Encoder === "libaom-av1" ? `libaom-av1 -cpu-used 8 -row-mt 1 -crf ${c.quality} -b:v 0` : `libsvtav1 -preset 10 -crf ${c.quality}`;
|
|
807
|
+
switch (codec) {
|
|
808
|
+
case "av1-mp4":
|
|
809
|
+
return `${av1} -pix_fmt yuv420p -movflags +faststart`;
|
|
810
|
+
case "animated-avif":
|
|
811
|
+
return `${av1}, -f avif`;
|
|
812
|
+
case "animated-webp":
|
|
813
|
+
return `libwebp_anim -q:v ${c.quality} -loop 0`;
|
|
814
|
+
case "h264-mp4":
|
|
815
|
+
return `libx264 -preset veryfast -crf ${c.quality} -pix_fmt yuv420p -movflags +faststart -an`;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* The line that says a target is capped at the file's own width: the device width needs more than the file has, so the
|
|
820
|
+
* target is the file's width and a wider one would add bytes, not detail. Undefined when the target is not capped.
|
|
821
|
+
*/
|
|
822
|
+
export function sourceCapLine(need, sourceWidth, served) {
|
|
823
|
+
if (need === undefined || sourceWidth === undefined || !(need > sourceWidth) || served !== sourceWidth)
|
|
824
|
+
return undefined;
|
|
825
|
+
return ` source is ${sourceWidth}px wide; needed ${need}px: serve ${sourceWidth}px (upscaling adds bytes, not detail)`;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* The lines of an animated image's hint. An image target (AVIF and/or WebP, with a fallback built from the candidates that
|
|
829
|
+
* exist) when the encode offers one; the `<video>` replacement with its AV1 source and the H.264 recipe when the target is
|
|
830
|
+
* video. With no encode, the bytes per view and the reason. `src` is the served file's URL, printed as a JSON string; `alt`
|
|
831
|
+
* is the image's alt text, which the `<video>` keeps as its accessible name.
|
|
832
|
+
*/
|
|
833
|
+
export function animatedLines(animated, src, alt) {
|
|
834
|
+
const { info, perViewBytes } = animated;
|
|
835
|
+
const head = `animated ${FORMAT_LABEL[info.format]} ${(info.durationMs / 1000).toFixed(1)} s, ${info.frames} frames`;
|
|
836
|
+
const targets = animatedTargetsOf(animated);
|
|
837
|
+
const { avif, webp, av1, h264, main } = targets;
|
|
838
|
+
if (main === undefined) {
|
|
839
|
+
return [` ${head}: ${kb(perViewBytes)}/view; not encoded: ${safeText(reasonText(animated.notEncoded ?? "no passing quality"))}`];
|
|
840
|
+
}
|
|
841
|
+
const saves = animatedSavingOf(animated);
|
|
842
|
+
const offered = [
|
|
843
|
+
avif !== undefined ? `animated AVIF CRF ${avif.quality}${qualityApprox(avif)} at ${avif.width}px (${encoderRecipe("animated-avif", avif)}): ${kb(avif.bytes)} ${estLabel(avif)}` : undefined,
|
|
844
|
+
webp !== undefined ? `animated WebP q${webp.quality} at ${webp.width}px (${encoderRecipe("animated-webp", webp)}): ${kb(webp.bytes)} ${estLabel(webp)}` : undefined,
|
|
845
|
+
].filter((part) => part !== undefined);
|
|
846
|
+
const coverage = [];
|
|
847
|
+
if (avif !== undefined)
|
|
848
|
+
coverage.push(COVERAGE.avif);
|
|
849
|
+
if (webp !== undefined)
|
|
850
|
+
coverage.push(COVERAGE.webp);
|
|
851
|
+
const cap = sourceCapLine(animated.need, animated.sourceWidth, main.width);
|
|
852
|
+
if (targets.video && av1 !== undefined) {
|
|
853
|
+
// An empty alt is a decorative image: the <video> is hidden from assistive technology, with no label. Otherwise the alt is
|
|
854
|
+
// kept as the accessible name (role="img", aria-label).
|
|
855
|
+
const decorative = alt !== undefined && alt.trim() === "";
|
|
856
|
+
const altPart = decorative
|
|
857
|
+
? ' aria-hidden="true"'
|
|
858
|
+
: alt !== undefined
|
|
859
|
+
? ` role="img" aria-label=${JSON.stringify(safeText(alt))}`
|
|
860
|
+
: "";
|
|
861
|
+
const lines = [` ${head}: replace with <video autoplay muted loop playsinline poster="<first frame>"${altPart}>`];
|
|
862
|
+
if (decorative)
|
|
863
|
+
lines.push(' the image is decorative (alt=""): the <video> is aria-hidden, with no label');
|
|
864
|
+
else if (alt !== undefined)
|
|
865
|
+
lines.push(" the image's alt text is kept as the <video>'s aria-label (role=\"img\"): the accessible name does not change");
|
|
866
|
+
lines.push(` <source src="…-${av1.width}.av1.mp4" type="${AV1_TYPE}"> AV1 MP4 (${encoderRecipe("av1-mp4", av1)}), CRF ${av1.quality}${qualityApprox(av1)}, ${av1.width}px, ${kb(av1.bytes)} ${estLabel(av1)}`);
|
|
867
|
+
lines.push(h264 !== undefined
|
|
868
|
+
? ` <source src="…-${h264.width}.h264.mp4" type="${H264_TYPE}"> H.264 MP4 (${encoderRecipe("h264-mp4", h264)}), ${h264.width}px, ${kb(h264.bytes)} ${estLabel(h264)}`
|
|
869
|
+
: ` <source src="…-${av1.width}.h264.mp4" type="${H264_TYPE}"> H.264 fallback, not measured: ${H264_RECIPE} at ${av1.width}px wide`);
|
|
870
|
+
if (cap !== undefined)
|
|
871
|
+
lines.push(cap);
|
|
872
|
+
lines.push(` evidence: ${evidenceOf(av1)}`);
|
|
873
|
+
lines.push(` browsers: ${COVERAGE.av1}`);
|
|
874
|
+
if (offered.length > 0)
|
|
875
|
+
lines.push(` alternative: ${offered.join(", ")}`);
|
|
876
|
+
if (offered.length > 0)
|
|
877
|
+
lines.push(...coverage.map((line) => ` browsers: ${line}`));
|
|
878
|
+
lines.push(` saves ${kb(saves)}/view est. ${SAMPLE_NOTE}`);
|
|
879
|
+
return lines;
|
|
880
|
+
}
|
|
881
|
+
const fallback = imageFallbackOf(animated.context, targets, src);
|
|
882
|
+
const lines = [` ${head}: serve ${offered.join(", ")}; ${fallback}; saves ${kb(saves)}/view est. ${SAMPLE_NOTE}`];
|
|
883
|
+
if (cap !== undefined)
|
|
884
|
+
lines.push(cap);
|
|
885
|
+
lines.push(` evidence: ${evidenceOf(main)}`);
|
|
886
|
+
lines.push(...coverage.map((line) => ` browsers: ${line}`));
|
|
887
|
+
return lines;
|
|
888
|
+
}
|
|
889
|
+
/** The name a video records by: where it is in the page, a file or component, else its selector. */
|
|
890
|
+
export function videoWhere(video) {
|
|
891
|
+
if (video.locator.file !== undefined)
|
|
892
|
+
return `${video.locator.file}:${video.locator.line ?? 0}`;
|
|
893
|
+
return video.locator.component[0] ?? video.locator.selector;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* The bytes a view and a play save, est. The lever steps are per play (the full file less the encode); the view's share is
|
|
897
|
+
* the same fraction of the bytes the page loads per view.
|
|
898
|
+
*/
|
|
899
|
+
export function videoSavingOf(video) {
|
|
900
|
+
if (video.steps === undefined || video.estimate === undefined)
|
|
901
|
+
return { view: 0, play: 0 };
|
|
902
|
+
const { audio, resolution, codec, quality } = video.steps;
|
|
903
|
+
const play = audio + resolution + codec + quality;
|
|
904
|
+
const { perView, perPlay } = video.bytes;
|
|
905
|
+
return { view: perPlay > 0 ? (play * perView) / perPlay : 0, play };
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* One record per file of each page: a URL that two `<video>` elements of a page use is one file, fetched once per page view.
|
|
909
|
+
* Of the records sharing a URL, the one with the most bytes per view stands for it (the page's own elements keep their rows).
|
|
910
|
+
*/
|
|
911
|
+
export function onePerFile(videos) {
|
|
912
|
+
const byFile = new Map();
|
|
913
|
+
for (const video of videos) {
|
|
914
|
+
const key = `${video.page}\n${video.src}`;
|
|
915
|
+
const current = byFile.get(key);
|
|
916
|
+
if (current === undefined || video.bytes.perView > current.bytes.perView)
|
|
917
|
+
byFile.set(key, video);
|
|
918
|
+
}
|
|
919
|
+
return [...byFile.values()];
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* The Video section's totals: bytes per view and per play over the files (each URL once per page, see onePerFile), and the
|
|
923
|
+
* saveable bytes (an estimate, marked when any video has steps). Undefined with no video.
|
|
924
|
+
*/
|
|
925
|
+
export function videoTotalsLine(videos) {
|
|
926
|
+
if (videos.length === 0)
|
|
927
|
+
return undefined;
|
|
928
|
+
const files = onePerFile(videos);
|
|
929
|
+
const sum = (values) => values.reduce((total, v) => total + v, 0);
|
|
930
|
+
const savings = files.map(videoSavingOf);
|
|
931
|
+
const est = files.some((v) => v.steps !== undefined && v.estimate !== undefined) ? " est." : "";
|
|
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);
|
|
989
|
+
}
|
|
990
|
+
/** A video's encode in one phrase: the encode at its width with its bytes, or why it was not encoded. */
|
|
991
|
+
export function videoStatusOf(video) {
|
|
992
|
+
const e = video.estimate;
|
|
993
|
+
if (e === undefined)
|
|
994
|
+
return `not encoded: ${safeText(reasonText(video.notEncoded ?? "no passing quality"))}`;
|
|
995
|
+
return `${codecName(e.codec)} CRF ${e.quality}${qualityApprox(e)}, ${e.width}px, ${kb(e.bytes)} ${estLabel(e)}`;
|
|
996
|
+
}
|
|
997
|
+
/** One video as a row of a page's Video section: its place (quoted by the caller), its bytes, and its encode or reason. */
|
|
998
|
+
export function videoRowOf(video, quote) {
|
|
999
|
+
return `${quote(videoWhere(video))}: ${kb(video.bytes.perView)}/view, ${kb(video.bytes.perPlay)}/play; ${videoStatusOf(video)}`;
|
|
1000
|
+
}
|
|
1001
|
+
/** What the bytes per view are, as a hint says it: the autoplay full file, the bytes seen in the scan, or none. */
|
|
1002
|
+
function viewBasisOf(video) {
|
|
1003
|
+
const b = video.bytes;
|
|
1004
|
+
if (b.perViewBasis === "full-file")
|
|
1005
|
+
return "autoplay: full file";
|
|
1006
|
+
if (b.perViewBasis === "none")
|
|
1007
|
+
return "not loaded before play";
|
|
1008
|
+
return b.browserDependent ? "preload=auto: browser-dependent, bytes seen in the scan" : "bytes seen in the scan";
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* The bytes per view of a width plan (audit), est. (scaled): each viewport's bytes at its width are the encode's bytes times
|
|
1012
|
+
* (w / w_encoded)², the pixel ratio, and the per-view share of the file. They are summed over the viewports and divided by
|
|
1013
|
+
* their count, the same average the image savings use. Without an encode, "not encoded" and the reason.
|
|
1014
|
+
*/
|
|
1015
|
+
function bytesAtPlan(video, plan) {
|
|
1016
|
+
const est = video.estimate;
|
|
1017
|
+
if (est === undefined || video.bytes.perPlay <= 0) {
|
|
1018
|
+
return `not encoded${video.notEncoded !== undefined ? `: ${safeText(reasonText(video.notEncoded))}` : ""}`;
|
|
1019
|
+
}
|
|
1020
|
+
if (plan.perViewport.length === 0)
|
|
1021
|
+
return "-";
|
|
1022
|
+
const ratio = video.bytes.perView / video.bytes.perPlay;
|
|
1023
|
+
const total = plan.perViewport.reduce((sum, v) => sum + est.bytes * (v.width / est.width) ** 2 * ratio, 0);
|
|
1024
|
+
return `${kb(total / plan.perViewport.length)}/view est. (scaled)`;
|
|
1025
|
+
}
|
|
1026
|
+
/** Audit: each viewport's need, and the bytes per view at one, two and three widths. No pick: the agent chooses. */
|
|
1027
|
+
function widthOptionLines(options) {
|
|
1028
|
+
const lines = [];
|
|
1029
|
+
const first = options[0];
|
|
1030
|
+
if (first !== undefined && first.perViewport.length > 0) {
|
|
1031
|
+
lines.push(` viewports: ${first.perViewport.map((v) => `${JSON.stringify(safeText(v.viewport))} needs ${v.need}px`).join(", ")}`);
|
|
1032
|
+
}
|
|
1033
|
+
lines.push(" widths → bytes/view (audit: no pick)");
|
|
1034
|
+
return lines;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* The line that says a video's served width is capped at its file's own: a viewport needs more than the file has, and the
|
|
1038
|
+
* plan serves the file's width. Undefined when no viewport is capped (or the file's width is not known).
|
|
1039
|
+
*/
|
|
1040
|
+
export function videoSourceCapLine(video) {
|
|
1041
|
+
const source = video.probe?.width;
|
|
1042
|
+
const plan = video.widths;
|
|
1043
|
+
if (plan === undefined || source === undefined || !(source > 0))
|
|
1044
|
+
return undefined;
|
|
1045
|
+
// The plan's needs are already capped at the file's width, so the need is taken from the boxes: what the page shows.
|
|
1046
|
+
const need = Math.max(0, ...video.perViewport.filter((v) => v.boxWidth > 0).map((v) => Math.ceil(v.boxWidth * v.dpr)));
|
|
1047
|
+
if (!(need > source) || !plan.perViewport.some((row) => row.width === source))
|
|
1048
|
+
return undefined;
|
|
1049
|
+
return sourceCapLine(need, source, source);
|
|
1050
|
+
}
|
|
1051
|
+
/** Scan: the `<source>` list of the planned widths, AV1 then H.264 for each, with the encode's CRF and bytes at its width. */
|
|
1052
|
+
function targetLines(video, plan) {
|
|
1053
|
+
if (plan.containerDriven) {
|
|
1054
|
+
return plan.perViewport.map((row) => {
|
|
1055
|
+
const box = video.perViewport.find((v) => v.viewport === row.viewport);
|
|
1056
|
+
const boxText = box !== undefined ? `box ${box.boxWidth} px at ${box.dpr}x` : "box not read";
|
|
1057
|
+
return ` viewport ${JSON.stringify(safeText(row.viewport))}: ${boxText}, serve ${row.width}px`;
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const est = video.estimate;
|
|
1061
|
+
const lines = [];
|
|
1062
|
+
for (const source of plan.sources) {
|
|
1063
|
+
const media = source.media !== undefined ? `media="${source.media}" ` : "";
|
|
1064
|
+
for (const kind of ["av1", "h264"]) {
|
|
1065
|
+
const codec = kind === "av1" ? "av1-mp4" : "h264-mp4";
|
|
1066
|
+
const label = kind === "av1" ? "AV1" : "H.264";
|
|
1067
|
+
// Every source names the exact encoder settings an agent runs: the estimate's codec, preset and CRF. At the estimate's
|
|
1068
|
+
// own width that is measured; at another width the same settings apply, which is not measured there.
|
|
1069
|
+
const same = est !== undefined && est.codec === codec ? est : undefined;
|
|
1070
|
+
let at;
|
|
1071
|
+
if (same !== undefined && same.width === source.width) {
|
|
1072
|
+
at = ` ${label} MP4 (${encoderRecipe(codec, same)}), CRF ${same.quality}${qualityApprox(same)}, ${same.width}px, ${kb(same.bytes)} ${estLabel(same)}`;
|
|
1073
|
+
}
|
|
1074
|
+
else if (same !== undefined) {
|
|
1075
|
+
at = ` ${label} MP4 (${encoderRecipe(codec, same)}), CRF ${same.quality}${qualityApprox(same)}, ${source.width}px: same settings, not measured at this width`;
|
|
1076
|
+
}
|
|
1077
|
+
else if (kind === "h264") {
|
|
1078
|
+
at = ` H.264 MP4 (${H264_RECIPE}), ${source.width}px: not measured (no H.264 encode for this video)`;
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
at = ` AV1 MP4 (libsvtav1 -preset 10 -pix_fmt yuv420p -movflags +faststart), CRF not measured: no AV1 encode passed for this video`;
|
|
1082
|
+
}
|
|
1083
|
+
lines.push(` <source ${media}src="…-${source.width}.${kind}.mp4" type="${kind === "av1" ? AV1_TYPE : H264_TYPE}">${at}`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return lines;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* The lines of one video's hint: where and the bytes; the levers of the encode (audio, resolution, re-encode, one line each
|
|
1090
|
+
* with its bytes per play; never a quality line, as the encode's quality is the re-encode); the loading, codec and poster
|
|
1091
|
+
* levers; the target sources (or the widths table in audit); the evidence; and the saving. Without steps, the target and its
|
|
1092
|
+
* bytes only. Without an encode, the reason.
|
|
1093
|
+
*/
|
|
1094
|
+
export function videoLines(video, rank = 1) {
|
|
1095
|
+
const lines = [`${rank}. ${safeText(video.id)} ${JSON.stringify(safeText(videoWhere(video)))} ${safeText(video.page)}`];
|
|
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
|
+
}
|
|
1104
|
+
const est = video.estimate;
|
|
1105
|
+
if (est !== undefined && video.steps !== undefined) {
|
|
1106
|
+
const s = video.steps;
|
|
1107
|
+
lines.push(` audio: drop the track (muted video): saves ${kb(s.audio)}/play est.`);
|
|
1108
|
+
lines.push(` resolution: serve ${est.width}px: saves ${kb(s.resolution)}/play est.`);
|
|
1109
|
+
lines.push(` re-encode (${codecName(est.codec)} CRF ${est.quality}): saves ${kb(s.codec)}/play est.`);
|
|
1110
|
+
}
|
|
1111
|
+
if (video.levers.loading !== undefined) {
|
|
1112
|
+
const loading = video.levers.loading;
|
|
1113
|
+
const attr = video.attributes.preload === "" ? "no preload attribute" : `preload=${JSON.stringify(safeText(video.attributes.preload))}`;
|
|
1114
|
+
const action = video.attributes.autoplay
|
|
1115
|
+
? "autoplay fetches the file on load: lazy-load the video until it is in view"
|
|
1116
|
+
: loading.belowFold
|
|
1117
|
+
? 'below the fold: set preload="none"'
|
|
1118
|
+
: 'set preload="metadata"';
|
|
1119
|
+
lines.push(` loading: ${attr} loads ${kb(loading.perViewBytes)}/view before play: ${action}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (video.levers.codec !== undefined)
|
|
1122
|
+
lines.push(" codec: no AV1 source: serve AV1 first, H.264 fallback");
|
|
1123
|
+
if (video.levers.posterMissing === true)
|
|
1124
|
+
lines.push(' poster: none: add poster="<first frame>" (audited as an image)');
|
|
1125
|
+
for (const note of video.levers.info)
|
|
1126
|
+
lines.push(` info: ${safeText(note)}`);
|
|
1127
|
+
if (video.widthOptions !== undefined) {
|
|
1128
|
+
lines.push(...widthOptionLines(video.widthOptions));
|
|
1129
|
+
for (const plan of video.widthOptions) {
|
|
1130
|
+
lines.push(` ${plan.widths.length} width(s): ${plan.widths.map((w) => `${w}px`).join(", ")} → ${bytesAtPlan(video, plan)}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
else if (video.widths !== undefined) {
|
|
1134
|
+
lines.push(...targetLines(video, video.widths));
|
|
1135
|
+
const cap = videoSourceCapLine(video);
|
|
1136
|
+
if (cap !== undefined)
|
|
1137
|
+
lines.push(cap);
|
|
1138
|
+
}
|
|
1139
|
+
if (est !== undefined)
|
|
1140
|
+
lines.push(` evidence: ${evidenceOf(est)}`);
|
|
1141
|
+
else if (video.notEncoded !== undefined)
|
|
1142
|
+
lines.push(` not encoded: ${safeText(reasonText(video.notEncoded))}`);
|
|
1143
|
+
if (est !== undefined && video.steps !== undefined) {
|
|
1144
|
+
const s = videoSavingOf(video);
|
|
1145
|
+
lines.push(` saves ${kb(s.view)}/view est., ${kb(s.play)}/play est. ${SAMPLE_NOTE}`);
|
|
1146
|
+
}
|
|
1147
|
+
else if (est !== undefined) {
|
|
1148
|
+
lines.push(` ${SAMPLE_NOTE}`);
|
|
1149
|
+
}
|
|
1150
|
+
return lines;
|
|
1151
|
+
}
|
|
1152
|
+
/** The hints command's Video part: each video's hint, numbered. Empty with no video. */
|
|
1153
|
+
export function renderVideoHints(videos) {
|
|
1154
|
+
if (videos.length === 0)
|
|
1155
|
+
return "";
|
|
1156
|
+
const body = videos.flatMap((video, i) => videoLines(video, i + 1));
|
|
1157
|
+
return `${videos.length} video(s) on the page(s), self-hosted files:\n${body.join("\n")}\n`;
|
|
1158
|
+
}
|
|
514
1159
|
/**
|
|
515
1160
|
* The line that says where the sources of a file came from: the dev page it was located on, or, when hinted images still
|
|
516
1161
|
* have no file:line, the command that locates them. Undefined when every hint has its source.
|
|
@@ -538,6 +1183,25 @@ function planJson(plan) {
|
|
|
538
1183
|
estTotalBytes: Math.round(plan.total),
|
|
539
1184
|
};
|
|
540
1185
|
}
|
|
1186
|
+
/** The videos of the selected pages, for the hints command (none when a --component filter is given: videos have no sizes). */
|
|
1187
|
+
function videosOf(results, args) {
|
|
1188
|
+
if (args.component !== undefined)
|
|
1189
|
+
return [];
|
|
1190
|
+
const page = args.page?.toLowerCase();
|
|
1191
|
+
return results.flatMap((r) => r.videos ?? []).filter((v) => page === undefined || v.page.toLowerCase().includes(page));
|
|
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
|
+
}
|
|
541
1205
|
/** `imgtune hints <scan.json> [<scan.json> ...] ...`: reads the pages' scan files, returns the text or JSON to print. */
|
|
542
1206
|
export async function hintsCommand(argv, cwd) {
|
|
543
1207
|
const args = parseHintsArgs(argv);
|
|
@@ -568,12 +1232,14 @@ export async function hintsCommand(argv, cwd) {
|
|
|
568
1232
|
if (dropped > 0)
|
|
569
1233
|
notes.push(`${dropped} sizes proposal(s) left out: no saving (their predicted outcome adds bytes or changes nothing)`);
|
|
570
1234
|
const filtered = selectHints(all, { ...args, limit: Number.MAX_SAFE_INTEGER, offset: 0 });
|
|
1235
|
+
const videos = videosOf(results, args);
|
|
1236
|
+
const resourceRows = resourceRowsOf(results);
|
|
571
1237
|
if (args.top !== undefined) {
|
|
572
1238
|
const top = groupHints(filtered).slice(0, args.top);
|
|
573
1239
|
if (args.format === "json") {
|
|
574
|
-
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 };
|
|
575
1241
|
}
|
|
576
|
-
return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}`, exitCode: 0 };
|
|
1242
|
+
return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}${renderResourceHints(results)}`, exitCode: 0 };
|
|
577
1243
|
}
|
|
578
1244
|
// JSON is not cut to the text default: it is all of it unless --limit is passed, and `truncated` says whether it is.
|
|
579
1245
|
const jsonLimit = args.limitGiven ? args.limit : Number.MAX_SAFE_INTEGER;
|
|
@@ -602,14 +1268,17 @@ export async function hintsCommand(argv, cwd) {
|
|
|
602
1268
|
saved: group.saved,
|
|
603
1269
|
sizes: combinedSizes(group),
|
|
604
1270
|
sizesFitted: groupSizesOf(group) ?? null,
|
|
1271
|
+
srcset: groupSrcsetOf(group) ?? null,
|
|
605
1272
|
callers: callerMapOf(group) ?? null,
|
|
606
1273
|
ids: group.hints.map((h) => h.id),
|
|
607
1274
|
hints: group.hints,
|
|
608
1275
|
})),
|
|
1276
|
+
...(videos.length > 0 ? { videos } : {}),
|
|
1277
|
+
...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
|
|
609
1278
|
};
|
|
610
1279
|
return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
|
|
611
1280
|
}
|
|
612
|
-
const text = renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)
|
|
1281
|
+
const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
|
|
613
1282
|
return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
|
|
614
1283
|
}
|
|
615
1284
|
const selected = selectHints(all, args.format === "json" ? { ...args, limit: jsonLimit } : args);
|
|
@@ -624,10 +1293,12 @@ export async function hintsCommand(argv, cwd) {
|
|
|
624
1293
|
truncated: args.offset + selected.length < filtered.length,
|
|
625
1294
|
qualities: planJson(plan),
|
|
626
1295
|
hints: selected,
|
|
1296
|
+
...(videos.length > 0 ? { videos } : {}),
|
|
1297
|
+
...(resourceRows.length > 0 ? { resources: resourceRows } : {}),
|
|
627
1298
|
};
|
|
628
1299
|
return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
|
|
629
1300
|
}
|
|
630
|
-
const text = renderHints(selected, filtered.length, args.offset, args.page)
|
|
1301
|
+
const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}${renderResourceHints(results)}`;
|
|
631
1302
|
return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
|
|
632
1303
|
}
|
|
633
1304
|
/** How many hints the PR comment lists. */
|
|
@@ -644,6 +1315,21 @@ export function commentHints(results, limit = COMMENT_HINTS, maxQualities) {
|
|
|
644
1315
|
const top = selectHints(all, { limit, offset: 0 });
|
|
645
1316
|
return { total: all.length, lines: top.map((hint, i) => `${i + 1}. ${commentLine(hint)}`) };
|
|
646
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
|
+
}
|
|
647
1333
|
/**
|
|
648
1334
|
* A page-derived value in the comment: capped at COMMENT_FIELD characters, then JSON-quoted the way the CLI text prints it
|
|
649
1335
|
* (so an instruction inside a name stays a quoted string). The block is fenced, and GitHub does not decode entities inside
|
|
@@ -659,9 +1345,11 @@ function commentLine(hint) {
|
|
|
659
1345
|
? `${quoted(`${hint.file}:${hint.line ?? 0}`)}${locationNote(hint)}`
|
|
660
1346
|
: quoted(hint.component[0] ?? hint.selector);
|
|
661
1347
|
const component = hint.file !== undefined && hint.component[0] !== undefined ? ` (${quoted(hint.component[0])})` : "";
|
|
662
|
-
const sizes = hint.
|
|
663
|
-
? `
|
|
664
|
-
:
|
|
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 ?? "-")}`;
|
|
665
1353
|
const parts = [
|
|
666
1354
|
hint.lcp === true ? `LCP image (${(hint.lcpViewports ?? []).map((v) => quoted(v)).join(", ")})` : undefined,
|
|
667
1355
|
`${savesText(hint)}, +${kb(hint.bytes.addedForSharpness)} sharpness`,
|