talking-head-studio 0.3.9 → 0.4.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.
@@ -11,8 +11,8 @@
11
11
  import React from 'react';
12
12
  import { ViewStyle, StyleProp } from 'react-native';
13
13
  import type { TalkingHeadViseme, TalkingHeadVisemeSchedule, TalkingHeadAccessory, TalkingHeadMood } from '../TalkingHead';
14
- export declare const CAMERA_FOCAL_FULL = 28;
15
- export declare const CAMERA_FOCAL_PIP = 50;
14
+ export declare const CAMERA_FOCAL_FULL = 50;
15
+ export declare const CAMERA_FOCAL_PIP = 85;
16
16
  export interface FilamentAvatarRef {
17
17
  sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
18
18
  scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
@@ -40,6 +40,9 @@ const jsx_runtime_1 = require("react/jsx-runtime");
40
40
  const react_1 = __importStar(require("react"));
41
41
  const react_native_1 = require("react-native");
42
42
  const react_native_filament_1 = require("react-native-filament");
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-require-imports
44
+ const { Skybox } = require('react-native-filament');
45
+ const expo_asset_1 = require("expo-asset");
43
46
  const useAuthedFilamentUri_1 = require("./useAuthedFilamentUri");
44
47
  const faceSqueezeAssets_1 = require("./faceSqueezeAssets");
45
48
  const morphTables_1 = require("./morphTables");
@@ -91,14 +94,14 @@ function pickIdleEmotion() {
91
94
  // Camera presets — tight head framing matching TalkingHead cameraView='head'
92
95
  // Avatar head is at approximately y=1.62 in Ready Player Me scale.
93
96
  // ---------------------------------------------------------------------------
94
- // z=1.4 gives head+shoulders framing at 50mm; z=1.05 was too tight (face only).
95
- const CAMERA_POSITION = [0, 1.55, 1.4];
96
- const CAMERA_TARGET = [0, 1.55, 0];
97
+ // Camera sits ~1.0m back, targeting the face for a tight crop.
98
+ const CAMERA_POSITION = [0, 1.52, 0.85];
99
+ const CAMERA_TARGET = [0, 1.52, 0];
97
100
  const CAMERA_UP = [0, 1, 0];
98
- // Full/assistant view: 50mm is a natural portrait focal length head is not distorted.
101
+ // 50mm gives natural portrait framing at 1.0m distance.
99
102
  // Pip: 85mm telephoto for a tight face crop in the small bubble.
100
- exports.CAMERA_FOCAL_FULL = 28;
101
- exports.CAMERA_FOCAL_PIP = 50;
103
+ exports.CAMERA_FOCAL_FULL = 50;
104
+ exports.CAMERA_FOCAL_PIP = 85;
102
105
  // ---------------------------------------------------------------------------
103
106
  // Idle micro-expression cycles (MotionEngine-style procedural layer)
104
107
  // Runs at ~60ms ticks, drives subtle head/face movement independent of speech
@@ -137,11 +140,15 @@ function buildVisemeMorphCache(dispatchMap) {
137
140
  }
138
141
  function FilamentAvatarInner({ localUri, mood: initialMood = 'neutral', hairColor: initialHairColor, skinColor: initialSkinColor, eyeColor: initialEyeColor, onReady, onError: _onError, // eslint-disable-line @typescript-eslint/no-unused-vars
139
142
  innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
140
- // localUri is either a file:// string (remote download) or a require() number (bundled fallback).
141
- // useModel accepts both as BufferSource (number | { uri: string }) cast to any since
142
- // tsconfig resolves to an older type declaration that only shows { uri: string }.
143
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
- const model = (0, react_native_filament_1.useModel)(typeof localUri === 'number' ? localUri : { uri: localUri });
143
+ // Memoize source object to avoid new reference on every render useModel reload → SIGSEGV.
144
+ // When localUri is empty (fallback not yet resolved) we still pass a source so
145
+ // FilamentAvatarInner stays mounted and FilamentView's SwapChain stays alive.
146
+ // useModel will fail gracefully for the empty string; ModelRenderer is gated on state==='loaded'.
147
+ const modelSource = react_1.default.useMemo(() => (localUri ? { uri: localUri } : null), [localUri]);
148
+ (0, react_1.useEffect)(() => {
149
+ console.log('[FilamentAvatar] useModel source:', localUri || '(empty — waiting for fallback)');
150
+ }, [localUri]);
151
+ const model = (0, react_native_filament_1.useModel)(modelSource);
145
152
  const modelRef = (0, react_1.useRef)(model);
146
153
  modelRef.current = model;
147
154
  const { renderableManager } = (0, react_native_filament_1.useFilamentContext)();
@@ -325,6 +332,7 @@ innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
325
332
  prevDirtyRef.current = new Set();
326
333
  return;
327
334
  }
335
+ console.log('[FilamentAvatar] model loaded, boundingBox:', JSON.stringify(m.boundingBox));
328
336
  const newDispatchMap = new Map();
329
337
  const newWeightsMap = new Map();
330
338
  const newEntityById = new Map();
@@ -682,29 +690,61 @@ innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
682
690
  });
683
691
  return () => sub.remove();
684
692
  }, []);
