talking-head-studio 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/html.js CHANGED
@@ -1,4 +1,7 @@
1
+ const VALID_MOODS = new Set(['neutral', 'happy', 'sad', 'angry', 'excited', 'thinking', 'concerned', 'surprised']);
1
2
  export function buildAvatarHtml(config) {
3
+ // Sanitize mood at build time so the WebView never receives an invalid value
4
+ const safeMood = VALID_MOODS.has(config.mood) ? config.mood : 'neutral';
2
5
  return `
3
6
  <!DOCTYPE html>
4
7
  <html>
@@ -28,8 +31,8 @@ const HEAD_AUDIO_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@main/d
28
31
  const HEAD_AUDIO_WORKLET = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@main/dist/headworklet.min.mjs';
29
32
  const HEAD_AUDIO_MODEL = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@main/dist/model-en-mixed.bin';
30
33
 
31
- const AVATAR_URL = ${JSON.stringify(config.avatarUrl)};
32
- const INITIAL_MOOD = ${JSON.stringify(config.mood)};
34
+ let AVATAR_URL = ${JSON.stringify(config.avatarUrl)};
35
+ const INITIAL_MOOD = ${JSON.stringify(safeMood)};
33
36
  const CAMERA_VIEW = ${JSON.stringify(config.cameraView)};
34
37
  const CAMERA_DISTANCE = ${config.cameraDistance};
35
38
  let HAIR_COLOR = ${JSON.stringify(config.initialHairColor ?? null)};
@@ -42,11 +45,15 @@ let headaudio = null;
42
45
  let mouthMeshes = [];
43
46
  let staticModel = null;
44
47
 
48
+ // Guard: prevent multiple concurrent init() calls (React StrictMode / rapid remounts)
49
+ let initStarted = false;
50
+
45
51
  function log(msg) {
46
52
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'log', message: msg }));
47
53
  }
48
54
 
49
55
  async function loadWithAuth(url) {
56
+ if (!url) throw new Error('Avatar URL is empty');
50
57
  if (AUTH_TOKEN && !url.startsWith('https://cdn.jsdelivr.net')) {
51
58
  log('Fetching authenticated model: ' + url);
52
59
  const resp = await fetch(url, { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } });
@@ -93,9 +100,9 @@ async function loadStaticFallback(loadedAvatarUrl) {
93
100
  const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
94
101
  camera.position.set(0, 0, 3);
95
102
 
96
- const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
103
+ const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, powerPreference: 'low-power' });
97
104
  renderer.setSize(window.innerWidth, window.innerHeight);
98
- renderer.setPixelRatio(window.devicePixelRatio);
105
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
99
106
  container.appendChild(renderer.domElement);
100
107
 
101
108
  const controls = new OrbitControls(camera, renderer.domElement);
@@ -113,45 +120,36 @@ async function loadStaticFallback(loadedAvatarUrl) {
113
120
  loader.load(loadedAvatarUrl, (gltf) => {
114
121
  staticModel = gltf.scene;
115
122
  applyColorOverrides();
116
-
117
123
  scene.add(staticModel);
118
124
 
119
- // Play any embedded animations (walk cycles, idle, etc.)
120
125
  if (gltf.animations && gltf.animations.length > 0) {
121
126
  staticMixer = new THREE.AnimationMixer(staticModel);
122
- for (const clip of gltf.animations) {
123
- staticMixer.clipAction(clip).play();
124
- }
127
+ for (const clip of gltf.animations) staticMixer.clipAction(clip).play();
125
128
  log('Playing ' + gltf.animations.length + ' animation(s)');
126
129
  }
127
130
 
128
- // Collect morph targets for amplitude-driven jaw/mouth
129
- jawMorphCache = null; // invalidate cache whenever meshes are repopulated
131
+ jawMorphCache = null;
132
+ visemeMorphCache = null;
133
+ rhubarbMorphCache = null;
130
134
  staticModel.traverse((child) => {
131
135
  if (child.isMesh && child.morphTargetDictionary && child.morphTargetInfluences) {
132
136
  mouthMeshes.push(child);
133
137
  }
134
138
  });
135
- if (mouthMeshes.length > 0) {
136
- log('Found ' + mouthMeshes.length + ' mesh(es) with morph targets');
137
- }
139
+ if (mouthMeshes.length > 0) log('Found ' + mouthMeshes.length + ' mesh(es) with morph targets');
138
140
 
139
- // Auto-frame the model
140
141
  const box = new THREE.Box3().setFromObject(staticModel);
141
142
  const size = box.getSize(new THREE.Vector3());
142
143
  const maxDim = Math.max(size.x, size.y, size.z);
143
-
144
144
  if (maxDim > 0 && maxDim !== Infinity) {
145
145
  const scale = 1.5 / maxDim;
146
146
  staticModel.scale.set(scale, scale, scale);
147
147
  }
148
-
149
148
  const scaledBox = new THREE.Box3().setFromObject(staticModel);
150
149
  const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
151
150
  staticModel.position.sub(scaledCenter);
152
151
 
153
152
  applyAccessories(pendingAccessoriesList);
154
-
155
153
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
156
154
 
157
155
  window.addEventListener('resize', () => {
@@ -163,14 +161,12 @@ async function loadStaticFallback(loadedAvatarUrl) {
163
161
  renderer.setAnimationLoop(() => {
164
162
  const delta = clock.getDelta();
165
163
  if (staticMixer) staticMixer.update(delta);
164
+ tickVisemeDecay();
166
165
  controls.update();
167
166
  renderer.render(scene, camera);
168
167
  });
169
168
  }, (ev) => {
170
- if (ev.lengthComputable) {
171
- const pct = Math.round((ev.loaded / ev.total) * 100);
172
- log('Fallback Loading: ' + pct + '%');
173
- }
169
+ if (ev.lengthComputable) log('Fallback Loading: ' + Math.round((ev.loaded / ev.total) * 100) + '%');
174
170
  }, (err) => {
175
171
  log('Fallback Error: ' + err.message);
176
172
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
@@ -182,6 +178,17 @@ async function loadStaticFallback(loadedAvatarUrl) {
182
178
  }
183
179
 
184
180
  async function init() {
181
+ // Prevent concurrent inits from React StrictMode double-invoke or rapid remounts
182
+ if (initStarted) { log('init() already running, skipping duplicate'); return; }
183
+ initStarted = true;
184
+
185
+ // If avatar URL is empty wait for a set_avatar_url message rather than crashing
186
+ if (!AVATAR_URL) {
187
+ log('No avatarUrl yet — waiting for set_avatar_url message');
188
+ initStarted = false;
189
+ return;
190
+ }
191
+
185
192
  try {
186
193
  log('Loading TalkingHead...');
187
194
  const module = await import(TALKING_HEAD_URL);
@@ -210,26 +217,26 @@ async function init() {
210
217
  await head.showAvatar({
211
218
  url: loadedAvatarUrl,
212
219
  body: 'F',
213
- avatarMood: INITIAL_MOOD,
220
+ avatarMood: INITIAL_MOOD, // always 'neutral' or a valid mood — sanitized above
214
221
  lipsyncLang: 'en',
215
222
  }, (ev) => {
216
223
  if (ev.lengthComputable) {
217
- const pct = Math.round((ev.loaded / ev.total) * 100);
218
- log('Loading: ' + pct + '%');
224
+ log('Loading: ' + Math.round((ev.loaded / ev.total) * 100) + '%');
219
225
  }
220
226
  });
221
227
  if (loadedAvatarUrl.startsWith('blob:')) URL.revokeObjectURL(loadedAvatarUrl);
222
228
  avatarLoaded = true;
223
229
  } catch (avatarErr) {
224
- // Non-rigged avatars (no Armature) are expected — fall back to static viewer silently
225
230
  log('No armature found, using static viewer: ' + avatarErr.message);
226
231
  await loadStaticFallback(loadedAvatarUrl);
227
232
  if (loadedAvatarUrl.startsWith('blob:')) URL.revokeObjectURL(loadedAvatarUrl);
228
- return; // fallback posts 'ready' itself; don't post 'error'
233
+ return;
229
234
  }
230
235
 
231
236
  if (avatarLoaded) {
232
- jawMorphCache = null; // invalidate cache whenever meshes are repopulated
237
+ jawMorphCache = null;
238
+ visemeMorphCache = null;
239
+ rhubarbMorphCache = null;
233
240
  head.armature?.traverse((child) => {
234
241
  if (child.isMesh && child.morphTargetDictionary && child.morphTargetInfluences) {
235
242
  mouthMeshes.push(child);
@@ -241,13 +248,12 @@ async function init() {
241
248
  log('Initializing HeadAudio...');
242
249
  try {
243
250
  const haModule = await import(HEAD_AUDIO_URL);
251
+ // AudioWorklet is not supported in React Native WebViews (iOS/Android).
244
252
  if (haModule.HeadAudio && head.audioCtx && head.audioCtx.audioWorklet) {
245
253
  await head.audioCtx.audioWorklet.addModule(HEAD_AUDIO_WORKLET);
246
254
  headaudio = new haModule.HeadAudio(head.audioCtx);
247
255
  await headaudio.loadModel(HEAD_AUDIO_MODEL);
248
- if (head.audioSpeechGainNode) {
249
- head.audioSpeechGainNode.connect(headaudio);
250
- }
256
+ if (head.audioSpeechGainNode) head.audioSpeechGainNode.connect(headaudio);
251
257
  headaudio.onvalue = (key, value) => {
252
258
  if (head.mtAvatar && head.mtAvatar[key]) {
253
259
  Object.assign(head.mtAvatar[key], { newvalue: value, needsUpdate: true });
@@ -255,9 +261,11 @@ async function init() {
255
261
  };
256
262
  head.opt.update = headaudio.update.bind(headaudio);
257
263
  log('HeadAudio ready (phoneme lip sync)');
264
+ } else {
265
+ log('HeadAudio skipped: AudioWorklet not supported in this WebView. Use sendViseme() from native TTS callbacks.');
258
266
  }
259
267
  } catch (err) {
260
- log('HeadAudio unavailable, amplitude fallback active: ' + err.message);
268
+ log('HeadAudio unavailable, viseme/amplitude fallback active: ' + err.message);
261
269
  }
262
270
 
263
271
  startAudioInterception();
@@ -266,6 +274,7 @@ async function init() {
266
274
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
267
275
  }
268
276
  } catch (err) {
277
+ initStarted = false; // allow retry on error
269
278
  log('Error: ' + err.message);
270
279
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
271
280
  }
@@ -282,27 +291,19 @@ function startAudioInterception() {
282
291
  const stream = el.srcObject;
283
292
  if (!stream || connected.has(stream)) return !!stream;
284
293
  connected.add(stream);
285
- try {
286
- audioCtx.createMediaStreamSource(stream).connect(gainNode);
287
- } catch (e) {
288
- log('Audio routing error: ' + e);
289
- }
294
+ try { audioCtx.createMediaStreamSource(stream).connect(gainNode); } catch (e) { log('Audio routing error: ' + e); }
290
295
  return true;
291
296
  }
292
297
 
293
298
  function track(el) {
294
299
  if (!tryConnect(el)) pending.add(el);
295
- // Listen for play events which happen when stream is ready to avoid polling
296
- const onReady = () => {
297
- if (tryConnect(el)) pending.delete(el);
298
- };
300
+ const onReady = () => { if (tryConnect(el)) pending.delete(el); };
299
301
  el.addEventListener('play', onReady);
300
302
  el.addEventListener('playing', onReady);
301
303
  el.addEventListener('loadedmetadata', onReady);
302
304
  }
303
305
 
304
306
  document.querySelectorAll('audio').forEach((el) => track(el));
305
-
306
307
  new MutationObserver((mutations) => {
307
308
  for (const mut of mutations) {
308
309
  for (const node of mut.addedNodes) {
@@ -314,11 +315,164 @@ function startAudioInterception() {
314
315
  }
315
316
 
316
317
  let amplitudeDecay = 0;
317
- // Cached jaw morph-target indices — built once after model load, used every amplitude tick.
318
- let jawMorphCache = null; // [{influences, idx}] or null = not yet built
318
+ let jawMorphCache = null;
319
+ let visemeMorphCache = null;
320
+ const visemeState = {};
321
+
322
+ const VISEME_MORPH_ALIASES = {
323
+ sil: ['viseme_sil', 'sil'],
324
+ PP: ['viseme_PP', 'pp', 'viseme_pp'],
325
+ FF: ['viseme_FF', 'ff', 'viseme_ff'],
326
+ TH: ['viseme_TH', 'th', 'viseme_th'],
327
+ DD: ['viseme_DD', 'dd', 'viseme_dd'],
328
+ kk: ['viseme_kk', 'kk', 'viseme_k'],
329
+ CH: ['viseme_CH', 'ch', 'viseme_ch'],
330
+ SS: ['viseme_SS', 'ss', 'viseme_s'],
331
+ nn: ['viseme_nn', 'nn', 'viseme_n'],
332
+ RR: ['viseme_RR', 'rr', 'viseme_r'],
333
+ aa: ['viseme_aa', 'viseme_AA', 'aa', 'jawOpen', 'jaw_open', 'jawopen', 'mouthOpen', 'mouth_open', 'mouthopen'],
334
+ ee: ['viseme_ee', 'viseme_E', 'ee'],
335
+ ih: ['viseme_ih', 'viseme_I', 'ih'],
336
+ oh: ['viseme_oh', 'viseme_O', 'oh'],
337
+ ou: ['viseme_ou', 'viseme_U', 'ou'],
338
+ };
339
+
340
+ function buildVisemeMorphCache() {
341
+ visemeMorphCache = {};
342
+ for (const [visemeKey, aliases] of Object.entries(VISEME_MORPH_ALIASES)) {
343
+ const entries = [];
344
+ for (const mesh of mouthMeshes) {
345
+ if (!mesh.morphTargetDictionary) continue;
346
+ const dictKeys = Object.keys(mesh.morphTargetDictionary);
347
+ for (const alias of aliases) {
348
+ const found = dictKeys.find(k => k.toLowerCase() === alias.toLowerCase());
349
+ if (found !== undefined) {
350
+ entries.push({ influences: mesh.morphTargetInfluences, idx: mesh.morphTargetDictionary[found] });
351
+ break;
352
+ }
353
+ }
354
+ }
355
+ if (entries.length > 0) visemeMorphCache[visemeKey] = entries;
356
+ }
357
+ const found = Object.keys(visemeMorphCache);
358
+ log('Viseme cache: ' + (found.length > 0 ? found.join(', ') : 'none — check morph target names'));
359
+ }
360
+
361
+ function applyViseme(visemeKey, weight) {
362
+ if (!visemeMorphCache) buildVisemeMorphCache();
363
+ if (visemeKey === 'sil' || weight <= 0) {
364
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
365
+ return;
366
+ }
367
+ visemeState[visemeKey] = Math.min(1, weight);
368
+ }
369
+
370
+ function tickVisemeDecay() {
371
+ if (!visemeMorphCache) return;
372
+ for (const [key, weight] of Object.entries(visemeState)) {
373
+ const decayed = weight * 0.82;
374
+ visemeState[key] = decayed < 0.01 ? 0 : decayed;
375
+ const entries = visemeMorphCache[key];
376
+ if (!entries) continue;
377
+ for (const e of entries) e.influences[e.idx] = visemeState[key];
378
+ }
379
+ }
380
+
381
+ // ============ RHUBARB VISEME SCHEDULER ============
382
+ // Rhubarb mouth shapes A-H, X mapped to the closest standard viseme morph targets.
383
+ // Amplitude fallback is gated while a schedule is active (visemeModeUntil).
384
+
385
+ const RHUBARB_TO_VISEME = {
386
+ X: 'sil', // silence — mouth closed
387
+ A: 'PP', // m, b, p — lips together
388
+ B: 'kk', // k, s, t — slightly open
389
+ C: 'ee', // e as in bed — open with smile
390
+ D: 'aa', // aa as in father — wide open
391
+ E: 'oh', // eh/uh — rounded open
392
+ F: 'ou', // oo/w — puckered
393
+ G: 'FF', // f, v — teeth on lip
394
+ H: 'ih', // ee as in see — wide smile
395
+ };
396
+
397
+ let rhubarbMorphCache = null;
398
+ let visemeTimers = [];
399
+ let activeVisemeScheduleId = 0;
400
+ let visemeModeUntil = 0;
401
+
402
+ function buildRhubarbMorphCache() {
403
+ if (!visemeMorphCache) buildVisemeMorphCache();
404
+ rhubarbMorphCache = {};
405
+ for (const [rhubarbShape, visemeKey] of Object.entries(RHUBARB_TO_VISEME)) {
406
+ if (visemeKey === 'sil') { rhubarbMorphCache[rhubarbShape] = null; continue; }
407
+ rhubarbMorphCache[rhubarbShape] = visemeMorphCache[visemeKey] || null;
408
+ }
409
+ log('Rhubarb morph cache built');
410
+ }
411
+
412
+ function applyRhubarbCue(shape) {
413
+ if (!rhubarbMorphCache) buildRhubarbMorphCache();
414
+ // Zero all active viseme channels first
415
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
416
+ if (shape === 'X' || !rhubarbMorphCache[shape]) return;
417
+ const visemeKey = RHUBARB_TO_VISEME[shape];
418
+ if (visemeKey && visemeKey !== 'sil') {
419
+ visemeState[visemeKey] = 1.0;
420
+ }
421
+ }
422
+
423
+ function clearScheduledVisemes() {
424
+ activeVisemeScheduleId++;
425
+ for (const id of visemeTimers) clearTimeout(id);
426
+ visemeTimers = [];
427
+ visemeModeUntil = 0;
428
+ // Zero all mouth morphs
429
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
430
+ }
431
+
432
+ function scheduleVisemes(schedule) {
433
+ clearScheduledVisemes();
434
+ if (!schedule || !Array.isArray(schedule.cues) || schedule.cues.length === 0) return;
435
+
436
+ const myScheduleId = activeVisemeScheduleId;
437
+ const startedAt = schedule.startedAtMs || Date.now();
438
+ const durationMs = schedule.durationMs || 0;
439
+
440
+ // Gate amplitude fallback for the full duration plus a small buffer
441
+ visemeModeUntil = startedAt + durationMs + 200;
442
+
443
+ for (const cue of schedule.cues) {
444
+ const delay = cue.startMs - (Date.now() - startedAt);
445
+ if (delay < -50) continue; // already in the past, skip
446
+
447
+ const applyId = setTimeout(() => {
448
+ if (activeVisemeScheduleId !== myScheduleId) return;
449
+ applyRhubarbCue(cue.viseme);
450
+ }, Math.max(0, delay));
451
+
452
+ const clearId = setTimeout(() => {
453
+ if (activeVisemeScheduleId !== myScheduleId) return;
454
+ // Only clear if the next cue hasn't already overwritten state
455
+ visemeState[RHUBARB_TO_VISEME[cue.viseme]] = 0;
456
+ }, Math.max(0, delay + (cue.endMs - cue.startMs)));
457
+
458
+ visemeTimers.push(applyId, clearId);
459
+ }
460
+
461
+ // Ensure silence at schedule end
462
+ const endDelay = durationMs - (Date.now() - startedAt);
463
+ if (endDelay > 0) {
464
+ visemeTimers.push(setTimeout(() => {
465
+ if (activeVisemeScheduleId !== myScheduleId) return;
466
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
467
+ visemeModeUntil = 0;
468
+ }, endDelay + 100));
469
+ }
470
+ }
471
+ // ============ END RHUBARB VISEME SCHEDULER ============
472
+
319
473
  let THREE_REF = null;
320
474
  let gltfLoaderInstance = null;
321
- const currentAccessories = {}; // { [id]: { model: THREE.Group | null, url, bone, position, rotation, scale, isLoading: boolean, latestData: any } }
475
+ const currentAccessories = {};
322
476
  let pendingAccessoriesList = [];
323
477
 
324
478
  function disposeHierarchy(node) {
@@ -347,7 +501,6 @@ async function ensureThree() {
347
501
  THREE_REF = await import('three');
348
502
  const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
349
503
  const { DRACOLoader } = await import('three/addons/loaders/DRACOLoader.js');
350
-
351
504
  gltfLoaderInstance = new GLTFLoader();
352
505
  const dracoLoader = new DRACOLoader();
353
506
  dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
@@ -363,21 +516,16 @@ async function applyAccessories(accessoriesList) {
363
516
  log('[ACC] root=' + (root ? root.constructor.name + '/' + root.name : 'NULL') + ' head=' + !!head + ' head.armature=' + !!(head && head.armature) + ' staticModel=' + !!staticModel);
364
517
  if (!root) { log('[ACC] ABORT: no root'); return; }
365
518
 
366
- // Debug: list all bones in the root
367
519
  const boneNames = [];
368
520
  root.traverse((child) => { if (child.isBone) boneNames.push(child.name); });
369
521
  log('[ACC] Bones found: ' + boneNames.join(', '));
370
522
 
371
523
  const newAccessoryIds = new Set(accessoriesList.map(a => a.id));
372
-
373
- // Remove old ones
374
524
  for (const id in currentAccessories) {
375
525
  if (!newAccessoryIds.has(id)) {
376
526
  const acc = currentAccessories[id];
377
527
  if (acc.model) {
378
- if (acc.model.parent) {
379
- acc.model.parent.remove(acc.model);
380
- }
528
+ if (acc.model.parent) acc.model.parent.remove(acc.model);
381
529
  disposeHierarchy(acc.model);
382
530
  }
383
531
  delete currentAccessories[id];
@@ -385,26 +533,17 @@ async function applyAccessories(accessoriesList) {
385
533
  }
386
534
  }
387
535
 
388
- // Add or update
389
536
  for (const accData of accessoriesList) {
390
537
  log('[ACC] Processing: id=' + accData.id + ' url=' + accData.url + ' bone=' + accData.bone);
391
538
  const existing = currentAccessories[accData.id];
392
539
 
393
- // If URL changed or it's new, we need to load it
394
540
  if (!existing || existing.url !== accData.url) {
395
541
  if (existing && existing.model) {
396
- if (existing.model.parent) {
397
- existing.model.parent.remove(existing.model);
398
- }
542
+ if (existing.model.parent) existing.model.parent.remove(existing.model);
399
543
  disposeHierarchy(existing.model);
400
544
  }
401
-
402
- // Mark as loading and store the latest transform data
403
545
  currentAccessories[accData.id] = { ...accData, model: null, isLoading: true, latestData: accData };
404
-
405
546
  log('[ACC] Starting GLB load: ' + accData.url);
406
-
407
- // Use an IIFE to handle the async load with auth
408
547
  (async () => {
409
548
  try {
410
549
  const loadedUrl = await loadWithAuth(accData.url);
@@ -412,41 +551,20 @@ async function applyAccessories(accessoriesList) {
412
551
  if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
413
552
  log('[ACC] GLB loaded OK for ' + accData.id);
414
553
  const model = gltf.scene;
415
-
416
- // Grab the *latest* data that might have arrived while we were loading
417
554
  const latestData = currentAccessories[accData.id]?.latestData || accData;
418
-
419
555
  let targetBone = null;
420
- // Exact match first, then prefix match for Sketchfab exports (e.g. Head_5)
421
556
  let prefixCandidate = null;
422
557
  root.traverse((child) => {
423
- if (child.isBone && child.name === latestData.bone) {
424
- targetBone = child;
425
- } else if (!prefixCandidate && child.name.replace(/_\\d+$/, '') === latestData.bone) {
426
- prefixCandidate = child;
427
- }
558
+ if (child.isBone && child.name === latestData.bone) targetBone = child;
559
+ else if (!prefixCandidate && child.name.replace(/_\\d+$/, '') === latestData.bone) prefixCandidate = child;
428
560
  });
429
561
  if (!targetBone && prefixCandidate) {
430
562
  targetBone = prefixCandidate;
431
- log('[ACC] Exact bone "' + accData.bone + '" not found, using prefix match: ' + targetBone.name);
563
+ log('[ACC] Prefix match bone: ' + targetBone.name);
432
564
  }
433
-
434
- // Prevent frustum culling from making the accessory disappear when bone moves
435
- model.traverse((child) => {
436
- if (child.isMesh) {
437
- child.frustumCulled = false;
438
- }
439
- });
440
-
441
- if (!targetBone) {
442
- log('[ACC] Bone not found: ' + latestData.bone + '. Falling back to root.');
443
- targetBone = root;
444
- } else {
445
- log('[ACC] Found bone: ' + targetBone.name);
446
- }
447
-
448
- // Apply scale and position directly from stored data.
449
- // Filament editor is the single source of truth for placement.
565
+ model.traverse((child) => { if (child.isMesh) child.frustumCulled = false; });
566
+ if (!targetBone) { log('[ACC] Bone not found: ' + latestData.bone + '. Using root.'); targetBone = root; }
567
+ else log('[ACC] Found bone: ' + targetBone.name);
450
568
  const modelScale = latestData.scale !== undefined ? latestData.scale : 1.0;
451
569
  model.scale.set(modelScale, modelScale, modelScale);
452
570
  model.position.set(
@@ -454,52 +572,29 @@ async function applyAccessories(accessoriesList) {
454
572
  latestData.position ? latestData.position[1] : 0,
455
573
  latestData.position ? latestData.position[2] : 0
456
574
  );
457
- if (latestData.rotation) {
458
- model.rotation.set(...latestData.rotation);
459
- }
460
-
461
- log('[ACC] Final pos=' + model.position.x.toFixed(3) + ',' + model.position.y.toFixed(3) + ',' + model.position.z.toFixed(3) + ' scale=' + model.scale.x.toFixed(3));
462
-
463
- // Ensure the accessory wasn't removed or changed while loading
575
+ if (latestData.rotation) model.rotation.set(...latestData.rotation);
464
576
  if (!currentAccessories[accData.id] || currentAccessories[accData.id].url !== accData.url) {
465
- log('[ACC] Aborting attachment: ' + accData.id + ' was removed or changed while loading.');
577
+ log('[ACC] Aborting: ' + accData.id + ' changed while loading.');
466
578
  return;
467
579
  }
468
-
469
580
  targetBone.add(model);
470
- log('[ACC] Model attached to bone ' + targetBone.name + '. Children count: ' + targetBone.children.length);
471
-
581
+ log('[ACC] Attached to ' + targetBone.name);
472
582
  currentAccessories[accData.id].model = model;
473
583
  currentAccessories[accData.id].isLoading = false;
474
-
475
- }, (progress) => {
476
- if (progress.lengthComputable) {
477
- // log('[ACC] Load progress ' + accData.id + ': ' + Math.round((progress.loaded / progress.total) * 100) + '%');
478
- }
479
- }, (err) => {
584
+ }, () => {}, (err) => {
480
585
  if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
481
- log('[ACC] FAILED to load accessory ' + accData.id + ': ' + err.message);
482
- if (currentAccessories[accData.id]) {
483
- currentAccessories[accData.id].isLoading = false;
484
- }
586
+ log('[ACC] FAILED: ' + accData.id + ': ' + err.message);
587
+ if (currentAccessories[accData.id]) currentAccessories[accData.id].isLoading = false;
485
588
  });
486
589
  } catch (authErr) {
487
- log('[ACC] FAILED to fetch accessory blob ' + accData.id + ': ' + authErr.message);
488
- if (currentAccessories[accData.id]) {
489
- currentAccessories[accData.id].isLoading = false;
490
- }
590
+ log('[ACC] Auth fetch failed ' + accData.id + ': ' + authErr.message);
591
+ if (currentAccessories[accData.id]) currentAccessories[accData.id].isLoading = false;
491
592
  }
492
593
  })();
493
594
  } else if (existing && existing.isLoading) {
494
- // It's already loading, just update the latestData so when it finishes it uses these transforms
495
- log('[ACC] Still loading ' + accData.id + '... buffering latest transforms');
496
595
  existing.latestData = accData;
497
596
  } else if (existing && existing.model) {
498
- log('[ACC] Updating existing model for ' + accData.id);
499
- // Just update transform if model already loaded
500
597
  const model = existing.model;
501
-
502
- // Apply scale and position directly from stored data.
503
598
  const accScale = accData.scale !== undefined ? accData.scale : 1.0;
504
599
  model.scale.set(accScale, accScale, accScale);
505
600
  model.position.set(
@@ -507,9 +602,7 @@ async function applyAccessories(accessoriesList) {
507
602
  accData.position ? accData.position[1] : 0,
508
603
  accData.position ? accData.position[2] : 0
509
604
  );
510
- if (accData.rotation) {
511
- model.rotation.set(...accData.rotation);
512
- }
605
+ if (accData.rotation) model.rotation.set(...accData.rotation);
513
606
  }
514
607
  }
515
608
  }
@@ -518,7 +611,8 @@ function onIncomingMessage(event) {
518
611
  try {
519
612
  const msg = JSON.parse(event.data);
520
613
  if (msg.type === 'amplitude' && mouthMeshes.length > 0) {
521
- // Build jaw index cache once, reuse on every subsequent tick
614
+ // Gate: do not fight an active Rhubarb viseme schedule
615
+ if (Date.now() < visemeModeUntil) return;
522
616
  if (!jawMorphCache) {
523
617
  jawMorphCache = [];
524
618
  for (const mesh of mouthMeshes) {
@@ -530,35 +624,28 @@ function onIncomingMessage(event) {
530
624
  lk.includes('mouthopen') || lk.includes('mouth_open') ||
531
625
  lk.includes('viseme_aa') || lk === 'aa';
532
626
  });
533
- if (jawKey !== undefined) {
534
- jawMorphCache.push({ influences: mesh.morphTargetInfluences, idx: mesh.morphTargetDictionary[jawKey] });
535
- }
627
+ if (jawKey !== undefined) jawMorphCache.push({ influences: mesh.morphTargetInfluences, idx: mesh.morphTargetDictionary[jawKey] });
536
628
  }
537
629
  }
538
- const val = Math.min(1, msg.value * 1.8);
630
+ const val = Math.min(1, msg.value * 2.5);
539
631
  amplitudeDecay = Math.max(amplitudeDecay * 0.7, val);
540
- for (let i = 0; i < jawMorphCache.length; i++) {
541
- jawMorphCache[i].influences[jawMorphCache[i].idx] = amplitudeDecay;
542
- }
632
+ for (let i = 0; i < jawMorphCache.length; i++) jawMorphCache[i].influences[jawMorphCache[i].idx] = amplitudeDecay;
633
+ } else if (msg.type === 'viseme') {
634
+ applyViseme(msg.viseme, msg.weight !== undefined ? msg.weight : 1.0);
635
+ } else if (msg.type === 'schedule_visemes') {
636
+ scheduleVisemes(msg.schedule || { cues: [] });
637
+ } else if (msg.type === 'clear_visemes') {
638
+ clearScheduledVisemes();
543
639
  } else if (msg.type === 'mood' && head) {
544
- // Map agent moods to library moods (library accepts: neutral,happy,angry,sad,fear,disgust,love,sleep)
545
- const moodMap = {
546
- neutral: 'neutral', happy: 'happy', sad: 'sad', angry: 'angry',
547
- excited: 'happy', thinking: 'neutral', concerned: 'sad', surprised: 'happy',
548
- };
549
- const m = moodMap[msg.value] || 'neutral';
550
- head.setMood(m);
640
+ const moodMap = { neutral:'neutral', happy:'happy', sad:'sad', angry:'angry', excited:'happy', thinking:'neutral', concerned:'sad', surprised:'happy' };
641
+ head.setMood(moodMap[msg.value] || 'neutral');
551
642
  } else if (msg.type === 'hair_color') {
552
- HAIR_COLOR = msg.value;
553
- applyColorOverrides();
643
+ HAIR_COLOR = msg.value; applyColorOverrides();
554
644
  } else if (msg.type === 'skin_color') {
555
- SKIN_COLOR = msg.value;
556
- applyColorOverrides();
645
+ SKIN_COLOR = msg.value; applyColorOverrides();
557
646
  } else if (msg.type === 'eye_color') {
558
- EYE_COLOR = msg.value;
559
- applyColorOverrides();
647
+ EYE_COLOR = msg.value; applyColorOverrides();
560
648
  } else if (msg.type === 'set_accessories') {
561
- log('[ACC] Received set_accessories message with ' + (msg.accessories ? msg.accessories.length : 0) + ' items');
562
649
  applyAccessories(msg.accessories || []);
563
650
  }
564
651
  } catch (err) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { TalkingHeadAccessory, TalkingHeadMood, TalkingHeadProps, TalkingHeadRef, } from './TalkingHead';
1
+ export type { TalkingHeadAccessory, TalkingHeadMood, TalkingHeadProps, TalkingHeadRef, TalkingHeadViseme, TalkingHeadVisemeCue, TalkingHeadVisemeSchedule, } from './TalkingHead';
2
2
  export { TalkingHead } from './TalkingHead';
3
3
  export * from './appearance';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,cAAc,GACf,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,cAAc,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,cAAc,cAAc,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-head-studio",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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.js",
6
6
  "types": "dist/index.d.ts",