@rig-c/parts 0.0.0-stage → 1.5.4

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 +433 -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 +150 -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/motion.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * The idle: every channel a sine, one blink, and the control bones that move
3
+ * idle keys off the bones a mesh is weighted to.
4
+ *
5
+ * ## Sines
6
+ *
7
+ * `v(t) = base + amp * sin(2 pi (t / period - phase))`, sampled at
8
+ * {@link KEYS_PER_PERIOD} keys per period across the idle, each key carrying
9
+ * the EXACT Hermite tangent of the sine as a raw bezier `curve`:
10
+ * `[t + dt/3, v + m dt/3, t1 - dt/3, v1 - m1 dt/3]` with `m = v'(t)`. A
11
+ * cubic bezier with those handles matches the sine's value and slope at both
12
+ * ends of every span, so the loop has no hitch at the seam and none inside.
13
+ * The loader already refuses a period that does not divide the idle
14
+ * (`CONFIG_PERIOD_DIVIDES_DURATION`), so the last key is the first key's value.
15
+ *
16
+ * A chain track drives `rotate` on every link, link `i` at phase
17
+ * `phase + lag * i` and amplitude `amps[i]` — amplitude growing and phase
18
+ * lagging down the chain is written in the config, not computed here. A
19
+ * single track without `base` oscillates about 0: `base` is the value the
20
+ * sine is centred on, and an absent one is the sine as written.
21
+ *
22
+ * Times are rounded to 6 decimals and values to 4, as the reference wrote them.
23
+ *
24
+ * ## Blink
25
+ *
26
+ * The `eyes` group's `scaley` goes 1 -> `squash` -> 1 and the `brows`
27
+ * group's `translatey` 0 -> `-brow_drop` -> 0 (no brows group and no brow
28
+ * track when the config states no `brows`; no blink groups or tracks at all
29
+ * when it states no `blink`), with a fixed timing: eyes shut
30
+ * over {@link BLINK.shut} s, hold {@link BLINK.hold} s, open over
31
+ * {@link BLINK.open} s; brows 0.08 / 0.084 / 0.20 s. The shut and open times
32
+ * and the two named easings are the reference's; the holds are not (issue
33
+ * #32). The reference held 0.04 s, under one frame of the idle the loop is
34
+ * encoded from ({@link IDLE_FPS} fps, a frame every 0.083333 s), so whether
35
+ * any frame showed the closed eye depended on `blink.t`: at the examples'
36
+ * 2.3 s the eyes were shut from 2.37 s to 2.41 s, between frames 28
37
+ * (2.333 s, scaley 0.8356) and 29 (2.417 s, 0.2435), and over every key time
38
+ * the 6-decimal grid can write, 129,999 of 250,000 phases against the frame
39
+ * grid showed none. A closed interval at least one frame period long holds a
40
+ * frame wherever it starts, so the hold is 1/12 s rounded up to the third
41
+ * place, 0.084 s; {@link blinkHoldMisses} counts the phases and the rig stage
42
+ * refuses a hold with any miss (`RIG_BLINK_HOLD_SPANS_A_FRAME`). The brows'
43
+ * hold grew by the same 0.044 s, so they still reach their drop 0.01 s after
44
+ * the lids shut and leave it 0.01 s after the lids open, as the reference
45
+ * ordered them. A blink whose window does not fit strictly inside the
46
+ * idle would write keys out of order, and is refused (`RIG_BLINK_INSIDE_IDLE`).
47
+ * The squash pivots at each eye bone's origin, so a part's rows above it move
48
+ * down by (1 - squash) times their height above it; the rows a
49
+ * `motion.blink.still` entry cuts off sit on a bone no group names and do not
50
+ * move at all (`src/rig.ts`).
51
+ *
52
+ * ## Control bones — `A15_IDLE_NO_MESH_BONE_KEYS`
53
+ *
54
+ * rig-c's `spine-html` profile refuses an `idle` that keys a bone a mesh
55
+ * is weighted to (the player skips idle work on meshes). The reference's
56
+ * answer, ported as is and still the default (`rig --idle-keys ctl`): for
57
+ * every keyed bone that some mesh names among its candidates, add a parent
58
+ * `<bone>_ctl` at the SAME origin, re-parent the bone under it, and move the
59
+ * keys to the control. The pose is the same up to float rounding: the
60
+ * extra bone in each chain moves the rendered idle frames by at most one
61
+ * level in a channel (measured on both public examples, rig-parts #13).
62
+ *
63
+ * ⚠️ **This satisfies the rule's wording only.** The meshes are still
64
+ * deformed every frame — by the control's motion, through the bone they are
65
+ * weighted to — so whatever cost `A15` exists to keep off the player is paid
66
+ * all the same. Measured (rig-parts #13): on both public examples every
67
+ * shown mesh has a driving bone whose world transform changes on every idle
68
+ * frame, with the controls and without them, so a dirty-skip renderer can
69
+ * skip none either way. rig-c 1.3.0 answered the rule's side
70
+ * (`invariants.idleDrivesMeshes`); `rig --idle-keys direct` keys the bones in
71
+ * place and declares it (`src/rig.ts`, `IDLE_KEYS`).
72
+ *
73
+ * One correction to the reference: it moved single-bone tracks to the control
74
+ * but left the blink GROUPS naming the original bone, so a mesh-bound eye or
75
+ * brow bone would get a control nobody keys and keep its own keys — red under
76
+ * `A15`. Here a group member that got a control is renamed with it.
77
+ */
78
+ import type { CharacterConfig } from './config.ts';
79
+ import { pyRound } from './round.ts';
80
+
81
+ export const KEYS_PER_PERIOD = 8;
82
+
83
+ /**
84
+ * The rate the idle is rendered at — `check`'s `idle_frames/`, its contact
85
+ * sheet, and the loop `build --loop` encodes from them. The reference renders
86
+ * at it too.
87
+ */
88
+ export const IDLE_FPS = 12;
89
+
90
+ /** The blink's fixed timing, in seconds after `blink.t`. The holds are 1/{@link IDLE_FPS} s rounded up (see the Blink section above). */
91
+ export const BLINK = {
92
+ shut: 0.07,
93
+ hold: 0.084,
94
+ open: 0.16,
95
+ browShut: 0.08,
96
+ browHold: 0.084,
97
+ browOpen: 0.2,
98
+ } as const;
99
+
100
+ export const EASINGS: Record<string, [number, number, number, number]> = {
101
+ shut: [0.5, 0, 0.9, 0.6],
102
+ open: [0.1, 0.4, 0.4, 1],
103
+ };
104
+
105
+ export const IDLE_NOTE =
106
+ 'Breath (chest), head roll, hair/accessory sway, sleeve and hem sway, one blink. Sines with exact tangents; phase lags down every chain.';
107
+
108
+ export const CONTROL_SUFFIX = '_ctl';
109
+
110
+ export interface MotionKey {
111
+ t: number;
112
+ v: number[];
113
+ curve?: [number, number, number, number];
114
+ ease?: string;
115
+ }
116
+
117
+ export interface MotionTrack {
118
+ bone?: string;
119
+ group?: string;
120
+ property: string;
121
+ keys: MotionKey[];
122
+ }
123
+
124
+ export interface MotionSpec {
125
+ spec: 'rigc-motion/1';
126
+ archetype: string;
127
+ cut: string;
128
+ easings: Record<string, [number, number, number, number]>;
129
+ groups: Record<string, string[]>;
130
+ animations: { idle: { duration: number; loop: true; note: string; tracks: MotionTrack[] } };
131
+ }
132
+
133
+ /**
134
+ * The motion `motion.json` holds: the idle, and after it every animation the
135
+ * file `config.motion.animations_from` names, as read (issue #183) — nothing
136
+ * added, nothing re-keyed, nothing judged; each is rigc's to accept at the
137
+ * gate. With none, `motion` itself, so a config that names no file writes the
138
+ * bytes it wrote before the field existed.
139
+ */
140
+ export function withAnimations(motion: MotionSpec, extra: Readonly<Record<string, unknown>> | null): MotionSpec | Record<string, unknown> {
141
+ if (extra === null) return motion;
142
+ return { ...motion, animations: { ...motion.animations, ...extra } };
143
+ }
144
+
145
+ /** One sine channel over an idle of `duration` seconds. */
146
+ export function sineTrack(bone: string, property: string, amp: number, period: number, phase: number, base: number, duration: number): MotionTrack {
147
+ const n = Math.round((duration / period) * KEYS_PER_PERIOD);
148
+ const w = (2 * Math.PI) / period;
149
+ const raw: Array<[number, number, number]> = [];
150
+ for (let k = 0; k <= n; k++) {
151
+ const t = (duration * k) / n;
152
+ const arg = w * t - 2 * Math.PI * phase;
153
+ raw.push([t, base + amp * Math.sin(arg), amp * w * Math.cos(arg)]);
154
+ }
155
+ const keys: MotionKey[] = raw.map(([t, v, m], i) => {
156
+ const key: MotionKey = { t: pyRound(t, 6), v: [pyRound(v, 4)] };
157
+ if (i < raw.length - 1) {
158
+ const [t1, v1, m1] = raw[i + 1];
159
+ const dt = t1 - t;
160
+ key.curve = [pyRound(t + dt / 3, 6), pyRound(v + (m * dt) / 3, 4), pyRound(t1 - dt / 3, 6), pyRound(v1 - (m1 * dt) / 3, 4)];
161
+ }
162
+ return key;
163
+ });
164
+ return { bone, property, keys };
165
+ }
166
+
167
+ /** The idle as the config states it, before control bones. `chains` maps a chain name to its link names. */
168
+ export function idleMotion(cfg: CharacterConfig, chains: ReadonlyMap<string, string[]>): MotionSpec {
169
+ const mc = cfg.motion;
170
+ const T = mc.duration;
171
+ const tracks: MotionTrack[] = [];
172
+ for (const tr of mc.tracks) {
173
+ if ('chain' in tr) {
174
+ const links = chains.get(tr.chain) ?? [];
175
+ links.forEach((link, i) => tracks.push(sineTrack(link, 'rotate', tr.amps[i], tr.period, tr.phase + tr.lag * i, 0, T)));
176
+ } else {
177
+ tracks.push(sineTrack(tr.bone, tr.prop, tr.amp, tr.period, tr.phase, tr.base ?? 0, T));
178
+ }
179
+ }
180
+ const groups: Record<string, string[]> = {};
181
+ const bl = mc.blink;
182
+ if (bl !== undefined) {
183
+ const tb = bl.t;
184
+ groups.eyes = [...bl.eyes];
185
+ // A blink without brows (no eyebrow part) writes no brows group: rigc
186
+ // refuses a group with no members, and the loader refuses an empty one.
187
+ if (bl.brows !== undefined) groups.brows = [...bl.brows];
188
+ const at = (dt: number): number => pyRound(tb + dt, 6);
189
+ tracks.push({
190
+ group: 'eyes',
191
+ property: 'scaley',
192
+ keys: [
193
+ { t: 0, v: [1] },
194
+ { t: tb, v: [1], ease: 'shut' },
195
+ { t: at(BLINK.shut), v: [bl.squash] },
196
+ { t: at(BLINK.shut + BLINK.hold), v: [bl.squash], ease: 'open' },
197
+ { t: at(BLINK.shut + BLINK.hold + BLINK.open), v: [1] },
198
+ { t: T, v: [1] },
199
+ ],
200
+ });
201
+ const drop = bl.brow_drop;
202
+ if (bl.brows !== undefined && drop !== undefined) {
203
+ tracks.push({
204
+ group: 'brows',
205
+ property: 'translatey',
206
+ keys: [
207
+ { t: 0, v: [0] },
208
+ { t: tb, v: [0], ease: 'shut' },
209
+ { t: at(BLINK.browShut), v: [-drop] },
210
+ { t: at(BLINK.browShut + BLINK.browHold), v: [-drop], ease: 'open' },
211
+ { t: at(BLINK.browShut + BLINK.browHold + BLINK.browOpen), v: [0] },
212
+ { t: T, v: [0] },
213
+ ],
214
+ });
215
+ }
216
+ }
217
+ const name = `${cfg.key}_painting`;
218
+ return {
219
+ spec: 'rigc-motion/1',
220
+ archetype: name,
221
+ cut: name,
222
+ easings: { shut: [...EASINGS.shut], open: [...EASINGS.open] },
223
+ groups,
224
+ animations: { idle: { duration: T, loop: true, note: IDLE_NOTE, tracks } },
225
+ };
226
+ }
227
+
228
+ /**
229
+ * The idle frames `0 .. count - 1` at `fps` whose time lies in the closed
230
+ * window `[from, to]` — the frames that show a held key. The same reading
231
+ * `check` writes as `BLINK_NO_HOLE.idle_frames_closed`.
232
+ */
233
+ export function framesInside(from: number, to: number, fps: number, count: number): number[] {
234
+ const out: number[] = [];
235
+ for (let k = 0; k < count; k++) if (k / fps >= from - 1e-9 && k / fps <= to + 1e-9) out.push(k);
236
+ return out;
237
+ }
238
+
239
+ export interface HoldMisses {
240
+ /** The phases tried: every microsecond key time over one period of the frame grid against the 6-decimal grid. */
241
+ phases: number;
242
+ /** Of those, the ones whose closed window holds no frame. */
243
+ misses: number;
244
+ /** The first missing `blink.t`, in seconds, or null. */
245
+ first: number | null;
246
+ }
247
+
248
+ /**
249
+ * Over every `blink.t` the idle can write, how many put no frame at `fps`
250
+ * inside the eyes' closed window `[t + shut, t + shut + hold]`, both ends
251
+ * rounded to 6 decimals as {@link idleMotion} writes them.
252
+ *
253
+ * Key times are 6-decimal numbers, and a frame falls at `k * 10^6 / fps`
254
+ * microseconds, so the pattern of frames against the microsecond grid
255
+ * repeats every `10^6 / gcd(fps, 10^6)` microseconds (250,000 at 12 fps).
256
+ * Trying every `t` over one such period is therefore every case, not a
257
+ * sample. A 1/120 s step was the brief's first choice and was rejected: it
258
+ * tries 30 of those 250,000 phases.
259
+ */
260
+ export function blinkHoldMisses(shut: number, hold: number, fps: number): Readonly<HoldMisses> {
261
+ const key = `${shut} ${hold} ${fps}`;
262
+ const known = HOLD_MISSES.get(key);
263
+ if (known !== undefined) return known;
264
+ const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
265
+ const period = 1e6 / gcd(fps, 1e6);
266
+ let misses = 0;
267
+ let first: number | null = null;
268
+ for (let j = 0; j < period; j++) {
269
+ const t = j / 1e6;
270
+ const from = pyRound(t + shut, 6);
271
+ const to = pyRound(t + shut + hold, 6);
272
+ const k = Math.ceil((from - 1e-9) * fps);
273
+ if (!(k / fps >= from - 1e-9 && k / fps <= to + 1e-9)) {
274
+ misses++;
275
+ if (first === null) first = pyRound(t, 6);
276
+ }
277
+ }
278
+ const out = { phases: period, misses, first };
279
+ HOLD_MISSES.set(key, out);
280
+ return out;
281
+ }
282
+
283
+ /** {@link blinkHoldMisses} is a pure function of its three numbers and costs a quarter of a million roundings; the rig stage asks it the same question on every build. */
284
+ const HOLD_MISSES = new Map<string, Readonly<HoldMisses>>();
285
+
286
+ /** The last time any blink key sits at before the closing key, in seconds after `blink.t`, for a blink with brows. */
287
+ export const BLINK_SPAN = Math.max(BLINK.shut + BLINK.hold + BLINK.open, BLINK.browShut + BLINK.browHold + BLINK.browOpen);
288
+
289
+ /** {@link BLINK_SPAN} for this blink: without brows, only the eyes' keys have to fit inside the idle. */
290
+ export function blinkSpan(bl: { brows?: readonly string[] }): number {
291
+ return bl.brows === undefined ? BLINK.shut + BLINK.hold + BLINK.open : BLINK_SPAN;
292
+ }
293
+
294
+ /**
295
+ * The bones that get a `<bone>_ctl`: keyed by a single track or named by a
296
+ * blink group, AND named by some mesh's candidates. Sorted, as the reference
297
+ * sorted them — the order they are appended to the bone table.
298
+ */
299
+ export function controlledBones(motion: MotionSpec, meshBones: ReadonlySet<string>): string[] {
300
+ const keyed = new Set<string>();
301
+ for (const t of motion.animations.idle.tracks) if (t.bone !== undefined) keyed.add(t.bone);
302
+ for (const members of Object.values(motion.groups)) for (const b of members) if (meshBones.has(b)) keyed.add(b);
303
+ return [...keyed].filter((b) => meshBones.has(b)).sort();
304
+ }
305
+
306
+ /** Rename every key on a controlled bone — track or group member — to its control. */
307
+ export function moveKeysToControls(motion: MotionSpec, controlled: readonly string[]): void {
308
+ const set = new Set(controlled);
309
+ for (const t of motion.animations.idle.tracks) if (t.bone !== undefined && set.has(t.bone)) t.bone = `${t.bone}${CONTROL_SUFFIX}`;
310
+ for (const g of Object.keys(motion.groups)) motion.groups[g] = motion.groups[g].map((b) => (set.has(b) ? `${b}${CONTROL_SUFFIX}` : b));
311
+ }
package/src/palette.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The one palette quantiser, shared by the GIF writer (`src/gif.ts`) and the
3
+ * indexed APNG writer (`encodeIndexedApng` in `src/apng.ts`): variance-driven
4
+ * median cut over the histogram of every frame's RGBA pixels together.
5
+ *
6
+ * Pure and deterministic — every sort has a total order (channel value, then
7
+ * the colour's packed key), ties go to the lower box index, and nothing reads a
8
+ * clock or a random source — so the same frames cut the same palette.
9
+ *
10
+ * The cut: the box whose pixels have the largest summed squared error round
11
+ * their mean is split next, on its widest channel, at the pixel-weighted
12
+ * median. Each box's colour is its pixel-weighted mean, rounded. Every colour
13
+ * then maps to its nearest entry by squared RGBA distance, lowest index on a
14
+ * tie.
15
+ *
16
+ * Alpha is a fourth channel like the other three, so a translucent edge gets
17
+ * graded palette alpha when the frames have one. A channel that is constant
18
+ * over a box contributes exactly 0 to its variance — computed as a branch, not
19
+ * as `sum(v^2) - sum(v)^2 / n`, which in float64 is not exactly 0 once `n`
20
+ * passes a few million pixels, and would perturb which box splits next. On
21
+ * frames that are opaque everywhere (every frame rig-c renders over its
22
+ * background) alpha is constant, and the cut is the three-channel median cut
23
+ * `src/gif.ts` has always made, byte for byte.
24
+ */
25
+ import type { Raster } from './raster/types.ts';
26
+
27
+ export type Rgba = readonly [number, number, number, number];
28
+
29
+ export interface Colour {
30
+ /** `((r * 256 + g) * 256 + b) * 256 + a`, a float64-exact integer below 2^32. */
31
+ key: number;
32
+ c: Rgba;
33
+ /** Pixels of this colour, over every frame. */
34
+ n: number;
35
+ }
36
+
37
+ export function keyOf(r: number, g: number, b: number, a: number): number {
38
+ return ((r * 256 + g) * 256 + b) * 256 + a;
39
+ }
40
+
41
+ /** Every distinct RGBA colour of the images, with its pixel count, sorted by key. */
42
+ export function histogram(images: readonly Raster[]): Colour[] {
43
+ const counts = new Map<number, number>();
44
+ for (const im of images) {
45
+ const d = im.data;
46
+ for (let i = 0; i < d.length; i += 4) {
47
+ const key = keyOf(d[i], d[i + 1], d[i + 2], d[i + 3]);
48
+ counts.set(key, (counts.get(key) ?? 0) + 1);
49
+ }
50
+ }
51
+ return [...counts.entries()]
52
+ .sort((a, b) => a[0] - b[0])
53
+ .map(([key, n]) => ({ key, c: [Math.floor(key / 16777216), Math.floor(key / 65536) % 256, Math.floor(key / 256) % 256, key % 256] as const, n }));
54
+ }
55
+
56
+ interface Box {
57
+ colours: Colour[];
58
+ n: number;
59
+ sse: number;
60
+ axis: 0 | 1 | 2 | 3;
61
+ }
62
+
63
+ function makeBox(colours: Colour[]): Box {
64
+ let n = 0;
65
+ const sum = [0, 0, 0, 0];
66
+ const sq = [0, 0, 0, 0];
67
+ const lo = [255, 255, 255, 255];
68
+ const hi = [0, 0, 0, 0];
69
+ for (const col of colours) {
70
+ n += col.n;
71
+ for (let k = 0; k < 4; k++) {
72
+ const v = col.c[k];
73
+ sum[k] += v * col.n;
74
+ sq[k] += v * v * col.n;
75
+ if (v < lo[k]) lo[k] = v;
76
+ if (v > hi[k]) hi[k] = v;
77
+ }
78
+ }
79
+ const variance = [0, 1, 2, 3].map((k) => (lo[k] === hi[k] ? 0 : sq[k] - (sum[k] * sum[k]) / n));
80
+ let axis: 0 | 1 | 2 | 3 = variance[0] >= variance[1] && variance[0] >= variance[2] ? 0 : variance[1] >= variance[2] ? 1 : 2;
81
+ if (variance[3] > variance[axis]) axis = 3;
82
+ return { colours, n, sse: colours.length < 2 ? 0 : variance[0] + variance[1] + variance[2] + variance[3], axis };
83
+ }
84
+
85
+ /** Median cut to at most `size` colours. */
86
+ export function medianCut(colours: Colour[], size: number): Rgba[] {
87
+ if (colours.length === 0) return [];
88
+ const boxes: Box[] = [makeBox(colours)];
89
+ while (boxes.length < size) {
90
+ let pick = -1;
91
+ for (let i = 0; i < boxes.length; i++) if (boxes[i].sse > 0 && (pick < 0 || boxes[i].sse > boxes[pick].sse)) pick = i;
92
+ if (pick < 0) break;
93
+ const box = boxes[pick];
94
+ const k = box.axis;
95
+ const sorted = [...box.colours].sort((a, b) => a.c[k] - b.c[k] || a.key - b.key);
96
+ let acc = 0;
97
+ let cut = 1;
98
+ for (let i = 0; i < sorted.length - 1; i++) {
99
+ acc += sorted[i].n;
100
+ cut = i + 1;
101
+ if (acc * 2 >= box.n) break;
102
+ }
103
+ // Both halves are non-empty because the box holds at least two colours.
104
+ boxes.splice(pick, 1, makeBox(sorted.slice(0, cut)), makeBox(sorted.slice(cut)));
105
+ }
106
+ return boxes.map((b) => {
107
+ const m = [0, 0, 0, 0];
108
+ for (const col of b.colours) for (let k = 0; k < 4; k++) m[k] += col.c[k] * col.n;
109
+ return [Math.round(m[0] / b.n), Math.round(m[1] / b.n), Math.round(m[2] / b.n), Math.round(m[3] / b.n)] as const;
110
+ });
111
+ }
112
+
113
+ /** The index of the entry nearest `(r, g, b, a)` by squared distance, lowest index on a tie. */
114
+ export function nearest(palette: readonly Rgba[], r: number, g: number, b: number, a: number): number {
115
+ let best = 0;
116
+ let bestD = Infinity;
117
+ for (let i = 0; i < palette.length; i++) {
118
+ const p = palette[i];
119
+ const d = (p[0] - r) ** 2 + (p[1] - g) ** 2 + (p[2] - b) ** 2 + (p[3] - a) ** 2;
120
+ if (d < bestD) {
121
+ bestD = d;
122
+ best = i;
123
+ }
124
+ }
125
+ return best;
126
+ }
127
+
128
+ export interface PaletteError {
129
+ /** Largest per-channel |original - palette| over R, G and B of every pixel measured. */
130
+ max: number;
131
+ /** Mean per-channel |original - palette| over R, G and B, in levels of 255. */
132
+ mean: number;
133
+ /** Largest |original alpha - palette alpha|; 0 on opaque frames. */
134
+ alphaMax: number;
135
+ }
136
+
137
+ /** Per-channel error of index planes against the images they were quantised from. */
138
+ export function paletteError(images: readonly Raster[], index: readonly Uint8Array[], palette: readonly Rgba[]): PaletteError {
139
+ let max = 0;
140
+ let alphaMax = 0;
141
+ let sum = 0;
142
+ let count = 0;
143
+ images.forEach((im, f) => {
144
+ for (let p = 0; p < im.width * im.height; p++) {
145
+ const c = palette[index[f][p]];
146
+ for (let k = 0; k < 3; k++) {
147
+ const e = Math.abs(im.data[p * 4 + k] - c[k]);
148
+ if (e > max) max = e;
149
+ sum += e;
150
+ count++;
151
+ }
152
+ const ea = Math.abs(im.data[p * 4 + 3] - c[3]);
153
+ if (ea > alphaMax) alphaMax = ea;
154
+ }
155
+ });
156
+ return { max, mean: count === 0 ? 0 : sum / count, alphaMax };
157
+ }
158
+
159
+ /**
160
+ * Map every pixel of every image to its nearest palette entry (among the
161
+ * first `usable` entries, so a reserved entry past them is never chosen).
162
+ *
163
+ * With `dither`, Floyd–Steinberg error diffusion — 7/16 right, 3/16 below
164
+ * left, 5/16 below, 1/16 below right — in a fixed raster scan, left to right,
165
+ * top to bottom, each frame on its own; the diffused value is clamped to
166
+ * 0..255 before it is matched. Deterministic, because the scan order is.
167
+ */
168
+ export function mapToPalette(images: readonly Raster[], palette: readonly Rgba[], dither: boolean): Uint8Array[] {
169
+ const memo = new Map<number, number>();
170
+ const lookup = (r: number, g: number, b: number, a: number): number => {
171
+ const key = keyOf(r, g, b, a);
172
+ let ix = memo.get(key);
173
+ if (ix === undefined) {
174
+ ix = nearest(palette, r, g, b, a);
175
+ memo.set(key, ix);
176
+ }
177
+ return ix;
178
+ };
179
+ return images.map((im) => {
180
+ const { width, height, data } = im;
181
+ const ix = new Uint8Array(width * height);
182
+ if (!dither) {
183
+ for (let p = 0; p < ix.length; p++) ix[p] = lookup(data[p * 4], data[p * 4 + 1], data[p * 4 + 2], data[p * 4 + 3]);
184
+ return ix;
185
+ }
186
+ let cur = new Float64Array(width * 4);
187
+ let next = new Float64Array(width * 4);
188
+ for (let y = 0; y < height; y++) {
189
+ for (let x = 0; x < width; x++) {
190
+ const p = y * width + x;
191
+ const v = [0, 0, 0, 0];
192
+ for (let k = 0; k < 4; k++) v[k] = Math.min(255, Math.max(0, Math.round(data[p * 4 + k] + cur[x * 4 + k])));
193
+ const i = lookup(v[0], v[1], v[2], v[3]);
194
+ ix[p] = i;
195
+ for (let k = 0; k < 4; k++) {
196
+ const e = data[p * 4 + k] + cur[x * 4 + k] - palette[i][k];
197
+ if (x + 1 < width) cur[(x + 1) * 4 + k] += (e * 7) / 16;
198
+ if (x > 0) next[(x - 1) * 4 + k] += (e * 3) / 16;
199
+ next[x * 4 + k] += (e * 5) / 16;
200
+ if (x + 1 < width) next[(x + 1) * 4 + k] += e / 16;
201
+ }
202
+ }
203
+ [cur, next] = [next, cur];
204
+ next.fill(0);
205
+ }
206
+ return ix;
207
+ });
208
+ }