@voqalize/avatar 0.2.2 → 0.4.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 (265) hide show
  1. package/LICENSE +21 -661
  2. package/LICENSE-CC-BY-4.0 +396 -0
  3. package/README.md +191 -662
  4. package/assets/README.md +48 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tushar.glb +0 -0
  8. package/client/Avatar.tsx +57 -0
  9. package/client/AvatarClient.ts +818 -0
  10. package/client/arjun.ts +26 -0
  11. package/client/createAvatar.ts +177 -0
  12. package/client/createCanvasAvatar.ts +72 -0
  13. package/client/index.ts +44 -0
  14. package/client/internal.ts +108 -0
  15. package/client/interviewer-female.ts +4 -0
  16. package/client/interviewer-male.ts +4 -0
  17. package/client/ishita.ts +26 -0
  18. package/client/kabir.ts +26 -0
  19. package/client/meera.ts +26 -0
  20. package/client/naina.ts +26 -0
  21. package/client/playout.ts +95 -0
  22. package/client/professional-female-a.ts +4 -0
  23. package/client/professional-female-b.ts +4 -0
  24. package/client/professional-male-a.ts +4 -0
  25. package/client/professional-male-b.ts +4 -0
  26. package/client/react.ts +13 -0
  27. package/client/supports.ts +28 -0
  28. package/client/three/assets.ts +24 -0
  29. package/client/three/budgets.ts +27 -0
  30. package/client/three/holds.ts +64 -0
  31. package/client/three/internal.ts +54 -0
  32. package/client/three/motion-limits.json +116 -0
  33. package/client/three/sequences.ts +281 -0
  34. package/client/three/tanya.ts +69 -0
  35. package/client/three/tara-rig.ts +1562 -0
  36. package/client/three/tara.ts +76 -0
  37. package/client/three/tushar.ts +65 -0
  38. package/client/types.ts +235 -0
  39. package/client/useAvatar.ts +74 -0
  40. package/client/vikram.ts +26 -0
  41. package/dist/Avatar.d.ts +44 -0
  42. package/dist/Avatar.d.ts.map +1 -0
  43. package/{client/dist → dist}/Avatar.js +2 -2
  44. package/dist/Avatar.js.map +1 -0
  45. package/dist/AvatarClient.d.ts +274 -0
  46. package/dist/AvatarClient.d.ts.map +1 -0
  47. package/dist/AvatarClient.js +712 -0
  48. package/dist/AvatarClient.js.map +1 -0
  49. package/dist/arjun.d.ts +7 -0
  50. package/dist/arjun.d.ts.map +1 -0
  51. package/dist/arjun.js +20 -0
  52. package/dist/arjun.js.map +1 -0
  53. package/dist/createAvatar.d.ts +135 -0
  54. package/dist/createAvatar.d.ts.map +1 -0
  55. package/dist/createAvatar.js +75 -0
  56. package/dist/createAvatar.js.map +1 -0
  57. package/dist/createCanvasAvatar.d.ts +22 -0
  58. package/dist/createCanvasAvatar.d.ts.map +1 -0
  59. package/dist/createCanvasAvatar.js +47 -0
  60. package/dist/createCanvasAvatar.js.map +1 -0
  61. package/dist/index.d.ts +34 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +33 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/internal.d.ts +34 -0
  66. package/dist/internal.d.ts.map +1 -0
  67. package/dist/internal.js +41 -0
  68. package/dist/internal.js.map +1 -0
  69. package/dist/interviewer-female.d.ts +4 -0
  70. package/dist/interviewer-female.d.ts.map +1 -0
  71. package/dist/interviewer-female.js +3 -0
  72. package/dist/interviewer-female.js.map +1 -0
  73. package/dist/interviewer-male.d.ts +4 -0
  74. package/dist/interviewer-male.d.ts.map +1 -0
  75. package/dist/interviewer-male.js +3 -0
  76. package/dist/interviewer-male.js.map +1 -0
  77. package/dist/ishita.d.ts +7 -0
  78. package/dist/ishita.d.ts.map +1 -0
  79. package/dist/ishita.js +20 -0
  80. package/dist/ishita.js.map +1 -0
  81. package/dist/kabir.d.ts +7 -0
  82. package/dist/kabir.d.ts.map +1 -0
  83. package/dist/kabir.js +20 -0
  84. package/dist/kabir.js.map +1 -0
  85. package/dist/meera.d.ts +7 -0
  86. package/dist/meera.d.ts.map +1 -0
  87. package/dist/meera.js +20 -0
  88. package/dist/meera.js.map +1 -0
  89. package/dist/naina.d.ts +7 -0
  90. package/dist/naina.d.ts.map +1 -0
  91. package/dist/naina.js +20 -0
  92. package/dist/naina.js.map +1 -0
  93. package/dist/playout.d.ts +42 -0
  94. package/dist/playout.d.ts.map +1 -0
  95. package/dist/playout.js +85 -0
  96. package/dist/playout.js.map +1 -0
  97. package/dist/professional-female-a.d.ts +4 -0
  98. package/dist/professional-female-a.d.ts.map +1 -0
  99. package/dist/professional-female-a.js +3 -0
  100. package/dist/professional-female-a.js.map +1 -0
  101. package/dist/professional-female-b.d.ts +4 -0
  102. package/dist/professional-female-b.d.ts.map +1 -0
  103. package/dist/professional-female-b.js +3 -0
  104. package/dist/professional-female-b.js.map +1 -0
  105. package/dist/professional-male-a.d.ts +4 -0
  106. package/dist/professional-male-a.d.ts.map +1 -0
  107. package/dist/professional-male-a.js +3 -0
  108. package/dist/professional-male-a.js.map +1 -0
  109. package/dist/professional-male-b.d.ts +4 -0
  110. package/dist/professional-male-b.d.ts.map +1 -0
  111. package/dist/professional-male-b.js +3 -0
  112. package/dist/professional-male-b.js.map +1 -0
  113. package/dist/react.d.ts +13 -0
  114. package/dist/react.d.ts.map +1 -0
  115. package/dist/react.js +13 -0
  116. package/dist/react.js.map +1 -0
  117. package/dist/supports.d.ts +20 -0
  118. package/dist/supports.d.ts.map +1 -0
  119. package/dist/supports.js +26 -0
  120. package/dist/supports.js.map +1 -0
  121. package/dist/three/assets.d.ts +24 -0
  122. package/dist/three/assets.d.ts.map +1 -0
  123. package/dist/three/assets.js +22 -0
  124. package/dist/three/assets.js.map +1 -0
  125. package/dist/three/budgets.d.ts +17 -0
  126. package/dist/three/budgets.d.ts.map +1 -0
  127. package/dist/three/budgets.js +21 -0
  128. package/dist/three/budgets.js.map +1 -0
  129. package/dist/three/holds.d.ts +33 -0
  130. package/dist/three/holds.d.ts.map +1 -0
  131. package/dist/three/holds.js +56 -0
  132. package/dist/three/holds.js.map +1 -0
  133. package/dist/three/internal.d.ts +52 -0
  134. package/dist/three/internal.d.ts.map +1 -0
  135. package/dist/three/internal.js +49 -0
  136. package/dist/three/internal.js.map +1 -0
  137. package/dist/three/motion-limits.json +116 -0
  138. package/dist/three/sequences.d.ts +71 -0
  139. package/dist/three/sequences.d.ts.map +1 -0
  140. package/dist/three/sequences.js +262 -0
  141. package/dist/three/sequences.js.map +1 -0
  142. package/dist/three/tanya.d.ts +35 -0
  143. package/dist/three/tanya.d.ts.map +1 -0
  144. package/dist/three/tanya.js +51 -0
  145. package/dist/three/tanya.js.map +1 -0
  146. package/dist/three/tara-rig.d.ts +403 -0
  147. package/dist/three/tara-rig.d.ts.map +1 -0
  148. package/dist/three/tara-rig.js +1500 -0
  149. package/dist/three/tara-rig.js.map +1 -0
  150. package/dist/three/tara.d.ts +35 -0
  151. package/dist/three/tara.d.ts.map +1 -0
  152. package/dist/three/tara.js +58 -0
  153. package/dist/three/tara.js.map +1 -0
  154. package/dist/three/tushar.d.ts +31 -0
  155. package/dist/three/tushar.d.ts.map +1 -0
  156. package/dist/three/tushar.js +47 -0
  157. package/dist/three/tushar.js.map +1 -0
  158. package/dist/types.d.ts +149 -0
  159. package/dist/types.d.ts.map +1 -0
  160. package/dist/types.js +140 -0
  161. package/dist/types.js.map +1 -0
  162. package/dist/useAvatar.d.ts +39 -0
  163. package/dist/useAvatar.d.ts.map +1 -0
  164. package/dist/useAvatar.js +35 -0
  165. package/dist/useAvatar.js.map +1 -0
  166. package/dist/vikram.d.ts +7 -0
  167. package/dist/vikram.d.ts.map +1 -0
  168. package/dist/vikram.js +20 -0
  169. package/dist/vikram.js.map +1 -0
  170. package/package.json +110 -25
  171. package/src/avatar.d.ts +218 -120
  172. package/src/avatar.js +950 -213
  173. package/src/behavior.d.ts +42 -0
  174. package/src/behavior.js +114 -0
  175. package/src/camera.js +29 -0
  176. package/src/canvas/author/parts/eye.mjs +722 -0
  177. package/src/canvas/author/parts/hand.mjs +1156 -0
  178. package/src/canvas/author/parts/mouth.mjs +741 -0
  179. package/src/canvas/author/parts/nose.mjs +100 -0
  180. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  181. package/src/canvas/author/path.mjs +283 -0
  182. package/src/canvas/author/rig.mjs +405 -0
  183. package/src/canvas/avatars/round/face.d.mts +3 -0
  184. package/src/canvas/avatars/round/face.mjs +1307 -0
  185. package/src/canvas/create-rig.d.ts +15 -0
  186. package/src/canvas/create-rig.js +100 -0
  187. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  188. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  189. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  190. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  191. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  192. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  193. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  194. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  195. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  196. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  197. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  198. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  199. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  200. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  201. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  202. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  203. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  204. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  205. package/src/canvas/data/interviewer-female.rig.json +1 -0
  206. package/src/canvas/data/interviewer-male.rig.json +1 -0
  207. package/src/canvas/data/professional-female-a.rig.json +1 -0
  208. package/src/canvas/data/professional-female-b.rig.json +1 -0
  209. package/src/canvas/data/professional-male-a.rig.json +1 -0
  210. package/src/canvas/data/professional-male-b.rig.json +1 -0
  211. package/src/canvas/src/live.js +508 -0
  212. package/src/canvas/src/render2d.js +218 -0
  213. package/src/canvas/src/rig.js +297 -0
  214. package/src/canvas/src/vocab.js +96 -0
  215. package/src/clips.js +18 -7
  216. package/src/conformance.js +119 -0
  217. package/src/emotions.js +8 -5
  218. package/src/face-core.js +27 -1
  219. package/src/face-myna.d.ts +7 -0
  220. package/src/face-myna.js +192 -131
  221. package/src/face-peep-control-plane.js +167 -0
  222. package/src/face-peep.d.ts +7 -0
  223. package/src/face-peep.js +274 -116
  224. package/src/face-wren.d.ts +7 -0
  225. package/src/face-wren.js +17 -19
  226. package/src/faces.d.ts +12 -0
  227. package/src/faces.js +53 -0
  228. package/src/gaze.js +434 -58
  229. package/src/hand.js +120 -91
  230. package/src/head.js +134 -0
  231. package/src/idle.js +227 -119
  232. package/src/interjections.js +200 -31
  233. package/src/params.js +8 -4
  234. package/src/perform.js +7 -9
  235. package/src/prosody.js +647 -0
  236. package/src/rig.d.ts +24 -0
  237. package/src/rig.js +32 -0
  238. package/src/speech-timing.js +23 -0
  239. package/src/visemes.js +110 -24
  240. package/client/dist/Avatar.d.ts +0 -27
  241. package/client/dist/Avatar.d.ts.map +0 -1
  242. package/client/dist/Avatar.js.map +0 -1
  243. package/client/dist/AvatarClient.d.ts +0 -139
  244. package/client/dist/AvatarClient.d.ts.map +0 -1
  245. package/client/dist/AvatarClient.js +0 -223
  246. package/client/dist/AvatarClient.js.map +0 -1
  247. package/client/dist/index.d.ts +0 -22
  248. package/client/dist/index.d.ts.map +0 -1
  249. package/client/dist/index.js +0 -22
  250. package/client/dist/index.js.map +0 -1
  251. package/client/dist/types.d.ts +0 -86
  252. package/client/dist/types.d.ts.map +0 -1
  253. package/client/dist/types.js +0 -31
  254. package/client/dist/types.js.map +0 -1
  255. package/client/dist/useAvatar.d.ts +0 -42
  256. package/client/dist/useAvatar.d.ts.map +0 -1
  257. package/client/dist/useAvatar.js +0 -51
  258. package/client/dist/useAvatar.js.map +0 -1
  259. package/client/src/Avatar.tsx +0 -33
  260. package/client/src/AvatarClient.ts +0 -270
  261. package/client/src/index.ts +0 -22
  262. package/client/src/types.ts +0 -104
  263. package/client/src/useAvatar.ts +0 -85
  264. package/docs/contract-avatar.md +0 -371
  265. package/docs/contract-protocol.md +0 -461
