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/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/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
|
-
|
|
44
|
-
const INITIAL_MOOD = ${JSON.stringify(
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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;
|
|
247
|
+
return;
|
|
241
248
|
}
|
|
242
249
|
|
|
243
250
|
if (avatarLoaded) {
|
|
244
|
-
jawMorphCache = null;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
let
|
|
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 = {};
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
461
|
-
|
|
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
|
|
591
|
+
log('[ACC] Aborting: ' + accData.id + ' changed while loading.');
|
|
478
592
|
return;
|
|
479
593
|
}
|
|
480
|
-
|
|
481
594
|
targetBone.add(model);
|
|
482
|
-
log('[ACC]
|
|
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
|
|
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]
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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