talking-head-studio 0.4.10 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +227 -351
  2. package/dist/TalkingHead.d.ts +16 -25
  3. package/dist/TalkingHead.web.d.ts +6 -0
  4. package/dist/TalkingHead.web.js +17 -7
  5. package/dist/api/studioApi.js +25 -26
  6. package/dist/appearance/apply.js +2 -3
  7. package/dist/appearance/matchers.js +1 -2
  8. package/dist/appearance/schema.js +1 -2
  9. package/dist/core/avatar/backend.d.ts +130 -0
  10. package/dist/core/avatar/backend.js +4 -0
  11. package/dist/core/avatar/backends/gaussian.d.ts +49 -0
  12. package/dist/core/avatar/backends/gaussian.js +291 -0
  13. package/dist/core/avatar/backends/index.d.ts +3 -0
  14. package/dist/core/avatar/backends/index.js +7 -0
  15. package/dist/core/avatar/backends/morphTarget.d.ts +39 -0
  16. package/dist/core/avatar/backends/morphTarget.js +179 -0
  17. package/dist/core/avatar/faceControls.d.ts +40 -0
  18. package/dist/core/avatar/faceControls.js +138 -0
  19. package/dist/core/avatar/schema.d.ts +50 -0
  20. package/dist/core/avatar/schema.js +134 -0
  21. package/dist/core/avatar/visemes.d.ts +31 -0
  22. package/dist/core/avatar/visemes.js +67 -1
  23. package/dist/editor/AvatarCanvas.js +1 -2
  24. package/dist/editor/AvatarEditor.native.js +18 -9
  25. package/dist/editor/AvatarModel.js +1 -2
  26. package/dist/editor/FaceSqueezeEditor.js +19 -9
  27. package/dist/editor/FaceSqueezeEditor.web.js +2 -2
  28. package/dist/editor/RigidAccessory.js +1 -2
  29. package/dist/editor/SkinnedClothing.js +18 -9
  30. package/dist/editor/boneSnap.js +22 -12
  31. package/dist/editor/studioTheme.js +2 -2
  32. package/dist/html.js +1 -2
  33. package/dist/index.d.ts +15 -1
  34. package/dist/index.js +28 -5
  35. package/dist/platform/api/types.d.ts +10 -0
  36. package/dist/platform/api/types.js +2 -0
  37. package/dist/platform/marketplace/types.d.ts +32 -0
  38. package/dist/platform/marketplace/types.js +2 -0
  39. package/dist/platform/sdk/unity.d.ts +27 -0
  40. package/dist/platform/sdk/unity.js +2 -0
  41. package/dist/platform/sdk/unreal.d.ts +23 -0
  42. package/dist/platform/sdk/unreal.js +2 -0
  43. package/dist/platform/sdk/web.d.ts +16 -0
  44. package/dist/platform/sdk/web.js +2 -0
  45. package/dist/sketchfab/api.js +4 -5
  46. package/dist/sketchfab/useSketchfabSearch.js +1 -2
  47. package/dist/tts/useDirectVisemeStream.d.ts +2 -6
  48. package/dist/tts/useDirectVisemeStream.js +1 -2
  49. package/dist/tts/useMotionMarkers.d.ts +0 -1
  50. package/dist/tts/useMotionMarkers.js +1 -2
  51. package/dist/utils/avatarUtils.js +2 -3
  52. package/dist/utils/faceLandmarkerToShapeWeights.js +19 -10
  53. package/dist/voice/convertToWav.js +1 -2
  54. package/dist/voice/index.d.ts +3 -0
  55. package/dist/voice/index.js +6 -1
  56. package/dist/voice/useAudioPlayer.js +1 -2
  57. package/dist/voice/useAudioRecording.js +1 -2
  58. package/dist/voice/useFaceControls.d.ts +14 -0
  59. package/dist/voice/useFaceControls.js +81 -0
  60. package/dist/voice/useVoicePreview.d.ts +7 -0
  61. package/dist/voice/useVoicePreview.js +81 -0
  62. package/dist/wardrobe/index.d.ts +2 -0
  63. package/dist/wardrobe/index.js +3 -1
  64. package/dist/wardrobe/useAvatarWardrobeHydration.js +1 -2
  65. package/dist/wardrobe/useStudioAvatar.d.ts +29 -0
  66. package/dist/wardrobe/useStudioAvatar.js +177 -0
  67. package/dist/wgpu/WgpuAvatar.js +17 -7
  68. package/dist/wgpu/useAuthedModelUri.js +18 -9
  69. package/package.json +8 -4
