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,491 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { deflateRawSync } from "node:zlib";
|
|
3
|
+
import { brotliSizeFast } from "./brotli.js";
|
|
4
|
+
import { digestOf, heavyValue } from "./cache.js";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { readZip } from "./zip.js";
|
|
7
|
+
/** Embedded rasters per animation the analyzer decodes (largest first). The rest are skipped. */
|
|
8
|
+
const MAX_RASTERS = 8;
|
|
9
|
+
/** Lottie precision savings below both thresholds are noise and not reported. */
|
|
10
|
+
const PRECISION_MIN_BYTES = 1024;
|
|
11
|
+
const PRECISION_MIN_SHARE = 0.05;
|
|
12
|
+
/** A dotLottie repack is reported only when it saves this much of the served bytes. */
|
|
13
|
+
const DOTLOTTIE_MIN_BYTES = 1024;
|
|
14
|
+
const DOTLOTTIE_MIN_SHARE = 0.1;
|
|
15
|
+
/** Overhead per zip entry (local header, central directory entry, name). */
|
|
16
|
+
const ZIP_ENTRY_OVERHEAD = 80;
|
|
17
|
+
const WEBP_QUALITY = 80;
|
|
18
|
+
/** A raster re-encode is reported when it saves this much, or when it also downscales. Below that it is noise. */
|
|
19
|
+
const RASTER_MIN_BYTES = 1024;
|
|
20
|
+
const RASTER_MIN_SHARE = 0.1;
|
|
21
|
+
const DATA_IMAGE = /^data:image\/([a-z0-9.+-]+);base64,/i;
|
|
22
|
+
/** Parses a Lottie animation: JSON with `v` and an array `layers`. Returns undefined for anything else. */
|
|
23
|
+
export function parseAnimation(buf) {
|
|
24
|
+
try {
|
|
25
|
+
const json = JSON.parse(buf.toString("utf8"));
|
|
26
|
+
if (json === null || typeof json !== "object" || Array.isArray(json))
|
|
27
|
+
return undefined;
|
|
28
|
+
const o = json;
|
|
29
|
+
if (typeof o.v !== "string" || !Array.isArray(o.layers))
|
|
30
|
+
return undefined;
|
|
31
|
+
return o;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Layers of the animation, counting the layers of precomposition assets too. */
|
|
38
|
+
export function countLayers(anim) {
|
|
39
|
+
let n = anim.layers.length;
|
|
40
|
+
for (const asset of anim.assets ?? []) {
|
|
41
|
+
const layers = asset.layers;
|
|
42
|
+
if (Array.isArray(layers))
|
|
43
|
+
n += layers.length;
|
|
44
|
+
}
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
/** Frame rate, duration in seconds and the composition size. */
|
|
48
|
+
export function animationStats(anim) {
|
|
49
|
+
const fps = typeof anim.fr === "number" && anim.fr > 0 ? anim.fr : undefined;
|
|
50
|
+
const span = typeof anim.op === "number" && typeof anim.ip === "number" ? anim.op - anim.ip : undefined;
|
|
51
|
+
const seconds = fps !== undefined && span !== undefined && span > 0 ? span / fps : undefined;
|
|
52
|
+
return {
|
|
53
|
+
...(fps !== undefined ? { fps } : {}),
|
|
54
|
+
...(seconds !== undefined ? { seconds } : {}),
|
|
55
|
+
...(typeof anim.w === "number" ? { w: anim.w } : {}),
|
|
56
|
+
...(typeof anim.h === "number" ? { h: anim.h } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Rounds every number in the tree to three decimals. Strings (including data URIs) are untouched. */
|
|
60
|
+
export function roundNumbers(value) {
|
|
61
|
+
// "+ 0" turns a rounded -0 back into 0, so the output has no negative zeros.
|
|
62
|
+
if (typeof value === "number")
|
|
63
|
+
return Number.isFinite(value) ? Math.round(value * 1000) / 1000 + 0 : value;
|
|
64
|
+
if (Array.isArray(value))
|
|
65
|
+
return value.map(roundNumbers);
|
|
66
|
+
if (value !== null && typeof value === "object") {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(value))
|
|
69
|
+
out[k] = roundNumbers(v);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
/** Width to encode an embedded raster at: its composition size times the display scale, never above its source width. */
|
|
75
|
+
export function rasterTargetWidth(assetWidth, sourceWidth, anim, box) {
|
|
76
|
+
const cw = anim.w ?? assetWidth;
|
|
77
|
+
const ch = anim.h ?? assetWidth;
|
|
78
|
+
const scale = box !== undefined && cw > 0 && ch > 0 ? Math.min(box.w / cw, box.h / ch) : 1;
|
|
79
|
+
const wanted = Math.round(assetWidth * scale);
|
|
80
|
+
return Math.max(1, Math.min(sourceWidth, wanted));
|
|
81
|
+
}
|
|
82
|
+
function deflateSize(buf) {
|
|
83
|
+
return deflateRawSync(buf, { level: 9 }).length;
|
|
84
|
+
}
|
|
85
|
+
function shortHash(url) {
|
|
86
|
+
return createHash("sha1").update(url).digest("hex").slice(0, 10);
|
|
87
|
+
}
|
|
88
|
+
function stripQuery(url) {
|
|
89
|
+
return url.replace(/[?#].*$/, "");
|
|
90
|
+
}
|
|
91
|
+
function sameResource(a, b, pageUrl) {
|
|
92
|
+
if (a === undefined)
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
return stripQuery(new URL(a, pageUrl).href) === stripQuery(new URL(b, pageUrl).href);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The display box of an animation on the page (CSS size times DPR), when the page shows it by this URL. */
|
|
102
|
+
function displayBox(url, ctx) {
|
|
103
|
+
const hit = ctx.lottie.find((l) => sameResource(l.src, url, ctx.url));
|
|
104
|
+
if (hit === undefined || hit.width <= 0 || hit.height <= 0)
|
|
105
|
+
return undefined;
|
|
106
|
+
const dpr = ctx.viewport.dpr || 1;
|
|
107
|
+
return { box: { w: hit.width * dpr, h: hit.height * dpr }, selector: hit.selector };
|
|
108
|
+
}
|
|
109
|
+
const kb = (n) => `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
|
|
110
|
+
/**
|
|
111
|
+
* Whether a re-encode is worth reporting: it must shrink the file, and either downscale it or save a real share of it.
|
|
112
|
+
* A WebP or AVIF source that keeps its size is already lossy and modern: a second lossy pass gains nothing.
|
|
113
|
+
*/
|
|
114
|
+
export function rasterWorthIt(fromFormat, srcBytes, dstBytes, downscaled) {
|
|
115
|
+
if (dstBytes >= srcBytes)
|
|
116
|
+
return false;
|
|
117
|
+
if (downscaled)
|
|
118
|
+
return true;
|
|
119
|
+
if (fromFormat === "webp" || fromFormat === "avif")
|
|
120
|
+
return false;
|
|
121
|
+
return srcBytes - dstBytes >= RASTER_MIN_BYTES && srcBytes - dstBytes >= srcBytes * RASTER_MIN_SHARE;
|
|
122
|
+
}
|
|
123
|
+
/** Decodes and re-encodes embedded data-URI rasters at the display size. Animated sources are skipped. */
|
|
124
|
+
async function rasterFixes(anim, box) {
|
|
125
|
+
const assets = (anim.assets ?? [])
|
|
126
|
+
.map((a, index) => ({ a: a, index }))
|
|
127
|
+
.filter(({ a }) => typeof a.p === "string" && DATA_IMAGE.test(a.p))
|
|
128
|
+
.sort((x, y) => y.a.p.length - x.a.p.length)
|
|
129
|
+
.slice(0, MAX_RASTERS);
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const { a, index } of assets) {
|
|
132
|
+
try {
|
|
133
|
+
const p = a.p;
|
|
134
|
+
const fmt = (DATA_IMAGE.exec(p)?.[1] ?? "").toLowerCase();
|
|
135
|
+
const src = Buffer.from(p.slice(p.indexOf(",") + 1), "base64");
|
|
136
|
+
const meta = await sharp(src, { limitInputPixels: 100_000_000 }).metadata();
|
|
137
|
+
if (meta.width === undefined || meta.height === undefined || (meta.pages ?? 1) > 1)
|
|
138
|
+
continue;
|
|
139
|
+
const assetW = typeof a.w === "number" && a.w > 0 ? a.w : meta.width;
|
|
140
|
+
const toW = rasterTargetWidth(assetW, meta.width, anim, box);
|
|
141
|
+
// The re-encode is heavy and depends on the source and the target width: once per run, and skipped once the budget is spent.
|
|
142
|
+
const webp = await heavyValue(`lottie:raster:${digestOf(src)}:${toW}`, async () => {
|
|
143
|
+
const out = await sharp(src, { limitInputPixels: 100_000_000 }).resize({ width: toW, withoutEnlargement: true }).webp({ quality: WEBP_QUALITY }).toBuffer();
|
|
144
|
+
return out.toString("base64");
|
|
145
|
+
});
|
|
146
|
+
if (webp === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
const webpBytes = Buffer.from(webp, "base64");
|
|
149
|
+
const prefix = "data:image/webp;base64,";
|
|
150
|
+
const newLen = prefix.length + Math.ceil(webpBytes.length / 3) * 4;
|
|
151
|
+
const savedBytes = p.length - newLen;
|
|
152
|
+
if (!rasterWorthIt(fmt, src.length, webpBytes.length, toW < meta.width))
|
|
153
|
+
continue;
|
|
154
|
+
const id = typeof a.id === "string" || typeof a.id === "number" ? String(a.id) : String(index);
|
|
155
|
+
out.push({
|
|
156
|
+
index,
|
|
157
|
+
id,
|
|
158
|
+
fromFormat: fmt,
|
|
159
|
+
fromW: meta.width,
|
|
160
|
+
fromH: meta.height,
|
|
161
|
+
fromBytes: src.length,
|
|
162
|
+
toW,
|
|
163
|
+
toBytes: webpBytes.length,
|
|
164
|
+
savedBytes,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// A raster sharp cannot read is left alone.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function rasterFinding(url, bytes, fixes, box, selector, inArchive) {
|
|
174
|
+
const saved = fixes.reduce((s, f) => s + f.savedBytes, 0);
|
|
175
|
+
if (fixes.length === 0 || saved <= 0)
|
|
176
|
+
return undefined;
|
|
177
|
+
const name = basename(url);
|
|
178
|
+
const label = inArchive ? "image file" : "embedded raster";
|
|
179
|
+
const shownAt = box !== undefined ? ` shown at ${Math.round(box.w)}×${Math.round(box.h)} px` : "";
|
|
180
|
+
const fix = fixes.slice(0, 5).map((f) => inArchive
|
|
181
|
+
? `file ${f.id}: ${f.fromFormat} ${f.fromW}×${f.fromH} (${kb(f.fromBytes)}) → WebP q${WEBP_QUALITY} at ${f.toW} px wide (${kb(f.toBytes)}): ` +
|
|
182
|
+
`sharp(src).resize({ width: ${f.toW}, withoutEnlargement: true }).webp({ quality: ${WEBP_QUALITY} }), then replace the file and its images/ entry`
|
|
183
|
+
: `asset ${f.id}: ${f.fromFormat} ${f.fromW}×${f.fromH} (${kb(f.fromBytes)}) → WebP q${WEBP_QUALITY} at ${f.toW} px wide (${kb(f.toBytes)}): ` +
|
|
184
|
+
`sharp(src).resize({ width: ${f.toW}, withoutEnlargement: true }).webp({ quality: ${WEBP_QUALITY} }), ` +
|
|
185
|
+
`then set assets[${f.index}].p to data:image/webp;base64,…`);
|
|
186
|
+
if (fixes.length > 5)
|
|
187
|
+
fix.push(`and ${fixes.length - 5} more ${label}s`);
|
|
188
|
+
return {
|
|
189
|
+
id: `lottie:${shortHash(url)}:raster`,
|
|
190
|
+
kind: "lottie",
|
|
191
|
+
url,
|
|
192
|
+
bytes,
|
|
193
|
+
savedBytes: saved,
|
|
194
|
+
estimate: false,
|
|
195
|
+
title: `lottie ${name}: ${fixes.length} ${label}${fixes.length === 1 ? "" : "s"}${shownAt}, re-encode to WebP saves ${kb(saved)}`,
|
|
196
|
+
fix,
|
|
197
|
+
details: {
|
|
198
|
+
rasters: fixes.map((f) => ({ asset: f.id, from: f.fromFormat, width: f.fromW, height: f.fromH, bytes: f.fromBytes, toWidth: f.toW, toBytes: f.toBytes })),
|
|
199
|
+
...(box !== undefined ? { displayBox: box } : {}),
|
|
200
|
+
},
|
|
201
|
+
...(selector !== undefined ? { locator: { selector } } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function basename(url) {
|
|
205
|
+
const path = stripQuery(url);
|
|
206
|
+
return path.slice(path.lastIndexOf("/") + 1) || url;
|
|
207
|
+
}
|
|
208
|
+
/** Numbers rounded to three decimals, minified, measured with brotli against the served body. */
|
|
209
|
+
function precisionFinding(url, body, anim) {
|
|
210
|
+
const trimmed = Buffer.from(JSON.stringify(roundNumbers(anim)));
|
|
211
|
+
// Both sizes at brotli quality 5 (an estimate): a quality-11 pass over a multi-megabyte JSON costs seconds per view.
|
|
212
|
+
const served = brotliSizeFast(body);
|
|
213
|
+
const after = brotliSizeFast(trimmed);
|
|
214
|
+
const saved = served.bytes - after.bytes;
|
|
215
|
+
if (saved < PRECISION_MIN_BYTES || saved < body.length * PRECISION_MIN_SHARE)
|
|
216
|
+
return undefined;
|
|
217
|
+
return {
|
|
218
|
+
id: `lottie:${shortHash(url)}:precision`,
|
|
219
|
+
kind: "lottie",
|
|
220
|
+
url,
|
|
221
|
+
bytes: body.length,
|
|
222
|
+
savedBytes: saved,
|
|
223
|
+
estimate: served.estimate || after.estimate,
|
|
224
|
+
title: `lottie ${basename(url)}: round numbers to 3 decimals and minify, brotli ${kb(served.bytes)} → ${kb(after.bytes)}`,
|
|
225
|
+
fix: [
|
|
226
|
+
"re-save the export with numbers rounded to 3 decimals and no indentation (JSON.stringify of the parsed file with rounded numbers)",
|
|
227
|
+
"play the animation before and after; 3 decimals is below one pixel at any normal display size",
|
|
228
|
+
],
|
|
229
|
+
details: { servedBrotli: served.bytes, trimmedBrotli: after.bytes },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** Estimated size of the same animation repacked as .lottie (zip, deflate) with each embedded raster as a file. */
|
|
233
|
+
async function dotLottieFinding(url, body, anim, rasters) {
|
|
234
|
+
const served = brotliSizeFast(body);
|
|
235
|
+
const copy = JSON.parse(JSON.stringify(anim));
|
|
236
|
+
const byIndex = new Map(rasters.map((r) => [r.index, r]));
|
|
237
|
+
(copy.assets ?? []).forEach((a, i) => {
|
|
238
|
+
const r = byIndex.get(i);
|
|
239
|
+
if (r === undefined)
|
|
240
|
+
return;
|
|
241
|
+
const asset = a;
|
|
242
|
+
asset.u = "images/";
|
|
243
|
+
asset.p = `image_${i}.${r.ext}`;
|
|
244
|
+
});
|
|
245
|
+
let est = deflateSize(Buffer.from(JSON.stringify(copy))) + ZIP_ENTRY_OVERHEAD;
|
|
246
|
+
for (const r of rasters)
|
|
247
|
+
est += deflateSize(r.bytes) + ZIP_ENTRY_OVERHEAD;
|
|
248
|
+
const saved = served.bytes - est;
|
|
249
|
+
if (saved < DOTLOTTIE_MIN_BYTES || saved < body.length * DOTLOTTIE_MIN_SHARE)
|
|
250
|
+
return undefined;
|
|
251
|
+
return {
|
|
252
|
+
id: `lottie:${shortHash(url)}:dotlottie`,
|
|
253
|
+
kind: "lottie",
|
|
254
|
+
url,
|
|
255
|
+
bytes: body.length,
|
|
256
|
+
savedBytes: saved,
|
|
257
|
+
estimate: true,
|
|
258
|
+
title: `lottie ${basename(url)}: repack as .lottie (zip) ~${kb(est)} vs ${kb(served.bytes)} brotli JSON`,
|
|
259
|
+
fix: [
|
|
260
|
+
`pack the animation as a .lottie file (zip with deflate): animations/${basename(url).replace(/\.json$/i, "")}.json and images/ for the ${rasters.length} embedded raster${rasters.length === 1 ? "" : "s"}, binary instead of base64`,
|
|
261
|
+
"serve it as application/zip or application/octet-stream; the players load .lottie directly",
|
|
262
|
+
"if the embedded rasters are also re-encoded (see the raster finding), the two savings overlap; take the larger one",
|
|
263
|
+
],
|
|
264
|
+
details: { estimatedDotLottie: est, servedBrotli: served.bytes, embeddedRasters: rasters.length },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function overlayFix(anim, box) {
|
|
268
|
+
const lines = [];
|
|
269
|
+
const stats = animationStats(anim);
|
|
270
|
+
const size = stats.w !== undefined && stats.h !== undefined ? `${stats.w}×${stats.h}` : "unknown size";
|
|
271
|
+
lines.push(`composition ${size}, ${countLayers(anim)} layers${stats.fps !== undefined ? `, ${stats.fps} fps` : ""}${stats.seconds !== undefined ? `, ${stats.seconds.toFixed(1)} s` : ""}`);
|
|
272
|
+
if (box !== undefined && stats.w !== undefined && stats.h !== undefined) {
|
|
273
|
+
const scale = Math.min(box.w / stats.w, box.h / stats.h);
|
|
274
|
+
if (scale < 0.5) {
|
|
275
|
+
lines.push(`shown at ${Math.round(box.w)}×${Math.round(box.h)} px, a quarter of its composition or less: export at the display size, or render a video/animated AVIF for this size`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
async function analyzeJson(record, body, anim, ctx) {
|
|
281
|
+
const url = record.url;
|
|
282
|
+
const shown = displayBox(url, ctx);
|
|
283
|
+
const box = shown?.box;
|
|
284
|
+
const stats = animationStats(anim);
|
|
285
|
+
const rasters = await rasterFixes(anim, box);
|
|
286
|
+
const findings = [];
|
|
287
|
+
const statLine = [
|
|
288
|
+
`${kb(body.length)} JSON`,
|
|
289
|
+
`${countLayers(anim)} layers`,
|
|
290
|
+
stats.fps !== undefined ? `${stats.fps} fps` : undefined,
|
|
291
|
+
stats.seconds !== undefined ? `${stats.seconds.toFixed(1)} s` : undefined,
|
|
292
|
+
].filter(Boolean).join(", ");
|
|
293
|
+
findings.push({
|
|
294
|
+
id: `lottie:${shortHash(url)}`,
|
|
295
|
+
kind: "lottie",
|
|
296
|
+
url,
|
|
297
|
+
bytes: body.length,
|
|
298
|
+
savedBytes: 0,
|
|
299
|
+
estimate: false,
|
|
300
|
+
title: `lottie ${basename(url)}: ${statLine}`,
|
|
301
|
+
fix: overlayFix(anim, box),
|
|
302
|
+
details: {
|
|
303
|
+
layers: countLayers(anim),
|
|
304
|
+
...(stats.fps !== undefined ? { fps: stats.fps } : {}),
|
|
305
|
+
...(stats.seconds !== undefined ? { seconds: stats.seconds } : {}),
|
|
306
|
+
...(stats.w !== undefined ? { width: stats.w, height: stats.h } : {}),
|
|
307
|
+
embeddedRasters: rasters.length,
|
|
308
|
+
...(box !== undefined ? { displayBox: box } : {}),
|
|
309
|
+
},
|
|
310
|
+
...(shown?.selector !== undefined ? { locator: { selector: shown.selector } } : {}),
|
|
311
|
+
});
|
|
312
|
+
const raster = rasterFinding(url, body.length, rasters, box, shown?.selector, false);
|
|
313
|
+
if (raster)
|
|
314
|
+
findings.push(raster);
|
|
315
|
+
// The precision trim re-serialises and brotli-sizes the JSON: once per run by content and URL, and not once the budget is spent.
|
|
316
|
+
const precisionValue = await heavyValue(`lottie:precision:${digestOf(body)}:${url}`, async () => precisionFinding(url, body, anim) ?? null);
|
|
317
|
+
const precision = precisionValue ?? undefined;
|
|
318
|
+
if (precision)
|
|
319
|
+
findings.push(precision);
|
|
320
|
+
const embedded = (anim.assets ?? [])
|
|
321
|
+
.map((a, index) => ({ index, p: a.p }))
|
|
322
|
+
.filter((a) => typeof a.p === "string" && DATA_IMAGE.test(a.p))
|
|
323
|
+
.map((a) => {
|
|
324
|
+
const ext = (DATA_IMAGE.exec(a.p)?.[1] ?? "png").toLowerCase().replace("jpeg", "jpg");
|
|
325
|
+
return { index: a.index, bytes: Buffer.from(a.p.slice(a.p.indexOf(",") + 1), "base64"), ext };
|
|
326
|
+
});
|
|
327
|
+
const repack = await dotLottieFinding(url, body, anim, embedded);
|
|
328
|
+
if (repack)
|
|
329
|
+
findings.push(repack);
|
|
330
|
+
return findings;
|
|
331
|
+
}
|
|
332
|
+
async function analyzeDotLottie(record, body, ctx) {
|
|
333
|
+
const url = record.url;
|
|
334
|
+
const shown = displayBox(url, ctx);
|
|
335
|
+
const box = shown?.box;
|
|
336
|
+
const entries = readZip(body);
|
|
337
|
+
const animations = [];
|
|
338
|
+
const images = [];
|
|
339
|
+
const findings = [];
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
if (/^animations\/[^/]+\.json$/i.test(entry.name)) {
|
|
342
|
+
const data = entry.data();
|
|
343
|
+
const anim = data !== undefined ? parseAnimation(data) : undefined;
|
|
344
|
+
if (data !== undefined && anim !== undefined)
|
|
345
|
+
animations.push({ entry, anim, data });
|
|
346
|
+
}
|
|
347
|
+
else if (/^images\/[^/]+\.(png|jpe?g|webp)$/i.test(entry.name)) {
|
|
348
|
+
images.push(entry);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (animations.length === 0)
|
|
352
|
+
return [];
|
|
353
|
+
const first = animations[0].anim;
|
|
354
|
+
const stats = animationStats(first);
|
|
355
|
+
const totalLayers = animations.reduce((s, a) => s + countLayers(a.anim), 0);
|
|
356
|
+
const statLine = [
|
|
357
|
+
`${kb(body.length)} zip`,
|
|
358
|
+
`${animations.length} animation${animations.length === 1 ? "" : "s"}`,
|
|
359
|
+
`${totalLayers} layers`,
|
|
360
|
+
stats.fps !== undefined ? `${stats.fps} fps` : undefined,
|
|
361
|
+
stats.seconds !== undefined ? `${stats.seconds.toFixed(1)} s` : undefined,
|
|
362
|
+
images.length > 0 ? `${images.length} image file${images.length === 1 ? "" : "s"}` : undefined,
|
|
363
|
+
].filter(Boolean).join(", ");
|
|
364
|
+
findings.push({
|
|
365
|
+
id: `lottie:${shortHash(url)}`,
|
|
366
|
+
kind: "lottie",
|
|
367
|
+
url,
|
|
368
|
+
bytes: body.length,
|
|
369
|
+
savedBytes: 0,
|
|
370
|
+
estimate: false,
|
|
371
|
+
title: `lottie ${basename(url)} (.lottie): ${statLine}`,
|
|
372
|
+
fix: overlayFix(first, box),
|
|
373
|
+
details: { animations: animations.length, layers: totalLayers, images: images.length },
|
|
374
|
+
...(shown?.selector !== undefined ? { locator: { selector: shown.selector } } : {}),
|
|
375
|
+
});
|
|
376
|
+
// Image files inside the archive: decode, re-encode at the display size, compare with the stored size.
|
|
377
|
+
const fixes = [];
|
|
378
|
+
for (const entry of images.slice(0, MAX_RASTERS)) {
|
|
379
|
+
try {
|
|
380
|
+
const src = entry.data();
|
|
381
|
+
if (src === undefined)
|
|
382
|
+
continue;
|
|
383
|
+
const meta = await sharp(src, { limitInputPixels: 100_000_000 }).metadata();
|
|
384
|
+
if (meta.width === undefined || meta.height === undefined || (meta.pages ?? 1) > 1)
|
|
385
|
+
continue;
|
|
386
|
+
const toW = rasterTargetWidth(meta.width, meta.width, first, box);
|
|
387
|
+
const webp = await sharp(src, { limitInputPixels: 100_000_000 }).resize({ width: toW, withoutEnlargement: true }).webp({ quality: WEBP_QUALITY }).toBuffer();
|
|
388
|
+
const saved = entry.compressedSize - webp.length;
|
|
389
|
+
if (!rasterWorthIt(meta.format ?? "", entry.compressedSize, webp.length, toW < meta.width))
|
|
390
|
+
continue;
|
|
391
|
+
fixes.push({
|
|
392
|
+
index: fixes.length,
|
|
393
|
+
id: entry.name,
|
|
394
|
+
fromFormat: meta.format ?? "image",
|
|
395
|
+
fromW: meta.width,
|
|
396
|
+
fromH: meta.height,
|
|
397
|
+
fromBytes: entry.compressedSize,
|
|
398
|
+
toW,
|
|
399
|
+
toBytes: webp.length,
|
|
400
|
+
savedBytes: saved,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Unreadable image: left alone.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const raster = rasterFinding(url, body.length, fixes, box, shown?.selector, true);
|
|
408
|
+
if (raster)
|
|
409
|
+
findings.push(raster);
|
|
410
|
+
// Stored (uncompressed) JSON entries, and the precision of each animation measured with deflate.
|
|
411
|
+
let storedSaved = 0;
|
|
412
|
+
let precisionSaved = 0;
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
if (entry.method === 0 && /\.json$/i.test(entry.name) && entry.size >= 1024) {
|
|
415
|
+
const data = entry.data();
|
|
416
|
+
if (data !== undefined)
|
|
417
|
+
storedSaved += entry.size - deflateSize(data);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const a of animations) {
|
|
421
|
+
const before = deflateSize(a.data);
|
|
422
|
+
const after = deflateSize(Buffer.from(JSON.stringify(roundNumbers(a.anim))));
|
|
423
|
+
precisionSaved += before - after;
|
|
424
|
+
}
|
|
425
|
+
if (storedSaved >= PRECISION_MIN_BYTES) {
|
|
426
|
+
findings.push({
|
|
427
|
+
id: `lottie:${shortHash(url)}:stored`,
|
|
428
|
+
kind: "lottie",
|
|
429
|
+
url,
|
|
430
|
+
bytes: body.length,
|
|
431
|
+
savedBytes: storedSaved,
|
|
432
|
+
estimate: false,
|
|
433
|
+
title: `lottie ${basename(url)}: JSON entries stored without compression, deflate saves ${kb(storedSaved)}`,
|
|
434
|
+
fix: ["pack the .lottie with deflate (method 8) for the animations/*.json entries"],
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (precisionSaved >= PRECISION_MIN_BYTES && precisionSaved >= body.length * PRECISION_MIN_SHARE) {
|
|
438
|
+
findings.push({
|
|
439
|
+
id: `lottie:${shortHash(url)}:precision`,
|
|
440
|
+
kind: "lottie",
|
|
441
|
+
url,
|
|
442
|
+
bytes: body.length,
|
|
443
|
+
savedBytes: precisionSaved,
|
|
444
|
+
estimate: false,
|
|
445
|
+
title: `lottie ${basename(url)}: round numbers to 3 decimals and minify, deflate saves ${kb(precisionSaved)}`,
|
|
446
|
+
fix: [
|
|
447
|
+
"re-save each animations/*.json with numbers rounded to 3 decimals and no indentation, then re-zip with deflate",
|
|
448
|
+
"play the animation before and after; 3 decimals is below one pixel at any normal display size",
|
|
449
|
+
],
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return findings;
|
|
453
|
+
}
|
|
454
|
+
function isDotLottie(r) {
|
|
455
|
+
return /\.lottie(?:[?#]|$)/i.test(r.url) || (r.body !== undefined && r.body.length > 4 && r.body.readUInt32LE(0) === 0x04034b50 && /zip|octet-stream/.test(r.contentType));
|
|
456
|
+
}
|
|
457
|
+
export const analyzer = {
|
|
458
|
+
kind: "lottie",
|
|
459
|
+
wants(r) {
|
|
460
|
+
if (r.bytes < 512)
|
|
461
|
+
return false;
|
|
462
|
+
if (/\.lottie(?:[?#]|$)/i.test(r.url))
|
|
463
|
+
return true;
|
|
464
|
+
if (/json/.test(r.contentType) || /\.json(?:[?#]|$)/i.test(r.url))
|
|
465
|
+
return true;
|
|
466
|
+
return false;
|
|
467
|
+
},
|
|
468
|
+
async analyze(records, ctx) {
|
|
469
|
+
const out = [];
|
|
470
|
+
const seen = new Set();
|
|
471
|
+
for (const r of records) {
|
|
472
|
+
if (r.body === undefined || seen.has(r.url))
|
|
473
|
+
continue;
|
|
474
|
+
seen.add(r.url);
|
|
475
|
+
try {
|
|
476
|
+
if (isDotLottie(r)) {
|
|
477
|
+
out.push(...(await analyzeDotLottie(r, r.body, ctx)));
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const anim = parseAnimation(r.body);
|
|
481
|
+
if (anim === undefined)
|
|
482
|
+
continue;
|
|
483
|
+
out.push(...(await analyzeJson(r, r.body, anim, ctx)));
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Never throws: a broken animation yields no findings.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
},
|
|
491
|
+
};
|