| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { TalkingHead } from "@met4citizen/talkinghead"; |
| import { HeadAudio } from "./vendor/headaudio.min.mjs"; |
| import { Box3, Vector3, HemisphereLight, DirectionalLight } from "three"; |
| import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; |
|
|
| const HEADAUDIO_WORKLET_URL = "/vendor/headworklet.min.mjs"; |
| const HEADAUDIO_MODEL_URL = "/vendor/model-en-mixed.bin"; |
|
|
| export const AVATAR_MOODS = [ |
| "neutral", "happy", "angry", "sad", "fear", "disgust", "love", "sleep", |
| ]; |
|
|
| export const AVATAR_GESTURES = [ |
| "handup", "index", "ok", "thumbup", "thumbdown", "side", "shrug", |
| ]; |
|
|
| const HEAD_CONFIG = { |
| ttsEndpoint: "N/A", |
| lipsyncModules: [], |
| cameraView: "upper", |
| cameraDistance: -1.4, |
| cameraY: -0.15, |
| cameraX: -0.18, |
| cameraRotateEnable: false, |
| lightAmbientIntensity: 0, |
| lightDirectIntensity: 0, |
| lightSpotIntensity: 0, |
| avatarIdleEyeContact: 0.3, |
| avatarSpeakingEyeContact: 0.7, |
| }; |
|
|
| |
| function _fullyDispose(head) { |
| try { |
| if (head.scene) { |
| head.scene.traverse((obj) => { |
| if (obj.isMesh) { |
| obj.geometry?.dispose(); |
| if (obj.material) { |
| (Array.isArray(obj.material) ? obj.material : [obj.material]).forEach((m) => m.dispose()); |
| } |
| } |
| }); |
| } |
| while (head.scene?.children?.length) head.scene.remove(head.scene.children[0]); |
| if (head.renderer) { |
| head.renderer.dispose(); |
| const canvas = head.renderer.domElement; |
| if (canvas?.parentNode) canvas.parentNode.removeChild(canvas); |
| } |
| if (head.audioCtx && head.audioCtx.state !== "closed") { |
| head.audioCtx.close().catch(() => {}); |
| } |
| head.dispose?.(); |
| } catch (e) { |
| console.warn("[AvatarStage] dispose:", e); |
| } |
| } |
|
|
| export class AvatarStage { |
| |
| constructor(container) { |
| this._container = container; |
| |
| this.head = null; |
| |
| this._headaudio = null; |
| this._lastSpeechEnded = 0; |
| this._loadingEl = document.getElementById("loading"); |
| this._lastError = null; |
| |
| this._staticModel = false; |
| |
| this._modelRoot = null; |
| |
| this._staticLights = []; |
| } |
|
|
| |
| |
| |
| |
| async init(opt = {}) { |
| |
| if (this.head) { |
| this._removeStaticLights(); |
| _fullyDispose(this.head); |
| this.head = null; |
| this._headaudio = null; |
| this._modelRoot = null; |
| this._staticModel = false; |
| } |
| while (this._container.firstChild) this._container.removeChild(this._container.firstChild); |
|
|
| this._lastError = null; |
| const isDefault = !opt.avatarUrl; |
|
|
| this.head = new TalkingHead(this._container, { |
| ...HEAD_CONFIG, |
| cameraDistance: isDefault ? HEAD_CONFIG.cameraDistance : -1.25, |
| cameraY: isDefault ? HEAD_CONFIG.cameraY : 0.0, |
| cameraX: isDefault ? HEAD_CONFIG.cameraX : 0.0, |
| }); |
|
|
| console.log("[AvatarStage] Loading:", opt.avatarUrl ?? "default"); |
|
|
| |
| let morphOk = false; |
| if (isDefault) { |
| try { |
| await this._loadShowAvatar(opt); |
| morphOk = true; |
| } catch (err) { |
| this._lastError = err?.message || String(err); |
| console.error("[AvatarStage] Default showAvatar FAILED:", this._lastError); |
| _fullyDispose(this.head); |
| this.head = null; |
| return false; |
| } |
| } else { |
| try { |
| await this._loadShowAvatar(opt); |
| morphOk = true; |
| console.log("[AvatarStage] Custom model loaded WITH morph targets"); |
| } catch (err) { |
| console.warn("[AvatarStage] showAvatar failed, trying static:", err?.message); |
| } |
| } |
|
|
| |
| if (!morphOk) { |
| try { |
| await this._loadStaticModel( (opt.avatarUrl), opt); |
| this._staticModel = true; |
| this._addStaticLights(); |
| console.log("[AvatarStage] Custom model loaded as STATIC"); |
| } catch (err2) { |
| this._lastError = err2?.message || String(err2); |
| console.error("[AvatarStage] Static ALSO failed:", this._lastError); |
| _fullyDispose(this.head); |
| this.head = null; |
| return false; |
| } |
| } |
|
|
| |
| this._adjustCamera(opt.avatarUrl); |
|
|
| |
| if (!this._staticModel) { |
| try { await this._initLipsync(); } catch (err) { console.warn("[AvatarStage] Lipsync:", err); } |
| } else { |
| console.log("[AvatarStage] Static model — lipsync skipped"); |
| } |
|
|
| return true; |
| } |
|
|
| |
| async _loadShowAvatar(opt) { |
| const head = (this.head); |
| await head.showAvatar( |
| { url: opt.avatarUrl ?? "/avatars/vuong.glb", body: opt.body ?? "F", avatarMood: "neutral" }, |
| (ev) => { |
| if (ev.lengthComputable) { |
| const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100)); |
| if (this._loadingEl) this._loadingEl.textContent = `Loading avatar ${pct}%`; |
| } |
| opt.onprogress?.(ev); |
| }, |
| ); |
| } |
|
|
| |
| _loadStaticModel(url, opt) { |
| return new Promise((resolve, reject) => { |
| const loader = new GLTFLoader(); |
| const head = (this.head); |
|
|
| loader.load( |
| url, |
| (gltf) => { |
| const model = gltf.scene; |
| this._modelRoot = model; |
|
|
| |
| for (let i = head.scene.children.length - 1; i >= 0; i--) { |
| const c = head.scene.children[i]; |
| if (c !== head.camera) head.scene.remove(c); |
| } |
|
|
| |
| model.visible = true; |
| head.scene.add(model); |
|
|
| if (this._loadingEl) this._loadingEl.textContent = "Positioning model..."; |
| opt?.onprogress?.({ lengthComputable: true, loaded: 1, total: 1 }); |
| resolve(); |
| }, |
| (ev) => { |
| if (ev.lengthComputable) { |
| const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100)); |
| if (this._loadingEl) this._loadingEl.textContent = `Loading model ${pct}%`; |
| } |
| opt?.onprogress?.(ev); |
| }, |
| (err) => reject(new Error(`GLTF load: ${err?.message || err}`)), |
| ); |
| }); |
| } |
|
|
| |
| _addStaticLights() { |
| this._removeStaticLights(); |
| const head = (this.head); |
| const lights = [ |
| new HemisphereLight(0xffffff, 0x444444, 0.7), |
| new DirectionalLight(0xffffff, 0.9), |
| ]; |
| lights[1].position.set(5, 8, 5); |
| const fill = new DirectionalLight(0x8888ff, 0.3); |
| fill.position.set(-3, 2, -4); |
| lights.push(fill); |
| for (const l of lights) { head.scene.add(l); this._staticLights.push(l); } |
| console.log("[AvatarStage] Added", lights.length, "lights for static model"); |
| } |
|
|
| _removeStaticLights() { |
| for (const l of this._staticLights) { |
| l.parent?.remove(l); |
| l.dispose?.(); |
| } |
| this._staticLights = []; |
| } |
|
|
| |
| _adjustCamera(avatarUrl) { |
| const head = (this.head); |
| if (!head.scene || !head.camera) return; |
|
|
| if (!avatarUrl && !this._staticModel) { |
| |
| requestAnimationFrame(() => { |
| try { head.renderer?.render(head.scene, head.camera); head.camera.updateProjectionMatrix?.(); } catch {} |
| }); |
| return; |
| } |
|
|
| try { |
| const box = new Box3().setFromObject(head.scene); |
| const size = box.getSize(new Vector3()); |
| const center = box.getCenter(new Vector3()); |
| console.log("[AvatarStage] BBox:", { size, center }); |
|
|
| if (size.x === 0 && size.y === 0 && size.z === 0) { |
| console.warn("[AvatarStage] Empty bbox, skipping"); |
| return; |
| } |
|
|
| if (size.y > 2.0) { |
| |
| head.scene.position.y = -(center.y - size.y * 0.35); |
| head.camera.position.z = -Math.max(size.z, size.y, 1.0) * 1.2; |
| head.camera.position.y = size.y * 0.3; |
| console.log("[AvatarStage] Full-body adjusted"); |
| } else if (size.y > 0.3) { |
| |
| head.camera.position.z = -Math.max(size.z, 0.5) * 2.5; |
| head.camera.position.y = center.y * 0.5; |
| console.log("[AvatarStage] Head model adjusted"); |
| } |
|
|
| requestAnimationFrame(() => { |
| try { head.renderer?.render(head.scene, head.camera); head.camera.updateProjectionMatrix?.(); } catch {} |
| }); |
| box.dispose(); |
| } catch (e) { |
| console.warn("[AvatarStage] Camera adjust:", e); |
| } |
| } |
|
|
| async _initLipsync() { |
| const head = (this.head); |
| if (!head.audioCtx) { console.warn("[AvatarStage] No AudioContext"); return; } |
| try { await head.audioCtx.audioWorklet.addModule(HEADAUDIO_WORKLET_URL); } catch (e) { console.warn("[AvatarStage] Worklet:", e); return; } |
|
|
| const headaudio = new HeadAudio(head.audioCtx); |
| await headaudio.loadModel(HEADAUDIO_MODEL_URL); |
| if (!head.audioSpeechGainNode) { console.warn("[AvatarStage] No audioSpeechGainNode"); return; } |
| head.audioSpeechGainNode.connect(headaudio); |
|
|
| headaudio.onvalue = (key, value) => { |
| const mt = head.mtAvatar?.[key]; |
| if (mt) Object.assign(mt, { newvalue: value, needsUpdate: true }); |
| }; |
| head.opt.update = headaudio.update.bind(headaudio); |
| headaudio.onended = () => { this._lastSpeechEnded = Date.now(); }; |
| headaudio.onstarted = () => { |
| if (Date.now() - this._lastSpeechEnded > 150) { head.lookAtCamera(500); head.speakWithHands(); } |
| }; |
| this._headaudio = headaudio; |
| } |
|
|
| get audioCtx() { return this.head?.audioCtx ?? null; } |
| get voiceSink() { return this.head?.audioAnalyzerNode ?? null; } |
| get lastError() { return this._lastError; } |
| get isStaticModel() { return this._staticModel; } |
|
|
| |
| |
| |
| |
| |
| |
| capturePNG() { |
| const head = this.head; |
| if (!head || !head.renderer || !head.scene || !head.camera) return null; |
| try { |
| head.renderer.render(head.scene, head.camera); |
| const canvas = head.renderer.domElement; |
| return canvas.toDataURL("image/png"); |
| } catch (e) { |
| console.warn("[AvatarStage] capturePNG:", e); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| resume() { |
| this.head?.start(); |
| if (this.head && this.head.audioCtx.state === "suspended") { |
| return this.head.audioCtx.resume().catch(() => {}); |
| } |
| return Promise.resolve(); |
| } |
|
|
| setConversationState(status) { |
| const h = this.head; |
| if (!h) return; |
| switch (status) { |
| case "user-speaking": h.isSpeaking = false; h.lookAtCamera(800); break; |
| case "ai-speaking": h.isSpeaking = true; break; |
| case "processing": h.isSpeaking = false; break; |
| case "closed": case "error": case "idle": h.isSpeaking = false; h.stopGesture(300); break; |
| default: h.isSpeaking = false; |
| } |
| } |
|
|
| runTool(name, args) { |
| const h = this.head; |
| if (!h) return null; |
| if (this._staticModel) { |
| if (name === "set_mood") return "Mood unavailable (static model)"; |
| if (name === "make_hand_gesture") return "Gestures unavailable (static model)"; |
| if (name === "make_facial_expression") return "Expressions unavailable (static model)"; |
| } |
| if (name === "set_mood") { |
| if (!AVATAR_MOODS.includes(args.mood)) return `Unknown mood: ${args.mood}`; |
| h.setMood(args.mood); return `Mood set to ${args.mood}.`; |
| } |
| if (name === "make_hand_gesture") { |
| if (!AVATAR_GESTURES.includes(args.gesture)) return `Unknown gesture: ${args.gesture}`; |
| h.playGesture(args.gesture, 3); return `Playing gesture ${args.gesture}.`; |
| } |
| if (name === "make_facial_expression") { |
| if (!args.emoji) return "No emoji given."; |
| h.speakEmoji(args.emoji); return `Expressing ${args.emoji}.`; |
| } |
| return null; |
| } |
| } |