talking-head-studio 0.2.8 → 0.3.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.
Files changed (38) hide show
  1. package/dist/TalkingHead.d.ts +8 -0
  2. package/dist/TalkingHead.js +104 -7
  3. package/dist/TalkingHead.web.d.ts +3 -2
  4. package/dist/TalkingHead.web.js +17 -2
  5. package/dist/TalkingHeadVisualization.d.ts +35 -0
  6. package/dist/TalkingHeadVisualization.js +277 -0
  7. package/dist/api/index.d.ts +2 -0
  8. package/dist/api/index.js +18 -0
  9. package/dist/api/studioApi.d.ts +38 -0
  10. package/dist/api/studioApi.js +235 -0
  11. package/dist/api/types.d.ts +87 -0
  12. package/dist/api/types.js +5 -0
  13. package/dist/assets/face-squeeze-local.glb +0 -0
  14. package/dist/filament/FilamentAvatar.d.ts +41 -0
  15. package/dist/filament/FilamentAvatar.js +737 -0
  16. package/dist/filament/faceSqueezeAssets.d.ts +1 -0
  17. package/dist/filament/faceSqueezeAssets.js +5 -0
  18. package/dist/filament/index.d.ts +5 -0
  19. package/dist/filament/index.js +22 -0
  20. package/dist/filament/morphTables.d.ts +5 -0
  21. package/dist/filament/morphTables.js +93 -0
  22. package/dist/filament/useAuthedFilamentUri.d.ts +11 -0
  23. package/dist/filament/useAuthedFilamentUri.js +126 -0
  24. package/dist/html.d.ts +7 -0
  25. package/dist/html.js +255 -56
  26. package/dist/index.d.ts +9 -2
  27. package/dist/index.js +13 -2
  28. package/dist/index.web.d.ts +6 -2
  29. package/dist/index.web.js +10 -2
  30. package/dist/utils/avatarUtils.d.ts +13 -0
  31. package/dist/utils/avatarUtils.js +56 -0
  32. package/dist/wardrobe/index.d.ts +2 -0
  33. package/dist/wardrobe/index.js +20 -0
  34. package/dist/wardrobe/useAvatarWardrobeHydration.d.ts +7 -0
  35. package/dist/wardrobe/useAvatarWardrobeHydration.js +34 -0
  36. package/dist/wardrobe/wardrobeStore.d.ts +30 -0
  37. package/dist/wardrobe/wardrobeStore.js +106 -0
  38. package/package.json +33 -4
@@ -1,6 +1,11 @@
1
1
  import React from 'react';
2
2
  import { type StyleProp, type ViewStyle } from 'react-native';
3
3
  export type TalkingHeadMood = 'neutral' | 'happy' | 'sad' | 'angry' | 'excited' | 'thinking' | 'concerned' | 'surprised';
4
+ export type TalkingHeadLoadingStage = 'booting' | 'fetching_model' | 'loading_avatar' | 'loading_fallback' | 'ready';
5
+ export interface TalkingHeadLoadingState {
6
+ stage: TalkingHeadLoadingStage;
7
+ progress?: number | null;
8
+ }
4
9
  /**
5
10
  * Standard viseme keys supported by the avatar.
6
11
  * Use with sendViseme() from your TTS viseme callbacks.
@@ -46,9 +51,12 @@ export interface TalkingHeadProps {
46
51
  skinColor?: string;
47
52
  eyeColor?: string;
48
53
  accessories?: TalkingHeadAccessory[];
54
+ onLoadingChange?: (state: TalkingHeadLoadingState) => void;
49
55
  onReady?: () => void;
50
56
  onError?: (message: string) => void;
51
57
  style?: StyleProp<ViewStyle>;
58
+ /** Base URL for vendored assets. When set, replaces all cdn.jsdelivr.net references. */
59
+ vendorBaseUrl?: string | null;
52
60
  }
