@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,93 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
function freshAnchor() {
|
|
3
|
+
return { base: new THREE.Quaternion(), lastWritten: new THREE.Quaternion(), has: false };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Subtle additive chest/spine motion so the body never reads frozen.
|
|
7
|
+
*
|
|
8
|
+
* RULING (2026-09-07 fix wave, C3, superseding an earlier fix in this same
|
|
9
|
+
* wave — see the coordinator correction in final-fix-report.md): the
|
|
10
|
+
* ORIGINAL version of this class post-multiplied the breath delta directly
|
|
11
|
+
* onto `chest.quaternion`/`waist.quaternion`, on the claim that "the mixer
|
|
12
|
+
* rewrites every frame, so multiplying in never accumulates." That's only
|
|
13
|
+
* true while an active AnimationMixer action is bound to those bones, and
|
|
14
|
+
* `createCharacter` registers clips without ever calling `.play()` — on
|
|
15
|
+
* that (default) path nothing resets the bone between frames, so the
|
|
16
|
+
* original code compounded without bound (measured ±30°+ at 30fps).
|
|
17
|
+
*
|
|
18
|
+
* A FIRST fix composed `rest * breath` absolutely every frame, using the
|
|
19
|
+
* bone's bind pose captured once at `resolveRig` time. That bounded the
|
|
20
|
+
* no-clip case but silently broke the WITH-clip case: every clip in
|
|
21
|
+
* `demo/fixtures/japanese-f-animations.glb` (Idle included, which the demo
|
|
22
|
+
* actually plays) animates `spine_02`/`spine_04` — the exact two bones this
|
|
23
|
+
* system drives — so composing onto a STATIC captured rest pose discarded
|
|
24
|
+
* the clip's own spine motion outright, every frame, on the normal
|
|
25
|
+
* (with-clip) path. That is worse than the bug it fixed: a spinning torso
|
|
26
|
+
* with no clip is unmissable on day one; silently-dropped clip spine
|
|
27
|
+
* animation on the normal path is not.
|
|
28
|
+
*
|
|
29
|
+
* Fixed properly here by having each bone remember what THIS system itself
|
|
30
|
+
* last wrote to it (`BoneAnchor.lastWritten`). Each frame:
|
|
31
|
+
* - if the bone's LIVE quaternion differs from `lastWritten`, something
|
|
32
|
+
* else (the mixer) rewrote it since — adopt the bone's current value as
|
|
33
|
+
* this frame's `base` and remember it.
|
|
34
|
+
* - if it is identical, this system's own previous write is still
|
|
35
|
+
* standing (nothing else touched the bone) — reuse the stored `base`
|
|
36
|
+
* rather than compounding on top of what was already composed.
|
|
37
|
+
* Then write `base * breath`. With an active clip continuously rewriting
|
|
38
|
+
* the bone (the normal path), `base` re-adopts the clip's live pose every
|
|
39
|
+
* frame and breath composes on top of it, exactly as the pipeline's stage-3
|
|
40
|
+
* "additive, after the mixer" contract intends. With no clip at all (the
|
|
41
|
+
* path the original bug was about), nothing ever differs from
|
|
42
|
+
* `lastWritten`, so `base` stays anchored to whatever the bone held on the
|
|
43
|
+
* first frame this system ever touched it (its bind pose, in practice) —
|
|
44
|
+
* bounded, exactly as the earlier fix intended, without needing a
|
|
45
|
+
* separately-captured rest pose at all.
|
|
46
|
+
*
|
|
47
|
+
* The comparison is EXACT (`Quaternion.equals`, not an epsilon): a
|
|
48
|
+
* `.copy()` followed by a later read of that SAME untouched object is
|
|
49
|
+
* bit-identical, so exact equality has no false-negative risk from float
|
|
50
|
+
* drift on the "nothing touched it" path. An epsilon would also risk a
|
|
51
|
+
* false negative on the "something touched it" path — a real clip whose
|
|
52
|
+
* frame-to-frame delta happens to be smaller than some chosen tolerance
|
|
53
|
+
* (e.g. a slow sway lingering near an extremum) would be missed and
|
|
54
|
+
* wrongly treated as "unchanged."
|
|
55
|
+
*/
|
|
56
|
+
export class BreathingSystem {
|
|
57
|
+
constructor(config = {}) {
|
|
58
|
+
this.stage = 3;
|
|
59
|
+
this.clock = Math.random() * 10; // random phase per instance
|
|
60
|
+
this.q = new THREE.Quaternion(); // pre-allocated
|
|
61
|
+
this.chestAnchor = freshAnchor();
|
|
62
|
+
this.waistAnchor = freshAnchor();
|
|
63
|
+
this.rate = config.rate ?? 0.22;
|
|
64
|
+
this.amount = config.amount ?? 0.012;
|
|
65
|
+
this.enabled = config.enabled ?? true;
|
|
66
|
+
}
|
|
67
|
+
update(dt, ctx) {
|
|
68
|
+
if (!this.enabled || ctx.reducedMotion)
|
|
69
|
+
return;
|
|
70
|
+
const chest = ctx.rig.bones.spineChest;
|
|
71
|
+
const waist = ctx.rig.bones.spineWaist;
|
|
72
|
+
if (!chest && !waist)
|
|
73
|
+
return;
|
|
74
|
+
this.clock += dt;
|
|
75
|
+
const phase = this.clock * this.rate * Math.PI * 2;
|
|
76
|
+
// Asymmetric breath: sharper rise (inhale), longer fall (exhale).
|
|
77
|
+
const breath = Math.sin(phase) + 0.3 * Math.sin(phase * 2 + 1.2);
|
|
78
|
+
const basis = ctx.rig.profile.basis;
|
|
79
|
+
if (chest)
|
|
80
|
+
this.applyBreath(chest, this.chestAnchor, -breath * this.amount, basis.right);
|
|
81
|
+
if (waist)
|
|
82
|
+
this.applyBreath(waist, this.waistAnchor, -breath * this.amount * 0.5, basis.right);
|
|
83
|
+
}
|
|
84
|
+
applyBreath(bone, anchor, angle, axis) {
|
|
85
|
+
if (!anchor.has || !bone.quaternion.equals(anchor.lastWritten)) {
|
|
86
|
+
anchor.base.copy(bone.quaternion);
|
|
87
|
+
anchor.has = true;
|
|
88
|
+
}
|
|
89
|
+
this.q.setFromAxisAngle(axis, angle);
|
|
90
|
+
bone.quaternion.copy(anchor.base).multiply(this.q);
|
|
91
|
+
anchor.lastWritten.copy(bone.quaternion);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FrameContext, System } from '../character/pipeline.js';
|
|
2
|
+
import type { GazeSource } from './types.js';
|
|
3
|
+
export interface EyeGazeConfig {
|
|
4
|
+
/**
|
|
5
|
+
* Per-frame ease rate toward the gaze target. Verbatim from
|
|
6
|
+
* `useEyeTracking.ts`'s `DEFAULT_CONFIG.lerpSpeed` (useEyeTracking.ts:108)
|
|
7
|
+
* — NOT this plan's originally-invented `ease: 0.25` (RULING R47).
|
|
8
|
+
*/
|
|
9
|
+
lerpSpeed?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Constant downward bias folded into the vertical target before
|
|
12
|
+
* clamping — "pupils look slightly lower by default"
|
|
13
|
+
* (`DEFAULT_CONFIG.verticalOffset`, useEyeTracking.ts:109, doc at :31).
|
|
14
|
+
* The original brief omitted this field entirely (RULING R47).
|
|
15
|
+
*/
|
|
16
|
+
verticalOffset?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Scale on the eye-look MORPH values at full deflection. RULING
|
|
19
|
+
* (2026-09-07 fix wave): default is the rig-tuned value from the actual
|
|
20
|
+
* convai-mha-1 call site (SofiaV4.tsx:323, `morphGain: 0.65`), not the
|
|
21
|
+
* generic `useEyeTracking.ts` DEFAULT_CONFIG value of 1 — that call site's
|
|
22
|
+
* rig shares this exact CTRL_expressions_eyeLook* morph set (same names
|
|
23
|
+
* `CONVAI_MHA_1` resolves against) and its own comment gives the reason:
|
|
24
|
+
* "the v4 eye-look shapes encode ~40°+ of eyeball rotation at 1.0; scale
|
|
25
|
+
* down so a 30° gaze angle maps to a ~30° eyeball turn." That is a
|
|
26
|
+
* rig-mesh property, independent of C4's `DOWN_LID_SCALE` (which damps
|
|
27
|
+
* only the down-gaze lid-travel channel) — the two compound only on the
|
|
28
|
+
* down direction, by design (down gaze is the one direction stacked with
|
|
29
|
+
* BlinkSystem's resting droop too).
|
|
30
|
+
*/
|
|
31
|
+
morphGain?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Eye gaze on convai-mha-1 is MORPH-ONLY — the mandated rig has no eye bones
|
|
35
|
+
* (character spec 2.2). This system ports only the morph-output half of
|
|
36
|
+
* `useEyeTracking`'s dual bone+morph mechanism
|
|
37
|
+
* (useEyeTracking.ts:538-562) — the bone-rotation path is CC4-only and out
|
|
38
|
+
* of scope here.
|
|
39
|
+
*
|
|
40
|
+
* The channel names are cross-named: driving `eyeLookLeft*` rotates the eyes
|
|
41
|
+
* toward the character's RIGHT (useEyeTracking.ts:156-159, verified live on
|
|
42
|
+
* the UE rig by driving each morph in isolation). `CONVAI_MHA_1` encodes
|
|
43
|
+
* that as in/out morph roles (`eyeLookInL`/`eyeLookOutL`/`eyeLookInR`/
|
|
44
|
+
* `eyeLookOutR`); this system only ever speaks in in/out terms and never
|
|
45
|
+
* references a literal left/right channel name.
|
|
46
|
+
*
|
|
47
|
+
* DEFERRED (RULING R48 — logged for final review, deliberately NOT built
|
|
48
|
+
* here):
|
|
49
|
+
* - saccades: dart-to-a-new-point-and-hold idle micro-movement
|
|
50
|
+
* (useEyeTracking.ts:254-262, 458-485).
|
|
51
|
+
* - the speech lock that suppresses saccades for full eye contact while
|
|
52
|
+
* speaking, and the `isListening` lerp-to-1.0 override
|
|
53
|
+
* (useEyeTracking.ts:264-265, 401, 485).
|
|
54
|
+
*
|
|
55
|
+
* Registered at stage 6 (morph), between lipsync and blink.
|
|
56
|
+
*/
|
|
57
|
+
export declare class EyeGazeSystem implements System {
|
|
58
|
+
private readonly gaze;
|
|
59
|
+
private readonly cfg;
|
|
60
|
+
readonly stage: 6;
|
|
61
|
+
private h;
|
|
62
|
+
private v;
|
|
63
|
+
constructor(gaze: GazeSource, cfg?: EyeGazeConfig);
|
|
64
|
+
update(_dt: number, ctx: FrameContext): void;
|
|
65
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { writeMorph } from '../rig/resolve.js';
|
|
3
|
+
/**
|
|
4
|
+
* Eye gaze on convai-mha-1 is MORPH-ONLY — the mandated rig has no eye bones
|
|
5
|
+
* (character spec 2.2). This system ports only the morph-output half of
|
|
6
|
+
* `useEyeTracking`'s dual bone+morph mechanism
|
|
7
|
+
* (useEyeTracking.ts:538-562) — the bone-rotation path is CC4-only and out
|
|
8
|
+
* of scope here.
|
|
9
|
+
*
|
|
10
|
+
* The channel names are cross-named: driving `eyeLookLeft*` rotates the eyes
|
|
11
|
+
* toward the character's RIGHT (useEyeTracking.ts:156-159, verified live on
|
|
12
|
+
* the UE rig by driving each morph in isolation). `CONVAI_MHA_1` encodes
|
|
13
|
+
* that as in/out morph roles (`eyeLookInL`/`eyeLookOutL`/`eyeLookInR`/
|
|
14
|
+
* `eyeLookOutR`); this system only ever speaks in in/out terms and never
|
|
15
|
+
* references a literal left/right channel name.
|
|
16
|
+
*
|
|
17
|
+
* DEFERRED (RULING R48 — logged for final review, deliberately NOT built
|
|
18
|
+
* here):
|
|
19
|
+
* - saccades: dart-to-a-new-point-and-hold idle micro-movement
|
|
20
|
+
* (useEyeTracking.ts:254-262, 458-485).
|
|
21
|
+
* - the speech lock that suppresses saccades for full eye contact while
|
|
22
|
+
* speaking, and the `isListening` lerp-to-1.0 override
|
|
23
|
+
* (useEyeTracking.ts:264-265, 401, 485).
|
|
24
|
+
*
|
|
25
|
+
* Registered at stage 6 (morph), between lipsync and blink.
|
|
26
|
+
*/
|
|
27
|
+
export class EyeGazeSystem {
|
|
28
|
+
constructor(gaze, cfg = {}) {
|
|
29
|
+
this.gaze = gaze;
|
|
30
|
+
this.cfg = cfg;
|
|
31
|
+
this.stage = 6;
|
|
32
|
+
this.h = 0;
|
|
33
|
+
this.v = 0;
|
|
34
|
+
}
|
|
35
|
+
update(_dt, ctx) {
|
|
36
|
+
const lerpSpeed = this.cfg.lerpSpeed ?? 0.2;
|
|
37
|
+
const verticalOffset = this.cfg.verticalOffset ?? -0.15;
|
|
38
|
+
const gain = this.cfg.morphGain ?? 0.65;
|
|
39
|
+
const d = this.gaze.direction;
|
|
40
|
+
const targetH = ctx.reducedMotion ? 0 : THREE.MathUtils.clamp(d.horizontal, -1, 1);
|
|
41
|
+
const targetV = ctx.reducedMotion
|
|
42
|
+
? 0
|
|
43
|
+
: THREE.MathUtils.clamp(d.vertical + verticalOffset, -1, 1);
|
|
44
|
+
this.h += (targetH - this.h) * lerpSpeed;
|
|
45
|
+
this.v += (targetV - this.v) * lerpSpeed;
|
|
46
|
+
const h = this.h * gain;
|
|
47
|
+
const v = this.v * gain;
|
|
48
|
+
// Positive h = looking to the character's RIGHT. That is "in" (nose-ward)
|
|
49
|
+
// for the LEFT eye and "out" for the RIGHT eye.
|
|
50
|
+
writeMorph(ctx.rig, 'eyeLookInL', Math.max(0, h));
|
|
51
|
+
writeMorph(ctx.rig, 'eyeLookOutL', Math.max(0, -h));
|
|
52
|
+
writeMorph(ctx.rig, 'eyeLookInR', Math.max(0, -h));
|
|
53
|
+
writeMorph(ctx.rig, 'eyeLookOutR', Math.max(0, h));
|
|
54
|
+
// Positive v = looking up. Up/down are named straight (not cross-named)
|
|
55
|
+
// on both eyes. Down-gaze lid travel is damped (RULING C4, 2026-09-07 fix
|
|
56
|
+
// wave): ported from useEyeTracking.ts:545-551's `DOWN_LID_SCALE = 0.65`
|
|
57
|
+
// — at 1:1 the Look_Down morphs plus BlinkSystem's resting droop
|
|
58
|
+
// (`relax`) shut the eyes whenever the gaze drops. Only the DOWN
|
|
59
|
+
// direction is scaled; up/left/right are untouched.
|
|
60
|
+
const DOWN_LID_SCALE = 0.65;
|
|
61
|
+
writeMorph(ctx.rig, 'eyeLookUpL', Math.max(0, v));
|
|
62
|
+
writeMorph(ctx.rig, 'eyeLookDownL', DOWN_LID_SCALE * Math.max(0, -v));
|
|
63
|
+
writeMorph(ctx.rig, 'eyeLookUpR', Math.max(0, v));
|
|
64
|
+
writeMorph(ctx.rig, 'eyeLookDownR', DOWN_LID_SCALE * Math.max(0, -v));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { FrameContext, System } from '../character/pipeline.js';
|
|
2
|
+
import type { GazeDirection, GazeSource } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Config surface for `GazeSystem`. Task 6 landed the geometric core —
|
|
5
|
+
* target -> yaw/pitch -> head/neck split -> time-based, velocity-capped
|
|
6
|
+
* smoothing -> `setLookQuat`. Task 7 (this task) adds the aliveness layer:
|
|
7
|
+
* idle drift, look-away gaze shifts and turn-bank, all read by `update()`
|
|
8
|
+
* below. Task 8 (clip-blending: camera lock, deviation pull-back) extends
|
|
9
|
+
* the SAME class and will read the fields still marked "reserved" — they
|
|
10
|
+
* are declared now so the class's public config shape does not change
|
|
11
|
+
* under that task, but `update()` does not read them yet.
|
|
12
|
+
*/
|
|
13
|
+
export interface GazeConfig {
|
|
14
|
+
/** Half-angle (rad) the HEAD alone covers before the neck starts sharing
|
|
15
|
+
* yaw. Verbatim from `HeadTrackingConfig.DEFAULT_CONFIG.headOnlyAngle`
|
|
16
|
+
* (useHeadTracking.ts:107) — NOT the interface JSDoc's PI/3 (line 17),
|
|
17
|
+
* which the implementation itself does not use. Per RULING R20: the
|
|
18
|
+
* DEFAULTS object is ground truth, and its own comment explains 0.44 was
|
|
19
|
+
* a deliberate retune down from a wider zone that put the head near its
|
|
20
|
+
* joint limit for a side viewer. */
|
|
21
|
+
headOnlyAngle?: number;
|
|
22
|
+
/** Beyond this yaw (rad) tracking disengages entirely for the frame
|
|
23
|
+
* (target clamped to zero, not clamped-in-range). */
|
|
24
|
+
maxTrackAngle?: number;
|
|
25
|
+
/** Vertical (pitch) clamp, radians. */
|
|
26
|
+
maxPitchAngle?: number;
|
|
27
|
+
/** Ease rate (per-second, before the `* 60` time-based conversion in
|
|
28
|
+
* `update()`) toward the target yaw/pitch. Velocity-capped — see
|
|
29
|
+
* `update()`. */
|
|
30
|
+
lerpSpeed?: number;
|
|
31
|
+
/** Share of pitch applied to the head. */
|
|
32
|
+
headPitchFactor?: number;
|
|
33
|
+
/** Share of pitch applied to each neck bone. */
|
|
34
|
+
neckPitchFactor?: number;
|
|
35
|
+
/** Share of the excess (beyond `headOnlyAngle`) yaw applied to the neck.
|
|
36
|
+
* Verbatim from `DEFAULT_CONFIG.neckYawFactor` (useHeadTracking.ts:113),
|
|
37
|
+
* which is 0.75, not the interface JSDoc's 0.7 (line 30). Per R20. */
|
|
38
|
+
neckYawFactor?: number;
|
|
39
|
+
/** Idle micro-drift amplitude on yaw, radians. */
|
|
40
|
+
idleYawAmp?: number;
|
|
41
|
+
/** Idle micro-drift amplitude on pitch, radians. */
|
|
42
|
+
idlePitchAmp?: number;
|
|
43
|
+
/** Idle micro-drift amplitude on roll, radians. */
|
|
44
|
+
idleRollAmp?: number;
|
|
45
|
+
/** Time multiplier for the idle-drift clock. */
|
|
46
|
+
idleSpeed?: number;
|
|
47
|
+
/** Roll added per radian of (capped) head yaw — banks the head into a
|
|
48
|
+
* turn. */
|
|
49
|
+
turnBank?: number;
|
|
50
|
+
/** Max yaw (radians) of an occasional look-away gaze shift. */
|
|
51
|
+
gazeYawRange?: number;
|
|
52
|
+
/** Max pitch (radians) of a gaze shift. */
|
|
53
|
+
gazePitchRange?: number;
|
|
54
|
+
/** Max roll (radians) of a gaze shift. */
|
|
55
|
+
gazeRollRange?: number;
|
|
56
|
+
/** Ease rate (per-second, before the `* 60` time-based conversion) the
|
|
57
|
+
* gaze-shift offset eases toward its target at. */
|
|
58
|
+
gazeEase?: number;
|
|
59
|
+
/** Minimum seconds a gaze-shift target is held before re-picking. */
|
|
60
|
+
gazeDwellMin?: number;
|
|
61
|
+
/** Maximum seconds a gaze-shift target is held before re-picking. */
|
|
62
|
+
gazeDwellMax?: number;
|
|
63
|
+
/** Camera-lock strength while idle (0-1): how much of the clip's local
|
|
64
|
+
* head rotation is replaced by the look pose. High values mean
|
|
65
|
+
* procedural drift/gaze carry the aliveness and the clip barely
|
|
66
|
+
* reaches the output. */
|
|
67
|
+
idleLock?: number;
|
|
68
|
+
/** Camera-lock strength while `ctx.speaking` — focused on the viewer. */
|
|
69
|
+
talkLock?: number;
|
|
70
|
+
/** Clip-deviation (radians, angle between the clip's head pose and the
|
|
71
|
+
* look pose) below which the clip flows through untouched. */
|
|
72
|
+
deviationLo?: number;
|
|
73
|
+
/** Clip-deviation (radians) by which the pull-back saturates — the head
|
|
74
|
+
* is fully locked back to the look pose regardless of `idleLock`/
|
|
75
|
+
* `talkLock`. */
|
|
76
|
+
deviationHi?: number;
|
|
77
|
+
/** Static construction-time gate. Live control is `setEnabled()`. */
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Head/neck gaze pose solve, ported from `useHeadTracking`
|
|
82
|
+
* (convai-web-sdk-internal `examples/react-three-fiber/src/hooks/useHeadTracking.ts`).
|
|
83
|
+
*
|
|
84
|
+
* Stage 2 — the ONLY system permitted to OVERRIDE the mixer's head/neck
|
|
85
|
+
* rotation outright (`.copy(...)`, never `.multiply(...)`): a head that
|
|
86
|
+
* blends additively with its own animation clip fights itself every frame.
|
|
87
|
+
*
|
|
88
|
+
* Task 6 ported the geometric core: world target -> yaw/pitch about the
|
|
89
|
+
* rig's own basis -> head/neck split -> time-based, velocity-capped ease ->
|
|
90
|
+
* `setLookQuat`. Task 7 layered the aliveness on top of that same solve:
|
|
91
|
+
* always-on idle micro-drift, occasional look-away gaze shifts with dwell
|
|
92
|
+
* timing, and turn-bank roll — all suppressed under `ctx.reducedMotion`.
|
|
93
|
+
* Task 8 (this task) adds the clip-blending layer: a smoothed camera-lock
|
|
94
|
+
* strength (`idleLock`/`talkLock`, eased rather than switched — see
|
|
95
|
+
* `update()`) blended against the mixer's own clip pose, with a deviation
|
|
96
|
+
* pull-back (`deviationLo`/`deviationHi`) that guards large clip excursions
|
|
97
|
+
* regardless of lock strength; and `calm`, which de-emphasises the idle
|
|
98
|
+
* drift/gaze-shift amplitudes while the (smoothed) lock is high, i.e. while
|
|
99
|
+
* speaking — a focused speaker stops glancing around.
|
|
100
|
+
*
|
|
101
|
+
* The HEAD is the only bone that blends with the clip; the neck bones are
|
|
102
|
+
* still overridden outright (Task 6/7 behaviour, unchanged) — see
|
|
103
|
+
* `update()`.
|
|
104
|
+
*
|
|
105
|
+
* See `.superpowers/sdd/2026-09-07-convai-embody-motion-and-staging/task-6-report.md`,
|
|
106
|
+
* `task-7-report.md` and `task-8-report.md` for the line-by-line comparison
|
|
107
|
+
* against the prior art and the places this deliberately differs from it.
|
|
108
|
+
*/
|
|
109
|
+
export declare class GazeSystem implements System, GazeSource {
|
|
110
|
+
private readonly cfg;
|
|
111
|
+
readonly stage: 2;
|
|
112
|
+
readonly direction: GazeDirection;
|
|
113
|
+
private gate;
|
|
114
|
+
private yaw;
|
|
115
|
+
private pitch;
|
|
116
|
+
/** Idle-roll + turn-bank, written by the aliveness layer below. */
|
|
117
|
+
private roll;
|
|
118
|
+
/** Idle-drift clock, advanced by `dt * idleSpeed`. Randomly seeded so
|
|
119
|
+
* multiple GazeSystem instances on stage together don't drift in
|
|
120
|
+
* lockstep. */
|
|
121
|
+
private driftClock;
|
|
122
|
+
/** Current eased gaze-shift offsets (the "look-away" the head is
|
|
123
|
+
* easing toward / holding). */
|
|
124
|
+
private shiftYaw;
|
|
125
|
+
private shiftPitch;
|
|
126
|
+
private shiftRoll;
|
|
127
|
+
/** The look-away target `shift*` is easing toward, re-picked every
|
|
128
|
+
* dwell period. */
|
|
129
|
+
private shiftTargetYaw;
|
|
130
|
+
private shiftTargetPitch;
|
|
131
|
+
private shiftTargetRoll;
|
|
132
|
+
/** Seconds remaining before the next gaze-shift target is re-picked. */
|
|
133
|
+
private dwell;
|
|
134
|
+
/** Smoothed camera-lock strength, eased toward `idleLock`/`talkLock`
|
|
135
|
+
* each frame (RULING R39) rather than switched instantly — ported
|
|
136
|
+
* from `useHeadTracking.ts:581`'s `lockRef`, but time-based (R40)
|
|
137
|
+
* instead of the prior art's bare per-frame `* 0.04`. Initialised to
|
|
138
|
+
* `idleLock` so the very first frame doesn't jump. */
|
|
139
|
+
private lock;
|
|
140
|
+
private readonly vTarget;
|
|
141
|
+
private readonly vHead;
|
|
142
|
+
private readonly vLocal;
|
|
143
|
+
private readonly mInv;
|
|
144
|
+
/** What the mixer wrote to the head this frame, before we touch it. */
|
|
145
|
+
private readonly qClip;
|
|
146
|
+
/** The camera-locked look pose computed this frame. */
|
|
147
|
+
private readonly qLook;
|
|
148
|
+
/** Low-passed (tau ~0.2s) copy of `qClip`. RULING R43 part 2: the
|
|
149
|
+
* deviation boost is measured against THIS, not the raw clip pose, so
|
|
150
|
+
* one noisy/fast clip frame can't make the boost — and therefore the
|
|
151
|
+
* blend — jump within a frame. Ported from useHeadTracking.ts:594-606. */
|
|
152
|
+
private readonly animHead;
|
|
153
|
+
private animHeadHas;
|
|
154
|
+
/** The blended pose, before the output velocity cap below is applied. */
|
|
155
|
+
private readonly qOut;
|
|
156
|
+
/** The actual pose WRITTEN to the head last frame — the anchor the
|
|
157
|
+
* output velocity cap (RULING R43 part 3) rotates from. Ported from
|
|
158
|
+
* useHeadTracking.ts:621-633. */
|
|
159
|
+
private readonly lastOutHead;
|
|
160
|
+
private lastOutHas;
|
|
161
|
+
/** RULING R61: the neck bones blend with their OWN clip pose too — same
|
|
162
|
+
* low-pass, same shared head-derived `blend`, same output cap — ported
|
|
163
|
+
* from useHeadTracking.ts:598-606 (low-pass) and :635-671 (blend +
|
|
164
|
+
* cap). One low-pass/cap pair per neck bone, each independent (the
|
|
165
|
+
* clip poses and, after the cap, the written poses differ per bone). */
|
|
166
|
+
private readonly animNeck1;
|
|
167
|
+
private animNeck1Has;
|
|
168
|
+
private readonly animNeck2;
|
|
169
|
+
private animNeck2Has;
|
|
170
|
+
private readonly qOutNeck;
|
|
171
|
+
private readonly lastOutNeck1;
|
|
172
|
+
private lastOutNeck1Has;
|
|
173
|
+
private readonly lastOutNeck2;
|
|
174
|
+
private lastOutNeck2Has;
|
|
175
|
+
constructor(cfg?: GazeConfig);
|
|
176
|
+
/**
|
|
177
|
+
* Live on/off gate, read every frame. While false the system writes
|
|
178
|
+
* NOTHING to the rig — the mixer's clips own the head/neck — and eases
|
|
179
|
+
* the internal yaw/pitch/roll state toward neutral so re-enabling does
|
|
180
|
+
* not snap the head back onto the camera.
|
|
181
|
+
*/
|
|
182
|
+
setEnabled(on: boolean): void;
|
|
183
|
+
/**
|
|
184
|
+
* Time-based exponential ease toward `tgt`, velocity-capped so a frame
|
|
185
|
+
* hitch can't yank the head. Ported verbatim from
|
|
186
|
+
* useHeadTracking.ts:480-484 — NOT the bare `x += (target - x) * lerp`
|
|
187
|
+
* form, which is frame-rate dependent by construction (a 144Hz monitor
|
|
188
|
+
* would track visibly faster than a 60Hz one). Per RULING R22.
|
|
189
|
+
*
|
|
190
|
+
* Hoisted to a method (RULING R25) rather than a closure allocated inside
|
|
191
|
+
* `update()` — a per-frame closure is exactly the steady-state allocation
|
|
192
|
+
* this class's zero-allocation rule forbids. `useHeadTracking.ts` can
|
|
193
|
+
* afford the closure because it's a React hook body re-created rarely and
|
|
194
|
+
* garbage-collected on unmount; embody's `update()` runs every frame for
|
|
195
|
+
* the lifetime of the character.
|
|
196
|
+
*/
|
|
197
|
+
private approach;
|
|
198
|
+
/**
|
|
199
|
+
* Smooth, non-repeating drift in ~[-1, 1] — three incommensurate sines
|
|
200
|
+
* (an fbm-lite). Ported VERBATIM from useHeadTracking.ts:144-150
|
|
201
|
+
* (function `drift`) — NOT the brief's single-sine placeholder
|
|
202
|
+
* (`Math.sin(t * 0.31)`), which is a different, un-sourced waveform.
|
|
203
|
+
* The three fixed frequencies/weights are shared by every axis; only
|
|
204
|
+
* the per-axis `seed` (and its own amplitude) differs, exactly as in
|
|
205
|
+
* the prior art's `drift(t, 1.0|7.3|13.1)` calls at lines 556-559.
|
|
206
|
+
*
|
|
207
|
+
* Hoisted to a method rather than a closure (RULING R25, same as
|
|
208
|
+
* `approach` above) — no steady-state allocation.
|
|
209
|
+
*/
|
|
210
|
+
private drift;
|
|
211
|
+
update(dt: number, ctx: FrameContext): void;
|
|
212
|
+
}
|