@voqalize/avatar 0.2.2 → 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.
Files changed (201) hide show
  1. package/LICENSE +21 -661
  2. package/README.md +144 -663
  3. package/client/Avatar.tsx +57 -0
  4. package/client/AvatarClient.ts +523 -0
  5. package/client/arjun.ts +24 -0
  6. package/client/createAvatar.ts +139 -0
  7. package/client/createCanvasAvatar.ts +72 -0
  8. package/client/index.ts +39 -0
  9. package/client/internal.ts +80 -0
  10. package/client/interviewer-female.ts +4 -0
  11. package/client/interviewer-male.ts +4 -0
  12. package/client/ishita.ts +24 -0
  13. package/client/kabir.ts +24 -0
  14. package/client/meera.ts +24 -0
  15. package/client/naina.ts +24 -0
  16. package/client/professional-female-a.ts +4 -0
  17. package/client/professional-female-b.ts +4 -0
  18. package/client/professional-male-a.ts +4 -0
  19. package/client/professional-male-b.ts +4 -0
  20. package/client/react.ts +13 -0
  21. package/client/types.ts +146 -0
  22. package/client/useAvatar.ts +74 -0
  23. package/client/vikram.ts +24 -0
  24. package/dist/Avatar.d.ts +44 -0
  25. package/dist/Avatar.d.ts.map +1 -0
  26. package/{client/dist → dist}/Avatar.js +2 -2
  27. package/dist/Avatar.js.map +1 -0
  28. package/dist/AvatarClient.d.ts +196 -0
  29. package/dist/AvatarClient.d.ts.map +1 -0
  30. package/dist/AvatarClient.js +450 -0
  31. package/dist/AvatarClient.js.map +1 -0
  32. package/dist/arjun.d.ts +6 -0
  33. package/dist/arjun.d.ts.map +1 -0
  34. package/dist/arjun.js +18 -0
  35. package/dist/arjun.js.map +1 -0
  36. package/dist/createAvatar.d.ts +99 -0
  37. package/dist/createAvatar.d.ts.map +1 -0
  38. package/dist/createAvatar.js +75 -0
  39. package/dist/createAvatar.js.map +1 -0
  40. package/dist/createCanvasAvatar.d.ts +22 -0
  41. package/dist/createCanvasAvatar.d.ts.map +1 -0
  42. package/dist/createCanvasAvatar.js +47 -0
  43. package/dist/createCanvasAvatar.js.map +1 -0
  44. package/dist/index.d.ts +30 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +29 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal.d.ts +30 -0
  49. package/dist/internal.d.ts.map +1 -0
  50. package/dist/internal.js +33 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/interviewer-female.d.ts +4 -0
  53. package/dist/interviewer-female.d.ts.map +1 -0
  54. package/dist/interviewer-female.js +3 -0
  55. package/dist/interviewer-female.js.map +1 -0
  56. package/dist/interviewer-male.d.ts +4 -0
  57. package/dist/interviewer-male.d.ts.map +1 -0
  58. package/dist/interviewer-male.js +3 -0
  59. package/dist/interviewer-male.js.map +1 -0
  60. package/dist/ishita.d.ts +6 -0
  61. package/dist/ishita.d.ts.map +1 -0
  62. package/dist/ishita.js +18 -0
  63. package/dist/ishita.js.map +1 -0
  64. package/dist/kabir.d.ts +6 -0
  65. package/dist/kabir.d.ts.map +1 -0
  66. package/dist/kabir.js +18 -0
  67. package/dist/kabir.js.map +1 -0
  68. package/dist/meera.d.ts +6 -0
  69. package/dist/meera.d.ts.map +1 -0
  70. package/dist/meera.js +18 -0
  71. package/dist/meera.js.map +1 -0
  72. package/dist/naina.d.ts +6 -0
  73. package/dist/naina.d.ts.map +1 -0
  74. package/dist/naina.js +18 -0
  75. package/dist/naina.js.map +1 -0
  76. package/dist/professional-female-a.d.ts +4 -0
  77. package/dist/professional-female-a.d.ts.map +1 -0
  78. package/dist/professional-female-a.js +3 -0
  79. package/dist/professional-female-a.js.map +1 -0
  80. package/dist/professional-female-b.d.ts +4 -0
  81. package/dist/professional-female-b.d.ts.map +1 -0
  82. package/dist/professional-female-b.js +3 -0
  83. package/dist/professional-female-b.js.map +1 -0
  84. package/dist/professional-male-a.d.ts +4 -0
  85. package/dist/professional-male-a.d.ts.map +1 -0
  86. package/dist/professional-male-a.js +3 -0
  87. package/dist/professional-male-a.js.map +1 -0
  88. package/dist/professional-male-b.d.ts +4 -0
  89. package/dist/professional-male-b.d.ts.map +1 -0
  90. package/dist/professional-male-b.js +3 -0
  91. package/dist/professional-male-b.js.map +1 -0
  92. package/dist/react.d.ts +13 -0
  93. package/dist/react.d.ts.map +1 -0
  94. package/dist/react.js +13 -0
  95. package/dist/react.js.map +1 -0
  96. package/dist/types.d.ts +94 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +93 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/useAvatar.d.ts +39 -0
  101. package/dist/useAvatar.d.ts.map +1 -0
  102. package/dist/useAvatar.js +35 -0
  103. package/dist/useAvatar.js.map +1 -0
  104. package/dist/vikram.d.ts +6 -0
  105. package/dist/vikram.d.ts.map +1 -0
  106. package/dist/vikram.js +18 -0
  107. package/dist/vikram.js.map +1 -0
  108. package/package.json +81 -24
  109. package/src/avatar.d.ts +161 -120
  110. package/src/avatar.js +234 -146
  111. package/src/behavior.d.ts +26 -0
  112. package/src/behavior.js +102 -0
  113. package/src/camera.js +29 -0
  114. package/src/canvas/author/parts/eye.mjs +722 -0
  115. package/src/canvas/author/parts/hand.mjs +1156 -0
  116. package/src/canvas/author/parts/mouth.mjs +741 -0
  117. package/src/canvas/author/parts/nose.mjs +100 -0
  118. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  119. package/src/canvas/author/path.mjs +283 -0
  120. package/src/canvas/author/rig.mjs +405 -0
  121. package/src/canvas/avatars/round/face.d.mts +3 -0
  122. package/src/canvas/avatars/round/face.mjs +1307 -0
  123. package/src/canvas/create-rig.d.ts +15 -0
  124. package/src/canvas/create-rig.js +100 -0
  125. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  126. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  127. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  128. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  129. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  130. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  131. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  132. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  133. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  134. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  135. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  136. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  137. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  138. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  139. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  140. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  141. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  142. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  143. package/src/canvas/data/interviewer-female.rig.json +1 -0
  144. package/src/canvas/data/interviewer-male.rig.json +1 -0
  145. package/src/canvas/data/professional-female-a.rig.json +1 -0
  146. package/src/canvas/data/professional-female-b.rig.json +1 -0
  147. package/src/canvas/data/professional-male-a.rig.json +1 -0
  148. package/src/canvas/data/professional-male-b.rig.json +1 -0
  149. package/src/canvas/src/live.js +508 -0
  150. package/src/canvas/src/render2d.js +218 -0
  151. package/src/canvas/src/rig.js +297 -0
  152. package/src/canvas/src/vocab.js +96 -0
  153. package/src/clips.js +18 -7
  154. package/src/conformance.js +119 -0
  155. package/src/emotions.js +4 -3
  156. package/src/face-core.js +27 -1
  157. package/src/face-myna.d.ts +7 -0
  158. package/src/face-myna.js +192 -131
  159. package/src/face-peep-control-plane.js +167 -0
  160. package/src/face-peep.d.ts +7 -0
  161. package/src/face-peep.js +274 -116
  162. package/src/face-wren.d.ts +7 -0
  163. package/src/face-wren.js +17 -19
  164. package/src/faces.d.ts +12 -0
  165. package/src/faces.js +53 -0
  166. package/src/gaze.js +119 -4
  167. package/src/hand.js +120 -91
  168. package/src/idle.js +18 -101
  169. package/src/interjections.js +156 -31
  170. package/src/params.js +8 -4
  171. package/src/perform.js +7 -9
  172. package/src/rig.d.ts +24 -0
  173. package/src/rig.js +32 -0
  174. package/src/speech-timing.js +23 -0
  175. package/src/visemes.js +28 -12
  176. package/client/dist/Avatar.d.ts +0 -27
  177. package/client/dist/Avatar.d.ts.map +0 -1
  178. package/client/dist/Avatar.js.map +0 -1
  179. package/client/dist/AvatarClient.d.ts +0 -139
  180. package/client/dist/AvatarClient.d.ts.map +0 -1
  181. package/client/dist/AvatarClient.js +0 -223
  182. package/client/dist/AvatarClient.js.map +0 -1
  183. package/client/dist/index.d.ts +0 -22
  184. package/client/dist/index.d.ts.map +0 -1
  185. package/client/dist/index.js +0 -22
  186. package/client/dist/index.js.map +0 -1
  187. package/client/dist/types.d.ts +0 -86
  188. package/client/dist/types.d.ts.map +0 -1
  189. package/client/dist/types.js +0 -31
  190. package/client/dist/types.js.map +0 -1
  191. package/client/dist/useAvatar.d.ts +0 -42
  192. package/client/dist/useAvatar.d.ts.map +0 -1
  193. package/client/dist/useAvatar.js +0 -51
  194. package/client/dist/useAvatar.js.map +0 -1
  195. package/client/src/Avatar.tsx +0 -33
  196. package/client/src/AvatarClient.ts +0 -270
  197. package/client/src/index.ts +0 -22
  198. package/client/src/types.ts +0 -104
  199. package/client/src/useAvatar.ts +0 -85
  200. package/docs/contract-avatar.md +0 -371
  201. package/docs/contract-protocol.md +0 -461
