@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.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -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/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
|
+
}
|