@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,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
|
+
}
|