685
- return ((0, jsx_runtime_1.jsxs)(react_native_filament_1.FilamentView, { ref: filamentViewRef, style: react_native_1.StyleSheet.absoluteFill, children: [(0, jsx_runtime_1.jsx)(react_native_filament_1.DefaultLight, {}), (0, jsx_runtime_1.jsx)(react_native_filament_1.Camera, { cameraPosition: CAMERA_POSITION, cameraTarget: CAMERA_TARGET, cameraUp: CAMERA_UP, aspect: aspect ?? 1, focalLengthInMillimeters: focalLength }), model.state === 'loaded' && ((0, jsx_runtime_1.jsx)(react_native_filament_1.ModelRenderer, { model: model, multiplyWithCurrentTransform: false }))] }));
693
+ return ((0, jsx_runtime_1.jsxs)(react_native_filament_1.FilamentView, { ref: filamentViewRef, style: react_native_1.StyleSheet.absoluteFill, enableTransparentRendering: false, children: [(0, jsx_runtime_1.jsx)(Skybox, { colorInHex: "#1a1a2e" }), (0, jsx_runtime_1.jsx)(react_native_filament_1.DefaultLight, {}), (0, jsx_runtime_1.jsx)(react_native_filament_1.Camera, { cameraPosition: CAMERA_POSITION, cameraTarget: CAMERA_TARGET, cameraUp: CAMERA_UP, aspect: aspect ?? 1, focalLengthInMillimeters: focalLength }), model.state === 'loaded' && ((0, jsx_runtime_1.jsx)(react_native_filament_1.ModelRenderer, { model: model }))] }));
686
694
  }
687
695
  // ---------------------------------------------------------------------------
688
696
  // Outer component — handles URL resolution + FilamentScene wrapper
689
697
  // ---------------------------------------------------------------------------
