imgtune 0.1.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +109 -5
  3. package/dist/animated.js +176 -0
  4. package/dist/audit.js +1 -0
  5. package/dist/backgrounds.js +42 -7
  6. package/dist/broken.js +5 -1
  7. package/dist/browser.js +4 -4
  8. package/dist/candidates.js +25 -0
  9. package/dist/check-report.js +56 -6
  10. package/dist/check.js +26 -5
  11. package/dist/cli.js +86 -8
  12. package/dist/egress-proxy.js +2 -1
  13. package/dist/fetch.js +9 -4
  14. package/dist/ffmpeg.js +530 -0
  15. package/dist/gpu.js +12 -0
  16. package/dist/hints.js +704 -16
  17. package/dist/image-file.js +154 -34
  18. package/dist/image-open.js +9 -0
  19. package/dist/images.js +142 -8
  20. package/dist/layout.js +7 -7
  21. package/dist/lcp.js +2 -1
  22. package/dist/locate-join.js +86 -0
  23. package/dist/locate-only.js +14 -3
  24. package/dist/locate.js +6 -2
  25. package/dist/measure.js +39 -1
  26. package/dist/media-encode.js +1054 -0
  27. package/dist/media-file.js +284 -0
  28. package/dist/media-limits.js +31 -0
  29. package/dist/media-model.js +251 -0
  30. package/dist/media-phase.js +675 -0
  31. package/dist/media-prepare.js +31 -0
  32. package/dist/px.js +11 -0
  33. package/dist/recheck.js +12 -4
  34. package/dist/remote.js +1065 -107
  35. package/dist/replay.js +20 -0
  36. package/dist/resources/audio.js +248 -0
  37. package/dist/resources/brotli.js +22 -0
  38. package/dist/resources/cache.js +59 -0
  39. package/dist/resources/capture.js +225 -0
  40. package/dist/resources/compression.js +122 -0
  41. package/dist/resources/favicon.js +130 -0
  42. package/dist/resources/font.js +440 -0
  43. package/dist/resources/index.js +8 -0
  44. package/dist/resources/lottie.js +491 -0
  45. package/dist/resources/model.js +478 -0
  46. package/dist/resources/phase.js +171 -0
  47. package/dist/resources/pool.js +47 -0
  48. package/dist/resources/report.js +57 -0
  49. package/dist/resources/sfnt.js +305 -0
  50. package/dist/resources/svg.js +178 -0
  51. package/dist/resources/types.js +1 -0
  52. package/dist/resources/zip.js +67 -0
  53. package/dist/result-cache.js +56 -3
  54. package/dist/run.js +32 -8
  55. package/dist/sizes.js +7 -5
  56. package/dist/summary.js +69 -3
  57. package/dist/vector-art.js +294 -0
  58. package/dist/vector-phase.js +222 -0
  59. package/dist/video-analysis.js +371 -0
  60. package/dist/webp-animation.js +283 -0
  61. package/package.json +25 -4
  62. package/skills/imgtune/SKILL.md +8 -4
  63. package/skills/imgtune/reference.md +1 -1
