talking-head-studio 0.4.1 → 0.4.3

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.
@@ -25,11 +25,17 @@ export interface TalkingHeadVisemeSchedule {
25
25
  /** Matches X-TTS-Request-Id / agent_visemes.requestId */
26
26
  requestId?: string;
27
27
  /**
28
- * Wall-clock ms at which audio playback started.
29
- * Anchor this to the moment you observe agent_state: speaking.
30
- * Used to skip cues that are already in the past on late delivery.
28
+ * Wall-clock ms at which the TTS request was fired (agent side).
29
+ * Used as the scheduling anchor plus AUDIO_PIPELINE_DELAY_MS.
31
30
  */
32
31
  startedAtMs?: number;
32
+ /**
33
+ * Wall-clock ms at which audio actually began playing in the speaker.
34
+ * When present, used directly as the scheduling anchor with no additional
35
+ * pipeline offset — more accurate than startedAtMs on fast connections.
36
+ * Stamp this from the LiveKit onAudioPlaybackStarted callback if available.
37
+ */
38
+ audioStartedAtMs?: number;
33
39
  durationMs?: number;
34
40
  cues: TalkingHeadVisemeCue[];
35
41
  }
@@ -46,10 +46,10 @@ function getLoadingLabel(stage) {
46
46
  */
47
47
  exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, vendorBaseUrl }, ref) => {
48
48
  const avatarRef = (0, react_1.useRef)(null);
49
- // On native, Filament ref is wired via callback ref — store it here so
49
+ // On native, WgpuAvatar ref is wired via callback ref — store it here so
50
50
  // scheduleVisemes / sendAmplitude can route to it.
51
51
  const wgpuRef = (0, react_1.useRef)(null);
52
- // Unified accessor — Filament on native, WebView on web
52
+ // Unified accessor — WgpuAvatar on native, WebView on web
53
53
  const activeAvatar = (0, react_1.useCallback)(() => (wgpuRef.current ?? avatarRef.current), []);
54
54
  // Fallback local GLB data URI — resolved once on mount
55
55
  const [fallbackUrl, setFallbackUrl] = (0, react_1.useState)(_fallbackDataUri);
@@ -114,7 +114,7 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
114
114
  if (lastScheduledVisemeKeyRef.current === scheduleKey)
115
115
  return;
116
116
  const av = activeAvatar();
117
- // Filament buffers pending schedules internally — no ready gate needed.
117
+ // WgpuAvatar buffers pending schedules internally — no ready gate needed.
118
118
  // WebView (avatarRef) still needs the ready gate.
119
119
  if (!av || (!wgpuRef.current && !isAvatarReady)) {
120
120
  pendingVisemeScheduleRef.current = schedule;
@@ -181,7 +181,7 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
181
181
  });
182
182
  lastScheduledVisemeKeyRef.current = scheduleKey;
183
183
  }, [isAvatarReady, onVisemeScheduleApplied, visemeSchedule, activeAvatar]);
