talking-head-studio 0.4.5 → 0.4.8

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.
@@ -61,6 +61,7 @@ export interface TalkingHeadProps {
61
61
  onReady?: () => void;
62
62
  onError?: (message: string) => void;
63
63
  onAvatarState?: (state: string) => void;
64
+ onVoiceMood?: (mood: TalkingHeadMood) => void;
64
65
  style?: StyleProp<ViewStyle>;
65
66
  /** Base URL for vendored assets. When set, replaces all cdn.jsdelivr.net references. */
66
67
  vendorBaseUrl?: string | null;
@@ -6,7 +6,7 @@ const react_1 = require("react");
6
6
  const react_native_1 = require("react-native");
7
7
  const react_native_webview_1 = require("react-native-webview");
8
8
  const html_1 = require("./html");
9
- exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, style, vendorBaseUrl, }, ref) => {
9
+ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, onVoiceMood, style, vendorBaseUrl, }, ref) => {
10
10
  const webViewRef = (0, react_1.useRef)(null);
11
11
  const readyRef = (0, react_1.useRef)(false);
12
12
  const firstMessageSeenRef = (0, react_1.useRef)(false);
@@ -25,6 +25,8 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
25
25
  onErrorRef.current = onError;
26
26
  const onAvatarStateRef = (0, react_1.useRef)(onAvatarState);
27
27
  onAvatarStateRef.current = onAvatarState;
28
+ const onVoiceMoodRef = (0, react_1.useRef)(onVoiceMood);
29
+ onVoiceMoodRef.current = onVoiceMood;
28
30
  // The WebView HTML is built once from stable initial values.
29
31
  // avatarUrl + authToken changing causes a controlled key-based remount.
30
32
  // All other prop changes (mood, colors, accessories) go via postMessage.
@@ -226,6 +228,9 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
226
228
  else if (msg.type === 'avatarState') {
227
229
  onAvatarStateRef.current?.(msg.state);
228
230
  }
231
+ else if (msg.type === 'voiceMood') {
232
+ onVoiceMoodRef.current?.(msg.mood);
233
+ }
229
234
  else if (msg.type === 'log') {
230
235
  console.log('[TalkingHead]', msg.message);
231
236
  }
@@ -16,6 +16,7 @@ interface TalkingHeadVisualizationProps {
16
16
  requestId: string | null;
17
17
  appliedAtMs: number;
18
18
  }) => void;
19
+ onVoiceMood?: (mood: TalkingHeadMood) => void;
19
20
  vendorBaseUrl?: string | null;
20
21
  }
21
22
  export interface TalkingHeadVisualizationRef {
@@ -24,6 +25,8 @@ export interface TalkingHeadVisualizationRef {
24
25
  sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
25
26
  scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
26
27
  clearVisemes: () => void;
28
+ /** Trigger a named motion on the avatar (e.g. 'celebrate', 'groove', 'wave') */
29
+ playMotion: (name: string) => void;
27
30
  }
28
31
  /**
29
32
  * TalkingHeadVisualization — optimized component for rendering the 3D avatar.
@@ -44,10 +44,12 @@ function getLoadingLabel(stage) {
44
44
  * On native: uses WgpuAvatar (direct morph writes, no WebView bridge).
45
45
  * On web: uses TalkingHead WebView renderer.
46
46
  */
47
- exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, vendorBaseUrl }, ref) => {
47
+ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, onVoiceMood, vendorBaseUrl }, ref) => {
48
48
  const avatarRef = (0, react_1.useRef)(null);
49
- // On native, WgpuAvatar ref is wired via callback ref store it here so
50
- // scheduleVisemes / sendAmplitude can route to it.
49
+ // On native, WgpuAvatar ref is stored here so scheduleVisemes / sendAmplitude can route to it.
50
+ // useImperativeHandle is the sole owner of the forwarded ref on all platforms —
51
+ // do NOT manually forward ref inside the WgpuAvatar callback ref, as that bypasses
52
+ // the dedup key check in scheduleVisemes and causes double-fire.
51
53
  const wgpuRef = (0, react_1.useRef)(null);
52
54
  // Unified accessor — WgpuAvatar on native, WebView on web
53
55
  const activeAvatar = (0, react_1.useCallback)(() => (wgpuRef.current ?? avatarRef.current), []);
@@ -109,6 +111,7 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
109
111
  sendAmplitude: (a) => activeAvatar()?.sendAmplitude(a),
110
112
  sendViseme: (viseme, weight) => activeAvatar()?.sendViseme(viseme, weight),
111
113
  clearVisemes: () => activeAvatar()?.clearVisemes(),
114
+ playMotion: (name) => avatarRef.current?.dispatchMotion(name),
112
115
  scheduleVisemes: (schedule) => {
113
116
  const scheduleKey = `${schedule.requestId ?? 'anonymous'}:${schedule.startedAtMs ?? 0}`;
114
117
  if (lastScheduledVisemeKeyRef.current === scheduleKey)
@@ -182,26 +185,15 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
182
185
  lastScheduledVisemeKeyRef.current = scheduleKey;
183
186
  }, [isAvatarReady, onVisemeScheduleApplied, visemeSchedule, activeAvatar]);
