talking-head-studio 0.4.6 → 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.
@@ -46,8 +46,10 @@ function getLoadingLabel(stage) {
46
46
  */
47
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), []);
@@ -183,26 +185,15 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
183
185
  lastScheduledVisemeKeyRef.current = scheduleKey;
184
186
  }, [isAvatarReady, onVisemeScheduleApplied, visemeSchedule, activeAvatar]);
185
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.
186
190
  if (react_native_1.Platform.OS !== 'web') {
187
- return ((0, jsx_runtime_1.jsx)(WgpuAvatar_1.WgpuAvatar, { focalLength: focalLength, ref: (fr) => {
188
- wgpuRef.current = fr;
189
- if (typeof ref === 'function')
190
- ref(fr);
191
- else if (ref)
192
- ref.current = fr;
193
- if (fr && pendingVisemeScheduleRef.current) {
194
- fr.scheduleVisemes(pendingVisemeScheduleRef.current);
195
- pendingVisemeScheduleRef.current = null;
196
- }
197
- }, 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 }));
198
192
  }
199
193
  if (!effectiveAvatarUrl) {
200
194
  return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.placeholder] }));
201
195
  }
202
- 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.jsx)(react_native_1.View, { style: styles.progressTrack, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
203
- styles.progressFill,
204
- { width: `${Math.max(6, loadingState.progress)}%` },
205
- ] }) })] })) : ((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" }))] }) }))] }));
206
197
  });
207
198
  exports.TalkingHeadVisualization.displayName = 'TalkingHeadVisualization';
208
199
  const styles = react_native_1.StyleSheet.create({
@@ -264,11 +255,7 @@ const styles = react_native_1.StyleSheet.create({
264
255
  backgroundColor: 'rgba(148, 163, 184, 0.18)',
265
256
  marginTop: 12,
266
257
  overflow: 'hidden',
267
- },
268
- progressFill: {
269
- height: '100%',
270
- borderRadius: 999,
271
- backgroundColor: '#5eead4',
258
+ flexDirection: 'row',
272
259
  },
273
260
  placeholder: {
274
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();
@@ -400,14 +401,14 @@ function updateVoiceMood(rawAmplitude) {
400
401
  voiceMoodFramesBelow = 0;
401
402
  if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
402
403
  voiceMoodCurrent = 'excited';
403
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
404
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
404
405
  }
405
406
  } else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
406
407
  voiceMoodFramesBelow++;
407
408
  voiceMoodFramesAbove = 0;
408
409
  if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
409
410
  voiceMoodCurrent = 'neutral';
410
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
411
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
411
412
  }
412
413
  }
413
414
  }
@@ -578,7 +579,7 @@ function clearScheduledVisemes() {
578
579
  for (const key of Object.keys(visemeState)) visemeState[key] = 0;
579
580
  }
580
581
 
581
- function tickVisemeDecay(deltaSeconds?: number) {
582
+ function tickVisemeDecay(deltaSeconds) {
582
583
  if (!visemeMorphCache) return;
583
584
 
584
585
  const isScheduled = Date.now() < visemeModeUntil;
@@ -614,7 +615,7 @@ function tickVisemeDecay(deltaSeconds?: number) {
614
615
  // Use realtime (not newvalue) — newvalue is consumed and cleared after
615
616
  // a single frame, so scheduled visemes would vanish immediately.
616
617
  // realtime persists until explicitly set to null.
617
- if (head?.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
618
+ if (head && head.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
618
619
  const mt = head.mtAvatar[e.morphName];
619
620
  mt.realtime = targetWeight > 0 ? targetWeight : null;
620
621
  mt.needsUpdate = true;
@@ -798,7 +799,7 @@ async function applyAccessories(accessoriesList) {
798
799
  if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
799
800
  log('[ACC] GLB loaded OK for ' + accData.id);
800
801
  const model = gltf.scene;
801
- const latestData = currentAccessories[accData.id]?.latestData || accData;
802
+ const latestData = (currentAccessories[accData.id] && currentAccessories[accData.id].latestData) || accData;
802
803
  let targetBone = null;
803
804
  let prefixCandidate = null;
804
805
  root.traverse((child) => {
@@ -1158,7 +1159,7 @@ function onIncomingMessage(event) {
1158
1159
  if (typeof window.playMotion === 'function') {
1159
1160
  window.playMotion(msg.name);
1160
1161
  }
1161
- window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
1162
+ rnPost(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
1162
1163
  log('motion dispatched: ' + msg.name);
1163
1164
  }
1164
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.6",
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",