184
- // On native use Filament — direct morph writes, no WebView bridge.
184
+ // On native use WgpuAvatar — direct morph writes, no WebView bridge.
185
185
  if (react_native_1.Platform.OS !== 'web') {
186
186
  return ((0, jsx_runtime_1.jsx)(WgpuAvatar_1.WgpuAvatar, { focalLength: focalLength, ref: (fr) => {
187
187
  wgpuRef.current = fr;
@@ -1,16 +1,5 @@
1
- /// <reference types="react" />
2
- import type { AvatarAppearance } from '../appearance';
3
- import type { AssetPlacement, EquippedAsset } from './types';
4
- interface AvatarCanvasProps {
5
- avatarUrl: string;
6
- appearance?: AvatarAppearance;
7
- equipped?: EquippedAsset[];
8
- placements?: Record<string, AssetPlacement>;
9
- editingAssetId?: string | null;
10
- onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
1
+ import type { AvatarEditorProps } from './types';
2
+ export declare function AvatarCanvas({ avatarUrl, equipped, placements, editingAssetId, activeAssetId, onPlacementChange, onSceneRef, style, className, }: AvatarEditorProps & {
11
3
  onSceneRef?: (scene: any) => void;
12
- style?: React.CSSProperties;
13
4
  className?: string;
14
- }
15
- export declare function AvatarCanvas({ avatarUrl, equipped, placements, editingAssetId, onPlacementChange, onSceneRef, style, className, }: AvatarCanvasProps): import("react/jsx-runtime").JSX.Element;
16
- export {};
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -18,7 +18,9 @@ function SceneRefCapture({ onSceneRef }) {
18
18
  }, [scene, onSceneRef]);
19
19
  return null;
20
20
  }
21
- function AvatarCanvas({ avatarUrl, equipped = [], placements = {}, editingAssetId = null, onPlacementChange, onSceneRef, style, className, }) {
21
+ function AvatarCanvas({ avatarUrl, equipped = [], placements = {}, editingAssetId = null, activeAssetId = null, onPlacementChange, onSceneRef, style, className, }) {
22
+ // Support both prop names — web consumers use editingAssetId, native uses activeAssetId
23
+ const editingId = editingAssetId ?? activeAssetId;
22
24
  const [skeleton, setSkeleton] = (0, react_1.useState)(null);
23
25
  const [avatarScene, setAvatarScene] = (0, react_1.useState)(null);
24
26
  const [cameraTarget, setCameraTarget] = (0, react_1.useState)([0, 1, 0]);
@@ -43,12 +45,11 @@ function AvatarCanvas({ avatarUrl, equipped = [], placements = {}, editingAssetI
43
45
  ]
44
46
  .filter(Boolean)
45
47
  .join(' ');
46
- return ((0, jsx_runtime_1.jsxs)("div", { className: wrapperClass, style: style, children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 opacity-[0.04] pointer-events-none", style: { backgroundImage: 'linear-gradient(rgba(255,255,255,0.3) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.3) 1px,transparent 1px)', backgroundSize: '60px 60px' } }), avatarUrl ? ((0, jsx_runtime_1.jsx)(AvatarCanvasErrorBoundary_1.AvatarCanvasErrorBoundary, { children: (0, jsx_runtime_1.jsx)(fiber_1.Canvas, { ref: canvasRef, camera: { position: cameraPosition, fov: 45 }, style: { width: '100%', height: '100%' }, children: (0, jsx_runtime_1.jsxs)(react_1.Suspense, { fallback: null, children: [onSceneRef && (0, jsx_runtime_1.jsx)(SceneRefCapture, { onSceneRef: onSceneRef }), (0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.6 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [5, 5, 5], intensity: 0.8 }), (0, jsx_runtime_1.jsx)(drei_1.Environment, { preset: "studio" }), (0, jsx_runtime_1.jsx)(drei_1.OrbitControls, { target: cameraTarget, minDistance: 1, maxDistance: 10, enablePan: false, makeDefault: true, enabled: !editingAssetId }), (0, jsx_runtime_1.jsx)(AvatarModel_1.AvatarModel, { url: avatarUrl, scale: 1, onSkeletonReady: handleSkeletonReady, onBoundsReady: handleBoundsReady }), equippedItems.map((asset) => {
48
+ return ((0, jsx_runtime_1.jsxs)("div", { className: wrapperClass, style: style, children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute inset-0 opacity-[0.04] pointer-events-none", style: { backgroundImage: 'linear-gradient(rgba(255,255,255,0.3) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.3) 1px,transparent 1px)', backgroundSize: '60px 60px' } }), avatarUrl ? ((0, jsx_runtime_1.jsx)(AvatarCanvasErrorBoundary_1.AvatarCanvasErrorBoundary, { children: (0, jsx_runtime_1.jsx)(fiber_1.Canvas, { ref: canvasRef, camera: { position: cameraPosition, fov: 45 }, style: { width: '100%', height: '100%' }, children: (0, jsx_runtime_1.jsxs)(react_1.Suspense, { fallback: null, children: [onSceneRef && (0, jsx_runtime_1.jsx)(SceneRefCapture, { onSceneRef: onSceneRef }), (0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.6 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [5, 5, 5], intensity: 0.8 }), (0, jsx_runtime_1.jsx)(drei_1.Environment, { preset: "studio" }), (0, jsx_runtime_1.jsx)(drei_1.OrbitControls, { target: cameraTarget, minDistance: 1, maxDistance: 10, enablePan: false, makeDefault: true, enabled: !editingId }), (0, jsx_runtime_1.jsx)(AvatarModel_1.AvatarModel, { url: avatarUrl, scale: 1, onSkeletonReady: handleSkeletonReady, onBoundsReady: handleBoundsReady }), equippedItems.map((asset) => {
47
49
  if (!asset)
48
50
  return null;
49
51
  const llmPlacement = placements[asset.id];
50
- const isEditing = editingAssetId === asset.id;
51
- // If a placement is provided, always render as rigid attachment
52
+ const isEditing = editingId === asset.id;
52
53
  if (llmPlacement) {
53
54
  const bone = llmPlacement.bone ?? asset.attach_bone;
54
55
  if (!bone)
@@ -0,0 +1 @@
1
+ export { AvatarCanvas as AvatarEditor } from './AvatarCanvas';
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AvatarEditor = void 0;
4
+ // Web: delegate to AvatarCanvas (existing web implementation).
5
+ var AvatarCanvas_1 = require("./AvatarCanvas");
6
+ Object.defineProperty(exports, "AvatarEditor", { enumerable: true, get: function () { return AvatarCanvas_1.AvatarCanvas; } });
@@ -0,0 +1,4 @@
1
+ import type { AvatarEditorProps } from './types';
2
+ export type { AvatarEditorProps };
3
+ export declare function AvatarEditor({ avatarUrl, activeAssetId, onPlacementChange, style, }: AvatarEditorProps): import("react/jsx-runtime").JSX.Element;
4
+ export default AvatarEditor;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.AvatarEditor = void 0;
27
+ const jsx_runtime_1 = require("react/jsx-runtime");
28
+ /**
29
+ * Native avatar placement editor.
30
+ * Uses WgpuAvatar (R3F + expo-gl) as the render surface and Three.js raycasting
31
+ * for gesture-driven accessory placement. Replaces the old FilamentEditor.
32
+ */
33
+ const react_1 = require("react");
34
+ const react_native_1 = require("react-native");
35
+ const THREE = __importStar(require("three"));
36
+ const wgpu_1 = require("../wgpu");
37
+ const boneSnap_1 = require("./boneSnap");
38
+ function tapToNDC(evt, layout) {
39
+ const { locationX, locationY } = evt.nativeEvent;
40
+ return new THREE.Vector2((locationX / layout.width) * 2 - 1, -((locationY / layout.height) * 2 - 1));
41
+ }
42
+ function AvatarEditor({ avatarUrl, activeAssetId = null, onPlacementChange, style, }) {
43
+ const cameraRef = (0, react_1.useRef)(null);
44
+ const layoutRef = (0, react_1.useRef)({ width: 1, height: 1 });
45
+ const [avatarScene, setAvatarScene] = (0, react_1.useState)(null);
46
+ const handleSceneReady = (0, react_1.useCallback)((scene, camera) => {
47
+ cameraRef.current = camera;
48
+ let avatarRoot = scene;
49
+ scene.traverse((obj) => {
50
+ if (obj instanceof THREE.SkinnedMesh && avatarRoot === scene) {
51
+ avatarRoot = obj.parent ?? obj;
52
+ }
53
+ });
54
+ setAvatarScene(avatarRoot);
55
+ }, []);
56
+ const handleTap = (0, react_1.useCallback)((evt) => {
57
+ if (!activeAssetId || !cameraRef.current || !avatarScene)
58
+ return;
59
+ const ndc = tapToNDC(evt, layoutRef.current);
60
+ const raycaster = new THREE.Raycaster();
61
+ raycaster.setFromCamera(ndc, cameraRef.current);
62
+ const meshes = [];
63
+ avatarScene.traverse((obj) => {
64
+ if (obj instanceof THREE.Mesh || obj instanceof THREE.SkinnedMesh) {
65
+ meshes.push(obj);
66
+ }
67
+ });
68
+ const hits = raycaster.intersectObjects(meshes, false);
69
+ if (hits.length === 0)
70
+ return;
71
+ const hitPoint = hits[0].point;
72
+ const snap = (0, boneSnap_1.findNearestBone)(avatarScene, hitPoint);
73
+ if (!snap)
74
+ return;
75
+ const offsetPos = [
76
+ hitPoint.x - snap.position.x,
77
+ hitPoint.y - snap.position.y,
78
+ hitPoint.z - snap.position.z,
79
+ ];
80
+ onPlacementChange?.(activeAssetId, {
81
+ bone: snap.bone,
82
+ position: offsetPos,
83
+ rotation: [0, 0, 0],
84
+ scale: 1,
85
+ });
86
+ }, [activeAssetId, avatarScene, onPlacementChange]);
87
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, style], onLayout: (e) => { layoutRef.current = e.nativeEvent.layout; }, children: [(0, jsx_runtime_1.jsx)(wgpu_1.WgpuAvatar, { avatarUrl: avatarUrl, style: react_native_1.StyleSheet.absoluteFill, onSceneReady: handleSceneReady }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: react_native_1.StyleSheet.absoluteFill, onTouchEnd: handleTap, pointerEvents: activeAssetId ? 'auto' : 'none' })] }));
88
+ }
89
+ exports.AvatarEditor = AvatarEditor;
90
+ exports.default = AvatarEditor;
91
+ const styles = react_native_1.StyleSheet.create({
92
+ container: { flex: 1, overflow: 'hidden' },
93
+ });
@@ -0,0 +1,25 @@
1
+ import * as THREE from 'three';
2
+ export declare const HUMANOID_BONES: readonly ["Hips", "Spine", "Spine1", "Spine2", "Neck", "Head", "LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand", "RightShoulder", "RightArm", "RightForeArm", "RightHand", "LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase", "RightUpLeg", "RightLeg", "RightFoot", "RightToeBase"];
3
+ export type HumanoidBone = (typeof HUMANOID_BONES)[number];
4
+ export interface BoneSnapResult {
5
+ bone: string;
6
+ position: THREE.Vector3;
7
+ distance: number;
8
+ }
9
+ /**
10
+ * Given an avatar scene and a world-space position, finds the nearest skeleton
11
+ * bone whose name matches a known humanoid bone name.
12
+ */
13
+ export declare function findNearestBone(avatarScene: THREE.Object3D, worldPos: THREE.Vector3, maxDistance?: number): BoneSnapResult | null;
14
+ /**
15
+ * Returns the world-space position of a named bone in the avatar scene.
16
+ * Returns the scene origin if the bone is not found.
17
+ */
18
+ export declare function getWorldPositionForBone(avatarScene: THREE.Object3D, boneName: string): THREE.Vector3;
19
+ export declare const getWorldPositionForPlacement: typeof getWorldPositionForBone;
20
+ export declare function snapPlacementToNearestBone(avatarScene: THREE.Object3D, worldPos: THREE.Vector3): BoneSnapResult | null;
21
+ /**
22
+ * Hook that returns a snap function. Fires haptic feedback when a new bone
23
+ * is snapped to.
24
+ */
25
+ export declare function useBoneSnap(avatarScene: THREE.Object3D | null): (worldPos: THREE.Vector3) => BoneSnapResult | null;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.useBoneSnap = exports.snapPlacementToNearestBone = exports.getWorldPositionForPlacement = exports.getWorldPositionForBone = exports.findNearestBone = exports.HUMANOID_BONES = void 0;
27
+ const react_1 = require("react");
28
+ const Haptics = __importStar(require("expo-haptics"));
29
+ const THREE = __importStar(require("three"));
30
+ exports.HUMANOID_BONES = [
31
+ 'Hips', 'Spine', 'Spine1', 'Spine2', 'Neck', 'Head',
32
+ 'LeftShoulder', 'LeftArm', 'LeftForeArm', 'LeftHand',
33
+ 'RightShoulder', 'RightArm', 'RightForeArm', 'RightHand',
34
+ 'LeftUpLeg', 'LeftLeg', 'LeftFoot', 'LeftToeBase',
35
+ 'RightUpLeg', 'RightLeg', 'RightFoot', 'RightToeBase',
36
+ ];
37
+ /**
38
+ * Given an avatar scene and a world-space position, finds the nearest skeleton
39
+ * bone whose name matches a known humanoid bone name.
40
+ */
41
+ function findNearestBone(avatarScene, worldPos, maxDistance = 0.35) {
42
+ let best = null;
43
+ const boneWorldPos = new THREE.Vector3();
44
+ avatarScene.traverse((obj) => {
45
+ if (!(obj instanceof THREE.Bone))
46
+ return;
47
+ if (!exports.HUMANOID_BONES.includes(obj.name))
48
+ return;
49
+ obj.getWorldPosition(boneWorldPos);
50
+ const dist = worldPos.distanceTo(boneWorldPos);
51
+ if (dist < maxDistance && (!best || dist < best.distance)) {
52
+ best = { bone: obj.name, position: boneWorldPos.clone(), distance: dist };
53
+ }
54
+ });
55
+ return best;
56
+ }
57
+ exports.findNearestBone = findNearestBone;
58
+ /**
59
+ * Returns the world-space position of a named bone in the avatar scene.
60
+ * Returns the scene origin if the bone is not found.
61
+ */
62
+ function getWorldPositionForBone(avatarScene, boneName) {
63
+ const result = new THREE.Vector3();
64
+ avatarScene.traverse((obj) => {
65
+ if (obj instanceof THREE.Bone && obj.name === boneName) {
66
+ obj.getWorldPosition(result);
67
+ }
68
+ });
69
+ return result;
70
+ }
71
+ exports.getWorldPositionForBone = getWorldPositionForBone;
72
+ // Legacy alias kept for call-site compatibility
73
+ exports.getWorldPositionForPlacement = getWorldPositionForBone;
74
+ function snapPlacementToNearestBone(avatarScene, worldPos) {
75
+ return findNearestBone(avatarScene, worldPos);
76
+ }
77
+ exports.snapPlacementToNearestBone = snapPlacementToNearestBone;
78
+ /**
79
+ * Hook that returns a snap function. Fires haptic feedback when a new bone
80
+ * is snapped to.
81
+ */
82
+ function useBoneSnap(avatarScene) {
83
+ return (0, react_1.useCallback)((worldPos) => {
84
+ if (!avatarScene)
85
+ return null;
86
+ const result = findNearestBone(avatarScene, worldPos);
87
+ if (result) {
88
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => null);
89
+ }
90
+ return result;
91
+ }, [avatarScene]);
92
+ }
93
+ exports.useBoneSnap = useBoneSnap;
@@ -1,5 +1,9 @@
1
1
  export { AvatarCanvas } from './AvatarCanvas';
2
+ export { AvatarEditor } from './AvatarEditor';
2
3
  export { AvatarModel } from './AvatarModel';
3
4
  export { RigidAccessory } from './RigidAccessory';
4
5
  export { SkinnedClothing } from './SkinnedClothing';
6
+ export { studioTheme, withAlpha } from './studioTheme';
7
+ export { useBoneSnap, findNearestBone, getWorldPositionForBone, getWorldPositionForPlacement, snapPlacementToNearestBone, HUMANOID_BONES } from './boneSnap';
5
8
  export type { AvatarEditorProps, AssetPlacement, EquippedAsset } from './types';
9
+ export type { BoneSnapResult, HumanoidBone } from './boneSnap';
@@ -1,11 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SkinnedClothing = exports.RigidAccessory = exports.AvatarModel = exports.AvatarCanvas = void 0;
3
+ exports.HUMANOID_BONES = exports.snapPlacementToNearestBone = exports.getWorldPositionForPlacement = exports.getWorldPositionForBone = exports.findNearestBone = exports.useBoneSnap = exports.withAlpha = exports.studioTheme = exports.SkinnedClothing = exports.RigidAccessory = exports.AvatarModel = exports.AvatarEditor = exports.AvatarCanvas = void 0;
4
4
  var AvatarCanvas_1 = require("./AvatarCanvas");
5
5
  Object.defineProperty(exports, "AvatarCanvas", { enumerable: true, get: function () { return AvatarCanvas_1.AvatarCanvas; } });
6
+ var AvatarEditor_1 = require("./AvatarEditor");
7
+ Object.defineProperty(exports, "AvatarEditor", { enumerable: true, get: function () { return AvatarEditor_1.AvatarEditor; } });
6
8
  var AvatarModel_1 = require("./AvatarModel");
7
9
  Object.defineProperty(exports, "AvatarModel", { enumerable: true, get: function () { return AvatarModel_1.AvatarModel; } });
8
10
  var RigidAccessory_1 = require("./RigidAccessory");
9
11
  Object.defineProperty(exports, "RigidAccessory", { enumerable: true, get: function () { return RigidAccessory_1.RigidAccessory; } });
10
12
  var SkinnedClothing_1 = require("./SkinnedClothing");
11
13
  Object.defineProperty(exports, "SkinnedClothing", { enumerable: true, get: function () { return SkinnedClothing_1.SkinnedClothing; } });
14
+ var studioTheme_1 = require("./studioTheme");
15
+ Object.defineProperty(exports, "studioTheme", { enumerable: true, get: function () { return studioTheme_1.studioTheme; } });
16
+ Object.defineProperty(exports, "withAlpha", { enumerable: true, get: function () { return studioTheme_1.withAlpha; } });
17
+ var boneSnap_1 = require("./boneSnap");
18
+ Object.defineProperty(exports, "useBoneSnap", { enumerable: true, get: function () { return boneSnap_1.useBoneSnap; } });
19
+ Object.defineProperty(exports, "findNearestBone", { enumerable: true, get: function () { return boneSnap_1.findNearestBone; } });
20
+ Object.defineProperty(exports, "getWorldPositionForBone", { enumerable: true, get: function () { return boneSnap_1.getWorldPositionForBone; } });
21
+ Object.defineProperty(exports, "getWorldPositionForPlacement", { enumerable: true, get: function () { return boneSnap_1.getWorldPositionForPlacement; } });
22
+ Object.defineProperty(exports, "snapPlacementToNearestBone", { enumerable: true, get: function () { return boneSnap_1.snapPlacementToNearestBone; } });
23
+ Object.defineProperty(exports, "HUMANOID_BONES", { enumerable: true, get: function () { return boneSnap_1.HUMANOID_BONES; } });
@@ -0,0 +1,86 @@
1
+ export declare const studioTheme: {
2
+ readonly colors: {
3
+ readonly background: "#111315";
4
+ readonly backgroundAlt: "#191d20";
5
+ readonly surface: "#1c2125";
6
+ readonly surfaceRaised: "#252b30";
7
+ readonly surfaceSoft: "#2b3238";
8
+ readonly border: "#394249";
9
+ readonly borderStrong: "#53606a";
10
+ readonly textPrimary: "#f6efe4";
11
+ readonly textSecondary: "#cabfaa";
12
+ readonly textMuted: "#8b847a";
13
+ readonly accent: "#ff7a59";
14
+ readonly accentStrong: "#ff9c72";
15
+ readonly accentMuted: "rgba(255, 122, 89, 0.16)";
16
+ readonly success: "#69c08a";
17
+ readonly danger: "#f07a7a";
18
+ readonly shadow: "rgba(0, 0, 0, 0.32)";
19
+ readonly haze: "rgba(255, 255, 255, 0.05)";
20
+ };
21
+ readonly spacing: {
22
+ readonly xs: 6;
23
+ readonly sm: 10;
24
+ readonly md: 16;
25
+ readonly lg: 22;
26
+ readonly xl: 28;
27
+ readonly xxl: 36;
28
+ };
29
+ readonly radius: {
30
+ readonly sm: 12;
31
+ readonly md: 18;
32
+ readonly lg: 24;
33
+ readonly pill: 999;
34
+ };
35
+ readonly shadow: {
36
+ readonly glow: {
37
+ readonly shadowColor: "#000000";
38
+ readonly shadowOffset: {
39
+ readonly width: 0;
40
+ readonly height: 10;
41
+ };
42
+ readonly shadowOpacity: 0.24;
43
+ readonly shadowRadius: 24;
44
+ readonly elevation: 10;
45
+ };
46
+ readonly card: {
47
+ readonly shadowColor: "#000000";
48
+ readonly shadowOffset: {
49
+ readonly width: 0;
50
+ readonly height: 8;
51
+ };
52
+ readonly shadowOpacity: 0.16;
53
+ readonly shadowRadius: 18;
54
+ readonly elevation: 6;
55
+ };
56
+ };
57
+ readonly type: {
58
+ readonly eyebrow: {
59
+ readonly fontSize: 12;
60
+ readonly fontWeight: "700";
61
+ readonly letterSpacing: 1.6;
62
+ readonly textTransform: "uppercase";
63
+ };
64
+ readonly sectionTitle: {
65
+ readonly fontSize: 24;
66
+ readonly fontWeight: "700";
67
+ readonly letterSpacing: 0.2;
68
+ };
69
+ readonly body: {
70
+ readonly fontSize: 15;
71
+ readonly lineHeight: 22;
72
+ readonly fontWeight: "500";
73
+ };
74
+ readonly bodySmall: {
75
+ readonly fontSize: 13;
76
+ readonly lineHeight: 18;
77
+ readonly fontWeight: "500";
78
+ };
79
+ readonly button: {
80
+ readonly fontSize: 15;
81
+ readonly fontWeight: "700";
82
+ readonly letterSpacing: 0.2;
83
+ };
84
+ };
85
+ };
86
+ export declare function withAlpha(hexOrRgb: string, alphaHex: string): string;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withAlpha = exports.studioTheme = void 0;
4
+ exports.studioTheme = {
5
+ colors: {
6
+ background: '#111315',
7
+ backgroundAlt: '#191d20',
8
+ surface: '#1c2125',
9
+ surfaceRaised: '#252b30',
10
+ surfaceSoft: '#2b3238',
11
+ border: '#394249',
12
+ borderStrong: '#53606a',
13
+ textPrimary: '#f6efe4',
14
+ textSecondary: '#cabfaa',
15
+ textMuted: '#8b847a',
16
+ accent: '#ff7a59',
17
+ accentStrong: '#ff9c72',
18
+ accentMuted: 'rgba(255, 122, 89, 0.16)',
19
+ success: '#69c08a',
20
+ danger: '#f07a7a',
21
+ shadow: 'rgba(0, 0, 0, 0.32)',
22
+ haze: 'rgba(255, 255, 255, 0.05)',
23
+ },
24
+ spacing: {
25
+ xs: 6,
26
+ sm: 10,
27
+ md: 16,
28
+ lg: 22,
29
+ xl: 28,
30
+ xxl: 36,
31
+ },
32
+ radius: {
33
+ sm: 12,
34
+ md: 18,
35
+ lg: 24,
36
+ pill: 999,
37
+ },
38
+ shadow: {
39
+ glow: {
40
+ shadowColor: '#000000',
41
+ shadowOffset: { width: 0, height: 10 },
42
+ shadowOpacity: 0.24,
43
+ shadowRadius: 24,
44
+ elevation: 10,
45
+ },
46
+ card: {
47
+ shadowColor: '#000000',
48
+ shadowOffset: { width: 0, height: 8 },
49
+ shadowOpacity: 0.16,
50
+ shadowRadius: 18,
51
+ elevation: 6,
52
+ },
53
+ },
54
+ type: {
55
+ eyebrow: {
56
+ fontSize: 12,
57
+ fontWeight: '700',
58
+ letterSpacing: 1.6,
59
+ textTransform: 'uppercase',
60
+ },
61
+ sectionTitle: {
62
+ fontSize: 24,
63
+ fontWeight: '700',
64
+ letterSpacing: 0.2,
65
+ },
66
+ body: {
67
+ fontSize: 15,
68
+ lineHeight: 22,
69
+ fontWeight: '500',
70
+ },
71
+ bodySmall: {
72
+ fontSize: 13,
73
+ lineHeight: 18,
74
+ fontWeight: '500',
75
+ },
76
+ button: {
77
+ fontSize: 15,
78
+ fontWeight: '700',
79
+ letterSpacing: 0.2,
80
+ },
81
+ },
82
+ };
83
+ function withAlpha(hexOrRgb, alphaHex) {
84
+ if (hexOrRgb.startsWith('#')) {
85
+ return `${hexOrRgb}${alphaHex}`;
86
+ }
87
+ return hexOrRgb;
88
+ }
89
+ exports.withAlpha = withAlpha;
@@ -1,5 +1,4 @@
1
1
  import type { AvatarAppearance } from '../appearance';
2
- import type React from 'react';
3
2
  export interface AssetPlacement {
4
3
  bone: string;
5
4
  position: [number, number, number];
@@ -15,13 +14,28 @@ export interface EquippedAsset {
15
14
  offset_position?: [number, number, number];
16
15
  offset_rotation?: [number, number, number];
17
16
  }
17
+ /**
18
+ * Shared props understood by both AvatarCanvas (web) and AvatarEditor (native).
19
+ * Web-only fields (className, appearance) are optional and ignored on native.
20
+ * Native-only fields (activeAssetId, onActiveAssetChange, onDone, skinColor)
21
+ * are optional and ignored on web.
22
+ *
23
+ * `style` is typed as `any` to avoid importing react-native into a file
24
+ * compiled by plain tsc. Call-sites should cast to the appropriate type.
25
+ */
18
26
  export interface AvatarEditorProps {
19
27
  avatarUrl: string;
20
- appearance?: AvatarAppearance;
21
28
  equipped?: EquippedAsset[];
22
29
  placements?: Record<string, AssetPlacement>;
23
- editingAssetId?: string | null;
24
30
  onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
31
+ /** @web which asset is currently being placed */
32
+ editingAssetId?: string | null;
33
+ appearance?: AvatarAppearance;
25
34
  className?: string;
26
- style?: React.CSSProperties;
35
+ style?: any;
36
+ /** @native which asset is currently being placed (maps to editingAssetId on web) */
37
+ activeAssetId?: string | null;
38
+ onActiveAssetChange?: (id: string | null) => void;
39
+ onDone?: () => void;
40
+ skinColor?: string;
27
41
  }
package/dist/html.js CHANGED
@@ -215,7 +215,7 @@ async function loadStaticFallback(loadedAvatarUrl) {
215
215
  renderer.setAnimationLoop(() => {
216
216
  const delta = clock.getDelta();
217
217
  if (staticMixer) staticMixer.update(delta);
218
- tickVisemeDecay();
218
+ tickVisemeDecay(delta);
219
219
  applyMotionBones();
220
220
  controls.update();
221
221
  renderer.render(scene, camera);
@@ -322,15 +322,15 @@ async function init() {
322
322
  }
323
323
  };
324
324
  const headaudioUpdate = headaudio.update.bind(headaudio);
325
- head.opt.update = (dt) => { headaudioUpdate(dt); tickVisemeDecay(); applyMotionBones(); };
325
+ head.opt.update = (dt) => { headaudioUpdate(dt); tickVisemeDecay(dt); applyMotionBones(); };
326
326
  log('HeadAudio ready (phoneme lip sync)');
327
327
  } else {
328
328
  log('HeadAudio skipped: AudioWorklet not supported in this WebView. Use sendViseme() from native TTS callbacks.');
329
- head.opt.update = () => { tickVisemeDecay(); applyMotionBones(); };
329
+ head.opt.update = (dt) => { tickVisemeDecay(dt); applyMotionBones(); };
330
330
  }
331
331
  } catch (err) {
332
332
  log('HeadAudio unavailable, viseme/amplitude fallback active: ' + err.message);
333
- head.opt.update = () => { tickVisemeDecay(); applyMotionBones(); };
333
+ head.opt.update = (dt) => { tickVisemeDecay(dt); applyMotionBones(); };
334
334
  }
335
335
 
336
336
  startAudioInterception();
@@ -551,7 +551,7 @@ function clearScheduledVisemes() {
551
551
  for (const key of Object.keys(visemeState)) visemeState[key] = 0;
552
552
  }
553
553
 
554
- function tickVisemeDecay() {
554
+ function tickVisemeDecay(deltaSeconds?: number) {
555
555
  if (!visemeMorphCache) return;
556
556
 
557
557
  const isScheduled = Date.now() < visemeModeUntil;
@@ -566,7 +566,12 @@ function tickVisemeDecay() {
566
566
  // Only decay if we aren't in the middle of a viseme schedule.
567
567
  // Scheduled visemes are cleared manually by timeouts.
568
568
  if (!isScheduled) {
569
- const decayed = weight * 0.82;
569
+ // Time-delta-aware decay: maintain consistent feel regardless of frame rate.
570
+ // Base rate is calibrated for 60 fps (0.82 per frame = ~12 frames to 10%).
571
+ // pow(0.82, delta*60) is frame-rate independent.
572
+ const dt = deltaSeconds ?? (1 / 60);
573
+ const decayFactor = Math.pow(0.82, dt * 60);
574
+ const decayed = weight * decayFactor;
570
575
  visemeState[key] = decayed < 0.01 ? 0 : decayed;
571
576
  }
572
577
 
@@ -609,12 +614,17 @@ function scheduleVisemes(schedule) {
609
614
  if (!schedule || !Array.isArray(schedule.cues) || schedule.cues.length === 0) return;
610
615
 
611
616
  const myScheduleId = activeVisemeScheduleId;
612
- // The startedAtMs anchor is set when tts_request_start arrives on the data
613
- // channel. Audio doesn't play until ~300ms later (LiveKit audio buffering).
614
- // TTS generation delay is no longer included here since visemes now arrive
615
- // via direct ref call before the React render cycle.
616
- const AUDIO_PIPELINE_DELAY_MS = 300;
617
- let startedAt = (schedule.startedAtMs || Date.now()) + AUDIO_PIPELINE_DELAY_MS;
617
+ // Anchor selection priority:
618
+ // 1. audioStartedAtMs stamped when audio actually begins playing (most accurate)
619
+ // 2. startedAtMs + pipeline delay stamped at TTS request fire time
620
+ //
621
+ // AUDIO_PIPELINE_DELAY_MS compensates for the gap between "TTS request fired"
622
+ // and "audio audible from speaker". Qwen3-TTS on local/tailnet is ~80–150 ms;
623
+ // LiveKit adds ~50–80 ms of jitter buffer on top. 150 ms is conservative but
624
+ // avoids the mouth running ahead of audio on fast connections.
625
+ const AUDIO_PIPELINE_DELAY_MS = 150;
626
+ let startedAt = schedule.audioStartedAtMs
627
+ ?? ((schedule.startedAtMs || Date.now()) + AUDIO_PIPELINE_DELAY_MS);
618
628
  const durationMs = schedule.durationMs || 0;
619
629
  const now = Date.now();
620
630
  let elapsedMs = Math.max(0, now - startedAt);
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Resolves a local Expo asset module into a file:// URI for use with
3
- * react-native-filament's useModel(). Never base64-encodes — Filament reads
4
- * file:// directly on both iOS and Android.
3
+ * WgpuAvatar model loading.
5
4
  */
6
- export declare function resolveFilamentAssetUri(module: unknown): Promise<string | null>;
5
+ export declare function resolveLocalAssetUri(module: unknown): Promise<string | null>;
7
6
  /**
8
7
  * Resolves a local Expo asset module (from require()) into a usable URL string.
9
8
  * On web, returns an absolute URL.
@@ -1,15 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveLocalAssetUrl = exports.resolveFilamentAssetUri = void 0;
3
+ exports.resolveLocalAssetUrl = exports.resolveLocalAssetUri = void 0;
4
4
  const expo_asset_1 = require("expo-asset");
5
5
  const react_native_1 = require("react-native");
6
6
  const expo_file_system_1 = require("expo-file-system");
7
7
  /**
8
8
  * Resolves a local Expo asset module into a file:// URI for use with
9
- * react-native-filament's useModel(). Never base64-encodes — Filament reads
10
- * file:// directly on both iOS and Android.
9
+ * WgpuAvatar model loading.
11
10
  */
12
- async function resolveFilamentAssetUri(module) {
11
+ async function resolveLocalAssetUri(module) {
13
12
  try {
14
13
  const asset = expo_asset_1.Asset.fromModule(module);
15
14
  await asset.downloadAsync();
@@ -17,11 +16,11 @@ async function resolveFilamentAssetUri(module) {
17
16
  return uri ?? null;
18
17
  }
19
18
  catch (e) {
20
- console.error('[AssetUtils] Failed to resolve Filament asset:', e);
19
+ console.error('[AssetUtils] Failed to resolve asset:', e);
21
20
  return null;
22
21
  }
23
22
  }
24
- exports.resolveFilamentAssetUri = resolveFilamentAssetUri;
23
+ exports.resolveLocalAssetUri = resolveLocalAssetUri;
25
24
  /**
26
25
  * Resolves a local Expo asset module (from require()) into a usable URL string.
27
26
  * On web, returns an absolute URL.
@@ -11,7 +11,7 @@ export interface WardrobeState {
11
11
  equipped: Record<string, WearableAsset>;
12
12
  /** assetId -> placement transform */
13
13
  placements: Record<string, AssetPlacement>;
14
- /** asset currently being positioned (Filament mode) */
14
+ /** asset currently being positioned (native mode) */
15
15
  activeAssetId: string | null;
16
16
  /** current gizmo operation mode */
17
17
  gizmoMode: GizmoMode;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import React from 'react';
15
15
  import { type StyleProp, type ViewStyle } from 'react-native';
16
+ import * as THREE from 'three';
16
17
  import type { TalkingHeadMood, TalkingHeadAccessory, TalkingHeadVisemeSchedule } from '../index';
17
18
  export interface WgpuAvatarRef {
18
19
  setMood: (mood: TalkingHeadMood) => void;
@@ -31,6 +32,8 @@ interface WgpuAvatarProps {
31
32
  accessories?: TalkingHeadAccessory[];
32
33
  onReady?: () => void;
33
34
  onError?: (message: string) => void;
35
+ /** Called once the R3F scene and camera are available (for editor raycasting). */
36
+ onSceneReady?: (scene: THREE.Scene, camera: THREE.Camera) => void;
34
37
  }
35
38
  export declare const WgpuAvatar: React.ForwardRefExoticComponent<WgpuAvatarProps & React.RefAttributes<WgpuAvatarRef>>;
36
39
  export {};
@@ -44,13 +44,25 @@ const THREE = __importStar(require("three"));
44
44
  const native_1 = require("@react-three/fiber/native");
45
45
  const native_2 = require("@react-three/drei/native");
46
46
  const morphTables_1 = require("./morphTables");
47
- const useAuthedFilamentUri_1 = require("./useAuthedFilamentUri");
47
+ const useAuthedModelUri_1 = require("./useAuthedModelUri");
48
48
  // ---------------------------------------------------------------------------
49
49
  // Camera defaults — match FilamentAvatar constants
50
50
  // ---------------------------------------------------------------------------
51
51
  const CAMERA_POSITION = [0, 1.52, 0.85];
52
52
  const CAMERA_TARGET = new THREE.Vector3(0, 1.52, 0);
53
- const CAMERA_FOV_FULL = 38; // rough Three.js FOV equiv to 50mm focal length
53
+ const CAMERA_FOV_FULL = 38;
54
+ // ---------------------------------------------------------------------------
55
+ // Scene/camera ref capture — runs inside Canvas so it can access R3F context
56
+ // ---------------------------------------------------------------------------
57
+ function SceneCapture({ onSceneReady }) {
58
+ const { scene, camera } = (0, native_1.useThree)();
59
+ (0, react_1.useEffect)(() => {
60
+ onSceneReady(scene, camera);
61
+ // Only fire once on mount — scene/camera identity is stable
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, []);
64
+ return null;
65
+ }
54
66
  function buildMorphIndex(mesh) {
55
67
  const map = new Map();
56
68
  const dict = mesh.morphTargetDictionary;
@@ -58,7 +70,7 @@ function buildMorphIndex(mesh) {
58
70
  return map;
59
71
  for (const [name, idx] of Object.entries(dict)) {
60
72
  map.set(name.toLowerCase(), idx);
61
- map.set(name, idx); // keep original casing too
73
+ map.set(name, idx);
62
74
  }
63
75
  return map;
64
76
  }
@@ -70,13 +82,12 @@ function resolveIndex(morphIndex, aliases) {
70
82
  }
71
83
  return undefined;
72
84
  }
73
- function AvatarScene({ uri, morphStateRef, fov, onReady, onError }) {
85
+ function AvatarScene({ uri, morphStateRef, fov, onReady, onError, onSceneReady }) {
74
86
  const { camera } = (0, native_1.useThree)();
75
87
  const gltf = (0, native_2.useGLTF)(uri);
76
88
  const headMeshRef = (0, react_1.useRef)(null);
77
89
  const morphIndexRef = (0, react_1.useRef)(new Map());
78
90
  const readyFiredRef = (0, react_1.useRef)(false);
79
- // Set up camera on mount
80
91
  (0, react_1.useEffect)(() => {
81
92
  if (!(camera instanceof THREE.PerspectiveCamera))
82
93
  return;
@@ -85,7 +96,6 @@ function AvatarScene({ uri, morphStateRef, fov, onReady, onError }) {
85
96
  camera.lookAt(CAMERA_TARGET);
86
97
  camera.updateProjectionMatrix();
87
98
  }, [camera, fov]);
88
- // Find the head/face mesh with morph targets after GLTF loads
89
99
  (0, react_1.useEffect)(() => {
90
100
  if (!gltf?.scene)
91
101
  return;
@@ -111,58 +121,43 @@ function AvatarScene({ uri, morphStateRef, fov, onReady, onError }) {
111
121
  onReady();
112
122
  }
113
123
  }, [gltf, onReady, onError]);
114
- // Per-frame morph weight application
115
124
  (0, native_1.useFrame)((_, delta) => {
116
125
  const mesh = headMeshRef.current;
117
126
  if (!mesh?.morphTargetInfluences)
118
127
  return;
119
128
  const state = morphStateRef.current;
120
- const alpha = Math.min(1, state.alpha * delta * 60); // normalize to 60fps
121
- // Merge mood base + viseme target
129
+ const alpha = Math.min(1, state.alpha * delta * 60);
122
130
  const combined = { ...state.moodBase };
123
131
  for (const [name, w] of Object.entries(state.visemeTarget)) {
124
132
  combined[name] = Math.max(combined[name] ?? 0, w);
125
133
  }
126
- // Smooth current toward combined
127
- const allNames = new Set([
128
- ...Object.keys(state.current),
129
- ...Object.keys(combined),
130
- ]);
134
+ const allNames = new Set([...Object.keys(state.current), ...Object.keys(combined)]);
131
135
  for (const name of allNames) {
132
136
  const target = combined[name] ?? 0;
133
137
  const cur = state.current[name] ?? 0;
134
138
  const next = cur + (target - cur) * alpha;
135
139
  state.current[name] = next;
136
140
  const idx = resolveIndex(morphIndexRef.current, [name]);
137
- if (idx !== undefined) {
141
+ if (idx !== undefined)
138
142
  mesh.morphTargetInfluences[idx] = next;
139
- }
140
143
  }
141
- // Decay viseme layer
142
144
  for (const name of Object.keys(state.visemeTarget)) {
143
- state.visemeTarget[name] *= Math.pow(0.1, delta); // ~10x decay/s
144
- if (state.visemeTarget[name] < 0.001) {
145
+ state.visemeTarget[name] *= Math.pow(0.1, delta);
146
+ if (state.visemeTarget[name] < 0.001)
145
147
  delete state.visemeTarget[name];
146
- }
147
148
  }
148
149
  });
149
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.6 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [1, 3, 2], intensity: 1.2, castShadow: false }), (0, jsx_runtime_1.jsx)("primitive", { object: gltf.scene })] }));
150
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [onSceneReady && (0, jsx_runtime_1.jsx)(SceneCapture, { onSceneReady: onSceneReady }), (0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.6 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [1, 3, 2], intensity: 1.2, castShadow: false }), (0, jsx_runtime_1.jsx)("primitive", { object: gltf.scene })] }));
150
151
  }
151
152
  // ---------------------------------------------------------------------------
152
- // Error boundary so GLTF load failures surface to onError
153
+ // Error boundary
153
154
  // ---------------------------------------------------------------------------
154
155
  class GltfErrorBoundary extends react_1.default.Component {
155
- constructor(props) {
156
- super(props);
157
- this.state = { hasError: false };
158
- }
156
+ constructor(props) { super(props); this.state = { hasError: false }; }
159
157
  static getDerivedStateFromError() { return { hasError: true }; }
160
158
  componentDidCatch(err) { this.props.onError(err.message); }
161
- render() {
162
- if (this.state.hasError)
163
- return null;
164
- return this.props.children;
165
- }
159
+ render() { if (this.state.hasError)
160
+ return null; return this.props.children; }
166
161
  }
167
162
  // ---------------------------------------------------------------------------
168
163
  // Viseme schedule player
@@ -172,41 +167,30 @@ function applyVisemeCue(morphState, visemeKey) {
172
167
  if (!aliases)
173
168
  return;
174
169
  const w = morphTables_1.VISEME_WEIGHTS[visemeKey] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
175
- for (const alias of aliases) {
170
+ for (const alias of aliases)
176
171
  morphState.visemeTarget[alias] = w;
177
- }
178
172
  }
179
173
  // ---------------------------------------------------------------------------
180
174
  // Main exported component
181
175
  // ---------------------------------------------------------------------------
182
- exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, mood = 'neutral', onReady, onError, }, ref) => {
183
- // Resolve authenticated file URI (same logic as FilamentAvatar)
176
+ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, mood = 'neutral', onReady, onError, onSceneReady }, ref) => {
184
177
  const remoteUrl = avatarUrl && (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://'))
185
- ? avatarUrl
186
- : null;
187
- const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(remoteUrl);
188
- // Lock-in URI on first resolve — never change mid-session
178
+ ? avatarUrl : null;
179
+ const fileResult = (0, useAuthedModelUri_1.useAuthedModelUri)(remoteUrl);
189
180
  const lockedUriRef = (0, react_1.useRef)(null);
190
181
  const currentUri = fileResult?.uri ?? null;
191
- if (lockedUriRef.current === null && currentUri) {
182
+ if (lockedUriRef.current === null && currentUri)
192
183
  lockedUriRef.current = currentUri;
193
- }
194
184
  const localUri = lockedUriRef.current;
195
185
  const fov = focalLength
196
186
  ? Math.round(2 * Math.atan(21.634 / focalLength) * (180 / Math.PI))
197
187
  : CAMERA_FOV_FULL;
198
- // Shared morph state — mutated directly in useFrame, never causes re-renders
199
188
  const morphStateRef = (0, react_1.useRef)({
200
- current: {},
201
- visemeTarget: {},
202
- moodBase: {},
203
- alpha: 0.18,
189
+ current: {}, visemeTarget: {}, moodBase: {}, alpha: 0.18,
204
190
  });
205
- // Update mood baseline when mood prop changes
206
191
  (0, react_1.useEffect)(() => {
207
192
  morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[mood] ?? {}) };
208
193
  }, [mood]);
209
- // Pending viseme schedule
210
194
  const scheduleRef = (0, react_1.useRef)(null);
211
195
  const scheduleTimersRef = (0, react_1.useRef)([]);
212
196
  const [isReady, setIsReady] = (0, react_1.useState)(false);
@@ -215,21 +199,6 @@ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, m
215
199
  clearTimeout(t);
216
200
  scheduleTimersRef.current = [];
217
201
  }, []);
218
- const handleReady = (0, react_1.useCallback)(() => {
219
- setIsReady(true);
220
- onReady?.();
221
- // Flush any pending schedule
222
- if (scheduleRef.current) {
223
- const s = scheduleRef.current;
224
- scheduleRef.current = null;
225
- applySchedule(s);
226
- }
227
- // eslint-disable-next-line react-hooks/exhaustive-deps
228
- }, [onReady]);
229
- const handleError = (0, react_1.useCallback)((msg) => {
230
- console.warn('[WgpuAvatar] GLTF error:', msg);
231
- onError?.(msg);
232
- }, [onError]);
233
202
  const applySchedule = (0, react_1.useCallback)((schedule) => {
234
203
  clearScheduleTimers();
235
204
  const now = Date.now();
@@ -238,39 +207,41 @@ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, m
238
207
  for (const cue of schedule.cues) {
239
208
  const delay = cue.startMs - offset;
240
209
  if (delay < -200)
241
- continue; // already expired
242
- const rhubarbKey = cue.viseme;
243
- const visemeKey = morphTables_1.RHUBARB_TO_VISEME[rhubarbKey] ?? rhubarbKey;
210
+ continue;
211
+ const visemeKey = morphTables_1.RHUBARB_TO_VISEME[cue.viseme] ?? cue.viseme;
244
212
  const t = setTimeout(() => {
245
213
  applyVisemeCue(morphStateRef.current, visemeKey);
246
214
  }, Math.max(0, delay));
247
215
  scheduleTimersRef.current.push(t);
248
216
  }
249
217
  }, [clearScheduleTimers]);
250
- // Cleanup on unmount
218
+ const handleReady = (0, react_1.useCallback)(() => {
219
+ setIsReady(true);
220
+ onReady?.();
221
+ if (scheduleRef.current) {
222
+ const s = scheduleRef.current;
223
+ scheduleRef.current = null;
224
+ applySchedule(s);
225
+ }
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
227
+ }, [onReady]);
228
+ const handleError = (0, react_1.useCallback)((msg) => {
229
+ console.warn('[WgpuAvatar] GLTF error:', msg);
230
+ onError?.(msg);
231
+ }, [onError]);
251
232
  (0, react_1.useEffect)(() => () => clearScheduleTimers(), [clearScheduleTimers]);
252
233
  (0, react_1.useImperativeHandle)(ref, () => ({
253
- setMood: (m) => {
254
- morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[m] ?? {}) };
255
- },
256
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
257
- sendAmplitude: (_amplitude) => {
258
- // Optional: could drive jaw open with amplitude
259
- // morphStateRef.current.visemeTarget['jawOpen'] = _amplitude * 0.3;
260
- },
234
+ setMood: (m) => { morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[m] ?? {}) }; },
235
+ sendAmplitude: (_amplitude) => { },
261
236
  sendViseme: (viseme, weight) => {
262
237
  const aliases = morphTables_1.VISEME_MORPH_ALIASES[viseme];
263
238
  if (!aliases)
264
239
  return;
265
240
  const w = weight ?? morphTables_1.VISEME_WEIGHTS[viseme] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
266
- for (const alias of aliases) {
241
+ for (const alias of aliases)
267
242
  morphStateRef.current.visemeTarget[alias] = w;
268
- }
269
- },
270
- clearVisemes: () => {
271
- clearScheduleTimers();
272
- morphStateRef.current.visemeTarget = {};
273
243
  },
244
+ clearVisemes: () => { clearScheduleTimers(); morphStateRef.current.visemeTarget = {}; },
274
245
  scheduleVisemes: (schedule) => {
275
246
  if (!isReady) {
276
247
  scheduleRef.current = schedule;
@@ -279,12 +250,9 @@ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, m
279
250
  applySchedule(schedule);
280
251
  },
281
252
  }), [isReady, applySchedule, clearScheduleTimers]);
282
- if (!localUri) {
253
+ if (!localUri)
283
254
  return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.placeholder, style] });
