@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +12 -8
  3. package/dist/character/createCharacter.d.ts +66 -5
  4. package/dist/character/createCharacter.js +93 -22
  5. package/dist/character/pipeline.d.ts +9 -5
  6. package/dist/clips/animations.d.ts +13 -1
  7. package/dist/clips/animations.js +28 -6
  8. package/dist/clips/retarget.d.ts +168 -0
  9. package/dist/clips/retarget.js +856 -0
  10. package/dist/clips/sources.d.ts +70 -0
  11. package/dist/clips/sources.js +119 -0
  12. package/dist/connect/session.d.ts +11 -0
  13. package/dist/connect/session.js +22 -0
  14. package/dist/index.d.ts +22 -1
  15. package/dist/index.js +12 -0
  16. package/dist/inspect/inspectModel.d.ts +14 -0
  17. package/dist/inspect/inspectModel.js +26 -0
  18. package/dist/inspect/morphMap.d.ts +54 -0
  19. package/dist/inspect/morphMap.js +140 -0
  20. package/dist/inspect/report.d.ts +88 -0
  21. package/dist/inspect/report.js +193 -0
  22. package/dist/motion/eyeAim.d.ts +94 -0
  23. package/dist/motion/eyeAim.js +276 -0
  24. package/dist/motion/eyeGaze.d.ts +17 -0
  25. package/dist/motion/eyeGaze.js +42 -0
  26. package/dist/motion/footLock.d.ts +67 -0
  27. package/dist/motion/footLock.js +176 -0
  28. package/dist/motion/gaze.d.ts +135 -180
  29. package/dist/motion/gaze.js +257 -491
  30. package/dist/motion/hair.d.ts +27 -0
  31. package/dist/motion/hair.js +127 -0
  32. package/dist/motion/hairClearance.d.ts +39 -0
  33. package/dist/motion/hairClearance.js +125 -0
  34. package/dist/motion/morphNormals.d.ts +38 -0
  35. package/dist/motion/morphNormals.js +237 -0
  36. package/dist/motion/types.d.ts +15 -0
  37. package/dist/react/CharacterModel.d.ts +3 -1
  38. package/dist/react/CharacterModel.js +9 -1
  39. package/dist/react/ConvaiCharacter.js +4 -1
  40. package/dist/react/PortraitLights.d.ts +3 -0
  41. package/dist/react/PortraitLights.js +44 -0
  42. package/dist/render/faceMaterials.d.ts +60 -3
  43. package/dist/render/faceMaterials.js +320 -96
  44. package/dist/render/portraitRig.d.ts +28 -0
  45. package/dist/render/portraitRig.js +55 -0
  46. package/dist/render/surfaces.d.ts +181 -0
  47. package/dist/render/surfaces.js +1020 -0
  48. package/dist/rig/resolve.d.ts +8 -2
  49. package/dist/rig/resolve.js +7 -3
  50. package/dist/rig/units.d.ts +35 -16
  51. package/dist/rig/units.js +143 -42
  52. package/dist/speech/expression.d.ts +20 -0
  53. package/dist/speech/expression.js +118 -0
  54. package/dist/speech/lipsync.d.ts +68 -2
  55. package/dist/speech/lipsync.js +205 -5
  56. package/dist/speech/mouthFinish.d.ts +46 -0
  57. package/dist/speech/mouthFinish.js +235 -0
  58. package/dist/speech/voiceClock.d.ts +94 -0
  59. package/dist/speech/voiceClock.js +176 -0
  60. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
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
+
33
+ ## 1.0.1
34
+
35
+ ### Patch Changes
36
+
37
+ - c779ffd: Fix: `ConvaiCharacter` and `CharacterModel` call `onReady` (and `onError`) once per character. An inline `onReady={(c) => …}` used to run again on every parent re-render, repeating whatever setup the app did there, such as adding a `speakingChange` listener.
38
+
39
+ Docs: two new guides, "Add your own animations to a character" (the `animations` option, `idleClip`, `crossfadeTo`, a talking clip on `speakingChange`, `register` at runtime) and "Use your own 3D model" (a resolver for a MetaHuman GLB you host, the rig requirements, Full and Lite files, `connect: false` previews). The `createCharacter` reference gains `modelVariant`, `character.model`, and sections for `AnimationController` and the `CharacterBundle` manifest; the React reference says which props reload the character. The README's Lite count no longer says 113.
40
+
3
41
  ## 1.0.1-beta.0
4
42
 
5
43
  ### 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 normalize | unit normalizer | root scale must settle before anything reads world space |
194
- | 1 mixer | animation clips | drives the skeleton |
195
- | 2 gaze | `GazeSystem` | **overrides** the mixer's head rotation rather than blending with it |
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)* | foot lock and hair land here later |
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** — head/neck solve about the rig's own axis basis, idle micro-drift,
201
- occasional look-away shifts with dwell timing, turn banking, camera lock, and
202
- a deviation pull-back that lets the clip's own head motion flow until it strays.
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
- /** Extra local clips (name → url), merged OVER manifest clips by name. */
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, 'update'>;
246
+ units: Pick<UnitNormalizer, 'ground'>;
189
247
  animations: Pick<AnimationController, 'update'>;
190
248
  lipsync: Pick<LipsyncSystem, 'update'>;
191
249
  rig: ResolvedRig;
192
- }, opts: Pick<CreateCharacterOptions, 'motion' | 'gaze' | 'breathing' | 'eyeGaze' | 'blink'>): {
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 and otherwise falls back to whatever the character shipped
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 — root scale must settle before anything reads world space.
40
- pipeline.add({ stage: 0, update: () => void units.update() });
41
- // Stage 1 — clips drive the skeleton.
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
- pipeline.add(gaze); // 2 — overrides mixer
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 and otherwise falls back to whatever the character shipped
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
- let modelGltf, clipGltfs;
206
+ const clipErrors = new Map();
207
+ let modelGltf, clipFiles;
188
208
  try {
189
209
  ;
190
- [modelGltf, ...clipGltfs] = await Promise.all([
210
+ [modelGltf, clipFiles] = await Promise.all([
191
211
  loader.loadAsync(modelUrl),
192
- ...uniqueUrls.map((u) => loader.loadAsync(u)),
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
- // gather every clip whose name the manifest/overrides declared
209
- const wanted = new Set(clipUrls.keys());
210
- const clips = [];
211
- for (const g of clipGltfs)
212
- for (const a of g.animations)
213
- if (wanted.has(a.name))
214
- clips.push(a);
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
- const lipsync = new LipsyncSystem(opts.mouth, opts.correctiveMap ?? null);
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
- const rig = resolveRig(root, CONVAI_MHA_1);
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 normalize — root scale must settle before anything reads world space
7
- * 1 mixer — clips drive the skeleton
8
- * 2 gaze — OVERRIDES the mixer's head rotation, never blends with it
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 (reserved; no system in this slice)
12
- * 5 attach — hair follows the final transform (reserved)
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
- /** Register clips, filtering tracks to bones present on this model. */
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;
@@ -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
- /** Register clips, filtering tracks to bones present on this model. */
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 bones = new Set();
30
- this.root.traverse((o) => bones.add(o.name));
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
- const kept = src.tracks.filter((t) => bones.has(t.name.split('.')[0]));
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 (dropped > 0)
36
- console.warn(`[embody] clip "${src.name}": ${dropped}/${src.tracks.length} tracks target absent bones — dropped`);
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
  }