53
61
  export interface TalkingHeadRef {
54
62
  sendAmplitude: (amplitude: number) => void;
@@ -6,14 +6,23 @@ const react_1 = require("react");
6
6
  const react_native_1 = require("react-native");
7
7
  const react_native_webview_1 = require("react-native-webview");
8
8
  const html_1 = require("./html");
9
- exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onReady, onError, style, }, ref) => {
9
+ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, style, vendorBaseUrl, }, ref) => {
10
10
  const webViewRef = (0, react_1.useRef)(null);
11
11
  const readyRef = (0, react_1.useRef)(false);
12
+ const firstMessageSeenRef = (0, react_1.useRef)(false);
13
+ const bootTimerRef = (0, react_1.useRef)(null);
12
14
  const pendingMoodRef = (0, react_1.useRef)(mood);
13
15
  const pendingHairColorRef = (0, react_1.useRef)(hairColor);
14
16
  const pendingSkinColorRef = (0, react_1.useRef)(skinColor);
15
17
  const pendingEyeColorRef = (0, react_1.useRef)(eyeColor);
16
18
  const accessoriesRef = (0, react_1.useRef)(accessories);
19
+ // Stable refs for callbacks so they never retrigger the boot effect.
20
+ const onLoadingChangeRef = (0, react_1.useRef)(onLoadingChange);
21
+ onLoadingChangeRef.current = onLoadingChange;
22
+ const onReadyRef = (0, react_1.useRef)(onReady);
23
+ onReadyRef.current = onReady;
24
+ const onErrorRef = (0, react_1.useRef)(onError);
25
+ onErrorRef.current = onError;
17
26
  // The WebView HTML is built once from stable initial values.
18
27
  // avatarUrl + authToken changing causes a controlled key-based remount.
19
28
  // All other prop changes (mood, colors, accessories) go via postMessage.
@@ -27,9 +36,63 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
27
36
  setWebViewKey(`${avatarUrl}__${authToken ?? ''}`);
28
37
  }
29
38
  }, [avatarUrl, authToken]);
