imgtune 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +41 -0
- package/dist/animated.js +176 -0
- package/dist/backgrounds.js +13 -2
- package/dist/check-report.js +20 -3
- package/dist/check.js +8 -4
- package/dist/cli.js +66 -8
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +495 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +418 -6
- package/dist/image-file.js +154 -34
- package/dist/images.js +27 -2
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/media-encode.js +1050 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +247 -0
- package/dist/media-phase.js +652 -0
- package/dist/media-prepare.js +31 -0
- package/dist/recheck.js +3 -3
- package/dist/remote.js +474 -69
- package/dist/result-cache.js +56 -3
- package/dist/run.js +8 -4
- package/dist/summary.js +23 -3
- package/dist/video-analysis.js +273 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +1 -1
package/dist/hints.js
CHANGED
|
@@ -8,6 +8,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
10
|
import { MAX_MAX_QUALITIES, MIN_MAX_QUALITIES, planQualityCap, qualitiesLine } from "./qualities-cap.js";
|
|
11
|
+
import { reasonText } from "./media-model.js";
|
|
11
12
|
export const DEFAULT_HINT_LIMIT = 10;
|
|
12
13
|
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
14
|
function nonNegativeInteger(name, text) {
|
|
@@ -98,7 +99,8 @@ export function parseHintsArgs(argv) {
|
|
|
98
99
|
* planQualityCap); it applies only when no --qualities list was given (that list is the framework's own).
|
|
99
100
|
*/
|
|
100
101
|
export function hintsOf(result, qualities, plan) {
|
|
101
|
-
|
|
102
|
+
// An animated image is a hint even without a sizes or byte saving: its saving is an encode (see animatedLines).
|
|
103
|
+
return (result.images ?? []).filter((record) => hasFix(record) || record.fix.animated !== undefined).map((record) => {
|
|
102
104
|
const served = record.current.format;
|
|
103
105
|
const rawTarget = served !== undefined ? record.fix.quality?.perFormat[served] : undefined;
|
|
104
106
|
// With the framework's allowed qualities, the target goes up to one of them. The quality saving was estimated at the
|
|
@@ -208,6 +210,16 @@ export function hintsOf(result, qualities, plan) {
|
|
|
208
210
|
}
|
|
209
211
|
if (record.fix.format !== undefined || served !== undefined)
|
|
210
212
|
hint.format = { current: served, best: record.fix.format };
|
|
213
|
+
if (record.fix.animated !== undefined) {
|
|
214
|
+
hint.animated = record.fix.animated;
|
|
215
|
+
if (record.current.src !== undefined)
|
|
216
|
+
hint.src = record.current.src;
|
|
217
|
+
// The animated saving is the encode's alone (est.): the target is encoded at the needed width, so perViewBytes less the
|
|
218
|
+
// target's bytes already holds the width change. Adding the record's sizes part would count it twice. A real sizes
|
|
219
|
+
// proposal is still printed as information (renderHint), but it adds nothing to the saving.
|
|
220
|
+
const encoded = animatedSavingOf(record.fix.animated);
|
|
221
|
+
hint.bytes = { saved: encoded, addedForSharpness: record.fix.bytes.addedForSharpness, quality: encoded };
|
|
222
|
+
}
|
|
211
223
|
return hint;
|
|
212
224
|
});
|
|
213
225
|
}
|
|
@@ -280,8 +292,9 @@ export function renderHint(hint, rank) {
|
|
|
280
292
|
lines.push(` find: ${hint.find.component} in ${hint.find.glob}`);
|
|
281
293
|
if (hint.width !== undefined)
|
|
282
294
|
lines.push(` width ${hint.width}px (device pixels needed)`);
|
|
283
|
-
else
|
|
295
|
+
else if (hint.animated === undefined || hint.sizes.proposed !== undefined) {
|
|
284
296
|
lines.push(` sizes ${safeText(hint.sizes.current ?? "none")} -> ${safeText(hint.sizes.proposed ?? "-")} (${hint.sizes.basis ?? "-"})`);
|
|
297
|
+
}
|
|
285
298
|
if (hint.sizesAuto !== undefined)
|
|
286
299
|
lines.push(` alternative: sizes="${safeText(hint.sizesAuto)}" (Chrome, Edge, Firefox 150+; Safari falls back to ${safeText(hint.sizes.proposed ?? "-")})`);
|
|
287
300
|
if (hint.via === "prod-sourcemap") {
|
|
@@ -292,6 +305,8 @@ export function renderHint(hint, rank) {
|
|
|
292
305
|
}
|
|
293
306
|
else if (hint.file === undefined && hint.find === undefined && hint.kind !== "owner")
|
|
294
307
|
lines.push(" owner unknown");
|
|
308
|
+
if (hint.animated !== undefined)
|
|
309
|
+
lines.push(...animatedLines(hint.animated, hint.src, hint.alt));
|
|
295
310
|
const parts = [];
|
|
296
311
|
if (hint.quality)
|
|
297
312
|
parts.push(`quality ${hint.quality.current}${qualityTarget(hint.quality)} (max of ${hint.quality.samples}, ${safeText(hint.quality.group)})`);
|
|
@@ -301,7 +316,9 @@ export function renderHint(hint, rank) {
|
|
|
301
316
|
lines.push(` ${parts.join(" ")}`);
|
|
302
317
|
if (hint.qualityNote !== undefined)
|
|
303
318
|
lines.push(` ${safeText(hint.qualityNote)}`);
|
|
304
|
-
|
|
319
|
+
// An animated hint's saving is an estimate from the sample encodes, and its rung is that estimate, not the image measurement.
|
|
320
|
+
const animated = hint.animated !== undefined;
|
|
321
|
+
lines.push(` ${savesText(hint)}${animated ? " est." : ""}, +${kb(hint.bytes.addedForSharpness)} sharpness (separate) rung ${animated ? "estimate" : hint.rung}`);
|
|
305
322
|
return lines;
|
|
306
323
|
}
|
|
307
324
|
/** The group a hint belongs to: its file:line, else its nearest component, else its selector. */
|
|
@@ -493,6 +510,11 @@ export function renderGroup(group, rank) {
|
|
|
493
510
|
const ids = group.hints.map((h) => h.id);
|
|
494
511
|
const shown = ids.slice(0, 8).join(" ");
|
|
495
512
|
lines.push(` ids ${shown}${ids.length > 8 ? ` (+${ids.length - 8} more; --flat lists each image)` : ""}`);
|
|
513
|
+
// An animated image's hint is its own (its target and bytes are not a sizes or quality change), so it is printed here.
|
|
514
|
+
for (const hint of group.hints) {
|
|
515
|
+
if (hint.animated !== undefined)
|
|
516
|
+
lines.push(` ${safeText(hint.id)}`, ...animatedLines(hint.animated, hint.src));
|
|
517
|
+
}
|
|
496
518
|
return lines;
|
|
497
519
|
}
|
|
498
520
|
/** The text of a grouped selection: a count line, then each group. */
|
|
@@ -511,6 +533,386 @@ export function renderHints(hints, total, offset, page) {
|
|
|
511
533
|
const body = hints.flatMap((hint, i) => renderHint(hint, offset + i + 1));
|
|
512
534
|
return `${head}${body.join("\n")}\n`;
|
|
513
535
|
}
|
|
536
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
537
|
+
// Animated images and `<video>` (Task 8): the text of their hints and of the Video section of a page. Every estimated byte
|
|
538
|
+
// figure is marked "est."; a hint names the sample encodes once. Page-derived names and attributes are JSON strings.
|
|
539
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
540
|
+
/** The note that marks a figure as an estimate from the 2-second sample encodes. A hint says it once. */
|
|
541
|
+
export const SAMPLE_NOTE = "(estimate from 2 s samples)";
|
|
542
|
+
/** The `type` of the two sources a video hint names: AV1 first, H.264 as the fallback. */
|
|
543
|
+
const AV1_TYPE = 'video/mp4; codecs=av01.0.08M.08';
|
|
544
|
+
const H264_TYPE = 'video/mp4; codecs=avc1.640028';
|
|
545
|
+
/** The H.264 fallback recipe an animated GIF's video hint gives. It is not encoded by imgtune (performance first). */
|
|
546
|
+
const H264_RECIPE = "libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart -an";
|
|
547
|
+
const FORMAT_LABEL = { gif: "GIF", webp: "WebP", apng: "APNG" };
|
|
548
|
+
/**
|
|
549
|
+
* 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
|
|
550
|
+
* says so: performance before precision, but the number is never presented as a measured CPU encode.
|
|
551
|
+
*/
|
|
552
|
+
export function estLabel(c) {
|
|
553
|
+
return c.encoder === "nvenc" ? "est. (GPU-encoded, scaled to a CPU encode ÷1.4)" : "est.";
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* 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
|
|
557
|
+
* the recipe's CRF is approximate. Empty for a CPU encode.
|
|
558
|
+
*/
|
|
559
|
+
export function qualityApprox(c) {
|
|
560
|
+
return c.encoder === "nvenc" ? " (approx.)" : "";
|
|
561
|
+
}
|
|
562
|
+
/** The smallest candidate of one codec, or undefined. The first of equal bytes wins. */
|
|
563
|
+
export function smallestCandidate(candidates, codec) {
|
|
564
|
+
let best;
|
|
565
|
+
for (const c of candidates ?? []) {
|
|
566
|
+
if (c.codec === codec && (best === undefined || c.bytes < best.bytes))
|
|
567
|
+
best = c;
|
|
568
|
+
}
|
|
569
|
+
return best;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The quality evidence of an encode: VMAF with its bar, and the still-frame guard with its bar. A bar the candidate does not
|
|
573
|
+
* carry is said so, not guessed: the run's flag is not in the scan file.
|
|
574
|
+
*/
|
|
575
|
+
export function evidenceOf(c) {
|
|
576
|
+
const bar = (value) => (value === undefined ? "bar not recorded" : `bar ${value}`);
|
|
577
|
+
return `VMAF ${c.vmaf.toFixed(1)} (${bar(c.vmafBar)}), still ${c.still.toFixed(1)} (${bar(c.stillBar)})`;
|
|
578
|
+
}
|
|
579
|
+
/** The codec as a hint names it: "av1", "h264", or the codec's own name. */
|
|
580
|
+
function codecName(codec) {
|
|
581
|
+
return codec === "av1-mp4" ? "av1" : codec === "h264-mp4" ? "h264" : codec;
|
|
582
|
+
}
|
|
583
|
+
/** The targets of an animated image's encode (see AnimatedTargets). The main one's bytes are the saving's basis. */
|
|
584
|
+
export function animatedTargetsOf(animated) {
|
|
585
|
+
const avif = smallestCandidate(animated.estimate, "animated-avif");
|
|
586
|
+
const webp = smallestCandidate(animated.estimate, "animated-webp");
|
|
587
|
+
const av1 = smallestCandidate(animated.estimate, "av1-mp4");
|
|
588
|
+
const h264 = smallestCandidate(animated.estimate, "h264-mp4");
|
|
589
|
+
const video = av1 !== undefined && (animated.target?.main === "video" || (avif === undefined && webp === undefined));
|
|
590
|
+
return { avif, webp, av1, h264, video, main: video ? av1 : (avif ?? webp) };
|
|
591
|
+
}
|
|
592
|
+
/** The bytes per view an animated image saves: its bytes per view less the main target's encode, floored at 0 (est.). */
|
|
593
|
+
export function animatedSavingOf(animated) {
|
|
594
|
+
const main = animatedTargetsOf(animated).main;
|
|
595
|
+
return main === undefined ? 0 : Math.max(0, animated.perViewBytes - main.bytes);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The fallback of an image-target hint, built only from the candidates that exist: the `<picture>` with an AVIF source (for
|
|
599
|
+
* AVIF, with or without WebP), a plain `src` swap for WebP alone (WebP animates everywhere it shows an image), and for a CSS
|
|
600
|
+
* background an `image-set()` with the existing types.
|
|
601
|
+
*/
|
|
602
|
+
function imageFallbackOf(context, targets, src) {
|
|
603
|
+
if (context === "css-background") {
|
|
604
|
+
const types = [
|
|
605
|
+
targets.avif !== undefined ? 'url(<avif>) type("image/avif")' : undefined,
|
|
606
|
+
targets.webp !== undefined ? 'url(<webp>) type("image/webp")' : undefined,
|
|
607
|
+
].filter((t) => t !== undefined);
|
|
608
|
+
return `fallback: image-set(${types.join(", ")})`;
|
|
609
|
+
}
|
|
610
|
+
if (targets.avif !== undefined) {
|
|
611
|
+
return `fallback: <picture><source type="image/avif" srcset="…"><img src=${JSON.stringify(src ?? "")}></picture>`;
|
|
612
|
+
}
|
|
613
|
+
return "plain src swap to the animated WebP (WebP animates in every browser that shows the image)";
|
|
614
|
+
}
|
|
615
|
+
/** Browser coverage of each option, as the hint states it (the caniuse data is not in the repo: these are the stated facts). */
|
|
616
|
+
const COVERAGE = {
|
|
617
|
+
webp: "animated WebP: all current browsers",
|
|
618
|
+
avif: "animated AVIF: Chrome 85+, Firefox 113+, Safari 16.4+",
|
|
619
|
+
av1: "AV1 video: Chrome, Firefox, Edge; Safari only on devices with AV1 hardware decode, so keep the H.264 fallback",
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* The exact encoder settings of a candidate, as an agent runs them: the encoder, its preset and CRF (or q), and the pixel
|
|
623
|
+
* format and container options that follow. An AV1 CRF is a CRF, never a q (see the quality label in animatedLines).
|
|
624
|
+
*/
|
|
625
|
+
export function encoderRecipe(codec, c) {
|
|
626
|
+
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}`;
|
|
627
|
+
switch (codec) {
|
|
628
|
+
case "av1-mp4":
|
|
629
|
+
return `${av1} -pix_fmt yuv420p -movflags +faststart`;
|
|
630
|
+
case "animated-avif":
|
|
631
|
+
return `${av1}, -f avif`;
|
|
632
|
+
case "animated-webp":
|
|
633
|
+
return `libwebp_anim -q:v ${c.quality} -loop 0`;
|
|
634
|
+
case "h264-mp4":
|
|
635
|
+
return `libx264 -preset veryfast -crf ${c.quality} -pix_fmt yuv420p -movflags +faststart -an`;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* 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
|
|
640
|
+
* target is the file's width and a wider one would add bytes, not detail. Undefined when the target is not capped.
|
|
641
|
+
*/
|
|
642
|
+
export function sourceCapLine(need, sourceWidth, served) {
|
|
643
|
+
if (need === undefined || sourceWidth === undefined || !(need > sourceWidth) || served !== sourceWidth)
|
|
644
|
+
return undefined;
|
|
645
|
+
return ` source is ${sourceWidth}px wide; needed ${need}px: serve ${sourceWidth}px (upscaling adds bytes, not detail)`;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The lines of an animated image's hint. An image target (AVIF and/or WebP, with a fallback built from the candidates that
|
|
649
|
+
* exist) when the encode offers one; the `<video>` replacement with its AV1 source and the H.264 recipe when the target is
|
|
650
|
+
* video. With no encode, the bytes per view and the reason. `src` is the served file's URL, printed as a JSON string; `alt`
|
|
651
|
+
* is the image's alt text, which the `<video>` keeps as its accessible name.
|
|
652
|
+
*/
|
|
653
|
+
export function animatedLines(animated, src, alt) {
|
|
654
|
+
const { info, perViewBytes } = animated;
|
|
655
|
+
const head = `animated ${FORMAT_LABEL[info.format]} ${(info.durationMs / 1000).toFixed(1)} s, ${info.frames} frames`;
|
|
656
|
+
const targets = animatedTargetsOf(animated);
|
|
657
|
+
const { avif, webp, av1, h264, main } = targets;
|
|
658
|
+
if (main === undefined) {
|
|
659
|
+
return [` ${head}: ${kb(perViewBytes)}/view; not encoded: ${safeText(reasonText(animated.notEncoded ?? "no passing quality"))}`];
|
|
660
|
+
}
|
|
661
|
+
const saves = animatedSavingOf(animated);
|
|
662
|
+
const offered = [
|
|
663
|
+
avif !== undefined ? `animated AVIF CRF ${avif.quality}${qualityApprox(avif)} at ${avif.width}px (${encoderRecipe("animated-avif", avif)}): ${kb(avif.bytes)} ${estLabel(avif)}` : undefined,
|
|
664
|
+
webp !== undefined ? `animated WebP q${webp.quality} at ${webp.width}px (${encoderRecipe("animated-webp", webp)}): ${kb(webp.bytes)} ${estLabel(webp)}` : undefined,
|
|
665
|
+
].filter((part) => part !== undefined);
|
|
666
|
+
const coverage = [];
|
|
667
|
+
if (avif !== undefined)
|
|
668
|
+
coverage.push(COVERAGE.avif);
|
|
669
|
+
if (webp !== undefined)
|
|
670
|
+
coverage.push(COVERAGE.webp);
|
|
671
|
+
const cap = sourceCapLine(animated.need, animated.sourceWidth, main.width);
|
|
672
|
+
if (targets.video && av1 !== undefined) {
|
|
673
|
+
// An empty alt is a decorative image: the <video> is hidden from assistive technology, with no label. Otherwise the alt is
|
|
674
|
+
// kept as the accessible name (role="img", aria-label).
|
|
675
|
+
const decorative = alt !== undefined && alt.trim() === "";
|
|
676
|
+
const altPart = decorative
|
|
677
|
+
? ' aria-hidden="true"'
|
|
678
|
+
: alt !== undefined
|
|
679
|
+
? ` role="img" aria-label=${JSON.stringify(safeText(alt))}`
|
|
680
|
+
: "";
|
|
681
|
+
const lines = [` ${head}: replace with <video autoplay muted loop playsinline poster="<first frame>"${altPart}>`];
|
|
682
|
+
if (decorative)
|
|
683
|
+
lines.push(' the image is decorative (alt=""): the <video> is aria-hidden, with no label');
|
|
684
|
+
else if (alt !== undefined)
|
|
685
|
+
lines.push(" the image's alt text is kept as the <video>'s aria-label (role=\"img\"): the accessible name does not change");
|
|
686
|
+
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)}`);
|
|
687
|
+
lines.push(h264 !== undefined
|
|
688
|
+
? ` <source src="…-${h264.width}.h264.mp4" type="${H264_TYPE}"> H.264 MP4 (${encoderRecipe("h264-mp4", h264)}), ${h264.width}px, ${kb(h264.bytes)} ${estLabel(h264)}`
|
|
689
|
+
: ` <source src="…-${av1.width}.h264.mp4" type="${H264_TYPE}"> H.264 fallback, not measured: ${H264_RECIPE} at ${av1.width}px wide`);
|
|
690
|
+
if (cap !== undefined)
|
|
691
|
+
lines.push(cap);
|
|
692
|
+
lines.push(` evidence: ${evidenceOf(av1)}`);
|
|
693
|
+
lines.push(` browsers: ${COVERAGE.av1}`);
|
|
694
|
+
if (offered.length > 0)
|
|
695
|
+
lines.push(` alternative: ${offered.join(", ")}`);
|
|
696
|
+
if (offered.length > 0)
|
|
697
|
+
lines.push(...coverage.map((line) => ` browsers: ${line}`));
|
|
698
|
+
lines.push(` saves ${kb(saves)}/view est. ${SAMPLE_NOTE}`);
|
|
699
|
+
return lines;
|
|
700
|
+
}
|
|
701
|
+
const fallback = imageFallbackOf(animated.context, targets, src);
|
|
702
|
+
const lines = [` ${head}: serve ${offered.join(", ")}; ${fallback}; saves ${kb(saves)}/view est. ${SAMPLE_NOTE}`];
|
|
703
|
+
if (cap !== undefined)
|
|
704
|
+
lines.push(cap);
|
|
705
|
+
lines.push(` evidence: ${evidenceOf(main)}`);
|
|
706
|
+
lines.push(...coverage.map((line) => ` browsers: ${line}`));
|
|
707
|
+
return lines;
|
|
708
|
+
}
|
|
709
|
+
/** The name a video records by: where it is in the page, a file or component, else its selector. */
|
|
710
|
+
export function videoWhere(video) {
|
|
711
|
+
if (video.locator.file !== undefined)
|
|
712
|
+
return `${video.locator.file}:${video.locator.line ?? 0}`;
|
|
713
|
+
return video.locator.component[0] ?? video.locator.selector;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* 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
|
|
717
|
+
* the same fraction of the bytes the page loads per view.
|
|
718
|
+
*/
|
|
719
|
+
export function videoSavingOf(video) {
|
|
720
|
+
if (video.steps === undefined || video.estimate === undefined)
|
|
721
|
+
return { view: 0, play: 0 };
|
|
722
|
+
const { audio, resolution, codec, quality } = video.steps;
|
|
723
|
+
const play = audio + resolution + codec + quality;
|
|
724
|
+
const { perView, perPlay } = video.bytes;
|
|
725
|
+
return { view: perPlay > 0 ? (play * perView) / perPlay : 0, play };
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* 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.
|
|
729
|
+
* 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).
|
|
730
|
+
*/
|
|
731
|
+
export function onePerFile(videos) {
|
|
732
|
+
const byFile = new Map();
|
|
733
|
+
for (const video of videos) {
|
|
734
|
+
const key = `${video.page}\n${video.src}`;
|
|
735
|
+
const current = byFile.get(key);
|
|
736
|
+
if (current === undefined || video.bytes.perView > current.bytes.perView)
|
|
737
|
+
byFile.set(key, video);
|
|
738
|
+
}
|
|
739
|
+
return [...byFile.values()];
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The Video section's totals: bytes per view and per play over the files (each URL once per page, see onePerFile), and the
|
|
743
|
+
* saveable bytes (an estimate, marked when any video has steps). Undefined with no video.
|
|
744
|
+
*/
|
|
745
|
+
export function videoTotalsLine(videos) {
|
|
746
|
+
if (videos.length === 0)
|
|
747
|
+
return undefined;
|
|
748
|
+
const files = onePerFile(videos);
|
|
749
|
+
const sum = (values) => values.reduce((total, v) => total + v, 0);
|
|
750
|
+
const savings = files.map(videoSavingOf);
|
|
751
|
+
const est = files.some((v) => v.steps !== undefined && v.estimate !== undefined) ? " est." : "";
|
|
752
|
+
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}`;
|
|
753
|
+
}
|
|
754
|
+
/** A video's encode in one phrase: the encode at its width with its bytes, or why it was not encoded. */
|
|
755
|
+
export function videoStatusOf(video) {
|
|
756
|
+
const e = video.estimate;
|
|
757
|
+
if (e === undefined)
|
|
758
|
+
return `not encoded: ${safeText(reasonText(video.notEncoded ?? "no passing quality"))}`;
|
|
759
|
+
return `${codecName(e.codec)} CRF ${e.quality}${qualityApprox(e)}, ${e.width}px, ${kb(e.bytes)} ${estLabel(e)}`;
|
|
760
|
+
}
|
|
761
|
+
/** One video as a row of a page's Video section: its place (quoted by the caller), its bytes, and its encode or reason. */
|
|
762
|
+
export function videoRowOf(video, quote) {
|
|
763
|
+
return `${quote(videoWhere(video))}: ${kb(video.bytes.perView)}/view, ${kb(video.bytes.perPlay)}/play; ${videoStatusOf(video)}`;
|
|
764
|
+
}
|
|
765
|
+
/** What the bytes per view are, as a hint says it: the autoplay full file, the bytes seen in the scan, or none. */
|
|
766
|
+
function viewBasisOf(video) {
|
|
767
|
+
const b = video.bytes;
|
|
768
|
+
if (b.perViewBasis === "full-file")
|
|
769
|
+
return "autoplay: full file";
|
|
770
|
+
if (b.perViewBasis === "none")
|
|
771
|
+
return "not loaded before play";
|
|
772
|
+
return b.browserDependent ? "preload=auto: browser-dependent, bytes seen in the scan" : "bytes seen in the scan";
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* The bytes per view of a width plan (audit), est. (scaled): each viewport's bytes at its width are the encode's bytes times
|
|
776
|
+
* (w / w_encoded)², the pixel ratio, and the per-view share of the file. They are summed over the viewports and divided by
|
|
777
|
+
* their count, the same average the image savings use. Without an encode, "not encoded" and the reason.
|
|
778
|
+
*/
|
|
779
|
+
function bytesAtPlan(video, plan) {
|
|
780
|
+
const est = video.estimate;
|
|
781
|
+
if (est === undefined || video.bytes.perPlay <= 0) {
|
|
782
|
+
return `not encoded${video.notEncoded !== undefined ? `: ${safeText(reasonText(video.notEncoded))}` : ""}`;
|
|
783
|
+
}
|
|
784
|
+
if (plan.perViewport.length === 0)
|
|
785
|
+
return "-";
|
|
786
|
+
const ratio = video.bytes.perView / video.bytes.perPlay;
|
|
787
|
+
const total = plan.perViewport.reduce((sum, v) => sum + est.bytes * (v.width / est.width) ** 2 * ratio, 0);
|
|
788
|
+
return `${kb(total / plan.perViewport.length)}/view est. (scaled)`;
|
|
789
|
+
}
|
|
790
|
+
/** Audit: each viewport's need, and the bytes per view at one, two and three widths. No pick: the agent chooses. */
|
|
791
|
+
function widthOptionLines(options) {
|
|
792
|
+
const lines = [];
|
|
793
|
+
const first = options[0];
|
|
794
|
+
if (first !== undefined && first.perViewport.length > 0) {
|
|
795
|
+
lines.push(` viewports: ${first.perViewport.map((v) => `${JSON.stringify(safeText(v.viewport))} needs ${v.need}px`).join(", ")}`);
|
|
796
|
+
}
|
|
797
|
+
lines.push(" widths → bytes/view (audit: no pick)");
|
|
798
|
+
return lines;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 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
|
|
802
|
+
* plan serves the file's width. Undefined when no viewport is capped (or the file's width is not known).
|
|
803
|
+
*/
|
|
804
|
+
export function videoSourceCapLine(video) {
|
|
805
|
+
const source = video.probe?.width;
|
|
806
|
+
const plan = video.widths;
|
|
807
|
+
if (plan === undefined || source === undefined || !(source > 0))
|
|
808
|
+
return undefined;
|
|
809
|
+
// The plan's needs are already capped at the file's width, so the need is taken from the boxes: what the page shows.
|
|
810
|
+
const need = Math.max(0, ...video.perViewport.filter((v) => v.boxWidth > 0).map((v) => Math.ceil(v.boxWidth * v.dpr)));
|
|
811
|
+
if (!(need > source) || !plan.perViewport.some((row) => row.width === source))
|
|
812
|
+
return undefined;
|
|
813
|
+
return sourceCapLine(need, source, source);
|
|
814
|
+
}
|
|
815
|
+
/** Scan: the `<source>` list of the planned widths, AV1 then H.264 for each, with the encode's CRF and bytes at its width. */
|
|
816
|
+
function targetLines(video, plan) {
|
|
817
|
+
if (plan.containerDriven) {
|
|
818
|
+
return plan.perViewport.map((row) => {
|
|
819
|
+
const box = video.perViewport.find((v) => v.viewport === row.viewport);
|
|
820
|
+
const boxText = box !== undefined ? `box ${box.boxWidth} px at ${box.dpr}x` : "box not read";
|
|
821
|
+
return ` viewport ${JSON.stringify(safeText(row.viewport))}: ${boxText}, serve ${row.width}px`;
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
const est = video.estimate;
|
|
825
|
+
const lines = [];
|
|
826
|
+
for (const source of plan.sources) {
|
|
827
|
+
const media = source.media !== undefined ? `media="${source.media}" ` : "";
|
|
828
|
+
for (const kind of ["av1", "h264"]) {
|
|
829
|
+
const codec = kind === "av1" ? "av1-mp4" : "h264-mp4";
|
|
830
|
+
const label = kind === "av1" ? "AV1" : "H.264";
|
|
831
|
+
// Every source names the exact encoder settings an agent runs: the estimate's codec, preset and CRF. At the estimate's
|
|
832
|
+
// own width that is measured; at another width the same settings apply, which is not measured there.
|
|
833
|
+
const same = est !== undefined && est.codec === codec ? est : undefined;
|
|
834
|
+
let at;
|
|
835
|
+
if (same !== undefined && same.width === source.width) {
|
|
836
|
+
at = ` ${label} MP4 (${encoderRecipe(codec, same)}), CRF ${same.quality}${qualityApprox(same)}, ${same.width}px, ${kb(same.bytes)} ${estLabel(same)}`;
|
|
837
|
+
}
|
|
838
|
+
else if (same !== undefined) {
|
|
839
|
+
at = ` ${label} MP4 (${encoderRecipe(codec, same)}), CRF ${same.quality}${qualityApprox(same)}, ${source.width}px: same settings, not measured at this width`;
|
|
840
|
+
}
|
|
841
|
+
else if (kind === "h264") {
|
|
842
|
+
at = ` H.264 MP4 (${H264_RECIPE}), ${source.width}px: not measured (no H.264 encode for this video)`;
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
at = ` AV1 MP4 (libsvtav1 -preset 10 -pix_fmt yuv420p -movflags +faststart), CRF not measured: no AV1 encode passed for this video`;
|
|
846
|
+
}
|
|
847
|
+
lines.push(` <source ${media}src="…-${source.width}.${kind}.mp4" type="${kind === "av1" ? AV1_TYPE : H264_TYPE}">${at}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return lines;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The lines of one video's hint: where and the bytes; the levers of the encode (audio, resolution, re-encode, one line each
|
|
854
|
+
* with its bytes per play; never a quality line, as the encode's quality is the re-encode); the loading, codec and poster
|
|
855
|
+
* levers; the target sources (or the widths table in audit); the evidence; and the saving. Without steps, the target and its
|
|
856
|
+
* bytes only. Without an encode, the reason.
|
|
857
|
+
*/
|
|
858
|
+
export function videoLines(video, rank = 1) {
|
|
859
|
+
const lines = [`${rank}. ${safeText(video.id)} ${JSON.stringify(safeText(videoWhere(video)))} ${safeText(video.page)}`];
|
|
860
|
+
lines.push(` bytes: ${kb(video.bytes.perView)}/view (${viewBasisOf(video)}), ${kb(video.bytes.perPlay)}/play`);
|
|
861
|
+
const est = video.estimate;
|
|
862
|
+
if (est !== undefined && video.steps !== undefined) {
|
|
863
|
+
const s = video.steps;
|
|
864
|
+
lines.push(` audio: drop the track (muted video): saves ${kb(s.audio)}/play est.`);
|
|
865
|
+
lines.push(` resolution: serve ${est.width}px: saves ${kb(s.resolution)}/play est.`);
|
|
866
|
+
lines.push(` re-encode (${codecName(est.codec)} CRF ${est.quality}): saves ${kb(s.codec)}/play est.`);
|
|
867
|
+
}
|
|
868
|
+
if (video.levers.loading !== undefined) {
|
|
869
|
+
const loading = video.levers.loading;
|
|
870
|
+
const attr = video.attributes.preload === "" ? "no preload attribute" : `preload=${JSON.stringify(safeText(video.attributes.preload))}`;
|
|
871
|
+
const action = video.attributes.autoplay
|
|
872
|
+
? "autoplay fetches the file on load: lazy-load the video until it is in view"
|
|
873
|
+
: loading.belowFold
|
|
874
|
+
? 'below the fold: set preload="none"'
|
|
875
|
+
: 'set preload="metadata"';
|
|
876
|
+
lines.push(` loading: ${attr} loads ${kb(loading.perViewBytes)}/view before play: ${action}`);
|
|
877
|
+
}
|
|
878
|
+
if (video.levers.codec !== undefined)
|
|
879
|
+
lines.push(" codec: no AV1 source: serve AV1 first, H.264 fallback");
|
|
880
|
+
if (video.levers.posterMissing === true)
|
|
881
|
+
lines.push(' poster: none: add poster="<first frame>" (audited as an image)');
|
|
882
|
+
for (const note of video.levers.info)
|
|
883
|
+
lines.push(` info: ${safeText(note)}`);
|
|
884
|
+
if (video.widthOptions !== undefined) {
|
|
885
|
+
lines.push(...widthOptionLines(video.widthOptions));
|
|
886
|
+
for (const plan of video.widthOptions) {
|
|
887
|
+
lines.push(` ${plan.widths.length} width(s): ${plan.widths.map((w) => `${w}px`).join(", ")} → ${bytesAtPlan(video, plan)}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
else if (video.widths !== undefined) {
|
|
891
|
+
lines.push(...targetLines(video, video.widths));
|
|
892
|
+
const cap = videoSourceCapLine(video);
|
|
893
|
+
if (cap !== undefined)
|
|
894
|
+
lines.push(cap);
|
|
895
|
+
}
|
|
896
|
+
if (est !== undefined)
|
|
897
|
+
lines.push(` evidence: ${evidenceOf(est)}`);
|
|
898
|
+
else if (video.notEncoded !== undefined)
|
|
899
|
+
lines.push(` not encoded: ${safeText(reasonText(video.notEncoded))}`);
|
|
900
|
+
if (est !== undefined && video.steps !== undefined) {
|
|
901
|
+
const s = videoSavingOf(video);
|
|
902
|
+
lines.push(` saves ${kb(s.view)}/view est., ${kb(s.play)}/play est. ${SAMPLE_NOTE}`);
|
|
903
|
+
}
|
|
904
|
+
else if (est !== undefined) {
|
|
905
|
+
lines.push(` ${SAMPLE_NOTE}`);
|
|
906
|
+
}
|
|
907
|
+
return lines;
|
|
908
|
+
}
|
|
909
|
+
/** The hints command's Video part: each video's hint, numbered. Empty with no video. */
|
|
910
|
+
export function renderVideoHints(videos) {
|
|
911
|
+
if (videos.length === 0)
|
|
912
|
+
return "";
|
|
913
|
+
const body = videos.flatMap((video, i) => videoLines(video, i + 1));
|
|
914
|
+
return `${videos.length} video(s) on the page(s), self-hosted files:\n${body.join("\n")}\n`;
|
|
915
|
+
}
|
|
514
916
|
/**
|
|
515
917
|
* 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
918
|
* have no file:line, the command that locates them. Undefined when every hint has its source.
|
|
@@ -538,6 +940,13 @@ function planJson(plan) {
|
|
|
538
940
|
estTotalBytes: Math.round(plan.total),
|
|
539
941
|
};
|
|
540
942
|
}
|
|
943
|
+
/** The videos of the selected pages, for the hints command (none when a --component filter is given: videos have no sizes). */
|
|
944
|
+
function videosOf(results, args) {
|
|
945
|
+
if (args.component !== undefined)
|
|
946
|
+
return [];
|
|
947
|
+
const page = args.page?.toLowerCase();
|
|
948
|
+
return results.flatMap((r) => r.videos ?? []).filter((v) => page === undefined || v.page.toLowerCase().includes(page));
|
|
949
|
+
}
|
|
541
950
|
/** `imgtune hints <scan.json> [<scan.json> ...] ...`: reads the pages' scan files, returns the text or JSON to print. */
|
|
542
951
|
export async function hintsCommand(argv, cwd) {
|
|
543
952
|
const args = parseHintsArgs(argv);
|
|
@@ -568,12 +977,13 @@ export async function hintsCommand(argv, cwd) {
|
|
|
568
977
|
if (dropped > 0)
|
|
569
978
|
notes.push(`${dropped} sizes proposal(s) left out: no saving (their predicted outcome adds bytes or changes nothing)`);
|
|
570
979
|
const filtered = selectHints(all, { ...args, limit: Number.MAX_SAFE_INTEGER, offset: 0 });
|
|
980
|
+
const videos = videosOf(results, args);
|
|
571
981
|
if (args.top !== undefined) {
|
|
572
982
|
const top = groupHints(filtered).slice(0, args.top);
|
|
573
983
|
if (args.format === "json") {
|
|
574
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 };
|
|
575
985
|
}
|
|
576
|
-
return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}`, exitCode: 0 };
|
|
986
|
+
return { output: `${notes.length > 0 ? `${notes.join("\n")}\n` : ""}${renderTop(top)}${renderVideoHints(videos)}`, exitCode: 0 };
|
|
577
987
|
}
|
|
578
988
|
// JSON is not cut to the text default: it is all of it unless --limit is passed, and `truncated` says whether it is.
|
|
579
989
|
const jsonLimit = args.limitGiven ? args.limit : Number.MAX_SAFE_INTEGER;
|
|
@@ -606,10 +1016,11 @@ export async function hintsCommand(argv, cwd) {
|
|
|
606
1016
|
ids: group.hints.map((h) => h.id),
|
|
607
1017
|
hints: group.hints,
|
|
608
1018
|
})),
|
|
1019
|
+
...(videos.length > 0 ? { videos } : {}),
|
|
609
1020
|
};
|
|
610
1021
|
return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
|
|
611
1022
|
}
|
|
612
|
-
const text = renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)
|
|
1023
|
+
const text = `${renderGroups(selectedGroups, groups.length, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}`;
|
|
613
1024
|
return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
|
|
614
1025
|
}
|
|
615
1026
|
const selected = selectHints(all, args.format === "json" ? { ...args, limit: jsonLimit } : args);
|
|
@@ -624,10 +1035,11 @@ export async function hintsCommand(argv, cwd) {
|
|
|
624
1035
|
truncated: args.offset + selected.length < filtered.length,
|
|
625
1036
|
qualities: planJson(plan),
|
|
626
1037
|
hints: selected,
|
|
1038
|
+
...(videos.length > 0 ? { videos } : {}),
|
|
627
1039
|
};
|
|
628
1040
|
return { output: `${JSON.stringify(body, null, 2)}\n`, exitCode: 0 };
|
|
629
1041
|
}
|
|
630
|
-
const text = renderHints(selected, filtered.length, args.offset, args.page)
|
|
1042
|
+
const text = `${renderHints(selected, filtered.length, args.offset, args.page)}${renderVideoHints(videos)}`;
|
|
631
1043
|
return { output: notes.length > 0 ? `${notes.join("\n")}\n${text}` : text, exitCode: 0 };
|
|
632
1044
|
}
|
|
633
1045
|
/** How many hints the PR comment lists. */
|