@convai/embody 0.1.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 (44) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +191 -0
  3. package/README.md +165 -0
  4. package/dist/character/createCharacter.d.ts +101 -0
  5. package/dist/character/createCharacter.js +163 -0
  6. package/dist/character/lifecycle.d.ts +55 -0
  7. package/dist/character/lifecycle.js +28 -0
  8. package/dist/character/pipeline.d.ts +46 -0
  9. package/dist/character/pipeline.js +29 -0
  10. package/dist/clips/animations.d.ts +39 -0
  11. package/dist/clips/animations.js +77 -0
  12. package/dist/decorate/types.d.ts +29 -0
  13. package/dist/decorate/types.js +13 -0
  14. package/dist/index.d.ts +30 -0
  15. package/dist/index.js +14 -0
  16. package/dist/motion/blink.d.ts +56 -0
  17. package/dist/motion/blink.js +127 -0
  18. package/dist/motion/breathe.d.ts +72 -0
  19. package/dist/motion/breathe.js +93 -0
  20. package/dist/motion/eyeGaze.d.ts +65 -0
  21. package/dist/motion/eyeGaze.js +66 -0
  22. package/dist/motion/gaze.d.ts +212 -0
  23. package/dist/motion/gaze.js +467 -0
  24. package/dist/motion/reducedMotion.d.ts +13 -0
  25. package/dist/motion/reducedMotion.js +13 -0
  26. package/dist/motion/types.d.ts +16 -0
  27. package/dist/motion/types.js +1 -0
  28. package/dist/react/ConvaiCharacter.d.ts +7 -0
  29. package/dist/react/ConvaiCharacter.js +25 -0
  30. package/dist/react/index.d.ts +4 -0
  31. package/dist/react/index.js +2 -0
  32. package/dist/react/useCharacter.d.ts +11 -0
  33. package/dist/react/useCharacter.js +45 -0
  34. package/dist/resolve/resolver.d.ts +56 -0
  35. package/dist/resolve/resolver.js +45 -0
  36. package/dist/rig/profile.d.ts +64 -0
  37. package/dist/rig/profile.js +110 -0
  38. package/dist/rig/resolve.d.ts +33 -0
  39. package/dist/rig/resolve.js +76 -0
  40. package/dist/rig/units.d.ts +27 -0
  41. package/dist/rig/units.js +61 -0
  42. package/dist/speech/lipsync.d.ts +56 -0
  43. package/dist/speech/lipsync.js +139 -0
  44. package/package.json +109 -0