30
- const post = (0, react_1.useCallback)((msg) => {
31
- webViewRef.current?.postMessage(JSON.stringify(msg));
39
+ (0, react_1.useEffect)(() => {
40
+ if (!avatarUrl)
41
+ return;
42
+ firstMessageSeenRef.current = false;
43
+ onLoadingChangeRef.current?.({ stage: 'booting', progress: null });
44
+ if (bootTimerRef.current)
45
+ clearTimeout(bootTimerRef.current);
46
+ bootTimerRef.current = setTimeout(() => {
47
+ if (readyRef.current || firstMessageSeenRef.current)
48
+ return;
49
+ const vendorLabel = vendorBaseUrl ? ` vendor=${vendorBaseUrl}` : ' vendor=<cdn>';
50
+ const avatarLabel = avatarUrl && avatarUrl.length > 80 ? avatarUrl.slice(0, 80) + '…' : avatarUrl;
51
+ onErrorRef.current?.(`[boot timeout] No WebView messages received after 8s.${vendorLabel} avatar=${avatarLabel}`);
52
+ }, 8000);
53
+ return () => {
54
+ if (bootTimerRef.current) {
55
+ clearTimeout(bootTimerRef.current);
56
+ bootTimerRef.current = null;
57
+ }
58
+ };
59
+ }, [avatarUrl, authToken, vendorBaseUrl]);
60
+ const postQueue = (0, react_1.useRef)([]);
61
+ const flushTimer = (0, react_1.useRef)(null);
62
+ (0, react_1.useEffect)(() => () => {
63
+ if (flushTimer.current)
64
+ clearTimeout(flushTimer.current);
65
+ }, []);
66
+ const flushQueue = (0, react_1.useCallback)(() => {
67
+ flushTimer.current = null;
68
+ try {
69
+ const wv = webViewRef.current;
70
+ if (!wv)
71
+ return;
72
+ const msgs = postQueue.current.splice(0);
73
+ if (msgs.length === 0)
74
+ return;
75
+ // Batch all pending messages into a single injectJavaScript call
76
+ const js = msgs
77
+ .map((m) => {
78
+ const json = JSON.stringify(JSON.stringify(m));
79
+ return `window.dispatchEvent(new MessageEvent('message',{data:${json}}))`;
80
+ })
81
+ .join(';');
82
+ wv.injectJavaScript(js + ';true;');
83
+ }
84
+ catch {
85
+ // WebView ref frozen/invalidated during unmount — ignore
86
+ postQueue.current = [];
87
+ }
32
88
  }, []);
89
+ const post = (0, react_1.useCallback)((msg) => {
90
+ postQueue.current.push(msg);
91
+ // Coalesce into a single microtask to avoid flooding injectJavaScript
92
+ if (!flushTimer.current) {
93
+ flushTimer.current = setTimeout(flushQueue, 0);
94
+ }
95
+ }, [flushQueue]);
33
96
  (0, react_1.useImperativeHandle)(ref, () => ({
34
97
  sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
35
98
  sendViseme: (viseme, weight = 1.0) => post({ type: 'viseme', viseme, weight }),
@@ -94,6 +157,18 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
94
157
  const [initialHairColor] = (0, react_1.useState)(hairColor);
95
158
  const [initialSkinColor] = (0, react_1.useState)(skinColor);
96
159
  const [initialEyeColor] = (0, react_1.useState)(eyeColor);
160
+ // Use the vendor origin as baseUrl so dynamic module imports are same-origin
161
+ // and don't get blocked by the WebView's security model.
162
+ const webViewBaseUrl = (0, react_1.useMemo)(() => {
163
+ if (!vendorBaseUrl)
164
+ return 'https://localhost/';
165
+ try {
166
+ return new URL(vendorBaseUrl).origin + '/';
167
+ }
168
+ catch {
169
+ return 'https://localhost/';
170
+ }
171
+ }, [vendorBaseUrl]);
97
172
  // html is stable — only rebuilds when webViewKey changes (avatarUrl/authToken)
98
173
  const html = (0, react_1.useMemo)(() => (0, html_1.buildAvatarHtml)({
99
174
  avatarUrl,
@@ -104,14 +179,26 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
104
179
  initialHairColor,
105
180
  initialSkinColor,
106
181
  initialEyeColor,
182
+ vendorBaseUrl,
107
183
  }),
108
184
  // eslint-disable-next-line react-hooks/exhaustive-deps
109
185
  [webViewKey]);
110
186
  const onMessage = (0, react_1.useCallback)((event) => {
111
187
  try {
188
+ firstMessageSeenRef.current = true;
112
189
  const msg = JSON.parse(event.nativeEvent.data);
190
+ if (msg.type === 'loading' && typeof msg.stage === 'string') {
191
+ onLoadingChangeRef.current?.({
192
+ stage: msg.stage,
193
+ progress: typeof msg.progress === 'number' && Number.isFinite(msg.progress)
194
+ ? msg.progress
195
+ : null,
196
+ });
197
+ return;
198
+ }
113
199
  if (msg.type === 'ready') {
114
200
  readyRef.current = true;
201
+ onLoadingChangeRef.current?.({ stage: 'ready', progress: 100 });
115
202
  // Flush pending appearance updates that arrived before the WebView was ready.
116
203
  if (pendingMoodRef.current) {
117
204
  post({ type: 'mood', value: pendingMoodRef.current });
@@ -128,10 +215,10 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
128
215
  if (accessoriesRef.current?.length) {
129
216
  post({ type: 'set_accessories', accessories: accessoriesRef.current });
130
217
  }
131
- onReady?.();
218
+ onReadyRef.current?.();
132
219
  }
133
220
  else if (msg.type === 'error') {
134
- onError?.(msg.message);
221
+ onErrorRef.current?.(msg.message);
135
222
  }
136
223
  else if (msg.type === 'log') {
137
224
  console.log('[TalkingHead]', msg.message);
@@ -140,8 +227,18 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
140
227
  catch (err) {
141
228
  console.warn('[TalkingHead] Invalid message from WebView:', err);
142
229
  }
143
- }, [onReady, onError, post]);
144
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.container, style], children: (0, jsx_runtime_1.jsx)(react_native_webview_1.WebView, { ref: webViewRef, source: { html }, style: styles.webview, javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, onMessage: onMessage, originWhitelist: ['*'], mixedContentMode: "always" }, webViewKey) }));
230
+ }, [post]);
231
+ const handleWebViewError = (0, react_1.useCallback)((event) => {
232
+ firstMessageSeenRef.current = true;
233
+ const description = event.nativeEvent.description || 'WebView failed to load avatar';
234
+ onErrorRef.current?.(`[webview] ${description}`);
235
+ }, []);
236
+ const handleWebViewHttpError = (0, react_1.useCallback)((event) => {
237
+ firstMessageSeenRef.current = true;
238
+ const { statusCode, description, url } = event.nativeEvent;
239
+ onErrorRef.current?.(`[http ${statusCode}] ${description || url || 'Avatar request failed'}`);
240
+ }, []);
241
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.container, style], children: (0, jsx_runtime_1.jsx)(react_native_webview_1.WebView, { ref: webViewRef, source: { html, baseUrl: webViewBaseUrl }, style: styles.webview, javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, onMessage: onMessage, onError: handleWebViewError, onHttpError: handleWebViewHttpError, onLoadStart: () => console.log('[TalkingHead] WebView load start'), onLoadEnd: () => console.log('[TalkingHead] WebView load end'), onLoadProgress: (event) => console.log('[TalkingHead] WebView progress', event.nativeEvent.progress), originWhitelist: ['*'], allowFileAccess: true, allowFileAccessFromFileURLs: true, allowUniversalAccessFromFileURLs: true, mixedContentMode: "always" }, webViewKey) }));
145
242
  });
