talking-head-studio 0.4.10 → 0.4.12

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 (178) hide show
  1. package/README.md +299 -337
  2. package/dist/TalkingHead.d.ts +44 -28
  3. package/dist/TalkingHead.js +21 -2
  4. package/dist/TalkingHead.web.d.ts +37 -4
  5. package/dist/TalkingHead.web.js +28 -8
  6. package/dist/TalkingHeadVisualization.d.ts +22 -0
  7. package/dist/TalkingHeadVisualization.js +30 -10
  8. package/dist/api/studioApi.d.ts +12 -1
  9. package/dist/api/studioApi.js +41 -28
  10. package/dist/appearance/apply.js +2 -3
  11. package/dist/appearance/matchers.js +1 -2
  12. package/dist/appearance/schema.js +1 -2
  13. package/dist/contract.d.ts +14 -0
  14. package/dist/contract.js +30 -0
  15. package/dist/core/avatar/avatarCapabilities.d.ts +60 -0
  16. package/dist/core/avatar/avatarCapabilities.js +100 -0
  17. package/dist/core/avatar/backend.d.ts +130 -0
  18. package/dist/core/avatar/backend.js +4 -0
  19. package/dist/core/avatar/backends/gaussian.d.ts +49 -0
  20. package/dist/core/avatar/backends/gaussian.js +293 -0
  21. package/dist/core/avatar/backends/index.d.ts +3 -0
  22. package/dist/core/avatar/backends/index.js +7 -0
  23. package/dist/core/avatar/backends/morphTarget.d.ts +39 -0
  24. package/dist/core/avatar/backends/morphTarget.js +179 -0
  25. package/dist/core/avatar/faceControls.d.ts +40 -0
  26. package/dist/core/avatar/faceControls.js +138 -0
  27. package/dist/core/avatar/motion.d.ts +1713 -0
  28. package/dist/core/avatar/motion.js +550 -0
  29. package/dist/core/avatar/motionRuntime.d.ts +46 -0
  30. package/dist/core/avatar/motionRuntime.js +84 -0
  31. package/dist/core/avatar/schema.d.ts +78 -0
  32. package/dist/core/avatar/schema.js +134 -0
  33. package/dist/core/avatar/visemes.d.ts +47 -1
  34. package/dist/core/avatar/visemes.js +114 -1
  35. package/dist/editor/AvatarCanvas.js +93 -3
  36. package/dist/editor/AvatarEditor.native.js +19 -9
  37. package/dist/editor/AvatarModel.js +2 -2
  38. package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
  39. package/dist/editor/FaceSqueezeEditor.js +195 -121
  40. package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
  41. package/dist/editor/FaceSqueezeEditor.web.js +32 -30
  42. package/dist/editor/RigidAccessory.js +18 -4
  43. package/dist/editor/SkinnedClothing.js +19 -9
  44. package/dist/editor/boneLockedDrag.d.ts +11 -0
  45. package/dist/editor/boneLockedDrag.js +68 -0
  46. package/dist/editor/boneSnap.js +22 -12
  47. package/dist/editor/boneSnap.web.d.ts +27 -0
  48. package/dist/editor/boneSnap.web.js +99 -0
  49. package/dist/editor/index.web.d.ts +10 -0
  50. package/dist/editor/index.web.js +26 -0
  51. package/dist/editor/sounds/haha.wav +0 -0
  52. package/dist/editor/sounds/owie.wav +0 -0
  53. package/dist/editor/sounds/stop.wav +0 -0
  54. package/dist/editor/studioTheme.d.ts +14 -14
  55. package/dist/editor/studioTheme.js +19 -16
  56. package/dist/editor/types.d.ts +1 -0
  57. package/dist/html/accessories.d.ts +7 -0
  58. package/dist/html/accessories.js +149 -0
  59. package/dist/html/motion.d.ts +1 -0
  60. package/dist/html/motion.js +189 -0
  61. package/dist/html/visemes.d.ts +7 -0
  62. package/dist/html/visemes.js +348 -0
  63. package/dist/html.d.ts +1 -1
  64. package/dist/html.js +56 -734
  65. package/dist/index.d.ts +19 -1
  66. package/dist/index.js +44 -5
  67. package/dist/index.web.d.ts +18 -1
  68. package/dist/index.web.js +36 -3
  69. package/dist/platform/api/types.d.ts +10 -0
  70. package/dist/platform/api/types.js +2 -0
  71. package/dist/platform/marketplace/types.d.ts +32 -0
  72. package/dist/platform/marketplace/types.js +2 -0
  73. package/dist/platform/sdk/unity.d.ts +27 -0
  74. package/dist/platform/sdk/unity.js +2 -0
  75. package/dist/platform/sdk/unreal.d.ts +23 -0
  76. package/dist/platform/sdk/unreal.js +2 -0
  77. package/dist/platform/sdk/web.d.ts +16 -0
  78. package/dist/platform/sdk/web.js +2 -0
  79. package/dist/sketchfab/api.js +5 -5
  80. package/dist/sketchfab/glbInspect.d.ts +22 -0
  81. package/dist/sketchfab/glbInspect.js +58 -0
  82. package/dist/sketchfab/index.d.ts +3 -0
  83. package/dist/sketchfab/index.js +8 -1
  84. package/dist/sketchfab/inspectRemote.d.ts +13 -0
  85. package/dist/sketchfab/inspectRemote.js +77 -0
  86. package/dist/sketchfab/types.d.ts +10 -0
  87. package/dist/sketchfab/useSketchfabSearch.js +1 -2
  88. package/dist/studio/AccessoryBrowserScreen.d.ts +6 -0
  89. package/dist/studio/AccessoryBrowserScreen.js +626 -0
  90. package/dist/studio/AccessoryPanel.d.ts +10 -0
  91. package/dist/studio/AccessoryPanel.js +396 -0
  92. package/dist/studio/AppearancePanel.d.ts +9 -0
  93. package/dist/studio/AppearancePanel.js +77 -0
  94. package/dist/studio/AvatarCreatorScreen.d.ts +5 -0
  95. package/dist/studio/AvatarCreatorScreen.js +806 -0
  96. package/dist/studio/AvatarEditorScreen.d.ts +14 -0
  97. package/dist/studio/AvatarEditorScreen.js +510 -0
  98. package/dist/studio/AvatarGrid.d.ts +23 -0
  99. package/dist/studio/AvatarGrid.js +257 -0
  100. package/dist/studio/ColorSwatch.d.ts +8 -0
  101. package/dist/studio/ColorSwatch.js +100 -0
  102. package/dist/studio/CreateVoiceProfileSheet.d.ts +8 -0
  103. package/dist/studio/CreateVoiceProfileSheet.js +242 -0
  104. package/dist/studio/DetailsPanel.d.ts +15 -0
  105. package/dist/studio/DetailsPanel.js +239 -0
  106. package/dist/studio/FilamentEditor.d.ts +2 -0
  107. package/dist/studio/FilamentEditor.js +6 -0
  108. package/dist/studio/PrecisionPanel.d.ts +2 -0
  109. package/dist/studio/PrecisionPanel.js +7 -0
  110. package/dist/studio/PublicGalleryScreen.d.ts +5 -0
  111. package/dist/studio/PublicGalleryScreen.js +358 -0
  112. package/dist/studio/SketchfabModelCard.d.ts +20 -0
  113. package/dist/studio/SketchfabModelCard.js +104 -0
  114. package/dist/studio/StudioBrowseHeader.d.ts +9 -0
  115. package/dist/studio/StudioBrowseHeader.js +28 -0
  116. package/dist/studio/StudioEmptyState.d.ts +8 -0
  117. package/dist/studio/StudioEmptyState.js +29 -0
  118. package/dist/studio/StudioFloatingAction.d.ts +13 -0
  119. package/dist/studio/StudioFloatingAction.js +42 -0
  120. package/dist/studio/StudioSectionHeader.d.ts +7 -0
  121. package/dist/studio/StudioSectionHeader.js +27 -0
  122. package/dist/studio/StudioSurfaceCard.d.ts +8 -0
  123. package/dist/studio/StudioSurfaceCard.js +20 -0
  124. package/dist/studio/VoicePanel.d.ts +15 -0
  125. package/dist/studio/VoicePanel.js +305 -0
  126. package/dist/studio/constants.d.ts +3 -0
  127. package/dist/studio/constants.js +6 -0
  128. package/dist/studio/index.d.ts +29 -0
  129. package/dist/studio/index.js +54 -0
  130. package/dist/studio/useSketchfabCapabilities.d.ts +31 -0
  131. package/dist/studio/useSketchfabCapabilities.js +82 -0
  132. package/dist/tts/useDirectVisemeStream.d.ts +2 -6
  133. package/dist/tts/useDirectVisemeStream.js +16 -12
  134. package/dist/tts/useMotionMarkers.d.ts +0 -1
  135. package/dist/tts/useMotionMarkers.js +1 -2
  136. package/dist/utils/avatarUtils.js +94 -8
  137. package/dist/utils/faceLandmarkerToShapeWeights.js +21 -14
  138. package/dist/voice/convertToWav.js +1 -2
  139. package/dist/voice/index.d.ts +3 -0
  140. package/dist/voice/index.js +6 -1
  141. package/dist/voice/useAudioPlayer.js +18 -6
  142. package/dist/voice/useAudioRecording.js +1 -2
  143. package/dist/voice/useFaceControls.d.ts +14 -0
  144. package/dist/voice/useFaceControls.js +81 -0
  145. package/dist/voice/useVoicePreview.d.ts +7 -0
  146. package/dist/voice/useVoicePreview.js +83 -0
  147. package/dist/wardrobe/index.d.ts +3 -0
  148. package/dist/wardrobe/index.js +8 -1
  149. package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
  150. package/dist/wardrobe/useAccessoryGestures.js +94 -0
  151. package/dist/wardrobe/useAvatarWardrobeHydration.js +9 -4
  152. package/dist/wardrobe/useStudioAvatar.d.ts +29 -0
  153. package/dist/wardrobe/useStudioAvatar.js +186 -0
  154. package/dist/wardrobe/wardrobeStore.d.ts +2 -0
  155. package/dist/wardrobe/wardrobeStore.js +12 -2
  156. package/dist/wgpu/R3FWebGpuCanvas.d.ts +15 -0
  157. package/dist/wgpu/R3FWebGpuCanvas.js +176 -0
  158. package/dist/wgpu/WgpuAvatar.d.ts +26 -2
  159. package/dist/wgpu/WgpuAvatar.js +313 -46
  160. package/dist/wgpu/accessoryDefaults.d.ts +12 -0
  161. package/dist/wgpu/accessoryDefaults.js +19 -0
  162. package/dist/wgpu/blobShim.d.ts +2 -0
  163. package/dist/wgpu/blobShim.js +191 -0
  164. package/dist/wgpu/index.d.ts +1 -0
  165. package/dist/wgpu/index.js +4 -1
  166. package/dist/wgpu/loadGLTFFromUri.d.ts +2 -0
  167. package/dist/wgpu/loadGLTFFromUri.js +75 -0
  168. package/dist/wgpu/morphTables.js +21 -10
  169. package/dist/wgpu/motionState.d.ts +20 -0
  170. package/dist/wgpu/motionState.js +31 -0
  171. package/dist/wgpu/patchThreeForRN.d.ts +28 -0
  172. package/dist/wgpu/patchThreeForRN.js +292 -0
  173. package/dist/wgpu/scenePlacement.d.ts +5 -0
  174. package/dist/wgpu/scenePlacement.js +50 -0
  175. package/dist/wgpu/useAuthedModelUri.js +22 -11
  176. package/dist/wgpu/useNativeGLTF.d.ts +7 -0
  177. package/dist/wgpu/useNativeGLTF.js +36 -0
  178. package/package.json +102 -32
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildMotionScript = buildMotionScript;
4
+ /**
5
+ * Generated avatar WebView motion script section.
6
+ *
7
+ * Kept as a string builder so html.ts can compose the final document without
8
+ * owning every browser-side subsystem inline. The motion definitions and bone
9
+ * search map are serialized from the shared, framework-free source of truth in
10
+ * src/core/avatar/motion.ts so the WebView and the native WGPU path can never
11
+ * drift apart. The math below mirrors src/core/avatar/motionRuntime.ts.
12
+ */
13
+ const motion_1 = require("../core/avatar/motion");
14
+ function buildMotionScript() {
15
+ return `/* ── MotionEngine ────────────────────────────────────────────────────────── */
16
+ /* Quaternion math helpers (no Three.js dep required) */
17
+ const QM = {
18
+ fromAxisAngle(ax, ay, az, angle) {
19
+ const s = Math.sin(angle / 2);
20
+ return { x: ax*s, y: ay*s, z: az*s, w: Math.cos(angle/2) };
21
+ },
22
+ multiply(a, b) {
23
+ return {
24
+ x: a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y,
25
+ y: a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x,
26
+ z: a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w,
27
+ w: a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z,
28
+ };
29
+ },
30
+ applyTo(bone, q) {
31
+ bone.quaternion.set(q.x, q.y, q.z, q.w);
32
+ },
33
+ copyFrom(bone) {
34
+ const q = bone.quaternion;
35
+ return { x: q.x, y: q.y, z: q.z, w: q.w };
36
+ },
37
+ };
38
+
39
+ /* Motion definitions + bone search map — serialized from the shared TS source
40
+ of truth (src/core/avatar/motion.ts) at build time. */
41
+ const MOTION_DEFS = ${JSON.stringify(motion_1.MOTION_DEFS)};
42
+ const BONE_SEARCH = ${JSON.stringify(motion_1.MOTION_BONE_SEARCH)};
43
+
44
+ /* Runtime state */
45
+ let motionActive = false;
46
+ let motionKey = null;
47
+ let motionStartTime = null;
48
+ let motionBones = {};
49
+ let motionRestQuats = {};
50
+ let motionSmileTargets = [];
51
+ let motionBeatTimer = null;
52
+ let motionBeatIndex = 0;
53
+ let motionAutoReturnTimer = null;
54
+
55
+ /* Bone scan: norm() strips punctuation and lowercases, so RPM names like
56
+ LeftShoulder, RightArm, LeftForeArm etc. all match correctly. */
57
+ function motionScanBones(root) {
58
+ motionBones = {}; motionRestQuats = {};
59
+ if (!root) return;
60
+ const norm = s => s.toLowerCase().replace(/[_\\s\\-\\.]/g, '');
61
+ root.traverse(child => {
62
+ if (!child.isBone) return;
63
+ const lk = norm(child.name);
64
+ for (const [key, kws] of Object.entries(BONE_SEARCH)) {
65
+ if (motionBones[key]) continue;
66
+ if (kws.some(kw => lk.includes(norm(kw)))) {
67
+ motionBones[key] = child;
68
+ motionRestQuats[key] = QM.copyFrom(child);
69
+ }
70
+ }
71
+ });
72
+ const found = Object.keys(motionBones);
73
+ log('MotionEngine: ' + (found.length === 0
74
+ ? 'No rigged bones detected — motion engine needs a humanoid skeleton.'
75
+ : found.length + ' bones mapped: ' + found.join(', ') + '.'));
76
+ }
77
+
78
+ function motionScanSmile(root) {
79
+ motionSmileTargets = [];
80
+ if (!root) return;
81
+ root.traverse(child => {
82
+ if (!child.isMesh || !child.morphTargetDictionary) return;
83
+ Object.keys(child.morphTargetDictionary).forEach(name => {
84
+ const lk = name.toLowerCase();
85
+ if (lk.includes('smile') || lk.includes('happy') || lk.includes('joy') || lk.includes('mouthsmile')) {
86
+ motionSmileTargets.push({ mesh: child, index: child.morphTargetDictionary[name] });
87
+ }
88
+ });
89
+ });
90
+ }
91
+
92
+ function motionInitFromRoot(root) {
93
+ motionScanBones(root);
94
+ motionScanSmile(root);
95
+ }
96
+
97
+ function motionSetSmile(v) {
98
+ motionSmileTargets.forEach(t => { t.mesh.morphTargetInfluences[t.index] = v; });
99
+ }
100
+
101
+ /* BPM beat indicator — no-ops in WebView (no beat UI) */
102
+ function motionStartBeat(bpm) {
103
+ clearInterval(motionBeatTimer);
104
+ motionBeatIndex = 0;
105
+ /* beat UI elements not present in WebView — intentional no-op */
106
+ }
107
+
108
+ function motionStopBeat() {
109
+ clearInterval(motionBeatTimer);
110
+ motionBeatTimer = null;
111
+ }
112
+
113
+ /* ── applyMotionBones ────────────────────────────────────────
114
+ Called every frame from inside the render loop
115
+ (fallback: before renderer.render; TH: inside head.opt.update).
116
+ Runs AFTER TH has written its own bones so our values are
117
+ the last thing written before the draw call.
118
+ ─────────────────────────────────────────────────────────── */
119
+ function applyMotionBones() {
120
+ if (!motionActive) return;
121
+ const def = MOTION_DEFS[motionKey];
122
+ if (!def) return;
123
+ const tSec = (performance.now() - motionStartTime) / 1000;
124
+
125
+ for (const [boneName, oscillators] of Object.entries(def.bones)) {
126
+ const bone = motionBones[boneName];
127
+ if (!bone) continue;
128
+ const rest = motionRestQuats[boneName];
129
+ let q = { ...rest };
130
+
131
+ for (const osc of oscillators) {
132
+ const angle = osc.freq === 0
133
+ ? osc.amp
134
+ : osc.amp * Math.sin(2 * Math.PI * osc.freq * tSec + osc.phase);
135
+ q = QM.multiply(q, QM.fromAxisAngle(osc.ax, osc.ay, osc.az, angle));
136
+ }
137
+
138
+ bone.quaternion.set(q.x, q.y, q.z, q.w);
139
+ }
140
+ }
141
+
142
+ window.playMotion = function(key) {
143
+ const def = MOTION_DEFS[key];
144
+ if (!def) { log('MotionEngine: unknown motion key: ' + key); return false; }
145
+ const root = (head && head.armature) ? head.armature : staticModel;
146
+ if (!root) { log('MotionEngine: no model loaded'); return false; }
147
+
148
+ if (Object.keys(motionBones).length === 0) motionInitFromRoot(root);
149
+
150
+ window.stopMotion(false);
151
+
152
+ motionKey = key;
153
+ motionActive = true;
154
+ motionStartTime = performance.now();
155
+
156
+ motionStartBeat(def.bpm);
157
+ motionSetSmile(def.smile);
158
+ log('MotionEngine: playing ' + def.label + ' @ ' + def.bpm + ' BPM');
159
+
160
+ /* Pose-like motions (attack/defend) auto-revert to rest so combat loops
161
+ return to guard without an explicit stop message. */
162
+ if (def.autoReturnMs > 0) {
163
+ motionAutoReturnTimer = setTimeout(function() {
164
+ if (motionKey === key) window.stopMotion(true);
165
+ }, def.autoReturnMs);
166
+ }
167
+ return true;
168
+ };
169
+
170
+ window.stopMotion = function(restore) {
171
+ if (restore === undefined) restore = true;
172
+ if (motionAutoReturnTimer) { clearTimeout(motionAutoReturnTimer); motionAutoReturnTimer = null; }
173
+ motionActive = false;
174
+ motionStopBeat();
175
+
176
+ if (restore) {
177
+ for (const [key, bone] of Object.entries(motionBones)) {
178
+ const rest = motionRestQuats[key];
179
+ if (rest) bone.quaternion.set(rest.x, rest.y, rest.z, rest.w);
180
+ }
181
+ motionSetSmile(0);
182
+ motionKey = null;
183
+ log('MotionEngine: stopped');
184
+ }
185
+ };
186
+ /* ── End MotionEngine ─────────────────────────────────────── */
187
+
188
+ `;
189
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Generated avatar WebView visemes script section.
3
+ *
4
+ * Kept as a string builder so html.ts can compose the final document without
5
+ * owning every browser-side subsystem inline.
6
+ */
7
+ export declare function buildVisemeScript(): string;
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildVisemeScript = buildVisemeScript;
4
+ /**
5
+ * Generated avatar WebView visemes script section.
6
+ *
7
+ * Kept as a string builder so html.ts can compose the final document without
8
+ * owning every browser-side subsystem inline.
9
+ */
10
+ function buildVisemeScript() {
11
+ return `let amplitudeDecay = 0;
12
+ let jawMorphCache = null;
13
+ let visemeMorphCache = null;
14
+
15
+ // Voice-driven mood detection
16
+ let voiceEnergySmoothed = 0;
17
+ let voiceMoodCurrent = 'neutral';
18
+ let voiceMoodFramesAbove = 0;
19
+ let voiceMoodFramesBelow = 0;
20
+ const VOICE_ENERGY_EXCITED_THRESH = 0.45;
21
+ const VOICE_ENERGY_NEUTRAL_THRESH = 0.15;
22
+ const VOICE_MOOD_FRAMES_REQUIRED = 12; // ~0.2s at 60fps
23
+ function updateVoiceMood(rawAmplitude) {
24
+ voiceEnergySmoothed = voiceEnergySmoothed * 0.85 + rawAmplitude * 0.15;
25
+ if (voiceEnergySmoothed > VOICE_ENERGY_EXCITED_THRESH) {
26
+ voiceMoodFramesAbove++;
27
+ voiceMoodFramesBelow = 0;
28
+ if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
29
+ voiceMoodCurrent = 'excited';
30
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
31
+ }
32
+ } else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
33
+ voiceMoodFramesBelow++;
34
+ voiceMoodFramesAbove = 0;
35
+ if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
36
+ voiceMoodCurrent = 'neutral';
37
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
38
+ }
39
+ }
40
+ }
41
+ const visemeState = {};
42
+
43
+ const VISEME_MORPH_ALIASES = {
44
+ sil: ['viseme_sil', 'sil', 'mouthClose', 'mouth_close'],
45
+ PP: ['viseme_PP', 'pp', 'viseme_pp', 'mouthPucker', 'mouth_pucker'],
46
+ FF: ['viseme_FF', 'ff', 'viseme_ff', 'mouthLowerLipIn', 'mouth_lower_lip_in', 'mouthRollLower', 'mouthShrugLower'],
47
+ TH: ['viseme_TH', 'th', 'viseme_th', 'tongueOut', 'tongue_out'],
48
+ DD: ['viseme_DD', 'dd', 'viseme_dd', 'mouthShrugUpper', 'mouth_shrug_upper'],
49
+ kk: ['viseme_kk', 'kk', 'viseme_k', 'mouthStretchLeft', 'mouth_stretch_left'],
50
+ CH: ['viseme_CH', 'ch', 'viseme_ch', 'mouthSmile', 'mouth_smile', 'mouthSmileLeft', 'mouth_smile_left'],
51
+ SS: ['viseme_SS', 'ss', 'viseme_ss', 'mouthStretchRight', 'mouth_stretch_right'],
52
+ nn: ['viseme_nn', 'nn', 'viseme_n', 'mouthDimpleLeft', 'mouth_dimple_left'],
53
+ RR: ['viseme_RR', 'rr', 'viseme_r', 'mouthDimpleRight', 'mouth_dimple_right'],
54
+ aa: ['viseme_aa', 'viseme_AA', 'aa', 'jawOpen', 'jaw_open', 'jawopen', 'mouthOpen', 'mouth_open', 'mouthopen'],
55
+ ee: ['viseme_ee', 'viseme_E', 'ee', 'mouthSmileLeft', 'mouth_smile_left'],
56
+ ih: ['viseme_ih', 'viseme_I', 'ih', 'mouthSmileRight', 'mouth_smile_right'],
57
+ oh: ['viseme_oh', 'viseme_O', 'oh', 'mouthFunnel', 'mouth_funnel'],
58
+ ou: ['viseme_ou', 'viseme_U', 'ou', 'mouthRollLower', 'mouth_roll_lower'],
59
+ };
60
+
61
+ // For ARKit models, each viseme may need multiple blend shapes driven together.
62
+ // Each entry is a list of morph names to combine for that viseme.
63
+ // The first alias list that has ANY match on the model is used.
64
+ const VISEME_COMPOUND_ARKIT = {
65
+ aa: [['jawOpen', 'mouthLowerDownLeft', 'mouthLowerDownRight'], ['jawOpen', 'mouthOpen']],
66
+ oh: [['mouthFunnel', 'jawOpen'], ['mouthFunnel']],
67
+ ou: [['mouthPucker', 'mouthRollLower'], ['mouthPucker']],
68
+ PP: [['mouthPucker', 'mouthClose'], ['mouthPucker']],
69
+ FF: [['mouthRollLower', 'mouthLowerDownLeft', 'mouthLowerDownRight'], ['mouthShrugLower']],
70
+ CH: [['mouthSmileLeft', 'mouthSmileRight', 'mouthStretchLeft', 'mouthStretchRight'], ['mouthSmileLeft', 'mouthSmileRight']],
71
+ ee: [['mouthSmileLeft', 'mouthSmileRight'], ['mouthSmileLeft']],
72
+ ih: [['mouthSmileLeft', 'mouthSmileRight'], ['mouthSmileRight']],
73
+ };
74
+
75
+ function buildVisemeMorphCache() {
76
+ visemeMorphCache = {};
77
+ for (const [visemeKey, aliases] of Object.entries(VISEME_MORPH_ALIASES)) {
78
+ const entries = [];
79
+ // Check if we have a compound ARKit mapping for this viseme
80
+ const compoundOptions = VISEME_COMPOUND_ARKIT[visemeKey];
81
+ if (compoundOptions) {
82
+ // Try each compound option; use the first one where all names exist on at least one mesh
83
+ let usedCompound = false;
84
+ for (const nameList of compoundOptions) {
85
+ // Collect entries for all names across all meshes
86
+ const compoundEntries = [];
87
+ for (const mesh of mouthMeshes) {
88
+ if (!mesh.morphTargetDictionary) continue;
89
+ const dict = mesh.morphTargetDictionary;
90
+ const dictKeysLower = Object.fromEntries(Object.keys(dict).map(k => [k.toLowerCase(), k]));
91
+ for (const name of nameList) {
92
+ const found = dictKeysLower[name.toLowerCase()];
93
+ if (found !== undefined) {
94
+ compoundEntries.push({ influences: mesh.morphTargetInfluences, idx: dict[found], morphName: found });
95
+ }
96
+ }
97
+ }
98
+ if (compoundEntries.length > 0) {
99
+ entries.push(...compoundEntries);
100
+ usedCompound = true;
101
+ break;
102
+ }
103
+ }
104
+ if (usedCompound) {
105
+ visemeMorphCache[visemeKey] = entries;
106
+ continue;
107
+ }
108
+ }
109
+ // Fallback: single-alias lookup
110
+ for (const mesh of mouthMeshes) {
111
+ if (!mesh.morphTargetDictionary) continue;
112
+ const dictKeys = Object.keys(mesh.morphTargetDictionary);
113
+ for (const alias of aliases) {
114
+ const found = dictKeys.find(k => k.toLowerCase() === alias.toLowerCase());
115
+ if (found !== undefined) {
116
+ entries.push({ influences: mesh.morphTargetInfluences, idx: mesh.morphTargetDictionary[found], morphName: found });
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ if (entries.length > 0) visemeMorphCache[visemeKey] = entries;
122
+ }
123
+ const found = Object.keys(visemeMorphCache);
124
+ log('Viseme cache: ' + (found.length > 0 ? found.join(', ') : 'none — check morph target names'));
125
+
126
+ // Always log all available morphs so we can see what the model actually has
127
+ if (mouthMeshes.length > 0) {
128
+ const allMorphs = Object.keys(mouthMeshes[0].morphTargetDictionary || {});
129
+ log('Available morphs: ' + (allMorphs.length > 0 ? allMorphs.join(', ') : 'none'));
130
+ }
131
+ }
132
+
133
+ function applyViseme(visemeKey, weight) {
134
+ if (!visemeMorphCache) buildVisemeMorphCache();
135
+ if (visemeKey === 'sil' || weight <= 0) {
136
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
137
+ return;
138
+ }
139
+ visemeState[visemeKey] = Math.min(1, weight);
140
+ visemeStateLastSet.set(visemeKey, Date.now());
141
+ }
142
+
143
+ const RHUBARB_DEFAULT_VISEME_WEIGHT = 0.72;
144
+ const RHUBARB_LABIAL_VISEME_WEIGHT = 0.85;
145
+ const RHUBARB_AA_VISEME_WEIGHT = 0.72;
146
+ const RHUBARB_ROUNDED_VISEME_WEIGHT = 0.62;
147
+ const RHUBARB_FALLBACK_AMPLITUDE_CAP = 0.72;
148
+ const RHUBARB_FALLBACK_AMPLITUDE_GAIN = 0.75;
149
+ const RHUBARB_VISEME_WEIGHTS = {
150
+ PP: RHUBARB_LABIAL_VISEME_WEIGHT,
151
+ FF: 0.78,
152
+ ee: 0.72,
153
+ ih: 0.68,
154
+ oh: RHUBARB_ROUNDED_VISEME_WEIGHT,
155
+ ou: 0.58,
156
+ aa: RHUBARB_AA_VISEME_WEIGHT,
157
+ };
158
+
159
+ const RHUBARB_TO_VISEME = {
160
+ A: 'aa',
161
+ B: 'PP',
162
+ C: 'ih',
163
+ D: 'FF',
164
+ E: 'ee',
165
+ F: 'oh',
166
+ G: 'ou',
167
+ H: 'nn',
168
+ X: 'sil',
169
+ };
170
+
171
+ let rhubarbMorphCache = null;
172
+ let visemeTimers = [];
173
+ let activeVisemeScheduleId = 0;
174
+ let visemeModeUntil = 0;
175
+ const visemeStateLastSet = new Map();
176
+
177
+ function buildRhubarbMorphCache() {
178
+ if (!visemeMorphCache) buildVisemeMorphCache();
179
+ rhubarbMorphCache = {};
180
+ for (const [rhubarbShape, visemeKey] of Object.entries(RHUBARB_TO_VISEME)) {
181
+ if (visemeKey === 'sil') { rhubarbMorphCache[rhubarbShape] = null; continue; }
182
+ rhubarbMorphCache[rhubarbShape] = visemeMorphCache[visemeKey] || null;
183
+ }
184
+ log('Rhubarb morph cache built');
185
+ }
186
+
187
+ function applyRhubarbCue(shape) {
188
+ if (!rhubarbMorphCache) buildRhubarbMorphCache();
189
+ // Zero all active viseme channels first
190
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
191
+ if (shape === 'X' || !rhubarbMorphCache[shape]) return;
192
+ const visemeKey = RHUBARB_TO_VISEME[shape];
193
+ if (visemeKey && visemeKey !== 'sil') {
194
+ visemeState[visemeKey] = RHUBARB_VISEME_WEIGHTS[visemeKey] || RHUBARB_DEFAULT_VISEME_WEIGHT;
195
+ visemeStateLastSet.set(visemeKey, Date.now());
196
+ }
197
+ }
198
+
199
+ function clearScheduledVisemes() {
200
+ activeVisemeScheduleId++;
201
+ for (const id of visemeTimers) clearTimeout(id);
202
+ visemeTimers = [];
203
+ visemeModeUntil = 0;
204
+ // Zero all mouth morphs
205
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
206
+ }
207
+
208
+ function tickVisemeDecay(deltaSeconds) {
209
+ if (!visemeMorphCache) return;
210
+
211
+ const isScheduled = Date.now() < visemeModeUntil;
212
+ const hasSpecificLipShape =
213
+ visemeState.PP > 0.05 ||
214
+ visemeState.FF > 0.05 ||
215
+ visemeState.kk > 0.05 ||
216
+ visemeState.ee > 0.05 ||
217
+ visemeState.ih > 0.05;
218
+
219
+ for (const [key, weight] of Object.entries(visemeState)) {
220
+ // Only decay if we aren't in the middle of a viseme schedule.
221
+ // Scheduled visemes are cleared manually by timeouts.
222
+ if (!isScheduled) {
223
+ // Time-delta-aware decay: maintain consistent feel regardless of frame rate.
224
+ // Base rate is calibrated for 60 fps (0.82 per frame = ~12 frames to 10%).
225
+ // pow(0.82, delta*60) is frame-rate independent.
226
+ const dt = deltaSeconds ?? (1 / 60);
227
+ const decayFactor = Math.pow(0.82, dt * 60);
228
+ const decayed = weight * decayFactor;
229
+ visemeState[key] = decayed < 0.01 ? 0 : decayed;
230
+ }
231
+
232
+ const entries = visemeMorphCache[key];
233
+ if (!entries) continue;
234
+
235
+ let targetWeight = visemeState[key];
236
+ if (key === 'aa' && hasSpecificLipShape) targetWeight = Math.min(targetWeight, 0.45);
237
+
238
+ for (const e of entries) {
239
+ // When TalkingHead is active, write through its morph API so the internal
240
+ // render loop doesn't overwrite our values every frame.
241
+ // Use realtime (not newvalue) — newvalue is consumed and cleared after
242
+ // a single frame, so scheduled visemes would vanish immediately.
243
+ // realtime persists until explicitly set to null.
244
+ if (head && head.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
245
+ const mt = head.mtAvatar[e.morphName];
246
+ mt.realtime = targetWeight > 0 ? targetWeight : null;
247
+ mt.needsUpdate = true;
248
+ } else {
249
+ e.influences[e.idx] = targetWeight;
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ function scheduleVisemes(schedule) {
256
+ clearScheduledVisemes();
257
+
258
+ // Prune visemeState keys that haven't been written in the last 2 seconds to
259
+ // prevent unbounded accumulation across many utterances.
260
+ const staleThreshold = Date.now() - 2000;
261
+ for (const key of Object.keys(visemeState)) {
262
+ if ((visemeStateLastSet.get(key) ?? 0) < staleThreshold) {
263
+ delete visemeState[key];
264
+ visemeStateLastSet.delete(key);
265
+ }
266
+ }
267
+
268
+ if (!schedule || !Array.isArray(schedule.cues) || schedule.cues.length === 0) return;
269
+
270
+ const myScheduleId = activeVisemeScheduleId;
271
+ // Anchor selection priority:
272
+ // 1. audioStartedAtMs — stamped when audio actually begins playing (most accurate)
273
+ // 2. startedAtMs + pipeline delay — stamped at TTS request fire time
274
+ //
275
+ // AUDIO_PIPELINE_DELAY_MS compensates for the gap between "TTS request fired"
276
+ // and "audio audible from speaker". Qwen3-TTS on local/tailnet is ~80–150 ms;
277
+ // LiveKit adds ~50–80 ms of jitter buffer on top. 150 ms is conservative but
278
+ // avoids the mouth running ahead of audio on fast connections.
279
+ const AUDIO_PIPELINE_DELAY_MS = 50;
280
+ let startedAt = schedule.audioStartedAtMs
281
+ ?? ((schedule.startedAtMs || Date.now()) + AUDIO_PIPELINE_DELAY_MS);
282
+ const durationMs = schedule.durationMs || 0;
283
+ const now = Date.now();
284
+ let elapsedMs = Math.max(0, now - startedAt);
285
+
286
+ // If the schedule still arrives late after the pipeline offset, shift further
287
+ if (elapsedMs > 300 && schedule.cues.length > 3) {
288
+ const shift = Math.min(elapsedMs - 50, 500);
289
+ startedAt += shift;
290
+ elapsedMs -= shift;
291
+ log('Viseme schedule arrived late, shifting anchor forward by ' + shift + 'ms');
292
+ }
293
+
294
+ const remainingMs = Math.max(0, durationMs - elapsedMs);
295
+ let scheduledCueCount = 0;
296
+ let skippedCueCount = 0;
297
+
298
+ // Gate amplitude fallback for the locally remaining duration plus a small buffer.
299
+ // If the schedule arrives a bit late, keep amplitude out of the way for the rest
300
+ // of the utterance instead of expiring immediately from the original timestamp.
301
+ visemeModeUntil = now + remainingMs + 200;
302
+
303
+ for (const cue of schedule.cues) {
304
+ const delay = cue.startMs - (Date.now() - startedAt);
305
+ if (delay < -50) {
306
+ skippedCueCount++;
307
+ continue; // already in the past, skip
308
+ }
309
+ scheduledCueCount++;
310
+
311
+ const applyId = setTimeout(() => {
312
+ if (activeVisemeScheduleId !== myScheduleId) return;
313
+ applyRhubarbCue(cue.viseme);
314
+ }, Math.max(0, delay));
315
+
316
+ const clearId = setTimeout(() => {
317
+ if (activeVisemeScheduleId !== myScheduleId) return;
318
+ // Only clear if the next cue hasn't already overwritten state
319
+ visemeState[RHUBARB_TO_VISEME[cue.viseme]] = 0;
320
+ }, Math.max(0, delay + (cue.endMs - cue.startMs)));
321
+
322
+ visemeTimers.push(applyId, clearId);
323
+ }
324
+
325
+ log(
326
+ 'Viseme schedule received: requestId=' +
327
+ (schedule.requestId || 'unknown') +
328
+ ' cues=' + schedule.cues.length +
329
+ ' scheduled=' + scheduledCueCount +
330
+ ' skipped=' + skippedCueCount +
331
+ ' elapsedMs=' + elapsedMs +
332
+ ' remainingMs=' + remainingMs,
333
+ );
334
+
335
+ // Ensure silence at schedule end
336
+ const endDelay = durationMs - (Date.now() - startedAt);
337
+ if (endDelay > 0) {
338
+ visemeTimers.push(setTimeout(() => {
339
+ if (activeVisemeScheduleId !== myScheduleId) return;
340
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
341
+ visemeModeUntil = 0;
342
+ }, endDelay + 100));
343
+ }
344
+ }
345
+ // ============ END RHUBARB VISEME SCHEDULER ============
346
+
347
+ `;
348
+ }
package/dist/html.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type AvatarConfig = {
2
2
  avatarUrl: string;
3
3
  authToken?: string | null;
4
- mood: 'neutral' | 'happy' | 'sad' | 'angry' | 'excited' | 'thinking' | 'concerned' | 'surprised';
4
+ mood: 'neutral' | 'happy' | 'sad' | 'angry' | 'fear' | 'disgust' | 'love' | 'sleep' | 'excited' | 'thinking' | 'concerned' | 'surprised';
5
5
  cameraView: 'head' | 'upper' | 'full';
6
6
  cameraDistance: number;
7
7
  /** Initial colors only — live updates go via postMessage (setHairColor etc.) */