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/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
- return (result.images ?? []).filter(hasFix).map((record) => {
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
- lines.push(` ${savesText(hint)}, +${kb(hint.bytes.addedForSharpness)} sharpness (separate) rung ${hint.rung}`);
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. */