284
- }
285
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.container, style], children: (0, jsx_runtime_1.jsx)(native_1.Canvas, { style: react_native_1.StyleSheet.absoluteFill, camera: { fov, position: CAMERA_POSITION, near: 0.01, far: 100 }, gl: { antialias: true, alpha: false }, onCreated: ({ gl }) => {
286
- gl.setClearColor(new THREE.Color('#1a1a2e'));
287
- }, children: (0, jsx_runtime_1.jsx)(GltfErrorBoundary, { onError: handleError, children: (0, jsx_runtime_1.jsx)(AvatarScene, { uri: localUri, morphStateRef: morphStateRef, fov: fov, onReady: handleReady, onError: handleError }) }) }) }));
255
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.container, style], children: (0, jsx_runtime_1.jsx)(native_1.Canvas, { style: react_native_1.StyleSheet.absoluteFill, camera: { fov, position: CAMERA_POSITION, near: 0.01, far: 100 }, gl: { antialias: true, alpha: false }, onCreated: ({ gl }) => { gl.setClearColor(new THREE.Color('#1a1a2e')); }, children: (0, jsx_runtime_1.jsx)(GltfErrorBoundary, { onError: handleError, children: (0, jsx_runtime_1.jsx)(AvatarScene, { uri: localUri, morphStateRef: morphStateRef, fov: fov, onReady: handleReady, onError: handleError, onSceneReady: onSceneReady }) }) }) }));
288
256
  });