@@ -1,371 +0,0 @@
1
- # Contract B — mixer ↔ face (the avatar contract)
2
-
3
- *Living document. Describes the code as of `src/face*.js` on `main`; the
4
- [Direction](#direction) section flags what is about to change. The counterpart
5
- contract — what the server drives — is
6
- [contract-protocol.md](contract-protocol.md).*
7
-
8
- An avatar is a module exporting exactly this, and nothing more:
9
-
10
- ```js
11
- createFace(mount, theme) -> { svg, apply(params), theme, destroy() }
12
- META = { viewBox: {x, y, w, h}, mouthCrop: {x, y, w, h} }
13
- ```
14
-
15
- - `mount` — element to render into. `destroy()` empties it.
16
- - `theme` — optional per-key colour overrides merged over the rig's palette;
17
- the merged object is returned as `theme`. Theme *keys* are per-avatar
18
- (peep and wren carry 8 each; the retired rigs shared a ~25-key palette).
19
- Hosts that paint *around* the widget read them off `api.theme` — see
20
- Palettes below.
21
- - `svg` — the live `<svg>` element.
22
- - `apply(params)` — write one full parameter vector into the DOM. Called every
23
- animation frame.
24
- - `META` — the **avatar descriptor**: what a host or tool may know about the
25
- face without opening it. `viewBox` is the framing (hosts derive aspect from
26
- it; `createAvatar` exposes it as `api.meta`); `mouthCrop` frames the mouth
27
- for close inspection (the contact sheet's viseme-detail row). Deliberately
28
- minimal — a landmark joins META when a second consumer needs it, not before.
29
-
30
- **A face must be callable standalone.** Three rig-tooling pages call
31
- `AVATARS[name].create(mount)` directly with no mixer and drive `apply()` from
32
- a raw vector; a face that only works under `createAvatar` is broken.
33
-
34
- Nothing above the face knows what a face looks like; nothing in the face knows
35
- what a call is. If a visual change needs `params.js` touched, reconsider; if
36
- it needs anything outside `src/face*.js`, it's a bug.
37
-
38
- ## Obligations of `apply(params)`
39
-
40
- - **Consume, don't smooth.** Values arrive already clamped to `RANGE` and
41
- already smoothed through per-channel time constants. Add no easing of your
42
- own.
43
- - **Idempotent and cheap.** Same vector in, same DOM out; memoize attribute
44
- writes (every rig shares a `prev`-map `set(node, attr, val)` helper) so
45
- an unchanged channel costs nothing. ~60 calls/s is the budget.
46
- - **Never write `viewBox`.** Every pose channel is a transform or a path,
47
- never the camera. Tooling relies on this to crop safely after `apply()`.
48
- - **Honour the channel's *semantic*, not its plumbing.** The channel value is
49
- what an author of `visemes.js`/`emotions.js` — who never sees your rig —
50
- thinks they are asking for. The standing example: `mouthOpen` denotes the
51
- *visible aperture*. peep initially mapped it to the gap between lip
52
- centrelines; the drawn lip band was ~11 units thick, so the mouth stayed
53
- visibly shut until 0.25 and two of the nine visemes live below that. The fix
54
- was to solve back from aperture to control points, not to re-tune the
55
- visemes.
56
-
57
- ## The parameter vector
58
-
59
- 30 float channels (`src/params.js`). Rest is the neutral face; range is the
60
- post-mix clamp; τ is the smoothing time constant the mixer applies (the face
61
- never does). Sign conventions are from the *viewer's* perspective.
62
-
63
- | channel | rest | range | τ (s) | means |
64
- |---|---|---|---|---|
65
- | `mouthOpen` | 0.02 | 0..1 | 0.042 | visible vertical aperture |
66
- | `mouthWidth` | 0.42 | 0..1 | 0.042 | narrow..wide (0.42 neutral) |
67
- | `mouthRound` | 0.10 | 0..1 | 0.042 | pucker / protrusion |
68
- | `mouthPress` | 0.15 | 0..1 | 0.042 | lips thinned & pressed |
69
- | `mouthTuck` | 0 | 0..1 | 0.042 | lower lip under upper teeth (F/V) |
70
- | `mouthCornerL/R` | 0.10 | −1.4..1.4 | 0.13 | −frown..+smile |
71
- | `teethUpper` | 0 | 0..1 | 0.042 | upper-teeth reveal |
72
- | `tongue` | 0 | 0..1 | 0.042 | tongue raised into aperture (L) |
73
- | `jaw` | 0 | 0..1 | 0.07 | extra chin drop, lags the lips |
74
- | `lidL/R` | 0.12 | 0..1 | 0.018 | 0 wide open..1 closed; rest grazes the iris |
75
- | `squintL/R` | 0 | 0..1 | 0.12 | lower lid raised (smile/suspicion) |
76
- | `pupilX/Y` | 0 / 0.05 | −1..1 | 0.032 | gaze offset, +right / +down |
77
- | `browRaiseL/R` | 0 | −1..1 | 0.08 | whole-brow lift |
78
- | `browAngleL/R` | 0 | −1.4..1.4 | 0.08 | outer-end up |
79
- | `browInnerL/R` | 0 | −1..1 | 0.08 | inner-end lift (AU1, "concern") |
80
- | `headYaw` | 0 | −1.4..1.4 | 0.16 | + toward viewer's right |
81
- | `headPitch` | 0 | −1.4..1.4 | 0.16 | + chin down |
82
- | `headRoll` | 0 | −1.4..1.4 | 0.16 | + tilt toward viewer's right |
83
- | `breath` | 0 | 0..1 | 0.25 | idle-driven breathing cycle |
84
- | `shoulderL/R` | 0 | −1..1 | 0.19 | −dropped..+raised |
85
- | `torsoLean` | 0 | −1..1 | 0.24 | −back..+forward; reads as scale change |
86
- | `torsoTurn` | 0 | −1..1 | 0.44 | trunk lateral travel, + toward viewer's right |
87
-
88
- Groups (`GROUPS`): `mouth`, `smile`, `eyes`, `gaze`, `brows`, `head`, `body`
89
- (breath + torsoLean + torsoTurn), `shoulders` — clips declare group ownership by
90
- them.
91
-
92
- `torsoTurn`'s time constant is nearly 3× the head's, and that ratio is load-
93
- bearing rather than taste: the mixer feeds it the *same* target as `headYaw`
94
- (scaled by `TRUNK_FOLLOW = 0.45` in `avatar.js`), so a sustained head turn is
95
- chased by a trunk that leaves late and settles late. Follow-through falls out of
96
- the smoothing the rig already had; there is no second animation system. Shorten
97
- it toward 0.16 and head and trunk move as one rigid piece, which is the puppet
98
- read.
99
-
100
- A face should consume all 30. One sanctioned exception exists: peep ignores
101
- `jaw` (its construction has no drawn jaw line to drop — a documented character
102
- decision, not an oversight).
103
-
104
- There are deliberately **no arm or hand channels**; see the note in
105
- `params.js` before considering any.
106
-
107
- ## Invariant vs per-avatar
108
-
109
- Three rigs were built independently and their `apply()` implementations
110
- converged on the same eight blocks in the same order — torso lean → shoulders
111
- → parallax layer loop → eyes → brows → mouth → teeth → tongue — with the same
112
- memoizer and the same return shape. That convergence now lives in
113
- **`src/face-core.js`**, which owns:
114
-
115
- - the shell: mount, id-scoped selector, the memoized `set(node, attr, val)`;
116
- - `poseTransforms(p, set, el, POSE)` — lean, shoulders, parallax, driven by a
117
- per-rig `POSE` spec of named scalars (below);
118
- - the shared feature fragments a rig opts into where its model matches:
119
- `pairedTeeth` (peep, wren). Two more — `irisLidEyes` and `browPair` — were
120
- removed with the rigs that used them; a future rig with sclera and
121
- endpoint-pair brows should recover them from git history rather than
122
- re-derive them;
123
- - the shared constants: lean scale `0.055`, head-roll multipliers ×5.5
124
- features / ×1.5 torso, shrug/tilt derivation `shrug=(L+R)/2`, `tilt=(R−L)/2`,
125
- lower-teeth reveal ramp `(open − 0.45) / 0.4`, tongue gate `> 0.02`;
126
- - `faceApi` — the return shape.
127
-
128
- What legitimately varies per avatar, and stays in the face module:
129
-
130
- - **The `POSE`/`EYES` spec values**: `yawPx`/`pitchPx` (parallax travel),
131
- `pivot`, lean travel and pivot, shrug lift and tilt degrees, `turnPx` (lateral
132
- trunk travel at `torsoTurn = 1`), the breath model, pupil travel, `lidFollow`
133
- strength (0.22 on both current rigs), plus a `units` factor (see Art units).
134
- - **The breath model's numbers**. A rig declares `breathSwell` + `swellPivot`
135
- and breathes as a *scale about the hem*:
136
- the shoulder line rises and the chest widens while the bottom of the shirt
137
- stays put, and the head's matching lift is derived arithmetic
138
- (`swell × (swellPivot.y − pivot.y)`) rather than a second tuned constant, so
139
- the two layers cannot drift and the neck cannot telescope. `breathSwell` is
140
- required. It replaced a rigid vertical slide of the whole shirt, which moved
141
- *more* pixels and read as *less* alive — a figure translating up and down has
142
- been nudged, not filled with air.
143
- - **Structural choices**: layer set and parallax table (the retired rigs ran 7
144
- layers and 4; peep and wren fuse to 4 — the art decides, not a standard); eye
145
- model
146
- (iris + 4 lid paths vs peep's single translated group + regenerated bean);
147
- brow input (endpoint pair vs drawn point list); mouth output (path strings
148
- vs peep's contour point list); which optional elements exist (`jaw` shade,
149
- `subLip`, `seam`, split lip edges, lower teeth). A rig whose model diverges
150
- keeps its own block instead of the shared fragment — peep's eyes and brows
151
- are the standing example.
152
-
153
- Do not chase parity between faces: they are separate drawings, not renderings
154
- of one drawing. A visual improvement lands in one face and stops there.
155
-
156
- ## Art units
157
-
158
- Units are per-rig (peep 760×950 cropped to `92 76 576 800`; wren and myna the
159
- same window at `92 50`; the retired rigs were 320×400 and a native 1024² cropped to
160
- `179 42 666 832` — note how little the aspect agreed). **Copying a magnitude
161
- between rigs is silent breakage**: one retired rig's travels were the other's
162
- numbers with `units: S` (S = 2.67) in its `POSE` spec; peep's torso channels
163
- were once ported without conversion and the shoulders stopped reading, while
164
- nothing threw and `sweep()` passed. The trap inside the
165
- trap: **translations convert, degrees don't** — a rotation is already
166
- unit-independent, which is why `shrugTiltDeg` never takes the `units` factor.
167
-
168
- `viewBox` is not a rig constant. Hosts derive aspect from `META.viewBox` (or
169
- `api.meta.viewBox`); the demos do.
170
-
171
- ## Registration
172
-
173
- `src/avatar.js` holds the registry — `{ create, meta }` records:
174
-
175
- ```js
176
- export const AVATARS = {
177
- peep: { create, meta },
178
- wren: { create, meta },
179
- myna: { create, meta },
180
- };
181
- export const DEFAULT_AVATAR = 'peep';
182
- createAvatar({ avatar: 'peep' }) // by name
183
- createAvatar({ face: myCreateFace }) // any factory, never registered
184
- ```
185
-
186
- A bare `face:` factory has no descriptor; `createAvatar` then derives
187
- `meta.viewBox` from the produced svg and `meta.mouthCrop` is absent — registry
188
- avatars always carry the full META.
189
-
190
- Palettes: there is no barrel `THEME` export — each face module owns its
191
- palette, and `api.theme` returns the mounted avatar's. A host needs it
192
- whenever it paints anything *around* the widget: every rig is drawn portrait,
193
- so a 16:9 call tile leaves a margin either side of the drawing, and the margin
194
- has to be filled with the rig's own backdrop or the tile reads as a portrait
195
- picture hung in a landscape frame. `demo/call.html` does exactly that — tile
196
- background from `theme.bg0/bg1`, plus a mask feathering the drawing's two
197
- vertical edges, because peep's white shirt is drawn to run off its own frame
198
- and otherwise stops in mid-air. Reshaping the art to fit a host's box is the
199
- wrong fix; the widget does not control the box. peep has
200
- no dark palette **by decision** (inverting two-value line art recolours the
201
- hair and ages the character; that is geometry wearing a palette's clothes) —
202
- its theme keys stay overridable, but do not add a `dark` selector.
203
-
204
- ## The hand — a layer no face draws
205
-
206
- `src/hand.js` puts a hand into the bottom of the frame for `gesture(id)`
207
- (protocol side: [contract-protocol.md](contract-protocol.md) § Hand gestures).
208
- It is deliberately **not** part of this contract's parameter space: it writes a
209
- transform on its own `<g>` appended over the face's svg, it has no channel in
210
- `params.js`, and a face that never plays a gesture renders byte-for-byte what
211
- it rendered before. That is the whole reason it could be added at all — a hand
212
- channel only one avatar could draw is precisely the mistake CLAUDE.md
213
- constraint 9 names.
214
-
215
- **What a face owes it: a `META.viewBox`, and `theme.ink` / `theme.paper`.**
216
- Nothing else, and no new META field. Placement derives four numbers from the
217
- window itself — centre `x + w/2`, floor `y + h`, a reach scaled off `w`, and an
218
- outboard limit of `w/2 − 8` — and every gesture timeline is authored in wrist
219
- depth *below the floor* rather than absolute `y`, so the same drawing lands
220
- correctly on windows of different heights. peep's bottom is 876 and wren's and
221
- myna's is 850; all three place identically.
222
-
223
- Two framing rules are asserted, not assumed. `checkHandFraming(meta)` throws if
224
- any keyframe would let the wrist rise into the window (the hand must always be
225
- *cut* by the bottom edge, never end in a floating stump) or let the hand's
226
- rotated width cross the window's side (a portrait window pillarboxed in a 16:9
227
- tile slices anything outboard with a hard vertical line that reads as a
228
- rendering fault). `sweep()` runs it for every registered avatar, so a new face
229
- with an unusual window fails the gate rather than the eye.
230
-
231
- If a character's idiom cannot carry it, mount with `hand: false`; `gesture()`
232
- then plays the face half alone.
233
-
234
- ## Checklist for a new avatar
235
-
236
- 1. Serve with `python3 serve.py 8777` (never `python3 -m http.server` — its
237
- caching has burned this project three times).
238
- 2. `demo/rig/contact-sheet.html?face=NAME` — every viseme, emotion, gaze and
239
- channel extreme. Check the **mouth-detail crop row**, not just full heads:
240
- two visemes can be numerically distinct and visually identical (`G` vs `B`
241
- both read as a white strip until `G` was rebuilt as nearly-all-teeth). At
242
- avatar size a viseme is ~40 px tall; letter collisions are invisible on the
243
- full-head row. The crop row frames itself from your `META.mouthCrop`.
244
- 3. `demo/rig/torso-check.html?face=NAME` — shoulders × lean × head pose.
245
- These channels only fail *in combination*; this is where a rig leaks
246
- background from behind the shirt if it is going to.
247
- 4. `demo/rig/clip-strip.html?clip=NOD_SMALL&face=NAME` — phase relationships
248
- through the mixer's own smoothing, as a filmstrip.
249
- 5. `demo/rig/rig-check.html` → `await sweep()` — conformance: params finite,
250
- `|v| ≤ 2`, svg connected, across every state/emotion/gaze/interjection and
251
- a viseme track, plus `checkHandFraming` against your window and a pass of
252
- every hand gesture. Sweep also cannot see *looks*; it reaches shoulders/torso
253
- only through clips, so drive those with a `setOverrides` loop over
254
- `[-1, 0, 1]` per channel — and look at one hand gesture at peak extension
255
- (`demo/rig/body-lab.html?face=NAME&gesture=HI&at=0.4`), because figure/ground
256
- between hand and shirt is a judgement the framing check cannot make.
257
- 6. Auto-traced art has known failure modes to budget for: zero-margin abutting
258
- contours open seams under parallax; the trace stops at the source crop;
259
- hard horizontal edges invisible in the source appear under motion.
260
- 7. **The 130 px acceptance pass** — downsample the rest pose, the emotions
261
- row and the X/A mouth crops to ~130 px and judge *there*. Author at
262
- close-up, accept at tile size: the mouth must still read as smiling (not
263
- merely present), the six emotions must be tellable apart, and X vs A must
264
- differ in *shape*. Run the fixation audit on the rest tile: name the first
265
- three things you see, in order — the eye/mouth band places no worse than
266
- second. (Why: [research-perception.md](research-perception.md) §2, §5, §8.)
267
- 8. **Levelness by mirror** — render rest, flip it horizontally, and compare
268
- the pair; tilt and lopsidedness pop instantly. Judge on the glasses line
269
- and eye line. Rest must be channel-neutral and dead level: the mixer adds
270
- roll, sway and glances at runtime, and a baked-in tilt compounds with all
271
- of them. Drawing asymmetry (fringe, chin off midline) is welcome; *pose*
272
- asymmetry is a defect.
273
- 9. **Worst-case composites, not rest poses, for clearances** — build the
274
- extreme combination for every pair of marks that move relative to each
275
- other (brows-down + squint + pitch against a glasses frame; wide-open
276
- mouth against any under-lip mark) and verify a hard 3–4 unit gap.
277
- Near-tangency shimmers under animation. If an accessory and a channel
278
- collide, the accessory yields. Also render one **mid-blink** frame (lids
279
- held ~0.5 via rig-check's `pose()`): anything that must ride the lid — a
280
- lash line — is caught here, not at open or closed.
281
- 10. **Independent design review** — before a face is called done, a fresh-eyes
282
- reviewer (not the author) critiques it against the *product brief* at
283
- tile size, organized around the questions in
284
- [research-perception.md](research-perception.md): fixation hierarchy,
285
- resting trust/warmth, neoteny calibration, caricature economy, animation
286
- head-room, silhouette, long-session comfort. The output is prescriptions
287
- ranked by perceptual payoff ÷ stroke cost, plus a **protect-list** of
288
- marks confirmed right — which then stops future churn on them.
289
-
290
- ## Adding a new avatar
291
-
292
- Both halves of the old Direction section landed (`src/face-core.js` and
293
- `META`), and the recipe has been run end-to-end twice: `wren` as the plumbing
294
- proof, and `myna` (2026-08-07) as the proof of the *staged* process below —
295
- which is where the time and the judgement actually went.
296
-
297
- ### The staged process
298
-
299
- Identity and production quality are different problems; solving them in
300
- stages, with different acceptance bars, is what kept the myna run converging.
301
-
302
- - **Stage 0 — identity source.** A stakeholder-supplied reference image is
303
- the identity spec. Hard lesson (koel, rejected on sight the same day it
304
- passed every rig check): character identity is judged against a concrete
305
- image, not against adjectives — a brief-first avatar optimizes the wrong
306
- target however well it verifies. If there is no reference, get one agreed
307
- before authoring.
308
- - **Stage 1 — distill, then match.** Extract from the reference: the
309
- silhouette, the 3–5 identity marks, and the palette structure (where the
310
- one accent sits). That distillate — not the pixels — is what gets matched
311
- (peak shift: exaggerate the distinctive, simplify the generic —
312
- research-perception.md §5). *Measure* proportions off the bitmap instead of
313
- eyeballing (lens w:h, feature heights as fractions of face height — myna's
314
- glasses only landed when measured). Hand-author in the idiom; never
315
- auto-trace. The bar for this stage is the squint test at full size and
316
- 130 px: same person?
317
- - **Stage 2 — production calibration.** The reference *stops being the bar*;
318
- the brief takes over. This is where the science does the work, all of it
319
- judged on the 130 px surface: mouth ink floor so warmth survives the tile;
320
- channel→geometry gains raised until shared emotion poses land (categorical
321
- perception: near-extreme or invisible); resting brow/mouth set for trust,
322
- not personality (the smirk composite); resting lid graze against stare;
323
- interiors calmed against nod-flicker; clearance envelopes at worst-case
324
- composites; shape-not-amplitude viseme separation. Guardrail: **one neoteny
325
- move per round** (eye size *or* forehead *or* jaw *or* nose), then re-judge
326
- — two at once is how a professional assistant becomes a mascot. Note where
327
- production calibration diverges from the reference rather than silently
328
- splitting the difference.
329
- - **Stage 3 — independent review** (checklist item 10), then the stakeholder.
330
- The author does not review their own likeness; anchoring is real. Expect
331
- the reviewer to find the class of error the author cannot: authored at
332
- close-up, judged at close-up.
333
-
334
- ### What a face module supplies
335
-
336
- A new face module supplies:
337
-
338
- 1. **Static art** — the markup function: layer groups, the element table's
339
- nodes, theme-keyed fills. Hand-authored or cleaned trace; budget for the
340
- auto-trace failure modes in the checklist if tracing. For a line-art
341
- character, build every mark with `src/line-art.js` (`taper`, `taperRing`,
342
- `region` — filled variable-width marks, width profiles over normalized s);
343
- the width *profiles* are per-character and stay in the face module.
344
- 2. **A `POSE` spec** for `poseTransforms` — the named scalars (travels,
345
- pivots, bob, tilt degrees), the layer list/parallax table/torso subset, and
346
- `units`. Start from the rig whose construction is closest and re-derive
347
- every *travel* in your own units; keep degrees as judgements about your own
348
- collar/neck geometry, not conversions.
349
- 3. **Feature blocks** — use the face-core fragments where your model matches
350
- (`irisLidEyes`, `browPair`, `pairedTeeth`); write your own where the
351
- character disagrees. The mouth is always yours: honour the channel
352
- semantics in *Obligations* above. peep's bean-eye, point-list-brow and
353
- contour-mouth generators carried into wren as copies with re-derived
354
- constants — if a third line-art face repeats that, extract them into
355
- parameterized factories the way the stroke engine was extracted.
356
- 4. **`META`** — viewBox and mouthCrop.
357
- 5. **A registry entry** — `{ create, meta }` in `src/avatar.js`.
358
-
359
- What you get for free: the mixer, visemes, emotions, gaze, idle, clips,
360
- interjections, the frame-edge hand (§ The hand — it needs only your viewBox and
361
- two theme keys), the pose mechanics, the memoizer, and every host page and rig
362
- tool — the demos' avatar pickers, contact sheet, torso check, clip strip and
363
- `sweep()` all enumerate the registry. The wren run measured the split: the
364
- plumbing steps (2, 4, 5) are mechanical; the art (step 1) and the read of
365
- every state at tile size (the checklist) are where the judgement — and the
366
- time — actually goes. Static accessories interact with channels: wren's lens
367
- rings cap pupil travel, the exact channel DISTRACTED needs most — check your
368
- accessory against the gaze extremes early, not last.
369
-
370
- Then run the checklist above, and judge by eye — passing `sweep()` is not
371
- evidence a face looks right.