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,122 @@
|
|
|
1
|
+
// Transfer compression: text-like responses served with no Content-Encoding. The saving is the body's length minus its
|
|
2
|
+
// brotli-11 length (a real encode; a body over SAMPLE_BYTES is encoded in part and extrapolated, labelled an estimate).
|
|
3
|
+
// Findings are grouped per host and content type: one server or CDN setting covers the group.
|
|
4
|
+
import { brotliCompressSync, constants } from "node:zlib";
|
|
5
|
+
import { digestOf, heavyValue } from "./cache.js";
|
|
6
|
+
/** Responses smaller than this are not worth a setting: the saving is below a packet. */
|
|
7
|
+
export const MIN_BYTES = 1024;
|
|
8
|
+
/** A body longer than this is brotli-encoded on its first SAMPLE_BYTES only, and the rest is extrapolated. */
|
|
9
|
+
export const SAMPLE_BYTES = 1024 * 1024;
|
|
10
|
+
/** A group saves less than this in total: no finding. */
|
|
11
|
+
const MIN_SAVED = 1024;
|
|
12
|
+
const MAX_URLS = 10;
|
|
13
|
+
/**
|
|
14
|
+
* The brotli size of a text body as a share of its size, for a file whose body the scan did not keep (a body still streaming at
|
|
15
|
+
* the settle, or over the refetch cap). A typical minified script, style sheet or document lands near this; it is an estimate.
|
|
16
|
+
*/
|
|
17
|
+
export const TYPICAL_BROTLI_RATIO = 0.3;
|
|
18
|
+
const TEXT_APPLICATION = /^application\/(javascript|x-javascript|ecmascript|json|ld\+json|manifest\+json|xml|wasm|x-font-ttf|font-sfnt|x-font-opentype)$/;
|
|
19
|
+
/** Whether a content type is text-like (compressible): text/*, scripts, JSON, XML, SVG, WASM, TTF and OTF fonts. */
|
|
20
|
+
export function isTextLike(contentType) {
|
|
21
|
+
return contentType.startsWith("text/") || contentType === "image/svg+xml" || TEXT_APPLICATION.test(contentType) || contentType === "font/ttf" || contentType === "font/otf" || contentType === "font/sfnt";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The brotli-11 size of a body: exact when short, else the sample's ratio applied to the whole (`estimate` true). Over the
|
|
25
|
+
* run's resource budget it is a quick brotli (quality 4) of the same bytes, also an estimate.
|
|
26
|
+
*/
|
|
27
|
+
export async function brotliSizeOf(body) {
|
|
28
|
+
const key = `compression:brotli11:${digestOf(body)}`;
|
|
29
|
+
const measured = await heavyValue(key, async () => {
|
|
30
|
+
const sampled = body.length > SAMPLE_BYTES;
|
|
31
|
+
const part = sampled ? body.subarray(0, SAMPLE_BYTES) : body;
|
|
32
|
+
const out = brotliCompressSync(part, {
|
|
33
|
+
params: {
|
|
34
|
+
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
|
|
35
|
+
[constants.BROTLI_PARAM_SIZE_HINT]: part.length,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const bytes = sampled ? Math.round((out.length / part.length) * body.length) : out.length;
|
|
39
|
+
return { bytes, estimate: sampled };
|
|
40
|
+
});
|
|
41
|
+
if (measured !== undefined)
|
|
42
|
+
return measured;
|
|
43
|
+
const part = body.length > SAMPLE_BYTES ? body.subarray(0, SAMPLE_BYTES) : body;
|
|
44
|
+
const quick = brotliCompressSync(part, { params: { [constants.BROTLI_PARAM_QUALITY]: 4, [constants.BROTLI_PARAM_SIZE_HINT]: part.length } });
|
|
45
|
+
return { bytes: Math.round((quick.length / part.length) * body.length), estimate: true };
|
|
46
|
+
}
|
|
47
|
+
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
|
|
48
|
+
function hostOf(url) {
|
|
49
|
+
try {
|
|
50
|
+
return new URL(url).host;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "unknown host";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The cheap filter: an uncompressed text-like response of at least MIN_BYTES, successful, under the body cap. */
|
|
57
|
+
export function wantsRecord(r) {
|
|
58
|
+
return r.contentEncoding === undefined && r.status >= 200 && r.status < 300 && isTextLike(r.contentType) && r.bytes >= MIN_BYTES && r.bytes <= 20 * 1024 * 1024;
|
|
59
|
+
}
|
|
60
|
+
export const analyzer = {
|
|
61
|
+
kind: "compression",
|
|
62
|
+
wants: wantsRecord,
|
|
63
|
+
async analyze(records) {
|
|
64
|
+
try {
|
|
65
|
+
const members = [];
|
|
66
|
+
for (const r of records) {
|
|
67
|
+
if (!wantsRecord(r))
|
|
68
|
+
continue;
|
|
69
|
+
if (r.body === undefined) {
|
|
70
|
+
// No body: the headers and the size decide the finding, and the brotli size is estimated (never claimed as measured).
|
|
71
|
+
const brotli = Math.round(r.bytes * TYPICAL_BROTLI_RATIO);
|
|
72
|
+
members.push({ url: r.url, host: hostOf(r.url), contentType: r.contentType, bytes: r.bytes, saved: Math.max(0, r.bytes - brotli), brotli, estimate: true });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const { bytes: brotli, estimate } = await brotliSizeOf(r.body);
|
|
76
|
+
const bytes = r.body.length;
|
|
77
|
+
members.push({ url: r.url, host: hostOf(r.url), contentType: r.contentType, bytes, saved: Math.max(0, bytes - brotli), brotli, estimate });
|
|
78
|
+
}
|
|
79
|
+
const groups = new Map();
|
|
80
|
+
for (const m of members) {
|
|
81
|
+
const key = `${m.host}\n${m.contentType}`;
|
|
82
|
+
groups.set(key, [...(groups.get(key) ?? []), m]);
|
|
83
|
+
}
|
|
84
|
+
const findings = [];
|
|
85
|
+
for (const [key, list] of groups) {
|
|
86
|
+
const saved = list.reduce((sum, m) => sum + m.saved, 0);
|
|
87
|
+
if (saved < MIN_SAVED)
|
|
88
|
+
continue;
|
|
89
|
+
const bytes = list.reduce((sum, m) => sum + m.bytes, 0);
|
|
90
|
+
const brotli = list.reduce((sum, m) => sum + m.brotli, 0);
|
|
91
|
+
const largest = [...list].sort((a, b) => b.bytes - a.bytes);
|
|
92
|
+
const { host, contentType } = largest[0];
|
|
93
|
+
const estimate = list.some((m) => m.estimate);
|
|
94
|
+
findings.push({
|
|
95
|
+
id: `compression:${digestOf(key).slice(0, 16)}`,
|
|
96
|
+
kind: "compression",
|
|
97
|
+
url: largest[0].url,
|
|
98
|
+
bytes,
|
|
99
|
+
savedBytes: saved,
|
|
100
|
+
estimate,
|
|
101
|
+
title: `${list.length} ${contentType} file(s) from ${host} sent uncompressed: ${kb(bytes)}, brotli would send ${kb(brotli)} (saves ~${kb(saved)})`,
|
|
102
|
+
fix: [
|
|
103
|
+
`enable brotli (or gzip) for ${contentType} responses from ${host} at the server or CDN (nginx: \`brotli on; brotli_types ${contentType};\` or \`gzip on; gzip_types ${contentType};\`)`,
|
|
104
|
+
`expected: ${kb(brotli)} per view for these files (brotli 11), from ${kb(bytes)}`,
|
|
105
|
+
],
|
|
106
|
+
details: {
|
|
107
|
+
host,
|
|
108
|
+
contentType,
|
|
109
|
+
files: list.length,
|
|
110
|
+
bytes,
|
|
111
|
+
brotliBytes: brotli,
|
|
112
|
+
urls: largest.slice(0, MAX_URLS).map((m) => m.url),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return findings.sort((a, b) => b.savedBytes - a.savedBytes);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Favicons and apple-touch-icons. A favicon over FAVICON_BYTES, or an apple-touch-icon over APPLE_BYTES, is flagged. The
|
|
2
|
+
// fix is a re-encode at the size the browser uses (32 px for a favicon, 180 px for apple-touch), and the saving is the
|
|
3
|
+
// real size of that re-encode (sharp, PNG). An ICO is read through its largest PNG frame. Favicons are fetched once per
|
|
4
|
+
// site, so every title says "per first visit".
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { cachedValue, digestOf } from "./cache.js";
|
|
7
|
+
export const FAVICON_BYTES = 10 * 1024;
|
|
8
|
+
export const APPLE_BYTES = 30 * 1024;
|
|
9
|
+
/** The largest icon response read at all (an icon this size is already the problem). */
|
|
10
|
+
export const ICON_READ_MAX = 1024 * 1024;
|
|
11
|
+
const FAVICON_PX = 32;
|
|
12
|
+
const APPLE_PX = 180;
|
|
13
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
14
|
+
const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
15
|
+
/** The icon roles the page declares, by absolute URL: apple-touch for an apple-touch-icon link, else favicon. */
|
|
16
|
+
export function roleOf(ctx, url) {
|
|
17
|
+
const link = ctx.icons.find((icon) => icon.href === url);
|
|
18
|
+
if (link !== undefined)
|
|
19
|
+
return /apple-touch/i.test(link.rel) ? "apple-touch" : "favicon";
|
|
20
|
+
if (/\/favicon\.ico$/i.test(new URL(url).pathname))
|
|
21
|
+
return "favicon";
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
/** The largest PNG frame of an ICO file, or undefined when it has none (BMP-only icons are not decoded). */
|
|
25
|
+
export function largestPngFrame(ico) {
|
|
26
|
+
if (ico.length < 6 || ico.readUInt16LE(0) !== 0 || ico.readUInt16LE(2) !== 1)
|
|
27
|
+
return undefined;
|
|
28
|
+
const count = ico.readUInt16LE(4);
|
|
29
|
+
let best;
|
|
30
|
+
for (let i = 0; i < count; i++) {
|
|
31
|
+
const at = 6 + i * 16;
|
|
32
|
+
if (at + 16 > ico.length)
|
|
33
|
+
break;
|
|
34
|
+
const w = ico[at] === 0 ? 256 : ico[at];
|
|
35
|
+
const h = ico[at + 1] === 0 ? 256 : ico[at + 1];
|
|
36
|
+
const size = ico.readUInt32LE(at + 8);
|
|
37
|
+
const offset = ico.readUInt32LE(at + 12);
|
|
38
|
+
if (offset + size > ico.length || size < 8)
|
|
39
|
+
continue;
|
|
40
|
+
const data = ico.subarray(offset, offset + size);
|
|
41
|
+
if (!data.subarray(0, 4).equals(PNG_MAGIC))
|
|
42
|
+
continue;
|
|
43
|
+
if (best === undefined || w * h > best.area)
|
|
44
|
+
best = { area: w * h, data };
|
|
45
|
+
}
|
|
46
|
+
return best?.data;
|
|
47
|
+
}
|
|
48
|
+
/** The image the browser decodes from an icon response: the PNG frame of an ICO, else the body itself. */
|
|
49
|
+
function decodableOf(record, body) {
|
|
50
|
+
const isIco = record.contentType === "image/x-icon" || record.contentType === "image/vnd.microsoft.icon" || body.subarray(0, 4).equals(Buffer.from([0, 0, 1, 0]));
|
|
51
|
+
if (!isIco)
|
|
52
|
+
return body;
|
|
53
|
+
return largestPngFrame(body);
|
|
54
|
+
}
|
|
55
|
+
/** The PNG of the icon at `px` square, and its width and height before (the source's size from the decoder). */
|
|
56
|
+
async function reencode(image, px) {
|
|
57
|
+
const key = `favicon:png:${px}:${digestOf(image)}`;
|
|
58
|
+
return cachedValue(key, async () => {
|
|
59
|
+
const meta = await sharp(image).metadata();
|
|
60
|
+
const out = await sharp(image).resize(px, px, { fit: "cover" }).png({ compressionLevel: 9 }).toBuffer();
|
|
61
|
+
return { bytes: out.length, width: px, height: px, sourceWidth: meta.width ?? 0, sourceHeight: meta.height ?? 0 };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export const wantsRecord = (r) => r.status >= 200 &&
|
|
65
|
+
r.status < 300 &&
|
|
66
|
+
r.contentType.startsWith("image/") &&
|
|
67
|
+
r.bytes <= ICON_READ_MAX &&
|
|
68
|
+
(r.contentType.includes("icon") || /favicon|apple-touch-icon/i.test(r.url) || /\.ico(?:[?#]|$)/i.test(r.url));
|
|
69
|
+
export const analyzer = {
|
|
70
|
+
kind: "favicon",
|
|
71
|
+
wants: wantsRecord,
|
|
72
|
+
async analyze(records, ctx) {
|
|
73
|
+
try {
|
|
74
|
+
const findings = [];
|
|
75
|
+
for (const r of records) {
|
|
76
|
+
if (r.body === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
const role = roleOf(ctx, r.url);
|
|
79
|
+
if (role === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
const threshold = role === "apple-touch" ? APPLE_BYTES : FAVICON_BYTES;
|
|
82
|
+
if (r.bytes <= threshold)
|
|
83
|
+
continue;
|
|
84
|
+
const px = role === "apple-touch" ? APPLE_PX : FAVICON_PX;
|
|
85
|
+
const label = role === "apple-touch" ? "apple-touch-icon" : "favicon";
|
|
86
|
+
const path = new URL(r.url).pathname;
|
|
87
|
+
const isSvg = r.contentType === "image/svg+xml";
|
|
88
|
+
const frame = isSvg ? undefined : decodableOf(r, r.body);
|
|
89
|
+
let measured;
|
|
90
|
+
if (frame !== undefined) {
|
|
91
|
+
try {
|
|
92
|
+
measured = await reencode(frame, px);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
measured = undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const source = measured !== undefined && measured.sourceWidth > 0 ? `${measured.sourceWidth}x${measured.sourceHeight}` : "unreadable";
|
|
99
|
+
const saved = measured === undefined ? 0 : Math.max(0, r.bytes - measured.bytes);
|
|
100
|
+
const fix = [];
|
|
101
|
+
if (isSvg) {
|
|
102
|
+
fix.push("serve it as an optimised SVG (svgo, default preset) or a 32 px PNG; an SVG icon scales at every size");
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
fix.push(measured !== undefined
|
|
106
|
+
? `replace with a ${px}x${px} PNG (sharp resize ${px}, fit cover, PNG compression 9): ${kb(measured.bytes)} (from ${kb(r.bytes)})`
|
|
107
|
+
: `replace with a ${px}x${px} PNG: the file is not decodable here (an ICO with no PNG frame, or not an image)`);
|
|
108
|
+
if (role === "favicon")
|
|
109
|
+
fix.push(`or an SVG favicon when the logo is vector: <link rel="icon" type="image/svg+xml" href="/icon.svg">`);
|
|
110
|
+
}
|
|
111
|
+
fix.push(`point the <link rel="${role === "apple-touch" ? "apple-touch-icon" : "icon"}"> at the new file; it is fetched once per site, on a first visit`);
|
|
112
|
+
findings.push({
|
|
113
|
+
id: `favicon:${digestOf(r.url).slice(0, 16)}`,
|
|
114
|
+
kind: "favicon",
|
|
115
|
+
url: r.url,
|
|
116
|
+
bytes: r.bytes,
|
|
117
|
+
savedBytes: saved,
|
|
118
|
+
estimate: false,
|
|
119
|
+
title: `${label} ${path}: ${source}${isSvg ? " SVG" : ""}, ${kb(r.bytes)} per first visit${measured !== undefined ? `; a ${px} px PNG is ${kb(measured.bytes)}` : ""}`,
|
|
120
|
+
fix,
|
|
121
|
+
details: { role, source, px, bytes: r.bytes, rebuiltBytes: measured?.bytes ?? null },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return findings;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|