@@ -0,0 +1,291 @@
1
+ "use strict";
2
+ // src/core/avatar/backends/gaussian.ts
3
+ // GaussianBackend — zero-prerequisite lip-sync for any GLB.
4
+ //
5
+ // Strategy: instead of relying on existing morph targets, we precompute
6
+ // per-viseme deformation patches using approximate FLAME-to-skeleton transfer.
7
+ // Each patch stores bone rotation deltas (for rigged models) and/or vertex
8
+ // position deltas (for unrigged meshes). setControl() blends active patches
9
+ // onto the model's base pose proportionally to ExpressionState weights.
10
+ //
11
+ // This is the "any model works" backend — no blend shapes, no rig requirements,
12
+ // no artist work. Quality scales with skeleton completeness: humanoid rigs get
13
+ // expressive results; static meshes get jaw + head motion only.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.GaussianBackend = void 0;
16
+ const visemes_1 = require("../visemes");
17
+ const FLAME_VISEME_COEFFS = {
18
+ sil: [],
19
+ PP: [
20
+ { bone: 'LowerJaw', dx: 0.02, dy: 0, dz: 0 },
21
+ { bone: 'LowerLip', dx: 0.04, dy: 0, dz: 0 },
22
+ ],
23
+ FF: [
24
+ { bone: 'LowerJaw', dx: 0.06, dy: 0, dz: 0 },
25
+ { bone: 'UpperLip', dx: -0.05, dy: 0, dz: 0 },
26
+ { bone: 'LowerLip', dx: 0.08, dy: 0, dz: 0 },
27
+ ],
28
+ TH: [
29
+ { bone: 'LowerJaw', dx: 0.10, dy: 0, dz: 0 },
30
+ { bone: 'Tongue', dx: 0.12, dy: 0, dz: 0 },
31
+ ],
32
+ DD: [
33
+ { bone: 'LowerJaw', dx: 0.12, dy: 0, dz: 0 },
34
+ { bone: 'Tongue', dx: 0.08, dy: 0, dz: 0 },
35
+ ],
36
+ kk: [
37
+ { bone: 'LowerJaw', dx: 0.15, dy: 0, dz: 0 },
38
+ ],
39
+ CH: [
40
+ { bone: 'LowerJaw', dx: 0.16, dy: 0, dz: 0 },
41
+ { bone: 'LowerLip', dx: 0.06, dy: 0, dz: 0.03 },
42
+ { bone: 'UpperLip', dx: -0.03, dy: 0, dz: -0.03 },
43
+ ],
44
+ SS: [
45
+ { bone: 'LowerJaw', dx: 0.05, dy: 0, dz: 0 },
46
+ { bone: 'LowerLip', dx: 0.03, dy: 0, dz: 0.04 },
47
+ ],
48
+ nn: [
49
+ { bone: 'LowerJaw', dx: 0.08, dy: 0, dz: 0 },
50
+ { bone: 'Tongue', dx: 0.06, dy: 0, dz: 0 },
51
+ ],
52
+ RR: [
53
+ { bone: 'LowerJaw', dx: 0.10, dy: 0, dz: 0 },
54
+ { bone: 'LowerLip', dx: 0.05, dy: 0, dz: 0.02 },
55
+ ],
56
+ aa: [
57
+ { bone: 'LowerJaw', dx: 0.40, dy: 0, dz: 0 },
58
+ { bone: 'LowerLip', dx: 0.12, dy: 0, dz: 0 },
59
+ ],
60
+ ee: [
61
+ { bone: 'LowerJaw', dx: 0.22, dy: 0, dz: 0 },
62
+ { bone: 'LowerLip', dx: -0.05, dy: 0, dz: 0.08 },
63
+ { bone: 'UpperLip', dx: -0.04, dy: 0, dz: -0.06 },
64
+ ],
65
+ ih: [
66
+ { bone: 'LowerJaw', dx: 0.18, dy: 0, dz: 0 },
67
+ { bone: 'LowerLip', dx: -0.03, dy: 0, dz: 0.05 },
68
+ ],
69
+ oh: [
70
+ { bone: 'LowerJaw', dx: 0.28, dy: 0, dz: 0 },
71
+ { bone: 'LowerLip', dx: 0.10, dy: 0, dz: -0.05 },
72
+ { bone: 'UpperLip', dx: -0.06, dy: 0, dz: 0.04 },
73
+ ],
74
+ ou: [
75
+ { bone: 'LowerJaw', dx: 0.12, dy: 0, dz: 0 },
76
+ { bone: 'LowerLip', dx: 0.14, dy: 0, dz: -0.08 },
77
+ { bone: 'UpperLip', dx: -0.08, dy: 0, dz: 0.06 },
78
+ ],
79
+ };
80
+ // Standard Mixamo bone name aliases for jaw / lips / tongue / eyes.
81
+ // We try each alias in order; the first found in the skeleton wins.
82
+ const BONE_ALIASES = {
83
+ LowerJaw: ['lowerJaw', 'jaw', 'Jaw', 'mandible', 'Head'], // Head fallback for rigid jaw
84
+ UpperLip: ['upperLip', 'UpperLip', 'lip_upper', 'mouthTopLip'],
85
+ LowerLip: ['lowerLip', 'LowerLip', 'lip_lower', 'mouthBottomLip'],
86
+ Tongue: ['tongue', 'Tongue', 'tongue01'],
87
+ Head: ['Head', 'head', 'mixamorigHead'],
88
+ LeftEye: ['LeftEye', 'leftEye', 'mixamorigLeftEye', 'eye_l', 'Eye_L'],
89
+ RightEye: ['RightEye', 'rightEye', 'mixamorigRightEye', 'eye_r', 'Eye_R'],
90
+ };
91
+ // ─── Implementation ──────────────────────────────────────────────────────────
92
+ class GaussianBackend {
93
+ constructor(config) {
94
+ /** Resolved skeleton bone map: canonical name → Three.js Bone */
95
+ this.boneMap = new Map();
96
+ /** Pre-computed deformation patches, one per Oculus viseme. */
97
+ this.patches = new Map();
98
+ /** Snapshot of bone rotations at attach() — restored on dispose(). */
99
+ this.basePose = [];
100
+ /** Last control applied — used in renderFrame() to avoid redundant writes. */
101
+ this.lastControl = null;
102
+ this.scene = config.scene;
103
+ this.schemaReport = config.schemaReport;
104
+ this.calibration = config.calibration ?? {
105
+ neutral: { pose: { yaw: 0, pitch: 0, roll: 0 }, expr: {} },
106
+ };
107
+ this.patchQuality = config.patchQuality ?? 'full';
108
+ }
109
+ // ── AvatarBackend.initialize ────────────────────────────────────────────
110
+ initialize() {
111
+ this._buildBoneMap();
112
+ this._captureBasePose();
113
+ this._buildPatches();
114
+ }
115
+ // ── AvatarBackend.attach ─────────────────────────────────────────────────
116
+ // Scene is passed in the constructor; attach is a no-op but can be used
117
+ // to re-initialize on scene swap.
118
+ attach(_target) {
119
+ this.initialize();
120
+ }
121
+ // ── AvatarBackend.setControl ─────────────────────────────────────────────
122
+ setControl(control) {
123
+ this.lastControl = control;
124
+ }
125
+ // ── AvatarBackend.renderFrame ────────────────────────────────────────────
126
+ // Writes blended bone deltas to the skeleton each render tick.
127
+ renderFrame() {
128
+ if (!this.lastControl)
129
+ return;
130
+ this._applyControl(this.lastControl);
131
+ }
132
+ // ── AvatarBackend.dispose ────────────────────────────────────────────────
133
+ dispose() {
134
+ this._restoreBasePose();
135
+ this.boneMap.clear();
136
+ this.patches.clear();
137
+ this.basePose = [];
138
+ this.lastControl = null;
139
+ }
140
+ // ── Internal: bone map ───────────────────────────────────────────────────
141
+ _buildBoneMap() {
142
+ this.boneMap.clear();
143
+ const allBones = new Map();
144
+ this.scene.traverse((obj) => {
145
+ if (obj.isBone)
146
+ allBones.set(obj.name, obj);
147
+ if (obj.isSkinnedMesh && obj.skeleton?.bones) {
148
+ for (const b of obj.skeleton.bones) {
149
+ allBones.set(b.name, b);
150
+ }
151
+ }
152
+ });
153
+ for (const [canonical, aliases] of Object.entries(BONE_ALIASES)) {
154
+ for (const alias of aliases) {
155
+ // Case-insensitive match
156
+ const found = [...allBones.entries()].find(([name]) => name.toLowerCase() === alias.toLowerCase());
157
+ if (found) {
158
+ this.boneMap.set(canonical, found[1]);
159
+ break;
160
+ }
161
+ }
162
+ }
163
+ }
164
+ // ── Internal: base pose snapshot ─────────────────────────────────────────
165
+ _captureBasePose() {
166
+ this.basePose = [];
167
+ for (const bone of this.boneMap.values()) {
168
+ this.basePose.push({
169
+ bone,
170
+ rx: bone.rotation.x,
171
+ ry: bone.rotation.y,
172
+ rz: bone.rotation.z,
173
+ });
174
+ }
175
+ }
176
+ _restoreBasePose() {
177
+ for (const { bone, rx, ry, rz } of this.basePose) {
178
+ bone.rotation.set(rx, ry, rz);
179
+ }
180
+ }
181
+ // ── Internal: patch compilation ──────────────────────────────────────────
182
+ _buildPatches() {
183
+ this.patches.clear();
184
+ for (const viseme of visemes_1.OCULUS_VISEMES) {
185
+ const coeffs = FLAME_VISEME_COEFFS[viseme] ?? [];
186
+ const targets = this.patchQuality === 'fast'
187
+ ? coeffs.filter(c => c.bone === 'LowerJaw')
188
+ : coeffs;
189
+ const boneDeltas = [];
190
+ for (const coeff of targets) {
191
+ const bone = this.boneMap.get(coeff.bone);
192
+ if (bone) {
193
+ boneDeltas.push({ bone, dx: coeff.dx, dy: coeff.dy, dz: coeff.dz });
194
+ }
195
+ }
196
+ // Always fall back to Head/LowerJaw if no lip bones found and viseme
197
+ // has any jaw motion — keeps jaw-only models animated.
198
+ if (boneDeltas.length === 0) {
199
+ const jawCoeff = coeffs.find(c => c.bone === 'LowerJaw');
200
+ if (jawCoeff) {
201
+ const fallback = this.boneMap.get('LowerJaw') ?? this.boneMap.get('Head');
202
+ if (fallback) {
203
+ boneDeltas.push({ bone: fallback, dx: jawCoeff.dx * 0.6, dy: 0, dz: 0 });
204
+ }
205
+ }
206
+ }
207
+ this.patches.set(viseme, { viseme, boneDeltas });
208
+ }
209
+ }
210
+ // ── Internal: apply FaceControl to skeleton ───────────────────────────────
211
+ _applyControl(control) {
212
+ const { expr, pose } = control;
213
+ const scale = 1.0;
214
+ // Reset to base pose each frame before accumulating deltas.
215
+ this._restoreBasePose();
216
+ // ── Expression channels → viseme patches ─────────────────────────────
217
+ // Map each ExpressionState channel to the closest Oculus viseme and
218
+ // accumulate the patch weighted by the channel value.
219
+ //
220
+ // Channel → viseme correspondence (loose phonetic mapping):
221
+ const CHANNEL_VISEME_MAP = [
222
+ ['jawOpen', 'aa', 1.0],
223
+ ['mouthFunnel', 'oh', 1.0],
224
+ ['mouthPucker', 'ou', 1.0],
225
+ ['mouthWide', 'ee', 0.8],
226
+ ['mouthSmile', 'ee', 0.4],
227
+ ['upperLipRaise', 'FF', 0.7],
228
+ ['lowerLipDepress', 'FF', 0.7],
229
+ ];
230
+ for (const [channel, viseme, gain] of CHANNEL_VISEME_MAP) {
231
+ const w = expr[channel] ?? 0;
232
+ if (w <= 0.001)
233
+ continue;
234
+ const patch = this.patches.get(viseme);
235
+ if (!patch)
236
+ continue;
237
+ const cal = this.calibration.ranges;
238
+ const range = cal?.[channel];
239
+ const remapped = range ? range.min + w * (range.max - range.min) : w;
240
+ const totalWeight = remapped * gain * scale;
241
+ for (const { bone, dx, dy, dz } of patch.boneDeltas) {
242
+ bone.rotation.x += dx * totalWeight;
243
+ bone.rotation.y += dy * totalWeight;
244
+ bone.rotation.z += dz * totalWeight;
245
+ }
246
+ }
247
+ // ── Head pose ────────────────────────────────────────────────────────
248
+ // Apply FaceControl head pose to the Head bone.
249
+ const headBone = this.boneMap.get('Head');
250
+ if (headBone) {
251
+ headBone.rotation.y += pose.yaw * 0.3; // radians, limited
252
+ headBone.rotation.x += pose.pitch * 0.2;
253
+ headBone.rotation.z += pose.roll * 0.15;
254
+ }
255
+ // ── Eye gaze ─────────────────────────────────────────────────────────
256
+ this._applyGaze(expr.eyeGazeLeft, 'LeftEye');
257
+ this._applyGaze(expr.eyeGazeRight, 'RightEye');
258
+ }
259
+ _applyGaze(gaze, canonicalBone) {
260
+ const bone = this.boneMap.get(canonicalBone);
261
+ if (!bone)
262
+ return;
263
+ const limits = this.calibration.gazeLimits;
264
+ const clamp = (v, axis) => {
265
+ const lim = limits?.[axis];
266
+ return lim ? Math.min(lim.max, Math.max(lim.min, v)) : v;
267
+ };
268
+ // x → yaw (left/right), y → pitch (down/up)
269
+ bone.rotation.y += clamp(gaze.x, 'x') * 0.25;
270
+ bone.rotation.x -= clamp(gaze.y, 'y') * 0.25; // negative: up = negative pitch
271
+ }
272
+ // ── Public diagnostics ───────────────────────────────────────────────────
273
+ /** Canonical bones resolved from the skeleton. Useful for debugging rig coverage. */
274
+ get resolvedBones() {
275
+ return Array.from(this.boneMap.keys());
276
+ }
277
+ /** Number of viseme patches with at least one resolved bone. */
278
+ get patchCoverage() {
279
+ let count = 0;
280
+ for (const patch of this.patches.values()) {
281
+ if (patch.boneDeltas.length > 0)
282
+ count++;
283
+ }
284
+ return count;
285
+ }
286
+ /** The schema report this backend was initialized with. */
287
+ get schema() {
288
+ return this.schemaReport;
289
+ }
290
+ }
291
+ exports.GaussianBackend = GaussianBackend;
@@ -0,0 +1,3 @@
1
+ export { MorphTargetBackend } from './morphTarget';
2
+ export { GaussianBackend } from './gaussian';
3
+ export type { GaussianBackendConfig } from './gaussian';
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GaussianBackend = exports.MorphTargetBackend = void 0;
4
+ var morphTarget_1 = require("./morphTarget");
5
+ Object.defineProperty(exports, "MorphTargetBackend", { enumerable: true, get: function () { return morphTarget_1.MorphTargetBackend; } });
6
+ var gaussian_1 = require("./gaussian");
7
+ Object.defineProperty(exports, "GaussianBackend", { enumerable: true, get: function () { return gaussian_1.GaussianBackend; } });
@@ -0,0 +1,39 @@
1
+ import type * as THREE from 'three';
2
+ import type { AvatarBackend, AvatarRenderTarget, CalibrationProfile } from '../backend';
3
+ import type { FaceControl } from '../faceControls';
4
+ interface MorphTargetBackendConfig {
5
+ /** Optional calibration profile to remap control ranges per-avatar. */
6
+ calibration?: CalibrationProfile;
7
+ /** Optional mood name to layer over expressions (from MOOD_MORPHS). */
8
+ mood?: string;
9
+ /** Scale factor for expression morph weights (default 1.0). */
10
+ expressionScale?: number;
11
+ }
12
+ export declare class MorphTargetBackend implements AvatarBackend {
13
+ private scene;
14
+ private meshes;
15
+ private visemeCache;
16
+ private expressionCache;
17
+ private gazeCache;
18
+ private config;
19
+ constructor(scene: THREE.Object3D, config?: MorphTargetBackendConfig);
20
+ /** Scan the scene for meshes with morph targets and build lookup caches. */
21
+ private _buildCaches;
22
+ private _buildNamedCache;
23
+ private _setWeight;
24
+ private _zeroCache;
25
+ attach(_target: AvatarRenderTarget): void;
26
+ setControl(control: FaceControl): void;
27
+ renderFrame(): void;
28
+ setMood(mood: string): void;
29
+ dispose(): void;
30
+ /** Re-scan the scene after hot-swapping the GLB (e.g. wardrobe change). */
31
+ rebuildCaches(): void;
32
+ /** Read-only view of which morph channels were found on this model. */
33
+ get availableChannels(): {
34
+ visemes: string[];
35
+ expressions: string[];
36
+ gaze: string[];
37
+ };
38
+ }
39
+ export {};
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ // src/core/avatar/backends/morphTarget.ts
3
+ // AvatarBackend implementation for Three.js GLB models with morph targets.
4
+ //
5
+ // Maps the canonical FaceControl (ExpressionState + HeadPose) to morph target
6
+ // weights on a Three.js Object3D scene. Works with any GLB that has Oculus
7
+ // viseme morphs, ARKit shapes, or the simpler fallback names from morphTables.ts.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.MorphTargetBackend = void 0;
10
+ const morphTables_1 = require("../../../wgpu/morphTables");
11
+ // ─── ExpressionState → morph target name mapping ────────────────────────────
12
+ //
13
+ // Each ExpressionState channel maps to one or more morph target names to try,
14
+ // in priority order. The first name found on any mesh wins.
15
+ // This bridges the abstract FaceControl space to concrete GLB morph names.
16
+ const EXPRESSION_MORPH_ALIASES = {
17
+ jawOpen: ['jawOpen', 'jaw_open', 'viseme_aa', 'mouthOpen'],
18
+ mouthSmile: ['mouthSmileLeft', 'mouthSmile', 'mouth_smile'],
19
+ mouthFunnel: ['mouthFunnel', 'mouth_funnel', 'viseme_O', 'viseme_oh'],
20
+ mouthPucker: ['mouthPucker', 'mouth_pucker', 'viseme_U', 'viseme_ou'],
21
+ mouthWide: ['mouthStretchLeft', 'mouthWide', 'mouth_wide'],
22
+ upperLipRaise: ['mouthUpperUpLeft', 'mouthUpperUpRight', 'upperLipRaiser'],
23
+ lowerLipDepress: ['mouthLowerDownLeft', 'mouthLowerDownRight', 'lowerLipDepressor'],
24
+ cheekRaise: ['cheekSquintLeft', 'cheekSquintRight', 'cheekRaiser'],
25
+ blinkLeft: ['eyeBlinkLeft', 'blink_left', 'eyesClosed'],
26
+ blinkRight: ['eyeBlinkRight', 'blink_right', 'eyesClosed'],
27
+ browInnerUp: ['browInnerUp', 'brow_inner_up', 'innerBrowRaiser'],
28
+ browDownLeft: ['browDownLeft', 'brow_down_left', 'browLowererLeft'],
29
+ browDownRight: ['browDownRight', 'brow_down_right', 'browLowererRight'],
30
+ };
31
+ // Eye gaze morph names (separate from scalar channels)
32
+ const EYE_GAZE_MORPHS = {
33
+ lookLeft: ['eyeLookOutLeft', 'eyeLookLeft', 'eye_look_left'],
34
+ lookRight: ['eyeLookOutRight', 'eyeLookRight', 'eye_look_right'],
35
+ lookUp: ['eyeLookUpLeft', 'eyeLookUpRight', 'eyeLookUp', 'eye_look_up'],
36
+ lookDown: ['eyeLookDownLeft', 'eyeLookDownRight', 'eyeLookDown', 'eye_look_down'],
37
+ };
38
+ // ─── Implementation ──────────────────────────────────────────────────────────
39
+ class MorphTargetBackend {
40
+ constructor(scene, config = {}) {
41
+ this.scene = null;
42
+ this.meshes = [];
43
+ this.visemeCache = {};
44
+ this.expressionCache = {};
45
+ this.gazeCache = {};
46
+ this.scene = scene;
47
+ this.config = {
48
+ calibration: config.calibration ?? { neutral: { pose: { yaw: 0, pitch: 0, roll: 0 }, expr: {} } },
49
+ mood: config.mood ?? 'neutral',
50
+ expressionScale: config.expressionScale ?? 1.0,
51
+ };
52
+ this._buildCaches();
53
+ }
54
+ /** Scan the scene for meshes with morph targets and build lookup caches. */
55
+ _buildCaches() {
56
+ if (!this.scene)
57
+ return;
58
+ this.meshes = [];
59
+ this.scene.traverse((child) => {
60
+ const mesh = child;
61
+ if (mesh.isMesh && mesh.morphTargetDictionary && mesh.morphTargetInfluences) {
62
+ this.meshes.push(mesh);
63
+ }
64
+ });
65
+ this.visemeCache = this._buildNamedCache(morphTables_1.VISEME_MORPH_ALIASES);
66
+ this.expressionCache = this._buildNamedCache(EXPRESSION_MORPH_ALIASES);
67
+ this.gazeCache = this._buildNamedCache(EYE_GAZE_MORPHS);
68
+ }
69
+ _buildNamedCache(aliasMap) {
70
+ const cache = {};
71
+ for (const [key, aliases] of Object.entries(aliasMap)) {
72
+ const entries = [];
73
+ for (const mesh of this.meshes) {
74
+ const dict = mesh.morphTargetDictionary;
75
+ const dictLower = Object.fromEntries(Object.keys(dict).map(k => [k.toLowerCase(), k]));
76
+ for (const alias of aliases) {
77
+ const found = dictLower[alias.toLowerCase()];
78
+ if (found !== undefined) {
79
+ entries.push({ influences: mesh.morphTargetInfluences, idx: dict[found] });
80
+ break; // first alias match per mesh
81
+ }
82
+ }
83
+ }
84
+ if (entries.length > 0)
85
+ cache[key] = entries;
86
+ }
87
+ return cache;
88
+ }
89
+ _setWeight(cache, key, weight) {
90
+ const entries = cache[key];
91
+ if (!entries)
92
+ return;
93
+ const w = Math.min(1, Math.max(0, weight));
94
+ for (const e of entries)
95
+ e.influences[e.idx] = w;
96
+ }
97
+ _zeroCache(cache) {
98
+ for (const entries of Object.values(cache)) {
99
+ for (const e of entries)
100
+ e.influences[e.idx] = 0;
101
+ }
102
+ }
103
+ // AvatarBackend.attach — this backend takes an Object3D directly in the
104
+ // constructor, so attach is a no-op (scene already set).
105
+ attach(_target) { }
106
+ setControl(control) {
107
+ if (!this.scene)
108
+ return;
109
+ const { expr } = control;
110
+ const scale = this.config.expressionScale;
111
+ const cal = this.config.calibration.ranges ?? {};
112
+ // Zero all managed channels before applying new values
113
+ this._zeroCache(this.visemeCache);
114
+ this._zeroCache(this.expressionCache);
115
+ this._zeroCache(this.gazeCache);
116
+ // Apply mood morphs as a base layer (low-weight persistent shapes)
117
+ const moodMorphs = morphTables_1.MOOD_MORPHS[this.config.mood] ?? {};
118
+ for (const [morphName, weight] of Object.entries(moodMorphs)) {
119
+ this._setWeight(this.visemeCache, morphName, weight);
120
+ this._setWeight(this.expressionCache, morphName, weight);
121
+ }
122
+ // Apply ExpressionState channels
123
+ for (const [channel, aliases] of Object.entries(EXPRESSION_MORPH_ALIASES)) {
124
+ const key = channel;
125
+ const raw = expr[key] ?? 0;
126
+ const range = cal[key];
127
+ const remapped = range ? range.min + raw * (range.max - range.min) : raw;
128
+ void aliases; // alias lookup happens via cache
129
+ this._setWeight(this.expressionCache, key, remapped * scale);
130
+ }
131
+ // Apply eye gaze — split x/y into directional morph targets
132
+ const gl = expr.eyeGazeLeft;
133
+ const gr = expr.eyeGazeRight;
134
+ const gazeX = (gl.x + gr.x) / 2;
135
+ const gazeY = (gl.y + gr.y) / 2;
136
+ const gazeLimits = this.config.calibration.gazeLimits;
137
+ const clampGaze = (v, axis) => {
138
+ const lim = gazeLimits?.[axis];
139
+ return lim ? Math.min(lim.max, Math.max(lim.min, v)) : v;
140
+ };
141
+ const cx = clampGaze(gazeX, 'x');
142
+ const cy = clampGaze(gazeY, 'y');
143
+ if (cx < 0)
144
+ this._setWeight(this.gazeCache, 'lookLeft', -cx);
145
+ if (cx > 0)
146
+ this._setWeight(this.gazeCache, 'lookRight', cx);
147
+ if (cy > 0)
148
+ this._setWeight(this.gazeCache, 'lookUp', cy);
149
+ if (cy < 0)
150
+ this._setWeight(this.gazeCache, 'lookDown', -cy);
151
+ }
152
+ renderFrame() {
153
+ // Three.js applies morph target influences automatically each render loop.
154
+ // Nothing to do here — weights set in setControl are live on the mesh.
155
+ }
156
+ setMood(mood) {
157
+ this.config.mood = mood;
158
+ }
159
+ dispose() {
160
+ this._zeroCache(this.visemeCache);
161
+ this._zeroCache(this.expressionCache);
162
+ this._zeroCache(this.gazeCache);
163
+ this.meshes = [];
164
+ this.scene = null;
165
+ }
166
+ /** Re-scan the scene after hot-swapping the GLB (e.g. wardrobe change). */
167
+ rebuildCaches() {
168
+ this._buildCaches();
169
+ }
170
+ /** Read-only view of which morph channels were found on this model. */
171
+ get availableChannels() {
172
+ return {
173
+ visemes: Object.keys(this.visemeCache),
174
+ expressions: Object.keys(this.expressionCache),
175
+ gaze: Object.keys(this.gazeCache),
176
+ };
177
+ }
178
+ }
179
+ exports.MorphTargetBackend = MorphTargetBackend;
@@ -0,0 +1,40 @@
1
+ import type { OculusViseme } from './visemes';
2
+ export type HeadPose = {
3
+ /** -1..1, left..right */
4
+ yaw: number;
5
+ /** -1..1, down..up */
6
+ pitch: number;
7
+ /** -1..1, left-ear-down..right-ear-down */
8
+ roll: number;
9
+ };
10
+ export type EyeGaze = {
11
+ /** -1..1, left..right */
12
+ x: number;
13
+ /** -1..1, down..up */
14
+ y: number;
15
+ };
16
+ export type ExpressionState = {
17
+ jawOpen: number;
18
+ mouthSmile: number;
19
+ mouthFunnel: number;
20
+ mouthPucker: number;
21
+ mouthWide: number;
22
+ upperLipRaise: number;
23
+ lowerLipDepress: number;
24
+ cheekRaise: number;
25
+ blinkLeft: number;
26
+ blinkRight: number;
27
+ browInnerUp: number;
28
+ browDownLeft: number;
29
+ browDownRight: number;
30
+ eyeGazeLeft: EyeGaze;
31
+ eyeGazeRight: EyeGaze;
32
+ };
33
+ export type FaceControl = {
34
+ pose: HeadPose;
35
+ expr: ExpressionState;
36
+ };
37
+ export type Viseme = OculusViseme;
38
+ export declare function createNeutralExpression(): ExpressionState;
39
+ export declare function visemeToExpression(viseme: Viseme, weight?: number): Partial<ExpressionState>;
40
+ export declare function applyVisemeToExpression(base: ExpressionState, viseme: Viseme, weight?: number): ExpressionState;