289
257
  exports.WgpuAvatar.displayName = 'WgpuAvatar';
290
258
  const styles = react_native_1.StyleSheet.create({
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  // ---------------------------------------------------------------------------
3
- // Morph target tables for Filament avatar viseme/mood rendering
3
+ // Morph target tables for avatar viseme/mood rendering
4
4
  // ---------------------------------------------------------------------------
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.MOOD_MORPHS = exports.DEFAULT_VISEME_WEIGHT = exports.VISEME_WEIGHTS = exports.VISEME_MORPH_ALIASES = exports.RHUBARB_TO_VISEME = void 0;
@@ -0,0 +1,11 @@
1
+ export type AuthedFileResult = {
2
+ uri: string;
3
+ size: number;
4
+ } | null;
5
+ /**
6
+ * Downloads a remote URL (with Bearer auth) to the local cache and returns a
7
+ * `file://` URI suitable for WgpuAvatar model source.
8
+ * The native model fetcher doesn't send auth headers, so we pre-fetch here.
9
+ * Also returns the file size in bytes for GPU memory budgeting.
10
+ */
11
+ export declare function useAuthedModelUri(remoteUrl: string | null): AuthedFileResult;
@@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.useAuthedFilamentUri = void 0;
26
+ exports.useAuthedModelUri = void 0;
27
27
  const react_1 = require("react");
28
28
  const FileSystem = __importStar(require("expo-file-system/legacy"));
29
29
  const studioApi_1 = require("../api/studioApi");
@@ -31,11 +31,11 @@ const studioApi_1 = require("../api/studioApi");
31
31
  const failedUrls = new Set();
32
32
  /**
33
33
  * Downloads a remote URL (with Bearer auth) to the local cache and returns a
34
- * `file://` URI suitable for react-native-filament's <Model> source prop.
35
- * The native Filament fetcher doesn't send auth headers, so we pre-fetch here.
34
+ * `file://` URI suitable for WgpuAvatar model source.
35
+ * The native model fetcher doesn't send auth headers, so we pre-fetch here.
36
36
  * Also returns the file size in bytes for GPU memory budgeting.
37
37
  */
38
- function useAuthedFilamentUri(remoteUrl) {
38
+ function useAuthedModelUri(remoteUrl) {
39
39
  const [result, setResult] = (0, react_1.useState)(null);
40
40
  (0, react_1.useEffect)(() => {
41
41
  if (!remoteUrl) {
@@ -57,7 +57,7 @@ function useAuthedFilamentUri(remoteUrl) {
57
57
  // Strip query params (access_token changes per session) so the same GLB is cached across sessions
58
58
  const urlWithoutQuery = remoteUrl.split('?')[0];
59
59
  const key = urlWithoutQuery.replace(/[^a-zA-Z0-9]/g, '_').slice(-100);
60
- const dir = `${FileSystem.cacheDirectory}filament/`;
60
+ const dir = `${FileSystem.cacheDirectory}model/`;
61
61
  // Append .glb so native loaders can identify the format
62
62
  const localPath = `${dir}${key}.glb`;
63
63
  await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
@@ -85,7 +85,7 @@ function useAuthedFilamentUri(remoteUrl) {
85
85
  const downloaded = await FileSystem.getInfoAsync(dlResult.uri);
86
86
  const downloadedSize = downloaded.size;
87
87
  if (!downloaded.exists || !downloadedSize || downloadedSize < 1000) {
88
- console.error('[useAuthedFilamentUri] Download too small (' + (downloadedSize ?? 0) + 'B), likely an error response');
88
+ console.error('[useAuthedModelUri] Download too small (' + (downloadedSize ?? 0) + 'B), likely an error response');
89
89
  failedUrls.add(remoteUrl);
90
90
  await FileSystem.deleteAsync(dlResult.uri);
91
91
  return;
@@ -96,7 +96,7 @@ function useAuthedFilamentUri(remoteUrl) {
96
96
  position: 0,
97
97
  });
98
98
  if (dlHeader !== 'Z2xURg==') {
99
- console.error('[useAuthedFilamentUri] Downloaded file is not a valid GLB (bad magic bytes)');
99
+ console.error('[useAuthedModelUri] Downloaded file is not a valid GLB (bad magic bytes)');
100
100
  failedUrls.add(remoteUrl);
101
101
  await FileSystem.deleteAsync(dlResult.uri);
102
102
  return;
@@ -105,7 +105,7 @@ function useAuthedFilamentUri(remoteUrl) {
105
105
  setResult({ uri: dlResult.uri, size: downloadedSize });
106
106
  }
107
107
  catch (e) {
108
- console.error('[useAuthedFilamentUri] Failed to download:', remoteUrl.slice(-60), e);
108
+ console.error('[useAuthedModelUri] Failed to download:', remoteUrl.slice(-60), e);
109
109
  }
110
110
  })();
111
111
  return () => {
@@ -114,4 +114,4 @@ function useAuthedFilamentUri(remoteUrl) {
114
114
  }, [remoteUrl]);
115
115
  return result;
116
116
  }
117
- exports.useAuthedFilamentUri = useAuthedFilamentUri;
117
+ exports.useAuthedModelUri = useAuthedModelUri;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-head-studio",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
5
5
  "main": "dist/index.web.js",
6
6
  "browser": "dist/index.web.js",
@@ -94,6 +94,7 @@
94
94
  "sideEffects": false,
95
95
  "dependencies": {
96
96
  "@mediapipe/tasks-vision": "^0.10.34",
97
+ "expo-gl": "^55.0.10",
97
98
  "zustand": "^5.0.12"
98
99
  },
99
100
  "peerDependencies": {
@@ -1,11 +0,0 @@
1
- export type AuthedFileResult = {
2
- uri: string;
3
- size: number;
4
- } | null;
5
- /**
6
- * Downloads a remote URL (with Bearer auth) to the local cache and returns a
7
- * `file://` URI suitable for react-native-filament's <Model> source prop.
8
- * The native Filament fetcher doesn't send auth headers, so we pre-fetch here.
9
- * Also returns the file size in bytes for GPU memory budgeting.
10
- */
11
- export declare function useAuthedFilamentUri(remoteUrl: string | null): AuthedFileResult;