@voqalize/avatar 0.2.1 → 0.3.0
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 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// author/rig.mjs — the authoring plumbing that survived a change of style.
|
|
3
|
+
//
|
|
4
|
+
// Two generators (avatars/facet, avatars/round) were written from scratch and
|
|
5
|
+
// then cloned across a style change. Exactly one layer came through
|
|
6
|
+
// byte-identical, and it is all in here: the paint registry, the control-vector
|
|
7
|
+
// machinery, the rebuild-and-diff pose harness, the small colour maths and the
|
|
8
|
+
// serialiser's contract defaults. Everything about the *character* — landmarks,
|
|
9
|
+
// geometry tables, the draw-builder body, palettes and ramps, control-channel
|
|
10
|
+
// names, lighting — deliberately stayed in each avatar's own build.mjs.
|
|
11
|
+
//
|
|
12
|
+
// Authoring-time only in intent — but PURE, and that is now load-bearing: it
|
|
13
|
+
// imports nothing from `node:`, so `avatars/round/face.mjs` (and the two parts
|
|
14
|
+
// under `parts/`, which import `clamp`/`lerp`/`drawPusher` back out of here)
|
|
15
|
+
// can be imported by a BROWSER. `src/live.js` evaluates the face at runtime and
|
|
16
|
+
// that is the whole chain it drags in. The one function that had to touch the
|
|
17
|
+
// filesystem, `writeRig`, moved to `finish.mjs` — its only caller — for exactly
|
|
18
|
+
// that reason.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { r2, rotMat } from './path.mjs';
|
|
22
|
+
import { DRIVER_DEFAULTS } from '../src/vocab.js';
|
|
23
|
+
import { eyeRest } from './parts/eye.mjs';
|
|
24
|
+
import { mouthRest } from './parts/mouth.mjs';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Small maths. Fifteen lines, copied byte-for-byte between both generators.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
|
31
|
+
export const sstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
|
|
32
|
+
export const lerp = (a, b, t) => a + (b - a) * t;
|
|
33
|
+
|
|
34
|
+
export function hsl(h, s, l) {
|
|
35
|
+
h = ((h % 360) + 360) % 360;
|
|
36
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
37
|
+
const hp = h / 60;
|
|
38
|
+
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
39
|
+
let r = 0, g = 0, b = 0;
|
|
40
|
+
if (hp < 1) [r, g, b] = [c, x, 0];
|
|
41
|
+
else if (hp < 2) [r, g, b] = [x, c, 0];
|
|
42
|
+
else if (hp < 3) [r, g, b] = [0, c, x];
|
|
43
|
+
else if (hp < 4) [r, g, b] = [0, x, c];
|
|
44
|
+
else if (hp < 5) [r, g, b] = [x, 0, c];
|
|
45
|
+
else [r, g, b] = [c, 0, x];
|
|
46
|
+
const m = l - c / 2;
|
|
47
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255), 1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Paint registry. Solids are deduped on a 3dp key, so a builder can call
|
|
52
|
+
// solid() freely from inside a loop without spraying near-identical entries.
|
|
53
|
+
// `paints` is the live array the serialiser wants; hold onto the reference.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export function paintRegistry() {
|
|
57
|
+
const paints = [];
|
|
58
|
+
const paintKey = new Map();
|
|
59
|
+
function solid(c) {
|
|
60
|
+
const k = c.map((v) => Math.round(v * 1000) / 1000).join(',');
|
|
61
|
+
if (paintKey.has(k)) return paintKey.get(k);
|
|
62
|
+
const i = paints.length;
|
|
63
|
+
paints.push({ t: 'solid', c: [c[0], c[1], c[2], c[3]] });
|
|
64
|
+
paintKey.set(k, i);
|
|
65
|
+
return i;
|
|
66
|
+
}
|
|
67
|
+
return { paints, solid };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// The draw pusher, and the per-draw override hook.
|
|
72
|
+
//
|
|
73
|
+
// All three builders open with the same line: an `out` array and a `push` that
|
|
74
|
+
// appends `{ slot, group, cmds, paint, a }`. Two of them then spread a sixth
|
|
75
|
+
// argument over it, because `toRig` passes four more fields through verbatim
|
|
76
|
+
// (`stroke`, `rule`, `blend`, `m` — see author/README.md) and nothing offered a
|
|
77
|
+
// place to put them. This is that place:
|
|
78
|
+
//
|
|
79
|
+
// push('limbal', HEAD, ring(...), solid(P.limbal), 1, { rule: 'evenodd' });
|
|
80
|
+
// push('toothSh', HEAD, band(...), solid(P.toothSh), tA, { blend: 'multiply' });
|
|
81
|
+
// push('faceInk', HEAD, spline(pts, 1), INK, 1, { stroke: STROKE(W_SIL) });
|
|
82
|
+
//
|
|
83
|
+
// `a` stays a POSITIONAL argument rather than joining the bag, and that is the
|
|
84
|
+
// distinction the hook is really drawing. `a` is a POSE CHANNEL: `rig.js`
|
|
85
|
+
// blends it frame by frame, so a shape can fade with the thing it belongs to
|
|
86
|
+
// (a lash that vanishes as the eye shuts, a seam that vanishes as the mouth
|
|
87
|
+
// opens) and the builder sets it from the control vector on nearly every
|
|
88
|
+
// interesting draw. The four in the bag are CONSTANTS of the draw — a rule, a
|
|
89
|
+
// composite op, a stroke width and a base matrix are copied from base to out
|
|
90
|
+
// and never interpolated — so they are rare, and they read better named.
|
|
91
|
+
//
|
|
92
|
+
// `extra` may be undefined or null; both spread to nothing.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export const drawPusher = (out) => (slot, group, cmds, paint, a = 1, extra) =>
|
|
96
|
+
out.push({ slot, group, cmds, paint, a, ...extra });
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// The control vector. REST is the character's own business — its channel names
|
|
100
|
+
// are one rigger's opinion about what one face can do — but the machinery that
|
|
101
|
+
// clones it and applies a shallow-with-one-level-of-nesting patch is not.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
105
|
+
|
|
106
|
+
// The rest control vector. This started out as the one thing most obviously
|
|
107
|
+
// NOT library — "one rigger's opinion about what one face can do" — and then
|
|
108
|
+
// three independently-styled avatars turned out to hold it byte-for-byte
|
|
109
|
+
// identically, because it is not really an opinion about a face: it is the set
|
|
110
|
+
// of channels the DRIVER's vocabulary needs somebody to implement. Six identity
|
|
111
|
+
// morphs (one per `MORPH_AXES` entry), a jaw and a cheek for the visemes, an
|
|
112
|
+
// eye block for the six eye states, a mouth block for the sixteen visemes.
|
|
113
|
+
//
|
|
114
|
+
// It is a default, not a law: `makeCtrl` takes whatever rest vector it is
|
|
115
|
+
// given, and an avatar that grows a channel adds it to its own copy.
|
|
116
|
+
//
|
|
117
|
+
// The `eye:` and `mouth:` blocks are no longer written out here: they are the
|
|
118
|
+
// rest blocks of `parts/eye.mjs` and `parts/mouth.mjs`, so the channels a part
|
|
119
|
+
// implements and the channels the vector offers are defined in one place and
|
|
120
|
+
// cannot drift. Both are FUNCTIONS rather than consts on purpose — a part
|
|
121
|
+
// imports `clamp`/`lerp`/`drawPusher` back out of this file, and a function
|
|
122
|
+
// declaration is initialised before any module body runs, so the cycle is
|
|
123
|
+
// harmless whichever end of it a process happens to import first.
|
|
124
|
+
export const REST_CONTROLS = {
|
|
125
|
+
headW: 0, eyeSize: 0, eyeSpace: 0, noseW: 0, lipFull: 0, browH: 0,
|
|
126
|
+
// Sex axes. Unlike the six above these are NOT animated and NOT exposed as
|
|
127
|
+
// morph poses: they are baked into the rest vector an avatar is built at
|
|
128
|
+
// (`face.restFor(persona)` -> `poseHarness(builder, rest)`), so a man and a
|
|
129
|
+
// woman of the same family are two builds of one skull rather than two
|
|
130
|
+
// skulls. 0 is the family's neutral (feminine) read; 1 is fully masculine.
|
|
131
|
+
// jawWidth — mandible width at the gonial corner, and how square it turns
|
|
132
|
+
// neckWidth — the neck, its cast shadow, and the collar it comes out of
|
|
133
|
+
jawWidth: 0, neckWidth: 0,
|
|
134
|
+
jaw: 0, cheekRaise: 0,
|
|
135
|
+
eye: eyeRest(),
|
|
136
|
+
mouth: mouthRest(),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Which channel of that vector each `morph/<axis>_±100` pose name drives. The
|
|
140
|
+
// axis names are contract (src/vocab.js MORPH_AXES, straight out of the state
|
|
141
|
+
// machine); the channel names are this rest vector's. The map is the join
|
|
142
|
+
// between them, and it is the reason the morph poses can be enumerated by a
|
|
143
|
+
// library instead of retyped per avatar.
|
|
144
|
+
export const MORPH_CHANNELS = {
|
|
145
|
+
head: 'headW', lips: 'lipFull', nose: 'noseW',
|
|
146
|
+
brows: 'browH', eyes: 'eyeSize', distance: 'eyeSpace',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export function makeCtrl(REST) {
|
|
150
|
+
return function ctrl(patch = {}) {
|
|
151
|
+
const c = clone(REST);
|
|
152
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
153
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(c[k], v);
|
|
154
|
+
else c[k] = v;
|
|
155
|
+
}
|
|
156
|
+
return c;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// The pose harness — the single most valuable thing either generator had.
|
|
162
|
+
//
|
|
163
|
+
// A pose is "run the whole builder again with a different control vector and
|
|
164
|
+
// keep whatever moved". Because both runs go through the same code path, the
|
|
165
|
+
// cmds arrays are emitted by the same emitters in the same order, so the opcode
|
|
166
|
+
// sequence is identical by construction — which is the runtime's precondition
|
|
167
|
+
// for interpolating instead of snapping. Nothing else about the format makes
|
|
168
|
+
// that safety free.
|
|
169
|
+
//
|
|
170
|
+
// buildDraws(c) must return an array of { slot, group, cmds, a, paint }.
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function same(a, b) {
|
|
174
|
+
if (a.length !== b.length) return false;
|
|
175
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function poseHarness(buildDraws, rest) {
|
|
180
|
+
const BASE = buildDraws(rest);
|
|
181
|
+
const N = BASE.length;
|
|
182
|
+
const IDX = Object.fromEntries(BASE.map((d, i) => [d.slot, i]));
|
|
183
|
+
const group = (g) => BASE.map((d, i) => (d.group === g ? i : -1)).filter((i) => i >= 0);
|
|
184
|
+
|
|
185
|
+
function poseOf(c) {
|
|
186
|
+
const cur = buildDraws(c);
|
|
187
|
+
const geo = {}, alpha = {};
|
|
188
|
+
for (let i = 0; i < N; i++) {
|
|
189
|
+
if (!same(cur[i].cmds, BASE[i].cmds)) geo[i] = cur[i].cmds;
|
|
190
|
+
if (cur[i].a !== BASE[i].a) alpha[i] = cur[i].a;
|
|
191
|
+
}
|
|
192
|
+
const p = {};
|
|
193
|
+
if (Object.keys(geo).length) p.geo = geo;
|
|
194
|
+
if (Object.keys(alpha).length) p.alpha = alpha;
|
|
195
|
+
return p;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { BASE, N, IDX, group, poseOf };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Poses are plain objects keyed by { geo, alpha, paint, mat }; stacking two of
|
|
202
|
+
// them is a per-section Object.assign, not a top-level one.
|
|
203
|
+
export const merge = (...ps) => {
|
|
204
|
+
const o = {};
|
|
205
|
+
for (const p of ps) for (const [k, v] of Object.entries(p)) o[k] = Object.assign(o[k] || {}, v);
|
|
206
|
+
return o;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// The missing transform hierarchy. There is no parent/child in the format, so
|
|
210
|
+
// "move this group" means writing the same 6 numbers onto every draw index in
|
|
211
|
+
// it. Each argument is [drawIndices, matrix].
|
|
212
|
+
//
|
|
213
|
+
// A null matrix is skipped, and so is an IDENTITY one: `toRig` gives every draw
|
|
214
|
+
// base matrix [1,0,0,1,0,0], so writing identity onto a group says "these 119
|
|
215
|
+
// draws move by exactly nothing" in 119 entries the runtime still has to
|
|
216
|
+
// prepare, dirty and blend every frame. The rest key of a cyclic clip
|
|
217
|
+
// (`headMat(0, 0, 0, 0)`) is the case that hits it, and it was the whole of
|
|
218
|
+
// every zero-delta warning the checker used to emit. An empty result drops the
|
|
219
|
+
// section entirely, which is what makes a rest key `{}` — the one shape
|
|
220
|
+
// `validate.mjs` calls a legitimate whole-frame rest.
|
|
221
|
+
const isIdentity = (m) => m.length === 6
|
|
222
|
+
&& m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
|
|
223
|
+
|
|
224
|
+
export const groupMat = (...pairs) => {
|
|
225
|
+
const mat = {};
|
|
226
|
+
for (const [indices, m] of pairs) {
|
|
227
|
+
if (!m || isIdentity(m)) continue;
|
|
228
|
+
for (const i of indices) mat[i] = m;
|
|
229
|
+
}
|
|
230
|
+
return Object.keys(mat).length ? { mat } : {};
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// A body translate is the common second half of a head matrix.
|
|
234
|
+
export const translateMat = (ty) => [1, 0, 0, 1, 0, r2(ty)];
|
|
235
|
+
|
|
236
|
+
// ...and this is the whole of the first half. All three avatars had the same
|
|
237
|
+
// six lines: rotate the head group about the neck pivot, translate the body
|
|
238
|
+
// group, skip the body when it does not move. `head` and `body` are draw-index
|
|
239
|
+
// arrays from the harness's `group()`; the returned function is the one every
|
|
240
|
+
// track frame is written in terms of.
|
|
241
|
+
//
|
|
242
|
+
// It is plumbing, not performance: WHICH draws are in the head, where the
|
|
243
|
+
// pivot is, and how many degrees a given key turns are all the avatar's.
|
|
244
|
+
export function headMatFactory({ head, body, pivot }) {
|
|
245
|
+
return (deg, tx = 0, ty = 0, bodyTy = 0) => groupMat(
|
|
246
|
+
[head, rotMat(deg, pivot, tx, ty)],
|
|
247
|
+
[body, bodyTy ? translateMat(bodyTy) : null],
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// The weight-table deformer. "Move every landmark by its share of one control"
|
|
253
|
+
// — a jaw drop, a cheek raise — written once instead of three times.
|
|
254
|
+
//
|
|
255
|
+
// The `L`/`R` rule is the part worth owning: a table is written in UNSUFFIXED
|
|
256
|
+
// landmark names because a weight is a property of the feature, not of which
|
|
257
|
+
// side of the face it is on, so `chin` covers `chinL` and `chinR` at once. The
|
|
258
|
+
// `?? table[n]` fallback then lets a table name one specific point that happens
|
|
259
|
+
// to end in L or R anyway, which is exactly the case one of the three copies
|
|
260
|
+
// had quietly dropped.
|
|
261
|
+
//
|
|
262
|
+
// fn(pt, w, name) mutates the point in place; what it does with the weight is
|
|
263
|
+
// the character's business (jaw drop also narrows, cheek raise only lifts).
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export function applyWeights(pts, table, fn) {
|
|
267
|
+
for (const n of Object.keys(pts)) {
|
|
268
|
+
const w = table[n.replace(/[LR]$/, '')] ?? table[n];
|
|
269
|
+
if (w) fn(pts[n], w, n);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// The iris base solve.
|
|
275
|
+
//
|
|
276
|
+
// The driver never shows the base iris paint. It holds one or two `hue/NNN`
|
|
277
|
+
// rungs at a combined weight of 1 and layers `iris/eyes-saturation-0` and
|
|
278
|
+
// `iris/eyes-brightness-0` on top, and `rig.js:278-285` blends all of them in
|
|
279
|
+
// RGBA *against the base*:
|
|
280
|
+
//
|
|
281
|
+
// shown = base + Σ wᵢ·(cᵢ − base) => base = (Σ wᵢ·cᵢ − shown) / (Σ wᵢ − 1)
|
|
282
|
+
//
|
|
283
|
+
// At the driver's boot state the weights are 1, 1 − saturation and brightness,
|
|
284
|
+
// so Σ wᵢ = 2.35 and the divisor is 1.35. Which means the base paint an artist
|
|
285
|
+
// writes into the file is NOT a colour anyone picked — it is an answer to an
|
|
286
|
+
// equation whose coefficients live in `drivers.js`. That coupling, not the fact
|
|
287
|
+
// that three files would otherwise hold the same six lines, is why this is
|
|
288
|
+
// library: change the driver's defaults and every avatar's base iris is wrong,
|
|
289
|
+
// silently, and this is the one place that says so.
|
|
290
|
+
//
|
|
291
|
+
// `ladder.hue(h)` is the avatar's own hue ramp; `grey` and `bright` are the two
|
|
292
|
+
// overlay colours it puts in the two iris poses; `target` is the colour the
|
|
293
|
+
// eye should actually be on screen at boot.
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export function solveIrisBase(target, ladder, defaults = DRIVER_DEFAULTS) {
|
|
297
|
+
const H = ladder.hue(defaults.hue), G = ladder.grey, B = ladder.bright;
|
|
298
|
+
const wH = 1, wS = 1 - defaults.saturation, wB = defaults.brightness;
|
|
299
|
+
const div = wH + wS + wB - 1;
|
|
300
|
+
return H.map((_, i) => Math.round((wH * H[i] + wS * G[i] + wB * B[i] - target[i]) / div));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// The camera.
|
|
305
|
+
//
|
|
306
|
+
// `render2d.js:60-68` computes, once per resize:
|
|
307
|
+
//
|
|
308
|
+
// global = fit(meta.artboard onto the canvas) · inverse(meta.align)
|
|
309
|
+
//
|
|
310
|
+
// and `invSimilarity` inverts align as a similarity, so an align of
|
|
311
|
+
// [1/k, 0, 0, 1/k, cx, cy] takes a design-space point p to (p − c)·k. Set
|
|
312
|
+
// meta.artboard to the OUTPUT FRAME rather than to the space the art was drawn
|
|
313
|
+
// in and the pair becomes a camera: "crop this rectangle of design space and
|
|
314
|
+
// blow it up to fill this frame". Nothing else in the file moves — not a draw,
|
|
315
|
+
// not a pose, not a stroke width, not a bitmap. It is metadata, and it is the
|
|
316
|
+
// only kind of framing change that cannot introduce a fidelity bug.
|
|
317
|
+
//
|
|
318
|
+
// A camera is three numbers plus the frame:
|
|
319
|
+
//
|
|
320
|
+
// camera: { frame: { w, h }, window: { cx, y, h } }
|
|
321
|
+
//
|
|
322
|
+
// where `frame` is the output raster's aspect (its absolute size only sets the
|
|
323
|
+
// units the player fits to) and `window` is the crop in DESIGN space: where it
|
|
324
|
+
// is centred horizontally, where its top edge is, how tall it is. The crop's
|
|
325
|
+
// WIDTH is derived from the frame's aspect and is not an input, because the one
|
|
326
|
+
// way to get this wrong is to write a window whose aspect disagrees with the
|
|
327
|
+
// frame's — the renderer would then letterbox inside the crop and every number
|
|
328
|
+
// the author computed would be off by that ratio. Three numbers cannot say it.
|
|
329
|
+
//
|
|
330
|
+
// Vertical-first is also how the framing is actually reasoned about: the crop
|
|
331
|
+
// is chosen by how much of frame height belongs above, across and below the
|
|
332
|
+
// head. All three measurements are y.
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
336
|
+
|
|
337
|
+
export function cameraMeta({ frame, window: win }) {
|
|
338
|
+
const bad = (m) => { throw new Error(`camera: ${m}`); };
|
|
339
|
+
for (const [o, ks, n] of [[frame, ['w', 'h'], 'frame'], [win, ['cx', 'y', 'h'], 'window']]) {
|
|
340
|
+
if (!o) bad(`${n} is required`);
|
|
341
|
+
for (const k of ks) if (!Number.isFinite(o[k])) bad(`${n}.${k} must be a finite number, got ${o[k]}`);
|
|
342
|
+
}
|
|
343
|
+
if (frame.w <= 0 || frame.h <= 0) bad(`frame must be positive, got ${frame.w}x${frame.h}`);
|
|
344
|
+
if (win.h <= 0) bad(`window.h must be positive, got ${win.h}`);
|
|
345
|
+
const w = win.h * (frame.w / frame.h); // the crop's width, never an input
|
|
346
|
+
const k = frame.h / win.h; // design units -> frame pixels
|
|
347
|
+
return {
|
|
348
|
+
artboard: { w: frame.w, h: frame.h },
|
|
349
|
+
align: [round6(1 / k), 0, 0, round6(1 / k), round6(win.cx - w / 2), round6(win.y)],
|
|
350
|
+
window: { x: round6(win.cx - w / 2), y: round6(win.y), w: round6(w), h: round6(win.h), k: round6(k) },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Serialiser. Three of the four contract defaults below are load-bearing and
|
|
356
|
+
// none of them is checked by the runtime, which is the entire argument for a
|
|
357
|
+
// writer: clipsets[0] is the "no mask" index every draw points at, images must
|
|
358
|
+
// be present because loadRig maps over it unconditionally, and meta.align is
|
|
359
|
+
// divided out rather than applied, so a hand-authored rig with no camera wants
|
|
360
|
+
// identity and artboard-space coordinates.
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
export function toRig({ artboard, paints, draws, poses, tracks, camera, images = [] }) {
|
|
364
|
+
// A bitmap draw is a different shape from a path one, not a path with extra
|
|
365
|
+
// fields: `render2d.js:141` reads `src`, `m`, `a`, `blend` and `clip` and
|
|
366
|
+
// nothing else, and a `cmds` on it would never be drawn. `images` is the
|
|
367
|
+
// table `loadRig` maps over to fetch `img/<file>` next to the rig JSON, so an
|
|
368
|
+
// entry's `id` is its own index and its `file` is a bare filename. Both are
|
|
369
|
+
// empty for a vector-only avatar, which is what keeps those rigs' bytes.
|
|
370
|
+
const d = draws.map((x) => (x.k === 'bitmap' ? {
|
|
371
|
+
k: 'bitmap', slot: x.slot, m: x.m, a: x.a,
|
|
372
|
+
blend: x.blend || 'source-over', clip: x.clip || 0, src: x.src, w: x.w, h: x.h,
|
|
373
|
+
} : {
|
|
374
|
+
// Four draw-level fields the two flat-fill avatars never set, so all four
|
|
375
|
+
// were hard-coded. They are contract, not style: `m` is the draw's own
|
|
376
|
+
// matrix (a pose's `mat` section overwrites it, so a non-identity base
|
|
377
|
+
// matrix is how a shape sits somewhere its points do not), `blend` is the
|
|
378
|
+
// canvas composite op (`render2d.js:124` reads `d.blend || 'source-over'`,
|
|
379
|
+
// so 'multiply' is a legal shadow), and `rule` picks the fill rule
|
|
380
|
+
// (`render2d.js:137`, `ctx.fill(path, d.rule)`) — the only way to say
|
|
381
|
+
// "this contour is a hole". Each is passed through when a builder supplies
|
|
382
|
+
// one and is byte-identical to the old constant when it does not.
|
|
383
|
+
k: 'path', slot: x.slot, m: x.m || [1, 0, 0, 1, 0, 0], a: x.a,
|
|
384
|
+
blend: x.blend || 'source-over', clip: 0, cmds: x.cmds, rule: x.rule || 'nonzero',
|
|
385
|
+
// `stroke` is the one draw-level field the two flat-fill avatars never set,
|
|
386
|
+
// so it was hard-coded to null. It is not style-agnostic to omit it:
|
|
387
|
+
// `render2d.js` reads `if (d.stroke) { ...ctx.stroke() } else { ctx.fill() }`,
|
|
388
|
+
// i.e. a draw is EITHER stroked or filled, and an outlined style needs the
|
|
389
|
+
// stroked half. Passed straight through when a builder supplies one,
|
|
390
|
+
// `{ w, cap, join }`; still exactly `null` when it does not, which is what
|
|
391
|
+
// keeps facet's and round's bytes identical. Width is deliberately not a
|
|
392
|
+
// pose channel — `rig.js` copies `stroke` from base to out and never blends
|
|
393
|
+
// it, and there is no `stroke` section in a pose.
|
|
394
|
+
stroke: x.stroke || null, paint: x.paint,
|
|
395
|
+
}));
|
|
396
|
+
// No camera: the frame IS the artboard and align is identity, which is what
|
|
397
|
+
// every rig here emitted before cameras existed and what keeps those bytes.
|
|
398
|
+
const cam = camera ? cameraMeta(camera) : null;
|
|
399
|
+
return {
|
|
400
|
+
meta: cam
|
|
401
|
+
? { artboard: cam.artboard, align: cam.align, drawCount: d.length }
|
|
402
|
+
: { artboard, align: [1, 0, 0, 1, 0, 0], drawCount: d.length },
|
|
403
|
+
paints, clipSlots: [], clipsets: [[]], draws: d, images, poses, tracks,
|
|
404
|
+
};
|
|
405
|
+
}
|