@convai/embody 0.6.0 → 1.0.0-beta.1

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # @convai/embody
2
2
 
3
+ ## 1.0.0-beta.1
4
+
5
+ ### Patch Changes
6
+
7
+ - `@convai/web-sdk` peer is now `>=2.0.1 <3`. web-sdk 1.9.x is no longer maintained, so embody 1.0 targets 2.x only.
8
+
9
+ ## 1.0.0-beta.0
10
+
11
+ ### Major Changes
12
+
13
+ - **1.0.0: embody is the 3D character only, and it works with `@convai/web-sdk` 2.x.** This is the first `latest` release since 0.4.2. It carries everything from 0.5.0 and 0.6.0, which were staging-only builds.
14
+
15
+ Migration from 0.4.2:
16
+
17
+ | Was (0.4.2) | Now (1.0.0) |
18
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
+ | `import { ConvaiWidget } from '@convai/embody/react'` | `import { ConvaiWidget } from '@convai/web-widget/react'` (install `@convai/web-widget`), fed by `useWidgetClient(character.client)` so the chat and the body share one connection |
20
+ | `import { AudioRenderer, AudioContext } from '@convai/embody/react'` | Gone. `ConvaiCharacter` and `createCharacter` play the character's audio themselves. For custom playback, `@convai/web-sdk/vanilla` has a headless `AudioRenderer` class (not a React component) |
21
+ | `@convai/web-sdk` peer `>=1.8.0-beta.4` | `>=1.9.4 <3`: 1.9.4 or any 2.x |
22
+ | `react` / `@react-three/fiber` / `three` peers unbounded | `react >=18 <20`, `@react-three/fiber >=8 <10`, `three >=0.160 <1` |
23
+
24
+ Nothing else needs a code change: every other export and option is unchanged or new (corrective maps, Full/Lite bodies, default-body face fixes).
25
+
26
+ ### Minor Changes
27
+
28
+ - 6527d85: Full and Lite bodies: when a body ships both GLBs, embody loads Lite (113 blendshapes) on phones, on tablets and on GPUs too small for Full's 251, and Full everywhere else. The new `modelVariant` option (`'auto'` | `'full'` | `'lite'`) forces a choice, and `character.model` reports which file loaded. `pickModelVariant` and `detectDevice` are exported for apps that build their own loader. Bodies with one GLB behave as before.
29
+
30
+ ### Patch Changes
31
+
32
+ - 86a6659: Face fixes for the convai-mha-1 default bodies (Formal Women, Activity Convai):
33
+
34
+ - Lipsync: the bilabial seal is off by default (it held the lips half shut on most speaking frames of the MHA stream), lower-lip gain is the SDK's 0.45, teeth follow a rest offset plus a share of the jaw, frames are consumed against the wall clock so the lips cannot fall behind the voice, and a stalled stream relaxes the mouth instead of freezing it. `teethLowerTuck` is deprecated and read as `teethLowerRest`.
35
+ - Gaze: the eye ease no longer speeds up on high-refresh displays.
36
+ - Materials: export defects are repaired by pattern: self-lit skin, hair and clothes lose the emissive copy of their base colour (eyes keep a quarter), out-of-range roughness and specular are clamped, the head loses its glossy scalp-edge roughness map and its alpha mask, and `Hide` helpers are hidden. `ConvaiCharacter` adds a neutral room environment for the now physically shaded skin and eyes.
37
+
38
+ > 0.5.0 and 0.6.0 were published only to the `stg` npm tag, as staging-backend builds. Their changes reach `latest` in 1.0.0.
39
+
3
40
  ## 0.6.0
4
41
 
5
42
  ### Minor Changes
@@ -20,6 +57,32 @@
20
57
 
21
58
  - 249a96f: Optional corrective map for one-morph-per-control bodies. A table fitted from the body's MetaHuman DNA re-weights each lipsync frame toward the full rig (PSD correctives, joint arcs). Pass `correctiveMap` (from `compileCorrectiveMap`) to `createCharacter` or the React components; it is off by default and swaps live through the prop or `character.lipsync.correctiveMap`.
22
59
 
60
+ ## 0.4.2
61
+
62
+ ### Patch Changes
63
+
64
+ - 532c49e: Swapping the body under a fixed character id now reaches the screen.
65
+
66
+ `useCharacter` rebuilt the character only when the character id, credentials
67
+ or base url changed. The **resolver** was not among them, and the effect read
68
+ its options from a ref — so an authoring tool that holds one avatar id and
69
+ swaps the block underneath (preview release 3, then release 4) got nothing:
70
+ the old GLB stayed on the canvas and it read as a stubborn cache. Nothing was
71
+ cached. The loader was never asked for the new url.
72
+
73
+ The resolver decides WHICH body a character id resolves to, so it belongs in
74
+ the rebuild set, and now is.
75
+
76
+ This makes the resolver's identity part of the contract: **memoize it**, and
77
+ change it exactly when the body should change. A resolver rebuilt inline on
78
+ every render would rebuild the character on every render. The README's new
79
+ "Swapping the body under a fixed character id" section shows the shape.
80
+
81
+ Also documented, unchanged in behaviour: the `orbit` prop (shipped in 0.4.0
82
+ and previously absent from the README), which backend each npm tag talks to,
83
+ and the correct release-branch names — the table named three branches that do
84
+ not exist.
85
+
23
86
  ## 0.4.1
24
87
 
25
88
  ### Patch Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Give a Convai character a body.**
4
4
 
