imgtune 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +41 -0
- package/dist/animated.js +176 -0
- package/dist/backgrounds.js +13 -2
- package/dist/check-report.js +20 -3
- package/dist/check.js +8 -4
- package/dist/cli.js +66 -8
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +495 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +418 -6
- package/dist/image-file.js +154 -34
- package/dist/images.js +27 -2
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/media-encode.js +1050 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +247 -0
- package/dist/media-phase.js +652 -0
- package/dist/media-prepare.js +31 -0
- package/dist/recheck.js +3 -3
- package/dist/remote.js +474 -69
- package/dist/result-cache.js +56 -3
- package/dist/run.js +8 -4
- package/dist/summary.js +23 -3
- package/dist/video-analysis.js +273 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imgtune",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "indicozy",
|