@voqalize/avatar 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +86 -51
  2. package/client/dist/Avatar.d.ts +17 -14
  3. package/client/dist/Avatar.d.ts.map +1 -1
  4. package/client/dist/Avatar.js +3 -3
  5. package/client/dist/Avatar.js.map +1 -1
  6. package/client/dist/AvatarClient.d.ts +23 -57
  7. package/client/dist/AvatarClient.d.ts.map +1 -1
  8. package/client/dist/AvatarClient.js +20 -71
  9. package/client/dist/AvatarClient.js.map +1 -1
  10. package/client/dist/index.d.ts +22 -0
  11. package/client/dist/index.d.ts.map +1 -0
  12. package/client/dist/index.js +22 -0
  13. package/client/dist/index.js.map +1 -0
  14. package/client/dist/types.d.ts +22 -37
  15. package/client/dist/types.d.ts.map +1 -1
  16. package/client/dist/types.js +12 -12
  17. package/client/dist/types.js.map +1 -1
  18. package/client/dist/useAvatar.d.ts +8 -19
  19. package/client/dist/useAvatar.d.ts.map +1 -1
  20. package/client/dist/useAvatar.js +15 -32
  21. package/client/dist/useAvatar.js.map +1 -1
  22. package/client/src/Avatar.tsx +19 -24
  23. package/client/src/AvatarClient.ts +38 -111
  24. package/client/src/index.ts +22 -0
  25. package/client/src/types.ts +24 -47
  26. package/client/src/useAvatar.ts +19 -47
  27. package/docs/contract-avatar.md +37 -3
  28. package/docs/contract-protocol.md +90 -30
  29. package/package.json +3 -11
  30. package/src/avatar.d.ts +38 -6
  31. package/src/avatar.js +72 -37
  32. package/src/hand.js +680 -0
  33. package/src/idle.js +8 -25
  34. package/src/line-art.js +26 -0
  35. package/src/perform.js +8 -4
  36. package/client/dist/pipecat.d.ts +0 -21
  37. package/client/dist/pipecat.d.ts.map +0 -1
  38. package/client/dist/pipecat.js +0 -21
  39. package/client/dist/pipecat.js.map +0 -1
  40. package/client/dist/react.d.ts +0 -16
  41. package/client/dist/react.d.ts.map +0 -1
  42. package/client/dist/react.js +0 -17
  43. package/client/dist/react.js.map +0 -1
  44. package/client/src/pipecat.ts +0 -38
  45. package/client/src/react.ts +0 -34
  46. package/src/audio-fallback.js +0 -100
