@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.
- package/README.md +86 -51
- package/client/dist/Avatar.d.ts +17 -14
- package/client/dist/Avatar.d.ts.map +1 -1
- package/client/dist/Avatar.js +3 -3
- package/client/dist/Avatar.js.map +1 -1
- package/client/dist/AvatarClient.d.ts +23 -57
- package/client/dist/AvatarClient.d.ts.map +1 -1
- package/client/dist/AvatarClient.js +20 -71
- package/client/dist/AvatarClient.js.map +1 -1
- package/client/dist/index.d.ts +22 -0
- package/client/dist/index.d.ts.map +1 -0
- package/client/dist/index.js +22 -0
- package/client/dist/index.js.map +1 -0
- package/client/dist/types.d.ts +22 -37
- package/client/dist/types.d.ts.map +1 -1
- package/client/dist/types.js +12 -12
- package/client/dist/types.js.map +1 -1
- package/client/dist/useAvatar.d.ts +8 -19
- package/client/dist/useAvatar.d.ts.map +1 -1
- package/client/dist/useAvatar.js +15 -32
- package/client/dist/useAvatar.js.map +1 -1
- package/client/src/Avatar.tsx +19 -24
- package/client/src/AvatarClient.ts +38 -111
- package/client/src/index.ts +22 -0
- package/client/src/types.ts +24 -47
- package/client/src/useAvatar.ts +19 -47
- package/docs/contract-avatar.md +37 -3
- package/docs/contract-protocol.md +90 -30
- package/package.json +3 -11
- package/src/avatar.d.ts +38 -6
- package/src/avatar.js +72 -37
- package/src/hand.js +680 -0
- package/src/idle.js +8 -25
- package/src/line-art.js +26 -0
- package/src/perform.js +8 -4
- package/client/dist/pipecat.d.ts +0 -21
- package/client/dist/pipecat.d.ts.map +0 -1
- package/client/dist/pipecat.js +0 -21
- package/client/dist/pipecat.js.map +0 -1
- package/client/dist/react.d.ts +0 -16
- package/client/dist/react.d.ts.map +0 -1
- package/client/dist/react.js +0 -17
- package/client/dist/react.js.map +0 -1
- package/client/src/pipecat.ts +0 -38
- package/client/src/react.ts +0 -34
- 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
|
+
}
|