@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +191 -0
- package/README.md +165 -0
- package/dist/character/createCharacter.d.ts +101 -0
- package/dist/character/createCharacter.js +163 -0
- package/dist/character/lifecycle.d.ts +55 -0
- package/dist/character/lifecycle.js +28 -0
- package/dist/character/pipeline.d.ts +46 -0
- package/dist/character/pipeline.js +29 -0
- package/dist/clips/animations.d.ts +39 -0
- package/dist/clips/animations.js +77 -0
- package/dist/decorate/types.d.ts +29 -0
- package/dist/decorate/types.js +13 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +14 -0
- package/dist/motion/blink.d.ts +56 -0
- package/dist/motion/blink.js +127 -0
- package/dist/motion/breathe.d.ts +72 -0
- package/dist/motion/breathe.js +93 -0
- package/dist/motion/eyeGaze.d.ts +65 -0
- package/dist/motion/eyeGaze.js +66 -0
- package/dist/motion/gaze.d.ts +212 -0
- package/dist/motion/gaze.js +467 -0
- package/dist/motion/reducedMotion.d.ts +13 -0
- package/dist/motion/reducedMotion.js +13 -0
- package/dist/motion/types.d.ts +16 -0
- package/dist/motion/types.js +1 -0
- package/dist/react/ConvaiCharacter.d.ts +7 -0
- package/dist/react/ConvaiCharacter.js +25 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/useCharacter.d.ts +11 -0
- package/dist/react/useCharacter.js +45 -0
- package/dist/resolve/resolver.d.ts +56 -0
- package/dist/resolve/resolver.js +45 -0
- package/dist/rig/profile.d.ts +64 -0
- package/dist/rig/profile.js +110 -0
- package/dist/rig/resolve.d.ts +33 -0
- package/dist/rig/resolve.js +76 -0
- package/dist/rig/units.d.ts +27 -0
- package/dist/rig/units.js +61 -0
- package/dist/speech/lipsync.d.ts +56 -0
- package/dist/speech/lipsync.js +139 -0
- 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,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
|
+
}
|