184
187
  // On native use WgpuAvatar — direct morph writes, no WebView bridge.
188
+ // NOTE: Only store wgpuRef here — do NOT forward `ref` manually. useImperativeHandle
189
+ // above is the sole owner of the forwarded ref and handles the dedup key check.
185
190
  if (react_native_1.Platform.OS !== 'web') {
186
- return ((0, jsx_runtime_1.jsx)(WgpuAvatar_1.WgpuAvatar, { focalLength: focalLength, ref: (fr) => {
187
- wgpuRef.current = fr;
188
- if (typeof ref === 'function')
189
- ref(fr);
190
- else if (ref)
191
- ref.current = fr;
192
- if (fr && pendingVisemeScheduleRef.current) {
193
- fr.scheduleVisemes(pendingVisemeScheduleRef.current);
194
- pendingVisemeScheduleRef.current = null;
195
- }
196
- }, style: style, avatarUrl: avatarUrl ?? null, aspect: aspect, mood: mood, accessories: accessories, onReady: handleReady, onError: handleError }));
191
+ return ((0, jsx_runtime_1.jsx)(WgpuAvatar_1.WgpuAvatar, { focalLength: focalLength, ref: (fr) => { wgpuRef.current = fr; }, style: style, avatarUrl: avatarUrl ?? null, aspect: aspect, mood: mood, accessories: accessories, onReady: handleReady, onError: handleError }));
197
192
  }
198
193
  if (!effectiveAvatarUrl) {
199
194
  return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.placeholder] }));
200
195
  }
201
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.progressTrack, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
202
- styles.progressFill,
203
- { width: `${Math.max(6, loadingState.progress)}%` },
204
- ] }) })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: "Preparing the avatar scene\u2026" }))] }) }))] }));
196
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, onVoiceMood: onVoiceMood, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.progressTrack, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: Math.max(6, loadingState.progress ?? 0), height: '100%', borderRadius: 999, backgroundColor: '#5eead4' } }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: 100 - Math.max(6, loadingState.progress ?? 0) } })] })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: "Preparing the avatar scene\u2026" }))] }) }))] }));
205
197
  });
206
198
  exports.TalkingHeadVisualization.displayName = 'TalkingHeadVisualization';
