talking-head-studio 0.2.3 → 0.2.5

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