@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
|
@@ -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
|
+
}
|