5
- [![npm](https://img.shields.io/npm/v/@convai/embody/stg?label=npm%20stg&color=blue)](https://www.npmjs.com/package/@convai/embody)
5
+ [![npm](https://img.shields.io/npm/v/@convai/embody/latest?label=npm&color=blue)](https://www.npmjs.com/package/@convai/embody)
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-3178C6)](https://www.typescriptlang.org/)
7
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE)
8
8
 
@@ -12,13 +12,21 @@ lipsync, gaze, breathing and blink. Layered on
12
12
 
13
13
  ---
14
14
 
15
- > **Branch: `stg`** — this branch defaults to the staging backend, which has
16
- > its own character database. Use `main` or `prod` for production.
15
+ > **Branch: `main`** — this branch defaults to the production backend and
16
+ > publishes the `latest` tag. `stg` targets staging, which has its own
17
+ > character database.
17
18
  >
18
19
  > **Status: early release.** The front door — a credential plus a
19
20
  > `characterId` — is settled; what is still moving is listed in
20
21
  > [Roadmap](#roadmap).
21
22
 
23
+ > **Which backend a build talks to is fixed at publish time.** The `latest`
24
+ > and `beta` tags point at `https://api.convai.com`; the `stg` tag points at
25
+ > `https://api-stg.convai.com`. Character ids are per-environment and not
26
+ > interchangeable — a staging id against production returns *"Character not
27
+ > found or doesn't belong to user"*, which reads like a permissions problem
28
+ > and is a wrong-host one. Override with `baseUrl` only if you know why.
29
+
22
30
  ## Install
23
31
 
24
32
  ```bash
@@ -63,6 +71,32 @@ For anything user-facing, mint a short-lived auth token server-side and pass
63
71
  <ConvaiCharacter token={token} characterId={characterId} />
64
72
  ```
65
73
 
74
+ ### Moving the camera
75
+
76
+ Drag to turn the character, scroll to move in and out. On by default — a 3D
77
+ character you cannot walk around is a picture of one.
78
+
79
+ ```tsx
80
+ <ConvaiCharacter apiKey={apiKey} characterId={characterId} orbit={false} />
81
+ ```
82
+
83
+ Pass an object to tune the limits instead of turning it off:
84
+
85
+ ```tsx
86
+ <ConvaiCharacter
87
+ apiKey={apiKey}
88
+ characterId={characterId}
89
+ orbit={{ maxDistance: 6, autoRotate: true, autoRotateSpeed: 0.8 }}
90
+ />
91
+ ```
92
+
93
+ `minDistance`, `maxDistance`, `minPolarAngle`, `maxPolarAngle`, `enablePan`,
94
+ `enableZoom`, `autoRotate`, `autoRotateSpeed`. The defaults are tuned for one
95
+ subject — a standing human — not for a general scene viewer: panning is off
96
+ because it only ever slides the character out of frame, and the vertical sweep
97
+ stops just below eye level because orbiting under the floor shows the inside
98
+ of a head.
99
+
66
100
  ### In a scene you already have
67
101
 
68
102
  `ConvaiCharacter` brings its own canvas. When the character should join a
@@ -85,12 +119,11 @@ a second one. Lift it out with `onReady`; chat UI is DOM, so it lives outside
85
119
  the canvas:
86
120
 
87
121
  ```tsx
88
- import { ConvaiCharacter, useWidgetClient, type ConvaiClient } from '@convai/embody/react'
122
+ import { ConvaiCharacter, type ConvaiClient } from '@convai/embody/react'
89
123
  import { ConvaiWidget } from '@convai/web-widget/react'
90
124
 
91
125
  function App() {
92
126
  const [client, setClient] = useState<ConvaiClient | null>(null)
93
- const widgetClient = useWidgetClient(client)
94
127
  return (
95
128
  <>
96
129
  <ConvaiCharacter
@@ -98,7 +131,7 @@ function App() {
98
131
  characterId={characterId}
99
132
  onReady={(c) => setClient(c.client)}
100
133
  />
101
- {widgetClient && <ConvaiWidget convaiClient={widgetClient} />}
134
+ {client && <ConvaiWidget convaiClient={client} />}
102
135
  </>
103
136
  )
104
137
  }
@@ -106,10 +139,15 @@ function App() {
106
139
 
107
140
  embody is the 3D character only; the chat panel is
108
141
  [`@convai/web-widget`](https://www.npmjs.com/package/@convai/web-widget)
109
- (`npm install @convai/web-widget`). `useWidgetClient` adapts
110
- embody's client to the widget's props — the SDK's own `useConvaiClient` would
111
- *construct a second client*, and so open a second connection, which is exactly
112
- what embody exists to avoid.
142
+ (`npm install @convai/web-widget`, 0.3.0 or later). Hand it the character's own
143
+ client — the widget subscribes to it, so chat and lip-sync share one
144
+ connection. Do not use the SDK's `useConvaiClient` for this: it *constructs a
145
+ second client*, and so opens a second connection, which is exactly what embody
146
+ exists to avoid.
147
+
148
+ `useWidgetClient` is still exported for `@convai/web-widget` before 0.3.0,
149
+ whose `ConvaiWidget` accepted only `useConvaiClient`'s result; on 0.3.0 and
150
+ later it is unnecessary.
113
151
 
114
152
  ## Without React
115
153
 
@@ -166,6 +204,21 @@ load-bearing, not incidental:
166
204
  ships but is deliberately not registered. Bone and morph names are data on the
167
205
  profile, so a new rig is a value rather than a code change.
168
206
 
207
+ ### Full and Lite bodies
208
+
209
+ A body can ship two GLBs of the same rig: **Full**, with all 251 MetaHuman
210
+ blendshapes, and **Lite**, with 113. Phones need Lite. three.js spends one
211
+ vertex-shader uniform per blendshape, many mobile GPUs allow 256 in all, and
212
+ past that the head fails to compile and is not drawn.
213
+
214
+ embody picks the file per device (`modelVariant: 'auto'`, the default):
215
+ phones and tablets get Lite, and so does any GPU whose
216
+ `MAX_VERTEX_UNIFORM_VECTORS` cannot hold Full's blendshapes. Everything else
217
+ gets Full. Pass `modelVariant: 'full'` or `'lite'` to force one;
218
+ `character.model` reports which file loaded. A body with one GLB loads that
219
+ file everywhere. Blendshapes Lite drops are skipped by lipsync, blink and
220
+ eye gaze rather than erroring.
221
+
169
222
  ## Escape hatches
170
223
 
171
224
  The defaults cover the normal case; each of these exists for a specific one.
@@ -177,9 +230,28 @@ The defaults cover the normal case; each of these exists for a specific one.
177
230
  | `connect: false` | A body with no mind — thumbnails, previews, asset review tools. `character.client` is then `null`. |
178
231
  | `session` | Extra SDK options (voice, memory, transport, `endUserId`). Credentials and `characterId` are not repeated here. |
179
232
  | `baseUrl` | A different backend environment. Defaults per release channel. |
233
+ | `modelVariant` | Force the Full or Lite GLB (`'full'` / `'lite'`) instead of choosing per device, e.g. to preview Lite on a desktop. |
180
234
  | `queue` on `update()` | One client driving several bodies, when you are deciding which owns the current turn. |
181
235
  | `correctiveMap` | A table fitted from the body's MetaHuman DNA (`compileCorrectiveMap(json)`) that re-weights each lipsync frame toward what the full rig shows: PSD correctives and joint arcs that one-morph-per-control bodies lack. Off by default. Changing the prop, or `character.lipsync.correctiveMap`, swaps it live. In `pnpm dev`, the demo lists `*.map.json` tables from `../open-rig-dna/maps` (or `$EMBODY_CORRECTIVE_MAPS`) for A/B: **C** toggles, **F** frames the face. |
182
236
 
237
+ ### Swapping the body under a fixed character id
238
+
239
+ `resolver` is how an authoring tool previews a body that is not published — or
240
+ not even saved. Its **identity is part of the rebuild contract**: memoize it,
241
+ and change it exactly when the body should change.
242
+
243
+ ```tsx
244
+ const resolver = useMemo(
245
+ () => ({ async resolve() { return bundle } }),
246
+ [bundle],
247
+ )
248
+
249
+ <ConvaiCharacter characterId={avatarId} resolver={resolver} connect={false} />
250
+ ```
251
+
252
+ Changing the resolver rebuilds the character; keeping it stable does not. A
253
+ resolver rebuilt inline on every render would reload the GLB on every render.
254
+
183
255
  ## Documentation
184
256
 
185
257
  **User documentation** is published on docs.convai.com under *Web Plugins →
@@ -3,7 +3,8 @@
3
3
  * framework-free handle the app ticks once per frame.
4
4
  */
5
5
  import * as THREE from 'three';
6
- import { type CharacterResolver, type CharacterBundle, type Representation } from '../resolve/resolver.js';
6
+ import { type CharacterResolver, type CharacterBundle, type Representation, type ModelVariant } from '../resolve/resolver.js';
7
+ import { type ModelVariantPreference } from '../resolve/modelVariant.js';
7
8
  import { type SessionOptions } from '../connect/session.js';
8
9
  import type { ConvaiClient } from '@convai/web-sdk/core';
9
10
  import { type UnitNormalizer } from '../rig/units.js';
@@ -85,6 +86,12 @@ export interface CreateCharacterOptions {
85
86
  dracoDecoderPath?: string;
86
87
  /** Rig profiles this build understands, in preference order. */
87
88
  supportedRigs?: string[];
89
+ /**
90
+ * Which model file to load when the body ships more than one. `auto`
91
+ * (default) loads Lite on phones and tablets and on any GPU too small for
92
+ * Full's blendshapes; `full` and `lite` force one. See `pickModelVariant`.
93
+ */
94
+ modelVariant?: ModelVariantPreference;
88
95
  targetHeight?: number;
89
96
  groundY?: number;
90
97
  mouth?: MouthTuning;
@@ -134,6 +141,8 @@ export interface CharacterHandle {
134
141
  root: THREE.Object3D;
135
142
  bundle: CharacterBundle;
136
143
  representation: Representation;
144
+ /** The model file that was loaded — `representation.model` lists every one on offer. */
145
+ model: ModelVariant;
137
146
  animations: AnimationController;
138
147
  lipsync: LipsyncSystem;
139
148
  rig: ResolvedRig;
@@ -1,6 +1,7 @@
1
1
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
2
2
  import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
3
3
  import { pickRepresentation, httpResolver, MISSING_CREDENTIAL } from '../resolve/resolver.js';
4
+ import { pickModelVariant } from '../resolve/modelVariant.js';
4
5
  import { createSession, adoptSession } from '../connect/session.js';
5
6
  import { createUnitNormalizer } from '../rig/units.js';
6
7
  import { AnimationController } from '../clips/animations.js';
@@ -174,7 +175,8 @@ export async function createCharacter(opts) {
174
175
  const draco = new DRACOLoader();
175
176
  draco.setDecoderPath(opts.dracoDecoderPath ?? DEFAULT_DRACO_DECODER_PATH);
176
177
  loader.setDRACOLoader(draco);
177
- const modelUrl = rep.model[0].url;
178
+ const model = pickModelVariant(rep.model, opts.modelVariant);
179
+ const modelUrl = model.url;
178
180
  // clip sources: manifest clips, with local overrides winning by name
179
181
  const clipUrls = new Map();
180
182
  for (const [name, c] of Object.entries(rep.clips ?? {}))
@@ -254,7 +256,7 @@ export async function createCharacter(opts) {
254
256
  throw sessionError;
255
257
  }
256
258
  const handle = {
257
- root, bundle, representation: rep, animations, lipsync, rig, pipeline, gaze,
259
+ root, bundle, representation: rep, model, animations, lipsync, rig, pipeline, gaze,
258
260
  client: session?.client ?? null,
259
261
  camera: undefined,
260
262
  update(delta, queue) {
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ export type { SessionOptions, SessionHandle } from './connect/session.js';
22
22
  export { httpResolver, pickRepresentation, DEFAULT_BASE_URL } from './resolve/resolver.js';
23
23
  export type { HttpResolverOptions } from './resolve/resolver.js';
24
24
  export type { CharacterResolver, CharacterBundle, Representation, ModelVariant } from './resolve/resolver.js';
25
+ export { pickModelVariant, detectDevice, MORPH_UNIFORM_RESERVE } from './resolve/modelVariant.js';
26
+ export type { ModelVariantPreference, DeviceProfile } from './resolve/modelVariant.js';
25
27
  export { createCharacter } from './character/createCharacter.js';
26
28
  export type { CharacterHandle, CreateCharacterOptions } from './character/createCharacter.js';
27
29
  export { AnimationController } from './clips/animations.js';
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@
22
22
  export { ConvaiClient, BlendshapeQueue, AudioManager, VideoManager, ScreenShareManager, MemoryManager, getDisconnectReasonMessage, } from '@convai/web-sdk/core';
23
23
  export { createSession, adoptSession } from './connect/session.js';
24
24
  export { httpResolver, pickRepresentation, DEFAULT_BASE_URL } from './resolve/resolver.js';
25
+ export { pickModelVariant, detectDevice, MORPH_UNIFORM_RESERVE } from './resolve/modelVariant.js';
25
26
  export { createCharacter } from './character/createCharacter.js';
26
27
  export { AnimationController } from './clips/animations.js';
27
28
  export { LipsyncSystem } from './speech/lipsync.js';
@@ -37,12 +37,12 @@ export interface EyeGazeConfig {
37
37
  * (useEyeTracking.ts:538-562) — the bone-rotation path is CC4-only and out
38
38
  * of scope here.
39
39
  *
40
- * The channel names are cross-named: driving `eyeLookLeft*` rotates the eyes
41
- * toward the character's RIGHT (useEyeTracking.ts:156-159, verified live on
42
- * the UE rig by driving each morph in isolation). `CONVAI_MHA_1` encodes
43
- * that as in/out morph roles (`eyeLookInL`/`eyeLookOutL`/`eyeLookInR`/
44
- * `eyeLookOutR`); this system only ever speaks in in/out terms and never
45
- * references a literal left/right channel name.
40
+ * Driving `eyeLookLeft*` turns the eyes to the character's own LEFT (each
41
+ * morph driven alone, in Chrome, on three convai-mha-1 heads, 2026-10-14).
42
+ * `CONVAI_MHA_1` maps its in/out morph roles (`eyeLookInL`/`eyeLookOutL`/
43
+ * `eyeLookInR`/`eyeLookOutR`) in the frame of the rig basis rather than the
44
+ * anatomy; see the profile. This system only ever speaks in in/out terms and
45
+ * never references a literal left/right channel name.
46
46
  *
47
47
  * DEFERRED (RULING R48 — logged for final review, deliberately NOT built
48
48
  * here):
@@ -61,5 +61,5 @@ export declare class EyeGazeSystem implements System {
61
61
  private h;
62
62
  private v;
63
63
  constructor(gaze: GazeSource, cfg?: EyeGazeConfig);
64
- update(_dt: number, ctx: FrameContext): void;
64
+ update(dt: number, ctx: FrameContext): void;
65
65
  }
@@ -7,12 +7,12 @@ import { writeMorph } from '../rig/resolve.js';
7
7
  * (useEyeTracking.ts:538-562) — the bone-rotation path is CC4-only and out
8
8
  * of scope here.
9
9
  *
10
- * The channel names are cross-named: driving `eyeLookLeft*` rotates the eyes
11
- * toward the character's RIGHT (useEyeTracking.ts:156-159, verified live on
12
- * the UE rig by driving each morph in isolation). `CONVAI_MHA_1` encodes
13
- * that as in/out morph roles (`eyeLookInL`/`eyeLookOutL`/`eyeLookInR`/
14
- * `eyeLookOutR`); this system only ever speaks in in/out terms and never
15
- * references a literal left/right channel name.
10
+ * Driving `eyeLookLeft*` turns the eyes to the character's own LEFT (each
11
+ * morph driven alone, in Chrome, on three convai-mha-1 heads, 2026-10-14).
12
+ * `CONVAI_MHA_1` maps its in/out morph roles (`eyeLookInL`/`eyeLookOutL`/
13
+ * `eyeLookInR`/`eyeLookOutR`) in the frame of the rig basis rather than the
14
+ * anatomy; see the profile. This system only ever speaks in in/out terms and
15
+ * never references a literal left/right channel name.
16
16
  *
17
17
  * DEFERRED (RULING R48 — logged for final review, deliberately NOT built
18
18
  * here):
@@ -32,7 +32,7 @@ export class EyeGazeSystem {
32
32
  this.h = 0;
33
33
  this.v = 0;
34
34
  }
35
- update(_dt, ctx) {
35
+ update(dt, ctx) {
36
36
  const lerpSpeed = this.cfg.lerpSpeed ?? 0.2;
37
37
  const verticalOffset = this.cfg.verticalOffset ?? -0.15;
38
38
  const gain = this.cfg.morphGain ?? 0.65;
@@ -47,12 +47,17 @@ export class EyeGazeSystem {
47
47
  const targetV = ctx.reducedMotion
48
48
  ? 0
49
49
  : THREE.MathUtils.clamp(d.vertical + verticalOffset, -1, 1);
50
- this.h += (targetH - this.h) * lerpSpeed;
51
- this.v += (targetV - this.v) * lerpSpeed;
50
+ // lerpSpeed is a per-frame rate tuned at 60 fps. Applied per frame as is,
51
+ // the eyes moved twice as fast on a 120 Hz display; this is the same
52
+ // ease, made independent of the frame rate.
53
+ const k = 1 - Math.pow(1 - lerpSpeed, Math.max(0, dt) * 60);
54
+ this.h += (targetH - this.h) * k;
55
+ this.v += (targetV - this.v) * k;
52
56
  const h = this.h * gain;
53
57
  const v = this.v * gain;
54
- // Positive h = looking to the character's RIGHT. That is "in" (nose-ward)
55
- // for the LEFT eye and "out" for the RIGHT eye.
58
+ // Positive h follows the rig basis' `right` axis. On convai-mha-1 that
59
+ // axis is the character's LEFT, and the profile's in/out roles are mapped
60
+ // to match, so the eyes turn toward the target; see the profile.
56
61
  writeMorph(ctx.rig, 'eyeLookInL', Math.max(0, h));
57
62
  writeMorph(ctx.rig, 'eyeLookOutL', Math.max(0, -h));
58
63
  writeMorph(ctx.rig, 'eyeLookInR', Math.max(0, -h));
@@ -25,6 +25,7 @@ import { Suspense } from 'react';
25
25
  import { Canvas } from '@react-three/fiber';
26
26
  import { CharacterModel } from './CharacterModel.js';
27
27
  import { OrbitRig } from './OrbitRig.js';
28
+ import { RoomLight } from './RoomLight.js';
28
29
  const DEFAULT_POSITION = [0, 1.45, 3.6];
29
30
  const DEFAULT_TARGET = [0, 0.92, 0];
30
31
  export function ConvaiCharacter({ style, className, background = '#17181c', camera, orbit = true, fallback = null, ...character }) {
@@ -38,6 +39,6 @@ export function ConvaiCharacter({ style, className, background = '#17181c', came
38
39
  // r3f points the camera at [0,0,0] otherwise. Harmless when orbit is
39
40
  // on — OrbitRig takes the same target and drives the camera from
40
41
  // there — and load-bearing when it is off.
41
- onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), orbit !== false && (_jsx(OrbitRig, { target: target, options: orbit === true ? undefined : orbit })), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
42
+ onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), _jsx(RoomLight, {}), orbit !== false && (_jsx(OrbitRig, { target: target, options: orbit === true ? undefined : orbit })), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
42
43
  }
43
44
  export default ConvaiCharacter;
@@ -0,0 +1,3 @@
1
+ export declare function RoomLight({ intensity }: {
2
+ intensity?: number;
3
+ }): null;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A neutral image-based light for `ConvaiCharacter`'s own canvas.
3
+ *
4
+ * The default bodies are physically shaded: glossy eyes, skin with a real
5
+ * specular lobe. With only a hemisphere and a key light there is nothing for
6
+ * that gloss to reflect, so the eyes read as black marbles and the skin as
7
+ * flat. Their exports used to hide this by wiring the base colour into the
8
+ * emissive slot, which made every surface glow instead; `applyFaceMaterials`
9
+ * now removes that, and this supplies the light the surfaces were missing.
10
+ *
11
+ * Built once per renderer from three's own RoomEnvironment (no asset to
12
+ * fetch), and removed again on unmount. A scene that sets its own
13
+ * `environment` keeps it.
14
+ */
15
+ import { useEffect } from 'react';
16
+ import { useThree } from '@react-three/fiber';
17
+ import * as THREE from 'three';
18
+ import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
19
+ export function RoomLight({ intensity = 0.6 }) {
20
+ const gl = useThree((s) => s.gl);
21
+ const scene = useThree((s) => s.scene);
22
+ useEffect(() => {
23
+ if (scene.environment)
24
+ return;
25
+ const pmrem = new THREE.PMREMGenerator(gl);
26
+ const room = new RoomEnvironment();
27
+ const target = pmrem.fromScene(room, 0.04);
28
+ room.dispose?.();
29
+ pmrem.dispose();
30
+ scene.environment = target.texture;
31
+ const previous = scene.environmentIntensity;
32
+ scene.environmentIntensity = intensity;
33
+ return () => {
34
+ if (scene.environment === target.texture)
35
+ scene.environment = null;
36
+ scene.environmentIntensity = previous;
37
+ target.dispose();
38
+ };
39
+ }, [gl, scene, intensity]);
40
+ return null;
41
+ }
@@ -11,7 +11,8 @@
11
11
  *
12
12
  * embody is the 3D character only. For a chat panel, install
13
13
  * `@convai/web-widget` and hand it the character's own client — one session,
14
- * not two. `useWidgetClient` adapts that client to the widget's props.
14
+ * not two. `ConvaiWidget` (web-widget 0.3.0+) takes that client directly;
15
+ * `useWidgetClient` adapts it for older widgets.
15
16
  *
16
17
  * Chat UI is DOM and the character is a `<Canvas>` child, so lift the client
17
18
  * out rather than rendering the widget from inside the scene:
@@ -20,12 +21,11 @@
20
21
  * import { ConvaiWidget } from '@convai/web-widget/react'
21
22
  *
22
23
  * const [client, setClient] = useState<ConvaiClient | null>(null)
23
- * const widgetClient = useWidgetClient(client)
24
24
  * <Canvas>
25
25
  * <ConvaiCharacter apiKey={apiKey} characterId={characterId}
26
26
  * onReady={(c) => setClient(c.client)} />
27
27
  * </Canvas>
28
- * {widgetClient && <ConvaiWidget convaiClient={widgetClient} />}
28
+ * {client && <ConvaiWidget convaiClient={client} />}
29
29
  * ```
30
30
  */
31
31
  export { useCharacter } from './useCharacter.js';
@@ -11,7 +11,8 @@
11
11
  *
12
12
  * embody is the 3D character only. For a chat panel, install
13
13
  * `@convai/web-widget` and hand it the character's own client — one session,
14
- * not two. `useWidgetClient` adapts that client to the widget's props.
14
+ * not two. `ConvaiWidget` (web-widget 0.3.0+) takes that client directly;
15
+ * `useWidgetClient` adapts it for older widgets.
15
16
  *
16
17
  * Chat UI is DOM and the character is a `<Canvas>` child, so lift the client
17
18
  * out rather than rendering the widget from inside the scene:
@@ -20,12 +21,11 @@
20
21
  * import { ConvaiWidget } from '@convai/web-widget/react'
21
22
  *
22
23
  * const [client, setClient] = useState<ConvaiClient | null>(null)
23
- * const widgetClient = useWidgetClient(client)
24
24
  * <Canvas>
25
25
  * <ConvaiCharacter apiKey={apiKey} characterId={characterId}
26
26
  * onReady={(c) => setClient(c.client)} />
27
27
  * </Canvas>
28
- * {widgetClient && <ConvaiWidget convaiClient={widgetClient} />}
28
+ * {client && <ConvaiWidget convaiClient={client} />}
29
29
  * ```
30
30
  */
31
31
  export { useCharacter } from './useCharacter.js';
@@ -7,6 +7,12 @@ export interface UseCharacterOptions extends CreateCharacterOptions {
7
7
  */
8
8
  queue?: BlendshapeQueue;
9
9
  }
10
+ /**
11
+ * NOTE ON `resolver` (inherited from CreateCharacterOptions): its identity is
12
+ * part of this hook's dependency set. Memoize it, and change it precisely
13
+ * when the body should change — an inline `{ resolve() {...} }` is a new
14
+ * object every render and would rebuild the character every render.
15
+ */
10
16
  export interface UseCharacterResult {
11
17
  character: CharacterHandle | null;
12
18
  /**
@@ -35,11 +35,26 @@ export function useCharacter(opts) {
35
35
  setError(null);
36
36
  }, (c) => setCharacter(c), (e) => setError(e));
37
37
  return () => lifecycle.cancel();
38
- // Re-create when the identity of the character OR the credentials change
39
- // — a new key/token means a different session, and reusing the old one
40
- // would leave the body talking over a connection the app has revoked.
38
+ // Re-create when the identity of the character, the credentials, OR the
39
+ // resolver change.
40
+ //
41
+ // The resolver belongs here because it decides WHICH BODY this character
42
+ // id resolves to. An authoring tool holds the character id fixed and
43
+ // swaps the block underneath — preview release 3, then release 4 — and
44
+ // without this dependency none of that reached the screen: the effect
45
+ // never re-ran, so the old GLB stayed on the canvas and the change looked
46
+ // like a stubborn cache. Nothing was cached; the loader was simply never
47
+ // asked for the new url.
48
+ //
49
+ // This makes the resolver's IDENTITY part of the contract: pass a stable
50
+ // one (`useMemo`) and change it exactly when the body should change. A
51
+ // resolver rebuilt inline on every render would rebuild the character on
52
+ // every render.
53
+ //
54
+ // `modelVariant` too: it picks which of the body's files (Full or Lite)
55
+ // to load.
41
56
  // eslint-disable-next-line react-hooks/exhaustive-deps
42
- }, [opts.characterId, opts.apiKey, opts.authToken, opts.token, opts.baseUrl]);
57
+ }, [opts.characterId, opts.apiKey, opts.authToken, opts.token, opts.baseUrl, opts.resolver, opts.modelVariant]);
43
58
  useEffect(() => {
44
59
  if (character)
45
60
  character.camera = camera;
@@ -9,7 +9,7 @@ import * as THREE from 'three';
9
9
  * neither is sufficient alone, and the teeth in particular need both:
10
10
  *
11
11
  * SHAPE — the arches must retract and tuck as the jaw moves. That lives in
12
- * the lipsync layer (teethRetract / teethUpperLift / teethLowerTuck)
12
+ * the lipsync layer (teethRetract / teethUpper* / teethLower*)
13
13
  * and is already here.
14
14
  * SHADE — the arches must be textured and lit like a mouth interior. That is
15
15
  * this file, and without it the mouth renders as one flat slab.
@@ -70,6 +70,10 @@ export interface FaceMaterialReport {
70
70
  eyes: number;
71
71
  teeth: number;
72
72
  occlusion: number;
73
+ /** Materials whose base colour was also wired up as their emissive map. */
74
+ selfLit: number;
75
+ /** Hidden `Hide` helper materials. */
76
+ hidden: number;
73
77
  }
74
78
  /**
75
79
  * Pull this model's teeth albedo out of the file, bound or not.
@@ -1,4 +1,88 @@
1
1
  import * as THREE from 'three';
2
+ const isEye = (name) => /^Eyes?(?!occlusion)/i.test(name);
3
+ /**
4
+ * Repair what the convai-mha-1 Blender exports get wrong, matched on the
5
+ * defect rather than the material name wherever the defect is detectable, so
6
+ * a re-export with renamed materials (`EyeSofia`, `Head.001`, `TeethSofia`)
7
+ * is repaired the same way. Found on three heads in Chrome and, independently,
8
+ * on the PlayCanvas templates.
9
+ *
10
+ * · SELF-LIT SURFACES. Skin, hair, brows, clothes ship their base colour a
11
+ * second time as the emissive map at full strength (the male head at 1.24
12
+ * via KHR_materials_emissive_strength). Everything glows: oily skin, white
13
+ * hair strands, no shading. The emissive is dropped. Eyes are the one
14
+ * exception and keep a quarter of it, so the iris stays readable instead of
15
+ * reflecting a dark room as a black marble.
16
+ * · OUT-OF-RANGE FACTORS. roughness up to 2.9 and specular colour 2.0.
17
+ * Clamped to 1.
18
+ * · THE HEAD. Its roughness map is near-glossy along the painted scalp edge,
19
+ * which renders as a grey fringe under the hair (specular, not alpha), and
20
+ * it is alpha-masked, which cuts holes at the hairline. Constant roughness,
21
+ * opaque.
22
+ * · `Hide` helper geometry, which is never meant to render.
23
+ */
24
+ function repairExport(mat, found) {
25
+ const m = mat;
26
+ const name = m.name || '';
27
+ let changed = false;
28
+ if (/^Hide/i.test(name)) {
29
+ m.visible = false;
30
+ found.hidden++;
31
+ return;
32
+ }
33
+ const emissive = m.emissive;
34
+ const emissiveMap = m.emissiveMap;
35
+ if (emissive && emissiveMap) {
36
+ const sameImage = !!m.map && (m.map === emissiveMap || m.map.source === emissiveMap.source);
37
+ const lit = emissive.r + emissive.g + emissive.b > 0;
38
+ if (isEye(name)) {
39
+ // Unlit eyes are a deliberate export choice (see `tune`); lit ones get a
40
+ // floor of their own albedo.
41
+ if ('roughness' in m) {
42
+ if (!lit)
43
+ emissive.setScalar(0.25);
44
+ else
45
+ emissive.multiplyScalar(0.25);
46
+ if ('emissiveIntensity' in m)
47
+ m.emissiveIntensity = 1;
48
+ changed = true;
49
+ }
50
+ }
51
+ else if (sameImage && lit) {
52
+ m.emissiveMap = null;
53
+ emissive.setScalar(0);
54
+ found.selfLit++;
55
+ changed = true;
56
+ }
57
+ }
58
+ else if (isEye(name) && emissive && m.map && 'roughness' in m) {
59
+ m.emissiveMap = m.map;
60
+ emissive.setScalar(0.25);
61
+ changed = true;
62
+ }
63
+ if (typeof m.roughness === 'number' && m.roughness > 1) {
64
+ m.roughness = 1;
65
+ changed = true;
66
+ }
67
+ const spec = m.specularColor;
68
+ if (spec && (spec.r > 1 || spec.g > 1 || spec.b > 1)) {
69
+ spec.setRGB(Math.min(1, spec.r), Math.min(1, spec.g), Math.min(1, spec.b));
70
+ changed = true;
71
+ }
72
+ if (/^(Base)?Head/i.test(name) && 'roughness' in m) {
73
+ if (m.roughnessMap) {
74
+ m.roughnessMap = null;
75
+ m.roughness = 0.62;
76
+ }
77
+ if ('specularIntensity' in m)
78
+ m.specularIntensity = 0.35;
79
+ if (m.alphaTest > 0 && !m.transparent)
80
+ m.alphaTest = 0;
81
+ changed = true;
82
+ }
83
+ if (changed)
84
+ m.needsUpdate = true;
85
+ }
2
86
  /**
3
87
  * Pull this model's teeth albedo out of the file, bound or not.
4
88
  *
@@ -59,7 +143,7 @@ export async function loadTeethTexture(parser) {
59
143
  function tune(mat, teethScale, found) {
60
144
  const m = mat;
61
145
  const name = m.name || '';
62
- if (/^Eyes/i.test(name)) {
146
+ if (isEye(name)) {
63
147
  // ONLY on a material that has a lighting model.
64
148
  //
65
149
  // Measured on the live convai-mha-1 body: its eye materials arrive as
@@ -145,7 +229,7 @@ function referenceCount(root, target) {
145
229
  */
146
230
  export function applyFaceMaterials(root, tuning = {}, parser) {
147
231
  const teethScale = tuning.teethScale ?? 1;
148
- const found = { eyes: 0, teeth: 0, occlusion: 0 };
232
+ const found = { eyes: 0, teeth: 0, occlusion: 0, selfLit: 0, hidden: 0 };
149
233
  /** Every teeth material on THIS model, for the map to land on once loaded. */
150
234
  const mouths = [];
151
235
  root.traverse((obj) => {
@@ -153,8 +237,10 @@ export function applyFaceMaterials(root, tuning = {}, parser) {
153
237
  if (!mesh.isMesh || !mesh.material)
154
238
  return;
155
239
  const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
156
- for (const m of mats)
240
+ for (const m of mats) {
241
+ repairExport(m, found);
157
242
  tune(m, teethScale, found);
243
+ }
158
244
  const teeth = mats.find((mm) => /^Teeth/i.test(mm.name || ''));
159
245
  if (!teeth)
160
246
  return;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Choosing which of a body's model files to load on this device.
3
+ *
4
+ * A body can ship more than one GLB of the same rig. The art team makes two
5
+ * per MetaHuman: Full, with all 251 facial blendshapes, and Lite, with 113.
6
+ * They differ only in blendshape count, and that count is what phones cannot
7
+ * afford:
8
+ *
9
+ * - three.js spends one vertex-shader uniform slot per blendshape
10
+ * (`uniform float morphTargetInfluences[MORPHTARGETS_COUNT]`). Many mobile
11
+ * GPUs allow 256 slots in all, so 251 plus three's own matrices overflows,
12
+ * the program fails to link, and the head is simply not drawn.
13
+ * - Morph data lives in a texture sized vertices × blendshapes, so Full also
14
+ * costs over twice Lite's GPU memory on the device least able to spare it.
15
+ *
16
+ * In the manifest, `lod` orders the files: 0 is the most detailed, higher is
17
+ * lighter. `morphTargets` is the most blendshapes on any one mesh in the file,
18
+ * which is the number the uniform limit applies to; the console records it at
19
+ * upload.
20
+ */
21
+ import type { ModelVariant } from './resolver.js';
22
+ /** `auto` picks per device; `full` and `lite` force the most and least detailed file. */
23
+ export type ModelVariantPreference = 'auto' | 'full' | 'lite';
24
+ export interface DeviceProfile {
25
+ /** `MAX_VERTEX_UNIFORM_VECTORS` of a WebGL2 context, or null when none could be made. */
26
+ maxVertexUniformVectors: number | null;
27
+ /** A phone or tablet: Lite regardless of what the GPU reports. */
28
+ mobile: boolean;
29
+ }
30
+ /**
31
+ * Uniform slots three.js uses besides the blendshape weights: model, view,
32
+ * projection, normal and bind matrices plus a few scalars, about 35 for a
33
+ * skinned mesh. Kept generous so a material with extra uniforms still links.
34
+ */
35
+ export declare const MORPH_UNIFORM_RESERVE = 64;
36
+ /**
37
+ * The model file to load. Pure: the device is an argument, so the rule is
38
+ * testable and an app can apply it to a profile of its own.
39
+ */
40
+ export declare function pickModelVariant(models: readonly ModelVariant[], prefer?: ModelVariantPreference, device?: DeviceProfile): ModelVariant;
41
+ /** Profile this device once. Safe to call during SSR, where it reports a desktop with no GPU. */
42
+ export declare function detectDevice(): DeviceProfile;
43
+ /** Test seam: forget the cached profile. */
44
+ export declare function resetDeviceProfileForTests(): void;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Uniform slots three.js uses besides the blendshape weights: model, view,
3
+ * projection, normal and bind matrices plus a few scalars, about 35 for a
4
+ * skinned mesh. Kept generous so a material with extra uniforms still links.
5
+ */
6
+ export const MORPH_UNIFORM_RESERVE = 64;
7
+ function byDetail(models) {
8
+ return [...models].sort((a, b) => (a.lod ?? 0) - (b.lod ?? 0));
9
+ }
10
+ /**
11
+ * The model file to load. Pure: the device is an argument, so the rule is
12
+ * testable and an app can apply it to a profile of its own.
13
+ */
14
+ export function pickModelVariant(models, prefer = 'auto', device = detectDevice()) {
15
+ if (models.length === 0)
16
+ throw new Error('representation has no model entries');
17
+ const ordered = byDetail(models);
18
+ const lightest = ordered[ordered.length - 1];
19
+ if (prefer === 'full')
20
+ return ordered[0];
21
+ if (prefer === 'lite' || ordered.length === 1)
22
+ return lightest;
23
+ if (device.mobile)
24
+ return lightest;
25
+ // Desktop: the most detailed file whose blendshapes fit this GPU. An
26
+ // unknown count or budget is assumed to fit; desktop budgets are 1024+.
27
+ const budget = device.maxVertexUniformVectors;
28
+ const fits = (m) => m.morphTargets === undefined || budget === null || m.morphTargets + MORPH_UNIFORM_RESERVE <= budget;
29
+ return ordered.find(fits) ?? lightest;
30
+ }
31
+ let cached;
32
+ /** Profile this device once. Safe to call during SSR, where it reports a desktop with no GPU. */
33
+ export function detectDevice() {
34
+ if (cached)
35
+ return cached;
36
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
37
+ return { maxVertexUniformVectors: null, mobile: false };
38
+ }
39
+ cached = { maxVertexUniformVectors: probeVertexUniforms(), mobile: isMobile() };
40
+ return cached;
41
+ }
42
+ function isMobile() {
43
+ const uaData = navigator.userAgentData;
44
+ if (uaData?.mobile)
45
+ return true;
46
+ // Safari has no userAgentData, and iPadOS reports a desktop user agent. A
47
+ // touch-first device with no hover is a phone or tablet whichever it is.
48
+ const mq = (q) => typeof window.matchMedia === 'function' && window.matchMedia(q).matches;
49
+ return mq('(pointer: coarse)') && !mq('(hover: hover)');
50
+ }
51
+ function probeVertexUniforms() {
52
+ try {
53
+ const gl = document.createElement('canvas').getContext('webgl2');
54
+ if (!gl)
55
+ return null;
56
+ const value = gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS);
57
+ // Browsers cap live contexts (about 16); give this one back at once.
58
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
59
+ return typeof value === 'number' ? value : null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Test seam: forget the cached profile. */
66
+ export function resetDeviceProfileForTests() {
67
+ cached = undefined;
68
+ }
@@ -7,9 +7,12 @@
7
7
  * the entire reason the seam exists.
8
8
  */
9
9
  export interface ModelVariant {
10
+ /** 0 is the most detailed file; higher is lighter (Lite is 1). Absent means 0. */
10
11
  lod?: number;
11
12
  url: string;
12
13
  size?: number;
14
+ /** The most blendshapes on any one mesh in the file — what mobile GPUs limit. */
15
+ morphTargets?: number;
13
16
  encodings?: Record<string, string>;
14
17
  }
15
18
  export interface Representation {
@@ -53,7 +56,7 @@ export interface CharacterResolver {
53
56
  * not pass `baseUrl` to the wrong backend, which is why it is checked rather
54
57
  * than trusted.
55
58
  */
56
- export declare const DEFAULT_BASE_URL = "https://api-stg.convai.com";
59
+ export declare const DEFAULT_BASE_URL = "https://api.convai.com";
57
60
  export interface HttpResolverOptions {
58
61
  /** Base url of the embodiment backend. Defaults to the release branch's host. */
59
62
  baseUrl?: string;
@@ -20,7 +20,7 @@
20
20
  * not pass `baseUrl` to the wrong backend, which is why it is checked rather
21
21
  * than trusted.
22
22
  */
23
- export const DEFAULT_BASE_URL = 'https://api-stg.convai.com';
23
+ export const DEFAULT_BASE_URL = 'https://api.convai.com';
24
24
  /** Shared so `createCharacter` and the resolver cannot drift apart. */
25
25
  export const MISSING_CREDENTIAL = '@convai/embody: a credential is required — pass `token` (recommended for ' +
26
26
  'browsers) or `apiKey`. There is no anonymous access to Convai.';
@@ -46,9 +46,15 @@ export const CONVAI_MHA_1 = {
46
46
  blinkR: ['CTRL_expressions_eyeBlinkR', 'EyeBlinkRight'],
47
47
  relax: ['CTRL_expressions_eyeRelaxL', 'CTRL_expressions_eyeRelaxR'],
48
48
  lowerLidUp: ['CTRL_expressions_eyeLowerLidUpL', 'CTRL_expressions_eyeLowerLidUpR'],
49
- // Cross-named on purpose — verified in useEyeTracking. eyeLookLeft*
50
- // rotates toward the character's RIGHT, so it is the "in" channel for the
51
- // LEFT eye and the "out" channel for the right.
49
+ // In/out here are in the frame of EyeGazeSystem's residual, not the
50
+ // anatomy. eyeLookLeft* turns the eyes to the character's own LEFT
51
+ // (verified 2026-10-14 in Chrome, each morph alone, on three convai-mha-1
52
+ // heads). This basis's `right` axis, (0, 0, -1) on the UE head bone, is
53
+ // also the character's left, so a positive residual means "toward the
54
+ // character's left" and these mappings send the eyes there. Behaviour is
55
+ // right end to end (camera at the character's left -> eyeLookLeft*);
56
+ // only the old "cross-named" explanation was wrong. Swap these two only
57
+ // together with the basis.
52
58
  eyeLookUpL: ['CTRL_expressions_eyeLookUpL', 'EyeLookUpLeft'],
53
59
  eyeLookDownL: ['CTRL_expressions_eyeLookDownL', 'EyeLookDownLeft'],
54
60
  eyeLookInL: ['CTRL_expressions_eyeLookLeftL', 'EyeLookInLeft'],
@@ -20,29 +20,59 @@ import { type CorrectiveMap } from './correctiveMap.js';
20
20
  export interface MouthTuning {
21
21
  /** Hard cap on jawOpen; peak excursions read exaggerated on camera. */
22
22
  jawCap?: number;
23
- /** Lip-seal floor ceiling and onset for bilabials (m/b/p). */
23
+ /**
24
+ * Lip-seal floor ceiling and onset for bilabials (m/b/p). Off by default
25
+ * (ceiling 0): the MHA stream keeps jawOpen low (median 0.04 measured on a
26
+ * live reply) and opens the mouth through the lip channels, so a seal keyed
27
+ * on the raw jaw fired on 74% of speaking frames and held the lips half
28
+ * shut against them. Raise the ceiling for a stream that drives the jaw.
29
+ */
24
30
  sealCeiling?: number;
25
31
  sealOnset?: number;
26
- /** Teeth follow rules. */
32
+ /**
33
+ * Teeth follow the shaped jaw, as weights on the teeth morphs:
34
+ * teethBackU/D = teethRetract × jaw
35
+ * teethUpU = teethUpperRest + teethUpperLift × jaw
36
+ * teethDownD = teethLowerRest + teethLowerDrop × jaw
37
+ * The rest terms keep the teeth behind the lips at a closed mouth; the
38
+ * lower teeth then drop with the jaw rather than snapping open.
39
+ */
27
40
  teethRetract?: number;
41
+ teethUpperRest?: number;
28
42
  teethUpperLift?: number;
43
+ teethLowerRest?: number;
44
+ teethLowerDrop?: number;
45
+ /**
46
+ * @deprecated Read as `teethLowerRest` when that is not set. It used to tuck
47
+ * the lower teeth only while the mouth was closed and release them as it
48
+ * opened, which is backwards: the lower teeth should sit low and drop.
49
+ */
29
50
  teethLowerTuck?: number;
30
51
  /** Gain multipliers by MHA channel name, forwarded to the processor. */
31
52
  gainOverrides?: Record<string, number>;
32
53
  /** Seconds of visual lead granted at speech start. */
33
54
  visualLeadSeconds?: number;
55
+ /**
56
+ * Seconds without a fresh frame, while the bot still reads as speaking,
57
+ * after which the mouth relaxes instead of holding its last shape: a stream
58
+ * stall, or a speaking flag that outlives the voice.
59
+ */
60
+ staleReleaseSeconds?: number;
34
61
  }
35
- declare const DEFAULTS: Required<Omit<MouthTuning, 'gainOverrides'>> & {
62
+ type Tuning = Required<Omit<MouthTuning, 'gainOverrides' | 'teethLowerTuck'>> & {
36
63
  gainOverrides: Record<string, number>;
37
64
  };
38
65
  export declare class LipsyncSystem {
39
- readonly t: typeof DEFAULTS;
66
+ readonly t: Tuning;
40
67
  private readonly processor;
41
68
  private bindings;
42
69
  private accum;
43
70
  private lastFrame;
44
71
  private wasSpeaking;
45
72
  private env;
73
+ /** Seconds since a fresh frame came off the queue while speaking. */
74
+ private sinceFresh;
75
+ private lastClock;
46
76
  /** 0–1 speech energy; gaze layers read it. */
47
77
  energy: number;
48
78
  /** True while the bot owns the mouth; body layers read it. */
@@ -54,6 +84,7 @@ export declare class LipsyncSystem {
54
84
  */
55
85
  correctiveMap: CorrectiveMap | null;
56
86
  private mapped;
87
+ private unpinned;
57
88
  private readonly ch;
58
89
  constructor(tuning?: MouthTuning, correctiveMap?: CorrectiveMap | null);
59
90
  /** Bind every morph-bearing mesh under root. Returns matched channel count. */
@@ -1,15 +1,26 @@
1
1
  import { createLipsyncProcessor, mhaChannelIndex, } from '@convai/web-sdk/lipsync-helpers';
2
2
  import { applyCorrectiveMap } from './correctiveMap.js';
3
+ /**
4
+ * Teeth values are the ones approved on a convai-mha-1 head (2026-10-13) and
5
+ * re-checked on the two default bodies: the teeth stay behind the lips at
6
+ * rest and never show more than the lip opening allows.
7
+ */
3
8
  const DEFAULTS = {
4
9
  jawCap: 0.55,
5
- sealCeiling: 0.5,
10
+ sealCeiling: 0,
6
11
  sealOnset: 0.45,
7
- teethRetract: 0.55,
8
- teethUpperLift: 0.3,
9
- teethLowerTuck: 0.34,
12
+ teethRetract: 0.12,
13
+ teethUpperRest: 0.03,
14
+ teethUpperLift: 0.2,
15
+ teethLowerRest: 0.18,
16
+ teethLowerDrop: 0.35,
10
17
  visualLeadSeconds: 0.1,
11
- gainOverrides: { mouthLowerLipDepressL: 0.28, mouthLowerLipDepressR: 0.28 },
18
+ staleReleaseSeconds: 0.35,
19
+ // 0.45 is the SDK's recommended gain; 0.28 damped articulation.
20
+ gainOverrides: { mouthLowerLipDepressL: 0.45, mouthLowerLipDepressR: 0.45 },
12
21
  };
22
+ /** Wall-clock seconds, for catching up when a caller caps or skips its delta. */
23
+ const nowSeconds = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
13
24
  export class LipsyncSystem {
14
25
  constructor(tuning = {}, correctiveMap = null) {
15
26
  this.bindings = [];
@@ -17,13 +28,23 @@ export class LipsyncSystem {
17
28
  this.lastFrame = null;
18
29
  this.wasSpeaking = false;
19
30
  this.env = 0;
31
+ /** Seconds since a fresh frame came off the queue while speaking. */
32
+ this.sinceFresh = 0;
33
+ this.lastClock = null;
20
34
  /** 0–1 speech energy; gaze layers read it. */
21
35
  this.energy = 0;
22
36
  /** True while the bot owns the mouth; body layers read it. */
23
37
  this.speaking = false;
24
38
  this.mapped = null;
39
+ this.unpinned = null;
25
40
  this.correctiveMap = correctiveMap;
26
- this.t = { ...DEFAULTS, ...tuning, gainOverrides: { ...DEFAULTS.gainOverrides, ...tuning.gainOverrides } };
41
+ const { teethLowerTuck, ...rest } = tuning;
42
+ this.t = {
43
+ ...DEFAULTS,
44
+ ...(teethLowerTuck !== undefined && rest.teethLowerRest === undefined ? { teethLowerRest: teethLowerTuck } : {}),
45
+ ...rest,
46
+ gainOverrides: { ...DEFAULTS.gainOverrides, ...tuning.gainOverrides },
47
+ };
27
48
  this.processor = createLipsyncProcessor('mha', {
28
49
  skipEyeChannels: true,
29
50
  gainOverrides: this.t.gainOverrides,
@@ -39,6 +60,7 @@ export class LipsyncSystem {
39
60
  teethBackLower: idx('teethBackD'),
40
61
  teethUpUpper: idx('teethUpU'),
41
62
  teethDownLower: idx('teethDownD'),
63
+ noseUpper: idxs('noseWrinkleUpperL', 'noseWrinkleUpperR'),
42
64
  };
43
65
  }
44
66
  /** Bind every morph-bearing mesh under root. Returns matched channel count. */
@@ -47,6 +69,21 @@ export class LipsyncSystem {
47
69
  return this.bindings.reduce((s, b) => s + b.matched, 0);
48
70
  }
49
71
  correct(frame) {
72
+ // The MHA stream sends noseWrinkleUpperL/R as exactly 1.0 on every frame
73
+ // of every reply (a server artifact, confirmed against staging and
74
+ // production), which reads as a frozen sneer. Treat a pinned value as
75
+ // unset, as the SDK's MHA processor now does. Copied, never written back:
76
+ // the queue's own frame is shared.
77
+ const nose = this.ch.noseUpper;
78
+ if (nose.some((i) => frame[i] >= 0.999)) {
79
+ if (this.unpinned?.length !== frame.length)
80
+ this.unpinned = new Float32Array(frame.length);
81
+ this.unpinned.set(frame);
82
+ for (const i of nose)
83
+ if (this.unpinned[i] >= 0.999)
84
+ this.unpinned[i] = 0;
85
+ frame = this.unpinned;
86
+ }
50
87
  const map = this.correctiveMap;
51
88
  if (!map)
52
89
  return frame;
@@ -58,6 +95,14 @@ export class LipsyncSystem {
58
95
  update(delta, q) {
59
96
  if (!this.bindings.length)
60
97
  return;
98
+ // The queue is paced by the voice, which keeps playing whatever the
99
+ // render loop does. A host that caps its delta (PlayCanvas caps at 0.1 s)
100
+ // or loses frames would otherwise leave the lips behind the voice for the
101
+ // rest of the reply, so frames are consumed against the wall clock when it
102
+ // has moved further than the delta says.
103
+ const clock = nowSeconds();
104
+ const elapsed = this.lastClock === null ? delta : Math.min(30, Math.max(delta, clock - this.lastClock));
105
+ this.lastClock = clock;
61
106
  if (!q) {
62
107
  // Not holding the queue means not speaking — and that must be SAID,
63
108
  // or the body layer holds "talking" forever.
@@ -69,12 +114,14 @@ export class LipsyncSystem {
69
114
  const fps = q.getPlaybackFps?.() || 60;
70
115
  const frameTime = 1 / fps;
71
116
  const speaking = q.isBotSpeaking?.() ?? true;
72
- if (speaking && !this.wasSpeaking)
117
+ if (speaking && !this.wasSpeaking) {
73
118
  this.accum = this.t.visualLeadSeconds;
119
+ this.sinceFresh = 0;
120
+ }
74
121
  this.wasSpeaking = speaking;
75
122
  let fresh = null;
76
123
  if (speaking) {
77
- this.accum += delta;
124
+ this.accum += elapsed;
78
125
  while (this.accum >= frameTime && q.hasFrames()) {
79
126
  const frame = q.getFrameWithAlpha(0);
80
127
  q.consumeFrames(1);
@@ -84,20 +131,28 @@ export class LipsyncSystem {
84
131
  }
85
132
  if (this.accum > 0.25)
86
133
  this.accum = 0.25; // a starved queue banks no debt
87
- if (fresh)
134
+ if (fresh) {
88
135
  this.lastFrame = this.correct(fresh);
136
+ this.sinceFresh = 0;
137
+ }
138
+ else {
139
+ this.sinceFresh += elapsed;
140
+ }
89
141
  }
90
142
  else {
91
143
  this.accum = 0;
92
144
  }
93
- const raw = speaking ? this.lastFrame : null;
145
+ // Holding the last shape through a stall reads as a frozen mouth; let the
146
+ // envelope settle it instead, and pick up again on the next frame.
147
+ const stale = this.sinceFresh > this.t.staleReleaseSeconds;
148
+ const raw = speaking && !stale ? this.lastFrame : null;
94
149
  const out = this.processor.process(raw, delta);
95
150
  // mirror the processor's envelope so the seal fades with speech
96
151
  const target = raw ? 1 : 0;
97
152
  const tau = target > this.env ? 0.09 : 0.18;
98
153
  this.env += (target - this.env) * (1 - Math.exp(-delta / tau));
99
154
  if (out) {
100
- const { jawCap, sealCeiling, sealOnset, teethRetract, teethUpperLift, teethLowerTuck } = this.t;
155
+ const { jawCap, sealCeiling, sealOnset, teethRetract, teethUpperRest, teethUpperLift, teethLowerRest, teethLowerDrop } = this.t;
101
156
  const ch = this.ch;
102
157
  // hard jaw cap, lower lip scaled by the same ratio so the cap changes
103
158
  // the size of the opening without changing its shape
@@ -110,7 +165,7 @@ export class LipsyncSystem {
110
165
  for (const i of ch.lowerLipDepress)
111
166
  out[i] *= capRatio;
112
167
  // bilabial closure floor, driven from the RAW jaw
113
- if (raw && ch.jawOpen >= 0 && ch.lipsTogether.length) {
168
+ if (sealCeiling > 0 && raw && ch.jawOpen >= 0 && ch.lipsTogether.length) {
114
169
  const jawRaw = raw[ch.jawOpen];
115
170
  const bilabial = 1 - Math.min(1, Math.max(0, (jawRaw - 0.02) / 0.16));
116
171
  if (bilabial > sealOnset) {
@@ -127,12 +182,9 @@ export class LipsyncSystem {
127
182
  if (ch.teethBackLower >= 0)
128
183
  out[ch.teethBackLower] = jaw * teethRetract;
129
184
  if (ch.teethUpUpper >= 0)
130
- out[ch.teethUpUpper] = jaw * teethUpperLift;
131
- if (ch.teethDownLower >= 0) {
132
- // normalised against the CAP, not 1 — lands on exactly 0 at widest
133
- const open = jawCap > 0 ? Math.min(1, jaw / jawCap) : 0;
134
- out[ch.teethDownLower] = teethLowerTuck * (1 - open);
135
- }
185
+ out[ch.teethUpUpper] = teethUpperRest + jaw * teethUpperLift;
186
+ if (ch.teethDownLower >= 0)
187
+ out[ch.teethDownLower] = teethLowerRest + jaw * teethLowerDrop;
136
188
  // speech energy: fast attack, slow release
137
189
  let mag = jaw;
138
190
  for (const i of ch.funnel)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@convai/embody",
3
- "version": "0.6.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "Give a Convai character a body: resolve 3D characters by characterId and drive them with lipsync and animation. Layered on @convai/web-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Convai <support@convai.com>",
@@ -58,10 +58,10 @@
58
58
  "access": "public"
59
59
  },
60
60
  "peerDependencies": {
61
- "@convai/web-sdk": ">=1.8.0-beta.4 || >=2.0.0-beta.0",
62
- "@react-three/fiber": ">=8",
63
- "react": ">=18",
64
- "three": ">=0.160"
61
+ "@convai/web-sdk": ">=2.0.1 <3",
62
+ "@react-three/fiber": ">=8 <10",
63
+ "react": ">=18 <20",
64
+ "three": ">=0.160 <1"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "react": {
@@ -73,8 +73,8 @@
73
73
  },
74
74
  "devDependencies": {
75
75
  "@changesets/cli": "^2.27.0",
76
- "@convai/web-sdk": "2.0.0-beta.0",
77
- "@convai/web-widget": "0.2.1",
76
+ "@convai/web-sdk": "2.0.1",
77
+ "@convai/web-widget": "0.3.0",
78
78
  "@react-three/fiber": "^8.18.0",
79
79
  "@types/react": "^18.3.31",
80
80
  "@types/react-dom": "^19.2.7",