@convai/embody 1.0.1 → 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 +30 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @convai/embody
|
|
2
2
|
|
|
3
|
+
## 1.1.0-beta.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 62874a4: Characters at the studio films' quality under any app lighting, lip sync that stays on the voice, and a check of any model at load.
|
|
8
|
+
|
|
9
|
+
**Lip sync**
|
|
10
|
+
|
|
11
|
+
- The mouth follows the voice's own clock. Each reply anchors on the voice's first sound and the stream's first mouth movement (any mouth channel, so a reply opening on rounded or closed lips no longer runs a second early), with one consistent lead on every reply.
|
|
12
|
+
- A reply ends with one smooth close: no pop after the last word, and a stream held under the voice's tail is not mistaken for a stall.
|
|
13
|
+
- Voices that arrive in chunks with stalls between them (OpenAI voices) keep their lips on the sound: after a stall the mouth advances only over frames that were waiting, and frames that arrive after a dry spell start at the voice's next onset instead of a catch-up burst.
|
|
14
|
+
- No voice plays on a still mouth: when the stream runs dry mid-reply the voice level drives the mouth until frames return, eased in and out.
|
|
15
|
+
- The voice level is read from inbound-rtp stats when RTP carries none, and ignored until the voice is actually played out (autoplay-blocked pages no longer flutter the mouth).
|
|
16
|
+
|
|
17
|
+
**Rendering**
|
|
18
|
+
|
|
19
|
+
- Skin, hair, eyes and teeth take the app's lights: physically based skin with light wrap and pores, hair drawn as coverage with faint hair-coloured highlights and depth toward the scalp, living eyes with a measured iris and a catchlight, lit teeth shaded by the mouth, shadows on skin that read as shade.
|
|
20
|
+
- The scalp under the hair is shaded in the hair's colour; no band under the hairline, no bare parting, no grey patch at a high hairline.
|
|
21
|
+
- `PortraitLights` for `ConvaiCharacter`'s own canvas and `createPortraitRig` / `placePortraitRig` for plain three.js scenes: a portrait rig that follows the camera.
|
|
22
|
+
- A male record (the body manifest's `gender`) gets a male look: lashes at the texture's own alpha, a tighter and less pink skin response with more pore, less light wrap, and a `steady` resting mood. Every value is overridable (`SurfaceTuning.gender`, `lashOpacity`, `SkinTuning.roughness`/`sheen`/`sheenColor`/`specular`/`tint`, `mouth.expression`).
|
|
23
|
+
- Normals for morphed faces that ship no blend-shape normals (`morphNormals`, on by default): closed eyelids no longer shade black.
|
|
24
|
+
- Hair never moves into the body; hair physics was removed in favour of resting hair on the shoulders.
|
|
25
|
+
|
|
26
|
+
**Models and motion**
|
|
27
|
+
|
|
28
|
+
- `inspectModel(url, options)` and `character.report` check any GLB at load: morph naming and mapping (`morphMap`, auto for MetaHuman, Character Creator and ARKit), what the face cannot do, the skeleton, and every animation clip, each problem with the config or export change that fixes it. `diagnostics` prints it.
|
|
29
|
+
- Mixamo and Rigify clips are retargeted onto the body at load (`animations`).
|
|
30
|
+
- Gaze moves the eyes, then the neck, then the torso, added to the clip pose; each eye is aimed from the body's own eye shapes.
|
|
31
|
+
- Scale is measured once and the soles are grounded, not the ankle.
|
|
32
|
+
|
|
3
33
|
## 1.0.1
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -190,21 +190,25 @@ load-bearing, not incidental:
|
|
|
190
190
|
|
|
191
191
|
| Stage | System | Why here |
|
|
192
192
|
|---|---|---|
|
|
193
|
-
| 0
|
|
194
|
-
| 1 mixer | animation clips |
|
|
195
|
-
| 2 gaze | `GazeSystem` | **
|
|
193
|
+
| 0 prepare | gaze restore | undoes last frame's gaze turn on bones the clip will not rewrite (scale is measured once at load) |
|
|
194
|
+
| 1 mixer → ground | animation clips, then grounding | clips drive the skeleton, then that pose's soles are put on the floor |
|
|
195
|
+
| 2 gaze | `GazeSystem` | eyes-then-neck-then-torso turns **added** to the clip's pose, so its sway and gestures stay |
|
|
196
196
|
| 3 additive | `BreathingSystem` | added *after* the mixer so nothing accumulates |
|
|
197
|
-
| 4 ik / 5 attach | *(reserved)* |
|
|
197
|
+
| 4 ik / 5 attach | *(reserved)* | |
|
|
198
198
|
| 6 morph | lipsync → `EyeGazeSystem` → `BlinkSystem` | blink must run after lipsync to win the eyelid channels |
|
|
199
199
|
|
|
200
|
-
- **Gaze** —
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
- **Gaze** — eyes, then neck, then body: the eyes take small changes, the head
|
|
201
|
+
follows smoothly beyond a few degrees (or shifts quickly, eye-led, on a large
|
|
202
|
+
jump), and the torso comes round only when the neck has been twisted far for a
|
|
203
|
+
moment. Added to the clip's pose, with its fast head sway steadied while
|
|
204
|
+
fixating, and brief glances away while listening.
|
|
203
205
|
- **Breathing** — asymmetric waveform, chest leading the waist, composed onto
|
|
204
206
|
whatever the mixer wrote.
|
|
205
207
|
- **Blink** — asymmetric close/hold/reopen envelope, resting droop that wanders
|
|
206
208
|
rather than loops, partial and double blinks, lids following downward gaze.
|
|
207
|
-
- **Eye gaze** — morph-only on `convai-mha-1`, which has no eye bones.
|
|
209
|
+
- **Eye gaze** — morph-only on `convai-mha-1`, which has no eye bones. Each eye
|
|
210
|
+
is aimed at the gaze point from the body's own measured eye shapes, with
|
|
211
|
+
micro-saccades while holding eye contact.
|
|
208
212
|
- **`prefers-reduced-motion`** damps every additive layer and suppresses
|
|
209
213
|
look-away shifts, while leaving lipsync and clip playback untouched.
|
|
210
214
|
|
|
@@ -7,7 +7,7 @@ import { type CharacterResolver, type CharacterBundle, type Representation, type
|
|
|
7
7
|
import { type ModelVariantPreference } from '../resolve/modelVariant.js';
|
|
8
8
|
import { type SessionOptions } from '../connect/session.js';
|
|
9
9
|
import type { ConvaiClient } from '@convai/web-sdk/core';
|
|
10
|
-
import { type UnitNormalizer } from '../rig/units.js';
|
|
10
|
+
import { type UnitNormalizer, type Grounding } from '../rig/units.js';
|
|
11
11
|
import { AnimationController } from '../clips/animations.js';
|
|
12
12
|
import { LipsyncSystem, type MouthTuning } from '../speech/lipsync.js';
|
|
13
13
|
import type { CorrectiveMap } from '../speech/correctiveMap.js';
|
|
@@ -19,8 +19,10 @@ import { GazeSystem, type GazeConfig } from '../motion/gaze.js';
|
|
|
19
19
|
import { type BreathingConfig } from '../motion/breathe.js';
|
|
20
20
|
import { type EyeGazeConfig } from '../motion/eyeGaze.js';
|
|
21
21
|
import { type FaceMaterialTuning } from '../render/faceMaterials.js';
|
|
22
|
+
import { type SurfaceTuning } from '../render/surfaces.js';
|
|
22
23
|
import { type BlinkConfig } from '../motion/blink.js';
|
|
23
24
|
import { type ReducedMotion } from '../motion/reducedMotion.js';
|
|
25
|
+
import { type CharacterReport } from '../inspect/report.js';
|
|
24
26
|
/**
|
|
25
27
|
* Where the Draco decoder is fetched from. Google's versioned CDN, which is
|
|
26
28
|
* what three's own examples use and is served with permissive CORS.
|
|
@@ -92,8 +94,17 @@ export interface CreateCharacterOptions {
|
|
|
92
94
|
* Full's blendshapes; `full` and `lite` force one. See `pickModelVariant`.
|
|
93
95
|
*/
|
|
94
96
|
modelVariant?: ModelVariantPreference;
|
|
97
|
+
/** Standing height the body is scaled to, metres. Measured once, at load. Default 1.7. */
|
|
95
98
|
targetHeight?: number;
|
|
99
|
+
/** World height of the floor the soles rest on. Default 0. */
|
|
96
100
|
groundY?: number;
|
|
101
|
+
/**
|
|
102
|
+
* `feet` (default): every frame the lowest point of either sole rests on
|
|
103
|
+
* `groundY`, whatever the clip does. `clip`: the body is grounded once at
|
|
104
|
+
* load and the clip's own root motion decides its height after that — for
|
|
105
|
+
* jumps and anything else that should leave the floor.
|
|
106
|
+
*/
|
|
107
|
+
grounding?: Grounding;
|
|
97
108
|
mouth?: MouthTuning;
|
|
98
109
|
/**
|
|
99
110
|
* A corrective map fitted from this body's DNA (`compileCorrectiveMap`).
|
|
@@ -102,7 +113,12 @@ export interface CreateCharacterOptions {
|
|
|
102
113
|
correctiveMap?: CorrectiveMap | null;
|
|
103
114
|
/** Tier-2 customization: run once after load, before first frame. */
|
|
104
115
|
decorators?: CharacterDecorator[];
|
|
105
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Extra local clips (name → url), merged OVER manifest clips by name. A GLB,
|
|
118
|
+
* glTF or FBX file; a clip authored on a Mixamo, Unreal mannequin or Rigify
|
|
119
|
+
* skeleton is retargeted onto the body at load. FBX support is loaded only
|
|
120
|
+
* when an `.fbx` URL is requested.
|
|
121
|
+
*/
|
|
106
122
|
animations?: Record<string, string>;
|
|
107
123
|
/**
|
|
108
124
|
* Which clip to play on load. Defaults to a clip named "idle"
|
|
@@ -115,6 +131,11 @@ export interface CreateCharacterOptions {
|
|
|
115
131
|
gaze?: GazeConfig;
|
|
116
132
|
/** Stage 6 eyelid/blink config. */
|
|
117
133
|
blink?: BlinkConfig;
|
|
134
|
+
/**
|
|
135
|
+
* Recompute normals where morphs move a face that ships no morph normals
|
|
136
|
+
* (closed lids shaded black with their open-eye normals). Default `true`.
|
|
137
|
+
*/
|
|
138
|
+
morphNormals?: boolean;
|
|
118
139
|
/** Stage 3 additive chest/spine breathing config. */
|
|
119
140
|
breathing?: BreathingConfig;
|
|
120
141
|
/** Stage 6 morph-only eye-look config. */
|
|
@@ -136,6 +157,38 @@ export interface CreateCharacterOptions {
|
|
|
136
157
|
* `Eyeocclusion*`).
|
|
137
158
|
*/
|
|
138
159
|
faceMaterials?: boolean | FaceMaterialTuning;
|
|
160
|
+
/**
|
|
161
|
+
* Skin and hair shading, so the body takes the app's lighting the way skin
|
|
162
|
+
* and hair do. On by default: skin becomes physically based (with light
|
|
163
|
+
* wrapping past the shadow edge and pore detail on WebGL), hair and lashes
|
|
164
|
+
* draw with soft, depth-sorted edges, the scalp under the hair is shaded,
|
|
165
|
+
* and textures are sampled anisotropically. Lighting stays the app's.
|
|
166
|
+
*
|
|
167
|
+
* Pass `false` to keep every skin and hair material exactly as shipped, or a
|
|
168
|
+
* `SurfaceTuning` to adjust or switch off parts of it.
|
|
169
|
+
*/
|
|
170
|
+
surfaces?: boolean | SurfaceTuning;
|
|
171
|
+
/**
|
|
172
|
+
* Hair. Long hair rests on the shoulders instead of swinging with the head
|
|
173
|
+
* as one shell (skin weights, every renderer), except where that would push
|
|
174
|
+
* it into the body. `false` leaves hair as shipped. Default on.
|
|
175
|
+
*/
|
|
176
|
+
hair?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* Face shapes under other names. Lipsync, blink and eye gaze address
|
|
179
|
+
* MetaHuman expression channels (`jawOpen`, `eyeBlinkL`, …); this maps
|
|
180
|
+
* channel → the model's own morph name. `'auto'` (default) finds them by
|
|
181
|
+
* name: renamed or prefixed MetaHuman shapes, Character Creator's extended
|
|
182
|
+
* set, and the ARKit 52 by meaning. The map in effect is in
|
|
183
|
+
* `handle.report.face.morphMap`, ready to pass back here to pin or correct.
|
|
184
|
+
* A map you pass is used as it is; `false` uses none.
|
|
185
|
+
*/
|
|
186
|
+
morphMap?: 'auto' | false | Record<string, string>;
|
|
187
|
+
/**
|
|
188
|
+
* Print `handle.report` to the console when the model has errors or
|
|
189
|
+
* warnings: what it cannot do, and the config that fixes it. Default true.
|
|
190
|
+
*/
|
|
191
|
+
diagnostics?: boolean;
|
|
139
192
|
}
|
|
140
193
|
export interface CharacterHandle {
|
|
141
194
|
root: THREE.Object3D;
|
|
@@ -160,6 +213,11 @@ export interface CharacterHandle {
|
|
|
160
213
|
client: ConvaiClient | null;
|
|
161
214
|
/** Set by the app once its camera exists; gaze tracking no-ops without one. */
|
|
162
215
|
camera?: THREE.Camera;
|
|
216
|
+
/**
|
|
217
|
+
* What this model can and cannot do with embody, read at load: face
|
|
218
|
+
* shapes found or mapped, skeleton, clips, and each problem with its fix.
|
|
219
|
+
*/
|
|
220
|
+
report: CharacterReport;
|
|
163
221
|
/**
|
|
164
222
|
* Tick every frame. The lipsync queue comes from this character's own
|
|
165
223
|
* session, so a single-character app passes nothing.
|
|
@@ -185,11 +243,13 @@ export interface CharacterHandle {
|
|
|
185
243
|
* then blink — see the stage-6 comment below and `pipeline.ts`'s header.
|
|
186
244
|
*/
|
|
187
245
|
export declare function buildPipeline(deps: {
|
|
188
|
-
units: Pick<UnitNormalizer, '
|
|
246
|
+
units: Pick<UnitNormalizer, 'ground'>;
|
|
189
247
|
animations: Pick<AnimationController, 'update'>;
|
|
190
248
|
lipsync: Pick<LipsyncSystem, 'update'>;
|
|
191
249
|
rig: ResolvedRig;
|
|
192
|
-
|
|
250
|
+
/** The body, for systems that read its meshes (morph normals). */
|
|
251
|
+
root?: THREE.Object3D;
|
|
252
|
+
}, opts: Pick<CreateCharacterOptions, 'motion' | 'gaze' | 'breathing' | 'eyeGaze' | 'blink' | 'morphNormals'>): {
|
|
193
253
|
pipeline: Pipeline;
|
|
194
254
|
gaze: GazeSystem;
|
|
195
255
|
};
|
|
@@ -202,7 +262,8 @@ export declare function buildPipeline(deps: {
|
|
|
202
262
|
* `requested` is `CreateCharacterOptions.idleClip`: a name to insist on,
|
|
203
263
|
* `false` to start paused, or undefined for the default. Clip names come from
|
|
204
264
|
* the manifest and are not guaranteed, so the default prefers a clip actually
|
|
205
|
-
* called idle
|
|
265
|
+
* called idle, then one whose name contains it (`Idle_Motion`,
|
|
266
|
+
* `Armature|Idle`), and otherwise falls back to whatever the character shipped
|
|
206
267
|
* first — anything is better than the bind pose.
|
|
207
268
|
*
|
|
208
269
|
* A requested name that the character does not have returns null rather than
|
|
@@ -5,6 +5,7 @@ import { pickModelVariant } from '../resolve/modelVariant.js';
|
|
|
5
5
|
import { createSession, adoptSession } from '../connect/session.js';
|
|
6
6
|
import { createUnitNormalizer } from '../rig/units.js';
|
|
7
7
|
import { AnimationController } from '../clips/animations.js';
|
|
8
|
+
import { loadClipFile, gatherClips } from '../clips/sources.js';
|
|
8
9
|
import { LipsyncSystem } from '../speech/lipsync.js';
|
|
9
10
|
import { collectMaterials } from '../decorate/types.js';
|
|
10
11
|
import { Pipeline } from './pipeline.js';
|
|
@@ -14,8 +15,14 @@ import { GazeSystem } from '../motion/gaze.js';
|
|
|
14
15
|
import { BreathingSystem } from '../motion/breathe.js';
|
|
15
16
|
import { EyeGazeSystem } from '../motion/eyeGaze.js';
|
|
16
17
|
import { applyFaceMaterials } from '../render/faceMaterials.js';
|
|
18
|
+
import { applySurfaces, shadeScalp, shadeHairDepth } from '../render/surfaces.js';
|
|
17
19
|
import { BlinkSystem } from '../motion/blink.js';
|
|
20
|
+
import { MorphNormals } from '../motion/morphNormals.js';
|
|
18
21
|
import { createReducedMotion } from '../motion/reducedMotion.js';
|
|
22
|
+
import { restHairOnShoulders } from '../motion/hair.js';
|
|
23
|
+
import { VoiceClock } from '../speech/voiceClock.js';
|
|
24
|
+
import { applyMorphMap, matchMorphs, morphNames } from '../inspect/morphMap.js';
|
|
25
|
+
import { inspectCharacter, formatReport } from '../inspect/report.js';
|
|
19
26
|
/**
|
|
20
27
|
* Where the Draco decoder is fetched from. Google's versioned CDN, which is
|
|
21
28
|
* what three's own examples use and is served with permissive CORS.
|
|
@@ -34,15 +41,19 @@ export const DEFAULT_DRACO_DECODER_PATH = 'https://www.gstatic.com/draco/version
|
|
|
34
41
|
* then blink — see the stage-6 comment below and `pipeline.ts`'s header.
|
|
35
42
|
*/
|
|
36
43
|
export function buildPipeline(deps, opts) {
|
|
37
|
-
const { units, animations, lipsync } = deps;
|
|
44
|
+
const { units, animations, lipsync, root } = deps;
|
|
38
45
|
const pipeline = new Pipeline();
|
|
39
|
-
// Stage 0
|
|
40
|
-
|
|
41
|
-
//
|
|
46
|
+
// Stage 0 has no per-frame work: scale is measured once, at load, by
|
|
47
|
+
// createUnitNormalizer. Stage 1 — clips drive the skeleton, then the pose
|
|
48
|
+
// they produced is put on the ground (registration order is the contract:
|
|
49
|
+
// grounding must read this frame's pose, not last frame's).
|
|
42
50
|
pipeline.add({ stage: 1, update: (dt) => animations.update(dt) });
|
|
51
|
+
pipeline.add({ stage: 1, update: () => units.ground() });
|
|
43
52
|
const gaze = new GazeSystem(opts.gaze);
|
|
44
53
|
if (opts.motion !== false) {
|
|
45
|
-
|
|
54
|
+
// Stage 0 — undo last frame's turns on bones the mixer will not rewrite.
|
|
55
|
+
pipeline.add({ stage: 0, update: () => gaze.beforeMixer() });
|
|
56
|
+
pipeline.add(gaze); // 2 — added to the clip pose
|
|
46
57
|
pipeline.add(new BreathingSystem(opts.breathing)); // 3 — additive
|
|
47
58
|
}
|
|
48
59
|
// Stage 6 — ORDER IS THE CONTRACT: lipsync, then eye gaze, then blink.
|
|
@@ -51,6 +62,9 @@ export function buildPipeline(deps, opts) {
|
|
|
51
62
|
pipeline.add(new EyeGazeSystem(gaze, opts.eyeGaze));
|
|
52
63
|
pipeline.add(new BlinkSystem(opts.blink, gaze));
|
|
53
64
|
}
|
|
65
|
+
// Last in stage 6, after every morph writer: normals for what they moved.
|
|
66
|
+
if (opts.morphNormals !== false && root)
|
|
67
|
+
pipeline.add(new MorphNormals(root));
|
|
54
68
|
return { pipeline, gaze };
|
|
55
69
|
}
|
|
56
70
|
/**
|
|
@@ -62,7 +76,8 @@ export function buildPipeline(deps, opts) {
|
|
|
62
76
|
* `requested` is `CreateCharacterOptions.idleClip`: a name to insist on,
|
|
63
77
|
* `false` to start paused, or undefined for the default. Clip names come from
|
|
64
78
|
* the manifest and are not guaranteed, so the default prefers a clip actually
|
|
65
|
-
* called idle
|
|
79
|
+
* called idle, then one whose name contains it (`Idle_Motion`,
|
|
80
|
+
* `Armature|Idle`), and otherwise falls back to whatever the character shipped
|
|
66
81
|
* first — anything is better than the bind pose.
|
|
67
82
|
*
|
|
68
83
|
* A requested name that the character does not have returns null rather than
|
|
@@ -73,7 +88,7 @@ export function pickIdleClip(names, requested) {
|
|
|
73
88
|
return null;
|
|
74
89
|
if (requested)
|
|
75
90
|
return names.includes(requested) ? requested : null;
|
|
76
|
-
return names.find((n) => n.toLowerCase() === 'idle') ?? names[0] ?? null;
|
|
91
|
+
return names.find((n) => n.toLowerCase() === 'idle') ?? names.find((n) => /idle/i.test(n)) ?? names[0] ?? null;
|
|
77
92
|
}
|
|
78
93
|
/** Every material property that can hold a disposable `THREE.Texture`. */
|
|
79
94
|
const TEXTURE_PROPS = [
|
|
@@ -109,6 +124,10 @@ export function disposeCharacter(deps) {
|
|
|
109
124
|
if (!mesh.isMesh)
|
|
110
125
|
return;
|
|
111
126
|
mesh.geometry?.dispose();
|
|
127
|
+
// Shadow-only materials (surfaces gives hair its own); their maps are
|
|
128
|
+
// the drawing material's, freed below.
|
|
129
|
+
mesh.customDepthMaterial?.dispose();
|
|
130
|
+
mesh.customDistanceMaterial?.dispose();
|
|
112
131
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
113
132
|
for (const mat of materials) {
|
|
114
133
|
if (!mat)
|
|
@@ -184,12 +203,18 @@ export async function createCharacter(opts) {
|
|
|
184
203
|
for (const [name, url] of Object.entries(opts.animations ?? {}))
|
|
185
204
|
clipUrls.set(name, url);
|
|
186
205
|
const uniqueUrls = [...new Set(clipUrls.values())];
|
|
187
|
-
|
|
206
|
+
const clipErrors = new Map();
|
|
207
|
+
let modelGltf, clipFiles;
|
|
188
208
|
try {
|
|
189
209
|
;
|
|
190
|
-
[modelGltf,
|
|
210
|
+
[modelGltf, clipFiles] = await Promise.all([
|
|
191
211
|
loader.loadAsync(modelUrl),
|
|
192
|
-
|
|
212
|
+
// A clip file that fails to load costs that clip, not the character:
|
|
213
|
+
// the report names it.
|
|
214
|
+
Promise.all(uniqueUrls.map((u) => loadClipFile(u, loader).catch((e) => {
|
|
215
|
+
clipErrors.set(u, e.message);
|
|
216
|
+
return null;
|
|
217
|
+
}))),
|
|
193
218
|
]);
|
|
194
219
|
}
|
|
195
220
|
finally {
|
|
@@ -205,15 +230,29 @@ export async function createCharacter(opts) {
|
|
|
205
230
|
if (opts.faceMaterials !== false) {
|
|
206
231
|
applyFaceMaterials(root, opts.faceMaterials === true || opts.faceMaterials === undefined ? {} : opts.faceMaterials, modelGltf.parser);
|
|
207
232
|
}
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
233
|
+
// The record's gender reaches the surfaces (male lashes are capped), unless
|
|
234
|
+
// the app set it.
|
|
235
|
+
const surfaces = opts.surfaces === false ? null : { gender: bundle.gender, ...(opts.surfaces === true || !opts.surfaces ? {} : opts.surfaces) };
|
|
236
|
+
if (surfaces)
|
|
237
|
+
applySurfaces(root, surfaces);
|
|
238
|
+
// Every clip the manifest/overrides declared. Clips from another skeleton
|
|
239
|
+
// (Mixamo, Rigify) are retargeted onto the body here, from its bind pose.
|
|
240
|
+
const files = new Map(uniqueUrls.flatMap((u, i) => (clipFiles[i] ? [[u, clipFiles[i]]] : [])));
|
|
241
|
+
const clipNotes = [];
|
|
242
|
+
let clips = gatherClips(root, files, clipUrls, {}, clipNotes);
|
|
243
|
+
for (const n of clipNotes)
|
|
244
|
+
if (n.status === 'not-loaded')
|
|
245
|
+
n.detail = clipErrors.get(n.url);
|
|
246
|
+
// A model file that brings its own animations, and nothing declared: use them.
|
|
247
|
+
let embedded = 0;
|
|
248
|
+
if (clipUrls.size === 0 && modelGltf.animations.length) {
|
|
249
|
+
clips = modelGltf.animations;
|
|
250
|
+
embedded = clips.length;
|
|
251
|
+
for (const c of clips)
|
|
252
|
+
clipNotes.push({ name: c.name, url: modelUrl, status: 'as-is' });
|
|
253
|
+
}
|
|
215
254
|
const animations = new AnimationController(root);
|
|
216
|
-
animations.register(clips);
|
|
255
|
+
const registered = animations.register(clips);
|
|
217
256
|
// Start idling. Without this a zero-config character stands in its bind
|
|
218
257
|
// pose — arms out, visibly wrong — until the app happens to call play().
|
|
219
258
|
// Making the caller do that is exactly the kind of required extra step
|
|
@@ -225,7 +264,16 @@ export async function createCharacter(opts) {
|
|
|
225
264
|
const idle = pickIdleClip(animations.names, opts.idleClip);
|
|
226
265
|
if (idle)
|
|
227
266
|
animations.play(idle);
|
|
228
|
-
|
|
267
|
+
// Face shapes under other names, aliased before anything binds by name.
|
|
268
|
+
const match = matchMorphs(root);
|
|
269
|
+
const wantMap = opts.morphMap ?? 'auto';
|
|
270
|
+
const morphMap = wantMap === 'auto' ? match.map : wantMap === false ? {} : wantMap;
|
|
271
|
+
const known = morphNames(root);
|
|
272
|
+
const unknownMorphs = wantMap && wantMap !== 'auto' ? Object.entries(wantMap).filter(([, m]) => !known.has(m)).map(([c, m]) => `${c} → ${m}`) : [];
|
|
273
|
+
const applied = applyMorphMap(root, morphMap);
|
|
274
|
+
// A male record rests steady rather than in the light smile, unless the app chose a mood.
|
|
275
|
+
const mouth = opts.mouth?.expression === undefined && /^male$/i.test(bundle.gender ?? '') ? { ...opts.mouth, expression: 'steady' } : opts.mouth;
|
|
276
|
+
const lipsync = new LipsyncSystem(mouth, opts.correctiveMap ?? null);
|
|
229
277
|
lipsync.bind(root);
|
|
230
278
|
for (const d of opts.decorators ?? []) {
|
|
231
279
|
d({ root, bundle, representation: rep, materials: collectMaterials(root) });
|
|
@@ -233,10 +281,29 @@ export async function createCharacter(opts) {
|
|
|
233
281
|
const units = createUnitNormalizer(root, {
|
|
234
282
|
targetHeight: opts.targetHeight,
|
|
235
283
|
groundY: opts.groundY,
|
|
284
|
+
grounding: opts.grounding,
|
|
285
|
+
});
|
|
286
|
+
// Needs the body in metres and still in its bind pose: after the units
|
|
287
|
+
// pass, before the first update. (Resting hair checks itself against the
|
|
288
|
+
// body in metres.)
|
|
289
|
+
if (opts.hair !== false)
|
|
290
|
+
restHairOnShoulders(root);
|
|
291
|
+
if (surfaces && surfaces.scalp !== false && surfaces.hair !== false)
|
|
292
|
+
shadeScalp(root);
|
|
293
|
+
if (surfaces && surfaces.hair !== false)
|
|
294
|
+
shadeHairDepth(root);
|
|
295
|
+
const rig = resolveRig(root, CONVAI_MHA_1, { quiet: true });
|
|
296
|
+
const report = inspectCharacter({
|
|
297
|
+
root, rig, match,
|
|
298
|
+
morphMap: { map: Object.fromEntries(applied.map((c) => [c, morphMap[c] ?? morphMap[`CTRL_expressions_${c}`]])), source: wantMap === 'auto' ? 'auto' : wantMap === false ? 'off' : 'app', unknown: unknownMorphs },
|
|
299
|
+
clipNotes, registered, idle, embedded,
|
|
300
|
+
idleRequested: typeof opts.idleClip === 'string' ? opts.idleClip : undefined,
|
|
236
301
|
});
|
|
237
|
-
|
|
302
|
+
if (opts.diagnostics !== false && report.issues.some((i) => i.level !== 'info')) {
|
|
303
|
+
console.warn(formatReport(report, `model ${model.url.split(/[?#]/, 1)[0].split('/').pop()}`));
|
|
304
|
+
}
|
|
238
305
|
const reduced = createReducedMotion();
|
|
239
|
-
const { pipeline, gaze } = buildPipeline({ units, animations, lipsync, rig }, { motion: opts.motion, gaze: opts.gaze, breathing: opts.breathing, eyeGaze: opts.eyeGaze, blink: opts.blink });
|
|
306
|
+
const { pipeline, gaze } = buildPipeline({ units, animations, lipsync, rig, root }, { motion: opts.motion, gaze: opts.gaze, breathing: opts.breathing, eyeGaze: opts.eyeGaze, blink: opts.blink });
|
|
240
307
|
// Reused every frame — mutated in place rather than reallocated, so the
|
|
241
308
|
// per-frame path makes zero steady-state allocations for the frame context.
|
|
242
309
|
const frameCtx = {
|
|
@@ -255,8 +322,12 @@ export async function createCharacter(opts) {
|
|
|
255
322
|
disposeCharacter({ root, mixer: animations.mixer, reducedMotion: reduced });
|
|
256
323
|
throw sessionError;
|
|
257
324
|
}
|
|
325
|
+
// The mouth follows the voice as it actually plays, read from the session's
|
|
326
|
+
// received audio (live: it changes on reconnect).
|
|
327
|
+
if (session)
|
|
328
|
+
lipsync.voice = new VoiceClock(() => session.voiceReceiver());
|
|
258
329
|
const handle = {
|
|
259
|
-
root, bundle, representation: rep, model, animations, lipsync, rig, pipeline, gaze,
|
|
330
|
+
root, bundle, representation: rep, model, animations, lipsync, rig, pipeline, gaze, report,
|
|
260
331
|
client: session?.client ?? null,
|
|
261
332
|
camera: undefined,
|
|
262
333
|
update(delta, queue) {
|
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Order is load-bearing and each constraint below is inherited from the
|
|
5
5
|
* prior art, not invented here:
|
|
6
|
-
* 0
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 0 prepare — systems that ADD to the clip pose undo last frame's
|
|
7
|
+
* additions on bones the mixer will not rewrite (root scale is
|
|
8
|
+
* measured once at load, not here)
|
|
9
|
+
* 1 mixer — clips drive the skeleton, then grounding puts that pose's
|
|
10
|
+
* soles on the floor (in that registration order)
|
|
11
|
+
* 2 gaze — eyes-then-neck-then-torso turns, ADDED to the clip pose
|
|
9
12
|
* 3 additive — breathe/lean/gestures, added AFTER the mixer so nothing
|
|
10
13
|
* accumulates (the mixer rewrites every frame)
|
|
11
|
-
* 4 ik — foot lock
|
|
12
|
-
*
|
|
14
|
+
* 4 ik — reserved (foot lock was measured and not needed: the clips'
|
|
15
|
+
* own root motion keeps planted feet within ~0.3 cm/s)
|
|
16
|
+
* 5 attach — reserved
|
|
13
17
|
* 6 morph — lipsync, eye-look, blink. Blink must run after lipsync.
|
|
14
18
|
*/
|
|
15
19
|
import type * as THREE from 'three';
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* which names those are via their own mapping. Loaded clips are track-filtered
|
|
8
8
|
* against the bones this model actually has — embody's dev-time validation:
|
|
9
9
|
* the clip GLBs carry richer rigs (fingers, hair) than some models.
|
|
10
|
+
*
|
|
11
|
+
* A clip also never moves, turns or scales the model's own container nodes
|
|
12
|
+
* (anything that is not a bone: the export's `root`, an armature group). Those
|
|
13
|
+
* transforms are the model's units and axes, not motion. Every published
|
|
14
|
+
* convai-mha-1 clip carries `root.scale = 0.01` while the bodies bind `root`
|
|
15
|
+
* at 1 with centimetre bones, so playing a clip used to shrink the body 100x,
|
|
16
|
+
* hidden only while the unit normaliser re-measured every frame.
|
|
10
17
|
*/
|
|
11
18
|
import * as THREE from 'three';
|
|
12
19
|
export interface ClipTransition {
|
|
@@ -24,7 +31,12 @@ export declare class AnimationController {
|
|
|
24
31
|
private current;
|
|
25
32
|
private pendingReturn;
|
|
26
33
|
constructor(root: THREE.Object3D);
|
|
27
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Register clips, keeping only tracks that animate this model: transforms of
|
|
36
|
+
* its bones, and any property (morph weights) of other nodes it has.
|
|
37
|
+
* Transforms of non-bone container nodes are dropped silently — see the
|
|
38
|
+
* header. Tracks naming nodes the model lacks are dropped with a warning.
|
|
39
|
+
*/
|
|
28
40
|
register(clips: THREE.AnimationClip[]): {
|
|
29
41
|
name: string;
|
|
30
42
|
kept: number;
|
package/dist/clips/animations.js
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* which names those are via their own mapping. Loaded clips are track-filtered
|
|
8
8
|
* against the bones this model actually has — embody's dev-time validation:
|
|
9
9
|
* the clip GLBs carry richer rigs (fingers, hair) than some models.
|
|
10
|
+
*
|
|
11
|
+
* A clip also never moves, turns or scales the model's own container nodes
|
|
12
|
+
* (anything that is not a bone: the export's `root`, an armature group). Those
|
|
13
|
+
* transforms are the model's units and axes, not motion. Every published
|
|
14
|
+
* convai-mha-1 clip carries `root.scale = 0.01` while the bodies bind `root`
|
|
15
|
+
* at 1 with centimetre bones, so playing a clip used to shrink the body 100x,
|
|
16
|
+
* hidden only while the unit normaliser re-measured every frame.
|
|
10
17
|
*/
|
|
11
18
|
import * as THREE from 'three';
|
|
12
19
|
export class AnimationController {
|
|
@@ -24,17 +31,32 @@ export class AnimationController {
|
|
|
24
31
|
}
|
|
25
32
|
});
|
|
26
33
|
}
|
|
27
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Register clips, keeping only tracks that animate this model: transforms of
|
|
36
|
+
* its bones, and any property (morph weights) of other nodes it has.
|
|
37
|
+
* Transforms of non-bone container nodes are dropped silently — see the
|
|
38
|
+
* header. Tracks naming nodes the model lacks are dropped with a warning.
|
|
39
|
+
*/
|
|
28
40
|
register(clips) {
|
|
29
|
-
const
|
|
30
|
-
this.root.traverse((o) =>
|
|
41
|
+
const nodes = new Map();
|
|
42
|
+
this.root.traverse((o) => { if (!nodes.has(o.name))
|
|
43
|
+
nodes.set(o.name, o); });
|
|
31
44
|
const report = [];
|
|
32
45
|
for (const src of clips) {
|
|
33
|
-
|
|
46
|
+
let absent = 0;
|
|
47
|
+
const kept = src.tracks.filter((t) => {
|
|
48
|
+
const { nodeName, propertyName } = THREE.PropertyBinding.parseTrackName(t.name);
|
|
49
|
+
const node = nodes.get(nodeName);
|
|
50
|
+
if (!node)
|
|
51
|
+
return void absent++, false;
|
|
52
|
+
const transform = propertyName === 'position' || propertyName === 'quaternion' || propertyName === 'scale';
|
|
53
|
+
return !transform || node.isBone === true;
|
|
54
|
+
});
|
|
34
55
|
const dropped = src.tracks.length - kept.length;
|
|
35
|
-
if (
|
|
36
|
-
console.warn(`[embody] clip "${src.name}": ${
|
|
56
|
+
if (absent > 0)
|
|
57
|
+
console.warn(`[embody] clip "${src.name}": ${absent}/${src.tracks.length} tracks target absent bones — dropped`);
|
|
37
58
|
const clip = new THREE.AnimationClip(src.name, src.duration, kept);
|
|
59
|
+
clip.userData = src.userData;
|
|
38
60
|
this.actions.set(src.name, this.mixer.clipAction(clip));
|
|
39
61
|
report.push({ name: src.name, kept: kept.length, dropped });
|
|
40
62
|
}
|