690
- // The fallback is the bundled require() number useModel accepts this directly
691
- // (BufferSource = number | { uri: string }), so no async asset resolution needed.
692
- exports.FilamentAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, aspect, focalLength, mood, hairColor, skinColor, eyeColor, accessories, onReady, onError }, ref) => {
693
- const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(avatarUrl ?? null);
698
+ // Resolve the fallback GLB to a file:// URI once at module load.
699
+ // useBuffer (inside useModel) resolves require() numbers via Image.resolveAssetSource
700
+ // which returns an http://localhost Metro URL in dev Filament's native loader can't
701
+ // fetch that. We must pre-resolve to a file:// path via expo-asset.
702
+ let _fallbackUri = null;
703
+ const _fallbackReady = (async () => {
704
+ const a = expo_asset_1.Asset.fromModule(faceSqueezeAssets_1.FACE_SQUEEZE_LOCAL_MODULE);
705
+ await a.downloadAsync();
706
+ _fallbackUri = a.localUri ?? a.uri ?? '';
707
+ return _fallbackUri;
708
+ })();
709
+ exports.FilamentAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, aspect: aspectProp, focalLength, mood, hairColor, skinColor, eyeColor, accessories, onReady, onError }, ref) => {
710
+ // Only download avatarUrl if it's a real remote studio URL (http/https).
711
+ const remoteUrl = (avatarUrl && (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')))
712
+ ? avatarUrl : null;
713
+ const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(remoteUrl);
694
714
  const currentUri = fileResult?.uri ?? null;
695
- // Stabilize localUri: once a valid remote file URI resolves, keep it until a new
696
- // one arrives. Falls back to the bundled require() number always available sync.
697
- const stableUriRef = react_1.default.useRef(null);
698
- if (currentUri)
699
- stableUriRef.current = currentUri;
700
- const localUri = stableUriRef.current ?? faceSqueezeAssets_1.FACE_SQUEEZE_LOCAL_MODULE;
715
+ // Fallback: resolved file:// URI of the bundled face-squeeze GLB.
716
+ // Starts null, populates async (fast already kicked off at module load).
717
+ const [fallbackUri, setFallbackUri] = react_1.default.useState(_fallbackUri);
718
+ react_1.default.useEffect(() => {
719
+ if (!_fallbackUri) {
720
+ _fallbackReady.then(setFallbackUri).catch(() => { });
721
+ }
722
+ }, []);
723
+ // Lock-in URI: once we pick a URI for this mount, NEVER change it.
724
+ // Switching from fallback → studio mid-session causes useModel to reload,
725
+ // which tears down GPU assets while FEngine::loop still holds references → SIGSEGV.
726
+ // Strategy: if the studio URI is ready before/at mount time, use it directly.
727
+ // If not, use the fallback and stay on it for the lifetime of this component.
728
+ const lockedUriRef = react_1.default.useRef(null);
729
+ if (lockedUriRef.current === null) {
730
+ // First render: pick whichever URI is available, preferring studio.
731
+ lockedUriRef.current = currentUri ?? fallbackUri;
732
+ }
733
+ const localUri = lockedUriRef.current;
734
+ // Compute aspect ratio from layout if not provided by caller.
735
+ const [measuredAspect, setMeasuredAspect] = react_1.default.useState(undefined);
736
+ const aspect = aspectProp ?? measuredAspect;
701
737
  // FilamentScene MUST stay mounted for the lifetime of this component.
702
738
  // Unmounting it disposes GPU assets on the JS thread while Filament's native
703
739
  // render thread (FEngine::loop) and surface thread (JNISurfaceTextu) still hold
704
740
  // references to those objects → SIGSEGV / corrupted-PC use-after-free crashes.
705
741
  // FilamentAvatarInner keeps FilamentView permanently mounted; only ModelRenderer
706
742
  // is conditional on the model being ready.
707
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.fill], children: (0, jsx_runtime_1.jsx)(react_native_filament_1.FilamentScene, { children: (0, jsx_runtime_1.jsx)(FilamentAvatarInner, { localUri: localUri, avatarUrl: avatarUrl, aspect: aspect, focalLength: focalLength, mood: mood, hairColor: hairColor, skinColor: skinColor, eyeColor: eyeColor, accessories: accessories, innerRef: ref, onReady: onReady, onError: onError }) }) }));
743
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.fill], onLayout: (e) => {
744
+ const { width, height } = e.nativeEvent.layout;
745
+ if (height > 0)
746
+ setMeasuredAspect(width / height);
747
+ }, children: (0, jsx_runtime_1.jsx)(react_native_filament_1.FilamentScene, { children: (0, jsx_runtime_1.jsx)(FilamentAvatarInner, { localUri: localUri ?? '', avatarUrl: avatarUrl, aspect: aspect, focalLength: focalLength, mood: mood, hairColor: hairColor, skinColor: skinColor, eyeColor: eyeColor, accessories: accessories, innerRef: ref, onReady: onReady, onError: onError }) }) }));
708
748
  });
709
749
  exports.FilamentAvatar.displayName = 'FilamentAvatar';
