@convai/embody 1.0.1-beta.0 → 1.1.0-beta.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 +38 -0
- package/README.md +12 -8
- package/dist/character/createCharacter.d.ts +66 -5
- package/dist/character/createCharacter.js +93 -22
- package/dist/character/pipeline.d.ts +9 -5
- package/dist/clips/animations.d.ts +13 -1
- package/dist/clips/animations.js +28 -6
- package/dist/clips/retarget.d.ts +168 -0
- package/dist/clips/retarget.js +856 -0
- package/dist/clips/sources.d.ts +70 -0
- package/dist/clips/sources.js +119 -0
- package/dist/connect/session.d.ts +11 -0
- package/dist/connect/session.js +22 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -0
- package/dist/inspect/inspectModel.d.ts +14 -0
- package/dist/inspect/inspectModel.js +26 -0
- package/dist/inspect/morphMap.d.ts +54 -0
- package/dist/inspect/morphMap.js +140 -0
- package/dist/inspect/report.d.ts +88 -0
- package/dist/inspect/report.js +193 -0
- package/dist/motion/eyeAim.d.ts +94 -0
- package/dist/motion/eyeAim.js +276 -0
- package/dist/motion/eyeGaze.d.ts +17 -0
- package/dist/motion/eyeGaze.js +42 -0
- package/dist/motion/footLock.d.ts +67 -0
- package/dist/motion/footLock.js +176 -0
- package/dist/motion/gaze.d.ts +135 -180
- package/dist/motion/gaze.js +257 -491
- package/dist/motion/hair.d.ts +27 -0
- package/dist/motion/hair.js +127 -0
- package/dist/motion/hairClearance.d.ts +39 -0
- package/dist/motion/hairClearance.js +125 -0
- package/dist/motion/morphNormals.d.ts +38 -0
- package/dist/motion/morphNormals.js +237 -0
- package/dist/motion/types.d.ts +15 -0
- package/dist/react/CharacterModel.d.ts +3 -1
- package/dist/react/CharacterModel.js +9 -1
- package/dist/react/ConvaiCharacter.js +4 -1
- package/dist/react/PortraitLights.d.ts +3 -0
- package/dist/react/PortraitLights.js +44 -0
- package/dist/render/faceMaterials.d.ts +60 -3
- package/dist/render/faceMaterials.js +320 -96
- package/dist/render/portraitRig.d.ts +28 -0
- package/dist/render/portraitRig.js +55 -0
- package/dist/render/surfaces.d.ts +181 -0
- package/dist/render/surfaces.js +1020 -0
- package/dist/rig/resolve.d.ts +8 -2
- package/dist/rig/resolve.js +7 -3
- package/dist/rig/units.d.ts +35 -16
- package/dist/rig/units.js +143 -42
- package/dist/speech/expression.d.ts +20 -0
- package/dist/speech/expression.js +118 -0
- package/dist/speech/lipsync.d.ts +68 -2
- package/dist/speech/lipsync.js +205 -5
- package/dist/speech/mouthFinish.d.ts +46 -0
- package/dist/speech/mouthFinish.js +235 -0
- package/dist/speech/voiceClock.d.ts +94 -0
- package/dist/speech/voiceClock.js +176 -0
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clip files → the clips a body plays.
|
|
3
|
+
*
|
|
4
|
+
* A clip authored on the body's own skeleton plays as it is (its tracks name
|
|
5
|
+
* the body's bones). A clip from a skeleton `readRigNames` recognises —
|
|
6
|
+
* Mixamo, the Unreal mannequin, Rigify — is retargeted onto the body at load
|
|
7
|
+
* time (`retarget.ts`). Anything else is handed on unchanged, and
|
|
8
|
+
* `AnimationController.register` reports what it cannot bind.
|
|
9
|
+
*
|
|
10
|
+
* FBX files are read with three's `FBXLoader`, imported only when an `.fbx`
|
|
11
|
+
* URL is actually requested, so an app that never loads FBX does not ship it.
|
|
12
|
+
*/
|
|
13
|
+
import * as THREE from 'three';
|
|
14
|
+
import { type RetargetOptions } from './retarget.js';
|
|
15
|
+
/** A loaded animation file. */
|
|
16
|
+
export interface ClipFile {
|
|
17
|
+
/** The file's scene: the GLTF `scene`, or the FBX group. */
|
|
18
|
+
root: THREE.Object3D;
|
|
19
|
+
animations: THREE.AnimationClip[];
|
|
20
|
+
}
|
|
21
|
+
/** True for a URL whose path ends in `.fbx` (any case, query and hash ignored). */
|
|
22
|
+
export declare const isFbxUrl: (url: string) => boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Loads one animation file: FBX through a lazily imported `FBXLoader`,
|
|
25
|
+
* anything else through `gltf` (which carries the app's Draco decoder).
|
|
26
|
+
*/
|
|
27
|
+
export declare function loadClipFile(url: string, gltf: {
|
|
28
|
+
loadAsync(url: string): Promise<{
|
|
29
|
+
scene: THREE.Object3D;
|
|
30
|
+
animations: THREE.AnimationClip[];
|
|
31
|
+
}>;
|
|
32
|
+
}): Promise<ClipFile>;
|
|
33
|
+
/**
|
|
34
|
+
* A clip authored on `model`'s skeleton. When the body's hips bone is known
|
|
35
|
+
* (`hips`), a clip that animates it is: clip files often carry a richer rig
|
|
36
|
+
* than the body (the convai-mha-1 packs animate ~580 finger, twist and
|
|
37
|
+
* corrective bones a body may lack), so a share of matching tracks says
|
|
38
|
+
* little. Otherwise, most of its transform tracks must name the body's bones.
|
|
39
|
+
*/
|
|
40
|
+
export declare function targetsModel(clip: THREE.AnimationClip, modelBones: ReadonlySet<string>, hips?: string): boolean;
|
|
41
|
+
/** What became of one requested clip. */
|
|
42
|
+
export interface ClipNote {
|
|
43
|
+
name: string;
|
|
44
|
+
url: string;
|
|
45
|
+
/**
|
|
46
|
+
* `as-is`: on the body's skeleton. `retargeted`: from `from`'s skeleton.
|
|
47
|
+
* `not-in-file`: the file has no animation by that name (`available`).
|
|
48
|
+
* `not-loaded`: the file did not load. `foreign`: another skeleton that
|
|
49
|
+
* could not be retargeted, played as it is. `failed`: retargeting threw.
|
|
50
|
+
*/
|
|
51
|
+
status: 'as-is' | 'retargeted' | 'not-in-file' | 'not-loaded' | 'foreign' | 'failed';
|
|
52
|
+
from?: string;
|
|
53
|
+
available?: string[];
|
|
54
|
+
detail?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The clips `wanted` (clip name → file URL) asks for, from the loaded
|
|
58
|
+
* `files`, ready to register on `model`.
|
|
59
|
+
*
|
|
60
|
+
* - On the body's own skeleton: the animation with that name, or the file's
|
|
61
|
+
* only animation, as it is.
|
|
62
|
+
* - On a recognised foreign skeleton: the animation with that name, or the
|
|
63
|
+
* file's only animation (Mixamo names every clip `mixamo.com`), retargeted
|
|
64
|
+
* onto the body and named as asked.
|
|
65
|
+
* - Otherwise: the animation with that name, as it is.
|
|
66
|
+
*
|
|
67
|
+
* The target is read from `model`'s bind pose once and shared by every
|
|
68
|
+
* retargeted clip.
|
|
69
|
+
*/
|
|
70
|
+
export declare function gatherClips(model: THREE.Object3D, files: ReadonlyMap<string, ClipFile>, wanted: ReadonlyMap<string, string>, opts?: Omit<RetargetOptions, 'name'>, notes?: ClipNote[]): THREE.AnimationClip[];
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clip files → the clips a body plays.
|
|
3
|
+
*
|
|
4
|
+
* A clip authored on the body's own skeleton plays as it is (its tracks name
|
|
5
|
+
* the body's bones). A clip from a skeleton `readRigNames` recognises —
|
|
6
|
+
* Mixamo, the Unreal mannequin, Rigify — is retargeted onto the body at load
|
|
7
|
+
* time (`retarget.ts`). Anything else is handed on unchanged, and
|
|
8
|
+
* `AnimationController.register` reports what it cannot bind.
|
|
9
|
+
*
|
|
10
|
+
* FBX files are read with three's `FBXLoader`, imported only when an `.fbx`
|
|
11
|
+
* URL is actually requested, so an app that never loads FBX does not ship it.
|
|
12
|
+
*/
|
|
13
|
+
import * as THREE from 'three';
|
|
14
|
+
import { createAnimationSource, createTargetRig, readRigNames, retargetClip } from './retarget.js';
|
|
15
|
+
/** True for a URL whose path ends in `.fbx` (any case, query and hash ignored). */
|
|
16
|
+
export const isFbxUrl = (url) => /\.fbx$/i.test(url.split(/[?#]/, 1)[0]);
|
|
17
|
+
/**
|
|
18
|
+
* Loads one animation file: FBX through a lazily imported `FBXLoader`,
|
|
19
|
+
* anything else through `gltf` (which carries the app's Draco decoder).
|
|
20
|
+
*/
|
|
21
|
+
export async function loadClipFile(url, gltf) {
|
|
22
|
+
if (isFbxUrl(url)) {
|
|
23
|
+
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
|
24
|
+
const group = await new FBXLoader().loadAsync(url);
|
|
25
|
+
return { root: group, animations: group.animations };
|
|
26
|
+
}
|
|
27
|
+
const g = await gltf.loadAsync(url);
|
|
28
|
+
return { root: g.scene, animations: g.animations };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A clip authored on `model`'s skeleton. When the body's hips bone is known
|
|
32
|
+
* (`hips`), a clip that animates it is: clip files often carry a richer rig
|
|
33
|
+
* than the body (the convai-mha-1 packs animate ~580 finger, twist and
|
|
34
|
+
* corrective bones a body may lack), so a share of matching tracks says
|
|
35
|
+
* little. Otherwise, most of its transform tracks must name the body's bones.
|
|
36
|
+
*/
|
|
37
|
+
export function targetsModel(clip, modelBones, hips) {
|
|
38
|
+
let bones = 0;
|
|
39
|
+
let hit = 0;
|
|
40
|
+
for (const t of clip.tracks) {
|
|
41
|
+
const { nodeName, propertyName } = THREE.PropertyBinding.parseTrackName(t.name);
|
|
42
|
+
if (propertyName !== 'quaternion' && propertyName !== 'position' && propertyName !== 'scale')
|
|
43
|
+
continue;
|
|
44
|
+
if (hips && nodeName === hips)
|
|
45
|
+
return true;
|
|
46
|
+
bones++;
|
|
47
|
+
if (modelBones.has(nodeName))
|
|
48
|
+
hit++;
|
|
49
|
+
}
|
|
50
|
+
return bones > 0 && hit * 2 >= bones;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The clips `wanted` (clip name → file URL) asks for, from the loaded
|
|
54
|
+
* `files`, ready to register on `model`.
|
|
55
|
+
*
|
|
56
|
+
* - On the body's own skeleton: the animation with that name, or the file's
|
|
57
|
+
* only animation, as it is.
|
|
58
|
+
* - On a recognised foreign skeleton: the animation with that name, or the
|
|
59
|
+
* file's only animation (Mixamo names every clip `mixamo.com`), retargeted
|
|
60
|
+
* onto the body and named as asked.
|
|
61
|
+
* - Otherwise: the animation with that name, as it is.
|
|
62
|
+
*
|
|
63
|
+
* The target is read from `model`'s bind pose once and shared by every
|
|
64
|
+
* retargeted clip.
|
|
65
|
+
*/
|
|
66
|
+
export function gatherClips(model, files, wanted, opts = {}, notes = []) {
|
|
67
|
+
const modelBones = new Set();
|
|
68
|
+
model.traverse((o) => { if (o.isBone)
|
|
69
|
+
modelBones.add(o.name); });
|
|
70
|
+
const hips = readRigNames([...modelBones])?.fromCanon.get('Hips');
|
|
71
|
+
let target;
|
|
72
|
+
const sources = new Map();
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const [name, url] of wanted) {
|
|
75
|
+
const file = files.get(url);
|
|
76
|
+
if (!file) {
|
|
77
|
+
notes.push({ name, url, status: 'not-loaded' });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const named = file.animations.find((a) => a.name === name);
|
|
81
|
+
const candidate = named ?? (file.animations.length === 1 ? file.animations[0] : undefined);
|
|
82
|
+
if (!candidate) {
|
|
83
|
+
notes.push({ name, url, status: 'not-in-file', available: file.animations.map((a) => a.name) });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (targetsModel(candidate, modelBones, hips)) {
|
|
87
|
+
// A file's only clip is the one asked for, whatever it is called.
|
|
88
|
+
if (named)
|
|
89
|
+
out.push(named);
|
|
90
|
+
else {
|
|
91
|
+
const clip = candidate.clone();
|
|
92
|
+
clip.name = name;
|
|
93
|
+
out.push(clip);
|
|
94
|
+
}
|
|
95
|
+
notes.push({ name, url, status: 'as-is' });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!sources.has(file))
|
|
99
|
+
sources.set(file, createAnimationSource(file.root, file.animations));
|
|
100
|
+
const source = sources.get(file);
|
|
101
|
+
if (source && target === undefined)
|
|
102
|
+
target = createTargetRig(model);
|
|
103
|
+
if (!source || !target) {
|
|
104
|
+
// Reported by the caller (inspectCharacter), with the fix.
|
|
105
|
+
notes.push({ name, url, status: 'foreign', from: source?.names.kind, detail: source ? "the body's skeleton is not one embody can retarget onto" : 'its skeleton is neither the body\'s nor one embody recognises' });
|
|
106
|
+
if (named)
|
|
107
|
+
out.push(named);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
out.push(retargetClip(source, candidate, target, { ...opts, name }));
|
|
112
|
+
notes.push({ name, url, status: 'retargeted', from: source.names.kind });
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
notes.push({ name, url, status: 'failed', from: source.names.kind, detail: e.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* module steps aside entirely.
|
|
14
14
|
*/
|
|
15
15
|
import { ConvaiClient, type BlendshapeQueue, type ConvaiConfig } from '@convai/web-sdk/core';
|
|
16
|
+
import type { VoiceReceiver } from '../speech/voiceClock.js';
|
|
16
17
|
export interface SessionOptions extends ConvaiConfig {
|
|
17
18
|
/**
|
|
18
19
|
* Attach an `AudioRenderer` to the room so the character is audible.
|
|
@@ -31,6 +32,11 @@ export interface SessionHandle {
|
|
|
31
32
|
client: ConvaiClient;
|
|
32
33
|
/** Lipsync frames for the turn currently being spoken. */
|
|
33
34
|
readonly queue: BlendshapeQueue;
|
|
35
|
+
/**
|
|
36
|
+
* The receiver of the character's voice track, read live (it changes on
|
|
37
|
+
* reconnect), or null before one is subscribed. Lipsync follows its clock.
|
|
38
|
+
*/
|
|
39
|
+
voiceReceiver(): VoiceReceiver | null;
|
|
34
40
|
/** Whether this session owns the client (and so should close it). */
|
|
35
41
|
readonly owned: boolean;
|
|
36
42
|
dispose(): Promise<void>;
|
|
@@ -49,3 +55,8 @@ export declare function createSession(opts: SessionOptions): Promise<SessionHand
|
|
|
49
55
|
* owns its own client's lifetime.
|
|
50
56
|
*/
|
|
51
57
|
export declare function adoptSession(client: ConvaiClient): SessionHandle;
|
|
58
|
+
/**
|
|
59
|
+
* The first subscribed remote audio track's RTCRtpReceiver: the character's
|
|
60
|
+
* voice. In a one-character session it is the only remote audio.
|
|
61
|
+
*/
|
|
62
|
+
export declare function voiceReceiverOf(client: Pick<ConvaiClient, 'room'>): VoiceReceiver | null;
|
package/dist/connect/session.js
CHANGED
|
@@ -61,6 +61,7 @@ function wrapSession(client, renderer, owned) {
|
|
|
61
61
|
get queue() {
|
|
62
62
|
return client.blendshapeQueue;
|
|
63
63
|
},
|
|
64
|
+
voiceReceiver: () => voiceReceiverOf(client),
|
|
64
65
|
async dispose() {
|
|
65
66
|
// Idempotent: React StrictMode double-invokes cleanup, and a second
|
|
66
67
|
// disconnect on an already-closed client throws.
|
|
@@ -74,3 +75,24 @@ function wrapSession(client, renderer, owned) {
|
|
|
74
75
|
},
|
|
75
76
|
};
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The first subscribed remote audio track's RTCRtpReceiver: the character's
|
|
80
|
+
* voice. In a one-character session it is the only remote audio.
|
|
81
|
+
*/
|
|
82
|
+
export function voiceReceiverOf(client) {
|
|
83
|
+
let room;
|
|
84
|
+
try {
|
|
85
|
+
room = client.room;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
for (const p of room?.remoteParticipants?.values() ?? []) {
|
|
91
|
+
for (const pub of p.audioTrackPublications?.values() ?? []) {
|
|
92
|
+
const receiver = pub.track?.receiver;
|
|
93
|
+
if (receiver && typeof receiver.getSynchronizationSources === 'function')
|
|
94
|
+
return receiver;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,11 +28,25 @@ export { createCharacter } from './character/createCharacter.js';
|
|
|
28
28
|
export type { CharacterHandle, CreateCharacterOptions } from './character/createCharacter.js';
|
|
29
29
|
export { AnimationController } from './clips/animations.js';
|
|
30
30
|
export type { ClipTransition } from './clips/animations.js';
|
|
31
|
+
export { retargetClip, createTargetRig, createAnimationSource, detectRig, readRigNames, UPRIGHT_DEG } from './clips/retarget.js';
|
|
32
|
+
export type { TargetRig, AnimationSource, RetargetOptions, RetargetReport, RigNames, RigKind } from './clips/retarget.js';
|
|
33
|
+
export { loadClipFile, isFbxUrl } from './clips/sources.js';
|
|
34
|
+
export type { ClipFile, ClipNote } from './clips/sources.js';
|
|
35
|
+
export { inspectModel } from './inspect/inspectModel.js';
|
|
36
|
+
export type { InspectModelOptions } from './inspect/inspectModel.js';
|
|
37
|
+
export { formatReport } from './inspect/report.js';
|
|
38
|
+
export type { CharacterReport, CharacterIssue, IssueLevel } from './inspect/report.js';
|
|
39
|
+
export { matchMorphs, applyMorphMap, normalizeMorphName, ARKIT_FOR } from './inspect/morphMap.js';
|
|
40
|
+
export type { MorphMatch, FaceNaming } from './inspect/morphMap.js';
|
|
31
41
|
export { LipsyncSystem } from './speech/lipsync.js';
|
|
32
42
|
export type { MouthTuning } from './speech/lipsync.js';
|
|
33
43
|
export { compileCorrectiveMap, applyCorrectiveMap, CORRECTIVE_MAP_FORMAT } from './speech/correctiveMap.js';
|
|
34
44
|
export type { CorrectiveMap, CorrectiveMapTable } from './speech/correctiveMap.js';
|
|
35
45
|
export { createUnitNormalizer } from './rig/units.js';
|
|
46
|
+
export { createPortraitRig, placePortraitRig, keyAzimuth, PORTRAIT_TONE } from './render/portraitRig.js';
|
|
47
|
+
export type { PortraitRig } from './render/portraitRig.js';
|
|
48
|
+
export { applySurfaces, shadeScalp, shadeHairDepth, meanSkinColor, patchSkinShader, poreDetailTexture, LASH_MALE } from './render/surfaces.js';
|
|
49
|
+
export type { SurfaceTuning, SkinTuning, SurfaceReport } from './render/surfaces.js';
|
|
36
50
|
export { collectMaterials } from './decorate/types.js';
|
|
37
51
|
export type { CharacterDecorator, DecoratorContext } from './decorate/types.js';
|
|
38
52
|
export { Pipeline } from './character/pipeline.js';
|
|
@@ -44,6 +58,13 @@ export type { ResolvedRig, MorphBinding } from './rig/resolve.js';
|
|
|
44
58
|
export { GazeSystem } from './motion/gaze.js';
|
|
45
59
|
export type { GazeConfig } from './motion/gaze.js';
|
|
46
60
|
export { EyeGazeSystem } from './motion/eyeGaze.js';
|
|
61
|
+
export { EyeAim } from './motion/eyeAim.js';
|
|
62
|
+
export { restHairOnShoulders } from './motion/hair.js';
|
|
63
|
+
export { VoiceClock } from './speech/voiceClock.js';
|
|
64
|
+
export type { VoiceReceiver } from './speech/voiceClock.js';
|
|
65
|
+
export { MouthFinish } from './speech/mouthFinish.js';
|
|
66
|
+
export { Expression } from './speech/expression.js';
|
|
67
|
+
export type { Mood } from './speech/expression.js';
|
|
47
68
|
export type { EyeGazeConfig } from './motion/eyeGaze.js';
|
|
48
69
|
export { BlinkSystem } from './motion/blink.js';
|
|
49
70
|
export type { BlinkConfig } from './motion/blink.js';
|
|
@@ -52,4 +73,4 @@ export type { BreathingConfig } from './motion/breathe.js';
|
|
|
52
73
|
export { createReducedMotion } from './motion/reducedMotion.js';
|
|
53
74
|
export type { ReducedMotion } from './motion/reducedMotion.js';
|
|
54
75
|
export type { GazeDirection, EyeGazeDirection, GazeSource } from './motion/types.js';
|
|
55
|
-
export type { UnitNormalizer } from './rig/units.js';
|
|
76
|
+
export type { UnitNormalizer, UnitNormalizerOptions, Grounding } from './rig/units.js';
|
package/dist/index.js
CHANGED
|
@@ -25,15 +25,27 @@ export { httpResolver, pickRepresentation, DEFAULT_BASE_URL } from './resolve/re
|
|
|
25
25
|
export { pickModelVariant, detectDevice, MORPH_UNIFORM_RESERVE } from './resolve/modelVariant.js';
|
|
26
26
|
export { createCharacter } from './character/createCharacter.js';
|
|
27
27
|
export { AnimationController } from './clips/animations.js';
|
|
28
|
+
export { retargetClip, createTargetRig, createAnimationSource, detectRig, readRigNames, UPRIGHT_DEG } from './clips/retarget.js';
|
|
29
|
+
export { loadClipFile, isFbxUrl } from './clips/sources.js';
|
|
30
|
+
export { inspectModel } from './inspect/inspectModel.js';
|
|
31
|
+
export { formatReport } from './inspect/report.js';
|
|
32
|
+
export { matchMorphs, applyMorphMap, normalizeMorphName, ARKIT_FOR } from './inspect/morphMap.js';
|
|
28
33
|
export { LipsyncSystem } from './speech/lipsync.js';
|
|
29
34
|
export { compileCorrectiveMap, applyCorrectiveMap, CORRECTIVE_MAP_FORMAT } from './speech/correctiveMap.js';
|
|
30
35
|
export { createUnitNormalizer } from './rig/units.js';
|
|
36
|
+
export { createPortraitRig, placePortraitRig, keyAzimuth, PORTRAIT_TONE } from './render/portraitRig.js';
|
|
37
|
+
export { applySurfaces, shadeScalp, shadeHairDepth, meanSkinColor, patchSkinShader, poreDetailTexture, LASH_MALE } from './render/surfaces.js';
|
|
31
38
|
export { collectMaterials } from './decorate/types.js';
|
|
32
39
|
export { Pipeline } from './character/pipeline.js';
|
|
33
40
|
export { setLookQuat, CONVAI_MHA_1, CC4_LEGACY, CC4_BASIS, UE_BASIS, REGISTERED_PROFILES } from './rig/profile.js';
|
|
34
41
|
export { resolveRig, writeMorph, REQUIRED_BONES } from './rig/resolve.js';
|
|
35
42
|
export { GazeSystem } from './motion/gaze.js';
|
|
36
43
|
export { EyeGazeSystem } from './motion/eyeGaze.js';
|
|
44
|
+
export { EyeAim } from './motion/eyeAim.js';
|
|
45
|
+
export { restHairOnShoulders } from './motion/hair.js';
|
|
46
|
+
export { VoiceClock } from './speech/voiceClock.js';
|
|
47
|
+
export { MouthFinish } from './speech/mouthFinish.js';
|
|
48
|
+
export { Expression } from './speech/expression.js';
|
|
37
49
|
export { BlinkSystem } from './motion/blink.js';
|
|
38
50
|
export { BreathingSystem } from './motion/breathe.js';
|
|
39
51
|
export { createReducedMotion } from './motion/reducedMotion.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check a model before wiring a conversation to it: load it exactly as
|
|
3
|
+
* `createCharacter` would (same shading, morph map, clip gathering), without
|
|
4
|
+
* a connection, and return its report.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const report = await inspectModel('/my-avatar.glb', { animations: { Idle: '/idle.fbx' } })
|
|
8
|
+
* console.log(formatReport(report))
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
import { type CreateCharacterOptions } from '../character/createCharacter.js';
|
|
12
|
+
import type { CharacterReport } from './report.js';
|
|
13
|
+
export type InspectModelOptions = Pick<CreateCharacterOptions, 'animations' | 'morphMap' | 'dracoDecoderPath' | 'idleClip' | 'diagnostics'>;
|
|
14
|
+
export declare function inspectModel(url: string, opts?: InspectModelOptions): Promise<CharacterReport>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check a model before wiring a conversation to it: load it exactly as
|
|
3
|
+
* `createCharacter` would (same shading, morph map, clip gathering), without
|
|
4
|
+
* a connection, and return its report.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const report = await inspectModel('/my-avatar.glb', { animations: { Idle: '/idle.fbx' } })
|
|
8
|
+
* console.log(formatReport(report))
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
import { createCharacter } from '../character/createCharacter.js';
|
|
12
|
+
export async function inspectModel(url, opts = {}) {
|
|
13
|
+
const character = await createCharacter({
|
|
14
|
+
characterId: 'inspect',
|
|
15
|
+
resolver: {
|
|
16
|
+
resolve: async (characterId) => ({ characterId, avatarId: url, representations: [{ rig: 'convai-mha-1', model: [{ url }] }] }),
|
|
17
|
+
},
|
|
18
|
+
connect: false,
|
|
19
|
+
modelVariant: 'full',
|
|
20
|
+
diagnostics: false,
|
|
21
|
+
...opts,
|
|
22
|
+
});
|
|
23
|
+
const { report } = character;
|
|
24
|
+
character.dispose();
|
|
25
|
+
return report;
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Morph maps: a face whose blendshapes are not named as the MetaHuman stream
|
|
3
|
+
* names them, driven anyway.
|
|
4
|
+
*
|
|
5
|
+
* The stream (and so lipsync, blink and eye gaze) addresses MetaHuman
|
|
6
|
+
* expression channels, `CTRL_expressions_jawOpen` and 250 more. A model
|
|
7
|
+
* exported elsewhere carries the same shapes under other names, or similar
|
|
8
|
+
* shapes from another standard:
|
|
9
|
+
*
|
|
10
|
+
* · renamed MetaHuman: a prefix (`head_lod0.CTRL_expressions_jawOpen`), the
|
|
11
|
+
* short name (`jawOpen`), or Character Creator's extended set, which is
|
|
12
|
+
* MetaHuman's channel list in snake case (`Mouth_Corner_Pull_L`);
|
|
13
|
+
* · ARKit's 52 (Ready Player Me, Apple face capture, most VRM and CC
|
|
14
|
+
* exports): different shapes for the same motions, mapped by meaning
|
|
15
|
+
* (`mouthCornerPullL` → `mouthSmileLeft`), several channels onto one shape
|
|
16
|
+
* where ARKit has one (the four funnel quadrants → `mouthFunnel`);
|
|
17
|
+
* · visemes (`viseme_aa`, `V_Open`): mouth poses per sound. The stream sends
|
|
18
|
+
* expression channels, not visemes, so nothing maps onto them.
|
|
19
|
+
*
|
|
20
|
+
* A map is channel short name → the model's morph name, and applying it
|
|
21
|
+
* aliases the channel's full name onto that morph in every mesh's
|
|
22
|
+
* `morphTargetDictionary`, so every system that resolves by name finds it.
|
|
23
|
+
* Names are compared ignoring case, separators, side spelling (`Left`/`L`), a
|
|
24
|
+
* namespace (`mesh.`, `mesh:`, `mesh|`), and a numbering prefix (`A25_`).
|
|
25
|
+
*/
|
|
26
|
+
import type * as THREE from 'three';
|
|
27
|
+
/** Every MetaHuman expression channel, short name (no prefix). */
|
|
28
|
+
export declare const CHANNELS: readonly string[];
|
|
29
|
+
/** MetaHuman channel → the ARKit blendshape that carries the same motion. */
|
|
30
|
+
export declare const ARKIT_FOR: Readonly<Record<string, string>>;
|
|
31
|
+
/** A morph name reduced to what identifies it. */
|
|
32
|
+
export declare function normalizeMorphName(name: string): string;
|
|
33
|
+
/** Every morph name on any mesh under `root`. */
|
|
34
|
+
export declare function morphNames(root: THREE.Object3D): Set<string>;
|
|
35
|
+
export type FaceNaming = 'metahuman' | 'metahuman-renamed' | 'arkit' | 'visemes' | 'custom' | 'none';
|
|
36
|
+
export interface MorphMatch {
|
|
37
|
+
/** Channels the model already carries under their MetaHuman names. */
|
|
38
|
+
native: number;
|
|
39
|
+
/** Channel → model morph, for the rest that can be found. */
|
|
40
|
+
map: Record<string, string>;
|
|
41
|
+
/** How each mapped channel was found. */
|
|
42
|
+
via: Record<string, 'renamed' | 'arkit'>;
|
|
43
|
+
naming: FaceNaming;
|
|
44
|
+
/** The model's viseme shapes, which the stream cannot drive. */
|
|
45
|
+
visemes: string[];
|
|
46
|
+
}
|
|
47
|
+
/** Find this model's shape for every MetaHuman channel it does not carry by name. */
|
|
48
|
+
export declare function matchMorphs(root: THREE.Object3D): MorphMatch;
|
|
49
|
+
/**
|
|
50
|
+
* Alias each channel's full MetaHuman name onto the model's morph, on every
|
|
51
|
+
* mesh carrying that morph. A name the mesh already has is never replaced.
|
|
52
|
+
* Returns the channels aliased on at least one mesh.
|
|
53
|
+
*/
|
|
54
|
+
export declare function applyMorphMap(root: THREE.Object3D, map: Readonly<Record<string, string>>): string[];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { MHA_PROFILE } from '@convai/web-sdk/lipsync-helpers';
|
|
2
|
+
const PREFIX = 'CTRL_expressions_';
|
|
3
|
+
/** Every MetaHuman expression channel, short name (no prefix). */
|
|
4
|
+
export const CHANNELS = MHA_PROFILE.channelNames.map((n) => n.replace(PREFIX, ''));
|
|
5
|
+
/** MetaHuman channel → the ARKit blendshape that carries the same motion. */
|
|
6
|
+
export const ARKIT_FOR = (() => {
|
|
7
|
+
const t = {
|
|
8
|
+
jawOpen: 'jawOpen', jawLeft: 'jawLeft', jawRight: 'jawRight', jawFwd: 'jawForward',
|
|
9
|
+
mouthLeft: 'mouthLeft', mouthRight: 'mouthRight',
|
|
10
|
+
};
|
|
11
|
+
// Not mouthLipsTogether → mouthClose: the stream keeps lipsTogether at or
|
|
12
|
+
// above jawOpen (a MetaHuman coupling, harmless there because the shape
|
|
13
|
+
// only acts with the jaw), and ARKit's mouthClose seals the lips outright,
|
|
14
|
+
// so the mapped mouth pinched shut whenever it opened (measured live:
|
|
15
|
+
// mouthClose peaked at 0.80 against a 0.19 jaw on a Ready Player Me head).
|
|
16
|
+
// Closures come from mouthPress and MouthFinish's jaw instead.
|
|
17
|
+
const quad = ['UL', 'UR', 'DL', 'DR'];
|
|
18
|
+
for (const q of quad) {
|
|
19
|
+
t[`mouthFunnel${q}`] = 'mouthFunnel';
|
|
20
|
+
t[`mouthLipsPurse${q}`] = 'mouthPucker';
|
|
21
|
+
t[`mouthPress${q}`] = q.endsWith('L') ? 'mouthPressLeft' : 'mouthPressRight';
|
|
22
|
+
}
|
|
23
|
+
for (const [s, side] of [['L', 'Left'], ['R', 'Right']]) {
|
|
24
|
+
Object.assign(t, {
|
|
25
|
+
[`browDown${s}`]: `browDown${side}`,
|
|
26
|
+
[`browRaiseIn${s}`]: 'browInnerUp',
|
|
27
|
+
[`browRaiseOuter${s}`]: `browOuterUp${side}`,
|
|
28
|
+
[`eyeBlink${s}`]: `eyeBlink${side}`,
|
|
29
|
+
[`eyeWiden${s}`]: `eyeWide${side}`,
|
|
30
|
+
[`eyeSquintInner${s}`]: `eyeSquint${side}`,
|
|
31
|
+
[`eyeCheekRaise${s}`]: `cheekSquint${side}`,
|
|
32
|
+
[`eyeLookUp${s}`]: `eyeLookUp${side}`,
|
|
33
|
+
[`eyeLookDown${s}`]: `eyeLookDown${side}`,
|
|
34
|
+
[`mouthCornerPull${s}`]: `mouthSmile${side}`,
|
|
35
|
+
[`mouthCornerDepress${s}`]: `mouthFrown${side}`,
|
|
36
|
+
[`mouthDimple${s}`]: `mouthDimple${side}`,
|
|
37
|
+
[`mouthStretch${s}`]: `mouthStretch${side}`,
|
|
38
|
+
[`mouthUpperLipRaise${s}`]: `mouthUpperUp${side}`,
|
|
39
|
+
[`mouthLowerLipDepress${s}`]: `mouthLowerDown${side}`,
|
|
40
|
+
[`mouthUpperLipRollIn${s}`]: 'mouthRollUpper',
|
|
41
|
+
[`mouthLowerLipRollIn${s}`]: 'mouthRollLower',
|
|
42
|
+
[`mouthCheekBlow${s}`]: 'cheekPuff',
|
|
43
|
+
[`noseWrinkle${s}`]: `noseSneer${side}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// MetaHuman names eye look by the character's own left/right; ARKit by
|
|
47
|
+
// in (toward the nose) and out. The left eye looking left looks out.
|
|
48
|
+
Object.assign(t, {
|
|
49
|
+
eyeLookLeftL: 'eyeLookOutLeft', eyeLookRightL: 'eyeLookInLeft',
|
|
50
|
+
eyeLookLeftR: 'eyeLookInRight', eyeLookRightR: 'eyeLookOutRight',
|
|
51
|
+
jawChinRaiseDL: 'mouthShrugLower', jawChinRaiseDR: 'mouthShrugLower',
|
|
52
|
+
jawChinRaiseUL: 'mouthShrugUpper', jawChinRaiseUR: 'mouthShrugUpper',
|
|
53
|
+
});
|
|
54
|
+
return t;
|
|
55
|
+
})();
|
|
56
|
+
/** The 52 ARKit names, for recognising an ARKit face. */
|
|
57
|
+
const ARKIT_NAMES = new Set([...Object.values(ARKIT_FOR), 'tongueOut', 'mouthClose']);
|
|
58
|
+
/** A morph name reduced to what identifies it. */
|
|
59
|
+
export function normalizeMorphName(name) {
|
|
60
|
+
return name
|
|
61
|
+
.replace(/^.*[.:|]/, '') // namespace
|
|
62
|
+
.replace(/^[A-Za-z]\d+_/, '') // numbering, as A25_Jaw_Open
|
|
63
|
+
.replace(/^CTRL_expressions_?/i, '')
|
|
64
|
+
.replace(/^blendShape\d*_?/i, '')
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9]/g, '')
|
|
67
|
+
.replace(/left/g, 'l')
|
|
68
|
+
.replace(/right/g, 'r');
|
|
69
|
+
}
|
|
70
|
+
/** Every morph name on any mesh under `root`. */
|
|
71
|
+
export function morphNames(root) {
|
|
72
|
+
const out = new Set();
|
|
73
|
+
root.traverse((o) => {
|
|
74
|
+
const dict = o.morphTargetDictionary;
|
|
75
|
+
if (dict)
|
|
76
|
+
for (const k of Object.keys(dict))
|
|
77
|
+
out.add(k);
|
|
78
|
+
});
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** Find this model's shape for every MetaHuman channel it does not carry by name. */
|
|
82
|
+
export function matchMorphs(root) {
|
|
83
|
+
const names = morphNames(root);
|
|
84
|
+
const byNorm = new Map();
|
|
85
|
+
for (const n of names)
|
|
86
|
+
if (!byNorm.has(normalizeMorphName(n)))
|
|
87
|
+
byNorm.set(normalizeMorphName(n), n);
|
|
88
|
+
const map = {};
|
|
89
|
+
const via = {};
|
|
90
|
+
let native = 0;
|
|
91
|
+
for (const c of CHANNELS) {
|
|
92
|
+
if (names.has(PREFIX + c)) {
|
|
93
|
+
native++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const direct = byNorm.get(normalizeMorphName(c));
|
|
97
|
+
if (direct) {
|
|
98
|
+
map[c] = direct;
|
|
99
|
+
via[c] = 'renamed';
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const arkit = ARKIT_FOR[c] && byNorm.get(normalizeMorphName(ARKIT_FOR[c]));
|
|
103
|
+
if (arkit) {
|
|
104
|
+
map[c] = arkit;
|
|
105
|
+
via[c] = 'arkit';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const visemes = [...names].filter((n) => /^(viseme_|V_)/i.test(n.replace(/^.*[.:|]/, '')));
|
|
109
|
+
const arkitHits = [...ARKIT_NAMES].filter((a) => byNorm.has(normalizeMorphName(a))).length;
|
|
110
|
+
const renamed = Object.values(via).filter((v) => v === 'renamed').length;
|
|
111
|
+
const naming = names.size === 0 ? 'none'
|
|
112
|
+
: native >= 100 ? 'metahuman'
|
|
113
|
+
: renamed >= 60 ? 'metahuman-renamed'
|
|
114
|
+
: arkitHits >= 30 ? 'arkit'
|
|
115
|
+
: visemes.length > 0 && visemes.length * 2 >= names.size ? 'visemes'
|
|
116
|
+
: 'custom';
|
|
117
|
+
return { native, map, via, naming, visemes };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Alias each channel's full MetaHuman name onto the model's morph, on every
|
|
121
|
+
* mesh carrying that morph. A name the mesh already has is never replaced.
|
|
122
|
+
* Returns the channels aliased on at least one mesh.
|
|
123
|
+
*/
|
|
124
|
+
export function applyMorphMap(root, map) {
|
|
125
|
+
const done = new Set();
|
|
126
|
+
root.traverse((o) => {
|
|
127
|
+
const dict = o.morphTargetDictionary;
|
|
128
|
+
if (!dict)
|
|
129
|
+
return;
|
|
130
|
+
for (const [channel, morph] of Object.entries(map)) {
|
|
131
|
+
const full = channel.startsWith(PREFIX) ? channel : PREFIX + channel;
|
|
132
|
+
const index = dict[morph];
|
|
133
|
+
if (index === undefined || dict[full] !== undefined)
|
|
134
|
+
continue;
|
|
135
|
+
dict[full] = index;
|
|
136
|
+
done.add(channel.replace(PREFIX, ''));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return [...done];
|
|
140
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a loaded character can and cannot do, said to the developer who
|
|
3
|
+
* brought it, with the config that fixes what can be fixed.
|
|
4
|
+
*
|
|
5
|
+
* Embody drives a body by names: bones for gaze and breathing, MetaHuman
|
|
6
|
+
* expression channels for lipsync, blink and eye gaze, clip tracks for
|
|
7
|
+
* animation. A model from anywhere else loads and renders whatever its names
|
|
8
|
+
* are, and the parts that do not match simply never move. That failure is
|
|
9
|
+
* silent, so this says it: once at load, as `handle.report`, and on the
|
|
10
|
+
* console when anything is wrong.
|
|
11
|
+
*
|
|
12
|
+
* Levels: `error`, a core behaviour cannot work (no mouth shapes, a clip that
|
|
13
|
+
* landed on nothing); `warning`, it works partly or not at all for a part
|
|
14
|
+
* (no blink, no gaze bones); `info`, what was done for it (a morph map
|
|
15
|
+
* applied, clips retargeted).
|
|
16
|
+
*/
|
|
17
|
+
import * as THREE from 'three';
|
|
18
|
+
import type { ClipNote } from '../clips/sources.js';
|
|
19
|
+
import { type ResolvedRig } from '../rig/resolve.js';
|
|
20
|
+
import { type FaceNaming, type MorphMatch } from './morphMap.js';
|
|
21
|
+
export type IssueLevel = 'error' | 'warning' | 'info';
|
|
22
|
+
export interface CharacterIssue {
|
|
23
|
+
level: IssueLevel;
|
|
24
|
+
area: 'face' | 'skeleton' | 'animation';
|
|
25
|
+
message: string;
|
|
26
|
+
/** Config or a change to the file that fixes it, when there is one. */
|
|
27
|
+
fix?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface CharacterReport {
|
|
30
|
+
/** No errors: everything embody needs was found or mapped. */
|
|
31
|
+
ok: boolean;
|
|
32
|
+
face: {
|
|
33
|
+
/** Morph targets across the model's meshes (distinct names). */
|
|
34
|
+
morphTargets: number;
|
|
35
|
+
naming: FaceNaming;
|
|
36
|
+
/** MetaHuman channels the face answers to, natively or through the map. */
|
|
37
|
+
channels: {
|
|
38
|
+
bound: number;
|
|
39
|
+
of: number;
|
|
40
|
+
};
|
|
41
|
+
/** Speech motions, and whether the face can make each. */
|
|
42
|
+
mouth: Record<string, boolean>;
|
|
43
|
+
blink: boolean;
|
|
44
|
+
eyeLook: boolean;
|
|
45
|
+
/** The morph map in effect (channel → the model's morph name). */
|
|
46
|
+
morphMap: Record<string, string>;
|
|
47
|
+
};
|
|
48
|
+
skeleton: {
|
|
49
|
+
kind: 'convai-mha-1' | 'unreal' | 'mixamo' | 'rigify' | 'character-creator' | 'unknown' | 'none';
|
|
50
|
+
bones: number;
|
|
51
|
+
/** Bones gaze and breathing drive that the model lacks. */
|
|
52
|
+
missing: string[];
|
|
53
|
+
};
|
|
54
|
+
animations: {
|
|
55
|
+
clips: (ClipNote & {
|
|
56
|
+
tracks?: number;
|
|
57
|
+
dropped?: number;
|
|
58
|
+
})[];
|
|
59
|
+
/** The clip playing at rest, or null (the body holds its bind pose). */
|
|
60
|
+
idle: string | null;
|
|
61
|
+
};
|
|
62
|
+
issues: CharacterIssue[];
|
|
63
|
+
}
|
|
64
|
+
export interface InspectInput {
|
|
65
|
+
root: THREE.Object3D;
|
|
66
|
+
rig: ResolvedRig;
|
|
67
|
+
match: MorphMatch;
|
|
68
|
+
/** The map applied, and whether embody chose it (`auto`) or the app passed it. */
|
|
69
|
+
morphMap: {
|
|
70
|
+
map: Record<string, string>;
|
|
71
|
+
source: 'auto' | 'app' | 'off';
|
|
72
|
+
unknown: string[];
|
|
73
|
+
};
|
|
74
|
+
clipNotes: ClipNote[];
|
|
75
|
+
registered: {
|
|
76
|
+
name: string;
|
|
77
|
+
kept: number;
|
|
78
|
+
dropped: number;
|
|
79
|
+
}[];
|
|
80
|
+
idle: string | null;
|
|
81
|
+
/** Clips taken from the model file itself because none were declared. */
|
|
82
|
+
embedded: number;
|
|
83
|
+
/** `idleClip` as the app passed it. */
|
|
84
|
+
idleRequested?: string;
|
|
85
|
+
}
|
|
86
|
+
export declare function inspectCharacter(input: InspectInput): CharacterReport;
|
|
87
|
+
/** The report as console text: a summary line, then each issue with its fix. */
|
|
88
|
+
export declare function formatReport(report: CharacterReport, label?: string): string;
|