@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +431 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +137 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. 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
+ }