@rig-c/parts 0.0.0-stage → 1.5.3
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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +431 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +137 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/apng.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animated PNG (APNG) encoder — `acTL` / `fcTL` / `fdAT` over 8-bit RGBA
|
|
3
|
+
* frames, the form GitHub renders in a README.
|
|
4
|
+
*
|
|
5
|
+
* Pure: bytes in, bytes out, no clock, no file system. The only thing it leans
|
|
6
|
+
* on is `node:zlib` for DEFLATE, which is also all rig-c's own PNG codec
|
|
7
|
+
* (`rig-c/tools/plate.ts`) leans on; the chunk framing and CRC are that
|
|
8
|
+
* codec's `pngChunk`, so there is one PNG chunk writer in the dependency tree,
|
|
9
|
+
* not two.
|
|
10
|
+
*
|
|
11
|
+
* What the encoder does, all of it lossless:
|
|
12
|
+
*
|
|
13
|
+
* - **Frame 0 is the default image.** Its `fcTL` precedes the `IDAT`, so a
|
|
14
|
+
* decoder that knows nothing of APNG shows frame 0 — which is what the
|
|
15
|
+
* selftest reads back through rig-c's `decodePng`.
|
|
16
|
+
* - **Consecutive identical frames are merged** into one frame whose delay is
|
|
17
|
+
* their sum; the timing is kept exactly, because an APNG delay is a fraction
|
|
18
|
+
* (`delay_num / delay_den`) and the fps is its denominator.
|
|
19
|
+
* - **Every later frame is cropped to the box of pixels that changed** from the
|
|
20
|
+
* frame before (`dispose_op` NONE). When every pixel of that box is opaque in
|
|
21
|
+
* both frames, unchanged pixels are written fully transparent and the frame
|
|
22
|
+
* is blended OVER the previous one — transparent zeros compress far better
|
|
23
|
+
* than repeated colour, and OVER of an opaque pixel is exact replacement.
|
|
24
|
+
* Where any pixel in the box is translucent the frame is blended SOURCE
|
|
25
|
+
* instead, since OVER would mix it with what is under it.
|
|
26
|
+
* - **Each scanline takes the PNG filter with the smallest sum of absolute
|
|
27
|
+
* values** (libpng's heuristic), then zlib level 9.
|
|
28
|
+
*
|
|
29
|
+
* {@link encodeIndexedApng} is the second writer here: colour type 3, one
|
|
30
|
+
* shared palette, lossy by that palette and reporting its error. It is the
|
|
31
|
+
* small file; this one stays the exactness record.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ Animated WebP is out of scope: it needs a VP8 (lossy) or VP8L (lossless)
|
|
34
|
+
* bitstream encoder, and none ships here.
|
|
35
|
+
*/
|
|
36
|
+
import { deflateSync } from 'node:zlib';
|
|
37
|
+
import { PNG_SIGNATURE, pngChunk } from 'rig-c/tools/plate.ts';
|
|
38
|
+
import { histogram, mapToPalette, medianCut, type PaletteError, paletteError, type Rgba } from './palette.ts';
|
|
39
|
+
import type { Raster } from './raster/types.ts';
|
|
40
|
+
|
|
41
|
+
export interface AnimFrame {
|
|
42
|
+
image: Raster;
|
|
43
|
+
/** How many ticks of `1/fps` this frame is shown for. */
|
|
44
|
+
ticks: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ApngStats {
|
|
48
|
+
/** Frames written into the file, after merging identical neighbours. */
|
|
49
|
+
frames: number;
|
|
50
|
+
/** Frames blended OVER with their unchanged pixels cleared, of `frames - 1` later frames. */
|
|
51
|
+
overFrames: number;
|
|
52
|
+
bytes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class EncodeError extends Error {
|
|
56
|
+
constructor(message: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'EncodeError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function u32(n: number): Uint8Array {
|
|
63
|
+
return new Uint8Array([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function u16(n: number): Uint8Array {
|
|
67
|
+
return new Uint8Array([(n >>> 8) & 255, n & 255]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
|
71
|
+
const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0));
|
|
72
|
+
let at = 0;
|
|
73
|
+
for (const p of parts) {
|
|
74
|
+
out.set(p, at);
|
|
75
|
+
at += p.length;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function paeth(a: number, b: number, c: number): number {
|
|
81
|
+
const p = a + b - c;
|
|
82
|
+
const pa = Math.abs(p - a);
|
|
83
|
+
const pb = Math.abs(p - b);
|
|
84
|
+
const pc = Math.abs(p - c);
|
|
85
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Filtered, deflated scanlines — an IDAT / fdAT payload. `rows` is `height`
|
|
90
|
+
* scanlines of `stride` bytes each, `bpp` bytes per pixel for the filters'
|
|
91
|
+
* left neighbour (1 for any indexed depth, as the PNG spec rounds it up).
|
|
92
|
+
* `adaptive` picks each scanline's filter by the smallest sum of absolute
|
|
93
|
+
* values (libpng's heuristic); otherwise every scanline is filter 0 (None).
|
|
94
|
+
*/
|
|
95
|
+
export function deflateScanlines(stride: number, height: number, bpp: number, rows: Uint8Array, adaptive: boolean): Uint8Array {
|
|
96
|
+
const raw = new Uint8Array((stride + 1) * height);
|
|
97
|
+
const trial = new Uint8Array(stride);
|
|
98
|
+
const best = new Uint8Array(stride);
|
|
99
|
+
for (let y = 0; y < height; y++) {
|
|
100
|
+
const row = rows.subarray(y * stride, (y + 1) * stride);
|
|
101
|
+
if (!adaptive) {
|
|
102
|
+
raw.set(row, y * (stride + 1) + 1);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const up = y > 0 ? rows.subarray((y - 1) * stride, y * stride) : null;
|
|
106
|
+
let bestType = 0;
|
|
107
|
+
let bestScore = Infinity;
|
|
108
|
+
for (let type = 0; type < 5; type++) {
|
|
109
|
+
let score = 0;
|
|
110
|
+
for (let i = 0; i < stride; i++) {
|
|
111
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
112
|
+
const b = up !== null ? up[i] : 0;
|
|
113
|
+
const c = i >= bpp && up !== null ? up[i - bpp] : 0;
|
|
114
|
+
const pred = type === 0 ? 0 : type === 1 ? a : type === 2 ? b : type === 3 ? (a + b) >>> 1 : paeth(a, b, c);
|
|
115
|
+
const v = (row[i] - pred) & 255;
|
|
116
|
+
trial[i] = v;
|
|
117
|
+
score += v < 128 ? v : 256 - v;
|
|
118
|
+
}
|
|
119
|
+
if (score < bestScore) {
|
|
120
|
+
bestScore = score;
|
|
121
|
+
bestType = type;
|
|
122
|
+
best.set(trial);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
raw[y * (stride + 1)] = bestType;
|
|
126
|
+
raw.set(best, y * (stride + 1) + 1);
|
|
127
|
+
}
|
|
128
|
+
return new Uint8Array(deflateSync(raw, { level: 9 }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Filtered, deflated scanlines of an RGBA window — the lossless APNG's IDAT / fdAT payload. */
|
|
132
|
+
export function deflateRgba(width: number, height: number, rgba: Uint8Array): Uint8Array {
|
|
133
|
+
return deflateScanlines(width * 4, height, 4, rgba, true);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sameFrame(a: Raster, b: Raster): boolean {
|
|
137
|
+
if (a.data.length !== b.data.length) return false;
|
|
138
|
+
for (let i = 0; i < a.data.length; i++) if (a.data[i] !== b.data[i]) return false;
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Merge runs of identical neighbours into one frame of their summed ticks. */
|
|
143
|
+
export function mergeRuns(frames: readonly AnimFrame[]): AnimFrame[] {
|
|
144
|
+
const out: AnimFrame[] = [];
|
|
145
|
+
for (const f of frames) {
|
|
146
|
+
const last = out[out.length - 1];
|
|
147
|
+
if (last !== undefined && sameFrame(last.image, f.image)) out[out.length - 1] = { image: last.image, ticks: last.ticks + f.ticks };
|
|
148
|
+
else out.push({ image: f.image, ticks: f.ticks });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The box of pixels that differ between two same-size frames, or null when none does. */
|
|
154
|
+
export function changedBox(prev: Raster, cur: Raster): { x: number; y: number; w: number; h: number } | null {
|
|
155
|
+
let x0 = cur.width;
|
|
156
|
+
let y0 = cur.height;
|
|
157
|
+
let x1 = -1;
|
|
158
|
+
let y1 = -1;
|
|
159
|
+
for (let y = 0; y < cur.height; y++) {
|
|
160
|
+
for (let x = 0; x < cur.width; x++) {
|
|
161
|
+
const i = (y * cur.width + x) * 4;
|
|
162
|
+
if (prev.data[i] !== cur.data[i] || prev.data[i + 1] !== cur.data[i + 1] || prev.data[i + 2] !== cur.data[i + 2] || prev.data[i + 3] !== cur.data[i + 3]) {
|
|
163
|
+
if (x < x0) x0 = x;
|
|
164
|
+
if (x > x1) x1 = x;
|
|
165
|
+
if (y < y0) y0 = y;
|
|
166
|
+
if (y > y1) y1 = y;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function checkFrames(frames: readonly AnimFrame[], fps: number): void {
|
|
174
|
+
if (frames.length === 0) throw new EncodeError('no frames to encode; at least one is required');
|
|
175
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 65535) throw new EncodeError(`fps ${fps}; an integer from 1 to 65535 is required`);
|
|
176
|
+
const { width, height } = frames[0].image;
|
|
177
|
+
if (width < 1 || height < 1) throw new EncodeError(`frame 0 is ${width}x${height}; a frame needs pixels`);
|
|
178
|
+
frames.forEach((f, i) => {
|
|
179
|
+
if (f.image.width !== width || f.image.height !== height) {
|
|
180
|
+
throw new EncodeError(`frame ${i} is ${f.image.width}x${f.image.height}; every frame must be frame 0's ${width}x${height}`);
|
|
181
|
+
}
|
|
182
|
+
if (!Number.isInteger(f.ticks) || f.ticks < 1) throw new EncodeError(`frame ${i} is shown for ${f.ticks} tick(s); a positive integer is required`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Encode frames as an APNG that loops forever, each frame shown for
|
|
188
|
+
* `ticks / fps` seconds.
|
|
189
|
+
*/
|
|
190
|
+
export function encodeApng(input: readonly AnimFrame[], fps: number): { bytes: Uint8Array; stats: ApngStats } {
|
|
191
|
+
checkFrames(input, fps);
|
|
192
|
+
const frames = mergeRuns(input);
|
|
193
|
+
const { width, height } = frames[0].image;
|
|
194
|
+
let seq = 0;
|
|
195
|
+
let overFrames = 0;
|
|
196
|
+
const chunks: Uint8Array[] = [PNG_SIGNATURE];
|
|
197
|
+
chunks.push(pngChunk('IHDR', concat([u32(width), u32(height), new Uint8Array([8, 6, 0, 0, 0])])));
|
|
198
|
+
chunks.push(pngChunk('acTL', concat([u32(frames.length), u32(0)])));
|
|
199
|
+
const fctl = (x: number, y: number, w: number, h: number, ticks: number, blend: number): Uint8Array =>
|
|
200
|
+
pngChunk('fcTL', concat([u32(seq++), u32(w), u32(h), u32(x), u32(y), u16(ticks), u16(fps), new Uint8Array([0, blend])]));
|
|
201
|
+
frames.forEach((f, i) => {
|
|
202
|
+
if (i === 0) {
|
|
203
|
+
chunks.push(fctl(0, 0, width, height, f.ticks, 0));
|
|
204
|
+
chunks.push(pngChunk('IDAT', deflateRgba(width, height, new Uint8Array(f.image.data.buffer, f.image.data.byteOffset, f.image.data.byteLength))));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const prev = frames[i - 1].image;
|
|
208
|
+
// mergeRuns guarantees a difference; the box is never null here.
|
|
209
|
+
const box = changedBox(prev, f.image) ?? { x: 0, y: 0, w: 1, h: 1 };
|
|
210
|
+
const win = new Uint8Array(box.w * box.h * 4);
|
|
211
|
+
let opaque = true;
|
|
212
|
+
for (let y = 0; y < box.h; y++) {
|
|
213
|
+
for (let x = 0; x < box.w; x++) {
|
|
214
|
+
const s = ((box.y + y) * width + box.x + x) * 4;
|
|
215
|
+
if (prev.data[s + 3] !== 255 || f.image.data[s + 3] !== 255) opaque = false;
|
|
216
|
+
win.set(f.image.data.subarray(s, s + 4), (y * box.w + x) * 4);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (opaque) {
|
|
220
|
+
overFrames++;
|
|
221
|
+
for (let y = 0; y < box.h; y++) {
|
|
222
|
+
for (let x = 0; x < box.w; x++) {
|
|
223
|
+
const s = ((box.y + y) * width + box.x + x) * 4;
|
|
224
|
+
const same = prev.data[s] === f.image.data[s] && prev.data[s + 1] === f.image.data[s + 1] && prev.data[s + 2] === f.image.data[s + 2];
|
|
225
|
+
if (same) win.fill(0, (y * box.w + x) * 4, (y * box.w + x) * 4 + 4);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
chunks.push(fctl(box.x, box.y, box.w, box.h, f.ticks, opaque ? 1 : 0));
|
|
230
|
+
chunks.push(pngChunk('fdAT', concat([u32(seq++), deflateRgba(box.w, box.h, win)])));
|
|
231
|
+
});
|
|
232
|
+
chunks.push(pngChunk('IEND', new Uint8Array(0)));
|
|
233
|
+
const bytes = concat(chunks);
|
|
234
|
+
return { bytes, stats: { frames: frames.length, overFrames, bytes: bytes.length } };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The quantised colours of an indexed APNG, before the reserved transparent entry. */
|
|
238
|
+
export const INDEXED_COLOURS = 255;
|
|
239
|
+
|
|
240
|
+
export interface IndexedOptions {
|
|
241
|
+
/** Floyd–Steinberg error diffusion before matching (`src/palette.ts`). */
|
|
242
|
+
dither: boolean;
|
|
243
|
+
/** Per-scanline adaptive filters instead of filter 0 on every scanline. */
|
|
244
|
+
adaptiveFilter: boolean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What `loop` ships: no dithering, filter 0 — the smaller file at the smaller error on both examples (see the PR measuring it). */
|
|
248
|
+
export const INDEXED_DEFAULTS: IndexedOptions = { dither: false, adaptiveFilter: false };
|
|
249
|
+
|
|
250
|
+
export interface IndexedApngStats extends ApngStats {
|
|
251
|
+
/** PLTE entries, the transparent one included; at most 256. */
|
|
252
|
+
entries: number;
|
|
253
|
+
/** Distinct RGBA colours across every input frame. */
|
|
254
|
+
distinct: number;
|
|
255
|
+
/** Bits per index: 1, 2, 4 or 8, the fewest the palette fits in. */
|
|
256
|
+
bitDepth: number;
|
|
257
|
+
/** Palette error against the input frames, over every frame. */
|
|
258
|
+
all: PaletteError;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function packIndices(ix: Uint8Array, w: number, h: number, depth: number): { rows: Uint8Array; stride: number } {
|
|
262
|
+
const stride = Math.ceil((w * depth) / 8);
|
|
263
|
+
if (depth === 8) return { rows: ix, stride };
|
|
264
|
+
const rows = new Uint8Array(stride * h);
|
|
265
|
+
const perByte = 8 / depth;
|
|
266
|
+
for (let y = 0; y < h; y++) {
|
|
267
|
+
for (let x = 0; x < w; x++) {
|
|
268
|
+
const shift = 8 - depth * ((x % perByte) + 1);
|
|
269
|
+
rows[y * stride + Math.floor(x / perByte)] |= ix[y * w + x] << shift;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { rows, stride };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Encode frames as an indexed (colour type 3) APNG that loops forever — the
|
|
277
|
+
* README-sized animation, with its palette error measured and returned.
|
|
278
|
+
*
|
|
279
|
+
* - **One palette for every frame**: entry 0 is reserved fully transparent
|
|
280
|
+
* (`tRNS` alpha 0), entries 1..255 are `src/palette.ts`'s median cut of the
|
|
281
|
+
* union of every frame's RGBA pixels, alpha graded per entry when the frames
|
|
282
|
+
* have translucent pixels. `tRNS` is written only as far as the last entry
|
|
283
|
+
* whose alpha is not 255.
|
|
284
|
+
* - **Bit depth** is the fewest bits the entries fit in (1, 2, 4 or 8).
|
|
285
|
+
* - **Frames are differenced in index space**, as the GIF is: neighbours that
|
|
286
|
+
* quantise to the same indices merge, and every later frame is cropped to the
|
|
287
|
+
* box of indices that changed. When every changed index in the box is an
|
|
288
|
+
* opaque entry, unchanged pixels are written as entry 0 and the frame is
|
|
289
|
+
* blended OVER (exact: OVER of an opaque pixel replaces, of alpha 0 keeps);
|
|
290
|
+
* otherwise the box is written whole and blended SOURCE.
|
|
291
|
+
* - zlib level 9; the filter is {@link IndexedOptions.adaptiveFilter}.
|
|
292
|
+
*/
|
|
293
|
+
export function encodeIndexedApng(input: readonly AnimFrame[], fps: number, opts: IndexedOptions = INDEXED_DEFAULTS): { bytes: Uint8Array; stats: IndexedApngStats } {
|
|
294
|
+
checkFrames(input, fps);
|
|
295
|
+
const images = input.map((f) => f.image);
|
|
296
|
+
const colours = histogram(images);
|
|
297
|
+
const cut = medianCut(colours, INDEXED_COLOURS);
|
|
298
|
+
const mapped = mapToPalette(images, cut, opts.dither);
|
|
299
|
+
const all = paletteError(images, mapped, cut);
|
|
300
|
+
const palette: Rgba[] = [[0, 0, 0, 0], ...cut];
|
|
301
|
+
const { width, height } = images[0];
|
|
302
|
+
const runs: Array<{ ix: Uint8Array; ticks: number }> = [];
|
|
303
|
+
mapped.forEach((m, i) => {
|
|
304
|
+
const ix = new Uint8Array(m.length);
|
|
305
|
+
for (let p = 0; p < m.length; p++) ix[p] = m[p] + 1;
|
|
306
|
+
const last = runs[runs.length - 1];
|
|
307
|
+
if (last !== undefined && last.ix.every((v, p) => v === ix[p])) last.ticks += input[i].ticks;
|
|
308
|
+
else runs.push({ ix, ticks: input[i].ticks });
|
|
309
|
+
});
|
|
310
|
+
const depth = palette.length <= 2 ? 1 : palette.length <= 4 ? 2 : palette.length <= 16 ? 4 : 8;
|
|
311
|
+
let lastTranslucent = 0;
|
|
312
|
+
palette.forEach((c, i) => {
|
|
313
|
+
if (c[3] !== 255) lastTranslucent = i;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
let seq = 0;
|
|
317
|
+
let overFrames = 0;
|
|
318
|
+
const chunks: Uint8Array[] = [PNG_SIGNATURE];
|
|
319
|
+
chunks.push(pngChunk('IHDR', concat([u32(width), u32(height), new Uint8Array([depth, 3, 0, 0, 0])])));
|
|
320
|
+
chunks.push(pngChunk('acTL', concat([u32(runs.length), u32(0)])));
|
|
321
|
+
chunks.push(pngChunk('PLTE', new Uint8Array(palette.flatMap((c) => [c[0], c[1], c[2]]))));
|
|
322
|
+
chunks.push(pngChunk('tRNS', new Uint8Array(palette.slice(0, lastTranslucent + 1).map((c) => c[3]))));
|
|
323
|
+
const fctl = (x: number, y: number, w: number, h: number, ticks: number, blend: number): Uint8Array =>
|
|
324
|
+
pngChunk('fcTL', concat([u32(seq++), u32(w), u32(h), u32(x), u32(y), u16(ticks), u16(fps), new Uint8Array([0, blend])]));
|
|
325
|
+
const payload = (ix: Uint8Array, w: number, h: number): Uint8Array => {
|
|
326
|
+
const { rows, stride } = packIndices(ix, w, h, depth);
|
|
327
|
+
return deflateScanlines(stride, h, 1, rows, opts.adaptiveFilter);
|
|
328
|
+
};
|
|
329
|
+
runs.forEach((run, i) => {
|
|
330
|
+
if (i === 0) {
|
|
331
|
+
chunks.push(fctl(0, 0, width, height, run.ticks, 0));
|
|
332
|
+
chunks.push(pngChunk('IDAT', payload(run.ix, width, height)));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const prev = runs[i - 1].ix;
|
|
336
|
+
let x0 = width;
|
|
337
|
+
let y0 = height;
|
|
338
|
+
let x1 = -1;
|
|
339
|
+
let y1 = -1;
|
|
340
|
+
let opaque = true;
|
|
341
|
+
for (let y = 0; y < height; y++) {
|
|
342
|
+
for (let x = 0; x < width; x++) {
|
|
343
|
+
const p = y * width + x;
|
|
344
|
+
if (prev[p] === run.ix[p]) continue;
|
|
345
|
+
if (palette[run.ix[p]][3] !== 255) opaque = false;
|
|
346
|
+
if (x < x0) x0 = x;
|
|
347
|
+
if (x > x1) x1 = x;
|
|
348
|
+
if (y < y0) y0 = y;
|
|
349
|
+
if (y > y1) y1 = y;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Merged runs differ, so the box is never empty here.
|
|
353
|
+
const w = x1 - x0 + 1;
|
|
354
|
+
const h = y1 - y0 + 1;
|
|
355
|
+
const win = new Uint8Array(w * h);
|
|
356
|
+
for (let y = 0; y < h; y++) {
|
|
357
|
+
for (let x = 0; x < w; x++) {
|
|
358
|
+
const p = (y0 + y) * width + x0 + x;
|
|
359
|
+
win[y * w + x] = opaque && prev[p] === run.ix[p] ? 0 : run.ix[p];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (opaque) overFrames++;
|
|
363
|
+
chunks.push(fctl(x0, y0, w, h, run.ticks, opaque ? 1 : 0));
|
|
364
|
+
chunks.push(pngChunk('fdAT', concat([u32(seq++), payload(win, w, h)])));
|
|
365
|
+
});
|
|
366
|
+
chunks.push(pngChunk('IEND', new Uint8Array(0)));
|
|
367
|
+
const bytes = concat(chunks);
|
|
368
|
+
return { bytes, stats: { frames: runs.length, overFrames, bytes: bytes.length, entries: palette.length, distinct: colours.length, bitDepth: depth, all } };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The chunk types of a PNG file, in order — what the selftest reads the structure back with. */
|
|
372
|
+
export function chunkTypes(bytes: Uint8Array): string[] {
|
|
373
|
+
const out: string[] = [];
|
|
374
|
+
let at = 8;
|
|
375
|
+
while (at + 8 <= bytes.length) {
|
|
376
|
+
const len = ((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0;
|
|
377
|
+
out.push(String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]));
|
|
378
|
+
at += 12 + len;
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|