imgtune 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/README.md +69 -6
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +31 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +37 -4
- package/dist/check.js +18 -1
- package/dist/cli.js +20 -0
- package/dist/egress-proxy.js +2 -1
- package/dist/ffmpeg.js +35 -0
- package/dist/hints.js +294 -18
- package/dist/image-open.js +9 -0
- package/dist/images.js +116 -7
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +5 -1
- package/dist/media-model.js +8 -4
- package/dist/media-phase.js +25 -2
- package/dist/px.js +11 -0
- package/dist/recheck.js +9 -1
- package/dist/remote.js +616 -63
- package/dist/replay.js +20 -0
- package/dist/resources/audio.js +248 -0
- package/dist/resources/brotli.js +22 -0
- package/dist/resources/cache.js +59 -0
- package/dist/resources/capture.js +225 -0
- package/dist/resources/compression.js +122 -0
- package/dist/resources/favicon.js +130 -0
- package/dist/resources/font.js +440 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/lottie.js +491 -0
- package/dist/resources/model.js +478 -0
- package/dist/resources/phase.js +171 -0
- package/dist/resources/pool.js +47 -0
- package/dist/resources/report.js +57 -0
- package/dist/resources/sfnt.js +305 -0
- package/dist/resources/svg.js +178 -0
- package/dist/resources/types.js +1 -0
- package/dist/resources/zip.js +67 -0
- package/dist/run.js +24 -4
- package/dist/sizes.js +7 -5
- package/dist/summary.js +47 -1
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +119 -21
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The vector phase of a scan: after every page is measured, the flat still rasters of all pages (fix.vector, see
|
|
2
|
+
// vector-art.ts) are ranked by their bytes per view, and the heaviest ones within --svg-budget are traced and checked.
|
|
3
|
+
// Each unique URL is traced once per run. Its numbers are kept in the result cache, keyed by the content hash, so a
|
|
4
|
+
// rescan does not score it again. A passing SVG is written to the scan's svg/ folder. Nothing here throws: a failure
|
|
5
|
+
// becomes the record's notChecked reason, and the scan always finishes.
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
9
|
+
import { mapLimit } from "./concurrency.js";
|
|
10
|
+
import { CACHE_VERSION, contentHash } from "./result-cache.js";
|
|
11
|
+
import { measure } from "./timing.js";
|
|
12
|
+
import { VECTOR_BAR, VECTOR_CAP_MS, VECTOR_MIN_SAVING, VECTORIZER_VERSION, loadVectorizer, svgBytes, svgFidelity, traceSvg, } from "./vector-art.js";
|
|
13
|
+
/** The note printed once when the vectorizer is missing: what installing it adds. */
|
|
14
|
+
export const VECTOR_NOTE = "install @neplex/vectorizer to check vector art";
|
|
15
|
+
/** Images traced at once. */
|
|
16
|
+
export const VECTOR_CONCURRENCY = 4;
|
|
17
|
+
/** The folder of the traced SVGs, under the scan's folder. */
|
|
18
|
+
export const VECTOR_SVG_DIR = "svg";
|
|
19
|
+
/** Bumped when the trace or the scoring changes (input size, render, flat rule), so the cached numbers of the old ones are not read. */
|
|
20
|
+
const TRACE_VERSION = "poster-3";
|
|
21
|
+
const TIMED_OUT = Symbol("timed out");
|
|
22
|
+
/**
|
|
23
|
+
* The served bytes of the flat images, as the browser got them, for the vector check: the check traces the file the page
|
|
24
|
+
* used, not a fresh download. Bounded: it keeps the `capacity` largest files seen (the ones the budget would trace), so
|
|
25
|
+
* the memory is the budget times a file, whatever the site has. A URL it does not keep is fetched instead.
|
|
26
|
+
*/
|
|
27
|
+
export class FlatBuffers {
|
|
28
|
+
capacity;
|
|
29
|
+
entries = new Map();
|
|
30
|
+
constructor(capacity) {
|
|
31
|
+
this.capacity = capacity;
|
|
32
|
+
}
|
|
33
|
+
/** Keeps `buffer` for `url` when it is among the `capacity` largest seen so far. */
|
|
34
|
+
offer(url, buffer, bytes) {
|
|
35
|
+
if (this.capacity <= 0 || buffer === undefined || this.entries.has(url))
|
|
36
|
+
return;
|
|
37
|
+
if (this.entries.size < this.capacity) {
|
|
38
|
+
this.entries.set(url, { buffer, bytes });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let smallest;
|
|
42
|
+
for (const [key, entry] of this.entries) {
|
|
43
|
+
if (smallest === undefined || entry.bytes < (this.entries.get(smallest)?.bytes ?? Infinity))
|
|
44
|
+
smallest = key;
|
|
45
|
+
}
|
|
46
|
+
if (smallest !== undefined && bytes > (this.entries.get(smallest)?.bytes ?? 0)) {
|
|
47
|
+
this.entries.delete(smallest);
|
|
48
|
+
this.entries.set(url, { buffer, bytes });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
get(url) {
|
|
52
|
+
return this.entries.get(url)?.buffer;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The bytes per page view of an image: the mean over its viewports. */
|
|
56
|
+
export function perViewBytesOf(record) {
|
|
57
|
+
return record.perViewport.reduce((sum, p) => sum + p.servedBytes, 0) / Math.max(1, record.perViewport.length);
|
|
58
|
+
}
|
|
59
|
+
/** The flat images of the pages, one per URL, heaviest first. A record without a URL cannot be traced and is left out. */
|
|
60
|
+
function candidatesOf(pages) {
|
|
61
|
+
const byUrl = new Map();
|
|
62
|
+
for (const page of pages) {
|
|
63
|
+
for (const record of page.images ?? []) {
|
|
64
|
+
if (record.fix.vector === undefined || record.current.src === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
const url = record.current.src;
|
|
67
|
+
const perView = perViewBytesOf(record);
|
|
68
|
+
const known = byUrl.get(url);
|
|
69
|
+
if (known === undefined || perView > known.perView)
|
|
70
|
+
byUrl.set(url, { url, id: known?.id ?? record.id, perView });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...byUrl.values()].sort((a, b) => b.perView - a.perView || a.url.localeCompare(b.url));
|
|
74
|
+
}
|
|
75
|
+
/** Runs the vector check over the flat images of the pages (see the file comment). */
|
|
76
|
+
export async function runVectorPhase(pages, opts, deps) {
|
|
77
|
+
const byUrl = new Map();
|
|
78
|
+
const candidates = candidatesOf(pages);
|
|
79
|
+
if (candidates.length === 0)
|
|
80
|
+
return { byUrl };
|
|
81
|
+
const skip = opts.survey ? "survey" : !opts.quality ? "quality off" : opts.budget === 0 ? "svg-budget 0" : undefined;
|
|
82
|
+
if (skip !== undefined) {
|
|
83
|
+
for (const c of candidates)
|
|
84
|
+
byUrl.set(c.url, { notChecked: skip });
|
|
85
|
+
return { byUrl };
|
|
86
|
+
}
|
|
87
|
+
if ((await loadVectorizer()) === undefined) {
|
|
88
|
+
for (const c of candidates)
|
|
89
|
+
byUrl.set(c.url, { notChecked: "vectorizer not installed" });
|
|
90
|
+
return { byUrl, note: VECTOR_NOTE };
|
|
91
|
+
}
|
|
92
|
+
const chosen = candidates.slice(0, opts.budget);
|
|
93
|
+
for (const c of candidates.slice(opts.budget))
|
|
94
|
+
byUrl.set(c.url, { notChecked: "over --svg-budget" });
|
|
95
|
+
opts.log(`vector: tracing ${chosen.length} of ${candidates.length} flat image(s) (--svg-budget ${opts.budget})`);
|
|
96
|
+
const traced = await mapLimit(chosen, VECTOR_CONCURRENCY, async (c) => [c.url, await traceOne(c.url, c.id, opts, deps)]);
|
|
97
|
+
for (const [url, outcome] of traced)
|
|
98
|
+
byUrl.set(url, outcome);
|
|
99
|
+
const passed = traced.filter(([, o]) => o.passed === true).length;
|
|
100
|
+
opts.log(`vector: ${passed} of ${traced.length} traced image(s) pass at SSIMULACRA2 ${VECTOR_BAR} (half size)`);
|
|
101
|
+
return { byUrl };
|
|
102
|
+
}
|
|
103
|
+
/** One URL: its numbers from the cache, or traced and scored within VECTOR_CAP_MS. Never throws. */
|
|
104
|
+
async function traceOne(url, id, opts, deps) {
|
|
105
|
+
const buffer = await deps.fetch(url).catch(() => undefined);
|
|
106
|
+
if (buffer === undefined)
|
|
107
|
+
return { notChecked: "trace failed" };
|
|
108
|
+
const key = `vector|${CACHE_VERSION}|${contentHash(buffer)}|${VECTORIZER_VERSION}|${TRACE_VERSION}|bar${VECTOR_BAR}`;
|
|
109
|
+
const cached = await opts.cache?.getJson(key);
|
|
110
|
+
let numbers = isCachedNumbers(cached) ? cached : undefined;
|
|
111
|
+
let svg;
|
|
112
|
+
if (numbers === undefined) {
|
|
113
|
+
// The cap aborts the trace itself (the vectorizer stops, and stops using CPU); the score is dropped with it.
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const traced = await within(measure("vector: trace and score", () => measureOne(buffer, controller.signal)), VECTOR_CAP_MS, controller);
|
|
116
|
+
if (traced === TIMED_OUT)
|
|
117
|
+
return { notChecked: "timeout" };
|
|
118
|
+
if (traced === undefined)
|
|
119
|
+
return { notChecked: "trace failed" };
|
|
120
|
+
numbers = traced.numbers;
|
|
121
|
+
svg = traced.svg;
|
|
122
|
+
await opts.cache?.putJson(key, numbers);
|
|
123
|
+
}
|
|
124
|
+
else if (numbers.passed) {
|
|
125
|
+
// The numbers say it passes, but its file is written from a trace: a trace is cheap, the score is not repeated.
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const traced = await within(traceSvg(buffer, controller.signal), VECTOR_CAP_MS, controller);
|
|
128
|
+
if (traced === TIMED_OUT)
|
|
129
|
+
return { notChecked: "timeout" };
|
|
130
|
+
if (traced === undefined)
|
|
131
|
+
return { notChecked: "trace failed" };
|
|
132
|
+
svg = traced;
|
|
133
|
+
}
|
|
134
|
+
const outcome = { svgBytes: numbers.svgBytes, score: numbers.score, badPixelShare: numbers.badPixelShare, passed: numbers.passed };
|
|
135
|
+
if (!numbers.passed)
|
|
136
|
+
return outcome;
|
|
137
|
+
if (svg === undefined)
|
|
138
|
+
return { notChecked: "trace failed" };
|
|
139
|
+
try {
|
|
140
|
+
const dir = join(opts.outDir, VECTOR_SVG_DIR);
|
|
141
|
+
await mkdir(dir, { recursive: true });
|
|
142
|
+
const file = join(dir, `${id}.svg`);
|
|
143
|
+
await writeFileAtomic(file, svg, { root: opts.outDir });
|
|
144
|
+
// Relative to the project folder when the file is inside it; absolute otherwise (a scan into another folder).
|
|
145
|
+
const named = relative(opts.cwd, file);
|
|
146
|
+
return { ...outcome, tracedFile: named.startsWith("..") ? file : named };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { notChecked: "trace failed" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** The trace of an image and its numbers: the SVG, its brotli size, and the fidelity against the served bytes. */
|
|
153
|
+
async function measureOne(buffer, signal) {
|
|
154
|
+
const svg = await traceSvg(buffer, signal);
|
|
155
|
+
if (svg === undefined || signal.aborted)
|
|
156
|
+
return undefined;
|
|
157
|
+
const bytes = svgBytes(svg);
|
|
158
|
+
const fidelity = await svgFidelity(buffer, svg);
|
|
159
|
+
const passed = fidelity.score >= VECTOR_BAR && bytes <= buffer.length * (1 - VECTOR_MIN_SAVING);
|
|
160
|
+
return { svg, numbers: { svgBytes: bytes, score: Math.round(fidelity.score * 10) / 10, badPixelShare: fidelity.badPixelShare, passed } };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The value, or TIMED_OUT when it is not ready in `ms` (the controller is then aborted, so the work stops), or undefined when
|
|
164
|
+
* it fails.
|
|
165
|
+
*/
|
|
166
|
+
async function within(work, ms, controller) {
|
|
167
|
+
let timer;
|
|
168
|
+
const timeout = new Promise((resolve) => {
|
|
169
|
+
timer = setTimeout(() => {
|
|
170
|
+
controller.abort();
|
|
171
|
+
resolve(TIMED_OUT);
|
|
172
|
+
}, ms);
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
return await Promise.race([work.catch(() => undefined), timeout]);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isCachedNumbers(value) {
|
|
182
|
+
if (typeof value !== "object" || value === null)
|
|
183
|
+
return false;
|
|
184
|
+
const v = value;
|
|
185
|
+
return (Number.isFinite(v.svgBytes) &&
|
|
186
|
+
Number.isFinite(v.score) &&
|
|
187
|
+
Number.isFinite(v.badPixelShare) &&
|
|
188
|
+
typeof v.passed === "boolean");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A page with the phase's outcomes written into its flat images' fix.vector. The saving of a passing SVG is the served bytes
|
|
192
|
+
* per view minus the SVG's, averaged over the page's viewports (as the other savings are). The record's claimed saving is
|
|
193
|
+
* the larger of the raster fix and the SVG; the SVG wins only when it is larger, and the raster fix stays as the fallback.
|
|
194
|
+
*/
|
|
195
|
+
export function applyVector(page, run) {
|
|
196
|
+
const flat = (page.images ?? []).some((record) => record.fix.vector !== undefined);
|
|
197
|
+
if (!flat)
|
|
198
|
+
return page;
|
|
199
|
+
const viewportCount = Math.max(1, page.viewports.length);
|
|
200
|
+
const images = (page.images ?? []).map((record) => {
|
|
201
|
+
const vector = record.fix.vector;
|
|
202
|
+
if (vector === undefined)
|
|
203
|
+
return record;
|
|
204
|
+
const outcome = record.current.src !== undefined ? run.byUrl.get(record.current.src) : undefined;
|
|
205
|
+
const next = { ...vector, ...(outcome ?? { notChecked: "trace failed" }) };
|
|
206
|
+
if (outcome?.passed === true && outcome.svgBytes !== undefined) {
|
|
207
|
+
const svg = outcome.svgBytes;
|
|
208
|
+
const saved = record.perViewport.reduce((sum, p) => sum + Math.max(0, p.servedBytes - svg), 0) / viewportCount;
|
|
209
|
+
next.savedPerView = Math.round(saved);
|
|
210
|
+
if (saved > record.fix.bytes.saved) {
|
|
211
|
+
next.wins = true;
|
|
212
|
+
return {
|
|
213
|
+
...record,
|
|
214
|
+
fix: { ...record.fix, vector: next, bytes: { saved: Math.round(saved), addedForSharpness: record.fix.bytes.addedForSharpness } },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { ...record, fix: { ...record.fix, vector: next } };
|
|
219
|
+
});
|
|
220
|
+
const hasNote = images.some((record) => record.fix.vector !== undefined);
|
|
221
|
+
return { ...page, images, ...(run.note !== undefined && hasNote ? { vectorNote: run.note } : {}) };
|
|
222
|
+
}
|
package/dist/video-analysis.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// so it is added to the page's images here, before the image pipeline runs.
|
|
5
5
|
import { sha1 } from "./atomic.js";
|
|
6
6
|
import { COMPONENT_NAME, sanitizeText } from "./locate.js";
|
|
7
|
+
import { hiddenFixCore } from "./images.js";
|
|
7
8
|
import { adapterFor, formatAutoOf } from "./loaders.js";
|
|
8
9
|
import { clusterVideoWidths, videoBytes, widthOptions, } from "./media-model.js";
|
|
9
10
|
/** Widths kept per video in scan mode (`--max-video-widths`, before the flag is wired). */
|
|
@@ -163,11 +164,65 @@ function hasAv1(video) {
|
|
|
163
164
|
const urls = [video.currentSrc, ...video.sources.map((s) => s.src)];
|
|
164
165
|
return urls.some((u) => /\.av1\.mp4(?:[?#]|$)/i.test(u));
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The fresh load's media bytes behind each observation. A resized viewport shares its group's page load, so its media map
|
|
169
|
+
* holds the bytes of every earlier step too. A hidden copy's bytes at a resized viewport are taken from the fresh load's map
|
|
170
|
+
* (see hiddenBytesOf), so a file fetched only for another viewport is not counted there.
|
|
171
|
+
*/
|
|
172
|
+
function freshMediaOf(observations) {
|
|
173
|
+
const fresh = new Map();
|
|
174
|
+
let current;
|
|
175
|
+
for (const obs of observations) {
|
|
176
|
+
if (obs.load === "fresh" || current === undefined)
|
|
177
|
+
current = obs;
|
|
178
|
+
fresh.set(obs, current);
|
|
179
|
+
}
|
|
180
|
+
return fresh;
|
|
181
|
+
}
|
|
182
|
+
/** The poster URL of a video that is a file the page can fetch (not a data: URI), or undefined. */
|
|
183
|
+
function fetchablePosterOf(video) {
|
|
184
|
+
return video.poster !== undefined && !video.poster.startsWith("data:") ? video.poster : undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The bytes a hidden copy costs per view at its viewport: the file the browser fetched for it (the same preload rules as a
|
|
188
|
+
* shown video, see videoBytes), plus its poster when the poster was fetched in the fresh load and no shown image of the
|
|
189
|
+
* viewport uses that URL. Zero when the copy's file is also shown at the viewport (one fetch, counted there), or when a
|
|
190
|
+
* resized viewport's copy does not fetch on load (its bytes are not the fresh load's).
|
|
191
|
+
*/
|
|
192
|
+
function hiddenBytesOf(member, shownUrls, freshOf) {
|
|
193
|
+
const { obs, video } = member;
|
|
194
|
+
const url = video.currentSrc;
|
|
195
|
+
if (shownUrls.has(url))
|
|
196
|
+
return 0;
|
|
197
|
+
const kind = preloadKindOf(video);
|
|
198
|
+
const fresh = freshOf.get(obs) ?? obs;
|
|
199
|
+
// A copy that does not fetch on load (preload none or metadata) has nothing of the page load to count at a resized viewport.
|
|
200
|
+
const fetchesOnLoad = kind === "autoplay" || kind === "auto" || kind === "default";
|
|
201
|
+
if (obs.load !== "fresh" && !fetchesOnLoad)
|
|
202
|
+
return 0;
|
|
203
|
+
const observed = observedBytesOf(obs.load === "fresh" ? obs : fresh, url);
|
|
204
|
+
const bytes = videoBytes({
|
|
205
|
+
kind,
|
|
206
|
+
playing: !video.paused || video.errorCode === 4,
|
|
207
|
+
observedBytes: observed,
|
|
208
|
+
fullBytes: observed,
|
|
209
|
+
visible: true,
|
|
210
|
+
}).perView;
|
|
211
|
+
const poster = fetchablePosterOf(video);
|
|
212
|
+
const posterBytes = poster !== undefined && obs.load === "fresh" && !obs.images.some((img) => img.currentSrc === poster && img.hidden === undefined)
|
|
213
|
+
? (obs.responses.get(poster)?.bytes ?? 0)
|
|
214
|
+
: 0;
|
|
215
|
+
return bytes + posterBytes;
|
|
216
|
+
}
|
|
166
217
|
/**
|
|
167
218
|
* The VideoRecords of a page, from its observations. Each `<video>` is one record across the viewports it appears in.
|
|
168
219
|
*
|
|
169
|
-
* Bytes: the record takes the viewport where
|
|
170
|
-
* `fullBytes` (see fullBytesOf) and is shown per play for every video.
|
|
220
|
+
* Bytes: the claim is the shown copies only. The record takes the viewport where a shown copy costs most per view, so the
|
|
221
|
+
* claim is never low. The full file is `fullBytes` (see fullBytesOf) and is shown per play for every video.
|
|
222
|
+
*
|
|
223
|
+
* Hidden: a copy that is not shown at a viewport (see ObservedVideo.hidden) is a loaded-but-not-shown fix when the browser
|
|
224
|
+
* fetched bytes for it (hiddenBytesOf). Its bytes are the fix's, apart from the claim. A video shown at no viewport is never
|
|
225
|
+
* shown on load: it has no width, no levers and no claim.
|
|
171
226
|
*
|
|
172
227
|
* Widths: the viewports the video is shown in (hidden ones are left out) are clustered to at most `maxWidths` widths in
|
|
173
228
|
* scan mode, and the plans for one, two and three widths are kept in audit mode, where the agent chooses.
|
|
@@ -179,21 +234,63 @@ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, s
|
|
|
179
234
|
const records = [];
|
|
180
235
|
// The records of each file so far: a file's videos are told apart by their document order (see videoIdOf).
|
|
181
236
|
const perFile = new Map();
|
|
237
|
+
const freshOf = freshMediaOf(observations);
|
|
238
|
+
// The file each viewport shows in full, over every video of the page: a hidden copy of one of them adds no bytes there.
|
|
239
|
+
const shownAt = new Map();
|
|
240
|
+
for (const obs of observations) {
|
|
241
|
+
const urls = shownAt.get(obs.viewport.name) ?? new Set();
|
|
242
|
+
for (const video of obs.videos ?? [])
|
|
243
|
+
if (!video.hidden)
|
|
244
|
+
urls.add(video.currentSrc);
|
|
245
|
+
shownAt.set(obs.viewport.name, urls);
|
|
246
|
+
}
|
|
182
247
|
for (const [key, members] of groupVideos(observations)) {
|
|
183
|
-
const
|
|
248
|
+
const shown = members.filter((m) => !m.video.hidden);
|
|
249
|
+
// The record's own file and attributes come from a shown copy when there is one.
|
|
250
|
+
const ref = (shown[0] ?? members[0]).video;
|
|
184
251
|
const source = sources?.get(ref);
|
|
185
252
|
const src = ref.currentSrc;
|
|
186
253
|
const observedMax = Math.max(0, ...members.map((m) => observedBytesOf(m.obs, m.video.currentSrc)));
|
|
187
254
|
const full = fullBytes.get(src) ?? observedMax;
|
|
188
|
-
|
|
255
|
+
// The claim: the shown copies. A video shown nowhere costs nothing in the claim.
|
|
256
|
+
const views = shown.map((m) => videoBytes({
|
|
189
257
|
kind: preloadKindOf(m.video),
|
|
190
258
|
playing: !m.video.paused || m.video.errorCode === 4,
|
|
191
259
|
observedBytes: observedBytesOf(m.obs, m.video.currentSrc),
|
|
192
260
|
fullBytes: full,
|
|
193
|
-
visible:
|
|
261
|
+
visible: true,
|
|
194
262
|
}));
|
|
195
|
-
const
|
|
196
|
-
const
|
|
263
|
+
const none = videoBytes({ kind: "none", playing: false, observedBytes: 0, fullBytes: full, visible: false });
|
|
264
|
+
const heaviest = views.length > 0 ? views.reduce((best, cur) => (cur.perView > best.perView ? cur : best)) : none;
|
|
265
|
+
// The claim is per viewport, averaged over the viewports (as an image's is): each viewport loads its own shown copy, so a
|
|
266
|
+
// twin pair counts once, not twice. Bounded by the file's own length (perPlay), as the single-copy figure is.
|
|
267
|
+
const shownBytesAt = new Map();
|
|
268
|
+
shown.forEach((m, i) => shownBytesAt.set(m, views[i].perView));
|
|
269
|
+
const claimPerView = Math.min(heaviest.perPlay, observations.length > 0 ? views.reduce((sum, v) => sum + v.perView, 0) / observations.length : 0);
|
|
270
|
+
// The hidden fix: one entry per viewport the video is in, shown or hidden. A hidden copy with no bytes is not reported.
|
|
271
|
+
const entries = [];
|
|
272
|
+
const copies = [];
|
|
273
|
+
for (const m of members) {
|
|
274
|
+
const viewport = { name: m.obs.viewport.name, width: m.obs.viewport.width };
|
|
275
|
+
if (!m.video.hidden) {
|
|
276
|
+
entries.push({ viewport, bytes: 0, inPanel: false, hover: false });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const bytes = hiddenBytesOf(m, shownAt.get(m.obs.viewport.name) ?? new Set(), freshOf);
|
|
280
|
+
shownBytesAt.set(m, bytes);
|
|
281
|
+
if (bytes <= 0)
|
|
282
|
+
continue;
|
|
283
|
+
const reason = m.video.hiddenReason ?? "display";
|
|
284
|
+
entries.push({ viewport, bytes, hidden: reason, inPanel: m.video.inPanel === true, hover: m.video.hover === true, unstable: m.video.unstable === "unknown" });
|
|
285
|
+
copies.push({ viewport: viewport.name, src: m.video.currentSrc, bytes, reason });
|
|
286
|
+
}
|
|
287
|
+
const hiddenBase = hiddenFixCore(entries, observations.length);
|
|
288
|
+
const hidden = hiddenBase !== undefined ? { ...hiddenBase, copies } : undefined;
|
|
289
|
+
const shownNow = shown.length > 0;
|
|
290
|
+
const info = [];
|
|
291
|
+
const handled = formatHandledNote(src, page);
|
|
292
|
+
if (handled !== undefined)
|
|
293
|
+
info.push(handled);
|
|
197
294
|
const knownWidth = shown.map((m) => m.video.videoWidth).find((w) => w !== undefined && w > 0);
|
|
198
295
|
const needs = shown.map((m) => ({
|
|
199
296
|
viewport: m.obs.viewport.name,
|
|
@@ -201,26 +298,24 @@ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, s
|
|
|
201
298
|
boxWidth: m.video.renderedWidth,
|
|
202
299
|
dpr: m.obs.viewport.dpr,
|
|
203
300
|
}));
|
|
204
|
-
const info = [];
|
|
205
|
-
const handled = formatHandledNote(src, page);
|
|
206
|
-
if (handled !== undefined)
|
|
207
|
-
info.push(handled);
|
|
208
301
|
let sourceWidth = knownWidth;
|
|
209
302
|
if (sourceWidth === undefined) {
|
|
210
303
|
// Without the source width the widths are capped at the largest display need, which is all the page shows.
|
|
211
304
|
sourceWidth = Math.max(1, ...shown.map((m) => Math.ceil(m.video.renderedWidth * m.obs.viewport.dpr)));
|
|
212
|
-
if (
|
|
305
|
+
if (shownNow)
|
|
213
306
|
info.push("source width not read: widths are capped at the largest display need");
|
|
214
307
|
}
|
|
215
|
-
const belowFold =
|
|
216
|
-
const levers = { info };
|
|
217
|
-
if (
|
|
218
|
-
|
|
308
|
+
const belowFold = shown.every((m) => !m.video.inFirstViewport);
|
|
309
|
+
const levers = { info: shownNow ? info : [] };
|
|
310
|
+
if (shownNow) {
|
|
311
|
+
if (claimPerView > 0 && (belowFold || ref.preload === "auto")) {
|
|
312
|
+
levers.loading = { perViewBytes: claimPerView, belowFold };
|
|
313
|
+
}
|
|
314
|
+
if (handled === undefined && !hasAv1(ref))
|
|
315
|
+
levers.codec = { missing: "av1" };
|
|
316
|
+
if (ref.poster === undefined)
|
|
317
|
+
levers.posterMissing = true;
|
|
219
318
|
}
|
|
220
|
-
if (handled === undefined && !hasAv1(ref))
|
|
221
|
-
levers.codec = { missing: "av1" };
|
|
222
|
-
if (ref.poster === undefined)
|
|
223
|
-
levers.posterMissing = true;
|
|
224
319
|
const locator = {
|
|
225
320
|
component: source?.component.length ? source.component : componentsOf(ref.locator.owners),
|
|
226
321
|
selector: sanitizeText(ref.locator.selector, 300),
|
|
@@ -254,14 +349,17 @@ export function videoRecordsOf(page, observations, fullBytes, mode, maxWidths, s
|
|
|
254
349
|
preload: ref.preload,
|
|
255
350
|
...(ref.poster !== undefined ? { poster: ref.poster } : {}),
|
|
256
351
|
},
|
|
257
|
-
bytes: { ...heaviest, fullBytesKnown: false },
|
|
352
|
+
bytes: { ...heaviest, perView: claimPerView, fullBytesKnown: false },
|
|
258
353
|
perViewport: members.map((m) => ({
|
|
259
354
|
viewport: m.obs.viewport.name,
|
|
260
355
|
boxWidth: m.video.renderedWidth,
|
|
261
356
|
dpr: m.obs.viewport.dpr,
|
|
262
357
|
inFirstViewport: m.video.inFirstViewport,
|
|
358
|
+
...(m.video.hidden ? { hidden: m.video.hiddenReason ?? "display" } : {}),
|
|
359
|
+
bytes: shownBytesAt.get(m) ?? 0,
|
|
263
360
|
})),
|
|
264
361
|
levers,
|
|
362
|
+
...(hidden !== undefined ? { hidden } : {}),
|
|
265
363
|
};
|
|
266
364
|
if (mode === "audit")
|
|
267
365
|
record.widthOptions = widthOptions(needs, sourceWidth);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imgtune",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Finds the
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Finds the wasted bytes on live pages (images, video, hidden downloads, fonts, compression, 3D and audio) and gives the fix for each, checked with SSIMULACRA2 and VMAF. Image and video fixes name the file and line when they resolve.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "indicozy",
|
|
7
7
|
"keywords": [
|
|
@@ -18,7 +18,18 @@
|
|
|
18
18
|
"cli",
|
|
19
19
|
"next/image",
|
|
20
20
|
"sizes",
|
|
21
|
-
"lcp"
|
|
21
|
+
"lcp",
|
|
22
|
+
"page-weight",
|
|
23
|
+
"web-performance",
|
|
24
|
+
"video",
|
|
25
|
+
"fonts",
|
|
26
|
+
"woff2",
|
|
27
|
+
"compression",
|
|
28
|
+
"brotli",
|
|
29
|
+
"lottie",
|
|
30
|
+
"svg",
|
|
31
|
+
"gltf",
|
|
32
|
+
"lazy-loading"
|
|
22
33
|
],
|
|
23
34
|
"homepage": "https://indicozy.github.io/imgtune/",
|
|
24
35
|
"repository": {
|
|
@@ -62,11 +73,21 @@
|
|
|
62
73
|
"packageManager": "pnpm@10.8.0",
|
|
63
74
|
"dependencies": {
|
|
64
75
|
"playwright": "^1.64.1",
|
|
65
|
-
"sharp": "^0.35.5"
|
|
76
|
+
"sharp": "^0.35.5",
|
|
77
|
+
"subset-font": "^2.9.0",
|
|
78
|
+
"svgo": "^4.1.0",
|
|
79
|
+
"wawoff2": "^2.0.1"
|
|
66
80
|
},
|
|
67
81
|
"devDependencies": {
|
|
68
82
|
"@types/node": "^26.6.5",
|
|
69
83
|
"typescript": "^5.9.3",
|
|
70
84
|
"vitest": "^5.0.5"
|
|
85
|
+
},
|
|
86
|
+
"optionalDependencies": {
|
|
87
|
+
"@neplex/vectorizer": "0.1.0",
|
|
88
|
+
"@gltf-transform/core": "^4.5.1",
|
|
89
|
+
"@gltf-transform/extensions": "^4.5.1",
|
|
90
|
+
"@gltf-transform/functions": "^4.5.1",
|
|
91
|
+
"meshoptimizer": "^1.3.0"
|
|
71
92
|
}
|
|
72
93
|
}
|
package/skills/imgtune/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: imgtune
|
|
3
|
-
description:
|
|
3
|
+
description: Cut page weight and fix slow pages in any web framework. Use when asked to optimise or optimize images or video, cut page weight, improve LCP, fix image sizes or next/image sizes, shrink fonts or subset them, find files served without compression, lazy-load hidden images or videos, or shrink 3D models, audio, Lottie or SVG. imgtune measures the live page at six viewports and gives per-item fix hints with the expected bytes and the component and line that renders each one, which you apply in the source.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# imgtune
|
|
@@ -11,9 +11,13 @@ format) with two byte numbers. Run `npx imgtune --help`, or `<command> --help` f
|
|
|
11
11
|
## When to use
|
|
12
12
|
|
|
13
13
|
The user asks to cut image weight or improve LCP; Lighthouse flags images as oversized, badly encoded or old-format; or
|
|
14
|
-
pages are heavy and the images are most of the bytes.
|
|
14
|
+
pages are heavy and the images are most of the bytes. It also covers video, hidden downloads, fonts, compression, 3D, audio, Lottie, SVG and favicons (below). It does not edit JavaScript or CSS: those get only the compression check. Do not use it on sites the user
|
|
15
15
|
does not own or run, unless they say they are authorised.
|
|
16
16
|
|
|
17
|
+
## Other page weight
|
|
18
|
+
|
|
19
|
+
`resources` in each scan JSON lists the findings for compression, favicons, fonts, SVG, Lottie, 3D models and audio, with their own bytes; they are not in the image totals. Images and videos the browser downloads but no viewport shows are in the hidden group (`images[].hidden`, `videos[].hidden`): their fix is lazy loading, a responsive `<picture>`, or removal, not a size change. `npx imgtune hints <scan.json> --format json` gives the resource findings as a top-level `resources` array, each with its page.
|
|
20
|
+
|
|
17
21
|
## Fix the images
|
|
18
22
|
|
|
19
23
|
Use this when the user asks to fix image sizes, next/image sizes, or the LCP image, or after a scan has hints to apply.
|
|
@@ -21,8 +25,8 @@ Use this when the user asks to fix image sizes, next/image sizes, or the LCP ima
|
|
|
21
25
|
1. **Scan** the pages on a built server into a folder: `npx imgtune scan <url> [<url> ...] --out .imgtune/scan`.
|
|
22
26
|
2. **Read the hints in batches**: `npx imgtune hints .imgtune/scan/*.json --limit 10`. The LCP image comes first, and its line
|
|
23
27
|
starts with `LCP image`. Take it first in every batch.
|
|
24
|
-
3. **Open each hint's file and line**, check that it renders that image, then apply the hint's `sizes` (or
|
|
25
|
-
|
|
28
|
+
3. **Open each hint's file and line**, check that it renders that image, then apply the hint's `sizes` (or, for a plain
|
|
29
|
+
`<img>` with no srcset, its `srcset` variants and the `sizes`), its quality and its format, as `reference.md` says. A hint marked `(unverified)`, or with no file and line,
|
|
26
30
|
is not edited: say so in the report.
|
|
27
31
|
4. **Apply the site-level `next.config: images.qualities: [...]` line once**, in `next.config`, and keep it as printed. Never
|
|
28
32
|
lower a quality below the hint's target.
|
|
@@ -336,4 +336,4 @@ failed, skipped and unmatched usages).
|
|
|
336
336
|
## Top call sites
|
|
337
337
|
|
|
338
338
|
`imgtune hints <scan.json> --top 10` prints the ten call sites with the most saving per view, one line each (where, the
|
|
339
|
-
images and pages, the saving split into sizes and quality). Several scan files group across pages.
|
|
339
|
+
images and pages, the saving split into sizes, resize and quality). Several scan files group across pages.
|