146
243
  exports.TalkingHead.displayName = 'TalkingHead';
147
244
  const styles = react_native_1.StyleSheet.create({
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
- import type { TalkingHeadVisemeCue, TalkingHeadVisemeSchedule } from './TalkingHead';
3
- export type { TalkingHeadVisemeCue, TalkingHeadVisemeSchedule };
2
+ import type { TalkingHeadLoadingState, TalkingHeadVisemeCue, TalkingHeadVisemeSchedule } from './TalkingHead';
3
+ export type { TalkingHeadLoadingState, TalkingHeadVisemeCue, TalkingHeadVisemeSchedule };
4
4
  export type TalkingHeadMood = 'neutral' | 'happy' | 'sad' | 'angry' | 'excited' | 'thinking' | 'concerned' | 'surprised';
5
5
  export interface TalkingHeadAccessory {
6
6
  id: string;
@@ -20,6 +20,7 @@ export interface TalkingHeadProps {
20
20
  skinColor?: string;
21
21
  eyeColor?: string;
22
22
  accessories?: TalkingHeadAccessory[];
23
+ onLoadingChange?: (state: TalkingHeadLoadingState) => void;
23
24
  onReady?: () => void;
24
25
  onError?: (message: string) => void;
25
26
  style?: React.CSSProperties;
@@ -48,7 +48,7 @@ const iframeStyle = {
48
48
  border: 'none',
49
49
  backgroundColor: 'transparent',
50
50
  };
51
- exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onReady, onError, style, }, ref) => {
51
+ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, style, }, ref) => {
52
52
  const iframeRef = (0, react_1.useRef)(null);
53
53
  const readyRef = (0, react_1.useRef)(false);
54
54
  const pendingMoodRef = (0, react_1.useRef)(mood);
@@ -59,6 +59,11 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
59
59
  (0, react_1.useEffect)(() => {
60
60
  accessoriesRef.current = accessories;
61
61
  }, [accessories]);
62
+ (0, react_1.useEffect)(() => {
63
+ if (!avatarUrl)
64
+ return;
65
+ onLoadingChange?.({ stage: 'booting', progress: null });
66
+ }, [avatarUrl, authToken, onLoadingChange]);
62
67
  const post = (0, react_1.useCallback)((msg) => {
63
68
  iframeRef.current?.contentWindow?.postMessage(JSON.stringify(msg), '*');
64
69
  }, []);
@@ -151,8 +156,18 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
151
156
  return;
152
157
  try {
153
158
  const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
159
+ if (msg.type === 'loading' && typeof msg.stage === 'string') {
160
+ onLoadingChange?.({
161
+ stage: msg.stage,
162
+ progress: typeof msg.progress === 'number' && Number.isFinite(msg.progress)
163
+ ? msg.progress
164
+ : null,
165
+ });
166
+ return;
167
+ }
154
168
  if (msg.type === 'ready') {
155
169
  readyRef.current = true;
170
+ onLoadingChange?.({ stage: 'ready', progress: 100 });
156
171
  if (pendingMoodRef.current) {
157
172
  post({ type: 'mood', value: pendingMoodRef.current });
158
173
  }
@@ -183,7 +198,7 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
183
198
  };
184
199
  window.addEventListener('message', onMessage);
185
200
  return () => window.removeEventListener('message', onMessage);
186
- }, [onReady, onError, post]);
201
+ }, [onLoadingChange, onReady, onError, post]);
187
202
  return ((0, jsx_runtime_1.jsx)("div", { style: { ...containerStyle, ...style }, children: (0, jsx_runtime_1.jsx)("iframe", { ref: iframeRef, srcDoc: srcdoc, style: iframeStyle, sandbox: "allow-scripts allow-same-origin", title: "TalkingHead Avatar" }) }));
188
203
  });