package/src/hand.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * The avatar has no arms (CLAUDE.md, constraint 9): two earlier rigs carried a
5
5
  * full forearm/hand chain, nine parameter channels and several hundred lines of
6
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
7
+ * survived a stakeholder trial and was promoted on 2026-08-07; what makes it a
8
+ * different proposition from the thing that was cut is that **it is not part of
9
+ * the rig**. No parameter channel, no per-face
10
10
  * geometry, no forearm — one drawing, placed by four numbers derived from the
11
11
  * mounted avatar's own `META.viewBox`, painted in its own theme, appended over
12
12
  * its SVG. A face that never plays a gesture is byte-for-byte what it was.
@@ -39,37 +39,37 @@
39
39
  * the waves sit LOWER and further back instead (`sc`), and the face stays
40
40
  * entirely clear.
41
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
42
+ * 4. THE ONLY EDGE THAT CUTS THE HAND IS THE BOTTOM ONE. Anything past the
43
+ * camera's side edge is sliced by a hard vertical line that reads as a
44
+ * rendering fault. Outboard travel is budgeted against the hand's own
46
45
  * width AND its rotation — a hand 440 units tall swings ~110 units sideways
47
46
  * at 14 degrees, which is what silently blew the budget the first time.
48
47
  * `checkHandFraming()` asserts both rules against the real timelines.
