@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
@@ -0,0 +1,167 @@
1
+ ---
2
+ name: rig-parts
3
+ description: Turn one anime character painting and its See-through layer decomposition into a verified 2D character rig (Spine 4.3 skeleton data) — measured parts, weighted meshes, bone chains and a looping idle — with rig-parts, which gates everything through rig-c before it is written. Use for a request to rig or animate a single character painting, to run or read rig-parts layers, sheet, assemble, propose, rig, check, loop or build, or to write or fix a rig-parts config.json. Not for cutting art into parts by hand, for authoring a rig from loose PNGs (that is the rigc skill), for lip-sync or swapping a part's image, or for running See-through itself.
4
+ license: MIT
5
+ compatibility: Requires Bun 1.2 or later. The tool is the npm package @rig-c/parts (npm i -D @rig-c/parts, or bun add -d @rig-c/parts), whose command is rig-parts; rig-c is installed with it. See-through is run separately.
6
+ ---
7
+
8
+ # rig-parts — a painting to a Spine rig, for agents
9
+
10
+ rig-parts merges See-through's two layer runs over one painting into rig-space
11
+ parts, authors a rig spec and a motion spec over them, and hands those to
12
+ rig-c, which compiles, gates, packs and renders. You cannot see the painting,
13
+ the layers or the rig. The printed lines, `docs/AUTHORING.md` and the files each
14
+ stage writes are the whole interface.
15
+
16
+ ## Non-negotiables
17
+
18
+ - **rig-c's gate is never bypassed.** rig-parts writes no Spine data
19
+ itself; `rig` writes only after rigc's `build` gate is green, and `build`'s
20
+ artifact is what rigc wrote. There is no flag that skips it, and none may be added.
21
+ `check.json`'s `rigc_entry` names which gate ran: rigc's own validator in an
22
+ install, the spine-core round trip where the runtime is installed beside rigc.
23
+ - **No value is invented.** A missing config field is a refusal naming it. Take a
24
+ value from the command that proposes it (`propose --head-box`,
25
+ `assemble --propose-plan`, `propose`) or decide it yourself — never guess one
26
+ because the tool refused.
27
+ - **The refusal is the instruction.** `FAIL RULE: object — detail` names the object,
28
+ the value found and the value required; AUTHORING §6 maps every rule to the field
29
+ or input that has to change.
30
+ - **Green is not "looks right".** `check` measures the gates, the seam, the loop and
31
+ six judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`,
32
+ `TIP_OVER_ROOT`, `STILL_REGIONS_DARK` — its face half in the head's own frame —
33
+ and `TEXTURE_STRETCH`) (AUTHORING §7). A judgement line that says
34
+ SKIP was not verified: report it as such, with its reason, never as passed.
35
+ No line judges a bang over the face or the eyes: `STILL_REGIONS_DARK`'s
36
+ `crossing` reports how deep each one swings, and only an eye can say whether
37
+ that reads right.
38
+ - **What a scene asks is declared, not assumed.** When the request says what the
39
+ motion must do — a hand on a cup, a sleeve taking half an arm's turn, a joint
40
+ inside a range, a fringe that must not part from the face — write it into a `spine-parts-requirements/1` file and pass
41
+ `check --requirements <file>` (or `build --requirements`); every bar in it is the
42
+ requester's, never yours to invent (AUTHORING §7, *Declared requirements*). Each
43
+ line is PASS, FAIL or NOT MEASURABLE; a NOT MEASURABLE is not green, and a kind the
44
+ file does not declare was not measured — say so, never "passed".
45
+ - **A hole in the layers is invisible to every bar.** Painting that no part holds is
46
+ missing from the setup pose and from the flat stack alike, so the seam passes over
47
+ it. `assemble` lists it (`uncovered hole N: <px> px at x,y wxh (between …)`) and
48
+ paints it red in `recomposite_error_rig.png`; `check` copies it into `check.json` as
49
+ `RECOMPOSITE_HOLES`, a REPORTED line with no bar. Read it; never report it as a
50
+ pass.
51
+
52
+ ## The loop
53
+
54
+ 1. `rig-parts inputs --source <painting> --config config.json --out <dir>` writes
55
+ `st_input_full.png`, the painting on a white square. The config needs only `key`,
56
+ `seethrough` and `assemble.rig_scale` at this point. Run See-through on it — any
57
+ route; the optional `comfy seethrough` adapter only talks to a ComfyUI box.
58
+ 2. `rig-parts layers <full run>` — read the table. A `WARN PLAN_LAYER_…` line names
59
+ a layer See-through invented (a translucent haze, or one out of proportion);
60
+ the plan step leaves it out and says so in its `notes`.
61
+ 3. `rig-parts propose --head-box --full <full run> --canvas <W>x<H>` → put
62
+ `head_box` in the config, run `inputs` again (it now also writes
63
+ `st_input_head.png`, the crop at that box), and run See-through on the crop.
64
+ 4. `rig-parts assemble --propose-plan …` → `config.assemble.plan` and
65
+ `extend_below_crop`.
66
+ 5. `rig-parts assemble … --out work` — read the `uncovered hole` lines and look at
67
+ `work/render/recomposite_error_rig.png`; a large red hole between two parts is a
68
+ gap See-through left (a skirt split into two legs), fixed in the plan or the layers,
69
+ not in the rig. Then `rig-parts propose --parts work/rig
70
+ --source <painting> --out work` → copy `bones`, `meshes`, `regions`, `motion`
71
+ from `proposal.json` into the config. `assemble` needs the plan and none of
72
+ those four; do not stub them to get past it (AUTHORING §4 says what each step
73
+ requires). If an `uncovered hole` stays large with the plan right — a piece
74
+ of the figure neither run holds, red in `recomposite_error_rig.png` — add an
75
+ `assemble.patches` entry (a rig-pixel box, `alpha: "silhouette"`, drawn
76
+ `"back"` and reaching under its neighbours, its bone in `regions`) and assemble
77
+ again; never add a part to `rig/` or `parts/` by hand (AUTHORING §5).
78
+ One layer holding two things that must move apart (a scarf end inside
79
+ `topwear`, both arms in one `handwear`): add an `assemble.cuts` entry — a
80
+ polygon in rig pixels read off `work/rig/parts/<part>.png` plus its
81
+ `parts.json` `x`, `y`, a new name, a `draw`, an `overlap` (the band the part
82
+ keeps under the piece's edge; 1 px closed the seam on scarf) — and give the piece its own
83
+ `meshes` entry; the `cut:` line gives the split. At overlap 0 a cut through
84
+ opaque art leaves a seam `check` reads (AUTHORING §5).
85
+ A seated or reclining figure: add `--keypoints <file>` (body-18 joints in
86
+ painting px, each observed, occluded or missing; AUTHORING §3, *A posed
87
+ figure*) so the neck, hip, chest and sleeves follow the pose and LINT reads
88
+ the joints, not screen y. Never write a joint you did not get from an
89
+ estimator or the painting: say `missing` and let the rule stand in.
90
+ 6. Correct against `render/landmarks.png` and `note:` lines; a
91
+ `coverage … not checked` line is a bone no check read (the line says why),
92
+ and `basis.json` beside the proposal says what each bone rests on — check
93
+ the `ratio` bones and any with a `fallback` first. `propose …
94
+ --from-config config.json` (with the same `--keypoints`) until it prints no
95
+ `LINT` line. `rig-parts compare
96
+ --left work/proposal.json --right config.json` lists what the correction changed,
97
+ bone by bone — origin, parent, tip, length, direction (AUTHORING §4).
98
+ A constraint the motion needs (an ik reaching a scene target, physics on a
99
+ link) goes in `config.constraints`, in rig-c's own rig-spec shape: the
100
+ loader resolves the bones it names and nothing else, and rigc's gate refuses
101
+ the rest in its words. A scene target is a single bone under `root`. A two-bone
102
+ ik over links the idle keys needs `--idle-keys direct` on `rig` or `build`
103
+ (rigc refuses the pair the link's control splits, and the line names the flag
104
+ on the command that ran; AUTHORING §3).
105
+ 7. `rig-parts build --config … --source … --full … --head … --out out --loop`
106
+ (add `--idle-keys direct` when step 6 said so).
107
+ It stops at the first stage that refuses, printing that stage's lines under
108
+ `[assemble]`, `[rig]` or `[check]`. Fix what the FAIL line names and build again.
109
+ 8. Read `out/check/check.json` — the six judgement lines the same way as the seam
110
+ and the loop: a FAIL names the field to change (AUTHORING §6);
111
+ `TEXTURE_STRETCH` names the worst mesh triangle (slot, triangle, vertices, edge,
112
+ frame), so quote it even on a PASS. Quote
113
+ `RECOMPOSITE_HOLES` (uncovered px, hole count, the largest hole's box) in the
114
+ report whatever `PASS` says. The artifact is the three paths the build prints
115
+ last: `check/build/skeleton.json`, `skeleton.atlas` and the packed page. Report
116
+ them with the pack line and the check figures verbatim.
117
+
118
+ Several characters in one rig: build each one green on its own, then
119
+ `rig-parts compose --scene scene.json --out scene` (spec `spine-parts-scene/1`:
120
+ the canvas, each character's `build` and `offset`, an optional plate with its
121
+ `provenance`, and the `order` of the characters' slots, back to front). You write
122
+ the order and the offsets; nothing infers them, nothing scales a character, and
123
+ nothing detects or separates people. Report the plate's provenance as you declared
124
+ it and the order as `scene.json` records it (AUTHORING §7, *Composing several
125
+ characters*).
126
+
127
+ `--seam silhouette` repairs more of the seam on white garments than the default
128
+ `near-white` (the demo: 10,340 → 8,644 recomposite error pixels); the default is
129
+ the reference implementation's rule. `--project visible` lets thin visible parts
130
+ (lashes, brows, irises) take the painting's pixels, which the default `core` rule
131
+ erodes away. Name both rules you used in your report, and quote the assemble
132
+ `pixels:` line: its occluded share is hidden art, its "visible but not projected"
133
+ share is synthesis where the painting was there to be taken.
134
+
135
+ ## What this will not do
136
+
137
+ - Run See-through or generate the painting itself. The optional `comfy seethrough`
138
+ and `comfy paint` commands drive a ComfyUI box you name; nothing else here reaches
139
+ a GPU or the network.
140
+ - Give a layer two depths: a layer in front of another in one place and behind it in
141
+ another is right in the still and wrong in motion.
142
+ - Propose, key or judge any animation but the looping `idle`, or lip-sync: every slot
143
+ holds one attachment, so nothing swaps a part's image. Expressions and a jiggle are
144
+ yours to build on top (below).
145
+ - Write animated WebP: `loop` writes GIF and APNG.
146
+ - Know what the art does not tag: a swinging element painted inside another layer,
147
+ hair of an unfamiliar shape, whether an accessory swings. Those are yours to add.
148
+ - Promise a success rate. Three public characters and eight private ones check green;
149
+ that is all that has been measured.
150
+
151
+ ## Where to read next
152
+
153
+ [docs/AUTHORING.md](https://github.com/rig-c/parts/blob/main/docs/AUTHORING.md)
154
+ first — every config field, the command order, what to read after each stage, every
155
+ refusal. It is in the installed package at `node_modules/@rig-c/parts/docs/AUTHORING.md`,
156
+ the copy that matches the version you run. Two recipes there cover what this package
157
+ leaves to you:
158
+
159
+ - An expression or any other named animation: AUTHORING §3, *Recipe: expressions and other
160
+ named animations* (`motion.animations_from`; built and gated with the rig, not measured).
161
+ - A jiggle (a bust, the hips): AUTHORING §3, *Recipe: a soft region driven by physics* (a
162
+ config-only bone, a `contour` region on it, a `physics` constraint; `examples/sample`
163
+ carries one, and its slow idle barely moves it).
164
+
165
+ A `RIG_RIGC_GREEN` or `CHECK_RIGC_GREEN`
166
+ line quotes rig-c's own assertion; rig-c's guide for those is
167
+ `node_modules/rig-c/docs/AUTHORING.md`, and its `rigc` skill covers the compiler.
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
+ }