189
204
  exports.TalkingHead.displayName = 'TalkingHead';
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { ViewStyle, StyleProp } from 'react-native';
3
+ import { type TalkingHeadMood, type TalkingHeadAccessory, type TalkingHeadViseme, type TalkingHeadVisemeSchedule } from './TalkingHead';
4
+ interface TalkingHeadVisualizationProps {
5
+ style?: StyleProp<ViewStyle>;
6
+ avatarUrl: string | null;
7
+ authToken?: string;
8
+ cameraView?: 'head' | 'upper' | 'full';
9
+ cameraDistance?: number;
10
+ accessories?: TalkingHeadAccessory[];
11
+ mood?: TalkingHeadMood;
12
+ aspect?: number;
13
+ focalLength?: number;
14
+ visemeSchedule?: TalkingHeadVisemeSchedule | null;
15
+ onVisemeScheduleApplied?: (info: {
16
+ requestId: string | null;
17
+ appliedAtMs: number;
18
+ }) => void;
19
+ vendorBaseUrl?: string | null;
20
+ }
21
+ export interface TalkingHeadVisualizationRef {
22
+ setMood: (mood: TalkingHeadMood) => void;
23
+ sendAmplitude: (amplitude: number) => void;
24
+ sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
25
+ scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
26
+ clearVisemes: () => void;
27
+ }
28
+ /**
29
+ * TalkingHeadVisualization — optimized component for rendering the 3D avatar.
30
+ *
31
+ * On native: uses FilamentAvatar (direct morph writes, no WebView bridge).
32
+ * On web: uses TalkingHead WebView renderer.
33
+ */
34
+ export declare const TalkingHeadVisualization: React.ForwardRefExoticComponent<TalkingHeadVisualizationProps & React.RefAttributes<TalkingHeadVisualizationRef>>;
35
+ export {};
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TalkingHeadVisualization = void 0;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /* eslint-disable @typescript-eslint/no-unused-expressions */
6
+ const react_1 = require("react");
7
+ const react_native_1 = require("react-native");
8
+ const TalkingHead_1 = require("./TalkingHead");
9
+ const FilamentAvatar_1 = require("./filament/FilamentAvatar");
10
+ const avatarUtils_1 = require("./utils/avatarUtils");
11
+ const faceSqueezeAssets_1 = require("./filament/faceSqueezeAssets");
12
+ // Cached fallback data URI — resolved once, reused across all instances
13
+ let _fallbackDataUri = null;
14
+ let _fallbackPromise = null;
15
+ function getFallbackAvatarUrl() {
16
+ if (_fallbackDataUri)
17
+ return Promise.resolve(_fallbackDataUri);
18
+ if (!_fallbackPromise) {
19
+ _fallbackPromise = (0, avatarUtils_1.resolveLocalAssetUrl)(faceSqueezeAssets_1.FACE_SQUEEZE_LOCAL_MODULE).then((uri) => {
20
+ _fallbackDataUri = uri;
21
+ return uri;
22
+ });
23
+ }
24
+ return _fallbackPromise;
25
+ }
26
+ function getLoadingLabel(stage) {
27
+ switch (stage) {
28
+ case 'fetching_model':
29
+ return 'Fetching avatar';
30
+ case 'loading_avatar':
31
+ return 'Loading avatar';
32
+ case 'loading_fallback':
33
+ return 'Loading viewer';
34
+ case 'ready':
35
+ return 'Ready';
36
+ case 'booting':
37
+ default:
38
+ return 'Starting avatar';
39
+ }
40
+ }
41
+ /**
42
+ * TalkingHeadVisualization — optimized component for rendering the 3D avatar.
43
+ *
44
+ * On native: uses FilamentAvatar (direct morph writes, no WebView bridge).
45
+ * On web: uses TalkingHead WebView renderer.
46
+ */
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
+ const avatarRef = (0, react_1.useRef)(null);
49
+ // On native, Filament ref is wired via callback ref — store it here so
50
+ // scheduleVisemes / sendAmplitude can route to it.
51
+ const filamentRef = (0, react_1.useRef)(null);
52
+ // Unified accessor — Filament on native, WebView on web
53
+ const activeAvatar = (0, react_1.useCallback)(() => (filamentRef.current ?? avatarRef.current), []);
54
+ // Fallback local GLB data URI — resolved once on mount
55
+ const [fallbackUrl, setFallbackUrl] = (0, react_1.useState)(_fallbackDataUri);
56
+ (0, react_1.useEffect)(() => {
57
+ if (_fallbackDataUri) {
58
+ setFallbackUrl(_fallbackDataUri);
59
+ return;
60
+ }
61
+ getFallbackAvatarUrl().then((u) => u && setFallbackUrl(u));
62
+ }, []);
63
+ const [mood, setMood] = (0, react_1.useState)(initialMood);
64
+ const [isAvatarReady, setIsAvatarReady] = (0, react_1.useState)(false);
65
+ const [avatarError, setAvatarError] = (0, react_1.useState)(null);
66
+ const [useFallback, setUseFallback] = (0, react_1.useState)(false);
67
+ const [loadingState, setLoadingState] = (0, react_1.useState)({
68
+ stage: 'booting',
69
+ progress: null,
70
+ });
71
+ const pendingVisemeScheduleRef = (0, react_1.useRef)(null);
72
+ const lastScheduledVisemeKeyRef = (0, react_1.useRef)(null);
73
+ const handleReady = (0, react_1.useCallback)(() => {
74
+ setAvatarError(null);
75
+ setIsAvatarReady(true);
76
+ setLoadingState({ stage: 'ready', progress: 100 });
77
+ const pendingSchedule = pendingVisemeScheduleRef.current;
78
+ if (!pendingSchedule)
79
+ return;
80
+ const ageMs = typeof pendingSchedule.startedAtMs === 'number'
81
+ ? Math.max(0, Date.now() - pendingSchedule.startedAtMs)
82
+ : null;
83
+ const durationMs = pendingSchedule.durationMs ?? 0;
84
+ // Drop if audio already finished playing
85
+ if (ageMs != null && durationMs > 0 && ageMs > durationMs + 500) {
86
+ __DEV__ && console.log('[TalkingHead] Dropping expired pending viseme schedule', {
87
+ requestId: pendingSchedule.requestId ?? null,
88
+ ageMs,
89
+ durationMs,
90
+ });
91
+ pendingVisemeScheduleRef.current = null;
92
+ return;
93
+ }
94
+ __DEV__ && console.log('[TalkingHead] Applying pending viseme schedule', {
95
+ requestId: pendingSchedule.requestId ?? null,
96
+ cues: pendingSchedule.cues.length,
97
+ ageMs,
98
+ });
99
+ activeAvatar()?.scheduleVisemes(pendingSchedule);
100
+ onVisemeScheduleApplied?.({
101
+ requestId: pendingSchedule.requestId ?? null,
102
+ appliedAtMs: Date.now(),
103
+ });
104
+ lastScheduledVisemeKeyRef.current = `${pendingSchedule.requestId ?? 'anonymous'}:${pendingSchedule.startedAtMs ?? 0}`;
105
+ pendingVisemeScheduleRef.current = null;
106
+ }, [onVisemeScheduleApplied, activeAvatar]);
107
+ (0, react_1.useImperativeHandle)(ref, () => ({
108
+ setMood: (m) => setMood(m),
109
+ sendAmplitude: (a) => activeAvatar()?.sendAmplitude(a),
110
+ sendViseme: (viseme, weight) => activeAvatar()?.sendViseme(viseme, weight),
111
+ clearVisemes: () => activeAvatar()?.clearVisemes(),
112
+ scheduleVisemes: (schedule) => {
113
+ const scheduleKey = `${schedule.requestId ?? 'anonymous'}:${schedule.startedAtMs ?? 0}`;
114
+ if (lastScheduledVisemeKeyRef.current === scheduleKey)
115
+ return;
116
+ const av = activeAvatar();
117
+ // Filament buffers pending schedules internally — no ready gate needed.
118
+ // WebView (avatarRef) still needs the ready gate.
119
+ if (!av || (!filamentRef.current && !isAvatarReady)) {
120
+ pendingVisemeScheduleRef.current = schedule;
121
+ return;
122
+ }
123
+ av.scheduleVisemes(schedule);
124
+ onVisemeScheduleApplied?.({ requestId: schedule.requestId ?? null, appliedAtMs: Date.now() });
125
+ lastScheduledVisemeKeyRef.current = scheduleKey;
126
+ },
127
+ }), [isAvatarReady, onVisemeScheduleApplied, activeAvatar]);
128
+ (0, react_1.useEffect)(() => {
129
+ setMood(initialMood);
130
+ }, [initialMood]);
131
+ (0, react_1.useEffect)(() => {
132
+ setIsAvatarReady(false);
133
+ setAvatarError(null);
134
+ setUseFallback(false);
135
+ setLoadingState({ stage: 'booting', progress: null });
136
+ pendingVisemeScheduleRef.current = null;
137
+ lastScheduledVisemeKeyRef.current = null;
138
+ }, [avatarUrl]);
139
+ const handleError = (0, react_1.useCallback)((message) => {
140
+ console.warn('[TalkingHeadVisualization] Avatar load failed, switching to fallback:', message);
141
+ setAvatarError(message);
142
+ setUseFallback(true);
143
+ }, []);
144
+ // Effective avatar URL: use remote if available, fallback GLB if error or missing
145
+ const effectiveAvatarUrl = useFallback || !avatarUrl ? fallbackUrl : avatarUrl;
146
+ const handleLoadingChange = (0, react_1.useCallback)((nextState) => {
147
+ setLoadingState({
148
+ stage: nextState.stage,
149
+ progress: typeof nextState.progress === 'number' && Number.isFinite(nextState.progress)
150
+ ? Math.max(0, Math.min(100, Math.round(nextState.progress)))
151
+ : null,
152
+ });
153
+ }, []);
154
+ (0, react_1.useEffect)(() => {
155
+ if (!visemeSchedule || visemeSchedule.cues.length === 0)
156
+ return;
157
+ const scheduleKey = `${visemeSchedule.requestId ?? 'anonymous'}:${visemeSchedule.startedAtMs ?? 0}`;
158
+ if (lastScheduledVisemeKeyRef.current === scheduleKey)
159
+ return;
160
+ __DEV__ && console.log('[TalkingHead] Received viseme schedule', {
161
+ requestId: visemeSchedule.requestId ?? null,
162
+ cues: visemeSchedule.cues.length,
163
+ ageMs: typeof visemeSchedule.startedAtMs === 'number'
164
+ ? Math.max(0, Date.now() - visemeSchedule.startedAtMs)
165
+ : null,
166
+ ready: isAvatarReady,
167
+ });
168
+ const av = activeAvatar();
169
+ if (!av || (!filamentRef.current && !isAvatarReady)) {
170
+ pendingVisemeScheduleRef.current = visemeSchedule;
171
+ return;
172
+ }
173
+ __DEV__ && console.log('[TalkingHead] Scheduling visemes immediately', {
174
+ requestId: visemeSchedule.requestId ?? null,
175
+ cues: visemeSchedule.cues.length,
176
+ });
177
+ av.scheduleVisemes(visemeSchedule);
178
+ onVisemeScheduleApplied?.({
179
+ requestId: visemeSchedule.requestId ?? null,
180
+ appliedAtMs: Date.now(),
181
+ });
182
+ lastScheduledVisemeKeyRef.current = scheduleKey;
183
+ }, [isAvatarReady, onVisemeScheduleApplied, visemeSchedule, activeAvatar]);
184
+ // On native use Filament — direct morph writes, no WebView bridge.
185
+ if (react_native_1.Platform.OS !== 'web') {
186
+ return ((0, jsx_runtime_1.jsx)(FilamentAvatar_1.FilamentAvatar, { focalLength: focalLength, ref: (fr) => {
187
+ filamentRef.current = fr;
188
+ if (typeof ref === 'function')
189
+ ref(fr);
190
+ else if (ref)
191
+ ref.current = fr;
192
+ if (fr && pendingVisemeScheduleRef.current) {
193
+ fr.scheduleVisemes(pendingVisemeScheduleRef.current);
194
+ pendingVisemeScheduleRef.current = null;
195
+ }
196
+ }, style: style, avatarUrl: avatarUrl ?? null, aspect: aspect, mood: mood, accessories: accessories, onReady: handleReady, onError: handleError }));
197
+ }
198
+ if (!effectiveAvatarUrl) {
199
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.placeholder] }));
200
+ }
201
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.progressTrack, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
202
+ styles.progressFill,
203
+ { width: `${Math.max(6, loadingState.progress)}%` },
204
+ ] }) })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: "Preparing the avatar scene\u2026" }))] }) }))] }));
205
+ });
206
+ exports.TalkingHeadVisualization.displayName = 'TalkingHeadVisualization';
207
+ const styles = react_native_1.StyleSheet.create({
208
+ container: {
209
+ overflow: 'hidden',
210
+ },
211
+ loadingOverlay: {
212
+ ...react_native_1.StyleSheet.absoluteFillObject,
213
+ alignItems: 'center',
214
+ justifyContent: 'center',
215
+ backgroundColor: 'rgba(5, 10, 18, 0.42)',
216
+ padding: 20,
217
+ },
218
+ loadingCard: {
219
+ minWidth: 190,
220
+ maxWidth: 240,
221
+ borderRadius: 20,
222
+ paddingHorizontal: 18,
223
+ paddingVertical: 16,
224
+ backgroundColor: 'rgba(13, 20, 31, 0.88)',
225
+ borderWidth: 1,
226
+ borderColor: 'rgba(151, 163, 184, 0.18)',
227
+ alignItems: 'center',
228
+ },
229
+ loadingBadge: {
230
+ paddingHorizontal: 8,
231
+ paddingVertical: 4,
232
+ borderRadius: 999,
233
+ backgroundColor: 'rgba(83, 156, 255, 0.16)',
234
+ marginBottom: 10,
235
+ },
236
+ loadingBadgeText: {
237
+ color: '#c7dbff',
238
+ fontSize: 10,
239
+ fontWeight: '700',
240
+ letterSpacing: 1.1,
241
+ },
242
+ loadingTitle: {
243
+ color: '#f8fbff',
244
+ fontSize: 18,
245
+ fontWeight: '700',
246
+ },
247
+ loadingPercent: {
248
+ marginTop: 8,
249
+ color: '#7dd3fc',
250
+ fontSize: 28,
251
+ fontWeight: '800',
252
+ },
253
+ loadingHint: {
254
+ marginTop: 10,
255
+ color: 'rgba(226, 232, 240, 0.82)',
256
+ fontSize: 13,
257
+ textAlign: 'center',
258
+ },
259
+ progressTrack: {
260
+ width: '100%',
261
+ height: 8,
262
+ borderRadius: 999,
263
+ backgroundColor: 'rgba(148, 163, 184, 0.18)',
264
+ marginTop: 12,
265
+ overflow: 'hidden',
266
+ },
267
+ progressFill: {
268
+ height: '100%',
269
+ borderRadius: 999,
270
+ backgroundColor: '#5eead4',
271
+ },
272
+ placeholder: {
273
+ backgroundColor: 'rgba(30,34,53,0.5)',
274
+ alignItems: 'center',
275
+ justifyContent: 'center',
276
+ },
277
+ });
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './studioApi';
@@ -0,0 +1,18 @@
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./types"), exports);
18
+ __exportStar(require("./studioApi"), exports);
@@ -0,0 +1,38 @@
1
+ import type { Avatar, AvatarUpdate, PublicAvatar, VoiceProfile, ProfileSample, WearableAsset, PlacementSuggestion } from './types';
2
+ export declare function configureAvatarApi(opts: {
3
+ baseUrl?: string;
4
+ getToken: () => Promise<string | null>;
5
+ }): void;
6
+ export declare function getToken(): Promise<string | null>;
7
+ export declare function studioFetch<T>(path: string, options?: RequestInit): Promise<T>;
8
+ export declare function getMyAvatars(): Promise<Avatar[]>;
9
+ export declare function getAvatar(id: string): Promise<Avatar>;
10
+ export declare function updateAvatar(id: string, data: AvatarUpdate): Promise<Avatar>;
11
+ export declare function deleteAvatar(id: string): Promise<void>;
12
+ export declare function createAvatar(fileUri: string, name: string, description?: string): Promise<Avatar>;
13
+ export declare function getPublicAvatars(): Promise<PublicAvatar[]>;
14
+ export declare function getVoiceProfileSamples(profileId: string): Promise<ProfileSample[]>;
15
+ export declare function getVoiceProfiles(): Promise<VoiceProfile[]>;
16
+ export declare function setDefaultVoice(avatarId: string, profileId: string): Promise<Avatar>;
17
+ export declare function removeDefaultVoice(avatarId: string): Promise<Avatar>;
18
+ export declare function avatarFileUrl(avatar: Avatar): string;
19
+ export declare function avatarThumbnailUrl(avatar: Avatar | PublicAvatar): string | null;
20
+ export declare function avatarAnimatedThumbnailUrl(avatar: Avatar | PublicAvatar): string | null;
21
+ export declare function thumbnailHeaders(): Promise<Record<string, string>>;
22
+ export declare function listAssets(category?: string): Promise<WearableAsset[]>;
23
+ export declare function getAsset(id: string): Promise<WearableAsset>;
24
+ export declare function uploadAsset(fileUri: string, meta: {
25
+ name: string;
26
+ category: string;
27
+ type: 'skinned' | 'rigid';
28
+ slot?: string;
29
+ attach_bone?: string;
30
+ offset_position?: number[];
31
+ offset_rotation?: number[];
32
+ hides_body_parts?: string[];
33
+ }): Promise<WearableAsset>;
34
+ export declare function deleteAsset(id: string): Promise<void>;
35
+ export declare function suggestPlacement(assetId: string, avatarId: string): Promise<PlacementSuggestion>;
36
+ export declare function assetFileUrl(asset: WearableAsset): string;
37
+ export declare function createVoiceProfile(name: string, language?: string): Promise<VoiceProfile>;
38
+ export declare function uploadVoiceSample(profileId: string, fileUri: string, fileName: string, referenceText: string): Promise<VoiceProfile>;