talking-head-studio 0.4.11 → 0.4.12
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/README.md +279 -193
- package/dist/TalkingHead.d.ts +28 -3
- package/dist/TalkingHead.js +21 -2
- package/dist/TalkingHead.web.d.ts +31 -4
- package/dist/TalkingHead.web.js +11 -1
- package/dist/TalkingHeadVisualization.d.ts +22 -0
- package/dist/TalkingHeadVisualization.js +30 -10
- package/dist/api/studioApi.d.ts +12 -1
- package/dist/api/studioApi.js +16 -2
- package/dist/contract.d.ts +14 -0
- package/dist/contract.js +30 -0
- package/dist/core/avatar/avatarCapabilities.d.ts +60 -0
- package/dist/core/avatar/avatarCapabilities.js +100 -0
- package/dist/core/avatar/backends/gaussian.js +6 -4
- package/dist/core/avatar/motion.d.ts +1713 -0
- package/dist/core/avatar/motion.js +550 -0
- package/dist/core/avatar/motionRuntime.d.ts +46 -0
- package/dist/core/avatar/motionRuntime.js +84 -0
- package/dist/core/avatar/schema.d.ts +33 -5
- package/dist/core/avatar/visemes.d.ts +16 -1
- package/dist/core/avatar/visemes.js +48 -1
- package/dist/editor/AvatarCanvas.js +92 -1
- package/dist/editor/AvatarEditor.native.js +1 -0
- package/dist/editor/AvatarModel.js +1 -0
- package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.js +176 -112
- package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.web.js +30 -28
- package/dist/editor/RigidAccessory.js +17 -2
- package/dist/editor/SkinnedClothing.js +1 -0
- package/dist/editor/boneLockedDrag.d.ts +11 -0
- package/dist/editor/boneLockedDrag.js +68 -0
- package/dist/editor/boneSnap.web.d.ts +27 -0
- package/dist/editor/boneSnap.web.js +99 -0
- package/dist/editor/index.web.d.ts +10 -0
- package/dist/editor/index.web.js +26 -0
- package/dist/editor/sounds/haha.wav +0 -0
- package/dist/editor/sounds/owie.wav +0 -0
- package/dist/editor/sounds/stop.wav +0 -0
- package/dist/editor/studioTheme.d.ts +14 -14
- package/dist/editor/studioTheme.js +17 -14
- package/dist/editor/types.d.ts +1 -0
- package/dist/html/accessories.d.ts +7 -0
- package/dist/html/accessories.js +149 -0
- package/dist/html/motion.d.ts +1 -0
- package/dist/html/motion.js +189 -0
- package/dist/html/visemes.d.ts +7 -0
- package/dist/html/visemes.js +348 -0
- package/dist/html.d.ts +1 -1
- package/dist/html.js +55 -732
- package/dist/index.d.ts +7 -3
- package/dist/index.js +17 -1
- package/dist/index.web.d.ts +18 -1
- package/dist/index.web.js +36 -3
- package/dist/sketchfab/api.js +1 -0
- package/dist/sketchfab/glbInspect.d.ts +22 -0
- package/dist/sketchfab/glbInspect.js +58 -0
- package/dist/sketchfab/index.d.ts +3 -0
- package/dist/sketchfab/index.js +8 -1
- package/dist/sketchfab/inspectRemote.d.ts +13 -0
- package/dist/sketchfab/inspectRemote.js +77 -0
- package/dist/sketchfab/types.d.ts +10 -0
- package/dist/studio/AccessoryBrowserScreen.d.ts +6 -0
- package/dist/studio/AccessoryBrowserScreen.js +626 -0
- package/dist/studio/AccessoryPanel.d.ts +10 -0
- package/dist/studio/AccessoryPanel.js +396 -0
- package/dist/studio/AppearancePanel.d.ts +9 -0
- package/dist/studio/AppearancePanel.js +77 -0
- package/dist/studio/AvatarCreatorScreen.d.ts +5 -0
- package/dist/studio/AvatarCreatorScreen.js +806 -0
- package/dist/studio/AvatarEditorScreen.d.ts +14 -0
- package/dist/studio/AvatarEditorScreen.js +510 -0
- package/dist/studio/AvatarGrid.d.ts +23 -0
- package/dist/studio/AvatarGrid.js +257 -0
- package/dist/studio/ColorSwatch.d.ts +8 -0
- package/dist/studio/ColorSwatch.js +100 -0
- package/dist/studio/CreateVoiceProfileSheet.d.ts +8 -0
- package/dist/studio/CreateVoiceProfileSheet.js +242 -0
- package/dist/studio/DetailsPanel.d.ts +15 -0
- package/dist/studio/DetailsPanel.js +239 -0
- package/dist/studio/FilamentEditor.d.ts +2 -0
- package/dist/studio/FilamentEditor.js +6 -0
- package/dist/studio/PrecisionPanel.d.ts +2 -0
- package/dist/studio/PrecisionPanel.js +7 -0
- package/dist/studio/PublicGalleryScreen.d.ts +5 -0
- package/dist/studio/PublicGalleryScreen.js +358 -0
- package/dist/studio/SketchfabModelCard.d.ts +20 -0
- package/dist/studio/SketchfabModelCard.js +104 -0
- package/dist/studio/StudioBrowseHeader.d.ts +9 -0
- package/dist/studio/StudioBrowseHeader.js +28 -0
- package/dist/studio/StudioEmptyState.d.ts +8 -0
- package/dist/studio/StudioEmptyState.js +29 -0
- package/dist/studio/StudioFloatingAction.d.ts +13 -0
- package/dist/studio/StudioFloatingAction.js +42 -0
- package/dist/studio/StudioSectionHeader.d.ts +7 -0
- package/dist/studio/StudioSectionHeader.js +27 -0
- package/dist/studio/StudioSurfaceCard.d.ts +8 -0
- package/dist/studio/StudioSurfaceCard.js +20 -0
- package/dist/studio/VoicePanel.d.ts +15 -0
- package/dist/studio/VoicePanel.js +305 -0
- package/dist/studio/constants.d.ts +3 -0
- package/dist/studio/constants.js +6 -0
- package/dist/studio/index.d.ts +29 -0
- package/dist/studio/index.js +54 -0
- package/dist/studio/useSketchfabCapabilities.d.ts +31 -0
- package/dist/studio/useSketchfabCapabilities.js +82 -0
- package/dist/tts/useDirectVisemeStream.js +15 -10
- package/dist/utils/avatarUtils.js +92 -5
- package/dist/utils/faceLandmarkerToShapeWeights.js +2 -4
- package/dist/voice/useAudioPlayer.js +17 -4
- package/dist/voice/useVoicePreview.js +4 -2
- package/dist/wardrobe/index.d.ts +1 -0
- package/dist/wardrobe/index.js +6 -1
- package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
- package/dist/wardrobe/useAccessoryGestures.js +94 -0
- package/dist/wardrobe/useAvatarWardrobeHydration.js +8 -2
- package/dist/wardrobe/useStudioAvatar.js +11 -2
- package/dist/wardrobe/wardrobeStore.d.ts +2 -0
- package/dist/wardrobe/wardrobeStore.js +12 -2
- package/dist/wgpu/R3FWebGpuCanvas.d.ts +15 -0
- package/dist/wgpu/R3FWebGpuCanvas.js +176 -0
- package/dist/wgpu/WgpuAvatar.d.ts +26 -2
- package/dist/wgpu/WgpuAvatar.js +296 -39
- package/dist/wgpu/accessoryDefaults.d.ts +12 -0
- package/dist/wgpu/accessoryDefaults.js +19 -0
- package/dist/wgpu/blobShim.d.ts +2 -0
- package/dist/wgpu/blobShim.js +191 -0
- package/dist/wgpu/index.d.ts +1 -0
- package/dist/wgpu/index.js +4 -1
- package/dist/wgpu/loadGLTFFromUri.d.ts +2 -0
- package/dist/wgpu/loadGLTFFromUri.js +75 -0
- package/dist/wgpu/morphTables.js +21 -10
- package/dist/wgpu/motionState.d.ts +20 -0
- package/dist/wgpu/motionState.js +31 -0
- package/dist/wgpu/patchThreeForRN.d.ts +28 -0
- package/dist/wgpu/patchThreeForRN.js +292 -0
- package/dist/wgpu/scenePlacement.d.ts +5 -0
- package/dist/wgpu/scenePlacement.js +50 -0
- package/dist/wgpu/useAuthedModelUri.js +4 -2
- package/dist/wgpu/useNativeGLTF.d.ts +7 -0
- package/dist/wgpu/useNativeGLTF.js +36 -0
- package/package.json +97 -31
package/dist/html.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildAvatarHtml = buildAvatarHtml;
|
|
4
|
+
const accessories_1 = require("./html/accessories");
|
|
5
|
+
const motion_1 = require("./html/motion");
|
|
6
|
+
const visemes_1 = require("./html/visemes");
|
|
4
7
|
const UPSTREAM_SAFE_MOOD_MAP = {
|
|
5
8
|
neutral: 'neutral',
|
|
6
9
|
happy: 'happy',
|
|
7
10
|
sad: 'sad',
|
|
8
11
|
angry: 'angry',
|
|
12
|
+
fear: 'fear',
|
|
13
|
+
disgust: 'disgust',
|
|
14
|
+
love: 'love',
|
|
15
|
+
sleep: 'sleep',
|
|
9
16
|
excited: 'happy',
|
|
10
17
|
thinking: 'neutral',
|
|
11
18
|
concerned: 'sad',
|
|
@@ -13,6 +20,9 @@ const UPSTREAM_SAFE_MOOD_MAP = {
|
|
|
13
20
|
};
|
|
14
21
|
function buildAvatarHtml(config) {
|
|
15
22
|
const safeMood = UPSTREAM_SAFE_MOOD_MAP[config.mood] ?? 'neutral';
|
|
23
|
+
const safeCameraDistance = Number.isFinite(config.cameraDistance)
|
|
24
|
+
? config.cameraDistance
|
|
25
|
+
: -0.5;
|
|
16
26
|
const v = config.vendorBaseUrl ? config.vendorBaseUrl.replace(/\/$/, '') : null;
|
|
17
27
|
const threeUrl = v ? `${v}/three.module.js` : 'https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js';
|
|
18
28
|
const threeAddonsUrl = v ? `${v}/three-addons/` : 'https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/';
|
|
@@ -82,7 +92,7 @@ const MOOD_MAP = ${JSON.stringify(UPSTREAM_SAFE_MOOD_MAP)};
|
|
|
82
92
|
let AVATAR_URL = ${JSON.stringify(config.avatarUrl)};
|
|
83
93
|
const INITIAL_MOOD = ${JSON.stringify(safeMood)};
|
|
84
94
|
const CAMERA_VIEW = ${JSON.stringify(config.cameraView)};
|
|
85
|
-
const CAMERA_DISTANCE = ${
|
|
95
|
+
const CAMERA_DISTANCE = ${JSON.stringify(safeCameraDistance)};
|
|
86
96
|
let HAIR_COLOR = ${JSON.stringify(config.initialHairColor ?? null)};
|
|
87
97
|
let SKIN_COLOR = ${JSON.stringify(config.initialSkinColor ?? null)};
|
|
88
98
|
let EYE_COLOR = ${JSON.stringify(config.initialEyeColor ?? null)};
|
|
@@ -384,736 +394,19 @@ function startAudioInterception() {
|
|
|
384
394
|
}).observe(document.body, { childList: true, subtree: true });
|
|
385
395
|
}
|
|
386
396
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const VOICE_ENERGY_NEUTRAL_THRESH = 0.15;
|
|
398
|
-
const VOICE_MOOD_FRAMES_REQUIRED = 12; // ~0.2s at 60fps
|
|
399
|
-
function updateVoiceMood(rawAmplitude) {
|
|
400
|
-
voiceEnergySmoothed = voiceEnergySmoothed * 0.85 + rawAmplitude * 0.15;
|
|
401
|
-
if (voiceEnergySmoothed > VOICE_ENERGY_EXCITED_THRESH) {
|
|
402
|
-
voiceMoodFramesAbove++;
|
|
403
|
-
voiceMoodFramesBelow = 0;
|
|
404
|
-
if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
|
|
405
|
-
voiceMoodCurrent = 'excited';
|
|
406
|
-
rnPost(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
|
|
407
|
-
}
|
|
408
|
-
} else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
|
|
409
|
-
voiceMoodFramesBelow++;
|
|
410
|
-
voiceMoodFramesAbove = 0;
|
|
411
|
-
if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
|
|
412
|
-
voiceMoodCurrent = 'neutral';
|
|
413
|
-
rnPost(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
const visemeState = {};
|
|
418
|
-
|
|
419
|
-
const VISEME_MORPH_ALIASES = {
|
|
420
|
-
sil: ['viseme_sil', 'sil', 'mouthClose', 'mouth_close'],
|
|
421
|
-
PP: ['viseme_PP', 'pp', 'viseme_pp', 'mouthPucker', 'mouth_pucker'],
|
|
422
|
-
FF: ['viseme_FF', 'ff', 'viseme_ff', 'mouthLowerLipIn', 'mouth_lower_lip_in', 'mouthRollLower', 'mouthShrugLower'],
|
|
423
|
-
TH: ['viseme_TH', 'th', 'viseme_th', 'tongueOut', 'tongue_out'],
|
|
424
|
-
DD: ['viseme_DD', 'dd', 'viseme_dd', 'mouthShrugUpper', 'mouth_shrug_upper'],
|
|
425
|
-
kk: ['viseme_kk', 'kk', 'viseme_k', 'mouthStretchLeft', 'mouth_stretch_left'],
|
|
426
|
-
CH: ['viseme_CH', 'ch', 'viseme_ch', 'mouthSmile', 'mouth_smile', 'mouthSmileLeft', 'mouth_smile_left'],
|
|
427
|
-
SS: ['viseme_SS', 'ss', 'viseme_ss', 'mouthStretchRight', 'mouth_stretch_right'],
|
|
428
|
-
nn: ['viseme_nn', 'nn', 'viseme_n', 'mouthDimpleLeft', 'mouth_dimple_left'],
|
|
429
|
-
RR: ['viseme_RR', 'rr', 'viseme_r', 'mouthDimpleRight', 'mouth_dimple_right'],
|
|
430
|
-
aa: ['viseme_aa', 'viseme_AA', 'aa', 'jawOpen', 'jaw_open', 'jawopen', 'mouthOpen', 'mouth_open', 'mouthopen'],
|
|
431
|
-
ee: ['viseme_ee', 'viseme_E', 'ee', 'mouthSmileLeft', 'mouth_smile_left'],
|
|
432
|
-
ih: ['viseme_ih', 'viseme_I', 'ih', 'mouthSmileRight', 'mouth_smile_right'],
|
|
433
|
-
oh: ['viseme_oh', 'viseme_O', 'oh', 'mouthFunnel', 'mouth_funnel'],
|
|
434
|
-
ou: ['viseme_ou', 'viseme_U', 'ou', 'mouthRollLower', 'mouth_roll_lower'],
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
// For ARKit models, each viseme may need multiple blend shapes driven together.
|
|
438
|
-
// Each entry is a list of morph names to combine for that viseme.
|
|
439
|
-
// The first alias list that has ANY match on the model is used.
|
|
440
|
-
const VISEME_COMPOUND_ARKIT = {
|
|
441
|
-
aa: [['jawOpen', 'mouthLowerDownLeft', 'mouthLowerDownRight'], ['jawOpen', 'mouthOpen']],
|
|
442
|
-
oh: [['mouthFunnel', 'jawOpen'], ['mouthFunnel']],
|
|
443
|
-
ou: [['mouthPucker', 'mouthRollLower'], ['mouthPucker']],
|
|
444
|
-
PP: [['mouthPucker', 'mouthClose'], ['mouthPucker']],
|
|
445
|
-
FF: [['mouthRollLower', 'mouthLowerDownLeft', 'mouthLowerDownRight'], ['mouthShrugLower']],
|
|
446
|
-
CH: [['mouthSmileLeft', 'mouthSmileRight', 'mouthStretchLeft', 'mouthStretchRight'], ['mouthSmileLeft', 'mouthSmileRight']],
|
|
447
|
-
ee: [['mouthSmileLeft', 'mouthSmileRight'], ['mouthSmileLeft']],
|
|
448
|
-
ih: [['mouthSmileLeft', 'mouthSmileRight'], ['mouthSmileRight']],
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
function buildVisemeMorphCache() {
|
|
452
|
-
visemeMorphCache = {};
|
|
453
|
-
for (const [visemeKey, aliases] of Object.entries(VISEME_MORPH_ALIASES)) {
|
|
454
|
-
const entries = [];
|
|
455
|
-
// Check if we have a compound ARKit mapping for this viseme
|
|
456
|
-
const compoundOptions = VISEME_COMPOUND_ARKIT[visemeKey];
|
|
457
|
-
if (compoundOptions) {
|
|
458
|
-
// Try each compound option; use the first one where all names exist on at least one mesh
|
|
459
|
-
let usedCompound = false;
|
|
460
|
-
for (const nameList of compoundOptions) {
|
|
461
|
-
// Collect entries for all names across all meshes
|
|
462
|
-
const compoundEntries = [];
|
|
463
|
-
for (const mesh of mouthMeshes) {
|
|
464
|
-
if (!mesh.morphTargetDictionary) continue;
|
|
465
|
-
const dict = mesh.morphTargetDictionary;
|
|
466
|
-
const dictKeysLower = Object.fromEntries(Object.keys(dict).map(k => [k.toLowerCase(), k]));
|
|
467
|
-
for (const name of nameList) {
|
|
468
|
-
const found = dictKeysLower[name.toLowerCase()];
|
|
469
|
-
if (found !== undefined) {
|
|
470
|
-
compoundEntries.push({ influences: mesh.morphTargetInfluences, idx: dict[found], morphName: found });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (compoundEntries.length > 0) {
|
|
475
|
-
entries.push(...compoundEntries);
|
|
476
|
-
usedCompound = true;
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
if (usedCompound) {
|
|
481
|
-
visemeMorphCache[visemeKey] = entries;
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
// Fallback: single-alias lookup
|
|
486
|
-
for (const mesh of mouthMeshes) {
|
|
487
|
-
if (!mesh.morphTargetDictionary) continue;
|
|
488
|
-
const dictKeys = Object.keys(mesh.morphTargetDictionary);
|
|
489
|
-
for (const alias of aliases) {
|
|
490
|
-
const found = dictKeys.find(k => k.toLowerCase() === alias.toLowerCase());
|
|
491
|
-
if (found !== undefined) {
|
|
492
|
-
entries.push({ influences: mesh.morphTargetInfluences, idx: mesh.morphTargetDictionary[found], morphName: found });
|
|
493
|
-
break;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
if (entries.length > 0) visemeMorphCache[visemeKey] = entries;
|
|
498
|
-
}
|
|
499
|
-
const found = Object.keys(visemeMorphCache);
|
|
500
|
-
log('Viseme cache: ' + (found.length > 0 ? found.join(', ') : 'none — check morph target names'));
|
|
501
|
-
|
|
502
|
-
// Always log all available morphs so we can see what the model actually has
|
|
503
|
-
if (mouthMeshes.length > 0) {
|
|
504
|
-
const allMorphs = Object.keys(mouthMeshes[0].morphTargetDictionary || {});
|
|
505
|
-
log('Available morphs: ' + (allMorphs.length > 0 ? allMorphs.join(', ') : 'none'));
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function applyViseme(visemeKey, weight) {
|
|
510
|
-
if (!visemeMorphCache) buildVisemeMorphCache();
|
|
511
|
-
if (visemeKey === 'sil' || weight <= 0) {
|
|
512
|
-
for (const key of Object.keys(visemeState)) visemeState[key] = 0;
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
visemeState[visemeKey] = Math.min(1, weight);
|
|
516
|
-
visemeStateLastSet.set(visemeKey, Date.now());
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const RHUBARB_DEFAULT_VISEME_WEIGHT = 0.72;
|
|
520
|
-
const RHUBARB_LABIAL_VISEME_WEIGHT = 0.85;
|
|
521
|
-
const RHUBARB_AA_VISEME_WEIGHT = 0.72;
|
|
522
|
-
const RHUBARB_ROUNDED_VISEME_WEIGHT = 0.62;
|
|
523
|
-
const RHUBARB_FALLBACK_AMPLITUDE_CAP = 0.72;
|
|
524
|
-
const RHUBARB_FALLBACK_AMPLITUDE_GAIN = 0.75;
|
|
525
|
-
const RHUBARB_VISEME_WEIGHTS = {
|
|
526
|
-
PP: RHUBARB_LABIAL_VISEME_WEIGHT,
|
|
527
|
-
FF: 0.78,
|
|
528
|
-
ee: 0.72,
|
|
529
|
-
ih: 0.68,
|
|
530
|
-
oh: RHUBARB_ROUNDED_VISEME_WEIGHT,
|
|
531
|
-
ou: 0.58,
|
|
532
|
-
aa: RHUBARB_AA_VISEME_WEIGHT,
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
const RHUBARB_TO_VISEME = {
|
|
536
|
-
A: 'aa',
|
|
537
|
-
B: 'PP',
|
|
538
|
-
C: 'ih',
|
|
539
|
-
D: 'FF',
|
|
540
|
-
E: 'ee',
|
|
541
|
-
F: 'oh',
|
|
542
|
-
G: 'ou',
|
|
543
|
-
H: 'nn',
|
|
544
|
-
X: 'sil',
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
let rhubarbMorphCache = null;
|
|
548
|
-
let visemeTimers = [];
|
|
549
|
-
let activeVisemeScheduleId = 0;
|
|
550
|
-
let visemeModeUntil = 0;
|
|
551
|
-
const visemeStateLastSet = new Map();
|
|
552
|
-
|
|
553
|
-
function buildRhubarbMorphCache() {
|
|
554
|
-
if (!visemeMorphCache) buildVisemeMorphCache();
|
|
555
|
-
rhubarbMorphCache = {};
|
|
556
|
-
for (const [rhubarbShape, visemeKey] of Object.entries(RHUBARB_TO_VISEME)) {
|
|
557
|
-
if (visemeKey === 'sil') { rhubarbMorphCache[rhubarbShape] = null; continue; }
|
|
558
|
-
rhubarbMorphCache[rhubarbShape] = visemeMorphCache[visemeKey] || null;
|
|
559
|
-
}
|
|
560
|
-
log('Rhubarb morph cache built');
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function applyRhubarbCue(shape) {
|
|
564
|
-
if (!rhubarbMorphCache) buildRhubarbMorphCache();
|
|
565
|
-
// Zero all active viseme channels first
|
|
566
|
-
for (const key of Object.keys(visemeState)) visemeState[key] = 0;
|
|
567
|
-
if (shape === 'X' || !rhubarbMorphCache[shape]) return;
|
|
568
|
-
const visemeKey = RHUBARB_TO_VISEME[shape];
|
|
569
|
-
if (visemeKey && visemeKey !== 'sil') {
|
|
570
|
-
visemeState[visemeKey] = RHUBARB_VISEME_WEIGHTS[visemeKey] || RHUBARB_DEFAULT_VISEME_WEIGHT;
|
|
571
|
-
visemeStateLastSet.set(visemeKey, Date.now());
|
|
397
|
+
${(0, visemes_1.buildVisemeScript)()}${(0, accessories_1.buildAccessoriesScript)()}${(0, motion_1.buildMotionScript)()}function dispatchProceduralMotion(name) {
|
|
398
|
+
const motionHandled = typeof window.playMotion === 'function'
|
|
399
|
+
? window.playMotion(name)
|
|
400
|
+
: false;
|
|
401
|
+
if (motionHandled) {
|
|
402
|
+
rnPost(JSON.stringify({ type: 'avatarState', state: 'motion:' + name }));
|
|
403
|
+
log('motion dispatched: ' + name);
|
|
404
|
+
} else {
|
|
405
|
+
rnPost(JSON.stringify({ type: 'avatarState', state: 'motion_error:unknown:' + name }));
|
|
406
|
+
log('motion rejected: unknown key ' + name);
|
|
572
407
|
}
|
|
573
408
|
}
|
|
574
409
|
|
|
575
|
-
function clearScheduledVisemes() {
|
|
576
|
-
activeVisemeScheduleId++;
|
|
577
|
-
for (const id of visemeTimers) clearTimeout(id);
|
|
578
|
-
visemeTimers = [];
|
|
579
|
-
visemeModeUntil = 0;
|
|
580
|
-
// Zero all mouth morphs
|
|
581
|
-
for (const key of Object.keys(visemeState)) visemeState[key] = 0;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function tickVisemeDecay(deltaSeconds) {
|
|
585
|
-
if (!visemeMorphCache) return;
|
|
586
|
-
|
|
587
|
-
const isScheduled = Date.now() < visemeModeUntil;
|
|
588
|
-
const hasSpecificLipShape =
|
|
589
|
-
visemeState.PP > 0.05 ||
|
|
590
|
-
visemeState.FF > 0.05 ||
|
|
591
|
-
visemeState.kk > 0.05 ||
|
|
592
|
-
visemeState.ee > 0.05 ||
|
|
593
|
-
visemeState.ih > 0.05;
|
|
594
|
-
|
|
595
|
-
for (const [key, weight] of Object.entries(visemeState)) {
|
|
596
|
-
// Only decay if we aren't in the middle of a viseme schedule.
|
|
597
|
-
// Scheduled visemes are cleared manually by timeouts.
|
|
598
|
-
if (!isScheduled) {
|
|
599
|
-
// Time-delta-aware decay: maintain consistent feel regardless of frame rate.
|
|
600
|
-
// Base rate is calibrated for 60 fps (0.82 per frame = ~12 frames to 10%).
|
|
601
|
-
// pow(0.82, delta*60) is frame-rate independent.
|
|
602
|
-
const dt = deltaSeconds ?? (1 / 60);
|
|
603
|
-
const decayFactor = Math.pow(0.82, dt * 60);
|
|
604
|
-
const decayed = weight * decayFactor;
|
|
605
|
-
visemeState[key] = decayed < 0.01 ? 0 : decayed;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const entries = visemeMorphCache[key];
|
|
609
|
-
if (!entries) continue;
|
|
610
|
-
|
|
611
|
-
let targetWeight = visemeState[key];
|
|
612
|
-
if (key === 'aa' && hasSpecificLipShape) targetWeight = Math.min(targetWeight, 0.45);
|
|
613
|
-
|
|
614
|
-
for (const e of entries) {
|
|
615
|
-
// When TalkingHead is active, write through its morph API so the internal
|
|
616
|
-
// render loop doesn't overwrite our values every frame.
|
|
617
|
-
// Use realtime (not newvalue) — newvalue is consumed and cleared after
|
|
618
|
-
// a single frame, so scheduled visemes would vanish immediately.
|
|
619
|
-
// realtime persists until explicitly set to null.
|
|
620
|
-
if (head && head.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
|
|
621
|
-
const mt = head.mtAvatar[e.morphName];
|
|
622
|
-
mt.realtime = targetWeight > 0 ? targetWeight : null;
|
|
623
|
-
mt.needsUpdate = true;
|
|
624
|
-
} else {
|
|
625
|
-
e.influences[e.idx] = targetWeight;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function scheduleVisemes(schedule) {
|
|
632
|
-
clearScheduledVisemes();
|
|
633
|
-
|
|
634
|
-
// Prune visemeState keys that haven't been written in the last 2 seconds to
|
|
635
|
-
// prevent unbounded accumulation across many utterances.
|
|
636
|
-
const staleThreshold = Date.now() - 2000;
|
|
637
|
-
for (const key of Object.keys(visemeState)) {
|
|
638
|
-
if ((visemeStateLastSet.get(key) ?? 0) < staleThreshold) {
|
|
639
|
-
delete visemeState[key];
|
|
640
|
-
visemeStateLastSet.delete(key);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (!schedule || !Array.isArray(schedule.cues) || schedule.cues.length === 0) return;
|
|
645
|
-
|
|
646
|
-
const myScheduleId = activeVisemeScheduleId;
|
|
647
|
-
// Anchor selection priority:
|
|
648
|
-
// 1. audioStartedAtMs — stamped when audio actually begins playing (most accurate)
|
|
649
|
-
// 2. startedAtMs + pipeline delay — stamped at TTS request fire time
|
|
650
|
-
//
|
|
651
|
-
// AUDIO_PIPELINE_DELAY_MS compensates for the gap between "TTS request fired"
|
|
652
|
-
// and "audio audible from speaker". Qwen3-TTS on local/tailnet is ~80–150 ms;
|
|
653
|
-
// LiveKit adds ~50–80 ms of jitter buffer on top. 150 ms is conservative but
|
|
654
|
-
// avoids the mouth running ahead of audio on fast connections.
|
|
655
|
-
const AUDIO_PIPELINE_DELAY_MS = 50;
|
|
656
|
-
let startedAt = schedule.audioStartedAtMs
|
|
657
|
-
?? ((schedule.startedAtMs || Date.now()) + AUDIO_PIPELINE_DELAY_MS);
|
|
658
|
-
const durationMs = schedule.durationMs || 0;
|
|
659
|
-
const now = Date.now();
|
|
660
|
-
let elapsedMs = Math.max(0, now - startedAt);
|
|
661
|
-
|
|
662
|
-
// If the schedule still arrives late after the pipeline offset, shift further
|
|
663
|
-
if (elapsedMs > 300 && schedule.cues.length > 3) {
|
|
664
|
-
const shift = Math.min(elapsedMs - 50, 500);
|
|
665
|
-
startedAt += shift;
|
|
666
|
-
elapsedMs -= shift;
|
|
667
|
-
log('Viseme schedule arrived late, shifting anchor forward by ' + shift + 'ms');
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const remainingMs = Math.max(0, durationMs - elapsedMs);
|
|
671
|
-
let scheduledCueCount = 0;
|
|
672
|
-
let skippedCueCount = 0;
|
|
673
|
-
|
|
674
|
-
// Gate amplitude fallback for the locally remaining duration plus a small buffer.
|
|
675
|
-
// If the schedule arrives a bit late, keep amplitude out of the way for the rest
|
|
676
|
-
// of the utterance instead of expiring immediately from the original timestamp.
|
|
677
|
-
visemeModeUntil = now + remainingMs + 200;
|
|
678
|
-
|
|
679
|
-
for (const cue of schedule.cues) {
|
|
680
|
-
const delay = cue.startMs - (Date.now() - startedAt);
|
|
681
|
-
if (delay < -50) {
|
|
682
|
-
skippedCueCount++;
|
|
683
|
-
continue; // already in the past, skip
|
|
684
|
-
}
|
|
685
|
-
scheduledCueCount++;
|
|
686
|
-
|
|
687
|
-
const applyId = setTimeout(() => {
|
|
688
|
-
if (activeVisemeScheduleId !== myScheduleId) return;
|
|
689
|
-
applyRhubarbCue(cue.viseme);
|
|
690
|
-
}, Math.max(0, delay));
|
|
691
|
-
|
|
692
|
-
const clearId = setTimeout(() => {
|
|
693
|
-
if (activeVisemeScheduleId !== myScheduleId) return;
|
|
694
|
-
// Only clear if the next cue hasn't already overwritten state
|
|
695
|
-
visemeState[RHUBARB_TO_VISEME[cue.viseme]] = 0;
|
|
696
|
-
}, Math.max(0, delay + (cue.endMs - cue.startMs)));
|
|
697
|
-
|
|
698
|
-
visemeTimers.push(applyId, clearId);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
log(
|
|
702
|
-
'Viseme schedule received: requestId=' +
|
|
703
|
-
(schedule.requestId || 'unknown') +
|
|
704
|
-
' cues=' + schedule.cues.length +
|
|
705
|
-
' scheduled=' + scheduledCueCount +
|
|
706
|
-
' skipped=' + skippedCueCount +
|
|
707
|
-
' elapsedMs=' + elapsedMs +
|
|
708
|
-
' remainingMs=' + remainingMs,
|
|
709
|
-
);
|
|
710
|
-
|
|
711
|
-
// Ensure silence at schedule end
|
|
712
|
-
const endDelay = durationMs - (Date.now() - startedAt);
|
|
713
|
-
if (endDelay > 0) {
|
|
714
|
-
visemeTimers.push(setTimeout(() => {
|
|
715
|
-
if (activeVisemeScheduleId !== myScheduleId) return;
|
|
716
|
-
for (const key of Object.keys(visemeState)) visemeState[key] = 0;
|
|
717
|
-
visemeModeUntil = 0;
|
|
718
|
-
}, endDelay + 100));
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
// ============ END RHUBARB VISEME SCHEDULER ============
|
|
722
|
-
|
|
723
|
-
let THREE_REF = null;
|
|
724
|
-
let gltfLoaderInstance = null;
|
|
725
|
-
const currentAccessories = {};
|
|
726
|
-
let pendingAccessoriesList = [];
|
|
727
|
-
|
|
728
|
-
function disposeHierarchy(node) {
|
|
729
|
-
if (!node) return;
|
|
730
|
-
node.traverse((child) => {
|
|
731
|
-
if (child.isMesh) {
|
|
732
|
-
if (child.geometry) child.geometry.dispose();
|
|
733
|
-
if (child.material) {
|
|
734
|
-
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
735
|
-
materials.forEach(mat => {
|
|
736
|
-
if (mat.map) mat.map.dispose();
|
|
737
|
-
if (mat.lightMap) mat.lightMap.dispose();
|
|
738
|
-
if (mat.bumpMap) mat.bumpMap.dispose();
|
|
739
|
-
if (mat.normalMap) mat.normalMap.dispose();
|
|
740
|
-
if (mat.specularMap) mat.specularMap.dispose();
|
|
741
|
-
if (mat.envMap) mat.envMap.dispose();
|
|
742
|
-
mat.dispose();
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
async function ensureThree() {
|
|
750
|
-
if (!THREE_REF) {
|
|
751
|
-
THREE_REF = await import('three');
|
|
752
|
-
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
|
|
753
|
-
const { DRACOLoader } = await import('three/addons/loaders/DRACOLoader.js');
|
|
754
|
-
gltfLoaderInstance = new GLTFLoader();
|
|
755
|
-
const dracoLoader = new DRACOLoader();
|
|
756
|
-
dracoLoader.setDecoderPath(DRACO_URL);
|
|
757
|
-
gltfLoaderInstance.setDRACOLoader(dracoLoader);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
async function applyAccessories(accessoriesList) {
|
|
762
|
-
log('[ACC] applyAccessories called with ' + accessoriesList.length + ' items');
|
|
763
|
-
pendingAccessoriesList = accessoriesList;
|
|
764
|
-
await ensureThree();
|
|
765
|
-
const root = (head && head.armature) ? head.armature : staticModel;
|
|
766
|
-
log('[ACC] root=' + (root ? root.constructor.name + '/' + root.name : 'NULL') + ' head=' + !!head + ' head.armature=' + !!(head && head.armature) + ' staticModel=' + !!staticModel);
|
|
767
|
-
if (!root) { log('[ACC] ABORT: no root'); return; }
|
|
768
|
-
|
|
769
|
-
const boneNames = [];
|
|
770
|
-
root.traverse((child) => { if (child.isBone) boneNames.push(child.name); });
|
|
771
|
-
log('[ACC] Bones found: ' + boneNames.join(', '));
|
|
772
|
-
|
|
773
|
-
const newAccessoryIds = new Set(accessoriesList.map(a => a.id));
|
|
774
|
-
for (const id in currentAccessories) {
|
|
775
|
-
if (!newAccessoryIds.has(id)) {
|
|
776
|
-
const acc = currentAccessories[id];
|
|
777
|
-
if (acc.model) {
|
|
778
|
-
if (acc.model.parent) acc.model.parent.remove(acc.model);
|
|
779
|
-
disposeHierarchy(acc.model);
|
|
780
|
-
}
|
|
781
|
-
delete currentAccessories[id];
|
|
782
|
-
log('[ACC] Removed old accessory: ' + id);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
for (const accData of accessoriesList) {
|
|
787
|
-
log('[ACC] Processing: id=' + accData.id + ' url=' + accData.url + ' bone=' + accData.bone);
|
|
788
|
-
const existing = currentAccessories[accData.id];
|
|
789
|
-
|
|
790
|
-
if (!existing || existing.url !== accData.url) {
|
|
791
|
-
if (existing && existing.model) {
|
|
792
|
-
if (existing.model.parent) existing.model.parent.remove(existing.model);
|
|
793
|
-
disposeHierarchy(existing.model);
|
|
794
|
-
}
|
|
795
|
-
currentAccessories[accData.id] = { ...accData, model: null, isLoading: true, latestData: accData };
|
|
796
|
-
log('[ACC] Starting GLB load: ' + accData.url);
|
|
797
|
-
(async () => {
|
|
798
|
-
try {
|
|
799
|
-
const loadedUrl = await loadWithAuth(accData.url);
|
|
800
|
-
gltfLoaderInstance.load(loadedUrl, (gltf) => {
|
|
801
|
-
if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
|
|
802
|
-
log('[ACC] GLB loaded OK for ' + accData.id);
|
|
803
|
-
const model = gltf.scene;
|
|
804
|
-
const latestData = (currentAccessories[accData.id] && currentAccessories[accData.id].latestData) || accData;
|
|
805
|
-
let targetBone = null;
|
|
806
|
-
let prefixCandidate = null;
|
|
807
|
-
root.traverse((child) => {
|
|
808
|
-
if (child.isBone && child.name === latestData.bone) targetBone = child;
|
|
809
|
-
else if (!prefixCandidate && child.name.replace(/_\\d+$/, '') === latestData.bone) prefixCandidate = child;
|
|
810
|
-
});
|
|
811
|
-
if (!targetBone && prefixCandidate) {
|
|
812
|
-
targetBone = prefixCandidate;
|
|
813
|
-
log('[ACC] Prefix match bone: ' + targetBone.name);
|
|
814
|
-
}
|
|
815
|
-
model.traverse((child) => { if (child.isMesh) child.frustumCulled = false; });
|
|
816
|
-
if (!targetBone) { log('[ACC] Bone not found: ' + latestData.bone + '. Using root.'); targetBone = root; }
|
|
817
|
-
else log('[ACC] Found bone: ' + targetBone.name);
|
|
818
|
-
const modelScale = latestData.scale !== undefined ? latestData.scale : 1.0;
|
|
819
|
-
model.scale.set(modelScale, modelScale, modelScale);
|
|
820
|
-
model.position.set(
|
|
821
|
-
latestData.position ? latestData.position[0] : 0,
|
|
822
|
-
latestData.position ? latestData.position[1] : 0,
|
|
823
|
-
latestData.position ? latestData.position[2] : 0
|
|
824
|
-
);
|
|
825
|
-
if (latestData.rotation) model.rotation.set(...latestData.rotation);
|
|
826
|
-
if (!currentAccessories[accData.id] || currentAccessories[accData.id].url !== accData.url) {
|
|
827
|
-
log('[ACC] Aborting: ' + accData.id + ' changed while loading.');
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
targetBone.add(model);
|
|
831
|
-
log('[ACC] Attached to ' + targetBone.name);
|
|
832
|
-
currentAccessories[accData.id].model = model;
|
|
833
|
-
currentAccessories[accData.id].isLoading = false;
|
|
834
|
-
}, () => {}, (err) => {
|
|
835
|
-
if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
|
|
836
|
-
log('[ACC] FAILED: ' + accData.id + ': ' + err.message);
|
|
837
|
-
if (currentAccessories[accData.id]) currentAccessories[accData.id].isLoading = false;
|
|
838
|
-
});
|
|
839
|
-
} catch (authErr) {
|
|
840
|
-
log('[ACC] Auth fetch failed ' + accData.id + ': ' + authErr.message);
|
|
841
|
-
if (currentAccessories[accData.id]) currentAccessories[accData.id].isLoading = false;
|
|
842
|
-
}
|
|
843
|
-
})();
|
|
844
|
-
} else if (existing && existing.isLoading) {
|
|
845
|
-
existing.latestData = accData;
|
|
846
|
-
} else if (existing && existing.model) {
|
|
847
|
-
const model = existing.model;
|
|
848
|
-
const accScale = accData.scale !== undefined ? accData.scale : 1.0;
|
|
849
|
-
model.scale.set(accScale, accScale, accScale);
|
|
850
|
-
model.position.set(
|
|
851
|
-
accData.position ? accData.position[0] : 0,
|
|
852
|
-
accData.position ? accData.position[1] : 0,
|
|
853
|
-
accData.position ? accData.position[2] : 0
|
|
854
|
-
);
|
|
855
|
-
if (accData.rotation) model.rotation.set(...accData.rotation);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/* ── MotionEngine ────────────────────────────────────────────────────────── */
|
|
861
|
-
/* Quaternion math helpers (no Three.js dep required) */
|
|
862
|
-
const QM = {
|
|
863
|
-
fromAxisAngle(ax, ay, az, angle) {
|
|
864
|
-
const s = Math.sin(angle / 2);
|
|
865
|
-
return { x: ax*s, y: ay*s, z: az*s, w: Math.cos(angle/2) };
|
|
866
|
-
},
|
|
867
|
-
multiply(a, b) {
|
|
868
|
-
return {
|
|
869
|
-
x: a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y,
|
|
870
|
-
y: a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x,
|
|
871
|
-
z: a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w,
|
|
872
|
-
w: a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z,
|
|
873
|
-
};
|
|
874
|
-
},
|
|
875
|
-
applyTo(bone, q) {
|
|
876
|
-
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
877
|
-
},
|
|
878
|
-
copyFrom(bone) {
|
|
879
|
-
const q = bone.quaternion;
|
|
880
|
-
return { x: q.x, y: q.y, z: q.z, w: q.w };
|
|
881
|
-
},
|
|
882
|
-
};
|
|
883
|
-
|
|
884
|
-
const MOTION_DEFS = {
|
|
885
|
-
groove: {
|
|
886
|
-
bpm: 120,
|
|
887
|
-
smile: 0.55,
|
|
888
|
-
label: 'Groove',
|
|
889
|
-
bones: {
|
|
890
|
-
hips: [{ ax:0, ay:0, az:1, amp:0.10, freq:1.0, phase:0 },
|
|
891
|
-
{ ax:0, ay:1, az:0, amp:0.05, freq:0.5, phase:0 }],
|
|
892
|
-
spine: [{ ax:0, ay:0, az:1, amp:0.08, freq:1.0, phase:0.3 },
|
|
893
|
-
{ ax:1, ay:0, az:0, amp:0.03, freq:2.0, phase:0 }],
|
|
894
|
-
spine2: [{ ax:0, ay:0, az:1, amp:0.06, freq:1.0, phase:0.6 }],
|
|
895
|
-
neck: [{ ax:0, ay:0, az:1, amp:0.05, freq:1.0, phase:Math.PI }],
|
|
896
|
-
head: [{ ax:1, ay:0, az:0, amp:0.06, freq:2.0, phase:0 },
|
|
897
|
-
{ ax:0, ay:0, az:1, amp:0.04, freq:1.0, phase:Math.PI }],
|
|
898
|
-
leftArm: [{ ax:0, ay:0, az:1, amp:0.22, freq:1.0, phase:Math.PI/2 }],
|
|
899
|
-
rightArm: [{ ax:0, ay:0, az:1, amp:0.22, freq:1.0, phase:-Math.PI/2 }],
|
|
900
|
-
},
|
|
901
|
-
},
|
|
902
|
-
wave: {
|
|
903
|
-
bpm: 90,
|
|
904
|
-
smile: 0.75,
|
|
905
|
-
label: 'Wave',
|
|
906
|
-
bones: {
|
|
907
|
-
hips: [{ ax:0, ay:0, az:1, amp:0.04, freq:0.5, phase:0 }],
|
|
908
|
-
spine: [{ ax:0, ay:0, az:1, amp:0.04, freq:0.5, phase:0.2 }],
|
|
909
|
-
head: [{ ax:0, ay:1, az:0, amp:0.12, freq:0.5, phase:0 },
|
|
910
|
-
{ ax:0, ay:0, az:1, amp:0.04, freq:0.5, phase:0.5 }],
|
|
911
|
-
rightShoulder:[{ ax:0, ay:0, az:1, amp:-0.6, freq:0, phase:0 }],
|
|
912
|
-
rightArm: [{ ax:0, ay:0, az:1, amp:-0.55, freq:0, phase:0 },
|
|
913
|
-
{ ax:1, ay:0, az:0, amp:0.35, freq:1.5, phase:0 }],
|
|
914
|
-
rightForeArm:[{ ax:1, ay:0, az:0, amp:0.4, freq:1.5, phase:Math.PI/3 }],
|
|
915
|
-
},
|
|
916
|
-
},
|
|
917
|
-
nod: {
|
|
918
|
-
bpm: 100,
|
|
919
|
-
smile: 0.4,
|
|
920
|
-
label: 'Head Bop',
|
|
921
|
-
bones: {
|
|
922
|
-
hips: [{ ax:0, ay:0, az:1, amp:0.04, freq:0.83, phase:0 }],
|
|
923
|
-
spine: [{ ax:1, ay:0, az:0, amp:0.04, freq:0.83, phase:0.2 },
|
|
924
|
-
{ ax:0, ay:0, az:1, amp:0.03, freq:0.83, phase:0.4 }],
|
|
925
|
-
neck: [{ ax:1, ay:0, az:0, amp:0.12, freq:1.67, phase:0 }],
|
|
926
|
-
head: [{ ax:1, ay:0, az:0, amp:0.16, freq:1.67, phase:Math.PI/6 },
|
|
927
|
-
{ ax:0, ay:1, az:0, amp:0.05, freq:0.83, phase:0 }],
|
|
928
|
-
leftArm:[{ ax:0, ay:0, az:1, amp:0.12, freq:0.83, phase:0 }],
|
|
929
|
-
rightArm:[{ ax:0, ay:0, az:1, amp:0.12, freq:0.83, phase:Math.PI }],
|
|
930
|
-
},
|
|
931
|
-
},
|
|
932
|
-
idle: {
|
|
933
|
-
bpm: 60,
|
|
934
|
-
smile: 0.15,
|
|
935
|
-
label: 'Idle Sway',
|
|
936
|
-
bones: {
|
|
937
|
-
hips: [{ ax:0, ay:0, az:1, amp:0.035, freq:0.5, phase:0 }],
|
|
938
|
-
spine: [{ ax:0, ay:0, az:1, amp:0.028, freq:0.5, phase:0.3 },
|
|
939
|
-
{ ax:1, ay:0, az:0, amp:0.012, freq:0.25, phase:0 }],
|
|
940
|
-
spine2: [{ ax:0, ay:0, az:1, amp:0.018, freq:0.5, phase:0.5 }],
|
|
941
|
-
head: [{ ax:1, ay:0, az:0, amp:0.025, freq:0.25, phase:0 },
|
|
942
|
-
{ ax:0, ay:1, az:0, amp:0.015, freq:0.5, phase:0.8}],
|
|
943
|
-
},
|
|
944
|
-
},
|
|
945
|
-
celebrate: {
|
|
946
|
-
bpm: 140,
|
|
947
|
-
smile: 0.95,
|
|
948
|
-
label: 'Celebrate',
|
|
949
|
-
bones: {
|
|
950
|
-
hips: [{ ax:0, ay:0, az:1, amp:0.14, freq:1.17, phase:0 },
|
|
951
|
-
{ ax:1, ay:0, az:0, amp:0.06, freq:2.33, phase:0 }],
|
|
952
|
-
spine: [{ ax:0, ay:0, az:1, amp:0.10, freq:1.17, phase:0.4 },
|
|
953
|
-
{ ax:1, ay:0, az:0, amp:0.05, freq:2.33, phase:0.2 }],
|
|
954
|
-
spine2: [{ ax:0, ay:0, az:1, amp:0.07, freq:1.17, phase:0.7 }],
|
|
955
|
-
neck: [{ ax:1, ay:0, az:0, amp:0.06, freq:2.33, phase:0 }],
|
|
956
|
-
head: [{ ax:1, ay:0, az:0, amp:0.10, freq:2.33, phase:0.15 },
|
|
957
|
-
{ ax:0, ay:0, az:1, amp:0.06, freq:1.17, phase:Math.PI }],
|
|
958
|
-
leftShoulder: [{ ax:0, ay:0, az:1, amp: 0.5, freq:0, phase:0 }],
|
|
959
|
-
rightShoulder:[{ ax:0, ay:0, az:1, amp:-0.5, freq:0, phase:0 }],
|
|
960
|
-
leftArm: [{ ax:0, ay:0, az:1, amp: 0.55, freq:0, phase:0 },
|
|
961
|
-
{ ax:1, ay:0, az:0, amp:0.30, freq:2.33, phase:0 }],
|
|
962
|
-
rightArm:[{ ax:0, ay:0, az:1, amp:-0.55, freq:0, phase:0 },
|
|
963
|
-
{ ax:1, ay:0, az:0, amp:0.30, freq:2.33, phase:Math.PI }],
|
|
964
|
-
leftForeArm: [{ ax:1, ay:0, az:0, amp:0.25, freq:2.33, phase:0.4 }],
|
|
965
|
-
rightForeArm:[{ ax:1, ay:0, az:0, amp:0.25, freq:2.33, phase:0.4 }],
|
|
966
|
-
},
|
|
967
|
-
},
|
|
968
|
-
};
|
|
969
|
-
|
|
970
|
-
/* Runtime state */
|
|
971
|
-
let motionActive = false;
|
|
972
|
-
let motionKey = null;
|
|
973
|
-
let motionStartTime = null;
|
|
974
|
-
let motionBones = {};
|
|
975
|
-
let motionRestQuats = {};
|
|
976
|
-
let motionSmileTargets = [];
|
|
977
|
-
let motionBeatTimer = null;
|
|
978
|
-
let motionBeatIndex = 0;
|
|
979
|
-
|
|
980
|
-
/* Bone name map: logical key -> keywords to match in armature.
|
|
981
|
-
norm() strips punctuation and lowercases, so RPM names like
|
|
982
|
-
LeftShoulder, RightArm, LeftForeArm etc. all match correctly. */
|
|
983
|
-
const BONE_SEARCH = {
|
|
984
|
-
hips: ['hips','pelvis','hip','root'],
|
|
985
|
-
spine: ['spine','spine0','spine_0','spine_01','spine1_'],
|
|
986
|
-
spine2: ['spine2','spine_02','upperchest','chest','spine1'],
|
|
987
|
-
neck: ['neck'],
|
|
988
|
-
head: ['head'],
|
|
989
|
-
leftShoulder: ['leftshoulder','l_shoulder','shoulderleft','leftclavicle','l_clavicle'],
|
|
990
|
-
rightShoulder: ['rightshoulder','r_shoulder','shoulderright','rightclavicle','r_clavicle'],
|
|
991
|
-
leftArm: ['leftarm','l_arm','armleft','leftupperarm','l_upperarm'],
|
|
992
|
-
rightArm: ['rightarm','r_arm','armright','rightupperarm','r_upperarm'],
|
|
993
|
-
leftForeArm: ['leftforearm','l_forearm','forearmleft','leftlowerarm'],
|
|
994
|
-
rightForeArm: ['rightforearm','r_forearm','forearmright','rightlowerarm'],
|
|
995
|
-
};
|
|
996
|
-
|
|
997
|
-
function motionScanBones(root) {
|
|
998
|
-
motionBones = {}; motionRestQuats = {};
|
|
999
|
-
if (!root) return;
|
|
1000
|
-
const norm = s => s.toLowerCase().replace(/[_\\s\\-\\.]/g, '');
|
|
1001
|
-
root.traverse(child => {
|
|
1002
|
-
if (!child.isBone) return;
|
|
1003
|
-
const lk = norm(child.name);
|
|
1004
|
-
for (const [key, kws] of Object.entries(BONE_SEARCH)) {
|
|
1005
|
-
if (motionBones[key]) continue;
|
|
1006
|
-
if (kws.some(kw => lk.includes(norm(kw)))) {
|
|
1007
|
-
motionBones[key] = child;
|
|
1008
|
-
motionRestQuats[key] = QM.copyFrom(child);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
});
|
|
1012
|
-
const found = Object.keys(motionBones);
|
|
1013
|
-
log('MotionEngine: ' + (found.length === 0
|
|
1014
|
-
? 'No rigged bones detected — motion engine needs a humanoid skeleton.'
|
|
1015
|
-
: found.length + ' bones mapped: ' + found.join(', ') + '.'));
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
function motionScanSmile(root) {
|
|
1019
|
-
motionSmileTargets = [];
|
|
1020
|
-
if (!root) return;
|
|
1021
|
-
root.traverse(child => {
|
|
1022
|
-
if (!child.isMesh || !child.morphTargetDictionary) return;
|
|
1023
|
-
Object.keys(child.morphTargetDictionary).forEach(name => {
|
|
1024
|
-
const lk = name.toLowerCase();
|
|
1025
|
-
if (lk.includes('smile') || lk.includes('happy') || lk.includes('joy') || lk.includes('mouthsmile')) {
|
|
1026
|
-
motionSmileTargets.push({ mesh: child, index: child.morphTargetDictionary[name] });
|
|
1027
|
-
}
|
|
1028
|
-
});
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
function motionInitFromRoot(root) {
|
|
1033
|
-
motionScanBones(root);
|
|
1034
|
-
motionScanSmile(root);
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function motionSetSmile(v) {
|
|
1038
|
-
motionSmileTargets.forEach(t => { t.mesh.morphTargetInfluences[t.index] = v; });
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/* BPM beat indicator — no-ops in WebView (no beat UI) */
|
|
1042
|
-
function motionStartBeat(bpm) {
|
|
1043
|
-
clearInterval(motionBeatTimer);
|
|
1044
|
-
motionBeatIndex = 0;
|
|
1045
|
-
/* beat UI elements not present in WebView — intentional no-op */
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
function motionStopBeat() {
|
|
1049
|
-
clearInterval(motionBeatTimer);
|
|
1050
|
-
motionBeatTimer = null;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/* ── applyMotionBones ────────────────────────────────────────
|
|
1054
|
-
Called every frame from inside the render loop
|
|
1055
|
-
(fallback: before renderer.render; TH: inside head.opt.update).
|
|
1056
|
-
Runs AFTER TH has written its own bones so our values are
|
|
1057
|
-
the last thing written before the draw call.
|
|
1058
|
-
─────────────────────────────────────────────────────────── */
|
|
1059
|
-
function applyMotionBones() {
|
|
1060
|
-
if (!motionActive) return;
|
|
1061
|
-
const def = MOTION_DEFS[motionKey];
|
|
1062
|
-
if (!def) return;
|
|
1063
|
-
const tSec = (performance.now() - motionStartTime) / 1000;
|
|
1064
|
-
|
|
1065
|
-
for (const [boneName, oscillators] of Object.entries(def.bones)) {
|
|
1066
|
-
const bone = motionBones[boneName];
|
|
1067
|
-
if (!bone) continue;
|
|
1068
|
-
const rest = motionRestQuats[boneName];
|
|
1069
|
-
let q = { ...rest };
|
|
1070
|
-
|
|
1071
|
-
for (const osc of oscillators) {
|
|
1072
|
-
const angle = osc.freq === 0
|
|
1073
|
-
? osc.amp
|
|
1074
|
-
: osc.amp * Math.sin(2 * Math.PI * osc.freq * tSec + osc.phase);
|
|
1075
|
-
q = QM.multiply(q, QM.fromAxisAngle(osc.ax, osc.ay, osc.az, angle));
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
window.playMotion = function(key) {
|
|
1083
|
-
const def = MOTION_DEFS[key];
|
|
1084
|
-
if (!def) { log('MotionEngine: unknown motion key: ' + key); return; }
|
|
1085
|
-
const root = (head && head.armature) ? head.armature : staticModel;
|
|
1086
|
-
if (!root) { log('MotionEngine: no model loaded'); return; }
|
|
1087
|
-
|
|
1088
|
-
if (Object.keys(motionBones).length === 0) motionInitFromRoot(root);
|
|
1089
|
-
|
|
1090
|
-
window.stopMotion(false);
|
|
1091
|
-
|
|
1092
|
-
motionKey = key;
|
|
1093
|
-
motionActive = true;
|
|
1094
|
-
motionStartTime = performance.now();
|
|
1095
|
-
|
|
1096
|
-
motionStartBeat(def.bpm);
|
|
1097
|
-
motionSetSmile(def.smile);
|
|
1098
|
-
log('MotionEngine: playing ' + def.label + ' @ ' + def.bpm + ' BPM');
|
|
1099
|
-
};
|
|
1100
|
-
|
|
1101
|
-
window.stopMotion = function(restore) {
|
|
1102
|
-
if (restore === undefined) restore = true;
|
|
1103
|
-
motionActive = false;
|
|
1104
|
-
motionStopBeat();
|
|
1105
|
-
|
|
1106
|
-
if (restore) {
|
|
1107
|
-
for (const [key, bone] of Object.entries(motionBones)) {
|
|
1108
|
-
const rest = motionRestQuats[key];
|
|
1109
|
-
if (rest) bone.quaternion.set(rest.x, rest.y, rest.z, rest.w);
|
|
1110
|
-
}
|
|
1111
|
-
motionSetSmile(0);
|
|
1112
|
-
log('MotionEngine: stopped');
|
|
1113
|
-
}
|
|
1114
|
-
};
|
|
1115
|
-
/* ── End MotionEngine ─────────────────────────────────────── */
|
|
1116
|
-
|
|
1117
410
|
function onIncomingMessage(event) {
|
|
1118
411
|
try {
|
|
1119
412
|
const msg = JSON.parse(event.data);
|
|
@@ -1158,11 +451,41 @@ function onIncomingMessage(event) {
|
|
|
1158
451
|
} else if (msg.type === 'set_accessories') {
|
|
1159
452
|
applyAccessories(msg.accessories || []);
|
|
1160
453
|
} else if (msg.type === 'motion' && typeof msg.name === 'string') {
|
|
1161
|
-
|
|
1162
|
-
|
|
454
|
+
dispatchProceduralMotion(msg.name);
|
|
455
|
+
} else if (msg.type === 'stop_motion') {
|
|
456
|
+
if (typeof window.stopMotion === 'function') window.stopMotion();
|
|
457
|
+
} else if (msg.type === 'gesture' && typeof msg.name === 'string') {
|
|
458
|
+
if (head && typeof head.playGesture === 'function') {
|
|
459
|
+
// TalkingHead@1.7 playGesture(name, dur, mirror, ms) positional contract.
|
|
460
|
+
head.playGesture(msg.name, msg.dur ?? 3, msg.mirror ?? false, msg.ms ?? 1000);
|
|
461
|
+
} else {
|
|
462
|
+
dispatchProceduralMotion(msg.name);
|
|
463
|
+
}
|
|
464
|
+
} else if (msg.type === 'stop_gesture') {
|
|
465
|
+
if (head && typeof head.stopGesture === 'function') {
|
|
466
|
+
head.stopGesture(msg.ms ?? 1000);
|
|
467
|
+
} else if (typeof window.stopMotion === 'function') {
|
|
468
|
+
window.stopMotion();
|
|
469
|
+
}
|
|
470
|
+
} else if (msg.type === 'pose' && typeof msg.url === 'string') {
|
|
471
|
+
if (head && typeof head.playPose === 'function') {
|
|
472
|
+
// url may be a built-in template name (e.g. 'oneknee') or a pose file URL.
|
|
473
|
+
head.playPose(msg.url, null, msg.dur ?? 5);
|
|
474
|
+
} else {
|
|
475
|
+
dispatchProceduralMotion(msg.url);
|
|
476
|
+
}
|
|
477
|
+
} else if (msg.type === 'stop_pose') {
|
|
478
|
+
if (head && typeof head.stopPose === 'function') {
|
|
479
|
+
head.stopPose();
|
|
480
|
+
} else if (typeof window.stopMotion === 'function') {
|
|
481
|
+
window.stopMotion();
|
|
1163
482
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
483
|
+
} else if (msg.type === 'animation' && head && typeof head.playAnimation === 'function') {
|
|
484
|
+
head.playAnimation(msg.url, null, msg.dur ?? 10, msg.index ?? 0);
|
|
485
|
+
} else if (msg.type === 'stop_animation' && head && typeof head.stopAnimation === 'function') {
|
|
486
|
+
head.stopAnimation();
|
|
487
|
+
} else if (msg.type === 'look_at' && head && typeof head.lookAt === 'function') {
|
|
488
|
+
head.lookAt(msg.x, msg.y, msg.ms ?? 500);
|
|
1166
489
|
}
|
|
1167
490
|
} catch (err) {
|
|
1168
491
|
log('Message parse error: ' + err);
|