@@ -0,0 +1,283 @@
1
+ // An animated WebP read one frame at a time. ffmpeg cannot read an animated WebP, and sharp can extract one page only by
2
+ // decoding every page before it (its cost grows with the page index), so the frames are read here instead: each ANMF frame is
3
+ // split out as a still WebP, decoded alone by sharp, and composited onto a canvas as the WebP spec describes (the frame's
4
+ // rectangle, its blend mode, and the previous frame's dispose). Memory is about one canvas and one frame, whatever the length.
5
+ //
6
+ // Random access is cheap where the file allows it: a frame that covers the whole canvas and either does not blend or is opaque
7
+ // does not depend on the canvas before it (a keyframe), so composition restarts there. Between keyframes the frames are
8
+ // composed in order, so a sequential read (which is how every caller reads) costs one decode per frame.
9
+ import sharp from "sharp";
10
+ import { MAX_INPUT_PIXELS_FFMPEG, PixelsTooLarge } from "./media-limits.js";
11
+ /** The shortest frame a browser shows: a frame of 0 ms is shown for 10 ms. */
12
+ const MIN_FRAME_MS = 10;
13
+ /** The ticks of a constant-rate timeline: at least one, the frame on screen at each tick (see cfrIndices). */
14
+ export function ticksOf(delaysMs, fps) {
15
+ const total = delaysMs.reduce((acc, d) => acc + d, 0);
16
+ return Math.max(1, Math.round((total * fps) / 1000));
17
+ }
18
+ /**
19
+ * The indices of the frames on screen at each constant-rate tick. Frame i covers [start, start + delay) in ms, and tick k is
20
+ * at (k + phase) × 1000 / fps: phase 0 is the tick's start, 0.5 its middle. A frame that lasts several ticks repeats; the
21
+ * last frame holds to the end.
22
+ */
23
+ export function cfrIndices(delaysMs, fps, ticks, phase = 0) {
24
+ const out = [];
25
+ let frame = 0;
26
+ let start = 0;
27
+ for (let k = 0; k < ticks; k++) {
28
+ const at = ((k + phase) * 1000) / fps;
29
+ while (frame < delaysMs.length - 1 && start + delaysMs[frame] <= at) {
30
+ start += delaysMs[frame];
31
+ frame++;
32
+ }
33
+ out.push(frame);
34
+ }
35
+ return out;
36
+ }
37
+ /** A RIFF chunk: its four-character type and its payload (the padding byte is not in the payload). */
38
+ function chunksOf(bytes, start, end) {
39
+ const out = [];
40
+ let pos = start;
41
+ while (pos + 8 <= end) {
42
+ const type = bytes.toString("latin1", pos, pos + 4);
43
+ const size = bytes.readUInt32LE(pos + 4);
44
+ const dataEnd = Math.min(pos + 8 + size, end);
45
+ out.push({ type, data: bytes.subarray(pos + 8, dataEnd) });
46
+ pos = pos + 8 + size + (size & 1);
47
+ }
48
+ return out;
49
+ }
50
+ /** A RIFF file of the given chunks, as a WebP. */
51
+ function riffWebp(chunks) {
52
+ const parts = [];
53
+ for (const chunk of chunks) {
54
+ const head = Buffer.alloc(8);
55
+ head.write(chunk.type, 0, "latin1");
56
+ head.writeUInt32LE(chunk.data.length, 4);
57
+ parts.push(head, chunk.data);
58
+ if (chunk.data.length & 1)
59
+ parts.push(Buffer.alloc(1));
60
+ }
61
+ const body = Buffer.concat([Buffer.from("WEBP", "latin1"), ...parts]);
62
+ const head = Buffer.alloc(8);
63
+ head.write("RIFF", 0, "latin1");
64
+ head.writeUInt32LE(body.length, 4);
65
+ return Buffer.concat([head, body]);
66
+ }
67
+ /** The VP8X chunk of a still with alpha, on a canvas of the frame's size. */
68
+ function vp8xChunk(width, height, alpha) {
69
+ const data = Buffer.alloc(10);
70
+ data[0] = alpha ? 0x10 : 0;
71
+ data.writeUIntLE(width - 1, 4, 3);
72
+ data.writeUIntLE(height - 1, 7, 3);
73
+ return { type: "VP8X", data };
74
+ }
75
+ /**
76
+ * The size of a still WebP from its bitstream header (VP8 or VP8L), or undefined when it has neither.
77
+ */
78
+ function stillSizeOf(top) {
79
+ const vp8 = top.find((c) => c.type === "VP8 ");
80
+ if (vp8 !== undefined && vp8.data.length >= 10 && vp8.data[3] === 0x9d && vp8.data[4] === 0x01 && vp8.data[5] === 0x2a) {
81
+ return { width: vp8.data.readUInt16LE(6) & 0x3fff, height: vp8.data.readUInt16LE(8) & 0x3fff };
82
+ }
83
+ const vp8l = top.find((c) => c.type === "VP8L");
84
+ if (vp8l !== undefined && vp8l.data.length >= 5 && vp8l.data[0] === 0x2f) {
85
+ const bits = vp8l.data.readUInt32LE(1);
86
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
87
+ }
88
+ return undefined;
89
+ }
90
+ /**
91
+ * Splits an animated WebP into its frames. A still WebP (one image, which an encoder writes when its frames are all alike) is
92
+ * one frame held for `stillMs` (at least MIN_FRAME_MS): that is how a candidate of a sample is read. Throws when the file is not
93
+ * a WebP, or has no image. The pixels are not decoded here; each frame's standalone still is kept for decoding when it is needed.
94
+ */
95
+ export function parseWebpAnimation(bytes, stillMs = 0) {
96
+ if (bytes.length < 12 || bytes.toString("latin1", 0, 4) !== "RIFF" || bytes.toString("latin1", 8, 12) !== "WEBP") {
97
+ throw new Error("not a WebP file");
98
+ }
99
+ const top = chunksOf(bytes, 12, bytes.length);
100
+ const canvas = top.find((c) => c.type === "VP8X");
101
+ if (canvas === undefined || canvas.data.length < 10 || (canvas.data[0] & 0x02) === 0) {
102
+ const size = stillSizeOf(top);
103
+ const image = top.find((c) => c.type === "VP8 " || c.type === "VP8L");
104
+ if (size === undefined || image === undefined)
105
+ throw new Error("not an animated WebP");
106
+ const still = riffWebp([image]);
107
+ const frame = {
108
+ x: 0,
109
+ y: 0,
110
+ width: size.width,
111
+ height: size.height,
112
+ durationMs: Math.max(MIN_FRAME_MS, stillMs),
113
+ noBlend: true,
114
+ dispose: false,
115
+ keyframe: true,
116
+ still,
117
+ };
118
+ return { canvasWidth: size.width, canvasHeight: size.height, frames: [frame] };
119
+ }
120
+ const canvasWidth = 1 + canvas.data.readUIntLE(4, 3);
121
+ const canvasHeight = 1 + canvas.data.readUIntLE(7, 3);
122
+ const raw = top.filter((c) => c.type === "ANMF");
123
+ if (raw.length === 0)
124
+ throw new Error("the animated WebP has no frame");
125
+ const frames = raw.map((anmf, index) => {
126
+ const d = anmf.data;
127
+ if (d.length < 16)
128
+ throw new Error(`frame ${index} is truncated`);
129
+ const x = 2 * d.readUIntLE(0, 3);
130
+ const y = 2 * d.readUIntLE(3, 3);
131
+ const width = 1 + d.readUIntLE(6, 3);
132
+ const height = 1 + d.readUIntLE(9, 3);
133
+ const durationMs = Math.max(MIN_FRAME_MS, d.readUIntLE(12, 3));
134
+ const flags = d[15];
135
+ const parts = chunksOf(d, 16, d.length);
136
+ const alph = parts.find((c) => c.type === "ALPH");
137
+ const vp8 = parts.find((c) => c.type === "VP8 ");
138
+ const vp8l = parts.find((c) => c.type === "VP8L");
139
+ let still;
140
+ let opaque;
141
+ if (vp8l !== undefined) {
142
+ still = riffWebp([vp8l]);
143
+ opaque = false;
144
+ }
145
+ else if (vp8 !== undefined) {
146
+ const chunks = alph !== undefined ? [vp8xChunk(width, height, true), alph, vp8] : [vp8xChunk(width, height, false), vp8];
147
+ still = riffWebp(chunks);
148
+ opaque = alph === undefined;
149
+ }
150
+ else {
151
+ throw new Error(`frame ${index} has no image data`);
152
+ }
153
+ const noBlend = (flags & 0x02) !== 0;
154
+ const covers = x === 0 && y === 0 && width === canvasWidth && height === canvasHeight;
155
+ return { x, y, width, height, durationMs, noBlend, dispose: (flags & 0x01) !== 0, keyframe: covers && (noBlend || opaque), still };
156
+ });
157
+ return { canvasWidth, canvasHeight, frames };
158
+ }
159
+ /**
160
+ * An animated WebP, read frame by frame at the constant-rate timeline of `fps`. The frames are composed in order and decoded
161
+ * on demand: `page(i)` returns the canvas as it stands at frame i (a copy, so the caller may keep it). The per-frame pixel limit
162
+ * is checked from the header, before any frame is decoded.
163
+ */
164
+ export class WebpAnimation {
165
+ width;
166
+ height;
167
+ /** The frames of the animation. */
168
+ pages;
169
+ /** The duration of each frame, in ms (at least MIN_FRAME_MS). */
170
+ delaysMs;
171
+ /** The frame on screen at each constant-rate tick (see cfrIndices). */
172
+ tickPages;
173
+ /** The whole animation's length, in ms. */
174
+ durationMs;
175
+ /** The constant rate the ticks are taken at. */
176
+ fps;
177
+ frames;
178
+ prevKeyframe;
179
+ framePixelLimit;
180
+ canvas;
181
+ /** The frame the canvas holds, or -1 before the first frame. */
182
+ index = -1;
183
+ /**
184
+ * `phase` places the ticks inside their intervals (see cfrIndices): 0 for the source, where a tick is the frame on screen at
185
+ * its start; 0.5 for an encoded candidate, whose rounded frame starts would otherwise shift a tick into the frame before.
186
+ */
187
+ constructor(bytes, fps, framePixelLimit = MAX_INPUT_PIXELS_FFMPEG, stillMs = 0, phase = 0) {
188
+ this.fps = fps;
189
+ const parsed = parseWebpAnimation(bytes, stillMs);
190
+ if (parsed.canvasWidth * parsed.canvasHeight > framePixelLimit) {
191
+ throw new PixelsTooLarge(parsed.canvasWidth, parsed.canvasHeight, framePixelLimit);
192
+ }
193
+ this.width = parsed.canvasWidth;
194
+ this.height = parsed.canvasHeight;
195
+ this.frames = parsed.frames;
196
+ this.pages = parsed.frames.length;
197
+ this.delaysMs = parsed.frames.map((f) => f.durationMs);
198
+ this.durationMs = this.delaysMs.reduce((acc, d) => acc + d, 0);
199
+ this.tickPages = cfrIndices(this.delaysMs, fps, ticksOf(this.delaysMs, fps), phase);
200
+ this.prevKeyframe = [];
201
+ let last = -1;
202
+ parsed.frames.forEach((f, i) => {
203
+ if (f.keyframe)
204
+ last = i;
205
+ this.prevKeyframe.push(last);
206
+ });
207
+ this.canvas = new Uint8ClampedArray(this.width * this.height * 4);
208
+ this.framePixelLimit = framePixelLimit;
209
+ }
210
+ /** The canvas at frame `index`, as a copy. Frames are read in order; going back restarts at the last keyframe before. */
211
+ async page(index) {
212
+ if (index < 0 || index >= this.pages)
213
+ throw new Error(`frame ${index} is out of range`);
214
+ if (index < this.index || this.prevKeyframe[index] > this.index) {
215
+ const key = this.prevKeyframe[index];
216
+ this.canvas.fill(0);
217
+ this.index = -1;
218
+ if (key >= 0) {
219
+ await this.compose(key);
220
+ this.index = key;
221
+ }
222
+ }
223
+ while (this.index < index) {
224
+ const next = this.index + 1;
225
+ if (next > 0 && this.frames[next - 1].dispose)
226
+ this.clear(this.frames[next - 1]);
227
+ await this.compose(next);
228
+ this.index = next;
229
+ }
230
+ return { data: new Uint8ClampedArray(this.canvas), width: this.width, height: this.height };
231
+ }
232
+ /** The canvas of the ticks [from, to), one frame copy per tick, for a raw video stream (see the sample cut). */
233
+ async *ticks(from, to) {
234
+ for (let k = from; k < to; k++) {
235
+ const frame = await this.page(this.tickPages[Math.min(k, this.tickPages.length - 1)]);
236
+ yield Buffer.from(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength);
237
+ }
238
+ }
239
+ /** Decodes frame `index` alone and draws it onto the canvas. */
240
+ async compose(index) {
241
+ const frame = this.frames[index];
242
+ const { data, info } = await sharp(frame.still, { limitInputPixels: this.framePixelLimit })
243
+ .ensureAlpha()
244
+ .raw()
245
+ .toBuffer({ resolveWithObject: true });
246
+ if (info.width !== frame.width || info.height !== frame.height)
247
+ throw new Error(`frame ${index} decoded at the wrong size`);
248
+ const src = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
249
+ const cw = this.width;
250
+ const dst = this.canvas;
251
+ for (let row = 0; row < frame.height; row++) {
252
+ for (let col = 0; col < frame.width; col++) {
253
+ const s = (row * frame.width + col) * 4;
254
+ const d = ((frame.y + row) * cw + frame.x + col) * 4;
255
+ const sa = src[s + 3];
256
+ if (frame.noBlend || sa === 255) {
257
+ dst[d] = src[s];
258
+ dst[d + 1] = src[s + 1];
259
+ dst[d + 2] = src[s + 2];
260
+ dst[d + 3] = sa;
261
+ }
262
+ else if (sa !== 0) {
263
+ // Source-over blending of non-premultiplied colour (the WebP "alpha-blend" mode).
264
+ const a = sa / 255;
265
+ const da = dst[d + 3] / 255;
266
+ const outA = a + da * (1 - a);
267
+ if (outA > 0) {
268
+ for (let c = 0; c < 3; c++)
269
+ dst[d + c] = (src[s + c] * a + dst[d + c] * da * (1 - a)) / outA;
270
+ }
271
+ dst[d + 3] = outA * 255;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ /** Clears a frame's rectangle to transparent (its dispose). */
277
+ clear(frame) {
278
+ for (let row = 0; row < frame.height; row++) {
279
+ const start = ((frame.y + row) * this.width + frame.x) * 4;
280
+ this.canvas.fill(0, start, start + frame.width * 4);
281
+ }
282
+ }
283
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "imgtune",
3
- "version": "0.1.0",
4
- "description": "Finds the image waste on live pages: the sizes, quality and format for each image, checked with SSIMULACRA2, and the line of code that renders it.",
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
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: imgtune
3
- description: Reduce image weight and fix slow pages in any web framework. Use when asked to optimise or optimize images, cut page weight, improve LCP, fix image sizes, next/image sizes, the LCP image, images Lighthouse flags as oversized, badly encoded or old-format, or make a live page send fewer image bytes. imgtune measures each image on the live page at six viewports and gives per-image fix hints (sizes, quality, format) with the component and line that renders each one, which you apply in the source.
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. Not for scripts, fonts or video. Do not use it on sites the user
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 `width` for a
25
- plain image), its quality and its format, as `reference.md` says. A hint marked `(unverified)`, or with no file and line,
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.