@convai/embody 0.6.0 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +82 -10
- package/dist/character/createCharacter.d.ts +10 -1
- package/dist/character/createCharacter.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/motion/eyeGaze.d.ts +7 -7
- package/dist/motion/eyeGaze.js +16 -11
- package/dist/react/ConvaiCharacter.js +2 -1
- package/dist/react/RoomLight.d.ts +3 -0
- package/dist/react/RoomLight.js +41 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +3 -3
- package/dist/react/useCharacter.d.ts +6 -0
- package/dist/react/useCharacter.js +19 -4
- package/dist/render/faceMaterials.d.ts +5 -1
- package/dist/render/faceMaterials.js +89 -3
- package/dist/resolve/modelVariant.d.ts +44 -0
- package/dist/resolve/modelVariant.js +68 -0
- package/dist/resolve/resolver.d.ts +4 -1
- package/dist/resolve/resolver.js +1 -1
- package/dist/rig/profile.js +9 -3
- package/dist/speech/lipsync.d.ts +35 -4
- package/dist/speech/lipsync.js +70 -18
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @convai/embody
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
9
|
+
Migration from 0.4.2:
|
|
10
|
+
|
|
11
|
+
| Was (0.4.2) | Now (1.0.0) |
|
|
12
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
13
|
+
| `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 |
|
|
14
|
+
| `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) |
|
|
15
|
+
| `@convai/web-sdk` peer `>=1.8.0-beta.4` | `>=1.9.4 <3`: 1.9.4 or any 2.x |
|
|
16
|
+
| `react` / `@react-three/fiber` / `three` peers unbounded | `react >=18 <20`, `@react-three/fiber >=8 <10`, `three >=0.160 <1` |
|
|
17
|
+
|
|
18
|
+
Nothing else needs a code change: every other export and option is unchanged or new (corrective maps, Full/Lite bodies, default-body face fixes).
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- 86a6659: Face fixes for the convai-mha-1 default bodies (Formal Women, Activity Convai):
|
|
27
|
+
|
|
28
|
+
- 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`.
|
|
29
|
+
- Gaze: the eye ease no longer speeds up on high-refresh displays.
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
32
|
+
> 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.
|
|
33
|
+
|
|
3
34
|
## 0.6.0
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
|
@@ -20,6 +51,32 @@
|
|
|
20
51
|
|
|
21
52
|
- 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
53
|
|
|
54
|
+
## 0.4.2
|
|
55
|
+
|
|
56
|
+
### Patch Changes
|
|
57
|
+
|
|
58
|
+
- 532c49e: Swapping the body under a fixed character id now reaches the screen.
|
|
59
|
+
|
|
60
|
+
`useCharacter` rebuilt the character only when the character id, credentials
|
|
61
|
+
or base url changed. The **resolver** was not among them, and the effect read
|
|
62
|
+
its options from a ref — so an authoring tool that holds one avatar id and
|
|
63
|
+
swaps the block underneath (preview release 3, then release 4) got nothing:
|
|
64
|
+
the old GLB stayed on the canvas and it read as a stubborn cache. Nothing was
|
|
65
|
+
cached. The loader was never asked for the new url.
|
|
66
|
+
|
|
67
|
+
The resolver decides WHICH body a character id resolves to, so it belongs in
|
|
68
|
+
the rebuild set, and now is.
|
|
69
|
+
|
|
70
|
+
This makes the resolver's identity part of the contract: **memoize it**, and
|
|
71
|
+
change it exactly when the body should change. A resolver rebuilt inline on
|
|
72
|
+
every render would rebuild the character on every render. The README's new
|
|
73
|
+
"Swapping the body under a fixed character id" section shows the shape.
|
|
74
|
+
|
|
75
|
+
Also documented, unchanged in behaviour: the `orbit` prop (shipped in 0.4.0
|
|
76
|
+
and previously absent from the README), which backend each npm tag talks to,
|
|
77
|
+
and the correct release-branch names — the table named three branches that do
|
|
78
|
+
not exist.
|
|
79
|
+
|
|
23
80
|
## 0.4.1
|
|
24
81
|
|
|
25
82
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Give a Convai character a body.**
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@convai/embody)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
|
|
@@ -12,13 +12,21 @@ lipsync, gaze, breathing and blink. Layered on
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
> **Branch: `
|
|
16
|
-
>
|
|
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,
|
|
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
|
-
{
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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';
|
package/dist/motion/eyeGaze.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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(
|
|
64
|
+
update(dt: number, ctx: FrameContext): void;
|
|
65
65
|
}
|
package/dist/motion/eyeGaze.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
55
|
-
//
|
|
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,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
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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. `
|
|
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
|
-
* {
|
|
28
|
+
* {client && <ConvaiWidget convaiClient={client} />}
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export { useCharacter } from './useCharacter.js';
|
package/dist/react/index.js
CHANGED
|
@@ -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. `
|
|
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
|
-
* {
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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 /
|
|
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 (
|
|
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
|
|
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;
|
package/dist/resolve/resolver.js
CHANGED
|
@@ -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
|
|
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.';
|
package/dist/rig/profile.js
CHANGED
|
@@ -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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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'],
|
package/dist/speech/lipsync.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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. */
|
package/dist/speech/lipsync.js
CHANGED
|
@@ -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
|
|
10
|
+
sealCeiling: 0,
|
|
6
11
|
sealOnset: 0.45,
|
|
7
|
-
teethRetract: 0.
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
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.
|
|
62
|
-
"@react-three/fiber": ">=8",
|
|
63
|
-
"react": ">=18",
|
|
64
|
-
"three": ">=0.160"
|
|
61
|
+
"@convai/web-sdk": ">=1.9.4 <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.
|
|
77
|
-
"@convai/web-widget": "0.
|
|
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",
|