@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,56 @@
1
+ /**
2
+ * CharacterResolver — the seam between @convai/embody and the asset backend.
3
+ *
4
+ * characterId → CharacterBundle. Backed today by middleman's
5
+ * GET /character/{id}/embodiment (phase-1 avatar_config storage); the release
6
+ * tables replace that storage later without this contract moving — which is
7
+ * the entire reason the seam exists.
8
+ */
9
+ export interface ModelVariant {
10
+ lod?: number;
11
+ url: string;
12
+ size?: number;
13
+ encodings?: Record<string, string>;
14
+ }
15
+ export interface Representation {
16
+ rig: string;
17
+ model: ModelVariant[];
18
+ clips?: Record<string, {
19
+ url: string;
20
+ }>;
21
+ }
22
+ export interface CharacterBundle {
23
+ characterId: string;
24
+ avatarId: string;
25
+ gender?: 'male' | 'female' | string;
26
+ release?: number;
27
+ representations: Representation[];
28
+ thumbnail?: string;
29
+ }
30
+ export interface CharacterResolver {
31
+ resolve(characterId: string): Promise<CharacterBundle>;
32
+ }
33
+ /**
34
+ * Default backend host.
35
+ *
36
+ * Release branches carry different values and `scripts/check-hosts.mjs`
37
+ * enforces which, mirroring how @convai/web-sdk pins its connect host:
38
+ *
39
+ * stable-release -> production (what ships to `latest`)
40
+ * beta-release -> production (first line of testing, against real infra)
41
+ * stg-release -> staging (staging has its own character database)
42
+ *
43
+ * A branch shipping the wrong default silently sends every consumer who does
44
+ * not pass `baseUrl` to the wrong backend, which is why it is checked rather
45
+ * than trusted.
46
+ */
47
+ export declare const DEFAULT_BASE_URL = "https://api-stg.convai.com";
48
+ export interface HttpResolverOptions {
49
+ /** Base url of the embodiment backend. Defaults to the release branch's host. */
50
+ baseUrl?: string;
51
+ /** Optional CONVAI-API-KEY; public characters resolve as guest without it. */
52
+ apiKey?: string;
53
+ }
54
+ export declare function httpResolver(opts?: HttpResolverOptions): CharacterResolver;
55
+ /** Pick the representation for a rig profile this build of embody understands. */
56
+ export declare function pickRepresentation(bundle: CharacterBundle, supported: string[]): Representation;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * CharacterResolver — the seam between @convai/embody and the asset backend.
3
+ *
4
+ * characterId → CharacterBundle. Backed today by middleman's
5
+ * GET /character/{id}/embodiment (phase-1 avatar_config storage); the release
6
+ * tables replace that storage later without this contract moving — which is
7
+ * the entire reason the seam exists.
8
+ */
9
+ /**
10
+ * Default backend host.
11
+ *
12
+ * Release branches carry different values and `scripts/check-hosts.mjs`
13
+ * enforces which, mirroring how @convai/web-sdk pins its connect host:
14
+ *
15
+ * stable-release -> production (what ships to `latest`)
16
+ * beta-release -> production (first line of testing, against real infra)
17
+ * stg-release -> staging (staging has its own character database)
18
+ *
19
+ * A branch shipping the wrong default silently sends every consumer who does
20
+ * not pass `baseUrl` to the wrong backend, which is why it is checked rather
21
+ * than trusted.
22
+ */
23
+ export const DEFAULT_BASE_URL = 'https://api-stg.convai.com';
24
+ export function httpResolver(opts = {}) {
25
+ const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
26
+ return {
27
+ async resolve(characterId) {
28
+ const res = await fetch(`${baseUrl}/character/${characterId}/embodiment`, {
29
+ headers: opts.apiKey ? { 'CONVAI-API-KEY': opts.apiKey } : undefined,
30
+ });
31
+ if (!res.ok)
32
+ throw new Error(`embodiment resolve failed: HTTP ${res.status}`);
33
+ return (await res.json());
34
+ },
35
+ };
36
+ }
37
+ /** Pick the representation for a rig profile this build of embody understands. */
38
+ export function pickRepresentation(bundle, supported) {
39
+ for (const rig of supported) {
40
+ const rep = bundle.representations.find((r) => r.rig === rig);
41
+ if (rep)
42
+ return rep;
43
+ }
44
+ throw new Error(`no supported representation: character offers [${bundle.representations.map((r) => r.rig).join(', ')}], embody supports [${supported.join(', ')}]`);
45
+ }
@@ -0,0 +1,64 @@
1
+ import * as THREE from 'three';
2
+ /**
3
+ * Bone roles every motion system addresses by name, resolved through a
4
+ * `RigProfile`'s alias lists rather than hard-coded bone names. Ported from
5
+ * the CC4/UE dual-rig hooks in convai-web-sdk's rigBones.ts, generalised so
6
+ * each rig's aliases and axis basis live together as data on one profile
7
+ * instead of being picked by name-sniffing (`rigBasisFor`).
8
+ */
9
+ export type BoneRole = 'head' | 'neck1' | 'neck2' | 'spineChest' | 'spineWaist';
10
+ export type MorphRole = 'blinkL' | 'blinkR' | 'blinkCorrectiveL' | 'blinkCorrectiveR' | 'relax' | 'lowerLidUp' | 'eyeLookUpL' | 'eyeLookDownL' | 'eyeLookInL' | 'eyeLookOutL' | 'eyeLookUpR' | 'eyeLookDownR' | 'eyeLookInR' | 'eyeLookOutR';
11
+ /** Which bone-LOCAL axes point right / up / forward in the rest pose. */
12
+ export interface RigBasis {
13
+ right: THREE.Vector3;
14
+ up: THREE.Vector3;
15
+ fwd: THREE.Vector3;
16
+ }
17
+ export interface RigProfile {
18
+ id: string;
19
+ bones: Partial<Record<BoneRole, readonly string[]>>;
20
+ basis: RigBasis;
21
+ morphs: Partial<Record<MorphRole, readonly string[]>>;
22
+ }
23
+ /** CC4 (Character Creator) bones: +X right, +Y up, +Z forward. */
24
+ export declare const CC4_BASIS: RigBasis;
25
+ /** Measured live on the Sofia v4 GLBs: head/neck_01/neck_02/spine_04 share it. */
26
+ export declare const UE_BASIS: RigBasis;
27
+ /**
28
+ * Yaw about `up`, then pitch about `right`, then roll about `fwd`.
29
+ * Bit-identical to Euler(pitch, yaw, roll, 'YXZ') on the CC4 basis.
30
+ * Positive yaw turns toward the character's right; positive pitch looks down.
31
+ *
32
+ * Writes into `out` and returns it — never allocates on the steady-state path.
33
+ */
34
+ export declare function setLookQuat(out: THREE.Quaternion, basis: RigBasis, yaw: number, pitch: number, roll: number): THREE.Quaternion;
35
+ /**
36
+ * The mandated Convai rig (character spec 2.1/2.2). UE MetaHuman names,
37
+ * lowercase and exact. No eye bones, no jaw bones — both are morph-driven.
38
+ */
39
+ export declare const CONVAI_MHA_1: RigProfile;
40
+ /**
41
+ * Legacy CC4 (the old Sofia). Present so the path is not lost; NOT registered.
42
+ * The character spec is explicit that the CC4 alias table "is not a licence
43
+ * for new characters to vary."
44
+ *
45
+ * RULING (2026-09-07 fix wave, must-fix triage item 2): this profile was
46
+ * publicly exported but broken — `relax`/`lowerLidUp` used the CC4 ARKit
47
+ * singular names (`Eye_Relax`, `Eye_LowerLid_Up`) instead of the CC4
48
+ * ExpressionPlus per-eye names this rig's morph set actually carries
49
+ * (`Eye_Relax_L`/`_R`, `Eye_LowerLid_Up_L`/`_R` — see useBlink.ts:45-56),
50
+ * and it had no eye-look table at all, so `EyeGazeSystem` resolved zero
51
+ * bindings against it. Fixed here (rather than unexported) by porting the
52
+ * full CC4 ExpressionPlus alias set from useEyeTracking.ts's
53
+ * `MORPH_NAME_TO_CHANNEL` (lines 139-146). The eye-look in/out cross-naming
54
+ * mirrors physically between eyes (left eye's nasal/"in" side is named
55
+ * `_Right_L`; right eye's is named `_Left_R`) — derived from, and consistent
56
+ * with, `CONVAI_MHA_1`'s own CTRL_expressions cross-naming above, and
57
+ * verified in rig-resolve.test.ts against a synthetic CC4-named mesh.
58
+ */
59
+ export declare const CC4_LEGACY: RigProfile;
60
+ /**
61
+ * Profiles embody will actually resolve against. CC4 is deliberately absent —
62
+ * the registered rig is `convai-mha-1` only.
63
+ */
64
+ export declare const REGISTERED_PROFILES: readonly RigProfile[];
@@ -0,0 +1,110 @@
1
+ import * as THREE from 'three';
2
+ /** CC4 (Character Creator) bones: +X right, +Y up, +Z forward. */
3
+ export const CC4_BASIS = {
4
+ right: new THREE.Vector3(1, 0, 0),
5
+ up: new THREE.Vector3(0, 1, 0),
6
+ fwd: new THREE.Vector3(0, 0, 1),
7
+ };
8
+ /** Measured live on the Sofia v4 GLBs: head/neck_01/neck_02/spine_04 share it. */
9
+ export const UE_BASIS = {
10
+ right: new THREE.Vector3(0, 0, -1),
11
+ up: new THREE.Vector3(1, 0, 0),
12
+ fwd: new THREE.Vector3(0, -1, 0),
13
+ };
14
+ const qYaw = new THREE.Quaternion();
15
+ const qPitch = new THREE.Quaternion();
16
+ const qRoll = new THREE.Quaternion();
17
+ /**
18
+ * Yaw about `up`, then pitch about `right`, then roll about `fwd`.
19
+ * Bit-identical to Euler(pitch, yaw, roll, 'YXZ') on the CC4 basis.
20
+ * Positive yaw turns toward the character's right; positive pitch looks down.
21
+ *
22
+ * Writes into `out` and returns it — never allocates on the steady-state path.
23
+ */
24
+ export function setLookQuat(out, basis, yaw, pitch, roll) {
25
+ qYaw.setFromAxisAngle(basis.up, yaw);
26
+ qPitch.setFromAxisAngle(basis.right, pitch);
27
+ qRoll.setFromAxisAngle(basis.fwd, roll);
28
+ return out.copy(qYaw).multiply(qPitch).multiply(qRoll);
29
+ }
30
+ /**
31
+ * The mandated Convai rig (character spec 2.1/2.2). UE MetaHuman names,
32
+ * lowercase and exact. No eye bones, no jaw bones — both are morph-driven.
33
+ */
34
+ export const CONVAI_MHA_1 = {
35
+ id: 'convai-mha-1',
36
+ basis: UE_BASIS,
37
+ bones: {
38
+ head: ['head'],
39
+ neck1: ['neck_01'],
40
+ neck2: ['neck_02'],
41
+ spineChest: ['spine_04'],
42
+ spineWaist: ['spine_02'],
43
+ },
44
+ morphs: {
45
+ blinkL: ['CTRL_expressions_eyeBlinkL', 'EyeBlinkLeft'],
46
+ blinkR: ['CTRL_expressions_eyeBlinkR', 'EyeBlinkRight'],
47
+ relax: ['CTRL_expressions_eyeRelaxL', 'CTRL_expressions_eyeRelaxR'],
48
+ lowerLidUp: ['CTRL_expressions_eyeLowerLidUpL', 'CTRL_expressions_eyeLowerLidUpR'],
49
+ // Cross-named on purpose — verified in useEyeTracking. eyeLookLeft*
50
+ // rotates toward the character's RIGHT, so it is the "in" channel for the
51
+ // LEFT eye and the "out" channel for the right.
52
+ eyeLookUpL: ['CTRL_expressions_eyeLookUpL', 'EyeLookUpLeft'],
53
+ eyeLookDownL: ['CTRL_expressions_eyeLookDownL', 'EyeLookDownLeft'],
54
+ eyeLookInL: ['CTRL_expressions_eyeLookLeftL', 'EyeLookInLeft'],
55
+ eyeLookOutL: ['CTRL_expressions_eyeLookRightL', 'EyeLookOutLeft'],
56
+ eyeLookUpR: ['CTRL_expressions_eyeLookUpR', 'EyeLookUpRight'],
57
+ eyeLookDownR: ['CTRL_expressions_eyeLookDownR', 'EyeLookDownRight'],
58
+ eyeLookInR: ['CTRL_expressions_eyeLookRightR', 'EyeLookInRight'],
59
+ eyeLookOutR: ['CTRL_expressions_eyeLookLeftR', 'EyeLookOutRight'],
60
+ },
61
+ };
62
+ /**
63
+ * Legacy CC4 (the old Sofia). Present so the path is not lost; NOT registered.
64
+ * The character spec is explicit that the CC4 alias table "is not a licence
65
+ * for new characters to vary."
66
+ *
67
+ * RULING (2026-09-07 fix wave, must-fix triage item 2): this profile was
68
+ * publicly exported but broken — `relax`/`lowerLidUp` used the CC4 ARKit
69
+ * singular names (`Eye_Relax`, `Eye_LowerLid_Up`) instead of the CC4
70
+ * ExpressionPlus per-eye names this rig's morph set actually carries
71
+ * (`Eye_Relax_L`/`_R`, `Eye_LowerLid_Up_L`/`_R` — see useBlink.ts:45-56),
72
+ * and it had no eye-look table at all, so `EyeGazeSystem` resolved zero
73
+ * bindings against it. Fixed here (rather than unexported) by porting the
74
+ * full CC4 ExpressionPlus alias set from useEyeTracking.ts's
75
+ * `MORPH_NAME_TO_CHANNEL` (lines 139-146). The eye-look in/out cross-naming
76
+ * mirrors physically between eyes (left eye's nasal/"in" side is named
77
+ * `_Right_L`; right eye's is named `_Left_R`) — derived from, and consistent
78
+ * with, `CONVAI_MHA_1`'s own CTRL_expressions cross-naming above, and
79
+ * verified in rig-resolve.test.ts against a synthetic CC4-named mesh.
80
+ */
81
+ export const CC4_LEGACY = {
82
+ id: 'cc4-legacy',
83
+ basis: CC4_BASIS,
84
+ bones: {
85
+ head: ['CC_Base_Head'],
86
+ neck1: ['CC_Base_NeckTwist01'],
87
+ neck2: ['CC_Base_NeckTwist02'],
88
+ spineChest: ['CC_Base_Spine02'],
89
+ spineWaist: ['CC_Base_Spine01'],
90
+ },
91
+ morphs: {
92
+ blinkL: ['Eye_Blink_L'], blinkR: ['Eye_Blink_R'],
93
+ blinkCorrectiveL: ['C_BlinkL'], blinkCorrectiveR: ['C_BlinkR'],
94
+ relax: ['Eye_Relax_L', 'Eye_Relax_R'],
95
+ lowerLidUp: ['Eye_LowerLid_Up_L', 'Eye_LowerLid_Up_R'],
96
+ eyeLookUpL: ['Eye_Look_Up_L'],
97
+ eyeLookDownL: ['Eye_Look_Down_L'],
98
+ eyeLookInL: ['Eye_Look_Right_L'],
99
+ eyeLookOutL: ['Eye_Look_Left_L'],
100
+ eyeLookUpR: ['Eye_Look_Up_R'],
101
+ eyeLookDownR: ['Eye_Look_Down_R'],
102
+ eyeLookInR: ['Eye_Look_Left_R'],
103
+ eyeLookOutR: ['Eye_Look_Right_R'],
104
+ },
105
+ };
106
+ /**
107
+ * Profiles embody will actually resolve against. CC4 is deliberately absent —
108
+ * the registered rig is `convai-mha-1` only.
109
+ */
110
+ export const REGISTERED_PROFILES = [CONVAI_MHA_1];
@@ -0,0 +1,33 @@
1
+ import * as THREE from 'three';
2
+ import type { BoneRole, MorphRole, RigProfile } from './profile.js';
3
+ export interface MorphBinding {
4
+ mesh: THREE.Mesh;
5
+ index: number;
6
+ }
7
+ export interface ResolvedRig {
8
+ profile: RigProfile;
9
+ bones: Partial<Record<BoneRole, THREE.Object3D>>;
10
+ /**
11
+ * Each resolved bone's LOCAL quaternion at resolve time — captured once,
12
+ * before any pipeline system (mixer, gaze, breathing) has ever touched it,
13
+ * so it is the true bind/rest pose regardless of whether a clip happens to
14
+ * be active on frame 1. Motion systems that OVERRIDE a bone (gaze) or
15
+ * additively perturb it (breathing) compose onto this, never onto identity
16
+ * — see GazeSystem/BreathingSystem's `update()` for why (C1/C3 in the
17
+ * 2026-09-07 fix wave: the prior art always composes the look/breath
18
+ * offset onto the bone's own rest orientation, not world/parent identity).
19
+ */
20
+ restPose: Partial<Record<BoneRole, THREE.Quaternion>>;
21
+ morphs: Map<MorphRole, MorphBinding[]>;
22
+ missing: BoneRole[];
23
+ }
24
+ /**
25
+ * Bones the motion systems in this slice actually drive. The character spec
26
+ * also requires spine_01/03/05 for CLIP compatibility, but nothing here reads
27
+ * them — validating those is the upload pipeline's job, not embody's.
28
+ */
29
+ export declare const REQUIRED_BONES: readonly BoneRole[];
30
+ /** Apply a profile to a loaded root. Call once, at load — never per frame. */
31
+ export declare function resolveRig(root: THREE.Object3D, profile: RigProfile): ResolvedRig;
32
+ /** Write a value to every mesh carrying a role. Absent roles are a no-op. */
33
+ export declare function writeMorph(rig: ResolvedRig, role: MorphRole, value: number): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Bones the motion systems in this slice actually drive. The character spec
3
+ * also requires spine_01/03/05 for CLIP compatibility, but nothing here reads
4
+ * them — validating those is the upload pipeline's job, not embody's.
5
+ */
6
+ export const REQUIRED_BONES = [
7
+ 'head', 'neck1', 'neck2', 'spineChest', 'spineWaist',
8
+ ];
9
+ function findFirst(root, aliases) {
10
+ for (const name of aliases) {
11
+ const hit = root.getObjectByName(name);
12
+ if (hit)
13
+ return hit;
14
+ }
15
+ return null;
16
+ }
17
+ /** Apply a profile to a loaded root. Call once, at load — never per frame. */
18
+ export function resolveRig(root, profile) {
19
+ const bones = {};
20
+ const restPose = {};
21
+ const missing = [];
22
+ for (const role of Object.keys(profile.bones)) {
23
+ const hit = findFirst(root, profile.bones[role] ?? []);
24
+ if (hit) {
25
+ bones[role] = hit;
26
+ // Cloned NOW, before any system's first update() — this is the only
27
+ // point in the character's lifecycle guaranteed to run before stage 1
28
+ // (the mixer) has ever written to the bone.
29
+ restPose[role] = hit.quaternion.clone();
30
+ }
31
+ }
32
+ for (const role of REQUIRED_BONES) {
33
+ if (!bones[role])
34
+ missing.push(role);
35
+ }
36
+ if (missing.length) {
37
+ const detail = missing.map((r) => {
38
+ const aliases = profile.bones[r] ?? [];
39
+ const desc = aliases.length > 0 ? aliases.join('|') : 'no aliases declared in profile';
40
+ return `${r} (${desc})`;
41
+ }).join(', ');
42
+ console.warn(`[embody] rig "${profile.id}": missing required bone(s) — ${detail}. ` +
43
+ `Motion systems driving them will no-op.`);
44
+ }
45
+ const morphs = new Map();
46
+ const meshes = [];
47
+ root.traverse((o) => {
48
+ const m = o;
49
+ if (m.isMesh && m.morphTargetDictionary)
50
+ meshes.push(m);
51
+ });
52
+ for (const role of Object.keys(profile.morphs)) {
53
+ const bindings = [];
54
+ for (const name of profile.morphs[role] ?? []) {
55
+ for (const mesh of meshes) {
56
+ const index = mesh.morphTargetDictionary?.[name];
57
+ if (index !== undefined)
58
+ bindings.push({ mesh, index });
59
+ }
60
+ }
61
+ if (bindings.length)
62
+ morphs.set(role, bindings);
63
+ }
64
+ return { profile, bones, restPose, morphs, missing };
65
+ }
66
+ /** Write a value to every mesh carrying a role. Absent roles are a no-op. */
67
+ export function writeMorph(rig, role, value) {
68
+ const bindings = rig.morphs.get(role);
69
+ if (!bindings)
70
+ return;
71
+ for (const b of bindings) {
72
+ const infl = b.mesh.morphTargetInfluences;
73
+ if (infl)
74
+ infl[b.index] = value;
75
+ }
76
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Unit normalisation for character models.
3
+ *
4
+ * Assets disagree about export scale (sofia.glb: raw centimetres;
5
+ * japanese-f.glb: centimetre geometry under a baked 0.01 root), and every
6
+ * static measure of a SkinnedMesh lies about rendered size — Box3 and
7
+ * computeBoundingBox read bind-pose geometry, bone spans miss bind scaling.
8
+ *
9
+ * The assumption-free answer (proven in the playhouse's CharacterPreview):
10
+ * measure the head→ankle bone span actually in world space every frame and
11
+ * multiply the scale by the error until it converges. Costs two
12
+ * getWorldPosition calls; settles in a frame or two; cannot be wrong about
13
+ * units because it never assumes any.
14
+ *
15
+ * This is consumer-side insurance. The character spec gains a units mandate
16
+ * and the processing pipeline will normalise export scale; this stays as the
17
+ * belt to that suspenders.
18
+ */
19
+ import * as THREE from 'three';
20
+ export interface UnitNormalizer {
21
+ /** Run every frame until it reports true (converged). Cheap either way. */
22
+ update(): boolean;
23
+ }
24
+ export declare function createUnitNormalizer(root: THREE.Object3D, opts?: {
25
+ targetHeight?: number;
26
+ groundY?: number;
27
+ }): UnitNormalizer;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Unit normalisation for character models.
3
+ *
4
+ * Assets disagree about export scale (sofia.glb: raw centimetres;
5
+ * japanese-f.glb: centimetre geometry under a baked 0.01 root), and every
6
+ * static measure of a SkinnedMesh lies about rendered size — Box3 and
7
+ * computeBoundingBox read bind-pose geometry, bone spans miss bind scaling.
8
+ *
9
+ * The assumption-free answer (proven in the playhouse's CharacterPreview):
10
+ * measure the head→ankle bone span actually in world space every frame and
11
+ * multiply the scale by the error until it converges. Costs two
12
+ * getWorldPosition calls; settles in a frame or two; cannot be wrong about
13
+ * units because it never assumes any.
14
+ *
15
+ * This is consumer-side insurance. The character spec gains a units mandate
16
+ * and the processing pipeline will normalise export scale; this stays as the
17
+ * belt to that suspenders.
18
+ */
19
+ import * as THREE from 'three';
20
+ /** Head→ankle joint span as a fraction of full stature (calibrated on Sofia). */
21
+ const BONE_SPAN_RATIO = 0.837;
22
+ export function createUnitNormalizer(root, opts = {}) {
23
+ const target = (opts.targetHeight ?? 1.7) * BONE_SPAN_RATIO;
24
+ const groundY = opts.groundY ?? 0;
25
+ let head = null;
26
+ let foot = null;
27
+ root.traverse((o) => {
28
+ const n = o.name.trim().toLowerCase();
29
+ if (!head && n === 'head')
30
+ head = o;
31
+ if (!foot && n === 'foot_l')
32
+ foot = o;
33
+ });
34
+ const a = new THREE.Vector3();
35
+ const b = new THREE.Vector3();
36
+ let settled = 0;
37
+ return {
38
+ update() {
39
+ if (!head || !foot)
40
+ return true;
41
+ head.updateWorldMatrix(true, false);
42
+ foot.updateWorldMatrix(true, false);
43
+ const span = head.getWorldPosition(a).y - foot.getWorldPosition(b).y;
44
+ if (!(span > 1e-9))
45
+ return false;
46
+ const err = target / span;
47
+ if (Math.abs(err - 1) > 0.005) {
48
+ root.scale.multiplyScalar(err);
49
+ root.updateWorldMatrix(true, true);
50
+ settled = 0;
51
+ }
52
+ else
53
+ settled++;
54
+ // feet on the floor, every pass, so scale correction never leaves the
55
+ // character hovering or sunk
56
+ foot.updateWorldMatrix(true, false);
57
+ root.position.y += groundY - foot.getWorldPosition(b).y;
58
+ return settled > 2;
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * LipsyncSystem — MHA lipsync, framework-free.
3
+ *
4
+ * A faithful port of pagoda-act's useMhaLipsync. The shaping is the SDK's
5
+ * (createLipsyncProcessor("mha"): symmetrize → limits → gain table →
6
+ * bloom/settle envelope → morph write, repairing MetaHuman export name
7
+ * mangling at bind). What this layer owns:
8
+ *
9
+ * · driving the queue by ASKING it (real fps, bot-speaking flag, the one-shot
10
+ * normalization signal) instead of inferring any of it
11
+ * · the mouth character layer — jaw cap with proportional lower lip, the
12
+ * bilabial lip-seal floor, teeth follow rules — exposed as MouthTuning so
13
+ * per-character adjustment is a typed parameter, not a fork of this file
14
+ * · speaking/energy signals for higher layers (body clip transitions, gaze)
15
+ */
16
+ import type * as THREE from 'three';
17
+ import type { BlendshapeQueue } from '@convai/web-sdk/core';
18
+ /** Tier-1 customization: the mouth knobs, per character. Defaults = pagoda-act's tuned values. */
19
+ export interface MouthTuning {
20
+ /** Hard cap on jawOpen; peak excursions read exaggerated on camera. */
21
+ jawCap?: number;
22
+ /** Lip-seal floor ceiling and onset for bilabials (m/b/p). */
23
+ sealCeiling?: number;
24
+ sealOnset?: number;
25
+ /** Teeth follow rules. */
26
+ teethRetract?: number;
27
+ teethUpperLift?: number;
28
+ teethLowerTuck?: number;
29
+ /** Gain multipliers by MHA channel name, forwarded to the processor. */
30
+ gainOverrides?: Record<string, number>;
31
+ /** Seconds of visual lead granted at speech start. */
32
+ visualLeadSeconds?: number;
33
+ }
34
+ declare const DEFAULTS: Required<Omit<MouthTuning, 'gainOverrides'>> & {
35
+ gainOverrides: Record<string, number>;
36
+ };
37
+ export declare class LipsyncSystem {
38
+ readonly t: typeof DEFAULTS;
39
+ private readonly processor;
40
+ private bindings;
41
+ private accum;
42
+ private lastFrame;
43
+ private wasSpeaking;
44
+ private env;
45
+ /** 0–1 speech energy; gaze layers read it. */
46
+ energy: number;
47
+ /** True while the bot owns the mouth; body layers read it. */
48
+ speaking: boolean;
49
+ private readonly ch;
50
+ constructor(tuning?: MouthTuning);
51
+ /** Bind every morph-bearing mesh under root. Returns matched channel count. */
52
+ bind(root: THREE.Object3D): number;
53
+ /** Tick each frame. Pass the queue only if this character owns the turn. */
54
+ update(delta: number, q: BlendshapeQueue | undefined): void;
55
+ }
56
+ export {};
@@ -0,0 +1,139 @@
1
+ import { createLipsyncProcessor, mhaChannelIndex, } from '@convai/web-sdk/lipsync-helpers';
2
+ const DEFAULTS = {
3
+ jawCap: 0.55,
4
+ sealCeiling: 0.5,
5
+ sealOnset: 0.45,
6
+ teethRetract: 0.55,
7
+ teethUpperLift: 0.3,
8
+ teethLowerTuck: 0.34,
9
+ visualLeadSeconds: 0.1,
10
+ gainOverrides: { mouthLowerLipDepressL: 0.28, mouthLowerLipDepressR: 0.28 },
11
+ };
12
+ export class LipsyncSystem {
13
+ constructor(tuning = {}) {
14
+ this.bindings = [];
15
+ this.accum = 0;
16
+ this.lastFrame = null;
17
+ this.wasSpeaking = false;
18
+ this.env = 0;
19
+ /** 0–1 speech energy; gaze layers read it. */
20
+ this.energy = 0;
21
+ /** True while the bot owns the mouth; body layers read it. */
22
+ this.speaking = false;
23
+ this.t = { ...DEFAULTS, ...tuning, gainOverrides: { ...DEFAULTS.gainOverrides, ...tuning.gainOverrides } };
24
+ this.processor = createLipsyncProcessor('mha', {
25
+ skipEyeChannels: true,
26
+ gainOverrides: this.t.gainOverrides,
27
+ });
28
+ const idx = (n) => mhaChannelIndex(n) ?? -1;
29
+ const idxs = (...ns) => ns.map((n) => mhaChannelIndex(n)).filter((i) => i !== undefined);
30
+ this.ch = {
31
+ jawOpen: idx('jawOpen'),
32
+ lipsTogether: idxs('mouthLipsTogetherUL', 'mouthLipsTogetherUR', 'mouthLipsTogetherDL', 'mouthLipsTogetherDR'),
33
+ lowerLipDepress: idxs('mouthLowerLipDepressL', 'mouthLowerLipDepressR'),
34
+ funnel: idxs('mouthFunnelUL', 'mouthFunnelUR'),
35
+ teethBackUpper: idx('teethBackU'),
36
+ teethBackLower: idx('teethBackD'),
37
+ teethUpUpper: idx('teethUpU'),
38
+ teethDownLower: idx('teethDownD'),
39
+ };
40
+ }
41
+ /** Bind every morph-bearing mesh under root. Returns matched channel count. */
42
+ bind(root) {
43
+ this.bindings = this.processor.bindAll(root);
44
+ return this.bindings.reduce((s, b) => s + b.matched, 0);
45
+ }
46
+ /** Tick each frame. Pass the queue only if this character owns the turn. */
47
+ update(delta, q) {
48
+ if (!this.bindings.length)
49
+ return;
50
+ if (!q) {
51
+ // Not holding the queue means not speaking — and that must be SAID,
52
+ // or the body layer holds "talking" forever.
53
+ this.speaking = false;
54
+ this.energy += (0 - this.energy) * (1 - Math.exp(-delta / 0.22));
55
+ this.processor.apply(this.processor.process(null, delta), this.bindings);
56
+ return;
57
+ }
58
+ const fps = q.getPlaybackFps?.() || 60;
59
+ const frameTime = 1 / fps;
60
+ const speaking = q.isBotSpeaking?.() ?? true;
61
+ if (speaking && !this.wasSpeaking)
62
+ this.accum = this.t.visualLeadSeconds;
63
+ this.wasSpeaking = speaking;
64
+ let fresh = null;
65
+ if (speaking) {
66
+ this.accum += delta;
67
+ while (this.accum >= frameTime && q.hasFrames()) {
68
+ const frame = q.getFrameWithAlpha(0);
69
+ q.consumeFrames(1);
70
+ this.accum -= frameTime;
71
+ if (frame)
72
+ fresh = frame; // draw only the newest; skip, never slow-replay
73
+ }
74
+ if (this.accum > 0.25)
75
+ this.accum = 0.25; // a starved queue banks no debt
76
+ if (fresh)
77
+ this.lastFrame = fresh;
78
+ }
79
+ else {
80
+ this.accum = 0;
81
+ }
82
+ const raw = speaking ? this.lastFrame : null;
83
+ const out = this.processor.process(raw, delta);
84
+ // mirror the processor's envelope so the seal fades with speech
85
+ const target = raw ? 1 : 0;
86
+ const tau = target > this.env ? 0.09 : 0.18;
87
+ this.env += (target - this.env) * (1 - Math.exp(-delta / tau));
88
+ if (out) {
89
+ const { jawCap, sealCeiling, sealOnset, teethRetract, teethUpperLift, teethLowerTuck } = this.t;
90
+ const ch = this.ch;
91
+ // hard jaw cap, lower lip scaled by the same ratio so the cap changes
92
+ // the size of the opening without changing its shape
93
+ let capRatio = 1;
94
+ if (ch.jawOpen >= 0 && out[ch.jawOpen] > jawCap) {
95
+ capRatio = jawCap / out[ch.jawOpen];
96
+ out[ch.jawOpen] = jawCap;
97
+ }
98
+ if (capRatio < 1)
99
+ for (const i of ch.lowerLipDepress)
100
+ out[i] *= capRatio;
101
+ // bilabial closure floor, driven from the RAW jaw
102
+ if (raw && ch.jawOpen >= 0 && ch.lipsTogether.length) {
103
+ const jawRaw = raw[ch.jawOpen];
104
+ const bilabial = 1 - Math.min(1, Math.max(0, (jawRaw - 0.02) / 0.16));
105
+ if (bilabial > sealOnset) {
106
+ const seal = ((bilabial - sealOnset) / (1 - sealOnset)) * sealCeiling * this.env;
107
+ for (const i of ch.lipsTogether)
108
+ if (out[i] < seal)
109
+ out[i] = seal;
110
+ }
111
+ }
112
+ // teeth follow the SHAPED jaw
113
+ const jaw = ch.jawOpen >= 0 ? out[ch.jawOpen] : 0;
114
+ if (ch.teethBackUpper >= 0)
115
+ out[ch.teethBackUpper] = jaw * teethRetract;
116
+ if (ch.teethBackLower >= 0)
117
+ out[ch.teethBackLower] = jaw * teethRetract;
118
+ if (ch.teethUpUpper >= 0)
119
+ out[ch.teethUpUpper] = jaw * teethUpperLift;
120
+ if (ch.teethDownLower >= 0) {
121
+ // normalised against the CAP, not 1 — lands on exactly 0 at widest
122
+ const open = jawCap > 0 ? Math.min(1, jaw / jawCap) : 0;
123
+ out[ch.teethDownLower] = teethLowerTuck * (1 - open);
124
+ }
125
+ // speech energy: fast attack, slow release
126
+ let mag = jaw;
127
+ for (const i of ch.funnel)
128
+ mag = Math.max(mag, out[i]);
129
+ const e = Math.min(1, mag / 0.35);
130
+ const eTau = e > this.energy ? 0.06 : 0.22;
131
+ this.energy += (e - this.energy) * (1 - Math.exp(-delta / eTau));
132
+ }
133
+ else {
134
+ this.energy += (0 - this.energy) * (1 - Math.exp(-delta / 0.22));
135
+ }
136
+ this.processor.apply(out, this.bindings);
137
+ this.speaking = speaking;
138
+ }
139
+ }