@voqalize/avatar 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
package/src/face-wren.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Avatar: "wren".
3
3
  *
4
4
  * The second line-art character, and the proof run for the recipe in
5
- * docs/contract-avatar.md: static art + a POSE spec + feature blocks + META,
5
+ * docs/authoring-a-face.md: static art + a POSE spec + feature blocks + META,
6
6
  * with the stroke engine imported from line-art.js rather than carried along.
7
7
  * Character: female, thirties, a big natural-curl mass, round glasses, a
8
8
  * crew-neck tee. Same idiom as peep — no strokes anywhere, ink and paper and
@@ -30,10 +30,9 @@ import {
30
30
  f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
31
31
  } from './face-core.js';
32
32
  import { taper, taperRing, region } from './line-art.js';
33
+ import { viewBoxForHead } from './camera.js';
33
34
 
34
35
  export const THEME = {
35
- bg0: '#f6f3ee',
36
- bg1: '#e8e2d8',
37
36
  ink: '#1b1b1b',
38
37
  paper: '#ffffff',
39
38
  accent: '#f97415',
@@ -42,10 +41,9 @@ export const THEME = {
42
41
  tongue: '#8d7f79',
43
42
  };
44
43
 
45
- // Frame: same native 760x950 art space as peep — not a requirement, just no
46
- // reason to differ — with the window shifted up because the hair cloud crowns
47
- // ~40 units higher than peep's fade.
48
- const VB = { x: 92, y: 50, w: 576, h: 800 };
44
+ // Wren's hair cloud is her visible crown; the skull begins well below it.
45
+ const FRAME = { centerX: 380, crownY: 102, chinY: 575 };
46
+ const VB = viewBoxForHead(FRAME);
49
47
 
50
48
  export const META = {
51
49
  viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
@@ -53,7 +51,7 @@ export const META = {
53
51
  };
54
52
 
55
53
  // --- landmarks --------------------------------------------------------------
56
- const CX = 380;
54
+ const CX = FRAME.centerX;
57
55
  const HEAD_TOP = 148;
58
56
  const CHIN_Y = 572;
59
57
 
@@ -224,7 +222,7 @@ const POSE = {
224
222
  // peep's breath and trunk numbers; same construction, same art units, and
225
223
  // the two characters should breathe alike. See face-peep.js for the
226
224
  // derivation.
227
- breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
225
+ breathSwell: 0.008, swellPivot: { x: CX, y: 950 },
228
226
  turnPx: 16,
229
227
  layers: LAYERS, parallax: PARALLAX,
230
228
  torsoLayers: ['body'],
@@ -254,8 +252,8 @@ function mouthGeometry(p) {
254
252
  const h = open * MOUTH_APERTURE;
255
253
  const k = clamp(open / 0.18);
256
254
 
257
- const yL = cy - 6 - p.mouthCornerL * 22;
258
- const yR = cy - 6 - p.mouthCornerR * 22;
255
+ const yL = cy - 1.5 - p.mouthCornerL * 22;
256
+ const yR = cy - 1.5 - p.mouthCornerR * 22;
259
257
 
260
258
  const apTop = cy - h * 0.25;
261
259
  let apBot = cy + h * 0.75;
@@ -348,14 +346,7 @@ function markup(id, t) {
348
346
  return `
349
347
  <svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
350
348
  preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
351
- <defs>
352
- <radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
353
- <stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
354
- </radialGradient>
355
- <clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
356
- </defs>
357
-
358
- <rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
349
+ <defs><clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath></defs>
359
350
 
360
351
  <!-- head and neck. No ears: the hair band covers them (see the hair note).
361
352
  Hair underlay at head parallax, same insurance as peep's — the cloud
@@ -468,3 +459,10 @@ export function createFace(mount, theme = {}) {
468
459
 
469
460
  return faceApi(mount, svg, apply, t);
470
461
  }
462
+
463
+ /**
464
+ * This face as a **Face** record — `{ create, meta }`, the shape
465
+ * `createAvatar({ face })` takes. Importing it costs this drawing and nothing
466
+ * else; `src/faces.js` is the all-three table, for tooling.
467
+ */
468
+ export const wren = { create: createFace, meta: META };
package/src/faces.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Face } from "./avatar.js";
2
+
3
+ export const peep: Face;
4
+ export const wren: Face;
5
+ export const myna: Face;
6
+
7
+ /** The faces we ship. Not an open vocabulary — a third face is a code change. */
8
+ export type FaceName = "peep" | "wren" | "myna";
9
+
10
+ export const FACES: Readonly<Record<FaceName, Face>>;
11
+ export const FACE_NAMES: readonly FaceName[];
12
+ export const DEFAULT_FACE: FaceName;
package/src/faces.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The faces we ship, as a table — for tooling that wants every one of them.
3
+ *
4
+ * **Importing this costs all three drawings** (~2k lines of path data). That is
5
+ * the right trade for `rig-check`, the contact sheets, the sweep and Studio,
6
+ * which exist to compare faces against each other. It is the wrong trade for an
7
+ * application, which renders one: those import a single face module and hand
8
+ * the record to `createAvatar` directly.
9
+ *
10
+ * import { peep } from '@voqalize/avatar/faces/peep';
11
+ * createAvatar({ mount, client, face: peep });
12
+ *
13
+ * A **Face** is `{ create, meta }` and nothing else.
14
+ *
15
+ * `create` is `createFace(mount, theme) -> { svg, apply, theme, destroy }`,
16
+ * callable standalone — the rig tooling drives faces with no mixer attached.
17
+ * That behavioural contract is the whole of what the *rig* needs: everything
18
+ * else — visemes, emotions, gaze, idle, clips, the mixer — works in parameter
19
+ * space and never learns which face it is driving.
20
+ *
21
+ * `meta` is the descriptor (viewBox, mouthCrop — see META in any face module):
22
+ * the things a HOST or a TOOL needs to frame a face without opening it. A face
23
+ * was once a bare factory, on the argument that a schema guessed from two of
24
+ * them would be wrong; the third settled it. Every rig needed exactly a framing
25
+ * rect and a mouth rect to stop the tooling hard-coding per-face tables, and
26
+ * nothing else — so that is all meta carries.
27
+ *
28
+ * The key is the face's name, not its rank. It used to be possible to read rank
29
+ * into it — the original rig was keyed `default`, which became a lie the moment
30
+ * it stopped being the one we ship. `DEFAULT_FACE` is the only place the choice
31
+ * is made, and `packages/avatar/client/createAvatar.ts` is the only place it is consulted.
32
+ *
33
+ * Two earlier rigs, `classic` and `blue-shirt`, were removed on 2026-08-06:
34
+ * stakeholders accepted the line-art pair and rejected both of the others, so
35
+ * carrying them was maintenance against art nobody wanted. What they taught the
36
+ * abstraction survives them — `face-core.js` exists because all three of the
37
+ * first rigs wrote the same apply(), and META exists because all three needed
38
+ * the same two rects. Their code is in git history if a lesson ever needs
39
+ * re-reading.
40
+ */
41
+
42
+ import { peep } from './face-peep.js';
43
+ import { wren } from './face-wren.js';
44
+ import { myna } from './face-myna.js';
45
+
46
+ export { peep, wren, myna };
47
+
48
+ export const FACES = { peep, wren, myna };
49
+
50
+ export const FACE_NAMES = Object.keys(FACES);
51
+
52
+ /** The face a host gets when it does not name one. */
53
+ export const DEFAULT_FACE = 'peep';
package/src/gaze.js CHANGED
@@ -59,6 +59,52 @@ const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked bli
59
59
  const HEAD_ACCEL = 4.0; // units/s² — sets both launch and braking firmness
60
60
  const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach it
61
61
 
62
+ /**
63
+ * Aversion profiles: a brief, deliberate break of eye contact that returns to
64
+ * whatever the gaze target already was.
65
+ *
66
+ * This exists because *holding* the user's eyes is not the attentive pose it
67
+ * looks like. Wang & Gratch (CHI 2010, n=133) ran the condition directly: a
68
+ * virtual listener that simply stares rated no better than one that visibly
69
+ * ignored the user (rapport 3.49 vs 3.34, n.s.), was rated the most *tense* of
70
+ * the three conditions, and raised the speaker's own disfluency rate to
71
+ * 36.75/min against 22.44 for a responsive listener. All three were rated
72
+ * equally natural, so it is not an animation-quality artefact. Rossano supplies
73
+ * the mechanism — sustained mutual gaze is a *demand for more talk*, not a
74
+ * signal of attention (95% of sequences expanded when both parties kept looking;
75
+ * 84% closed when both withdrew) — and Binetti (N=498) puts preferred mutual
76
+ * gaze at 3295 ± 706 ms, which is well short of forever.
77
+ *
78
+ * The other wall is just as hard: sustained *aversion* is an ostracism cue, and
79
+ * measured on an animated character (Chotpitayasunondh & Douglas, N=128) it
80
+ * costs η²ₚ = .52–.56 with post-hoc d of 1.09–2.69, with partial inattention
81
+ * costing most of what total inattention costs. So this is not "look away
82
+ * sometimes" — it is a narrow band, and the numbers below are Andrist's measured
83
+ * ones rather than a taste call (docs/research-biomechanics.md §4.2). The spike
84
+ * that arrived at this band, and what it measured on peep, is
85
+ * docs/research-active-listening.md.
86
+ *
87
+ * `every`/`dur` are seconds. `dirs` are unit-ish directions weighted by how
88
+ * often each is taken; magnitude is scaled per-fire.
89
+ */
90
+ export const AVERSION = {
91
+ // While listening: 1.14 s (SD 0.27) every 7.21 s (SD 1.88), 57.5% sideways.
92
+ // Sideways dominates because down reads as submission and up reads as
93
+ // exasperation on a face this schematic.
94
+ LISTEN: {
95
+ every: [5.3, 9.1],
96
+ dur: [0.85, 1.45],
97
+ mag: [0.30, 0.44],
98
+ dirs: [[-1, 0.06], [-1, 0.06], [1, 0.02], [1, 0.02], [-0.7, -0.5], [0.5, 0.35]],
99
+ },
100
+ };
101
+ // There is deliberately no THINK profile. The *cognitive* aversion — 3.54 s
102
+ // (SD 1.26), splitting 39.3% down / 29.4% up / 31.3% side (§4.2) — is longer
103
+ // and deeper than the listening kind, and THINKING already renders it through
104
+ // `wander`, which moves the whole gaze target rather than nudging off it. Two
105
+ // mechanisms producing the same look would fight; the state that thinks looks
106
+ // away properly, and this profile is for the state that must not.
107
+
62
108
  export class GazeLayer {
63
109
  constructor() {
64
110
  this.target = GAZE_TARGETS.USER;
@@ -68,8 +114,67 @@ export class GazeLayer {
68
114
  this.onLargeShift = null;
69
115
  this.jitter = { x: 0, y: 0 };
70
116
  this._nextMicro = 0;
117
+ this._t = 0;
118
+ // --- aversion scheduler
119
+ this.aversion = null; // one of AVERSION, or null for none
120
+ /** Set true when eye contact must be held: the floor is about to change
121
+ * hands. Andrist prohibits intimacy-regulating aversions near utterance
122
+ * end for exactly this reason — the floor is passed with mutual gaze, and
123
+ * an avatar that looks away as the user finishes has just declined it. */
124
+ this.hold = false;
125
+ this._avNext = 0;
126
+ this._avUntil = 0;
127
+ this._avVec = { x: 0, y: 0 };
128
+ this._avAmt = 0; // glided 0..1 so the return is a movement, not a cut
129
+ this._avProfileRef = undefined;
71
130
  }
72
131
 
132
+ /** Adopt an aversion profile (or null). Cheap to call every frame. */
133
+ setAversion(p) {
134
+ if (p === this._avProfileRef) return;
135
+ this._avProfileRef = p;
136
+ this.aversion = p || null;
137
+ // Re-arm rather than inherit: a state that averts must not fire the instant
138
+ // it is entered off a stale timestamp from one that didn't.
139
+ this._avNext = this._t + (p ? p.every[0] + Math.random() * (p.every[1] - p.every[0]) : 0);
140
+ this._avUntil = 0;
141
+ }
142
+
143
+ _avert(t, dt) {
144
+ const p = this.aversion;
145
+ if (!p) {
146
+ // Glide home even after the profile is gone, so a state change mid-look
147
+ // returns the eyes instead of snapping them.
148
+ this._avAmt = Math.max(0, this._avAmt - dt / 0.18);
149
+ return;
150
+ }
151
+ if (this._avUntil && t >= this._avUntil) {
152
+ this._avUntil = 0;
153
+ this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
154
+ } else if (!this._avUntil && t >= this._avNext && !this.hold) {
155
+ this._avUntil = t + p.dur[0] + Math.random() * (p.dur[1] - p.dur[0]);
156
+ const d = p.dirs[(Math.random() * p.dirs.length) | 0];
157
+ const m = p.mag[0] + Math.random() * (p.mag[1] - p.mag[0]);
158
+ this._avVec.x = d[0] * m;
159
+ this._avVec.y = d[1] * m;
160
+ }
161
+ // `hold` cancels an aversion already running, it does not merely postpone
162
+ // the next: the turn can end mid-look, and the eyes have to be back.
163
+ const want = this._avUntil && !this.hold ? 1 : 0;
164
+ // Out fast, back slightly slower. A saccade away is ballistic; the return
165
+ // to a face is a fraction more deliberate, and symmetric timing here is one
166
+ // of the things that makes a rig read as a metronome.
167
+ const rate = want ? dt / 0.055 : dt / 0.11;
168
+ this._avAmt = want
169
+ ? Math.min(1, this._avAmt + rate)
170
+ : Math.max(0, this._avAmt - rate);
171
+ }
172
+
173
+ /** How far off-target the eyes currently are, 0..1. The mixer reads this to
174
+ * keep the trunk out of it — an aversion is eyes and a little head, never a
175
+ * body turn. */
176
+ get averted() { return this._avAmt; }
177
+
73
178
  /**
74
179
  * @param {string} name one of GAZE_NAMES
75
180
  * @param {{x:number,y:number}} [override] normalized -1..1 escape hatch for
@@ -101,7 +206,11 @@ export class GazeLayer {
101
206
  }
102
207
 
103
208
  update(t, dt) {
209
+ this._t = t;
104
210
  this._micro(t, dt);
211
+ this._avert(t, dt);
212
+ const avx = this._avVec.x * this._avAmt;
213
+ const avy = this._avVec.y * this._avAmt;
105
214
 
106
215
  // Ballistic head follow: steer velocity toward "full speed at the target,
107
216
  // but never faster than can still brake to a stop within the distance
@@ -140,12 +249,18 @@ export class GazeLayer {
140
249
  const k = 1 - Math.exp(-dt / HEAD_FOLLOW_TAU);
141
250
  this.head.roll += ((this.target.roll || 0) - this.head.roll) * k;
142
251
 
143
- const pupilY = this.target.py + this.jitter.y;
252
+ const pupilY = this.target.py + this.jitter.y + avy;
253
+ // The aversion rides *on top of* the ballistic follow rather than through
254
+ // it: it never touches `this.head`, so it cannot disturb the braking model
255
+ // and cannot trip the large-shift blink. The head takes only a fraction of
256
+ // what the eyes take — a brief look-away is an eye movement that the head
257
+ // barely joins, and a head that follows it fully reads as turning away.
258
+ const HEAD_SHARE = 0.22;
144
259
  return {
145
- pupilX: this.target.px + this.jitter.x,
260
+ pupilX: this.target.px + this.jitter.x + avx,
146
261
  pupilY,
147
- headYaw: this.head.x,
148
- headPitch: this.head.y,
262
+ headYaw: this.head.x + avx * HEAD_SHARE,
263
+ headPitch: this.head.y + avy * HEAD_SHARE,
149
264
  headRoll: this.head.roll,
150
265
  // The upper lid tracks the eye vertically. Without this, looking down
151
266
  // exposes a band of sclera above the iris and the avatar looks startled.