49
48
  *
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
49
+ * See docs/internal-mixer.md § Hand gestures for the wire surface and
50
+ * docs/authoring-a-face.md § The hand for what a face module owes this module
52
51
  * (the answer is: a viewBox and two theme keys).
53
52
  */
54
53
 
55
54
  import { taper, taperRing, region, smooth } from './line-art.js';
56
55
  import { f } from './face-core.js';
56
+ import { CALL_CAMERA } from './camera.js';
57
57
 
58
58
  // --- the frame --------------------------------------------------------------
59
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.
60
+ // that is the whole reason this module needed no new META field. The shared
61
+ // camera makes 70% of its height a head, so size follows the person rather
62
+ // than growing merely because the new 4:3 camera is wider.
63
63
  //
64
64
  // The wrist sits below the frame edge by a clear margin, never at it: a wrist
65
65
  // exactly on the line renders as a rounded end kissing the border, which reads
66
66
  // as a hand stuck to the frame rather than one coming from off-camera.
67
67
  const WRIST_DROP = 24; // minimum units the wrist stays below the frame bottom
68
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.
69
+ // How far the outermost ink may sit from the frame centre before the camera
70
+ // slices it (rule 4): half the window, less a hair of margin.
71
71
  const SIDE_MARGIN = 8;
72
- // Author scale at a 576-unit-wide window. The number this design changed most,
72
+ // Author scale at a 480-unit-tall head. The number this design changed most,
73
73
  // and both directions were rendered and judged. Earlier cuts drew the hand at