@@ -0,0 +1,467 @@
1
+ import * as THREE from 'three';
2
+ import { setLookQuat } from '../rig/profile.js';
3
+ /**
4
+ * Head/neck gaze pose solve, ported from `useHeadTracking`
5
+ * (convai-web-sdk-internal `examples/react-three-fiber/src/hooks/useHeadTracking.ts`).
6
+ *
7
+ * Stage 2 — the ONLY system permitted to OVERRIDE the mixer's head/neck
8
+ * rotation outright (`.copy(...)`, never `.multiply(...)`): a head that
9
+ * blends additively with its own animation clip fights itself every frame.
10
+ *
11
+ * Task 6 ported the geometric core: world target -> yaw/pitch about the
12
+ * rig's own basis -> head/neck split -> time-based, velocity-capped ease ->
13
+ * `setLookQuat`. Task 7 layered the aliveness on top of that same solve:
14
+ * always-on idle micro-drift, occasional look-away gaze shifts with dwell
15
+ * timing, and turn-bank roll — all suppressed under `ctx.reducedMotion`.
16
+ * Task 8 (this task) adds the clip-blending layer: a smoothed camera-lock
17
+ * strength (`idleLock`/`talkLock`, eased rather than switched — see
18
+ * `update()`) blended against the mixer's own clip pose, with a deviation
19
+ * pull-back (`deviationLo`/`deviationHi`) that guards large clip excursions
20
+ * regardless of lock strength; and `calm`, which de-emphasises the idle
21
+ * drift/gaze-shift amplitudes while the (smoothed) lock is high, i.e. while
22
+ * speaking — a focused speaker stops glancing around.
23
+ *
24
+ * The HEAD is the only bone that blends with the clip; the neck bones are
25
+ * still overridden outright (Task 6/7 behaviour, unchanged) — see
26
+ * `update()`.
27
+ *
28
+ * See `.superpowers/sdd/2026-09-07-convai-embody-motion-and-staging/task-6-report.md`,
29
+ * `task-7-report.md` and `task-8-report.md` for the line-by-line comparison
30
+ * against the prior art and the places this deliberately differs from it.
31
+ */
32
+ export class GazeSystem {
33
+ constructor(cfg = {}) {
34
+ this.cfg = cfg;
35
+ this.stage = 2;
36
+ this.direction = {
37
+ left: { horizontal: 0, vertical: 0 },
38
+ right: { horizontal: 0, vertical: 0 },
39
+ horizontal: 0, vertical: 0,
40
+ };
41
+ this.yaw = 0;
42
+ this.pitch = 0;
43
+ /** Idle-roll + turn-bank, written by the aliveness layer below. */
44
+ this.roll = 0;
45
+ // --- Aliveness layer (Task 7) state -------------------------------
46
+ /** Idle-drift clock, advanced by `dt * idleSpeed`. Randomly seeded so
47
+ * multiple GazeSystem instances on stage together don't drift in
48
+ * lockstep. */
49
+ this.driftClock = Math.random() * 100;
50
+ /** Current eased gaze-shift offsets (the "look-away" the head is
51
+ * easing toward / holding). */
52
+ this.shiftYaw = 0;
53
+ this.shiftPitch = 0;
54
+ this.shiftRoll = 0;
55
+ /** The look-away target `shift*` is easing toward, re-picked every
56
+ * dwell period. */
57
+ this.shiftTargetYaw = 0;
58
+ this.shiftTargetPitch = 0;
59
+ this.shiftTargetRoll = 0;
60
+ /** Seconds remaining before the next gaze-shift target is re-picked. */
61
+ this.dwell = 0;
62
+ // Pre-allocated scratch — zero allocations on the steady-state path.
63
+ this.vTarget = new THREE.Vector3();
64
+ this.vHead = new THREE.Vector3();
65
+ this.vLocal = new THREE.Vector3();
66
+ this.mInv = new THREE.Matrix4();
67
+ /** What the mixer wrote to the head this frame, before we touch it. */
68
+ this.qClip = new THREE.Quaternion();
69
+ /** The camera-locked look pose computed this frame. */
70
+ this.qLook = new THREE.Quaternion();
71
+ /** Low-passed (tau ~0.2s) copy of `qClip`. RULING R43 part 2: the
72
+ * deviation boost is measured against THIS, not the raw clip pose, so
73
+ * one noisy/fast clip frame can't make the boost — and therefore the
74
+ * blend — jump within a frame. Ported from useHeadTracking.ts:594-606. */
75
+ this.animHead = new THREE.Quaternion();
76
+ this.animHeadHas = false;
77
+ /** The blended pose, before the output velocity cap below is applied. */
78
+ this.qOut = new THREE.Quaternion();
79
+ /** The actual pose WRITTEN to the head last frame — the anchor the
80
+ * output velocity cap (RULING R43 part 3) rotates from. Ported from
81
+ * useHeadTracking.ts:621-633. */
82
+ this.lastOutHead = new THREE.Quaternion();
83
+ this.lastOutHas = false;
84
+ /** RULING R61: the neck bones blend with their OWN clip pose too — same
85
+ * low-pass, same shared head-derived `blend`, same output cap — ported
86
+ * from useHeadTracking.ts:598-606 (low-pass) and :635-671 (blend +
87
+ * cap). One low-pass/cap pair per neck bone, each independent (the
88
+ * clip poses and, after the cap, the written poses differ per bone). */
89
+ this.animNeck1 = new THREE.Quaternion();
90
+ this.animNeck1Has = false;
91
+ this.animNeck2 = new THREE.Quaternion();
92
+ this.animNeck2Has = false;
93
+ this.qOutNeck = new THREE.Quaternion();
94
+ this.lastOutNeck1 = new THREE.Quaternion();
95
+ this.lastOutNeck1Has = false;
96
+ this.lastOutNeck2 = new THREE.Quaternion();
97
+ this.lastOutNeck2Has = false;
98
+ this.gate = cfg.enabled ?? true;
99
+ this.lock = cfg.idleLock ?? 0.9;
100
+ }
101
+ /**
102
+ * Live on/off gate, read every frame. While false the system writes
103
+ * NOTHING to the rig — the mixer's clips own the head/neck — and eases
104
+ * the internal yaw/pitch/roll state toward neutral so re-enabling does
105
+ * not snap the head back onto the camera.
106
+ */
107
+ setEnabled(on) { this.gate = on; }
108
+ /**
109
+ * Time-based exponential ease toward `tgt`, velocity-capped so a frame
110
+ * hitch can't yank the head. Ported verbatim from
111
+ * useHeadTracking.ts:480-484 — NOT the bare `x += (target - x) * lerp`
112
+ * form, which is frame-rate dependent by construction (a 144Hz monitor
113
+ * would track visibly faster than a 60Hz one). Per RULING R22.
114
+ *
115
+ * Hoisted to a method (RULING R25) rather than a closure allocated inside
116
+ * `update()` — a per-frame closure is exactly the steady-state allocation
117
+ * this class's zero-allocation rule forbids. `useHeadTracking.ts` can
118
+ * afford the closure because it's a React hook body re-created rarely and
119
+ * garbage-collected on unmount; embody's `update()` runs every frame for
120
+ * the lifetime of the character.
121
+ */
122
+ approach(cur, tgt, k, maxStep) {
123
+ return cur + THREE.MathUtils.clamp((tgt - cur) * k, -maxStep, maxStep);
124
+ }
125
+ /**
126
+ * Smooth, non-repeating drift in ~[-1, 1] — three incommensurate sines
127
+ * (an fbm-lite). Ported VERBATIM from useHeadTracking.ts:144-150
128
+ * (function `drift`) — NOT the brief's single-sine placeholder
129
+ * (`Math.sin(t * 0.31)`), which is a different, un-sourced waveform.
130
+ * The three fixed frequencies/weights are shared by every axis; only
131
+ * the per-axis `seed` (and its own amplitude) differs, exactly as in
132
+ * the prior art's `drift(t, 1.0|7.3|13.1)` calls at lines 556-559.
133
+ *
134
+ * Hoisted to a method rather than a closure (RULING R25, same as
135
+ * `approach` above) — no steady-state allocation.
136
+ */
137
+ drift(t, seed) {
138
+ return (Math.sin(t * 0.47 + seed) * 0.55 +
139
+ Math.sin(t * 0.83 + seed * 2.13) * 0.3 +
140
+ Math.sin(t * 1.71 + seed * 4.7) * 0.15);
141
+ }
142
+ update(dt, ctx) {
143
+ const head = ctx.rig.bones.head;
144
+ const neck1 = ctx.rig.bones.neck1;
145
+ const neck2 = ctx.rig.bones.neck2;
146
+ if (!head || !ctx.camera)
147
+ return;
148
+ const lerpRate = this.cfg.lerpSpeed ?? 0.08;
149
+ const k = 1 - Math.exp(-lerpRate * 60 * dt);
150
+ const maxStep = 1.4 * dt; // rad — ~80 deg/s retarget speed limit
151
+ if (!this.gate) {
152
+ // Ease internal state toward neutral; write NOTHING to the rig.
153
+ this.yaw = this.approach(this.yaw, 0, k, maxStep);
154
+ this.pitch = this.approach(this.pitch, 0, k, maxStep);
155
+ this.roll = this.approach(this.roll, 0, k, maxStep);
156
+ // The clip owns the bone while gated — restart the clip low-pass and
157
+ // the output-cap anchor from the live pose on re-enable, rather than
158
+ // fighting a stale reference (same rationale as
159
+ // useHeadTracking.ts:322-324's gate<0.02 branch).
160
+ this.animHeadHas = false;
161
+ this.lastOutHas = false;
162
+ this.animNeck1Has = false;
163
+ this.animNeck2Has = false;
164
+ this.lastOutNeck1Has = false;
165
+ this.lastOutNeck2Has = false;
166
+ return;
167
+ }
168
+ // Target direction in the head's PARENT space, so the solve reads off
169
+ // the rig's own rest-pose axes and is independent of where the body
170
+ // faces in the world. Using the head's actual world position (not its
171
+ // parent's origin) matters on a real rig, where the head bone sits
172
+ // offset up from neck_02 — see report for why this differs from the
173
+ // brief's reference snippet.
174
+ ctx.camera.getWorldPosition(this.vTarget);
175
+ head.getWorldPosition(this.vHead);
176
+ const parent = head.parent ?? ctx.root;
177
+ this.mInv.copy(parent.matrixWorld).invert();
178
+ this.vLocal.subVectors(this.vTarget, this.vHead).transformDirection(this.mInv);
179
+ const basis = ctx.rig.profile.basis;
180
+ // Yaw/pitch of the target about the rig's own bone-local axes.
181
+ const right = this.vLocal.dot(basis.right);
182
+ const up = this.vLocal.dot(basis.up);
183
+ const fwd = this.vLocal.dot(basis.fwd);
184
+ let targetYaw = Math.atan2(right, fwd);
185
+ let targetPitch = -Math.atan2(up, Math.hypot(right, fwd));
186
+ const maxTrack = this.cfg.maxTrackAngle ?? Math.PI / 2;
187
+ const maxPitch = this.cfg.maxPitchAngle ?? Math.PI / 4;
188
+ if (Math.abs(targetYaw) > maxTrack) {
189
+ targetYaw = 0;
190
+ targetPitch = 0;
191
+ }
192
+ targetPitch = THREE.MathUtils.clamp(targetPitch, -maxPitch, maxPitch);
193
+ this.yaw = this.approach(this.yaw, targetYaw, k, maxStep);
194
+ this.pitch = this.approach(this.pitch, targetPitch, k, maxStep);
195
+ // --- Clip-blending layer (Task 8): smoothed camera lock ------------
196
+ // `ctx.speaking` selects the TARGET lock strength; the lock itself
197
+ // eases toward it over ~0.4s (RULING R39) rather than switching
198
+ // instantly, using the same time-based exponential form as `approach`
199
+ // above (RULING R40, same grounds as R22) — NOT the brief's bare
200
+ // `const lock = ctx.speaking ? talkLock : idleLock`, which would pop
201
+ // the lock (and therefore the head pose) in a single frame whenever
202
+ // speech starts or stops.
203
+ const idleLockCfg = this.cfg.idleLock ?? 0.9;
204
+ const talkLockCfg = this.cfg.talkLock ?? 0.95;
205
+ const lockTarget = ctx.speaking ? talkLockCfg : idleLockCfg;
206
+ const lockK = 1 - Math.exp(-0.04 * 60 * dt);
207
+ this.lock = this.approach(this.lock, lockTarget, lockK, Infinity);
208
+ // `calm`: a focused (highly locked) speaker stops glancing around —
209
+ // ported from useHeadTracking.ts:527-533. Derived from the SMOOTHED
210
+ // lock (not raw `ctx.speaking`) so it fades in/out with the lock
211
+ // transition above rather than popping with it. Applied below to the
212
+ // idle-drift and gaze-shift amplitudes (RULING R41) — Task 7 deferred
213
+ // this because it depends on this smoothed lock.
214
+ const speakBlend = THREE.MathUtils.clamp((this.lock - idleLockCfg) / Math.max(0.001, talkLockCfg - idleLockCfg), 0, 1);
215
+ const calm = 1 - 0.65 * speakBlend;
216
+ // --- Aliveness layer (Task 7): idle micro-drift + occasional
217
+ // look-away gaze shifts. Both are computed here, AFTER the tracking
218
+ // ease above and BEFORE the head/neck split below, and folded into
219
+ // local `yaw`/`pitch`/`this.roll` — `this.yaw`/`this.pitch` themselves
220
+ // stay pure-tracking (Task 6) values, because `direction` below is
221
+ // published from them and downstream consumers (blink, eye-gaze) want
222
+ // the deliberate look target, not idle jitter.
223
+ let driftYaw = 0, driftPitch = 0, driftRoll = 0;
224
+ if (!ctx.reducedMotion) {
225
+ this.driftClock += dt * (this.cfg.idleSpeed ?? 1.0);
226
+ const t = this.driftClock;
227
+ // `drift()` ported verbatim from useHeadTracking.ts:144-150; the
228
+ // per-axis seeds (1.0 / 7.3 / 13.1) match lines 556, 558 and 559.
229
+ driftYaw = this.drift(t, 1.0) * (this.cfg.idleYawAmp ?? 0.03);
230
+ driftPitch = this.drift(t, 7.3) * (this.cfg.idlePitchAmp ?? 0.02);
231
+ driftRoll = this.drift(t, 13.1) * (this.cfg.idleRollAmp ?? 0.035);
232
+ // Occasional look-away, held for a random dwell then re-picked —
233
+ // ported from useHeadTracking.ts:507-522 (the "gaze" ref). The
234
+ // countdown-timer bookkeeping here (`this.dwell -= dt`) is
235
+ // equivalent to the prior art's absolute `nextAt` comparison; the
236
+ // pick and dwell-duration formulas themselves are verbatim.
237
+ this.dwell -= dt;
238
+ if (this.dwell <= 0) {
239
+ const min = this.cfg.gazeDwellMin ?? 1.4;
240
+ const max = this.cfg.gazeDwellMax ?? 4.0;
241
+ this.dwell = min + Math.random() * (max - min);
242
+ this.shiftTargetYaw = (Math.random() * 2 - 1) * (this.cfg.gazeYawRange ?? 0.15);
243
+ this.shiftTargetPitch = (Math.random() * 2 - 1) * (this.cfg.gazePitchRange ?? 0.09);
244
+ this.shiftTargetRoll = (Math.random() * 2 - 1) * (this.cfg.gazeRollRange ?? 0.09);
245
+ }
246
+ // Time-based exponential ease toward the gaze-shift target — the
247
+ // SAME `1 - exp(-rate * 60 * dt)` form as `approach()`'s `k` above
248
+ // (RULING R22), ported verbatim from useHeadTracking.ts:519-522.
249
+ // NOT the brief's bare `+= (target - cur) * gazeEase`, which is
250
+ // frame-rate dependent by construction, same defect R22 already
251
+ // ruled out for the main tracking ease.
252
+ const gk = 1 - Math.exp(-(this.cfg.gazeEase ?? 0.035) * 60 * dt);
253
+ this.shiftYaw += (this.shiftTargetYaw - this.shiftYaw) * gk;
254
+ this.shiftPitch += (this.shiftTargetPitch - this.shiftPitch) * gk;
255
+ this.shiftRoll += (this.shiftTargetRoll - this.shiftRoll) * gk;
256
+ }
257
+ else {
258
+ // Reduced motion engaged mid-shift: ease any in-flight look-away back
259
+ // to centre rather than freezing it there (RULING R34). A hard reset
260
+ // would snap the offset to zero in a single frame — itself a motion
261
+ // artifact, and exactly what a reduced-motion user should not get.
262
+ // Velocity-capped via `approach()` (same k/maxStep as the tracking
263
+ // ease above) so the decay is smooth and frame-rate independent.
264
+ this.shiftYaw = this.approach(this.shiftYaw, 0, k, maxStep);
265
+ this.shiftPitch = this.approach(this.shiftPitch, 0, k, maxStep);
266
+ this.shiftRoll = this.approach(this.shiftRoll, 0, k, maxStep);
267
+ this.dwell = 0; // re-enabling picks a fresh target, not a stale one
268
+ }
269
+ // `calm` (Task 8) scales only the idle-drift and gaze-shift
270
+ // contributions, never the deliberate tracking (`this.yaw`/`this.pitch`
271
+ // themselves) — a focused speaker still looks at the viewer, she just
272
+ // stops the micro-life around it.
273
+ const yaw = this.yaw + (driftYaw + this.shiftYaw) * calm;
274
+ const pitch = this.pitch + (driftPitch + this.shiftPitch) * calm;
275
+ // Head CAPS at the head-only zone; the neck takes a share of the excess.
276
+ // Ported verbatim from useHeadTracking.ts:450-460 (per RULING R21) — NOT
277
+ // `headYaw = yaw - neckYaw`, which lets the head keep rotating past
278
+ // `headOnlyAngle` (scaled down by the neck's share) instead of capping
279
+ // there outright. That's the exact near-joint-limit pose the 0.44 retune
280
+ // (see `headOnlyAngle` doc above) was fixing.
281
+ const headOnly = this.cfg.headOnlyAngle ?? 0.44;
282
+ const absYaw = Math.abs(yaw);
283
+ const yawSign = Math.sign(yaw);
284
+ let headYaw;
285
+ let neckYaw;
286
+ if (absYaw <= headOnly) {
287
+ headYaw = yaw;
288
+ neckYaw = 0;
289
+ }
290
+ else {
291
+ const extraAngle = absYaw - headOnly;
292
+ headYaw = headOnly * yawSign;
293
+ neckYaw = extraAngle * yawSign * (this.cfg.neckYawFactor ?? 0.75);
294
+ }
295
+ const headPitch = pitch * (this.cfg.headPitchFactor ?? 0.7);
296
+ const neckPitch = pitch * (this.cfg.neckPitchFactor ?? 0.3);
297
+ // Bank the head into the turn: roll proportional to the (capped) head
298
+ // yaw. Ported from useHeadTracking.ts:560-562
299
+ // (`bankRoll = -(headYaw + gaze.curYaw * calm) * turnBank`) — NOT a
300
+ // positive-sign `yaw * turnBank` off the raw, uncapped yaw, which
301
+ // would keep banking further as the neck (not the head) absorbs
302
+ // excess yaw beyond `headOnlyAngle`. Per RULING R31, the negative
303
+ // sign / capped-`headYaw` base above is confirmed. `calm` (Task 8) is
304
+ // already folded into `headYaw` here (it scaled `driftYaw`/`shiftYaw`
305
+ // above, before the head/neck split), so `bank` inherits the calming
306
+ // for free — no separate `* calm` needed on this line.
307
+ //
308
+ // Per RULING R30, banking is suppressed under `ctx.reducedMotion`:
309
+ // it's decorative roll carrying no information, and head tilt is a
310
+ // recognised vestibular trigger — precisely what reduced-motion
311
+ // exists to avoid. Tracking yaw/pitch themselves stay (the character
312
+ // still looks at you; that's functional), only this stylistic roll
313
+ // is gated. The prior art has no reduced-motion concept, so there is
314
+ // no upstream formula to defer to here — this gate is judgement, not
315
+ // a port.
316
+ const bank = ctx.reducedMotion ? 0 : headYaw * (this.cfg.turnBank ?? 0.2);
317
+ this.roll = (driftRoll + this.shiftRoll) * calm - bank;
318
+ // --- Clip-blending layer (Task 8): camera lock + deviation pull-back
319
+ //
320
+ // RULING R43: this is a FOUR-part mechanism ported from
321
+ // useHeadTracking.ts:594-633, not the two-line `max(lock, linear-pull)`
322
+ // form. Each part exists to stabilise the one after it — the low-pass
323
+ // stops the boost jittering frame to frame, the boost+lock combination
324
+ // is what actually blends, and the output cap catches whatever burst
325
+ // still gets through (e.g. a hitch, or a large one-frame clip jump).
326
+ //
327
+ // What the mixer wrote to the head THIS frame, before we touch it —
328
+ // captured now, since nothing above has written to `head.quaternion`
329
+ // yet this frame.
330
+ this.qClip.copy(head.quaternion);
331
+ setLookQuat(this.qLook, basis, headYaw, headPitch, this.roll);
332
+ // RULING (2026-09-07 fix wave, C1): compose the look pose ONTO the
333
+ // bone's own bind-time local rotation, never treat it as absolute — a
334
+ // real rig's head/neck bones are not identity-oriented in their
335
+ // parent's frame. Ported from useHeadTracking.ts:590-592
336
+ // (`lockedHead = copy(restPose.head).multiply(headLook)`). `premultiply`
337
+ // computes exactly `rest * qLook` in place, no extra scratch quaternion.
338
+ // Absent rest (a rig this system never resolved) leaves qLook untouched
339
+ // — falls back to the pre-fix, identity-relative behaviour rather than
340
+ // throwing.
341
+ const restHead = ctx.rig.restPose.head;
342
+ if (restHead)
343
+ this.qLook.premultiply(restHead);
344
+ // Part 2 — low-pass the clip pose (tau ~0.2s) BEFORE measuring
345
+ // deviation from it. Ported from useHeadTracking.ts:594-606; the prior
346
+ // art's own comment gives the reason verbatim: "Deviation is computed
347
+ // against the SMOOTHED pose so the boost itself cannot jump within a
348
+ // frame." First frame: seed directly from the raw clip (no
349
+ // artificial 0.2s ramp-up from an arbitrary identity start).
350
+ const kAnim = 1 - Math.exp(-dt / 0.2);
351
+ if (!this.animHeadHas) {
352
+ this.animHead.copy(this.qClip);
353
+ this.animHeadHas = true;
354
+ }
355
+ else {
356
+ this.animHead.slerp(this.qClip, kAnim);
357
+ }
358
+ // Part 1 — deviation pull-back as a SMOOTHSTEP boost applied ON TOP OF
359
+ // the (already time-eased, RULING R39/R40) lock — `lock += (1 - lock)
360
+ // * boost` — NOT `max(lock, linear-pull)`. Ported verbatim from
361
+ // useHeadTracking.ts:611-618. `deviationHi` is 0.35 (RULING R38:
362
+ // verified against `HeadTrackingConfig.DEFAULT_CONFIG.deviationHi`,
363
+ // useHeadTracking.ts:135 — NOT the stale interface JSDoc's 0.5,
364
+ // useHeadTracking.ts:67, which the implementation itself does not use).
365
+ const deviation = this.animHead.angleTo(this.qLook);
366
+ const lo = this.cfg.deviationLo ?? 0.12;
367
+ const hi = this.cfg.deviationHi ?? 0.35;
368
+ const boost = THREE.MathUtils.smoothstep(deviation, lo, hi);
369
+ const blend = this.lock + (1 - this.lock) * boost;
370
+ // Blend the LOW-PASSED clip pose (never the raw one) toward the look
371
+ // pose. Still a `.copy()` onto the bone (never `.multiply()`), per
372
+ // this class's contract — `.slerp()` interpolates, it doesn't compose
373
+ // with the bone's existing rotation the way `.multiply()` would.
374
+ this.qOut.copy(this.animHead).slerp(this.qLook, blend);
375
+ // Part 3 — output velocity cap, applied AFTER the blend. Ported from
376
+ // useHeadTracking.ts:625-631. The `Math.min(dt, 0.02)` is load-bearing,
377
+ // not incidental: the prior art's own comment records the exact bug it
378
+ // fixes — "on a hitched frame the window would otherwise expand and
379
+ // admit the whole burst as one visible jump (measured 1.6°-in-one-frame
380
+ // spikes exactly on dt≈21ms frames)." 55°/s sits above this class's own
381
+ // normal motion but compresses a fast clip burst across several
382
+ // frames. Skipped on the very first written frame (no prior output to
383
+ // rotate from).
384
+ const outCap = (55 * Math.PI / 180) * Math.min(dt, 0.02);
385
+ if (this.lastOutHas) {
386
+ head.quaternion.copy(this.lastOutHead).rotateTowards(this.qOut, outCap);
387
+ }
388
+ else {
389
+ head.quaternion.copy(this.qOut);
390
+ }
391
+ this.lastOutHead.copy(head.quaternion);
392
+ this.lastOutHas = true;
393
+ // RULING R61: the neck bones blend with their own clip pose too — the
394
+ // brief (and the first cut of this class) applied the clip-blend
395
+ // mechanism to the HEAD only, which meant an authored clip's neck
396
+ // motion was discarded outright every frame (a hard `.copy()`
397
+ // override, never blended). The prior art low-passes `anim.neck1`/
398
+ // `anim.neck2` alongside `anim.head` for exactly this reason
399
+ // (useHeadTracking.ts:598-606) and blends both necks with the SAME
400
+ // head-derived blend fraction (`blend`, computed above from `lock` and
401
+ // the head's own deviation boost) — NOT a separately computed per-neck
402
+ // deviation (useHeadTracking.ts:644,
403
+ // 663). `this.qClip`/`this.qLook` are reused as scratch here (the
404
+ // head's use of them above is finished for this frame) — neck1 is
405
+ // fully written before neck2 starts, so this sequential reuse is
406
+ // safe and allocation-free.
407
+ if (neck1) {
408
+ this.qClip.copy(neck1.quaternion);
409
+ if (!this.animNeck1Has) {
410
+ this.animNeck1.copy(this.qClip);
411
+ this.animNeck1Has = true;
412
+ }
413
+ else {
414
+ this.animNeck1.slerp(this.qClip, kAnim);
415
+ }
416
+ setLookQuat(this.qLook, basis, neckYaw * 0.5, neckPitch * 0.5, 0);
417
+ // C1: compose onto neck_01's own rest pose — see the head's identical
418
+ // comment above.
419
+ const restNeck1 = ctx.rig.restPose.neck1;
420
+ if (restNeck1)
421
+ this.qLook.premultiply(restNeck1);
422
+ this.qOutNeck.copy(this.animNeck1).slerp(this.qLook, blend);
423
+ if (this.lastOutNeck1Has) {
424
+ neck1.quaternion.copy(this.lastOutNeck1).rotateTowards(this.qOutNeck, outCap);
425
+ }
426
+ else {
427
+ neck1.quaternion.copy(this.qOutNeck);
428
+ }
429
+ this.lastOutNeck1.copy(neck1.quaternion);
430
+ this.lastOutNeck1Has = true;
431
+ }
432
+ if (neck2) {
433
+ this.qClip.copy(neck2.quaternion);
434
+ if (!this.animNeck2Has) {
435
+ this.animNeck2.copy(this.qClip);
436
+ this.animNeck2Has = true;
437
+ }
438
+ else {
439
+ this.animNeck2.slerp(this.qClip, kAnim);
440
+ }
441
+ setLookQuat(this.qLook, basis, neckYaw * 0.5, neckPitch * 0.5, 0);
442
+ // C1: compose onto neck_02's own rest pose — see the head's identical
443
+ // comment above.
444
+ const restNeck2 = ctx.rig.restPose.neck2;
445
+ if (restNeck2)
446
+ this.qLook.premultiply(restNeck2);
447
+ this.qOutNeck.copy(this.animNeck2).slerp(this.qLook, blend);
448
+ if (this.lastOutNeck2Has) {
449
+ neck2.quaternion.copy(this.lastOutNeck2).rotateTowards(this.qOutNeck, outCap);
450
+ }
451
+ else {
452
+ neck2.quaternion.copy(this.qOutNeck);
453
+ }
454
+ this.lastOutNeck2.copy(neck2.quaternion);
455
+ this.lastOutNeck2Has = true;
456
+ }
457
+ // Publish normalised direction for blink (Task 5) and eye-gaze (Task 9).
458
+ const h = THREE.MathUtils.clamp(this.yaw / maxTrack, -1, 1);
459
+ const v = THREE.MathUtils.clamp(-this.pitch / maxPitch, -1, 1);
460
+ this.direction.horizontal = h;
461
+ this.direction.vertical = v;
462
+ this.direction.left.horizontal = h;
463
+ this.direction.left.vertical = v;
464
+ this.direction.right.horizontal = h;
465
+ this.direction.right.vertical = v;
466
+ }
467
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * prefers-reduced-motion watcher.
3
+ *
4
+ * Continuous breathing, drifting, swaying and blinking is a genuine
5
+ * vestibular problem for some users, and this media query is exactly what it
6
+ * is for. Damping costs almost nothing, so it ships in v1 rather than the
7
+ * roadmap. Lipsync and clip playback are NOT damped — they carry meaning.
8
+ */
9
+ export interface ReducedMotion {
10
+ readonly value: boolean;
11
+ dispose(): void;
12
+ }
13
+ export declare function createReducedMotion(): ReducedMotion;
@@ -0,0 +1,13 @@
1
+ export function createReducedMotion() {
2
+ if (typeof globalThis.matchMedia !== 'function') {
3
+ return { value: false, dispose() { } };
4
+ }
5
+ const mq = globalThis.matchMedia('(prefers-reduced-motion: reduce)');
6
+ let current = mq.matches;
7
+ const onChange = (e) => { current = e.matches; };
8
+ mq.addEventListener('change', onChange);
9
+ return {
10
+ get value() { return current; },
11
+ dispose() { mq.removeEventListener('change', onChange); },
12
+ };
13
+ }
@@ -0,0 +1,16 @@
1
+ /** Per-eye gaze. horizontal: -1 (left) to 1 (right). vertical: -1 (down) to 1 (up). */
2
+ export interface EyeGazeDirection {
3
+ horizontal: number;
4
+ vertical: number;
5
+ }
6
+ export interface GazeDirection {
7
+ left: EyeGazeDirection;
8
+ right: EyeGazeDirection;
9
+ /** Averages, for consumers that do not care about vergence. */
10
+ horizontal: number;
11
+ vertical: number;
12
+ }
13
+ /** Anything that can tell a downstream system where the character is looking. */
14
+ export interface GazeSource {
15
+ readonly direction: GazeDirection;
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import { type UseCharacterOptions } from './useCharacter.js';
2
+ import type { CharacterHandle } from '../character/createCharacter.js';
3
+ export interface ConvaiCharacterProps extends UseCharacterOptions {
4
+ onReady?: (character: CharacterHandle) => void;
5
+ onError?: (error: Error) => void;
6
+ }
7
+ export declare function ConvaiCharacter({ onReady, onError, ...opts }: ConvaiCharacterProps): import("react").JSX.Element | null;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Thin wiring only. All lifecycle/tick behaviour lives in `useCharacter`
4
+ * (which itself delegates cancellation to `../character/lifecycle`) — this
5
+ * component adds nothing but the `<primitive>` render and the
6
+ * onReady/onError callback plumbing.
7
+ */
8
+ import { useEffect } from 'react';
9
+ import { useCharacter } from './useCharacter.js';
10
+ export function ConvaiCharacter({ onReady, onError, ...opts }) {
11
+ const { character, error } = useCharacter(opts);
12
+ useEffect(() => {
13
+ if (character && onReady)
14
+ onReady(character);
15
+ // eslint-disable-next-line react-hooks/exhaustive-deps
16
+ }, [character, onReady]);
17
+ useEffect(() => {
18
+ if (error && onError)
19
+ onError(error);
20
+ // eslint-disable-next-line react-hooks/exhaustive-deps
21
+ }, [error, onError]);
22
+ if (!character)
23
+ return null;
24
+ return _jsx("primitive", { object: character.root });
25
+ }
@@ -0,0 +1,4 @@
1
+ export { useCharacter } from './useCharacter.js';
2
+ export type { UseCharacterOptions, UseCharacterResult } from './useCharacter.js';
3
+ export { ConvaiCharacter } from './ConvaiCharacter.js';
4
+ export type { ConvaiCharacterProps } from './ConvaiCharacter.js';
@@ -0,0 +1,2 @@
1
+ export { useCharacter } from './useCharacter.js';
2
+ export { ConvaiCharacter } from './ConvaiCharacter.js';
@@ -0,0 +1,11 @@
1
+ import type { BlendshapeQueue } from '@convai/web-sdk/core';
2
+ import { type CharacterHandle, type CreateCharacterOptions } from '../character/createCharacter.js';
3
+ export interface UseCharacterOptions extends CreateCharacterOptions {
4
+ /** Pass the queue only while this character owns the conversational turn. */
5
+ queue?: BlendshapeQueue;
6
+ }
7
+ export interface UseCharacterResult {
8
+ character: CharacterHandle | null;
9
+ error: Error | null;
10
+ }
11
+ export declare function useCharacter(opts: UseCharacterOptions): UseCharacterResult;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thin wiring only — see the module doc on `../character/lifecycle` for why
3
+ * the cancel/dispose behaviour lives there and not here. This hook does
4
+ * nothing `createCharacterLifecycle` doesn't already own: it creates a
5
+ * lifecycle per `characterId`, feeds it `createCharacter`, cancels it on
6
+ * unmount/re-run, and forwards the two pieces of state React needs
7
+ * (`character`, `error`). Ticking and camera assignment are similarly
8
+ * one-line pass-throughs to `CharacterHandle.update`/`.camera`.
9
+ */
10
+ import { useEffect, useRef, useState } from 'react';
11
+ import { useFrame, useThree } from '@react-three/fiber';
12
+ import { createCharacter } from '../character/createCharacter.js';
13
+ import { createCharacterLifecycle } from '../character/lifecycle.js';
14
+ export function useCharacter(opts) {
15
+ const [character, setCharacter] = useState(null);
16
+ const [error, setError] = useState(null);
17
+ const camera = useThree((s) => s.camera);
18
+ const queueRef = useRef(opts.queue);
19
+ queueRef.current = opts.queue;
20
+ // Re-created options each render, but only read inside the effect below at
21
+ // the instant it (re-)runs — see the exhaustive-deps note on that effect.
22
+ const optsRef = useRef(opts);
23
+ optsRef.current = opts;
24
+ useEffect(() => {
25
+ const lifecycle = createCharacterLifecycle();
26
+ lifecycle.start(() => createCharacter(optsRef.current),
27
+ // Hide the previous attempt's result the instant a new one begins —
28
+ // owned by the lifecycle contract (R57), not a bare effect-body
29
+ // setState pair.
30
+ () => {
31
+ setCharacter(null);
32
+ setError(null);
33
+ }, (c) => setCharacter(c), (e) => setError(e));
34
+ return () => lifecycle.cancel();
35
+ // Re-create only when the identity of the character changes — matching
36
+ // the lifecycle contract of one `start` per instance.
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ }, [opts.characterId]);
39
+ useEffect(() => {
40
+ if (character)
41
+ character.camera = camera;
42
+ }, [character, camera]);
43
+ useFrame((_, delta) => character?.update(delta, queueRef.current));
44
+ return { character, error };
45
+ }