207
199
  const styles = react_native_1.StyleSheet.create({
@@ -263,11 +255,7 @@ const styles = react_native_1.StyleSheet.create({
263
255
  backgroundColor: 'rgba(148, 163, 184, 0.18)',
264
256
  marginTop: 12,
265
257
  overflow: 'hidden',
266
- },
267
- progressFill: {
268
- height: '100%',
269
- borderRadius: 999,
270
- backgroundColor: '#5eead4',
258
+ flexDirection: 'row',
271
259
  },
272
260
  placeholder: {
273
261
  backgroundColor: 'rgba(30,34,53,0.5)',
package/dist/html.js CHANGED
@@ -40,25 +40,26 @@ function buildAvatarHtml(config) {
40
40
  }
41
41
  </script>
42
42
  <script>
43
- window.ReactNativeWebView?.postMessage(
43
+ function rnPost(d) { window.ReactNativeWebView && window.ReactNativeWebView.postMessage(d); }
44
+ rnPost(
44
45
  JSON.stringify({ type: 'log', message: '[bootstrap] inline script start' })
45
46
  );
46
47
  function postBootstrapError(kind, message) {
47
- window.ReactNativeWebView?.postMessage(
48
+ rnPost(
48
49
  JSON.stringify({ type: 'error', message: '[' + kind + '] ' + String(message || 'Unknown error') })
49
50
  );
50
51
  }
51
52
  document.addEventListener('DOMContentLoaded', function() {
52
- window.ReactNativeWebView?.postMessage(
53
+ rnPost(
53
54
  JSON.stringify({ type: 'log', message: '[bootstrap] DOMContentLoaded' })
54
55
  );
55
56
  });
56
57
  window.addEventListener('error', function(event) {
57
- postBootstrapError('window.error', event?.message || event?.error?.message || 'Script error');
58
+ postBootstrapError('window.error', (event && event.message) || (event && event.error && event.error.message) || 'Script error');
58
59
  });
59
60
  window.addEventListener('unhandledrejection', function(event) {
60
- const reason = event?.reason;
61
- postBootstrapError('unhandledrejection', reason?.message || reason || 'Unhandled promise rejection');
61
+ const reason = event && event.reason;
62
+ postBootstrapError('unhandledrejection', (reason && reason.message) || reason || 'Unhandled promise rejection');
62
63
  });
63
64
  </script>
64
65
  </head>
@@ -66,7 +67,7 @@ window.addEventListener('unhandledrejection', function(event) {
66
67
  <div id="avatar"></div>
67
68
  <script type="module">
68
69
  (async function() {
69
- window.ReactNativeWebView?.postMessage(
70
+ rnPost(
70
71
  JSON.stringify({ type: 'log', message: '[module] script start' })
71
72
  );
72
73
  const AUTH_TOKEN = ${JSON.stringify(config.authToken ?? null)};
@@ -94,11 +95,11 @@ let staticModel = null;
94
95
  let initStarted = false;
95
96
 
96
97
  function log(msg) {
97
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'log', message: msg }));
98
+ rnPost(JSON.stringify({ type: 'log', message: msg }));
98
99
  }
99
100
 
100
101
  function emitLoading(stage, progress = null) {
101
- window.ReactNativeWebView?.postMessage(
102
+ rnPost(
102
103
  JSON.stringify({ type: 'loading', stage, progress }),
103
104
  );
104
105
  }
@@ -125,13 +126,13 @@ function applyColorOverrides() {
125
126
  mats.forEach((mat) => {
126
127
  const name = (mat.name || '').toLowerCase();
127
128
  if (HAIR_COLOR && (name.includes('hair') || name.includes('fur'))) {
128
- mat.color?.set(HAIR_COLOR);
129
+ mat.color && mat.color.set(HAIR_COLOR);
129
130
  }
130
131
  if (SKIN_COLOR && (name.includes('skin') || name.includes('body') || name.includes('face'))) {
131
- mat.color?.set(SKIN_COLOR);
132
+ mat.color && mat.color.set(SKIN_COLOR);
132
133
  }
133
134
  if (EYE_COLOR && (name.includes('eye') || name.includes('iris'))) {
134
- mat.color?.set(EYE_COLOR);
135
+ mat.color && mat.color.set(EYE_COLOR);
135
136
  }
136
137
  });
137
138
  });
@@ -204,7 +205,7 @@ async function loadStaticFallback(loadedAvatarUrl) {
204
205
  applyAccessories(pendingAccessoriesList);
205
206
  motionInitFromRoot(staticModel);
206
207
  emitLoading('ready', 100);
207
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
208
+ rnPost(JSON.stringify({ type: 'ready' }));
208
209
 
209
210
  window.addEventListener('resize', () => {
210
211
  camera.aspect = window.innerWidth / window.innerHeight;
@@ -228,11 +229,11 @@ async function loadStaticFallback(loadedAvatarUrl) {
228
229
  }
229
230
  }, (err) => {
230
231
  log('Fallback Error: ' + err.message);
231
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
232
+ rnPost(JSON.stringify({ type: 'error', message: err.message }));
232
233
  });
233
234
  } catch (err) {
234
235
  log('Fallback setup error: ' + err.message);
235
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
236
+ rnPost(JSON.stringify({ type: 'error', message: err.message }));
236
237
  }
237
238
  }
238
239
 
@@ -299,7 +300,7 @@ async function init() {
299
300
  jawMorphCache = null;
300
301
  visemeMorphCache = null;
301
302
  rhubarbMorphCache = null;
302
- head.armature?.traverse((child) => {
303
+ head.armature && head.armature.traverse(function(child) {
303
304
  if (child.isMesh && child.morphTargetDictionary && child.morphTargetInfluences) {
304
305
  mouthMeshes.push(child);
305
306
  }
@@ -338,17 +339,17 @@ async function init() {
338
339
  applyAccessories(pendingAccessoriesList);
339
340
  if (head && head.armature) motionInitFromRoot(head.armature);
340
341
  emitLoading('ready', 100);
341
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
342
+ rnPost(JSON.stringify({ type: 'ready' }));
342
343
  }
343
344
  } catch (err) {
344
345
  initStarted = false; // allow retry on error
345
346
  log('Error: ' + err.message);
346
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
347
+ rnPost(JSON.stringify({ type: 'error', message: err.message }));
347
348
  }
348
349
  }
349
350
 
350
351
  function startAudioInterception() {
351
- if (!head?.audioCtx || !head?.audioSpeechGainNode) return;
352
+ if (!head || !head.audioCtx || !head.audioSpeechGainNode) return;
352
353
  const audioCtx = head.audioCtx;
353
354
  const gainNode = head.audioSpeechGainNode;
354
355
  const connected = new WeakSet();
@@ -384,6 +385,33 @@ function startAudioInterception() {
384
385
  let amplitudeDecay = 0;
385
386
  let jawMorphCache = null;
386
387
  let visemeMorphCache = null;
388
+
389
+ // Voice-driven mood detection
390
+ let voiceEnergySmoothed = 0;
391
+ let voiceMoodCurrent = 'neutral';
392
+ let voiceMoodFramesAbove = 0;
393
+ let voiceMoodFramesBelow = 0;
394
+ const VOICE_ENERGY_EXCITED_THRESH = 0.45;
395
+ const VOICE_ENERGY_NEUTRAL_THRESH = 0.15;
396
+ const VOICE_MOOD_FRAMES_REQUIRED = 12; // ~0.2s at 60fps
397
+ function updateVoiceMood(rawAmplitude) {
398
+ voiceEnergySmoothed = voiceEnergySmoothed * 0.85 + rawAmplitude * 0.15;
399
+ if (voiceEnergySmoothed > VOICE_ENERGY_EXCITED_THRESH) {
400
+ voiceMoodFramesAbove++;
401
+ voiceMoodFramesBelow = 0;
402
+ if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
403
+ voiceMoodCurrent = 'excited';
404
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
405
+ }
406
+ } else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
407
+ voiceMoodFramesBelow++;
408
+ voiceMoodFramesAbove = 0;
409
+ if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
410
+ voiceMoodCurrent = 'neutral';
411
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
412
+ }
413
+ }
414
+ }
387
415
  const visemeState = {};
388
416
 
389
417
  const VISEME_MORPH_ALIASES = {
@@ -551,7 +579,7 @@ function clearScheduledVisemes() {
551
579
  for (const key of Object.keys(visemeState)) visemeState[key] = 0;
552
580
  }
553
581
 
554
- function tickVisemeDecay(deltaSeconds?: number) {
582
+ function tickVisemeDecay(deltaSeconds) {
555
583
  if (!visemeMorphCache) return;
556
584
 
557
585
  const isScheduled = Date.now() < visemeModeUntil;
@@ -587,7 +615,7 @@ function tickVisemeDecay(deltaSeconds?: number) {
587
615
  // Use realtime (not newvalue) — newvalue is consumed and cleared after
588
616
  // a single frame, so scheduled visemes would vanish immediately.
589
617
  // realtime persists until explicitly set to null.
590
- if (head?.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
618
+ if (head && head.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
591
619
  const mt = head.mtAvatar[e.morphName];
592
620
  mt.realtime = targetWeight > 0 ? targetWeight : null;
593
621
  mt.needsUpdate = true;
@@ -771,7 +799,7 @@ async function applyAccessories(accessoriesList) {
771
799
  if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
772
800
  log('[ACC] GLB loaded OK for ' + accData.id);
773
801
  const model = gltf.scene;
774
- const latestData = currentAccessories[accData.id]?.latestData || accData;
802
+ const latestData = (currentAccessories[accData.id] && currentAccessories[accData.id].latestData) || accData;
775
803
  let targetBone = null;
776
804
  let prefixCandidate = null;
777
805
  root.traverse((child) => {
@@ -1109,6 +1137,7 @@ function onIncomingMessage(event) {
1109
1137
  msg.value * RHUBARB_FALLBACK_AMPLITUDE_GAIN,
1110
1138
  );
1111
1139
  amplitudeDecay = Math.max(amplitudeDecay * 0.7, val);
1140
+ updateVoiceMood(msg.value);
1112
1141
  for (let i = 0; i < jawMorphCache.length; i++) jawMorphCache[i].influences[jawMorphCache[i].idx] = amplitudeDecay;
1113
1142
  } else if (msg.type === 'viseme') {
1114
1143
  applyViseme(msg.viseme, msg.weight !== undefined ? msg.weight : 1.0);
@@ -1130,7 +1159,7 @@ function onIncomingMessage(event) {
1130
1159
  if (typeof window.playMotion === 'function') {
1131
1160
  window.playMotion(msg.name);
1132
1161
  }
1133
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
1162
+ rnPost(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
1134
1163
  log('motion dispatched: ' + msg.name);
1135
1164
  }
1136
1165
  } catch (err) {
@@ -232,7 +232,7 @@ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, m
232
232
  (0, react_1.useEffect)(() => () => clearScheduleTimers(), [clearScheduleTimers]);
233
233
  (0, react_1.useImperativeHandle)(ref, () => ({
234
234
  setMood: (m) => { morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[m] ?? {}) }; },
235
- sendAmplitude: (_amplitude) => { },
235
+ sendAmplitude: () => { },
236
236
  sendViseme: (viseme, weight) => {
237
237
  const aliases = morphTables_1.VISEME_MORPH_ALIASES[viseme];
238
238
  if (!aliases)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-head-studio",
3
- "version": "0.4.5",
3
+ "version": "0.4.8",
4
4
  "description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
5
5
  "main": "dist/index.web.js",
6
6
  "browser": "dist/index.web.js",