@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/coords.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one door to the coordinate conversion.
3
+ *
4
+ * Everything this package measures is in crop pixels — y down, origin at the
5
+ * top-left — because that is what a painting, a See-through layer and a part
6
+ * PNG are. Spine's world is y up with its origin at the bottom-left of the
7
+ * crop. rig-c owns that conversion (`src/transform.ts`: `cropToSpineY`,
8
+ * `toBoneLocal`, `computeWorldTransforms`) and its own doctrine forbids a
9
+ * second copy of it anywhere, so this package does not write one: every stage
10
+ * that needs Spine coordinates imports them from here, and here re-exports
11
+ * rigc's.
12
+ *
13
+ * A y flip written anywhere else in `src/` is the defect this file exists to
14
+ * prevent; the selftest holds the tree to it.
15
+ *
16
+ * The same goes for a bone's frame. Since issue #73 a chain link is turned to
17
+ * point along its chain, so a point under it is placed by inverting the
18
+ * link's setup matrix: `src/rig.ts` evaluates the setup pose with rigc's
19
+ * `computeExactFrameTransforms` (the textbook arithmetic, in which an
20
+ * unturned bone's frame is the identity exactly, so a bone that is not turned
21
+ * writes the very numbers it wrote before) and binds through `toBoneLocal`;
22
+ * `flattenRig` goes back through `toWorld`. No rotation matrix is written out
23
+ * by hand in this package.
24
+ */
25
+ export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from 'rig-c/src/transform.ts';
26
+ export type { BoneTransform } from 'rig-c/src/transform.ts';
@@ -0,0 +1,198 @@
1
+ /**
2
+ * What `propose` looked at, said (issue #86): the coverage lines lint's
3
+ * per-bone record prints, and the basis file written beside the proposal.
4
+ *
5
+ * ## Coverage
6
+ *
7
+ * `lint` (`src/propose.ts`) returns, beside its findings, one record per bone
8
+ * of the spec: the checks that read it, or why none did. {@link coverageLines}
9
+ * prints one line per bone and a summary that counts "checked, clean",
10
+ * "checked, LINT" and "not checked" apart, with the roles
11
+ * (`src/structure.ts`'s {@link configRoles}, imported, never derived again).
12
+ * The lines are printed after every line `propose` printed before them, so
13
+ * nothing that was printed before changes.
14
+ *
15
+ * ## The basis file
16
+ *
17
+ * `basis.json` (spec {@link BASIS_SPEC}) records, for every bone the proposal
18
+ * places, what its origin — and its tip, where the proposal states one —
19
+ * rests on: a joint (`joint`), a measurement off a part (`mask`), a rule of
20
+ * proportion (`ratio`) or other bones (`derived`), recorded in `propose()`
21
+ * where the placement happens. Under `propose --compare <config>` each bone
22
+ * also says whether the config's bone differs from the proposal's and by how
23
+ * far, through `src/structure.ts`'s comparison (origin, tip and parent); a
24
+ * bone only one side has is said to be that, and nothing more is inferred —
25
+ * a difference is a fact, not a verdict on either side. No confidence figure
26
+ * is computed anywhere; a score a keypoint file carried is recorded as the
27
+ * file's.
28
+ *
29
+ * The file is written only after green: {@link checkBasis} holds it to the
30
+ * proposal and the parts before anything is written, and refuses by name.
31
+ * Fixed key order, two-space indent, trailing newline — the same inputs
32
+ * write the same bytes.
33
+ */
34
+ import { type Problem, refuseIfAny } from './errors.ts';
35
+ import { BASIS_KINDS, type BasisKind, type BoneBasis, type BoneCoverage, COVERAGE_OUTCOMES, coverageOutcome, expand, FRAME_KEYS, type FrameBasis, type FrameKey, type PartSet, type Placement, type Proposal, type ProposalBasis } from './propose.ts';
36
+ import { BONE_ROLES, type BoneRole, compareStructure, readSkeleton } from './structure.ts';
37
+
38
+ export const BASIS_SPEC = 'spine-parts-basis/1';
39
+
40
+ /** The file's name, beside `proposal.json`. */
41
+ export const BASIS_FILE = 'basis.json';
42
+
43
+ /**
44
+ * Under `--compare`: the config's bone against the proposal's. `status` is
45
+ * `differs` when its origin or tip is anywhere else, or its parent is another
46
+ * bone, or one side states a tip the other does not; `proposal only` when the
47
+ * config has no bone of that name.
48
+ */
49
+ export interface ConfigDelta {
50
+ status: 'same' | 'differs' | 'proposal only';
51
+ /** Rig px between the two origins; `null` for a bone the config does not have. */
52
+ origin_px: number | null;
53
+ /** Rig px between the two tips; `null` with `tip_why` when it is not a figure. */
54
+ tip_px: number | null;
55
+ tip_why: string | null;
56
+ parent: { proposal: string; config: string | null };
57
+ }
58
+
59
+ export interface BasisRecord extends BoneBasis {
60
+ /** `null` without `--compare`. */
61
+ config: ConfigDelta | null;
62
+ }
63
+
64
+ export interface BasisFile {
65
+ spec: typeof BASIS_SPEC;
66
+ frame: Record<FrameKey, FrameBasis>;
67
+ /** The config `--compare` named, as `structure.ts` labels it; `null` without `--compare`. */
68
+ compared_with: string | null;
69
+ bones: BasisRecord[];
70
+ /** Under `--compare`, the config's bones the proposal does not have; `null` without it. */
71
+ config_only: string[] | null;
72
+ }
73
+
74
+ /**
75
+ * Hold the record to the proposal before it is written: one record per
76
+ * proposed bone, in the proposal's order, and every name it gives resolves —
77
+ * each part to a part of the set, each bone to a bone of the proposal. A
78
+ * `joint` placement names at least one joint and every other kind none; a
79
+ * `derived` placement names at least one bone; a constant is a finite number.
80
+ */
81
+ export function checkBasis(P: PartSet, proposal: Proposal, basis: ProposalBasis): void {
82
+ const problems: Problem[] = [];
83
+ const names = [...expand(proposal.bones).byName.keys()];
84
+ const bones = new Set(names);
85
+ const parts = new Set(P.recs.map((p) => p.name));
86
+ const recorded = basis.bones.map((b) => b.bone);
87
+ if (recorded.join('\n') !== names.join('\n')) {
88
+ const missing = names.filter((n) => !recorded.includes(n));
89
+ const extra = recorded.filter((n) => !bones.has(n));
90
+ const twice = recorded.filter((n, i) => recorded.indexOf(n) !== i);
91
+ problems.push({
92
+ code: 'BASIS_RECORDS_EVERY_BONE',
93
+ object: BASIS_FILE,
94
+ detail:
95
+ `records ${recorded.length} bone(s) for the proposal's ${names.length}; one record per proposed bone, in the proposal's order, is required` +
96
+ `${missing.length > 0 ? `; none for ${missing.join(', ')}` : ''}${extra.length > 0 ? `; records ${extra.join(', ')}, which the proposal does not place` : ''}${twice.length > 0 ? `; twice: ${twice.join(', ')}` : ''}` +
97
+ `${missing.length === 0 && extra.length === 0 && twice.length === 0 ? ' (the same bones in another order)' : ''}`,
98
+ });
99
+ }
100
+ for (const [k, f] of Object.entries(basis.frame)) {
101
+ for (const p of f.parts) if (!parts.has(p)) problems.push({ code: 'BASIS_NAMES_RESOLVE', object: `${BASIS_FILE} frame.${k}`, detail: `names the part "${p}", which parts.json does not hold` });
102
+ }
103
+ const hold = (b: BoneBasis, which: 'origin' | 'tip', pl: Placement): void => {
104
+ const object = `${BASIS_FILE} bone "${b.bone}" ${which}`;
105
+ if (!BASIS_KINDS.includes(pl.kind)) problems.push({ code: 'BASIS_KIND_KNOWN', object, detail: `has the kind ${JSON.stringify(pl.kind)}; one of ${BASIS_KINDS.join(', ')} is required` });
106
+ for (const p of pl.parts) if (!parts.has(p)) problems.push({ code: 'BASIS_NAMES_RESOLVE', object, detail: `names the part "${p}", which parts.json does not hold` });
107
+ for (const n of pl.bones) if (!bones.has(n)) problems.push({ code: 'BASIS_NAMES_RESOLVE', object, detail: `names the bone "${n}", which the proposal does not place` });
108
+ for (const f of pl.frame) if (!FRAME_KEYS.includes(f)) problems.push({ code: 'BASIS_NAMES_RESOLVE', object, detail: `names the frame quantity "${f}"; one of ${FRAME_KEYS.join(', ')} is required` });
109
+ if (pl.kind === 'joint' && pl.joints.length === 0) problems.push({ code: 'BASIS_KIND_HOLDS', object, detail: 'is a joint placement that names no joint; a joint placement names the joints it took' });
110
+ if (pl.kind !== 'joint' && pl.joints.length > 0) problems.push({ code: 'BASIS_KIND_HOLDS', object, detail: `is a ${pl.kind} placement that names the joint(s) ${pl.joints.map((j) => j.name).join(', ')}; only a joint placement takes a joint` });
111
+ if (pl.kind === 'derived' && pl.bones.length === 0) problems.push({ code: 'BASIS_KIND_HOLDS', object, detail: 'is a derived placement that names no bone; a derived placement names the bones it is derived from' });
112
+ for (const c of pl.constants) if (!Number.isFinite(c.value)) problems.push({ code: 'BASIS_KIND_HOLDS', object, detail: `carries the constant ${c.name} = ${c.value}; a finite number is required` });
113
+ };
114
+ for (const b of basis.bones) {
115
+ hold(b, 'origin', b.origin);
116
+ if (b.tip !== null) hold(b, 'tip', b.tip);
117
+ }
118
+ refuseIfAny(problems);
119
+ }
120
+
121
+ /**
122
+ * The basis file: the proposal's record held by {@link checkBasis} and, given
123
+ * the config `--compare` read, each bone against the config's through
124
+ * `src/structure.ts` (equal names; the proposal is the left side).
125
+ */
126
+ export function basisFile(P: PartSet, proposal: Proposal, basis: ProposalBasis, compared: { where: string; config: unknown } | null): BasisFile {
127
+ checkBasis(P, proposal, basis);
128
+ if (compared === null) return { spec: BASIS_SPEC, frame: basis.frame, compared_with: null, bones: basis.bones.map((b) => ({ ...b, config: null })), config_only: null };
129
+ const left = readSkeleton(proposal, 'proposal');
130
+ const right = readSkeleton(compared.config, compared.where);
131
+ const c = compareStructure(left, right, null);
132
+ const rows = new Map(c.rows.map((r) => [r.left, r]));
133
+ const bones = basis.bones.map((b): BasisRecord => {
134
+ const l = left.byName.get(b.bone);
135
+ const r = right.byName.get(b.bone);
136
+ const row = rows.get(b.bone);
137
+ if (l === undefined || r === undefined || row === undefined) {
138
+ return { ...b, config: { status: 'proposal only', origin_px: null, tip_px: null, tip_why: 'the config has no bone of this name', parent: { proposal: l?.parent ?? '', config: null } } };
139
+ }
140
+ const origin = 'value' in row.origin ? row.origin.value : null;
141
+ const tip = 'value' in row.tip ? row.tip.value : null;
142
+ // structure.ts names the sides left and right; here the left is the proposal and the right the config.
143
+ const tipWhy = 'skip' in row.tip ? row.tip.skip.replace(/^left: /, 'proposal: ').replace(/(^|; )right: /, '$1config: ') : null;
144
+ const differs = origin !== 0 || row.parent.kind !== 'same' || (tip !== null && tip !== 0) || (l.tip === null) !== (r.tip === null);
145
+ return { ...b, config: { status: differs ? 'differs' : 'same', origin_px: origin, tip_px: tip, tip_why: tipWhy, parent: { proposal: l.parent ?? '', config: r.parent } } };
146
+ });
147
+ const placed = new Set(basis.bones.map((b) => b.bone));
148
+ // `root` is every rig's and states no position on either side: not a bone either one placed.
149
+ const configOnly = right.bones.map((b) => b.name).filter((n) => n !== 'root' && !placed.has(n));
150
+ return { spec: BASIS_SPEC, frame: basis.frame, compared_with: right.label, bones, config_only: configOnly };
151
+ }
152
+
153
+ /** Fixed key order, two-space indent, trailing newline. */
154
+ export function serializeBasis(f: BasisFile): string {
155
+ return `${JSON.stringify(f, null, 2)}\n`;
156
+ }
157
+
158
+ /** Bones per basis kind of their origin, in {@link BASIS_KINDS} order. */
159
+ export function basisCounts(f: BasisFile): Record<BasisKind, number> {
160
+ const out: Record<BasisKind, number> = { joint: 0, mask: 0, ratio: 0, derived: 0 };
161
+ for (const b of f.bones) out[b.origin.kind]++;
162
+ return out;
163
+ }
164
+
165
+ /** The line `propose` prints after writing the file. */
166
+ export function basisLine(f: BasisFile, out: string): string {
167
+ const k = basisCounts(f);
168
+ const kinds = BASIS_KINDS.map((x) => `${k[x]} ${x}`).join(', ');
169
+ let cmp = '';
170
+ if (f.compared_with !== null) {
171
+ const n = (s: ConfigDelta['status']): number => f.bones.filter((b) => b.config?.status === s).length;
172
+ cmp = `; against ${f.compared_with}: ${n('differs')} differ, ${n('same')} same, ${n('proposal only')} in the proposal only, ${(f.config_only ?? []).length} in the config only`;
173
+ }
174
+ return `wrote ${BASIS_FILE} (${f.bones.length} bone record(s) by the basis of their origin: ${kinds}${cmp}) under ${out}`;
175
+ }
176
+
177
+ /** One coverage line per bone, then the summary. Every line starts `coverage`. */
178
+ export function coverageLines(coverage: readonly BoneCoverage[]): string[] {
179
+ const out: string[] = [];
180
+ for (const c of coverage) {
181
+ const role = c.role === null ? 'role not read' : c.role.role;
182
+ const outcome = coverageOutcome(c);
183
+ if (outcome === 'not checked') out.push(`coverage ${c.bone} [${role}]: not checked — ${c.why.join('; ')}`);
184
+ else out.push(`coverage ${c.bone} [${role}]: ${outcome} — read by ${c.read.join(', ')}${c.lint > 0 ? `; named by ${c.lint} LINT line(s)` : ''}`);
185
+ }
186
+ const by = (o: (typeof COVERAGE_OUTCOMES)[number]): number => coverage.filter((c) => coverageOutcome(c) === o).length;
187
+ const roles: Record<BoneRole, number> = { target: 0, deforms: 0, control: 0, unclassified: 0 };
188
+ let unread = 0;
189
+ for (const c of coverage) {
190
+ if (c.role === null) unread++;
191
+ else roles[c.role.role]++;
192
+ }
193
+ out.push(
194
+ `coverage: ${coverage.length} bone(s) — ${by('checked, clean')} checked and clean, ${by('checked, LINT')} checked with a LINT line, ${by('not checked')} not checked; ` +
195
+ `roles ${BONE_ROLES.map((r) => `${roles[r]} ${r}`).join(', ')}${unread > 0 ? `, ${unread} not read` : ''}`,
196
+ );
197
+ return out;
198
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one error shape every stage refuses with.
3
+ *
4
+ * An agent that cannot see the image reads these and nothing else, so a
5
+ * problem carries three things the reader can act on without guessing: the
6
+ * CODE it can grep for, the OBJECT it concerns (a layer, a config field, a
7
+ * file), and a sentence naming the value found and the value required. A
8
+ * refusal that says only "invalid" is not a refusal this repository writes.
9
+ *
10
+ * A reader collects every problem it can find before it throws, so one run
11
+ * names every missing field rather than one per attempt.
12
+ */
13
+ export interface Problem {
14
+ /** Upper-snake name of the rule that refused, e.g. `LAYERS_PNG_PRESENT`. */
15
+ code: string;
16
+ /** The thing refused, spelled the way the input spells it: `layer "face"`, `config.bones[3].parent`. */
17
+ object: string;
18
+ /** What was found and what was required. */
19
+ detail: string;
20
+ }
21
+
22
+ export function problemLine(p: Problem): string {
23
+ return `${p.code}: ${p.object} — ${p.detail}`;
24
+ }
25
+
26
+ export class PartsError extends Error {
27
+ readonly problems: readonly Problem[];
28
+
29
+ constructor(problems: readonly Problem[]) {
30
+ super(problems.map(problemLine).join('\n'));
31
+ this.name = 'PartsError';
32
+ this.problems = problems;
33
+ }
34
+ }
35
+
36
+ /** Throw once, with every problem, if there are any. */
37
+ export function refuseIfAny(problems: readonly Problem[]): void {
38
+ if (problems.length > 0) throw new PartsError(problems);
39
+ }
package/src/gif.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Animated GIF encoder — GIF89a, one global palette chosen by median cut,
3
+ * LZW, a NETSCAPE2.0 block that loops forever.
4
+ *
5
+ * Pure: bytes in, bytes out, no dependency, no clock, no randomness, and
6
+ * deterministic — every sort has a total order, so the same frames write the
7
+ * same bytes.
8
+ *
9
+ * What the encoder does:
10
+ *
11
+ * - **One palette for every frame**, 255 colours cut from the histogram of all
12
+ * frames together, plus one transparent index. A per-frame palette would make
13
+ * a still background shimmer between frames and would forbid the next step.
14
+ * The cut is `src/palette.ts`'s variance-driven median cut, the one the
15
+ * indexed APNG uses too, so the two files' palette errors are one figure;
16
+ * every colour maps to its nearest entry. **No dithering**: it would add
17
+ * noise the frame differencing below has to pay for, and the palette error is
18
+ * reported instead of hidden.
19
+ * - **Consecutive frames whose quantised pixels are identical are merged**,
20
+ * and every later frame is cropped to the box of indices that changed, with
21
+ * the unchanged ones written as the transparent index over the previous frame
22
+ * (disposal 1, "do not dispose").
23
+ * - **Delays are in centiseconds and the fps is not**: 12 fps is 8.33 cs. So
24
+ * each frame's delay is the difference of its rounded start and end times,
25
+ * `round(100 * end / fps) - round(100 * start / fps)` — 8, 9, 8, 8, 9, 8, …
26
+ * at 12 fps, which keeps the loop's total length exact rather than drifting
27
+ * by 4 % as a flat 8 cs would.
28
+ *
29
+ * GIF holds no partial alpha. Frames with a translucent pixel are refused by
30
+ * name rather than thresholded: rig-c renders over an opaque background,
31
+ * so a translucent frame is not one this encoder was built for.
32
+ *
33
+ * ⚠️ Animated WebP is out of scope: it needs a VP8 or VP8L bitstream encoder,
34
+ * and none ships here.
35
+ */
36
+ import { type AnimFrame, checkFrames, EncodeError } from './apng.ts';
37
+ import { histogram, mapToPalette, medianCut, type PaletteError, paletteError } from './palette.ts';
38
+
39
+ export const PALETTE_COLOURS = 255;
40
+ export const TRANSPARENT_INDEX = 255;
41
+
42
+ export type { PaletteError };
43
+
44
+ export interface GifStats {
45
+ frames: number;
46
+ colours: number;
47
+ /** Distinct RGB colours across every input frame. */
48
+ distinct: number;
49
+ frame0: PaletteError;
50
+ all: PaletteError;
51
+ bytes: number;
52
+ }
53
+
54
+ /** GIF has one bit of alpha: a translucent pixel is refused by name, not thresholded. */
55
+ function refuseTranslucent(frames: readonly AnimFrame[]): void {
56
+ frames.forEach((f, fi) => {
57
+ const d = f.image.data;
58
+ for (let i = 0; i < d.length; i += 4) {
59
+ if (d[i + 3] !== 255) {
60
+ const p = i / 4;
61
+ throw new EncodeError(`frame ${fi} pixel ${p % f.image.width},${Math.floor(p / f.image.width)} has alpha ${d[i + 3]}; GIF holds no partial alpha, so every pixel must be opaque (255)`);
62
+ }
63
+ }
64
+ });
65
+ }
66
+
67
+ /** LZW-compress palette indices the way GIF wants it, with `minCode` bits for the root alphabet. */
68
+ export function lzw(indices: Uint8Array, minCode: number): Uint8Array {
69
+ const clear = 1 << minCode;
70
+ const eoi = clear + 1;
71
+ const out: number[] = [];
72
+ let acc = 0;
73
+ let bits = 0;
74
+ let size = minCode + 1;
75
+ const emit = (code: number): void => {
76
+ acc |= code << bits;
77
+ bits += size;
78
+ while (bits >= 8) {
79
+ out.push(acc & 255);
80
+ acc >>>= 8;
81
+ bits -= 8;
82
+ }
83
+ };
84
+ let table = new Map<number, number>();
85
+ let next = eoi + 1;
86
+ emit(clear);
87
+ if (indices.length === 0) {
88
+ emit(eoi);
89
+ if (bits > 0) out.push(acc & 255);
90
+ return new Uint8Array(out);
91
+ }
92
+ let prefix = indices[0];
93
+ for (let i = 1; i < indices.length; i++) {
94
+ const k = indices[i];
95
+ const key = (prefix << 8) | k;
96
+ const hit = table.get(key);
97
+ if (hit !== undefined) {
98
+ prefix = hit;
99
+ continue;
100
+ }
101
+ emit(prefix);
102
+ if (next === 4096) {
103
+ emit(clear);
104
+ table = new Map();
105
+ next = eoi + 1;
106
+ size = minCode + 1;
107
+ } else {
108
+ if (next >= 1 << size) size++;
109
+ table.set(key, next++);
110
+ }
111
+ prefix = k;
112
+ }
113
+ emit(prefix);
114
+ emit(eoi);
115
+ if (bits > 0) out.push(acc & 255);
116
+ return new Uint8Array(out);
117
+ }
118
+
119
+ function subBlocks(data: Uint8Array): number[] {
120
+ const out: number[] = [];
121
+ for (let at = 0; at < data.length; at += 255) {
122
+ const n = Math.min(255, data.length - at);
123
+ out.push(n);
124
+ for (let i = 0; i < n; i++) out.push(data[at + i]);
125
+ }
126
+ out.push(0);
127
+ return out;
128
+ }
129
+
130
+ function le16(n: number): number[] {
131
+ return [n & 255, (n >>> 8) & 255];
132
+ }
133
+
134
+ /** Encode frames as a looping GIF, each frame shown for `ticks / fps` seconds. */
135
+ export function encodeGif(input: readonly AnimFrame[], fps: number): { bytes: Uint8Array; stats: GifStats } {
136
+ checkFrames(input, fps);
137
+ refuseTranslucent(input);
138
+ const images = input.map((f) => f.image);
139
+ const colours = histogram(images);
140
+ const palette = medianCut(colours, PALETTE_COLOURS);
141
+ const { width, height } = input[0].image;
142
+ const indexed = mapToPalette(images, palette, false);
143
+ const frame0 = paletteError([images[0]], [indexed[0]], palette);
144
+ const all = paletteError(images, indexed, palette);
145
+
146
+ // Merge neighbours that quantise to the same indices.
147
+ const runs: Array<{ ix: Uint8Array; ticks: number }> = [];
148
+ indexed.forEach((ix, i) => {
149
+ const last = runs[runs.length - 1];
150
+ if (last !== undefined && last.ix.every((v, p) => v === ix[p])) last.ticks += input[i].ticks;
151
+ else runs.push({ ix, ticks: input[i].ticks });
152
+ });
153
+
154
+ const out: number[] = [];
155
+ for (const ch of 'GIF89a') out.push(ch.charCodeAt(0));
156
+ out.push(...le16(width), ...le16(height), 0xf7, 0, 0);
157
+ for (let i = 0; i < 256; i++) {
158
+ const c = palette[i] ?? [0, 0, 0];
159
+ out.push(c[0], c[1], c[2]);
160
+ }
161
+ out.push(0x21, 0xff, 0x0b);
162
+ for (const ch of 'NETSCAPE2.0') out.push(ch.charCodeAt(0));
163
+ out.push(0x03, 0x01, 0, 0, 0);
164
+
165
+ let tick = 0;
166
+ runs.forEach((run, i) => {
167
+ const start = Math.round((100 * tick) / fps);
168
+ tick += run.ticks;
169
+ const delay = Math.round((100 * tick) / fps) - start;
170
+ let x0 = 0;
171
+ let y0 = 0;
172
+ let w = width;
173
+ let h = height;
174
+ let body = run.ix;
175
+ let transparent = false;
176
+ if (i > 0) {
177
+ const prev = runs[i - 1].ix;
178
+ let bx0 = width;
179
+ let by0 = height;
180
+ let bx1 = -1;
181
+ let by1 = -1;
182
+ for (let y = 0; y < height; y++) {
183
+ for (let x = 0; x < width; x++) {
184
+ if (prev[y * width + x] !== run.ix[y * width + x]) {
185
+ if (x < bx0) bx0 = x;
186
+ if (x > bx1) bx1 = x;
187
+ if (y < by0) by0 = y;
188
+ if (y > by1) by1 = y;
189
+ }
190
+ }
191
+ }
192
+ x0 = bx0;
193
+ y0 = by0;
194
+ w = bx1 - bx0 + 1;
195
+ h = by1 - by0 + 1;
196
+ body = new Uint8Array(w * h);
197
+ for (let y = 0; y < h; y++) {
198
+ for (let x = 0; x < w; x++) {
199
+ const p = (y0 + y) * width + x0 + x;
200
+ body[y * w + x] = prev[p] === run.ix[p] ? TRANSPARENT_INDEX : run.ix[p];
201
+ }
202
+ }
203
+ transparent = true;
204
+ }
205
+ out.push(0x21, 0xf9, 0x04, (1 << 2) | (transparent ? 1 : 0), ...le16(delay), TRANSPARENT_INDEX, 0);
206
+ out.push(0x2c, ...le16(x0), ...le16(y0), ...le16(w), ...le16(h), 0);
207
+ out.push(8);
208
+ for (const v of subBlocks(lzw(body, 8))) out.push(v);
209
+ });
210
+ out.push(0x3b);
211
+ const bytes = new Uint8Array(out);
212
+ return { bytes, stats: { frames: runs.length, colours: palette.length, distinct: colours.length, frame0, all, bytes: bytes.length } };
213
+ }