package/src/hand.js ADDED
@@ -0,0 +1,680 @@
1
+ /**
2
+ * hand — a hand at the frame edge, drawn the way a webcam sees one.
3
+ *
4
+ * The avatar has no arms (CLAUDE.md, constraint 9): two earlier rigs carried a
5
+ * full forearm/hand chain, nine parameter channels and several hundred lines of
6
+ * geometry each, and it was removed on sight. This is the other design. It
7
+ * survived a stakeholder trial in `experiments/arm-gesture/` and was promoted
8
+ * on 2026-08-07; what makes it a different proposition from the thing that was
9
+ * cut is that **it is not part of the rig**. No parameter channel, no per-face
10
+ * geometry, no forearm — one drawing, placed by four numbers derived from the
11
+ * mounted avatar's own `META.viewBox`, painted in its own theme, appended over
12
+ * its SVG. A face that never plays a gesture is byte-for-byte what it was.
13
+ *
14
+ * The four rules the drawing obeys, all of them earned by rendering something
15
+ * that broke them:
16
+ *
17
+ * 1. THE WRIST NEVER ENTERS THE FRAME (`WRIST_DROP`). Every gesture is posed by
18
+ * wrist position, and the wrist is pinned below the viewBox bottom at all
19
+ * times, so the hand is always CUT by the frame edge and never ends in a
20
+ * stump hanging in mid-air. Each shape is drawn with a long tail below the
21
+ * wrist that is clipped away; the ink outline is an OPEN mark that runs off
22
+ * the bottom rather than a closed ring, so there is no line across the
23
+ * bottom to give the crop away. This is also why there is no arm to draw: a
24
+ * hand entering from off-camera implies the arm for free, and an arm drawn
25
+ * is an arm that has to be posed.
26
+ *
27
+ * 2. THE HAND IS BIG, because it is nearer the lens than the face and because
28
+ * once only fingers clear the edge, the fingers carry the whole read. How
29
+ * big is a depth question, not a style one, and gestures differ: a thumbs-up
30
+ * is pushed toward the lens, a wave is thrown out to the side and further
31
+ * away. `REACH` sets the near case and each gesture's `sc` steps back.
32
+ *
33
+ * 3. WHAT RISES PAST THE MOUTH IS ONLY EVER A DIGIT. Mouth sync is the headline
34
+ * feature (constraint 2), so a gesture that covers the mouth is a regression
35
+ * whatever else it does. The high gestures drift outboard as they rise, so
36
+ * at mouth height the only thing in front of the face is one finger. At true
37
+ * webcam scale an open palm is 65% of the frame's width, which cannot be got
38
+ * out of the way by moving it sideways — rule 4 forbids that much travel. So
39
+ * the waves sit LOWER and further back instead (`sc`), and the face stays
40
+ * entirely clear.
41
+ *
42
+ * 4. THE ONLY EDGE THAT CUTS THE HAND IS THE BOTTOM ONE. Every shipped avatar's
43
+ * viewBox is a portrait window pillarboxed inside a 16:9 tile, so anything
44
+ * past the window's right edge is sliced by a hard vertical line that reads
45
+ * as a rendering fault. Outboard travel is budgeted against the hand's own
46
+ * width AND its rotation — a hand 440 units tall swings ~110 units sideways
47
+ * at 14 degrees, which is what silently blew the budget the first time.
48
+ * `checkHandFraming()` asserts both rules against the real timelines.
49
+ *
50
+ * See docs/contract-protocol.md § Hand gestures for the wire surface and
51
+ * docs/contract-avatar.md § The hand for what a face module owes this module
52
+ * (the answer is: a viewBox and two theme keys).
53
+ */
54
+
55
+ import { taper, taperRing, region, smooth } from './line-art.js';
56
+ import { f } from './face-core.js';
57
+
58
+ // --- the frame --------------------------------------------------------------
59
+ // Everything the hand needs to place itself comes out of `META.viewBox`, and
60
+ // that is the whole reason this module needed no new META field. The three
61
+ // shipped avatars draw a 576x800 portrait window; the numbers below are ratios
62
+ // and offsets against that window, not against any one character's anatomy.
63
+ //
64
+ // The wrist sits below the frame edge by a clear margin, never at it: a wrist
65
+ // exactly on the line renders as a rounded end kissing the border, which reads
66
+ // as a hand stuck to the frame rather than one coming from off-camera.
67
+ const WRIST_DROP = 24; // minimum units the wrist stays below the frame bottom
68
+ const HIDE = 574; // fully off-camera; must clear the tallest shape
69
+ // How far the outermost ink may sit from the frame centre before the portrait
70
+ // window slices it (rule 4): half the window, less a hair of margin.
71
+ const SIDE_MARGIN = 8;
72
+ // Author scale at a 576-unit-wide window. The number this design changed most,
73
+ // and both directions were rendered and judged. Earlier cuts drew the hand at
74
+ // FACE depth: a 19 cm hand against a 23 cm head is 0.83 of it, peep's head is
75
+ // 477 art units, so ~394 — which came out looking like a pale tube rising past
76
+ // the collar, because it is the wrong depth. A hand raised to a webcam sits
77
+ // roughly 40 cm from the lens with the face at 60, so it images about 1.5x
78
+ // larger: 3.4 is the optically honest scale.
79
+ //
80
+ // 3.4 was then walked back, and the argument that pulled it back is not the
81
+ // optical one. It put the palm at ~1.05 head-widths, which is true — but at the
82
+ // 130 px acceptance size the viewer gets ONE GLANCE, and the largest brightest
83
+ // mass in the tile becomes its subject. When that mass is an information-free
84
+ // white slab the composition inverts: the hand becomes the figure and the head
85
+ // becomes ground, and the tile reads as broken before it reads as a gesture.
86
+ // Optically correct, perceptually wrong. 2.95 puts the palm at ~0.82
87
+ // head-widths — still clearly nearer the lens, still clearly not the subject.
88
+ const REACH_AT_576 = 2.95;
89
+
90
+ /** The four placement numbers, derived. A host never sees these. */
91
+ function frameOf(viewBox) {
92
+ const vb = viewBox;
93
+ return {
94
+ cx: vb.x + vb.w / 2, // frame centre; also where a resting hand starts
95
+ bottom: vb.y + vb.h, // the visible bottom edge — the line the hand rises past
96
+ reach: (REACH_AT_576 * vb.w) / 576,
97
+ outboardLimit: vb.w / 2 - SIDE_MARGIN,
98
+ };
99
+ }
100
+
101
+ // --- geometry ---------------------------------------------------------------
102
+ // Author units: wrist at (0,0), fingers up (-y), thumb toward -x. Points below
103
+ // are ON-CURVE and get run through `smooth()`; the +300 tails are the part the
104
+ // frame eats. One hand is drawn and mirrored for the other side (see `dir`).
105
+ //
106
+ // Two hand FAMILIES, and no gesture blurs them. A wave shows the palm; a
107
+ // thumbs-up necessarily shows the back of the hand, and the first version of
108
+ // this drawing used one shape for both — which is why nothing read. Built from
109
+ // only the cues that survive 130 px:
110
+ //
111
+ // palmar (palm to camera) dorsal (back to camera)
112
+ // webs smooth, shallow U bumpy knuckle row
113
+ // base wide soft-cornered heel straighter, narrower
114
+ // thumb out and clear of the fingers crossing IN FRONT of the fist
115
+ // interior one thenar crease no crease; a curl line instead
116
+ //
117
+ // Cut because none of them survive the acceptance size: finger creases, tendon
118
+ // lines, knuckle bulges on extended fingers, fingernails.
119
+
120
+ // --- palmar: the open hand, for waving --------------------------------------
121
+ // Three finger masses, not four: the ring and little fingers are one shape.
122
+ // That is standard caricature economy and it costs nothing in read — measured
123
+ // on an earlier cut, two of the finger gaps shrank to under a pixel at 130 px
124
+ // and fused, which is what turned the wave into a smear. Every white gap here
125
+ // is at least as wide as the ink beside it at the acceptance size.
126
+ const PALM = [
127
+ // Ulnar edge. The tail NARROWS toward the frame cut — the single cheapest fix
128
+ // in the whole drawing. Every earlier hand widened as it approached the
129
+ // bottom edge, which reads as a shape RESTING ON the edge (a bag, a sheet of
130
+ // paper) rather than one continuing past it. Widest at ~a third down from the
131
+ // fingertips, ~65% of that at the cut, and the eye infers the arm for free.
132
+ [26, 300], [28, 60], [31, 16],
133
+ [36, -20], [41, -54], // the widest band
134
+ [40, -86], [38, -112], // ring+little, splayed
135
+ [35, -138], [27, -150], [18, -147], // DOMED tip, never pointed
136
+ [13, -134], // notch floor: ~15% of finger length, and a U not a V
137
+ [8, -146], [1, -164], [-8, -166], // middle, the longest
138
+ [-16, -156],
139
+ [-20, -136], // notch floor
140
+ [-25, -148], [-33, -157], [-40, -150], // index, domed
141
+ [-44, -130], [-46, -110],
142
+ [-47, -92], // the thumb web — shallow, so this is a hand not a fork
143
+ // The thumb is a CONVEX WEDGE, and that is a deletion rather than an
144
+ // addition. An earlier thumb had a re-entrant curl in its outer contour; at
145
+ // 130 px the hook read as a detached ear sitting beside the jaw, which was the
146
+ // single most confusing mark in the set. There is no concavity anywhere in
147
+ // this contour: it leaves the palm's outer edge, swells to a rounded pad at
148
+ // ~40 degrees off the palm axis, and comes back. A thumb is a wedge, and a
149
+ // wedge has two edges.
150
+ [-56, -99], [-65, -96], [-71, -87],
151
+ [-72, -75], [-65, -67], // the pad: broad, round
152
+ [-56, -58], [-49, -46],
153
+ [-46, -26], [-42, 4], // thenar, then the taper
154
+ [-36, 60], [-32, 300],
155
+ ];
156
+ // Where the finger information moved to. An earlier cut put the interdigital
157
+ // valleys 50-60% of the way down the fingers, which makes a SAW: at 130 px a
158
+ // deep-notched silhouette reads as a crown or a claw, never as a hand. Real
159
+ // fingers are not separated at the silhouette, they are separated by short
160
+ // creases in the flesh. So the notches came up to ~15% and these marks took
161
+ // over the job — thick at the notch floor, tapered to nothing over about a
162
+ // third of the finger's length, in exactly the mark language of peep's own ear
163
+ // whorl and nose hook.
164
+ // There are exactly TWO, because three finger masses have two gaps between
165
+ // them. A third mark hinting at the ring/little split was drawn and removed: at
166
+ // 130 px it carries no information (the split it implies is invisible at any
167
+ // size this ships at) and at full size it reads as a scratch. Economy is not a
168
+ // style here, it is the difference between a mark and a blemish.
169
+ const PALM_SEPS = [
170
+ [[13, -130], [14, -122], [15, -113]], // little/ring from middle
171
+ [[-20, -132], [-21, -124], [-21, -115]], // middle from index
172
+ ];
173
+ // The one interior mark that says PALM rather than back-of-hand — and it is the
174
+ // THENAR crease, the arc around the ball of the thumb, not a line across the
175
+ // palm. That is the change a person drawing a hand makes without thinking about
176
+ // it, and the earlier horizontal version is why: a wide shallow smile low in a
177
+ // rounded white form, at tile size, beside a face, reads as a MOUTH. The arc
178
+ // runs off the bottom edge instead of ending, so nothing about it says "this
179
+ // mark stopped here".
180
+ const PALM_CREASE = [[-46, -80], [-41, -54], [-30, -26], [-14, -2], [-8, 14]];
181
+
182
+ // --- dorsal: the fist, for thumbs-up ----------------------------------------
183
+ // The back of a closed hand: an undulating top edge of metacarpal heads, and a
184
+ // base that is straighter and narrower than the palmar heel. No palm crease — a
185
+ // crease here would say palm.
186
+ //
187
+ // The knuckle row sits higher than anatomy alone would put it, and deliberately.
188
+ // Rule 1 keeps the wrist below the frame, so only the TOP of the fist is ever on
189
+ // screen — and the thumb's proportion is read against the VISIBLE mass, not
190
+ // against the whole hand. At an anatomically honest knuckle height two thirds of
191
+ // the fist was off-camera, which left a correct thumb looking as long as
192
+ // everything it was attached to. Raising the row to 0.56 of hand length puts the
193
+ // ratio back where the eye expects it.
194
+ //
195
+ // The visible fist must also be WIDER THAN TALL. Drawn 75 wide against 90 tall
196
+ // above the frame cut it was not a fist, it was a tower — and a crest that
197
+ // ramped monotonically to a single spike at the index knuckle was its steeple.
198
+ // The crest is now a gentle arc, still highest toward the index side because
199
+ // that is true, but by 16 units across 60 rather than by 30; the mass is 82 wide
200
+ // against 78 visible. The two undulations survive only as inflections in it.
201
+ const FIST = [
202
+ [24, 300], [26, 60], [30, 12], // the wrist really is ~60% of a fist
203
+ [36, -22], [40, -46], // the heel, and the widest band
204
+ [39, -66], [30, -74], [22, -70], // TWO undulations, not four knuckles
205
+ [12, -80], [-2, -86], [-14, -82], // the larger one, index side
206
+ [-26, -76], [-36, -64],
207
+ [-42, -42], [-41, -16],
208
+ [-37, 60], [-34, 300],
209
+ ];
210
+ // Four small knuckle bumps were drawn at the same scale and rhythm as peep's
211
+ // hair spikes, so at 130 px the row read as a lapel zigzag continuing the
212
+ // collar — the fist stopped being a hand and became clothing. Two large soft
213
+ // undulations carry the same information (this mass is knuckled, therefore it is
214
+ // a closed hand) without colliding with a mark the character already owns.
215
+ //
216
+ // One interior mark, and only one. The dorsal fist was an empty mitten: a big
217
+ // white slab with all its information in the outline, which is the definition of
218
+ // clip art. A single S-curve for the curled proximal phalanges is the only
219
+ // interior mark that earns its ink at the acceptance size. Not four knuckle
220
+ // lines — one.
221
+ // It runs PARALLEL to the knuckle crest, about 24 units under it, which is where
222
+ // the proximal phalanges actually are — and, drawn any lower, it sat near the
223
+ // frame cut and left the knuckle half of the fist empty. An empty top and a
224
+ // marked bottom is upside down: the eye goes to the crest first, and finds
225
+ // nothing there.
226
+ const FIST_CURL = [[24, -48], [8, -58], [-8, -60], [-24, -52]];
227
+ // The thumb is a SEPARATE CLOSED SHAPE crossing the fist, which is what keeps
228
+ // the middle-finger read dead — the first thumbs-up drawn here was rejected on
229
+ // sight for exactly that. A glyph has tolerances and these are them:
230
+ // - within 8 degrees of VERTICAL. Leaning it outboard to get away from the
231
+ // middle finger's axis reads as a corner of the mass rather than a digit;
232
+ // - the part clearing the crest is 32 wide by 42 tall — about square, which is
233
+ // the emoji's proportion and the whole reason this does not read as
234
+ // ONE_MOMENT at 130 px. Longer and thinner is a finger; much stubbier and it
235
+ // disappears into the silhouette;
236
+ // - width ~1.4x a finger's;
237
+ // - THE BASE IS INSIDE THE MASS, NOT BESIDE IT. This is the load-bearing one.
238
+ // Two attempts put the thumb on the flank — once as a long lozenge that
239
+ // detached into a second object, once as an egg tangent to the knuckles that
240
+ // read as a raised finger — and the diagnosis both times was the same. A
241
+ // thumb whose base sits outside the fist's silhouette is not a thumb, it is
242
+ // a NEIGHBOUR. Its base is buried across 32 units of the crest;
243
+ // - a NOTCH ON BOTH SIDES, and both SHALLOW. The notch floor is what makes a
244
+ // thumb a thumb instead of a corner, but a deep notch is what re-detaches
245
+ // it. Both contours cross the crest within a few units of it.
246
+ // It is drawn UNDER the fist (see `build`), and that order is the whole trick.
247
+ // Painted on top, the thumb's closed shape runs unbroken from the frame edge to
248
+ // its tip and the entire radial column reads as ONE very long digit — the
249
+ // middle-finger silhouette, rebuilt out of correct parts.
250
+ const FIST_THUMB = [
251
+ [10, -48], // base, deep inside the fist's mass
252
+ [2, -78], [-1, -100], // inner edge — SHORT, and that is the point
253
+ [-6, -118], [-18, -128], [-31, -122], // domed tip, and a wide dome
254
+ [-38, -106], [-38, -88],
255
+ [-36, -70], [-32, -56], [-25, -44], // outer edge, tucking back under the crest
256
+ [10, -48],
257
+ ];
258
+ // There is no thumbnail, and that is the clearest single deletion in this
259
+ // drawing. A short curved crease near the top of the thumb, at 130 px, on a
260
+ // rounded white form beside a face: it read as a CLOSED EYE. The tile had two
261
+ // faces in it.
262
+
263
+ // --- dorsal: index up, for "one moment" -------------------------------------
264
+ // The same fist back, with the index extended. It is the fist's silhouette that
265
+ // separates this from THUMBS_UP at a glance: a long straight digit rising well
266
+ // above the knuckles, against a short fat one clearing them by half. This is the
267
+ // gesture that passed review first and changed least — it is the reference the
268
+ // other three were rebuilt toward, and the reasons it works are the whole
269
+ // lesson: it stands against the BACKGROUND rather than against the shirt, its
270
+ // meaning lives in one unambiguous silhouette rather than in notches, and where
271
+ // it crosses the hair, value does the separating.
272
+ const POINT = [
273
+ [24, 300], [26, 60], [30, 12],
274
+ [36, -22], [40, -46], // the heel, and the widest band
275
+ [39, -66], [30, -74], [22, -70], // two undulations, as on FIST
276
+ [12, -80], [-2, -84],
277
+ [-8, -90], // the web — a rounded corner, barely a notch
278
+ // The index. It once cleared the knuckles by 75 units against a visible fist
279
+ // of 90 — anatomically defensible, and at 0.79 of a head-width it was the
280
+ // longest single mark in the tile. Nothing that is not the face gets to be
281
+ // that. It now clears by 56 against 78, which is what a person reads as a
282
+ // raised finger rather than as a pole; the long-thin-against-short-fat
283
+ // contrast with THUMBS_UP survives on the ratio (0.36 against 0.76), which is
284
+ // where it was always doing its work.
285
+ //
286
+ // It tapers toward the tip and carries no joint pinch. The pinch was drawn in
287
+ // the OUTLINE, and a wobble that small in a contour does not read as a knuckle
288
+ // at any size — it reads as an unsteady line.
289
+ [-12, -106], [-15, -122], // inner edge
290
+ [-18, -136], [-25, -146], [-32, -143], // domed tip, never pointed
291
+ [-35, -129], [-34, -112], // outer edge
292
+ [-36, -94],
293
+ // The thumb, clamped across the curled fingers and showing as a lobe on the
294
+ // flank. An index-up fist with no thumb anywhere in it is quietly impossible.
295
+ [-44, -76], [-47, -62], [-44, -50],
296
+ [-42, -38], [-41, -16],
297
+ [-37, 60], [-34, 300],
298
+ ];
299
+
300
+ // Ink weights are in ART units, sampled off peep's own marks: the torso runs
301
+ // 8-11, the head peaks at 17 but its face contour sits at 10-12. An earlier cut
302
+ // ran a near-constant 14 all the way round, which made the hand both the
303
+ // heaviest and the only untapered mark in the picture — the review's diagnosis
304
+ // for why it read as a sticker composited over a drawing.
305
+ //
306
+ // So the profile carries a light direction, upper-left as the faces already
307
+ // imply: thin across the fingertips (s in the middle of the mark), thick down
308
+ // the two tails where the hand is nearest the camera and furthest from the
309
+ // light. Widths are NOT scaled with `reach` — perspective makes the hand bigger,
310
+ // not the pen wider.
311
+ //
312
+ // The whole profile is heavier than it looks like it should be, and that is the
313
+ // fix for a hand that would not sit in front of the shirt. The nearest object in
314
+ // the frame was carrying the lightest line in the frame: peep's jaw contour runs
315
+ // 10-12 and the hand peaked at 13, so the drawing said "behind" while the
316
+ // geometry said "in front", and the eye believes the drawing. This is device 2
317
+ // of the three a two-colour inker has for a white form crossing a white form
318
+ // (compose so it doesn't cross; weight hierarchy; knockout) and it is the one
319
+ // already native to these rigs, whose head contour outweighs the brow which
320
+ // outweighs the ear whorl.
321
+ const W_OUTLINE = [16, 15, 9, 6, 9, 15, 17];
322
+ // Device 3, the knockout: a white halo that breaks the shirt's seams where the
323
+ // hand crosses them. It is deliberately NARROW across the middle of the mark:
324
+ // the hand's upper half sits against the background, and a white rim there would
325
+ // turn it into a cut-out sticker. Only the tails — the part over the shirt — get
326
+ // the full gap. Where the halo width equals the ink width it is entirely covered
327
+ // and costs nothing, so the profile is scaled with the ink above it.
328
+ const W_HALO = [34, 29, 13, 6, 13, 29, 35];
329
+ const W_CREASE = [2, 8, 2];
330
+ // The palm's finger separators: thick where they leave the notch floor, gone by
331
+ // the end. This is the ear-whorl mark language, applied to a hand.
332
+ const W_SEP = [7, 3, 0.5];
333
+ // The fist's one interior mark. Thick-to-thin across an S, so it reads as a form
334
+ // turning rather than as a drawn line.
335
+ const W_CURL = [3, 9, 7, 2];
336
+ const W_THUMB = [12, 14, 11, 8, 11, 14, 12];
337
+
338
+ // The shapes, and the interior marks each one carries. Interior marks carry
339
+ // their own width profile rather than sharing one: three of them exist, they do
340
+ // three different jobs, and a separator drawn at crease weight is a crease.
341
+ const SHAPES = {
342
+ PALM: {
343
+ outline: PALM,
344
+ marks: [{ pts: PALM_CREASE, w: W_CREASE }, ...PALM_SEPS.map((p) => ({ pts: p, w: W_SEP }))],
345
+ rings: [],
346
+ },
347
+ FIST: {
348
+ outline: FIST,
349
+ marks: [{ pts: FIST_CURL, w: W_CURL }],
350
+ rings: [{ pts: FIST_THUMB }],
351
+ },
352
+ // The index-up fist gets the same single curl, shortened: its extended digit
353
+ // already carries the read, so the mark is only there to stop the mass below
354
+ // being an empty slab.
355
+ POINT: {
356
+ outline: POINT,
357
+ marks: [{ pts: [[22, -50], [6, -58], [-10, -58]], w: W_CURL }],
358
+ rings: [],
359
+ },
360
+ };
361
+ const shapePoints = (name) => {
362
+ const s = SHAPES[name];
363
+ return [s.outline, ...s.marks.map((m) => m.pts), ...s.rings.map((r) => r.pts)].flat();
364
+ };
365
+ // Half the heaviest mark, so the framing check measures ink rather than
366
+ // centreline. In art units at reach 1 it would be meaningless; it is applied in
367
+ // frame units alongside the scaled geometry.
368
+ const INK_HALF = 9;
369
+
370
+ // --- timelines --------------------------------------------------------------
371
+ // Channels: `out` (outward offset from frame centre, art units — the sign is
372
+ // applied by `dir`), `dy` (how far the wrist sits BELOW the frame's bottom edge,
373
+ // so a timeline is portable between avatars whose windows differ), `rot`
374
+ // (degrees about the wrist). Keys are [ms, value] with smoothstep between.
375
+ // Timings are baked so every gesture reads with no audio at all (constraint 5).
376
+ //
377
+ // Every gesture starts at out=0: the hand comes up from the middle, from
378
+ // wherever it was resting, and finds its position on the way. Nothing teleports
379
+ // to the side and then rises, which is what an arc-less rig looks like.
380
+ //
381
+ // Three animation habits every timeline has, each of them added after a review
382
+ // found it missing:
383
+ // - the rise OVERSHOOTS its hold by ~8% and settles back over ~110 ms. A hand
384
+ // that stops dead on the frame it arrives reads as a sprite being placed;
385
+ // - the hold BREATHES. Pixel-identical holds for four and five frames running
386
+ // read as a frozen render, not as stillness;
387
+ // - the exit leaves on a different path from the entry — straighter down and
388
+ // angled toward the body, because that is what dropping a hand looks like.
389
+ //
390
+ // And the whole set was re-timed shorter on the note "it is a bit slower and it
391
+ // lingers a bit longer than I'd think". Three separable faults, because a
392
+ // gesture is a rise, a hold and an exit and they were each wrong differently:
393
+ //
394
+ // THE RISE eased in AND out, over 420-470 ms, which is a lift, not a throw.
395
+ // A hand entering frame is ballistic: it leaves fast and brakes late. The
396
+ // first key sits at ~55% of the travel in ~150 ms, so most of the distance is
397
+ // gone before the eye catches up, and the last 45% is the brake. Same
398
+ // argument as gaze.js's ballistic head-follow — the STOP is what says the
399
+ // movement arrived somewhere on purpose.
400
+ //
401
+ // THE HOLD was ~1 s on all four, which is why they lingered. The hold only
402
+ // has to be long enough to be read, and a gesture the viewer has already read
403
+ // is a gesture standing in front of the face for no reason. Waves hold only as
404
+ // long as the swings take; THUMBS_UP holds 750 ms; ONE_MOMENT keeps the
405
+ // longest hold of the four because buying time IS its job.
406
+ //
407
+ // THE EXIT decelerated into the frame edge, so the hand sank rather than
408
+ // dropped. A released hand accelerates away, so the exit covers <20% of its
409
+ // travel in its first third. It is also the shortest of the three phases: you
410
+ // take longer to raise a hand than to let it fall.
411
+ //
412
+ // Wave rate is 2.8-3.0 Hz. 2-3 Hz is the social wave band; the bottom of it
413
+ // reads as tired, and the swing is the only part of a wave that carries the
414
+ // word "hi". BYE stays the slower and wider of the two, and buys its extra
415
+ // weight with a FOURTH swing rather than with a longer hold — which is the
416
+ // difference between a farewell and a stall.
417
+ //
418
+ // `face` names the interjection clip the mixer plays with the hand. It is not
419
+ // decoration: a hand rising to the jaw while the shoulders and head sit
420
+ // perfectly still is not attached to anybody. Those clips already exist in
421
+ // interjections.js and already move head, brows, shoulders and torso — the hand
422
+ // is the missing half of a gesture the rig has always half-played.
423
+ //
424
+ // `sc` is the gesture's DEPTH, multiplying `reach` about the wrist. One number,
425
+ // and it is the difference between a wave and a hand held up to the lens: you
426
+ // push a thumbs-up toward the camera and you throw a wave out to the side, so
427
+ // the waves render at 0.70 and everything else at 1. Without it the palm — the
428
+ // widest shape in the set — covered the whole face at every height it could
429
+ // legally occupy, which is a mouth-sync regression, i.e. a hard no.
430
+ export const HAND_GESTURES = {
431
+ // Wave. Out and up along an arc, three swings, gone. The peak puts the
432
+ // fingertips a third of the frame height above the bottom edge, which is
433
+ // where a webcam wave sits — so the hand passes the neck and shoulder, never
434
+ // the face.
435
+ //
436
+ // The swing is deliberately ASYMMETRIC, -2 out and +16 in. A wave rotating
437
+ // about a wrist below the frame throws the fingertips ~110 units sideways,
438
+ // and spending that outboard is what put the thumb through the portrait
439
+ // window the first time. Swinging further toward the person you are waving at
440
+ // is also, conveniently, what people do.
441
+ HI: {
442
+ id: 'HI', label: 'wave hello', shape: 'PALM', face: 'WAVE', dur: 1250, sc: 0.70,
443
+ out: [[0, 0], [150, 74], [300, 114], [1000, 114], [1250, 30]],
444
+ dy: [[0, HIDE], [150, 240], [300, 40], [390, 60], [700, 50], [1000, 58], [1120, 136], [1250, HIDE]],
445
+ rot: [[0, -3], [150, 2], [310, 16], [475, -2], [640, 16], [805, -1], [970, 12], [1000, 8], [1250, -3]],
446
+ },
447
+ // Goodbye: same hand, slower, one more swing, and it lingers at the top
448
+ // before dropping. A wave that leaves as briskly as it arrived reads as a
449
+ // dismissal rather than a farewell.
450
+ BYE: {
451
+ id: 'BYE', label: 'wave goodbye', shape: 'PALM', face: 'WAVE', dur: 1550, sc: 0.70,
452
+ out: [[0, 0], [170, 76], [320, 116], [1300, 116], [1550, 32]],
453
+ dy: [[0, HIDE], [160, 230], [320, 32], [410, 54], [700, 42], [1000, 52], [1300, 46], [1420, 128], [1550, HIDE]],
454
+ rot: [[0, -3], [170, 2], [330, 16], [510, -2], [690, 16], [870, -2], [1050, 16], [1230, -1], [1300, 8], [1550, -3]],
455
+ },
456
+ // Thumbs-up: straight up the middle, drifting outboard, and it stops at the
457
+ // JAW rather than the nose. A fist-with-thumb-up reaches ~0.76 of a hand's
458
+ // length from the wrist; pin the wrist below the frame edge as rule 1
459
+ // requires and nose height is unreachable without drawing the thumb longer
460
+ // than a middle finger — which is exactly what the rejected version did and
461
+ // exactly why it read the way it did. The wrist spends the entire budget (it
462
+ // sits on the floor), so the fist rides the bottom edge and only the thumb is
463
+ // up near the face.
464
+ THUMBS_UP: {
465
+ id: 'THUMBS_UP', label: 'thumbs up', shape: 'FIST', face: 'THUMBS_UP', dur: 1300,
466
+ out: [[0, 0], [160, 54], [300, 84], [1050, 84], [1300, 32]],
467
+ dy: [[0, HIDE], [160, 174], [300, 24], [390, 42], [700, 34], [1050, 40], [1160, 120], [1300, HIDE]],
468
+ rot: [[0, -6], [160, -2], [300, 3], [400, 0], [700, 1.5], [1000, 0], [1050, 0], [1300, -8]],
469
+ },
470
+ // One moment: index up, and then almost still. The hold is the signal — any
471
+ // real sway during it turns a "wait" into a wave, so the drift here is a
472
+ // couple of units and a degree, which reads as a held hand rather than as a
473
+ // stopped clock. Held longest of the four, because it is the one gesture
474
+ // whose job is to buy time (research-perception.md §1, latency masking).
475
+ ONE_MOMENT: {
476
+ id: 'ONE_MOMENT', label: 'one moment', shape: 'POINT', face: 'ONE_MOMENT', dur: 1700,
477
+ out: [[0, 0], [170, 60], [320, 96], [1400, 96], [1700, 34]],
478
+ dy: [[0, HIDE], [170, 244], [320, 32], [410, 54], [700, 46], [1100, 53], [1400, 48], [1520, 136], [1700, HIDE]],
479
+ rot: [[0, -7], [170, -2], [320, 2], [420, 0], [800, 1], [1200, -0.5], [1400, 0], [1700, -9]],
480
+ },
481
+ };
482
+
483
+ export const HAND_GESTURE_IDS = Object.keys(HAND_GESTURES);
484
+
485
+ // GO_ON is deliberately absent, and this is the reasoning rather than an
486
+ // oversight. It was drawn as a low splayed open palm rocking at the wrist; the
487
+ // stakeholder's verdict was "go on doesn't work for me" and the review was
488
+ // blunter — a black comb with no baseline. Three reasons it cannot be tuned into
489
+ // shape in this idiom:
490
+ // - a splayed palm facing camera is the universal STOP sign, so the drawing
491
+ // fights the meaning before the motion starts;
492
+ // - held low and cut by the edge, only the fingers clear the frame, so there
493
+ // is no hand identity to fall back on;
494
+ // - a real "go on" is a FINGER CURL, and this idiom has no way to curl a
495
+ // finger — the shape is static and only position and rotation animate.
496
+ // The one design that might work is a fingers-together PALM-UP hand scooping
497
+ // along its own axis, which is the invite register rather than the stop one. It
498
+ // is unbuilt on purpose: "keep going" is a backchannel, it fires every time the
499
+ // user pauses, and a hand entering the frame that often is a cost the message
500
+ // does not justify. The face carries it well — `GO_ON` and `GO_ON_ARM` survive
501
+ // as face-only interjections and are untouched by this module.
502
+
503
+ const smoothstep = (t) => t * t * (3 - 2 * t);
504
+
505
+ function sample(keys, t) {
506
+ if (t <= keys[0][0]) return keys[0][1];
507
+ const last = keys[keys.length - 1];
508
+ if (t >= last[0]) return last[1];
509
+ for (let i = 1; i < keys.length; i++) {
510
+ if (t <= keys[i][0]) {
511
+ const [t0, v0] = keys[i - 1];
512
+ const [t1, v1] = keys[i];
513
+ return v0 + (v1 - v0) * smoothstep((t - t0) / (t1 - t0));
514
+ }
515
+ }
516
+ return last[1];
517
+ }
518
+
519
+ // --- the layer --------------------------------------------------------------
520
+
521
+ /**
522
+ * Mount the hand over a face and return the player. Built by `createAvatar`;
523
+ * a host drives it through `avatar.gesture(id)`, never directly.
524
+ *
525
+ * @param {SVGElement} svg the mounted face's own root — same coordinate space
526
+ * @param {{ink:string, paper:string}} theme the mounted face's palette
527
+ * @param {{viewBox:{x:number,y:number,w:number,h:number}}} meta
528
+ * @param {{dir?: number}} [opts] dir +1 puts the hand on the viewer's right
529
+ * (the avatar gesturing with its left hand), -1 on the viewer's left. Both are
530
+ * anatomically real — the thumb always splays outward, away from the body — so
531
+ * this is a choice of which hand the character uses, not a mirroring bug.
532
+ */
533
+ export function createHand(svg, theme, meta, opts = {}) {
534
+ const fr = frameOf(meta.viewBox);
535
+ const NS = 'http://www.w3.org/2000/svg';
536
+ const g = document.createElementNS(NS, 'g');
537
+ // Appended last, so the hand is in front of everything. It is the nearest
538
+ // object in the frame; anything else would be a depth lie.
539
+ svg.appendChild(g);
540
+
541
+ let dir = opts.dir === -1 ? -1 : 1;
542
+
543
+ const ink = (d) => `<path d="${d}" fill="${theme.ink}"/>`;
544
+ const pap = (d) => `<path d="${d}" fill="${theme.paper}"/>`;
545
+ const scaled = (pts) => smooth(pts.map(([x, y]) => [x * fr.reach, y * fr.reach]));
546
+
547
+ // Paint order per shape, and each layer earns its place:
548
+ // halo white, wider than the ink, and only at the tails — it breaks the
549
+ // shirt's seams where the hand crosses them so the two whites do not
550
+ // run together (an early hand dissolved into the shirt);
551
+ // fill the closed contour, tail and all;
552
+ // ink the SAME contour as an OPEN mark, so it runs off the bottom edge
553
+ // instead of drawing a lid across the wrist.
554
+ // The rings (the thumb) go UNDER all of it — see FIST_THUMB. Painted
555
+ // underneath, the fist's fill swallows the thumb's base and its halo opens a
556
+ // white gap at the crossing, so the thumb emerges from the flank exactly as
557
+ // far as it should: about half the fist's height, and no further.
558
+ const build = ({ outline, marks, rings }) => {
559
+ const el = document.createElementNS(NS, 'g');
560
+ const pts = scaled(outline);
561
+ el.innerHTML =
562
+ rings.map((r) => {
563
+ const rp = scaled(r.pts);
564
+ return pap(region(rp)) + ink(taperRing(rp, W_THUMB));
565
+ }).join('') +
566
+ pap(taper(pts, W_HALO)) +
567
+ pap(region(pts)) +
568
+ ink(taper(pts, W_OUTLINE)) +
569
+ marks.map((m) => ink(taper(scaled(m.pts), m.w))).join('');
570
+ el.style.display = 'none';
571
+ g.appendChild(el);
572
+ return el;
573
+ };
574
+ const shapes = {};
575
+ for (const [k, s] of Object.entries(SHAPES)) shapes[k] = build(s);
576
+
577
+ let current = null; // { def, start }
578
+ let lastT = 0;
579
+
580
+ function place(x, y, rot, sc) {
581
+ // scale(-dir) mirrors the drawing so the thumb always points AWAY from the
582
+ // body, whichever side the hand is on; `sc` is the gesture's depth, and
583
+ // scaling about the wrist origin leaves the wrist where the timeline put it.
584
+ g.setAttribute('transform',
585
+ `translate(${f(x)} ${f(y)}) scale(${f(-dir * sc)} ${f(sc)}) rotate(${f(rot)})`);
586
+ }
587
+ function park() {
588
+ for (const s of Object.values(shapes)) s.style.display = 'none';
589
+ place(fr.cx, fr.bottom + HIDE, 0, 1);
590
+ }
591
+ park();
592
+
593
+ return {
594
+ get playing() { return !!current; },
595
+ get id() { return current ? current.def.id : null; },
596
+ setDir(d) { dir = d === -1 ? -1 : 1; if (!current) park(); },
597
+ /** @param {string} id @param {number} [atMs] start time on the layer's clock */
598
+ play(id, atMs) {
599
+ const def = HAND_GESTURES[id];
600
+ if (!def) throw new Error(`unknown hand gesture: ${id}`);
601
+ for (const [k, s] of Object.entries(shapes)) {
602
+ s.style.display = k === def.shape ? '' : 'none';
603
+ }
604
+ current = { def, start: atMs !== undefined ? atMs : lastT };
605
+ return def;
606
+ },
607
+ update(tMs) {
608
+ lastT = tMs;
609
+ if (!current) return;
610
+ const local = tMs - current.start;
611
+ if (local < 0) return;
612
+ const def = current.def;
613
+ if (local >= def.dur) { current = null; park(); return def; }
614
+ place(
615
+ fr.cx + dir * sample(def.out, local),
616
+ fr.bottom + sample(def.dy, local),
617
+ sample(def.rot, local),
618
+ def.sc || 1
619
+ );
620
+ },
621
+ stop() { current = null; park(); },
622
+ destroy() { if (g.parentNode) g.parentNode.removeChild(g); },
623
+ };
624
+ }
625
+
626
+ /**
627
+ * Rules 1 and 4, asserted rather than eyeballed — both were violated by an
628
+ * early cut and neither is visible in a still of the resting pose. Run by
629
+ * `tools/sweep.mjs` against every registered avatar, because the frame numbers
630
+ * are derived per avatar and a rig with a different window could break the
631
+ * budget without anything else noticing.
632
+ *
633
+ * rule 1 the wrist never rises into the frame;
634
+ * rule 4 no gesture pushes ink through the portrait window's side edge, at
635
+ * any point in its rotation. A hand 440 units tall throws its outer
636
+ * corner ~110 units sideways at 14 degrees, so the budget has to be
637
+ * spent against `out` AND `rot` together.
638
+ *
639
+ * @param {{viewBox:{x:number,y:number,w:number,h:number}}} meta
640
+ */
641
+ export function checkHandFraming(meta) {
642
+ const fr = frameOf(meta.viewBox);
643
+ const bad = [];
644
+ const worst = {};
645
+ for (const [name, def] of Object.entries(HAND_GESTURES)) {
646
+ for (const [ms, dy] of def.dy) {
647
+ if (dy < WRIST_DROP) bad.push(`${name}@${ms}ms wrist only ${dy} below the edge`);
648
+ }
649
+
650
+ // Walk the timeline rather than pairing extremes: `out` peaks during the
651
+ // hold and `rot` peaks during the swings, and a check that multiplies the
652
+ // two worst numbers together condemns gestures that are actually fine.
653
+ // Points are only counted while they are ON SCREEN — the tails spend the
654
+ // whole gesture below the frame edge and may reach anywhere they like.
655
+ const sc = (def.sc || 1) * fr.reach;
656
+ const pts = shapePoints(def.shape).map(([x, y]) => [x * sc, y * sc]);
657
+ let reach = 0;
658
+ let at = 0;
659
+ for (let ms = 0; ms <= def.dur; ms += 20) {
660
+ const out = sample(def.out, ms);
661
+ const dy = sample(def.dy, ms);
662
+ const a = (sample(def.rot, ms) * Math.PI) / 180;
663
+ const c = Math.cos(a);
664
+ const s = Math.sin(a);
665
+ for (const [x, y] of pts) {
666
+ // rotate (SVG clockwise), then the mirror in `place` flips x.
667
+ const sx = -(x * c - y * s);
668
+ const sy = x * s + y * c;
669
+ if (dy + sy > 0) continue; // below the frame's bottom edge
670
+ if (out + sx > reach) { reach = out + sx; at = ms; }
671
+ }
672
+ }
673
+ worst[name] = Math.round(reach + INK_HALF);
674
+ if (reach + INK_HALF > fr.outboardLimit) {
675
+ bad.push(`${name} puts ink ${worst[name]} outboard at ${at}ms, limit ${Math.round(fr.outboardLimit)}`);
676
+ }
677
+ }
678
+ if (bad.length) throw new Error(`hand framing: ${bad.join('; ')}`);
679
+ return { ok: true, wristDrop: WRIST_DROP, outboardLimit: fr.outboardLimit, worst };
680
+ }