talking-head-studio 0.2.0
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/LICENSE +21 -0
- package/README.md +459 -0
- package/dist/TalkingHead.d.ts +35 -0
- package/dist/TalkingHead.d.ts.map +1 -0
- package/dist/TalkingHead.js +107 -0
- package/dist/TalkingHead.web.d.ts +35 -0
- package/dist/TalkingHead.web.d.ts.map +1 -0
- package/dist/TalkingHead.web.js +117 -0
- package/dist/__tests__/TalkingHead.test.d.ts +2 -0
- package/dist/__tests__/TalkingHead.test.d.ts.map +1 -0
- package/dist/__tests__/TalkingHead.test.js +23 -0
- package/dist/__tests__/sketchfab.test.d.ts +2 -0
- package/dist/__tests__/sketchfab.test.d.ts.map +1 -0
- package/dist/__tests__/sketchfab.test.js +21 -0
- package/dist/appearance/apply.d.ts +7 -0
- package/dist/appearance/apply.d.ts.map +1 -0
- package/dist/appearance/apply.js +56 -0
- package/dist/appearance/index.d.ts +5 -0
- package/dist/appearance/index.d.ts.map +1 -0
- package/dist/appearance/index.js +3 -0
- package/dist/appearance/matchers.d.ts +3 -0
- package/dist/appearance/matchers.d.ts.map +1 -0
- package/dist/appearance/matchers.js +32 -0
- package/dist/appearance/schema.d.ts +9 -0
- package/dist/appearance/schema.d.ts.map +1 -0
- package/dist/appearance/schema.js +20 -0
- package/dist/editor/AvatarCanvas.d.ts +16 -0
- package/dist/editor/AvatarCanvas.d.ts.map +1 -0
- package/dist/editor/AvatarCanvas.js +85 -0
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts +17 -0
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +1 -0
- package/dist/editor/AvatarCanvasErrorBoundary.js +41 -0
- package/dist/editor/AvatarModel.d.ts +12 -0
- package/dist/editor/AvatarModel.d.ts.map +1 -0
- package/dist/editor/AvatarModel.js +31 -0
- package/dist/editor/RigidAccessory.d.ts +15 -0
- package/dist/editor/RigidAccessory.d.ts.map +1 -0
- package/dist/editor/RigidAccessory.js +76 -0
- package/dist/editor/SkinnedClothing.d.ts +7 -0
- package/dist/editor/SkinnedClothing.d.ts.map +1 -0
- package/dist/editor/SkinnedClothing.js +88 -0
- package/dist/editor/index.d.ts +6 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/types.d.ts +28 -0
- package/dist/editor/types.d.ts.map +1 -0
- package/dist/editor/types.js +1 -0
- package/dist/html.d.ts +13 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +560 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.web.d.ts +4 -0
- package/dist/index.web.d.ts.map +1 -0
- package/dist/index.web.js +2 -0
- package/dist/sketchfab/api.d.ts +12 -0
- package/dist/sketchfab/api.d.ts.map +1 -0
- package/dist/sketchfab/api.js +52 -0
- package/dist/sketchfab/categories.d.ts +5 -0
- package/dist/sketchfab/categories.d.ts.map +1 -0
- package/dist/sketchfab/categories.js +124 -0
- package/dist/sketchfab/index.d.ts +7 -0
- package/dist/sketchfab/index.d.ts.map +1 -0
- package/dist/sketchfab/index.js +3 -0
- package/dist/sketchfab/types.d.ts +51 -0
- package/dist/sketchfab/types.d.ts.map +1 -0
- package/dist/sketchfab/types.js +1 -0
- package/dist/sketchfab/useSketchfabSearch.d.ts +19 -0
- package/dist/sketchfab/useSketchfabSearch.d.ts.map +1 -0
- package/dist/sketchfab/useSketchfabSearch.js +78 -0
- package/dist/voice/convertToWav.d.ts +6 -0
- package/dist/voice/convertToWav.d.ts.map +1 -0
- package/dist/voice/convertToWav.js +74 -0
- package/dist/voice/index.d.ts +6 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/useAudioPlayer.d.ts +11 -0
- package/dist/voice/useAudioPlayer.d.ts.map +1 -0
- package/dist/voice/useAudioPlayer.js +61 -0
- package/dist/voice/useAudioRecording.d.ts +14 -0
- package/dist/voice/useAudioRecording.d.ts.map +1 -0
- package/dist/voice/useAudioRecording.js +162 -0
- package/package.json +120 -0
- package/src/TalkingHead.tsx +207 -0
- package/src/TalkingHead.web.tsx +210 -0
- package/src/__tests__/TalkingHead.test.tsx +32 -0
- package/src/__tests__/sketchfab.test.ts +24 -0
- package/src/appearance/apply.ts +94 -0
- package/src/appearance/index.ts +4 -0
- package/src/appearance/matchers.ts +43 -0
- package/src/appearance/schema.ts +35 -0
- package/src/editor/AvatarCanvas.tsx +167 -0
- package/src/editor/AvatarCanvasErrorBoundary.tsx +64 -0
- package/src/editor/AvatarModel.tsx +49 -0
- package/src/editor/RigidAccessory.tsx +130 -0
- package/src/editor/SkinnedClothing.tsx +114 -0
- package/src/editor/index.ts +5 -0
- package/src/editor/r3f-shim.d.ts +34 -0
- package/src/editor/types.ts +30 -0
- package/src/html.ts +572 -0
- package/src/index.ts +8 -0
- package/src/index.web.ts +8 -0
- package/src/sketchfab/api.ts +82 -0
- package/src/sketchfab/categories.ts +127 -0
- package/src/sketchfab/index.ts +6 -0
- package/src/sketchfab/types.ts +40 -0
- package/src/sketchfab/useSketchfabSearch.ts +110 -0
- package/src/voice/convertToWav.ts +87 -0
- package/src/voice/index.ts +7 -0
- package/src/voice/useAudioPlayer.ts +78 -0
- package/src/voice/useAudioRecording.ts +207 -0
package/dist/html.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
export function buildAvatarHtml(config) {
|
|
2
|
+
return `
|
|
3
|
+
<!DOCTYPE html>
|
|
4
|
+
<html>
|
|
5
|
+
<head>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body { background: transparent; overflow: hidden; width: 100vw; height: 100vh; }
|
|
10
|
+
#avatar { width: 100%; height: 100%; }
|
|
11
|
+
</style>
|
|
12
|
+
<script type="importmap">
|
|
13
|
+
{
|
|
14
|
+
"imports": {
|
|
15
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
|
|
16
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
|
|
17
|
+
"talkinghead": "https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.7/modules/talkinghead.mjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<div id="avatar"></div>
|
|
24
|
+
<script type="module">
|
|
25
|
+
const AUTH_TOKEN = ${JSON.stringify(config.authToken ?? null)};
|
|
26
|
+
const TALKING_HEAD_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.7/modules/talkinghead.mjs';
|
|
27
|
+
const HEAD_AUDIO_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@main/dist/headaudio.min.mjs';
|
|
28
|
+
const HEAD_AUDIO_WORKLET = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@main/dist/headworklet.min.mjs';
|
|
29
|
+
const HEAD_AUDIO_MODEL = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@main/dist/model-en-mixed.bin';
|
|
30
|
+
|
|
31
|
+
const AVATAR_URL = ${JSON.stringify(config.avatarUrl)};
|
|
32
|
+
const INITIAL_MOOD = ${JSON.stringify(config.mood)};
|
|
33
|
+
const CAMERA_VIEW = ${JSON.stringify(config.cameraView)};
|
|
34
|
+
const CAMERA_DISTANCE = ${config.cameraDistance};
|
|
35
|
+
let HAIR_COLOR = ${JSON.stringify(config.initialHairColor ?? null)};
|
|
36
|
+
let SKIN_COLOR = ${JSON.stringify(config.initialSkinColor ?? null)};
|
|
37
|
+
let EYE_COLOR = ${JSON.stringify(config.initialEyeColor ?? null)};
|
|
38
|
+
|
|
39
|
+
const container = document.getElementById('avatar');
|
|
40
|
+
let head = null;
|
|
41
|
+
let headaudio = null;
|
|
42
|
+
let mouthMeshes = [];
|
|
43
|
+
let staticModel = null;
|
|
44
|
+
|
|
45
|
+
function log(msg) {
|
|
46
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'log', message: msg }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadWithAuth(url) {
|
|
50
|
+
if (AUTH_TOKEN && !url.startsWith('https://cdn.jsdelivr.net')) {
|
|
51
|
+
log('Fetching authenticated model: ' + url);
|
|
52
|
+
const resp = await fetch(url, { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } });
|
|
53
|
+
if (!resp.ok) throw new Error('Failed to fetch model: ' + resp.status + ' ' + resp.statusText);
|
|
54
|
+
const blob = await resp.blob();
|
|
55
|
+
return URL.createObjectURL(blob);
|
|
56
|
+
}
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function applyColorOverrides() {
|
|
61
|
+
const root = (head && head.armature) ? head.armature : staticModel;
|
|
62
|
+
if (!root) return;
|
|
63
|
+
root.traverse((child) => {
|
|
64
|
+
if (!child.isMesh || !child.material) return;
|
|
65
|
+
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
66
|
+
mats.forEach((mat) => {
|
|
67
|
+
const name = (mat.name || '').toLowerCase();
|
|
68
|
+
if (HAIR_COLOR && (name.includes('hair') || name.includes('fur'))) {
|
|
69
|
+
mat.color?.set(HAIR_COLOR);
|
|
70
|
+
}
|
|
71
|
+
if (SKIN_COLOR && (name.includes('skin') || name.includes('body') || name.includes('face'))) {
|
|
72
|
+
mat.color?.set(SKIN_COLOR);
|
|
73
|
+
}
|
|
74
|
+
if (EYE_COLOR && (name.includes('eye') || name.includes('iris'))) {
|
|
75
|
+
mat.color?.set(EYE_COLOR);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let staticMixer = null;
|
|
82
|
+
|
|
83
|
+
async function loadStaticFallback(loadedAvatarUrl) {
|
|
84
|
+
try {
|
|
85
|
+
const THREE = await import('three');
|
|
86
|
+
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
|
|
87
|
+
const { OrbitControls } = await import('three/addons/controls/OrbitControls.js');
|
|
88
|
+
|
|
89
|
+
container.innerHTML = '';
|
|
90
|
+
head = null;
|
|
91
|
+
|
|
92
|
+
const scene = new THREE.Scene();
|
|
93
|
+
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
|
|
94
|
+
camera.position.set(0, 0, 3);
|
|
95
|
+
|
|
96
|
+
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
|
97
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
98
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
99
|
+
container.appendChild(renderer.domElement);
|
|
100
|
+
|
|
101
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
102
|
+
controls.enableDamping = true;
|
|
103
|
+
controls.dampingFactor = 0.05;
|
|
104
|
+
|
|
105
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
|
|
106
|
+
scene.add(ambientLight);
|
|
107
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 3);
|
|
108
|
+
dirLight.position.set(1, 1, 2);
|
|
109
|
+
scene.add(dirLight);
|
|
110
|
+
|
|
111
|
+
const clock = new THREE.Clock();
|
|
112
|
+
const loader = new GLTFLoader();
|
|
113
|
+
loader.load(loadedAvatarUrl, (gltf) => {
|
|
114
|
+
staticModel = gltf.scene;
|
|
115
|
+
applyColorOverrides();
|
|
116
|
+
|
|
117
|
+
scene.add(staticModel);
|
|
118
|
+
|
|
119
|
+
// Play any embedded animations (walk cycles, idle, etc.)
|
|
120
|
+
if (gltf.animations && gltf.animations.length > 0) {
|
|
121
|
+
staticMixer = new THREE.AnimationMixer(staticModel);
|
|
122
|
+
for (const clip of gltf.animations) {
|
|
123
|
+
staticMixer.clipAction(clip).play();
|
|
124
|
+
}
|
|
125
|
+
log('Playing ' + gltf.animations.length + ' animation(s)');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Collect morph targets for amplitude-driven jaw/mouth
|
|
129
|
+
staticModel.traverse((child) => {
|
|
130
|
+
if (child.isMesh && child.morphTargetDictionary && child.morphTargetInfluences) {
|
|
131
|
+
mouthMeshes.push(child);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
if (mouthMeshes.length > 0) {
|
|
135
|
+
log('Found ' + mouthMeshes.length + ' mesh(es) with morph targets');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Auto-frame the model
|
|
139
|
+
const box = new THREE.Box3().setFromObject(staticModel);
|
|
140
|
+
const size = box.getSize(new THREE.Vector3());
|
|
141
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
142
|
+
|
|
143
|
+
if (maxDim > 0 && maxDim !== Infinity) {
|
|
144
|
+
const scale = 1.5 / maxDim;
|
|
145
|
+
staticModel.scale.set(scale, scale, scale);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scaledBox = new THREE.Box3().setFromObject(staticModel);
|
|
149
|
+
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
|
|
150
|
+
staticModel.position.sub(scaledCenter);
|
|
151
|
+
|
|
152
|
+
applyAccessories(pendingAccessoriesList);
|
|
153
|
+
|
|
154
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
|
|
155
|
+
|
|
156
|
+
window.addEventListener('resize', () => {
|
|
157
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
158
|
+
camera.updateProjectionMatrix();
|
|
159
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
renderer.setAnimationLoop(() => {
|
|
163
|
+
const delta = clock.getDelta();
|
|
164
|
+
if (staticMixer) staticMixer.update(delta);
|
|
165
|
+
controls.update();
|
|
166
|
+
renderer.render(scene, camera);
|
|
167
|
+
});
|
|
168
|
+
}, (ev) => {
|
|
169
|
+
if (ev.lengthComputable) {
|
|
170
|
+
const pct = Math.round((ev.loaded / ev.total) * 100);
|
|
171
|
+
log('Fallback Loading: ' + pct + '%');
|
|
172
|
+
}
|
|
173
|
+
}, (err) => {
|
|
174
|
+
log('Fallback Error: ' + err.message);
|
|
175
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
log('Fallback setup error: ' + err.message);
|
|
179
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function init() {
|
|
184
|
+
try {
|
|
185
|
+
log('Loading TalkingHead...');
|
|
186
|
+
const module = await import(TALKING_HEAD_URL);
|
|
187
|
+
|
|
188
|
+
head = new module.TalkingHead(container, {
|
|
189
|
+
ttsEndpoint: null,
|
|
190
|
+
lipsyncModules: ['en'],
|
|
191
|
+
cameraView: CAMERA_VIEW,
|
|
192
|
+
cameraDistance: CAMERA_DISTANCE,
|
|
193
|
+
cameraRotateEnable: false,
|
|
194
|
+
cameraZoomEnable: false,
|
|
195
|
+
lightAmbientIntensity: 2,
|
|
196
|
+
lightDirectIntensity: 10,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
log('Loading avatar model...');
|
|
200
|
+
let avatarLoaded = false;
|
|
201
|
+
let loadedAvatarUrl = AVATAR_URL;
|
|
202
|
+
try {
|
|
203
|
+
loadedAvatarUrl = await loadWithAuth(AVATAR_URL);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
log('Failed to fetch avatar blob: ' + err.message);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await head.showAvatar({
|
|
210
|
+
url: loadedAvatarUrl,
|
|
211
|
+
body: 'F',
|
|
212
|
+
avatarMood: INITIAL_MOOD,
|
|
213
|
+
lipsyncLang: 'en',
|
|
214
|
+
}, (ev) => {
|
|
215
|
+
if (ev.lengthComputable) {
|
|
216
|
+
const pct = Math.round((ev.loaded / ev.total) * 100);
|
|
217
|
+
log('Loading: ' + pct + '%');
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
if (loadedAvatarUrl.startsWith('blob:')) URL.revokeObjectURL(loadedAvatarUrl);
|
|
221
|
+
avatarLoaded = true;
|
|
222
|
+
} catch (avatarErr) {
|
|
223
|
+
// Non-rigged avatars (no Armature) are expected — fall back to static viewer silently
|
|
224
|
+
log('No armature found, using static viewer: ' + avatarErr.message);
|
|
225
|
+
await loadStaticFallback(loadedAvatarUrl);
|
|
226
|
+
if (loadedAvatarUrl.startsWith('blob:')) URL.revokeObjectURL(loadedAvatarUrl);
|
|
227
|
+
return; // fallback posts 'ready' itself; don't post 'error'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (avatarLoaded) {
|
|
231
|
+
head.armature?.traverse((child) => {
|
|
232
|
+
if (child.isMesh && child.morphTargetDictionary && child.morphTargetInfluences) {
|
|
233
|
+
mouthMeshes.push(child);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
applyColorOverrides();
|
|
238
|
+
|
|
239
|
+
log('Initializing HeadAudio...');
|
|
240
|
+
try {
|
|
241
|
+
const haModule = await import(HEAD_AUDIO_URL);
|
|
242
|
+
if (haModule.HeadAudio && head.audioCtx) {
|
|
243
|
+
await head.audioCtx.audioWorklet.addModule(HEAD_AUDIO_WORKLET);
|
|
244
|
+
headaudio = new haModule.HeadAudio(head.audioCtx);
|
|
245
|
+
await headaudio.loadModel(HEAD_AUDIO_MODEL);
|
|
246
|
+
if (head.audioSpeechGainNode) {
|
|
247
|
+
head.audioSpeechGainNode.connect(headaudio);
|
|
248
|
+
}
|
|
249
|
+
headaudio.onvalue = (key, value) => {
|
|
250
|
+
if (head.mtAvatar && head.mtAvatar[key]) {
|
|
251
|
+
Object.assign(head.mtAvatar[key], { newvalue: value, needsUpdate: true });
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
head.opt.update = headaudio.update.bind(headaudio);
|
|
255
|
+
log('HeadAudio ready (phoneme lip sync)');
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log('HeadAudio unavailable, amplitude fallback active: ' + err.message);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
startAudioInterception();
|
|
262
|
+
log('[ACC] init() complete, calling applyAccessories with ' + pendingAccessoriesList.length + ' pending items');
|
|
263
|
+
applyAccessories(pendingAccessoriesList);
|
|
264
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
log('Error: ' + err.message);
|
|
268
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'error', message: err.message }));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function startAudioInterception() {
|
|
273
|
+
if (!head?.audioCtx || !head?.audioSpeechGainNode) return;
|
|
274
|
+
const audioCtx = head.audioCtx;
|
|
275
|
+
const gainNode = head.audioSpeechGainNode;
|
|
276
|
+
const connected = new WeakSet();
|
|
277
|
+
const pending = new Set();
|
|
278
|
+
|
|
279
|
+
function tryConnect(el) {
|
|
280
|
+
const stream = el.srcObject;
|
|
281
|
+
if (!stream || connected.has(stream)) return !!stream;
|
|
282
|
+
connected.add(stream);
|
|
283
|
+
try {
|
|
284
|
+
audioCtx.createMediaStreamSource(stream).connect(gainNode);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
log('Audio routing error: ' + e);
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function track(el) {
|
|
292
|
+
if (!tryConnect(el)) pending.add(el);
|
|
293
|
+
// Listen for play events which happen when stream is ready to avoid polling
|
|
294
|
+
const onReady = () => {
|
|
295
|
+
if (tryConnect(el)) pending.delete(el);
|
|
296
|
+
};
|
|
297
|
+
el.addEventListener('play', onReady);
|
|
298
|
+
el.addEventListener('playing', onReady);
|
|
299
|
+
el.addEventListener('loadedmetadata', onReady);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
document.querySelectorAll('audio').forEach((el) => track(el));
|
|
303
|
+
|
|
304
|
+
new MutationObserver((mutations) => {
|
|
305
|
+
for (const mut of mutations) {
|
|
306
|
+
for (const node of mut.addedNodes) {
|
|
307
|
+
if (node instanceof HTMLAudioElement) track(node);
|
|
308
|
+
if (node instanceof HTMLElement) node.querySelectorAll('audio').forEach(track);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let amplitudeDecay = 0;
|
|
315
|
+
let THREE_REF = null;
|
|
316
|
+
let gltfLoaderInstance = null;
|
|
317
|
+
const currentAccessories = {}; // { [id]: { model: THREE.Group | null, url, bone, position, rotation, scale, isLoading: boolean, latestData: any } }
|
|
318
|
+
let pendingAccessoriesList = [];
|
|
319
|
+
|
|
320
|
+
function disposeHierarchy(node) {
|
|
321
|
+
if (!node) return;
|
|
322
|
+
node.traverse((child) => {
|
|
323
|
+
if (child.isMesh) {
|
|
324
|
+
if (child.geometry) child.geometry.dispose();
|
|
325
|
+
if (child.material) {
|
|
326
|
+
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
327
|
+
materials.forEach(mat => {
|
|
328
|
+
if (mat.map) mat.map.dispose();
|
|
329
|
+
if (mat.lightMap) mat.lightMap.dispose();
|
|
330
|
+
if (mat.bumpMap) mat.bumpMap.dispose();
|
|
331
|
+
if (mat.normalMap) mat.normalMap.dispose();
|
|
332
|
+
if (mat.specularMap) mat.specularMap.dispose();
|
|
333
|
+
if (mat.envMap) mat.envMap.dispose();
|
|
334
|
+
mat.dispose();
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function ensureThree() {
|
|
342
|
+
if (!THREE_REF) {
|
|
343
|
+
THREE_REF = await import('three');
|
|
344
|
+
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
|
|
345
|
+
const { DRACOLoader } = await import('three/addons/loaders/DRACOLoader.js');
|
|
346
|
+
|
|
347
|
+
gltfLoaderInstance = new GLTFLoader();
|
|
348
|
+
const dracoLoader = new DRACOLoader();
|
|
349
|
+
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
|
|
350
|
+
gltfLoaderInstance.setDRACOLoader(dracoLoader);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function applyAccessories(accessoriesList) {
|
|
355
|
+
log('[ACC] applyAccessories called with ' + accessoriesList.length + ' items');
|
|
356
|
+
pendingAccessoriesList = accessoriesList;
|
|
357
|
+
await ensureThree();
|
|
358
|
+
const root = (head && head.armature) ? head.armature : staticModel;
|
|
359
|
+
log('[ACC] root=' + (root ? root.constructor.name + '/' + root.name : 'NULL') + ' head=' + !!head + ' head.armature=' + !!(head && head.armature) + ' staticModel=' + !!staticModel);
|
|
360
|
+
if (!root) { log('[ACC] ABORT: no root'); return; }
|
|
361
|
+
|
|
362
|
+
// Debug: list all bones in the root
|
|
363
|
+
const boneNames = [];
|
|
364
|
+
root.traverse((child) => { if (child.isBone) boneNames.push(child.name); });
|
|
365
|
+
log('[ACC] Bones found: ' + boneNames.join(', '));
|
|
366
|
+
|
|
367
|
+
const newAccessoryIds = new Set(accessoriesList.map(a => a.id));
|
|
368
|
+
|
|
369
|
+
// Remove old ones
|
|
370
|
+
for (const id in currentAccessories) {
|
|
371
|
+
if (!newAccessoryIds.has(id)) {
|
|
372
|
+
const acc = currentAccessories[id];
|
|
373
|
+
if (acc.model) {
|
|
374
|
+
if (acc.model.parent) {
|
|
375
|
+
acc.model.parent.remove(acc.model);
|
|
376
|
+
}
|
|
377
|
+
disposeHierarchy(acc.model);
|
|
378
|
+
}
|
|
379
|
+
delete currentAccessories[id];
|
|
380
|
+
log('[ACC] Removed old accessory: ' + id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Add or update
|
|
385
|
+
for (const accData of accessoriesList) {
|
|
386
|
+
log('[ACC] Processing: id=' + accData.id + ' url=' + accData.url + ' bone=' + accData.bone);
|
|
387
|
+
const existing = currentAccessories[accData.id];
|
|
388
|
+
|
|
389
|
+
// If URL changed or it's new, we need to load it
|
|
390
|
+
if (!existing || existing.url !== accData.url) {
|
|
391
|
+
if (existing && existing.model) {
|
|
392
|
+
if (existing.model.parent) {
|
|
393
|
+
existing.model.parent.remove(existing.model);
|
|
394
|
+
}
|
|
395
|
+
disposeHierarchy(existing.model);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Mark as loading and store the latest transform data
|
|
399
|
+
currentAccessories[accData.id] = { ...accData, model: null, isLoading: true, latestData: accData };
|
|
400
|
+
|
|
401
|
+
log('[ACC] Starting GLB load: ' + accData.url);
|
|
402
|
+
|
|
403
|
+
// Use an IIFE to handle the async load with auth
|
|
404
|
+
(async () => {
|
|
405
|
+
try {
|
|
406
|
+
const loadedUrl = await loadWithAuth(accData.url);
|
|
407
|
+
gltfLoaderInstance.load(loadedUrl, (gltf) => {
|
|
408
|
+
if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
|
|
409
|
+
log('[ACC] GLB loaded OK for ' + accData.id);
|
|
410
|
+
const model = gltf.scene;
|
|
411
|
+
|
|
412
|
+
// Grab the *latest* data that might have arrived while we were loading
|
|
413
|
+
const latestData = currentAccessories[accData.id]?.latestData || accData;
|
|
414
|
+
|
|
415
|
+
let targetBone = null;
|
|
416
|
+
// Exact match first, then prefix match for Sketchfab exports (e.g. Head_5)
|
|
417
|
+
let prefixCandidate = null;
|
|
418
|
+
root.traverse((child) => {
|
|
419
|
+
if (child.isBone && child.name === latestData.bone) {
|
|
420
|
+
targetBone = child;
|
|
421
|
+
} else if (!prefixCandidate && child.name.replace(/_\\d+$/, '') === latestData.bone) {
|
|
422
|
+
prefixCandidate = child;
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
if (!targetBone && prefixCandidate) {
|
|
426
|
+
targetBone = prefixCandidate;
|
|
427
|
+
log('[ACC] Exact bone "' + accData.bone + '" not found, using prefix match: ' + targetBone.name);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Prevent frustum culling from making the accessory disappear when bone moves
|
|
431
|
+
model.traverse((child) => {
|
|
432
|
+
if (child.isMesh) {
|
|
433
|
+
child.frustumCulled = false;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
if (!targetBone) {
|
|
438
|
+
log('[ACC] Bone not found: ' + latestData.bone + '. Falling back to root.');
|
|
439
|
+
targetBone = root;
|
|
440
|
+
} else {
|
|
441
|
+
log('[ACC] Found bone: ' + targetBone.name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Apply scale and position directly from stored data.
|
|
445
|
+
// Filament editor is the single source of truth for placement.
|
|
446
|
+
const modelScale = latestData.scale !== undefined ? latestData.scale : 1.0;
|
|
447
|
+
model.scale.set(modelScale, modelScale, modelScale);
|
|
448
|
+
model.position.set(
|
|
449
|
+
latestData.position ? latestData.position[0] : 0,
|
|
450
|
+
latestData.position ? latestData.position[1] : 0,
|
|
451
|
+
latestData.position ? latestData.position[2] : 0
|
|
452
|
+
);
|
|
453
|
+
if (latestData.rotation) {
|
|
454
|
+
model.rotation.set(...latestData.rotation);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
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));
|
|
458
|
+
|
|
459
|
+
// Ensure the accessory wasn't removed or changed while loading
|
|
460
|
+
if (!currentAccessories[accData.id] || currentAccessories[accData.id].url !== accData.url) {
|
|
461
|
+
log('[ACC] Aborting attachment: ' + accData.id + ' was removed or changed while loading.');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
targetBone.add(model);
|
|
466
|
+
log('[ACC] Model attached to bone ' + targetBone.name + '. Children count: ' + targetBone.children.length);
|
|
467
|
+
|
|
468
|
+
currentAccessories[accData.id].model = model;
|
|
469
|
+
currentAccessories[accData.id].isLoading = false;
|
|
470
|
+
|
|
471
|
+
}, (progress) => {
|
|
472
|
+
if (progress.lengthComputable) {
|
|
473
|
+
// log('[ACC] Load progress ' + accData.id + ': ' + Math.round((progress.loaded / progress.total) * 100) + '%');
|
|
474
|
+
}
|
|
475
|
+
}, (err) => {
|
|
476
|
+
if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
|
|
477
|
+
log('[ACC] FAILED to load accessory ' + accData.id + ': ' + err.message);
|
|
478
|
+
if (currentAccessories[accData.id]) {
|
|
479
|
+
currentAccessories[accData.id].isLoading = false;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
} catch (authErr) {
|
|
483
|
+
log('[ACC] FAILED to fetch accessory blob ' + accData.id + ': ' + authErr.message);
|
|
484
|
+
if (currentAccessories[accData.id]) {
|
|
485
|
+
currentAccessories[accData.id].isLoading = false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
})();
|
|
489
|
+
} else if (existing && existing.isLoading) {
|
|
490
|
+
// It's already loading, just update the latestData so when it finishes it uses these transforms
|
|
491
|
+
log('[ACC] Still loading ' + accData.id + '... buffering latest transforms');
|
|
492
|
+
existing.latestData = accData;
|
|
493
|
+
} else if (existing && existing.model) {
|
|
494
|
+
log('[ACC] Updating existing model for ' + accData.id);
|
|
495
|
+
// Just update transform if model already loaded
|
|
496
|
+
const model = existing.model;
|
|
497
|
+
|
|
498
|
+
// Apply scale and position directly from stored data.
|
|
499
|
+
const accScale = accData.scale !== undefined ? accData.scale : 1.0;
|
|
500
|
+
model.scale.set(accScale, accScale, accScale);
|
|
501
|
+
model.position.set(
|
|
502
|
+
accData.position ? accData.position[0] : 0,
|
|
503
|
+
accData.position ? accData.position[1] : 0,
|
|
504
|
+
accData.position ? accData.position[2] : 0
|
|
505
|
+
);
|
|
506
|
+
if (accData.rotation) {
|
|
507
|
+
model.rotation.set(...accData.rotation);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function onIncomingMessage(event) {
|
|
514
|
+
try {
|
|
515
|
+
const msg = JSON.parse(event.data);
|
|
516
|
+
if (msg.type === 'amplitude' && mouthMeshes.length > 0) {
|
|
517
|
+
const val = Math.min(1, msg.value * 2.5);
|
|
518
|
+
amplitudeDecay = Math.max(amplitudeDecay * 0.7, val);
|
|
519
|
+
for (const mesh of mouthMeshes) {
|
|
520
|
+
if (!mesh.morphTargetDictionary) continue;
|
|
521
|
+
const keys = Object.keys(mesh.morphTargetDictionary);
|
|
522
|
+
const jawKey = keys.find((k) => {
|
|
523
|
+
const lk = k.toLowerCase();
|
|
524
|
+
return lk.includes('jawopen') || lk.includes('jaw_open') ||
|
|
525
|
+
lk.includes('mouthopen') || lk.includes('mouth_open') ||
|
|
526
|
+
lk.includes('viseme_aa') || lk === 'aa';
|
|
527
|
+
});
|
|
528
|
+
if (jawKey !== undefined) {
|
|
529
|
+
mesh.morphTargetInfluences[mesh.morphTargetDictionary[jawKey]] = amplitudeDecay;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else if (msg.type === 'mood' && head) {
|
|
533
|
+
head.setMood(msg.value);
|
|
534
|
+
} else if (msg.type === 'hair_color') {
|
|
535
|
+
HAIR_COLOR = msg.value;
|
|
536
|
+
applyColorOverrides();
|
|
537
|
+
} else if (msg.type === 'skin_color') {
|
|
538
|
+
SKIN_COLOR = msg.value;
|
|
539
|
+
applyColorOverrides();
|
|
540
|
+
} else if (msg.type === 'eye_color') {
|
|
541
|
+
EYE_COLOR = msg.value;
|
|
542
|
+
applyColorOverrides();
|
|
543
|
+
} else if (msg.type === 'set_accessories') {
|
|
544
|
+
log('[ACC] Received set_accessories message with ' + (msg.accessories ? msg.accessories.length : 0) + ' items');
|
|
545
|
+
applyAccessories(msg.accessories || []);
|
|
546
|
+
}
|
|
547
|
+
} catch (err) {
|
|
548
|
+
log('Message parse error: ' + err);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
window.addEventListener('message', onIncomingMessage);
|
|
553
|
+
document.addEventListener('message', onIncomingMessage);
|
|
554
|
+
|
|
555
|
+
init();
|
|
556
|
+
</script>
|
|
557
|
+
</body>
|
|
558
|
+
</html>
|
|
559
|
+
`;
|
|
560
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,cAAc,GACf,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,cAAc,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../src/index.web.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,cAAc,GACf,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,cAAc,cAAc,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SketchfabModel, SketchfabResponse } from './types';
|
|
2
|
+
export interface SketchfabSearchOptions {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
query?: string;
|
|
5
|
+
cursor?: string;
|
|
6
|
+
count?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function searchSketchfab(options: SketchfabSearchOptions): Promise<SketchfabResponse>;
|
|
9
|
+
export declare function getDownloadUrl(uid: string, apiKey: string): Promise<string | null>;
|
|
10
|
+
export declare function downloadModel(uid: string, name: string, apiKey: string): Promise<File>;
|
|
11
|
+
export declare function getBestThumbnail(thumbnails: SketchfabModel['thumbnails'], targetWidth?: number): string;
|
|
12
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/sketchfab/api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAKjE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAwBjG;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWxF;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe5F;AAED,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,YAAY,CAAC,EACxC,WAAW,SAAM,GAChB,MAAM,CAUR"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const SKETCHFAB_API_BASE = 'https://api.sketchfab.com/v3';
|
|
2
|
+
const PAGE_SIZE = 24;
|
|
3
|
+
export async function searchSketchfab(options) {
|
|
4
|
+
const { apiKey, query = 'character humanoid avatar', cursor, count = PAGE_SIZE } = options;
|
|
5
|
+
const params = new URLSearchParams({
|
|
6
|
+
type: 'models',
|
|
7
|
+
downloadable: 'true',
|
|
8
|
+
count: String(count),
|
|
9
|
+
sort_by: '-likeCount',
|
|
10
|
+
q: query,
|
|
11
|
+
});
|
|
12
|
+
if (cursor) {
|
|
13
|
+
params.set('cursor', cursor);
|
|
14
|
+
}
|
|
15
|
+
const response = await fetch(`${SKETCHFAB_API_BASE}/search?${params}`, {
|
|
16
|
+
headers: { Authorization: `Token ${apiKey}` },
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`Sketchfab API error: ${response.status}`);
|
|
20
|
+
}
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
export async function getDownloadUrl(uid, apiKey) {
|
|
24
|
+
const response = await fetch(`${SKETCHFAB_API_BASE}/models/${uid}/download`, {
|
|
25
|
+
headers: { Authorization: `Token ${apiKey}` },
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const data = (await response.json());
|
|
31
|
+
return data?.glb?.url ?? data?.gltf?.url ?? null;
|
|
32
|
+
}
|
|
33
|
+
export async function downloadModel(uid, name, apiKey) {
|
|
34
|
+
const url = await getDownloadUrl(uid, apiKey);
|
|
35
|
+
if (!url) {
|
|
36
|
+
throw new Error(`No download URL available for model ${uid}`);
|
|
37
|
+
}
|
|
38
|
+
const response = await fetch(url);
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(`Failed to fetch GLB from Sketchfab: ${response.status}`);
|
|
41
|
+
}
|
|
42
|
+
const blob = await response.blob();
|
|
43
|
+
const cleanName = name.replace(/[^\w\s-]/g, '').trim() || 'model';
|
|
44
|
+
return new File([blob], `${cleanName}.glb`, { type: 'model/gltf-binary' });
|
|
45
|
+
}
|
|
46
|
+
export function getBestThumbnail(thumbnails, targetWidth = 280) {
|
|
47
|
+
if (!thumbnails?.images?.length) {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
const sorted = [...thumbnails.images].sort((left, right) => Math.abs(left.width - targetWidth) - Math.abs(right.width - targetWidth));
|
|
51
|
+
return sorted[0].url;
|
|
52
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AccessoryCategory } from './types';
|
|
2
|
+
export declare const ACCESSORY_CATEGORIES: AccessoryCategory[];
|
|
3
|
+
/** Tags indicating a humanoid / character model (for badge display in avatar browsers) */
|
|
4
|
+
export declare const HUMANOID_TAGS: readonly ["character", "humanoid", "human", "anime", "avatar", "person", "figure", "girl", "boy", "woman", "man"];
|
|
5
|
+
//# sourceMappingURL=categories.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"categories.d.ts","sourceRoot":"","sources":["../../src/sketchfab/categories.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,eAAO,MAAM,oBAAoB,EAAE,iBAAiB,EA6GnD,CAAC;AAEF,0FAA0F;AAC1F,eAAO,MAAM,aAAa,mHAYhB,CAAC"}
|