710
750
  const styles = react_native_1.StyleSheet.create({
@@ -0,0 +1,36 @@
1
+ /**
2
+ * WgpuAvatar — drop-in replacement for FilamentAvatar using react-three-fiber
3
+ * + react-native-wgpu (or expo-gl fallback). Exposes the same ref interface as
4
+ * FilamentAvatarRef so siteclaw can swap renderers without changing call sites.
5
+ *
6
+ * No SurfaceTexture, no choreographer, no JNI surface lifecycle bugs.
7
+ *
8
+ * Peer deps required by the host app:
9
+ * react-native-wgpu (or expo-gl for the expo-gl fallback canvas)
10
+ * @react-three/fiber >= 8
11
+ * @react-three/drei >= 9
12
+ * three >= 0.170
13
+ */
14
+ import React from 'react';
15
+ import { type StyleProp, type ViewStyle } from 'react-native';
16
+ import type { TalkingHeadMood, TalkingHeadAccessory, TalkingHeadVisemeSchedule } from '../index';
17
+ export interface WgpuAvatarRef {
18
+ setMood: (mood: TalkingHeadMood) => void;
19
+ sendAmplitude: (amplitude: number) => void;
20
+ sendViseme: (viseme: string, weight?: number) => void;
21
+ scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
22
+ clearVisemes: () => void;
23
+ }
24
+ interface WgpuAvatarProps {
25
+ style?: StyleProp<ViewStyle>;
26
+ avatarUrl: string | null;
27
+ authToken?: string;
28
+ aspect?: number;
29
+ focalLength?: number;
30
+ mood?: TalkingHeadMood;
31
+ accessories?: TalkingHeadAccessory[];
32
+ onReady?: () => void;
33
+ onError?: (message: string) => void;
34
+ }
35
+ export declare const WgpuAvatar: React.ForwardRefExoticComponent<WgpuAvatarProps & React.RefAttributes<WgpuAvatarRef>>;
36
+ export {};
@@ -0,0 +1,293 @@
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.WgpuAvatar = void 0;
27
+ const jsx_runtime_1 = require("react/jsx-runtime");
28
+ /**
29
+ * WgpuAvatar — drop-in replacement for FilamentAvatar using react-three-fiber
30
+ * + react-native-wgpu (or expo-gl fallback). Exposes the same ref interface as
31
+ * FilamentAvatarRef so siteclaw can swap renderers without changing call sites.
32
+ *
33
+ * No SurfaceTexture, no choreographer, no JNI surface lifecycle bugs.
34
+ *
35
+ * Peer deps required by the host app:
36
+ * react-native-wgpu (or expo-gl for the expo-gl fallback canvas)
37
+ * @react-three/fiber >= 8
38
+ * @react-three/drei >= 9
39
+ * three >= 0.170
40
+ */
41
+ const react_1 = __importStar(require("react"));
42
+ const react_native_1 = require("react-native");
43
+ const THREE = __importStar(require("three"));
44
+ const native_1 = require("@react-three/fiber/native");
45
+ const native_2 = require("@react-three/drei/native");
46
+ const morphTables_1 = require("../filament/morphTables");
47
+ const useAuthedFilamentUri_1 = require("../filament/useAuthedFilamentUri");
48
+ // ---------------------------------------------------------------------------
49
+ // Camera defaults — match FilamentAvatar constants
50
+ // ---------------------------------------------------------------------------
51
+ const CAMERA_POSITION = [0, 1.52, 0.85];
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
54
+ function buildMorphIndex(mesh) {
55
+ const map = new Map();
56
+ const dict = mesh.morphTargetDictionary;
57
+ if (!dict)
58
+ return map;
59
+ for (const [name, idx] of Object.entries(dict)) {
60
+ map.set(name.toLowerCase(), idx);
61
+ map.set(name, idx); // keep original casing too
62
+ }
63
+ return map;
64
+ }
65
+ function resolveIndex(morphIndex, aliases) {
66
+ for (const alias of aliases) {
67
+ const idx = morphIndex.get(alias) ?? morphIndex.get(alias.toLowerCase());
68
+ if (idx !== undefined)
69
+ return idx;
70
+ }
71
+ return undefined;
72
+ }
73
+ function AvatarScene({ uri, morphStateRef, fov, onReady, onError }) {
74
+ const { camera } = (0, native_1.useThree)();
75
+ const gltf = (0, native_2.useGLTF)(uri);
76
+ const headMeshRef = (0, react_1.useRef)(null);
77
+ const morphIndexRef = (0, react_1.useRef)(new Map());
78
+ const readyFiredRef = (0, react_1.useRef)(false);
79
+ // Set up camera on mount
80
+ (0, react_1.useEffect)(() => {
81
+ if (!(camera instanceof THREE.PerspectiveCamera))
82
+ return;
83
+ camera.fov = fov;
84
+ camera.position.set(...CAMERA_POSITION);
85
+ camera.lookAt(CAMERA_TARGET);
86
+ camera.updateProjectionMatrix();
87
+ }, [camera, fov]);
88
+ // Find the head/face mesh with morph targets after GLTF loads
89
+ (0, react_1.useEffect)(() => {
90
+ if (!gltf?.scene)
91
+ return;
92
+ let bestMesh = null;
93
+ let bestCount = 0;
94
+ gltf.scene.traverse((node) => {
95
+ if (!(node instanceof THREE.Mesh))
96
+ return;
97
+ const count = Object.keys(node.morphTargetDictionary ?? {}).length;
98
+ if (count > bestCount) {
99
+ bestCount = count;
100
+ bestMesh = node;
101
+ }
102
+ });
103
+ if (!bestMesh) {
104
+ onError('No mesh with morph targets found in GLB');
105
+ return;
106
+ }
107
+ headMeshRef.current = bestMesh;
108
+ morphIndexRef.current = buildMorphIndex(bestMesh);
109
+ if (!readyFiredRef.current) {
110
+ readyFiredRef.current = true;
111
+ onReady();
112
+ }
113
+ }, [gltf, onReady, onError]);
114
+ // Per-frame morph weight application
115
+ (0, native_1.useFrame)((_, delta) => {
116
+ const mesh = headMeshRef.current;
117
+ if (!mesh?.morphTargetInfluences)
118
+ return;
119
+ const state = morphStateRef.current;
120
+ const alpha = Math.min(1, state.alpha * delta * 60); // normalize to 60fps
121
+ // Merge mood base + viseme target
122
+ const combined = { ...state.moodBase };
123
+ for (const [name, w] of Object.entries(state.visemeTarget)) {
124
+ combined[name] = Math.max(combined[name] ?? 0, w);
125
+ }
126
+ // Smooth current toward combined
127
+ const allNames = new Set([
128
+ ...Object.keys(state.current),
129
+ ...Object.keys(combined),
130
+ ]);
131
+ for (const name of allNames) {
132
+ const target = combined[name] ?? 0;
133
+ const cur = state.current[name] ?? 0;
134
+ const next = cur + (target - cur) * alpha;
135
+ state.current[name] = next;
136
+ const idx = resolveIndex(morphIndexRef.current, [name]);
137
+ if (idx !== undefined) {
138
+ mesh.morphTargetInfluences[idx] = next;
139
+ }
140
+ }
141
+ // Decay viseme layer
142
+ 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
+ delete state.visemeTarget[name];
146
+ }
147
+ }
148
+ });
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
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Error boundary so GLTF load failures surface to onError
153
+ // ---------------------------------------------------------------------------
154
+ class GltfErrorBoundary extends react_1.default.Component {
155
+ constructor(props) {
156
+ super(props);
157
+ this.state = { hasError: false };
158
+ }
159
+ static getDerivedStateFromError() { return { hasError: true }; }
160
+ componentDidCatch(err) { this.props.onError(err.message); }
161
+ render() {
162
+ if (this.state.hasError)
163
+ return null;
164
+ return this.props.children;
165
+ }
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Viseme schedule player
169
+ // ---------------------------------------------------------------------------
170
+ function applyVisemeCue(morphState, visemeKey) {
171
+ const aliases = morphTables_1.VISEME_MORPH_ALIASES[visemeKey];
172
+ if (!aliases)
173
+ return;
174
+ const w = morphTables_1.VISEME_WEIGHTS[visemeKey] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
175
+ for (const alias of aliases) {
176
+ morphState.visemeTarget[alias] = w;
177
+ }
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Main exported component
181
+ // ---------------------------------------------------------------------------
182
+ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, mood = 'neutral', onReady, onError, }, ref) => {
183
+ // Resolve authenticated file URI (same logic as FilamentAvatar)
184
+ 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
189
+ const lockedUriRef = (0, react_1.useRef)(null);
190
+ const currentUri = fileResult?.uri ?? null;
191
+ if (lockedUriRef.current === null && currentUri) {
192
+ lockedUriRef.current = currentUri;
193
+ }
194
+ const localUri = lockedUriRef.current;
195
+ const fov = focalLength
196
+ ? Math.round(2 * Math.atan(21.634 / focalLength) * (180 / Math.PI))
197
+ : CAMERA_FOV_FULL;
198
+ // Shared morph state — mutated directly in useFrame, never causes re-renders
199
+ const morphStateRef = (0, react_1.useRef)({
200
+ current: {},
201
+ visemeTarget: {},
202
+ moodBase: {},
203
+ alpha: 0.18,
204
+ });
205
+ // Update mood baseline when mood prop changes
206
+ (0, react_1.useEffect)(() => {
207
+ morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[mood] ?? {}) };
208
+ }, [mood]);
209
+ // Pending viseme schedule
210
+ const scheduleRef = (0, react_1.useRef)(null);
211
+ const scheduleTimersRef = (0, react_1.useRef)([]);
212
+ const [isReady, setIsReady] = (0, react_1.useState)(false);
213
+ const clearScheduleTimers = (0, react_1.useCallback)(() => {
214
+ for (const t of scheduleTimersRef.current)
215
+ clearTimeout(t);
216
+ scheduleTimersRef.current = [];
217
+ }, []);
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
+ const applySchedule = (0, react_1.useCallback)((schedule) => {
234
+ clearScheduleTimers();
235
+ const now = Date.now();
236
+ const startedAt = schedule.startedAtMs ?? now;
237
+ const offset = now - startedAt;
238
+ for (const cue of schedule.cues) {
239
+ const delay = cue.startMs - offset;
240
+ if (delay < -200)
241
+ continue; // already expired
242
+ const rhubarbKey = cue.viseme;
243
+ const visemeKey = morphTables_1.RHUBARB_TO_VISEME[rhubarbKey] ?? rhubarbKey;
244
+ const t = setTimeout(() => {
245
+ applyVisemeCue(morphStateRef.current, visemeKey);
246
+ }, Math.max(0, delay));
247
+ scheduleTimersRef.current.push(t);
248
+ }
249
+ }, [clearScheduleTimers]);
250
+ // Cleanup on unmount
251
+ (0, react_1.useEffect)(() => () => clearScheduleTimers(), [clearScheduleTimers]);
252
+ (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
+ },
261
+ sendViseme: (viseme, weight) => {
262
+ const aliases = morphTables_1.VISEME_MORPH_ALIASES[viseme];
263
+ if (!aliases)
264
+ return;
265
+ const w = weight ?? morphTables_1.VISEME_WEIGHTS[viseme] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
266
+ for (const alias of aliases) {
267
+ morphStateRef.current.visemeTarget[alias] = w;
268
+ }
269
+ },
270
+ clearVisemes: () => {
271
+ clearScheduleTimers();
272
+ morphStateRef.current.visemeTarget = {};
273
+ },
274
+ scheduleVisemes: (schedule) => {
275
+ if (!isReady) {
276
+ scheduleRef.current = schedule;
277
+ return;
278
+ }
279
+ applySchedule(schedule);
280
+ },
281
+ }), [isReady, applySchedule, clearScheduleTimers]);
282
+ if (!localUri) {
283
+ 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 }) }) }) }));
288
+ });
289
+ exports.WgpuAvatar.displayName = 'WgpuAvatar';
290
+ const styles = react_native_1.StyleSheet.create({
291
+ container: { overflow: 'hidden', backgroundColor: '#1a1a2e' },
292
+ placeholder: { backgroundColor: '#1a1a2e' },
293
+ });
@@ -0,0 +1,2 @@
1
+ export { WgpuAvatar } from './WgpuAvatar';
2
+ export type { WgpuAvatarRef } from './WgpuAvatar';
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WgpuAvatar = void 0;
4
+ var WgpuAvatar_1 = require("./WgpuAvatar");
5
+ Object.defineProperty(exports, "WgpuAvatar", { enumerable: true, get: function () { return WgpuAvatar_1.WgpuAvatar; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-head-studio",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
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",
@@ -46,6 +46,11 @@
46
46
  "react-native": "./dist/filament/editor/index.js",
47
47
  "types": "./dist/filament/editor/index.d.ts",
48
48
  "default": "./dist/filament/editor/index.js"
49
+ },
50
+ "./wgpu": {
51
+ "react-native": "./dist/wgpu/index.js",
52
+ "types": "./dist/wgpu/index.d.ts",
53
+ "default": "./dist/wgpu/index.js"
49
54
  }
50
55
  },
51
56
  "files": [
@@ -111,6 +116,7 @@
111
116
  "react-native": ">=0.73",
112
117
  "react-native-filament": ">=1",
113
118
  "react-native-webview": ">=13",
119
+ "react-native-wgpu": ">=0.1",
114
120
  "three": ">=0.170"
115
121
  },
116
122
  "peerDependenciesMeta": {
@@ -123,6 +129,9 @@
123
129
  "react-native-filament": {
124
130
  "optional": true
125
131
  },
132
+ "react-native-wgpu": {
133
+ "optional": true
134
+ },
126
135
  "expo": {
127
136
  "optional": true
128
137
  },
@@ -178,7 +187,7 @@
178
187
  "react-native-gesture-handler": "^2.30.0",
179
188
  "react-native-reanimated": "^4.2.3",
180
189
  "react-native-webview": "^13.16.0",
181
- "react-test-renderer": "^19.2.4",
190
+ "react-test-renderer": "^18.3.1",
182
191
  "three": "^0.180.0",
183
192
  "ts-jest": "^29.4.6",
184
193
  "typescript": "^5.3.3",