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/TalkingHead.d.ts +54 -0
- package/dist/TalkingHead.d.ts.map +1 -1
- package/dist/TalkingHead.js +36 -29
- package/dist/TalkingHead.web.d.ts +4 -0
- package/dist/TalkingHead.web.d.ts.map +1 -1
- package/dist/TalkingHead.web.js +2 -0
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +232 -145
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/TalkingHead.tsx +100 -31
- package/src/TalkingHead.web.tsx +10 -0
- package/src/html.ts +234 -145
- package/src/index.ts +3 -0
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
|
-
|
|
32
|
-
const INITIAL_MOOD = ${JSON.stringify(
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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;
|
|
233
|
+
return;
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
if (avatarLoaded) {
|
|
232
|
-
jawMorphCache = null;
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
let
|
|
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 = {};
|
|
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
|
-
|
|
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]
|
|
563
|
+
log('[ACC] Prefix match bone: ' + targetBone.name);
|
|
432
564
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
577
|
+
log('[ACC] Aborting: ' + accData.id + ' changed while loading.');
|
|
466
578
|
return;
|
|
467
579
|
}
|
|
468
|
-
|
|
469
580
|
targetBone.add(model);
|
|
470
|
-
log('[ACC]
|
|
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
|
|
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]
|
|
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
|
-
//
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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",
|