@rig-c/parts 0.0.0-stage → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/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
|
+
}
|