74
74
  // FACE depth: a 19 cm hand against a 23 cm head is 0.83 of it, peep's head is
75
75
  // 477 art units, so ~394 — which came out looking like a pale tube rising past
@@ -85,7 +85,8 @@ const SIDE_MARGIN = 8;
85
85
  // becomes ground, and the tile reads as broken before it reads as a gesture.
86
86
  // Optically correct, perceptually wrong. 2.95 puts the palm at ~0.82
87
87
  // head-widths — still clearly nearer the lens, still clearly not the subject.
88
- const REACH_AT_576 = 2.95;
88
+ // Compact avatar tiles need a hand that reads as a gesture, not as the subject.
89
+ const REACH_AT_480_HEAD = 2.40;
89
90
 
90
91
  /** The four placement numbers, derived. A host never sees these. */
91
92
  function frameOf(viewBox) {
@@ -93,7 +94,7 @@ function frameOf(viewBox) {
93
94
  return {
94
95
  cx: vb.x + vb.w / 2, // frame centre; also where a resting hand starts
95
96
  bottom: vb.y + vb.h, // the visible bottom edge — the line the hand rises past
96
- reach: (REACH_AT_576 * vb.w) / 576,
97
+ reach: (REACH_AT_480_HEAD * vb.h * CALL_CAMERA.head) / 480,
97
98
  outboardLimit: vb.w / 2 - SIDE_MARGIN,
98
99
  };
99
100
  }
@@ -129,17 +130,10 @@ const PALM = [
129
130
  // bottom edge, which reads as a shape RESTING ON the edge (a bag, a sheet of
130
131
  // paper) rather than one continuing past it. Widest at ~a third down from the
131
132
  // 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
133
+ [24, 300], [26, 58], [30, 18], [38, -18], [42, -48],
134
+ [39, -72], [31, -86], [21, -84],
135
+ [17, -104], [8, -116], [-4, -114], [-13, -102], [-16, -84],
136
+ [-22, -102], [-34, -111], [-46, -104], [-53, -91], [-53, -76],
143
137
  // The thumb is a CONVEX WEDGE, and that is a deletion rather than an
144
138
  // addition. An earlier thumb had a re-entrant curl in its outer contour; at
145
139
  // 130 px the hook read as a detached ear sitting beside the jaw, which was the
@@ -147,11 +141,8 @@ const PALM = [
147
141
  // this contour: it leaves the palm's outer edge, swells to a rounded pad at
148
142
  // ~40 degrees off the palm axis, and comes back. A thumb is a wedge, and a
149
143
  // 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],
144
+ [-68, -73], [-80, -64], [-84, -52], [-80, -40],
145
+ [-68, -31], [-56, -22], [-47, -5], [-41, 28], [-34, 300],
155
146
  ];
156
147
  // Where the finger information moved to. An earlier cut put the interdigital
157
148
  // valleys 50-60% of the way down the fingers, which makes a SAW: at 130 px a
@@ -167,8 +158,8 @@ const PALM = [
167
158
  // size this ships at) and at full size it reads as a scratch. Economy is not a
168
159
  // style here, it is the difference between a mark and a blemish.
169
160
  const PALM_SEPS = [
170
- [[13, -130], [14, -122], [15, -113]], // little/ring from middle
171
- [[-20, -132], [-21, -124], [-21, -115]], // middle from index
161
+ [[10, -94], [10, -89], [11, -84]],
162
+ [[-20, -94], [-21, -89], [-21, -84]],
172
163
  ];
173
164
  // The one interior mark that says PALM rather than back-of-hand — and it is the
174
165
  // THENAR crease, the arc around the ball of the thumb, not a line across the
@@ -177,7 +168,7 @@ const PALM_SEPS = [
177
168
  // rounded white form, at tile size, beside a face, reads as a MOUTH. The arc
178
169
  // runs off the bottom edge instead of ending, so nothing about it says "this
179
170
  // mark stopped here".
180
- const PALM_CREASE = [[-46, -80], [-41, -54], [-30, -26], [-14, -2], [-8, 14]];
171
+ const PALM_CREASE = [[-53, -65], [-46, -47], [-34, -27], [-18, -8]];
181
172
 
182
173
  // --- dorsal: the fist, for thumbs-up ----------------------------------------
183
174
  // The back of a closed hand: an undulating top edge of metacarpal heads, and a
@@ -199,13 +190,10 @@ const PALM_CREASE = [[-46, -80], [-41, -54], [-30, -26], [-14, -2], [-8, 14]];
199
190
  // that is true, but by 16 units across 60 rather than by 30; the mass is 82 wide
200
191
  // against 78 visible. The two undulations survive only as inflections in it.
201
192
  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],
193
+ [24, 300], [26, 62], [30, 20], [39, -7], [45, -31],
194
+ [42, -51], [31, -64], [14, -70], [-4, -69],
195
+ [-21, -63], [-35, -51], [-42, -31], [-40, -6],
196
+ [-35, 62], [-31, 300],
209
197
  ];
210
198
  // Four small knuckle bumps were drawn at the same scale and rhythm as peep's
211
199
  // hair spikes, so at 130 px the row read as a lapel zigzag continuing the
@@ -223,7 +211,7 @@ const FIST = [
223
211
  // frame cut and left the knuckle half of the fist empty. An empty top and a
224
212
  // marked bottom is upside down: the eye goes to the crest first, and finds
225
213
  // nothing there.
226
- const FIST_CURL = [[24, -48], [8, -58], [-8, -60], [-24, -52]];
214
+ const FIST_CURL = [[22, -43], [7, -50], [-10, -49], [-25, -41]];
227
215
  // The thumb is a SEPARATE CLOSED SHAPE crossing the fist, which is what keeps
228
216
  // the middle-finger read dead — the first thumbs-up drawn here was rejected on
229
217
  // sight for exactly that. A glyph has tolerances and these are them:
@@ -248,12 +236,9 @@ const FIST_CURL = [[24, -48], [8, -58], [-8, -60], [-24, -52]];
248
236
  // its tip and the entire radial column reads as ONE very long digit — the
249
237
  // middle-finger silhouette, rebuilt out of correct parts.
250
238
  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],
239
+ [17, -42], [8, -61], [2, -78], [-8, -90],
240
+ [-21, -90], [-31, -80], [-33, -66], [-27, -52],
241
+ [-16, -41], [17, -42],
257
242
  ];
258
243
  // There is no thumbnail, and that is the clearest single deletion in this
259
244
  // drawing. A short curved crease near the top of the thumb, at 130 px, on a
@@ -270,11 +255,9 @@ const FIST_THUMB = [
270
255
  // meaning lives in one unambiguous silhouette rather than in notches, and where
271
256
  // it crosses the hair, value does the separating.
272
257
  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
258
+ [24, 300], [26, 62], [30, 20], [39, -7], [44, -29],
259
+ [37, -49], [17, -53], [8, -66], [6, -107],
260
+ [0, -128], [-11, -136], [-23, -130], [-29, -116], [-29, -72],
278
261
  // The index. It once cleared the knuckles by 75 units against a visible fist
279
262
  // of 90 — anatomically defensible, and at 0.79 of a head-width it was the
280
263
  // longest single mark in the tile. Nothing that is not the face gets to be
@@ -286,15 +269,9 @@ const POINT = [
286
269
  // It tapers toward the tip and carries no joint pinch. The pinch was drawn in
287
270
  // the OUTLINE, and a wobble that small in a contour does not read as a knuckle
288
271
  // 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
272
  // The thumb, clamped across the curled fingers and showing as a lobe on the
294
273
  // 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],
274
+ [-39, -55], [-42, -31], [-40, -6], [-35, 62], [-31, 300],
298
275
  ];
299
276
 
300
277
  // Ink weights are in ART units, sampled off peep's own marks: the torso runs
@@ -318,22 +295,22 @@ const POINT = [
318
295
  // (compose so it doesn't cross; weight hierarchy; knockout) and it is the one
319
296
  // already native to these rigs, whose head contour outweighs the brow which
320
297
  // outweighs the ear whorl.
321
- const W_OUTLINE = [16, 15, 9, 6, 9, 15, 17];
298
+ const W_OUTLINE = [12, 11, 8, 6, 8, 11, 12];
322
299
  // Device 3, the knockout: a white halo that breaks the shirt's seams where the
323
300
  // hand crosses them. It is deliberately NARROW across the middle of the mark:
324
301
  // the hand's upper half sits against the background, and a white rim there would
325
302
  // turn it into a cut-out sticker. Only the tails — the part over the shirt — get
326
303
  // the full gap. Where the halo width equals the ink width it is entirely covered
327
304
  // and costs nothing, so the profile is scaled with the ink above it.
328
- const W_HALO = [34, 29, 13, 6, 13, 29, 35];
305
+ const W_HALO = [26, 22, 13, 6, 13, 22, 26];
329
306
  const W_CREASE = [2, 8, 2];
330
307
  // The palm's finger separators: thick where they leave the notch floor, gone by
331
308
  // the end. This is the ear-whorl mark language, applied to a hand.
332
- const W_SEP = [7, 3, 0.5];
309
+ const W_SEP = [5, 2, 0.5];
333
310
  // The fist's one interior mark. Thick-to-thin across an S, so it reads as a form
334
311
  // turning rather than as a drawn line.
335
312
  const W_CURL = [3, 9, 7, 2];
336
- const W_THUMB = [12, 14, 11, 8, 11, 14, 12];
313
+ const W_THUMB = [9, 11, 9, 7, 9, 11, 9];
337
314
 
338
315
  // The shapes, and the interior marks each one carries. Interior marks carry
339
316
  // their own width profile rather than sharing one: three of them exist, they do
@@ -354,7 +331,7 @@ const SHAPES = {
354
331
  // being an empty slab.
355
332
  POINT: {
356
333
  outline: POINT,
357
- marks: [{ pts: [[22, -50], [6, -58], [-10, -58]], w: W_CURL }],
334
+ marks: [{ pts: [[20, -42], [5, -48], [-11, -47]], w: W_CURL }],
358
335
  rings: [],
359
336
  },
360
337
  };
@@ -435,11 +412,11 @@ export const HAND_GESTURES = {
435
412
  //
436
413
  // The swing is deliberately ASYMMETRIC, -2 out and +16 in. A wave rotating
437
414
  // 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
415
+ // and spending that outboard is what put the thumb through the camera edge
416
+ // the first time. Swinging further toward the person you are waving at
440
417
  // is also, conveniently, what people do.
441
- HI: {
442
- id: 'HI', label: 'wave hello', shape: 'PALM', face: 'WAVE', dur: 1250, sc: 0.70,
418
+ GESTURE_GREET: {
419
+ id: 'GESTURE_GREET', label: 'greet', shape: 'PALM', face: 'GESTURE_GREET', dur: 1250, sc: 0.70,
443
420
  out: [[0, 0], [150, 74], [300, 114], [1000, 114], [1250, 30]],
444
421
  dy: [[0, HIDE], [150, 240], [300, 40], [390, 60], [700, 50], [1000, 58], [1120, 136], [1250, HIDE]],
445
422
  rot: [[0, -3], [150, 2], [310, 16], [475, -2], [640, 16], [805, -1], [970, 12], [1000, 8], [1250, -3]],
@@ -447,8 +424,8 @@ export const HAND_GESTURES = {
447
424
  // Goodbye: same hand, slower, one more swing, and it lingers at the top
448
425
  // before dropping. A wave that leaves as briskly as it arrived reads as a
449
426
  // dismissal rather than a farewell.
450
- BYE: {
451
- id: 'BYE', label: 'wave goodbye', shape: 'PALM', face: 'WAVE', dur: 1550, sc: 0.70,
427
+ GESTURE_GOODBYE: {
428
+ id: 'GESTURE_GOODBYE', label: 'goodbye', shape: 'PALM', face: 'GESTURE_GREET', dur: 1550, sc: 0.70,
452
429
  out: [[0, 0], [170, 76], [320, 116], [1300, 116], [1550, 32]],
453
430
  dy: [[0, HIDE], [160, 230], [320, 32], [410, 54], [700, 42], [1000, 52], [1300, 46], [1420, 128], [1550, HIDE]],
454
431
  rot: [[0, -3], [170, 2], [330, 16], [510, -2], [690, 16], [870, -2], [1050, 16], [1230, -1], [1300, 8], [1550, -3]],
@@ -461,8 +438,8 @@ export const HAND_GESTURES = {
461
438
  // exactly why it read the way it did. The wrist spends the entire budget (it
462
439
  // sits on the floor), so the fist rides the bottom edge and only the thumb is
463
440
  // up near the face.
464
- THUMBS_UP: {
465
- id: 'THUMBS_UP', label: 'thumbs up', shape: 'FIST', face: 'THUMBS_UP', dur: 1300,
441
+ GESTURE_APPROVE: {
442
+ id: 'GESTURE_APPROVE', label: 'approve', shape: 'FIST', face: 'GESTURE_APPROVE', dur: 1300, sc: 0.82,
466
443
  out: [[0, 0], [160, 54], [300, 84], [1050, 84], [1300, 32]],
467
444
  dy: [[0, HIDE], [160, 174], [300, 24], [390, 42], [700, 34], [1050, 40], [1160, 120], [1300, HIDE]],
468
445
  rot: [[0, -6], [160, -2], [300, 3], [400, 0], [700, 1.5], [1000, 0], [1050, 0], [1300, -8]],
@@ -472,8 +449,8 @@ export const HAND_GESTURES = {
472
449
  // couple of units and a degree, which reads as a held hand rather than as a
473
450
  // stopped clock. Held longest of the four, because it is the one gesture
474
451
  // 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,
452
+ GESTURE_WAIT: {
453
+ id: 'GESTURE_WAIT', label: 'wait', shape: 'POINT', face: 'GESTURE_WAIT', dur: 1700, sc: 0.78,
477
454
  out: [[0, 0], [170, 60], [320, 96], [1400, 96], [1700, 34]],
478
455
  dy: [[0, HIDE], [170, 244], [320, 32], [410, 54], [700, 46], [1100, 53], [1400, 48], [1520, 136], [1700, HIDE]],
479
456
  rot: [[0, -7], [170, -2], [320, 2], [420, 0], [800, 1], [1200, -0.5], [1400, 0], [1700, -9]],
@@ -482,6 +459,22 @@ export const HAND_GESTURES = {
482
459
 
483
460
  export const HAND_GESTURE_IDS = Object.keys(HAND_GESTURES);
484
461
 
462
+ /** Public renderer-frame gesture names → current SVG timelines. */
463
+ const FRAME_GESTURES = Object.freeze({
464
+ greet: 'GESTURE_GREET',
465
+ farewell: 'GESTURE_GOODBYE',
466
+ approve: 'GESTURE_APPROVE',
467
+ wait: 'GESTURE_WAIT',
468
+ });
469
+ const GESTURE_TO_FRAME = Object.freeze(Object.fromEntries(
470
+ Object.entries(FRAME_GESTURES).map(([name, id]) => [id, name]),
471
+ ));
472
+
473
+ /** Stable SVG-action names → renderer-neutral hand controls. */
474
+ export const HAND_ACTION_TO_FRAME_GESTURE = Object.freeze({
475
+ ...GESTURE_TO_FRAME,
476
+ });
477
+
485
478
  // GO_ON is deliberately absent, and this is the reasoning rather than an
486
479
  // oversight. It was drawn as a low splayed open palm rocking at the wrist; the
487
480
  // stakeholder's verdict was "go on doesn't work for me" and the review was
@@ -520,7 +513,7 @@ function sample(keys, t) {
520
513
 
521
514
  /**
522
515
  * Mount the hand over a face and return the player. Built by `createAvatar`;
523
- * a host drives it through `avatar.gesture(id)`, never directly.
516
+ * the public avatar composes it through a semantic `GESTURE_*` action.
524
517
  *
525
518
  * @param {SVGElement} svg the mounted face's own root — same coordinate space
526
519
  * @param {{ink:string, paper:string}} theme the mounted face's palette
@@ -575,6 +568,7 @@ export function createHand(svg, theme, meta, opts = {}) {
575
568
  for (const [k, s] of Object.entries(SHAPES)) shapes[k] = build(s);
576
569
 
577
570
  let current = null; // { def, start }
571
+ const queue = [];
578
572
  let lastT = 0;
579
573
 
580
574
  function place(x, y, rot, sc) {
@@ -588,19 +582,50 @@ export function createHand(svg, theme, meta, opts = {}) {
588
582
  for (const s of Object.values(shapes)) s.style.display = 'none';
589
583
  place(fr.cx, fr.bottom + HIDE, 0, 1);
590
584
  }
585
+ function show(def) {
586
+ for (const [k, s] of Object.entries(shapes)) s.style.display = k === def.shape ? '' : 'none';
587
+ }
588
+ function applyFrame(frame) {
589
+ if (!frame) { park(); return; }
590
+ const id = FRAME_GESTURES[frame.gesture];
591
+ const def = HAND_GESTURES[id];
592
+ if (!def) { park(); return; }
593
+ dir = frame.side === 'left' ? -1 : 1;
594
+ show(def);
595
+ const local = Math.max(0, Math.min(1, frame.progress)) * def.dur;
596
+ place(
597
+ fr.cx + dir * sample(def.out, local),
598
+ fr.bottom + sample(def.dy, local),
599
+ sample(def.rot, local),
600
+ def.sc || 1,
601
+ );
602
+ }
591
603
  park();
592
604
 
593
605
  return {
594
606
  get playing() { return !!current; },
595
607
  get id() { return current ? current.def.id : null; },
608
+ /** The semantic hand control for the current animation frame, or null. */
609
+ get frame() {
610
+ if (!current) return null;
611
+ return {
612
+ gesture: GESTURE_TO_FRAME[current.def.id],
613
+ progress: Math.max(0, Math.min(1, (lastT - current.start) / current.def.dur)),
614
+ side: dir === -1 ? 'left' : 'right',
615
+ };
616
+ },
617
+ /** Render a first-class AvatarFrame hand control. */
618
+ applyFrame,
596
619
  setDir(d) { dir = d === -1 ? -1 : 1; if (!current) park(); },
597
620
  /** @param {string} id @param {number} [atMs] start time on the layer's clock */
598
- play(id, atMs) {
621
+ play(id, atMs, { queue: shouldQueue = false } = {}) {
599
622
  const def = HAND_GESTURES[id];
600
623
  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';
624
+ if (current && shouldQueue) {
625
+ if (current.def.id !== id && !queue.some((item) => item.id === id)) queue.push({ id, def });
626
+ return def;
603
627
  }
628
+ show(def);
604
629
  current = { def, start: atMs !== undefined ? atMs : lastT };
605
630
  return def;
606
631
  },
@@ -610,15 +635,19 @@ export function createHand(svg, theme, meta, opts = {}) {
610
635
  const local = tMs - current.start;
611
636
  if (local < 0) return;
612
637
  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
- );
638
+ if (local >= def.dur) {
639
+ const done = def;
640
+ const next = queue.shift();
641
+ if (next) {
642
+ show(next.def);
643
+ current = { def: next.def, start: tMs };
644
+ } else {
645
+ current = null;
646
+ }
647
+ return done;
648
+ }
620
649
  },
621
- stop() { current = null; park(); },
650
+ stop() { current = null; queue.length = 0; park(); },
622
651
  destroy() { if (g.parentNode) g.parentNode.removeChild(g); },
623
652
  };
624
653
  }
@@ -626,12 +655,12 @@ export function createHand(svg, theme, meta, opts = {}) {
626
655
  /**
627
656
  * Rules 1 and 4, asserted rather than eyeballed — both were violated by an
628
657
  * 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
658
+ * `pnpm test` against every registered avatar, because the frame numbers
630
659
  * are derived per avatar and a rig with a different window could break the
631
660
  * budget without anything else noticing.
632
661
  *
633
662
  * rule 1 the wrist never rises into the frame;
634
- * rule 4 no gesture pushes ink through the portrait window's side edge, at
663
+ * rule 4 no gesture pushes ink through the camera window's side edge, at
635
664
  * any point in its rotation. A hand 440 units tall throws its outer
636
665
  * corner ~110 units sideways at 14 degrees, so the budget has to be
637
666
  * spent against `out` AND `rot` together.
package/src/head.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Hold-and-move: how a head spends a conversation.
3
+ *
4
+ * Watch a person on a video call with the sound off and the head is almost
5
+ * never *moving*. It is held, and every second or two it goes somewhere else
6
+ * — a quick, decided move of a few degrees, a short settle, and then still
7
+ * again. Speech adds strokes on top: a nod on a stressed word, down and back,
8
+ * over in a third of a second. What it never does is glide, and gliding is
9
+ * the one thing a stack of smoothed envelopes can do. That stack is what this
10
+ * replaced: beats, a phrase drift and a lead-in, each a linear keyframe curve,
11
+ * summed and then low-passed at the head's 160 ms, drew a head in continuous
12
+ * motion from the first word to the last. A video reviewer called it a
13
+ * screensaver (a recorded call, reviewed 2026-09-12), and that is
14
+ * the right word: every part was a movement the literature measured, and the
15
+ * sum was a movement nobody makes.
16
+ *
17
+ * So this keeps the *events* the research found — a head that moves before the
18
+ * voice, beats on stressed vowels, the settle as a phrase ends — and changes
19
+ * what an event is:
20
+ *
21
+ * - A **move** takes the held pose somewhere new along a minimum-jerk path,
22
+ * the profile a practised human reach follows (Flash & Hogan 1985): it
23
+ * starts and stops at zero velocity and acceleration, and nearly all of its
24
+ * travel is in the middle. It arrives, and then the pose is exactly still.
25
+ * - A **stroke** is a pulse on top of the held pose: out fast, back a little
26
+ * slower, and nothing left over when it is done.
27
+ *
28
+ * Between those the output does not change at all, and that stillness is the
29
+ * point: it is what makes the moves read as decisions.
30
+ *
31
+ * Times are ms on whatever clock the owner keeps; this only needs it to be
32
+ * monotonic.
33
+ */
34
+
35
+ /** Minimum-jerk position for a normalised time in [0, 1]. */
36
+ export const minJerk = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * x * (10 + x * (-15 + 6 * x)));
37
+
38
+ /**
39
+ * Fold `x` into ±`limit` without a corner: identity up to `KNEE` of the way
40
+ * there, then an exponential approach that reaches the limit only in the
41
+ * limit. It is memoryless — the same input gives the same output whenever it
42
+ * arrives — so nothing it touches is re-timed, which is what lets it sit in a
43
+ * chain whose curves are all authored against a known smoothing.
44
+ *
45
+ * A hard clamp was the obvious thing and is wrong here: what wants limiting is
46
+ * a sum of layers that each stay small and occasionally point the same way, so
47
+ * a clamp would be invisible for a minute and then hold the head perfectly
48
+ * still at the stop for half a second — the one thing a held pose must not do,
49
+ * because stillness is how this file says "decided". The knee sits at 0.7
50
+ * because below it the pose passes through untouched, and a recorded minute of
51
+ * either conversational state spends its median there.
52
+ */
53
+ const KNEE = 0.7;
54
+ export const soften = (x, limit) => {
55
+ const a = KNEE * limit;
56
+ const m = Math.abs(x);
57
+ if (!(limit > 0) || m <= a) return x;
58
+ return Math.sign(x) * (a + (limit - a) * (1 - Math.exp(-(m - a) / (limit - a))));
59
+ };
60
+
61
+ export const HEAD_AXES = ['headYaw', 'headPitch', 'headRoll'];
62
+ const ZERO = Object.freeze({ headYaw: 0, headPitch: 0, headRoll: 0 });
63
+
64
+ export class HeadPose {
65
+ constructor() {
66
+ // The held pose as a list of moves, each starting where the one before it
67
+ // had got to. A move may be booked ahead of time (a head moves before the
68
+ // voice it anticipates), so the next one can be waiting while this one
69
+ // plays.
70
+ this._moves = [{ at: -Infinity, dur: 0, from: ZERO, to: ZERO }];
71
+ this._strokes = [];
72
+ }
73
+
74
+ /** The pose the head will hold once every booked move has landed. */
75
+ get aim() {
76
+ return this._moves[this._moves.length - 1].to;
77
+ }
78
+
79
+ /**
80
+ * Go to `pose` (missing axes keep where they are), starting at `at` and
81
+ * arriving `dur` ms later. A move booked to start before one already
82
+ * booked replaces it: the later plan wins, from wherever the head will be.
83
+ */
84
+ moveTo(pose, at, dur) {
85
+ while (this._moves.length > 1 && this._moves[this._moves.length - 1].at > at) this._moves.pop();
86
+ const from = this._held(at);
87
+ this._moves.push({ at, dur: Math.max(1, dur), from, to: { ...from, ...pose } });
88
+ }
89
+
90
+ /** A pulse of `add` (per axis) that peaks `attack` ms after `at` and is gone `release` ms later. */
91
+ stroke(add, at, attack, release) {
92
+ this._strokes.push({ add, at, attack, release });
93
+ }
94
+
95
+ /** Drop every stroke that has not begun by `now`; the ones under way finish. */
96
+ cancelStrokes(now) {
97
+ this._strokes = this._strokes.filter((s) => s.at <= now);
98
+ }
99
+
100
+ /** True when nothing is booked or playing after `now`: the head is holding. */
101
+ still(now) {
102
+ const m = this._moves[this._moves.length - 1];
103
+ return now >= m.at + m.dur && this._strokes.every((s) => now >= s.at + s.attack + s.release);
104
+ }
105
+
106
+ /** The head's offset at `now`, per axis. */
107
+ sample(now) {
108
+ // A move whose successor has started can no longer be seen.
109
+ while (this._moves.length > 1 && this._moves[1].at <= now) this._moves.shift();
110
+ this._strokes = this._strokes.filter((s) => now < s.at + s.attack + s.release);
111
+ const out = this._held(now);
112
+ for (const s of this._strokes) {
113
+ const t = now - s.at;
114
+ if (t <= 0) continue;
115
+ const e = t < s.attack ? minJerk(t / s.attack) : 1 - minJerk((t - s.attack) / s.release);
116
+ for (const k in s.add) out[k] += e * s.add[k];
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /** The held pose at `t`, strokes left out: where the head *is*, not what it is doing. */
122
+ held(t) {
123
+ return this._held(t);
124
+ }
125
+
126
+ _held(t) {
127
+ let m = this._moves[0];
128
+ for (const n of this._moves) if (n.at <= t) m = n;
129
+ const k = minJerk((t - m.at) / m.dur);
130
+ const out = {};
131
+ for (const c of HEAD_AXES) out[c] = m.from[c] + (m.to[c] - m.from[c]) * k;
132
+ return out;
133
+ }
134
+ }