talking-head-studio 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +459 -0
  3. package/dist/TalkingHead.d.ts +35 -0
  4. package/dist/TalkingHead.d.ts.map +1 -0
  5. package/dist/TalkingHead.js +107 -0
  6. package/dist/TalkingHead.web.d.ts +35 -0
  7. package/dist/TalkingHead.web.d.ts.map +1 -0
  8. package/dist/TalkingHead.web.js +117 -0
  9. package/dist/__tests__/TalkingHead.test.d.ts +2 -0
  10. package/dist/__tests__/TalkingHead.test.d.ts.map +1 -0
  11. package/dist/__tests__/TalkingHead.test.js +23 -0
  12. package/dist/__tests__/sketchfab.test.d.ts +2 -0
  13. package/dist/__tests__/sketchfab.test.d.ts.map +1 -0
  14. package/dist/__tests__/sketchfab.test.js +21 -0
  15. package/dist/appearance/apply.d.ts +7 -0
  16. package/dist/appearance/apply.d.ts.map +1 -0
  17. package/dist/appearance/apply.js +56 -0
  18. package/dist/appearance/index.d.ts +5 -0
  19. package/dist/appearance/index.d.ts.map +1 -0
  20. package/dist/appearance/index.js +3 -0
  21. package/dist/appearance/matchers.d.ts +3 -0
  22. package/dist/appearance/matchers.d.ts.map +1 -0
  23. package/dist/appearance/matchers.js +32 -0
  24. package/dist/appearance/schema.d.ts +9 -0
  25. package/dist/appearance/schema.d.ts.map +1 -0
  26. package/dist/appearance/schema.js +20 -0
  27. package/dist/editor/AvatarCanvas.d.ts +16 -0
  28. package/dist/editor/AvatarCanvas.d.ts.map +1 -0
  29. package/dist/editor/AvatarCanvas.js +85 -0
  30. package/dist/editor/AvatarCanvasErrorBoundary.d.ts +17 -0
  31. package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +1 -0
  32. package/dist/editor/AvatarCanvasErrorBoundary.js +41 -0
  33. package/dist/editor/AvatarModel.d.ts +12 -0
  34. package/dist/editor/AvatarModel.d.ts.map +1 -0
  35. package/dist/editor/AvatarModel.js +31 -0
  36. package/dist/editor/RigidAccessory.d.ts +15 -0
  37. package/dist/editor/RigidAccessory.d.ts.map +1 -0
  38. package/dist/editor/RigidAccessory.js +76 -0
  39. package/dist/editor/SkinnedClothing.d.ts +7 -0
  40. package/dist/editor/SkinnedClothing.d.ts.map +1 -0
  41. package/dist/editor/SkinnedClothing.js +88 -0
  42. package/dist/editor/index.d.ts +6 -0
  43. package/dist/editor/index.d.ts.map +1 -0
  44. package/dist/editor/index.js +4 -0
  45. package/dist/editor/types.d.ts +28 -0
  46. package/dist/editor/types.d.ts.map +1 -0
  47. package/dist/editor/types.js +1 -0
  48. package/dist/html.d.ts +13 -0
  49. package/dist/html.d.ts.map +1 -0
  50. package/dist/html.js +560 -0
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +2 -0
  54. package/dist/index.web.d.ts +4 -0
  55. package/dist/index.web.d.ts.map +1 -0
  56. package/dist/index.web.js +2 -0
  57. package/dist/sketchfab/api.d.ts +12 -0
  58. package/dist/sketchfab/api.d.ts.map +1 -0
  59. package/dist/sketchfab/api.js +52 -0
  60. package/dist/sketchfab/categories.d.ts +5 -0
  61. package/dist/sketchfab/categories.d.ts.map +1 -0
  62. package/dist/sketchfab/categories.js +124 -0
  63. package/dist/sketchfab/index.d.ts +7 -0
  64. package/dist/sketchfab/index.d.ts.map +1 -0
  65. package/dist/sketchfab/index.js +3 -0
  66. package/dist/sketchfab/types.d.ts +51 -0
  67. package/dist/sketchfab/types.d.ts.map +1 -0
  68. package/dist/sketchfab/types.js +1 -0
  69. package/dist/sketchfab/useSketchfabSearch.d.ts +19 -0
  70. package/dist/sketchfab/useSketchfabSearch.d.ts.map +1 -0
  71. package/dist/sketchfab/useSketchfabSearch.js +78 -0
  72. package/dist/voice/convertToWav.d.ts +6 -0
  73. package/dist/voice/convertToWav.d.ts.map +1 -0
  74. package/dist/voice/convertToWav.js +74 -0
  75. package/dist/voice/index.d.ts +6 -0
  76. package/dist/voice/index.d.ts.map +1 -0
  77. package/dist/voice/index.js +3 -0
  78. package/dist/voice/useAudioPlayer.d.ts +11 -0
  79. package/dist/voice/useAudioPlayer.d.ts.map +1 -0
  80. package/dist/voice/useAudioPlayer.js +61 -0
  81. package/dist/voice/useAudioRecording.d.ts +14 -0
  82. package/dist/voice/useAudioRecording.d.ts.map +1 -0
  83. package/dist/voice/useAudioRecording.js +162 -0
  84. package/package.json +120 -0
  85. package/src/TalkingHead.tsx +207 -0
  86. package/src/TalkingHead.web.tsx +210 -0
  87. package/src/__tests__/TalkingHead.test.tsx +32 -0
  88. package/src/__tests__/sketchfab.test.ts +24 -0
  89. package/src/appearance/apply.ts +94 -0
  90. package/src/appearance/index.ts +4 -0
  91. package/src/appearance/matchers.ts +43 -0
  92. package/src/appearance/schema.ts +35 -0
  93. package/src/editor/AvatarCanvas.tsx +167 -0
  94. package/src/editor/AvatarCanvasErrorBoundary.tsx +64 -0
  95. package/src/editor/AvatarModel.tsx +49 -0
  96. package/src/editor/RigidAccessory.tsx +130 -0
  97. package/src/editor/SkinnedClothing.tsx +114 -0
  98. package/src/editor/index.ts +5 -0
  99. package/src/editor/r3f-shim.d.ts +34 -0
  100. package/src/editor/types.ts +30 -0
  101. package/src/html.ts +572 -0
  102. package/src/index.ts +8 -0
  103. package/src/index.web.ts +8 -0
  104. package/src/sketchfab/api.ts +82 -0
  105. package/src/sketchfab/categories.ts +127 -0
  106. package/src/sketchfab/index.ts +6 -0
  107. package/src/sketchfab/types.ts +40 -0
  108. package/src/sketchfab/useSketchfabSearch.ts +110 -0
  109. package/src/voice/convertToWav.ts +87 -0
  110. package/src/voice/index.ts +7 -0
  111. package/src/voice/useAudioPlayer.ts +78 -0
  112. package/src/voice/useAudioRecording.ts +207 -0
package/package.json ADDED
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "talking-head-studio",
3
+ "version": "0.2.0",
4
+ "description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "source": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "react-native": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.web.js"
13
+ },
14
+ "./editor": {
15
+ "types": "./dist/editor/index.d.ts",
16
+ "default": "./dist/editor/index.js"
17
+ },
18
+ "./voice": {
19
+ "types": "./dist/voice/index.d.ts",
20
+ "default": "./dist/voice/index.js"
21
+ },
22
+ "./appearance": {
23
+ "types": "./dist/appearance/index.d.ts",
24
+ "default": "./dist/appearance/index.js"
25
+ },
26
+ "./sketchfab": {
27
+ "types": "./dist/sketchfab/index.d.ts",
28
+ "default": "./dist/sketchfab/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc --project tsconfig.json",
37
+ "typecheck": "tsc --noEmit",
38
+ "lint": "eslint 'src/**/*.{ts,tsx}'",
39
+ "format": "prettier --write 'src/**/*.{ts,tsx}'",
40
+ "test": "jest",
41
+ "prepublishOnly": "npm run build"
42
+ },
43
+ "keywords": [
44
+ "react-native",
45
+ "avatar",
46
+ "3d",
47
+ "talking-head",
48
+ "lipsync",
49
+ "tts",
50
+ "threejs",
51
+ "glb",
52
+ "lip-sync",
53
+ "voice",
54
+ "webview",
55
+ "expo",
56
+ "cross-platform",
57
+ "llm",
58
+ "motion",
59
+ "gesture",
60
+ "face-tracking",
61
+ "avatar-editor",
62
+ "gizmo",
63
+ "accessories",
64
+ "audio-recording",
65
+ "react-three-fiber",
66
+ "self-hosted"
67
+ ],
68
+ "author": "SiteBay <hello@sitebay.org>",
69
+ "license": "MIT",
70
+ "homepage": "https://github.com/nichochar/avatar-studio",
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "https://github.com/nichochar/avatar-studio.git"
74
+ },
75
+ "bugs": {
76
+ "url": "https://github.com/nichochar/avatar-studio/issues"
77
+ },
78
+ "sideEffects": false,
79
+ "peerDependencies": {
80
+ "react": ">=18",
81
+ "react-native": ">=0.73",
82
+ "react-native-webview": ">=13",
83
+ "@react-three/fiber": ">=8",
84
+ "@react-three/drei": ">=9",
85
+ "three": ">=0.170"
86
+ },
87
+ "peerDependenciesMeta": {
88
+ "react-native": { "optional": true },
89
+ "react-native-webview": { "optional": true },
90
+ "@react-three/fiber": { "optional": true },
91
+ "@react-three/drei": { "optional": true },
92
+ "three": { "optional": true }
93
+ },
94
+ "devDependencies": {
95
+ "@babel/core": "^7.29.0",
96
+ "@babel/preset-env": "^7.29.0",
97
+ "@babel/preset-typescript": "^7.28.5",
98
+ "@react-native/babel-preset": "^0.84.1",
99
+ "@testing-library/react-native": "^13.3.3",
100
+ "@types/jest": "^30.0.0",
101
+ "@types/react": "^19.2.14",
102
+ "@types/react-native": "^0.73.0",
103
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
104
+ "@typescript-eslint/parser": "^8.56.1",
105
+ "babel-jest": "^30.2.0",
106
+ "eslint": "^9.39.3",
107
+ "eslint-config-prettier": "^10.1.8",
108
+ "eslint-plugin-react": "^7.37.5",
109
+ "eslint-plugin-react-hooks": "^7.0.1",
110
+ "eslint-plugin-react-native": "^5.0.0",
111
+ "express": "^5.2.1",
112
+ "jest": "^30.2.0",
113
+ "jest-environment-jsdom": "^30.2.0",
114
+ "metro-react-native-babel-preset": "^0.77.0",
115
+ "multer": "^2.1.0",
116
+ "prettier": "^3.8.1",
117
+ "react-test-renderer": "^19.2.4",
118
+ "ts-jest": "^29.4.6"
119
+ }
120
+ }
@@ -0,0 +1,207 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ } from 'react';
9
+ import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native';
10
+ import { WebView, type WebViewMessageEvent } from 'react-native-webview';
11
+ import { buildAvatarHtml } from './html';
12
+
13
+ export type TalkingHeadMood =
14
+ | 'neutral'
15
+ | 'happy'
16
+ | 'sad'
17
+ | 'angry'
18
+ | 'excited'
19
+ | 'thinking'
20
+ | 'concerned'
21
+ | 'surprised';
22
+
23
+ export interface TalkingHeadAccessory {
24
+ id: string;
25
+ url: string;
26
+ bone: string;
27
+ position: [number, number, number];
28
+ rotation: [number, number, number];
29
+ scale: number;
30
+ }
31
+
32
+ export interface TalkingHeadProps {
33
+ avatarUrl: string;
34
+ authToken?: string | null;
35
+ mood?: TalkingHeadMood;
36
+ cameraView?: 'head' | 'upper' | 'full';
37
+ cameraDistance?: number;
38
+ hairColor?: string;
39
+ skinColor?: string;
40
+ eyeColor?: string;
41
+ accessories?: TalkingHeadAccessory[];
42
+ onReady?: () => void;
43
+ onError?: (message: string) => void;
44
+ style?: StyleProp<ViewStyle>;
45
+ }
46
+
47
+ export interface TalkingHeadRef {
48
+ sendAmplitude: (amplitude: number) => void;
49
+ setMood: (mood: TalkingHeadMood) => void;
50
+ setHairColor: (color: string) => void;
51
+ setSkinColor: (color: string) => void;
52
+ setEyeColor: (color: string) => void;
53
+ setAccessories: (accessories: TalkingHeadAccessory[]) => void;
54
+ }
55
+
56
+ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
57
+ (
58
+ {
59
+ avatarUrl,
60
+ authToken,
61
+ mood = 'neutral',
62
+ cameraView = 'upper',
63
+ cameraDistance = -0.5,
64
+ hairColor,
65
+ skinColor,
66
+ eyeColor,
67
+ accessories,
68
+ onReady,
69
+ onError,
70
+ style,
71
+ },
72
+ ref,
73
+ ) => {
74
+ const webViewRef = useRef<WebView>(null);
75
+
76
+ const post = useCallback((msg: object) => {
77
+ webViewRef.current?.postMessage(JSON.stringify(msg));
78
+ }, []);
79
+
80
+ useImperativeHandle(
81
+ ref,
82
+ () => ({
83
+ sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
84
+ setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
85
+ setHairColor: (color) => post({ type: 'hair_color', value: color }),
86
+ setSkinColor: (color) => post({ type: 'skin_color', value: color }),
87
+ setEyeColor: (color) => post({ type: 'eye_color', value: color }),
88
+ setAccessories: (newAccessories) =>
89
+ post({ type: 'set_accessories', accessories: newAccessories }),
90
+ }),
91
+ [post],
92
+ );
93
+
94
+ useEffect(() => {
95
+ if (readyRef.current) post({ type: 'mood', value: mood });
96
+ }, [mood, post]);
97
+
98
+ // Track whether the WebView JS is ready to receive messages
99
+ const readyRef = useRef(false);
100
+ // Always hold the latest accessories so the ready handler can send them
101
+ const accessoriesRef = useRef(accessories);
102
+ useEffect(() => {
103
+ accessoriesRef.current = accessories;
104
+ }, [accessories]);
105
+
106
+ useEffect(() => {
107
+ // Only post if the WebView is already ready; otherwise the ready handler sends them
108
+ if (accessories && readyRef.current) {
109
+ post({ type: 'set_accessories', accessories });
110
+ }
111
+ }, [accessories, post]);
112
+
113
+ useEffect(() => {
114
+ if (hairColor && readyRef.current) post({ type: 'hair_color', value: hairColor });
115
+ }, [hairColor, post]);
116
+
117
+ useEffect(() => {
118
+ if (skinColor && readyRef.current) post({ type: 'skin_color', value: skinColor });
119
+ }, [skinColor, post]);
120
+
121
+ useEffect(() => {
122
+ if (eyeColor && readyRef.current) post({ type: 'eye_color', value: eyeColor });
123
+ }, [eyeColor, post]);
124
+
125
+ // Color props are intentionally excluded from deps — live updates go via postMessage.
126
+ // Only avatarUrl, authToken, cameraView, cameraDistance cause a full WebView reload.
127
+ const [initialMood] = React.useState(mood);
128
+ const [initialHairColor] = React.useState(hairColor);
129
+ const [initialSkinColor] = React.useState(skinColor);
130
+ const [initialEyeColor] = React.useState(eyeColor);
131
+
132
+ const html = useMemo(
133
+ () =>
134
+ buildAvatarHtml({
135
+ avatarUrl,
136
+ authToken,
137
+ mood: initialMood,
138
+ cameraView,
139
+ cameraDistance,
140
+ initialHairColor: initialHairColor,
141
+ initialSkinColor: initialSkinColor,
142
+ initialEyeColor: initialEyeColor,
143
+ }),
144
+ [
145
+ avatarUrl,
146
+ authToken,
147
+ cameraView,
148
+ cameraDistance,
149
+ initialMood,
150
+ initialHairColor,
151
+ initialSkinColor,
152
+ initialEyeColor,
153
+ ],
154
+ );
155
+
156
+ const onMessage = useCallback(
157
+ (event: WebViewMessageEvent) => {
158
+ try {
159
+ const msg = JSON.parse(event.nativeEvent.data);
160
+ if (msg.type === 'ready') {
161
+ readyRef.current = true;
162
+ // Flush any accessories that arrived before the WebView was ready
163
+ if (accessoriesRef.current?.length) {
164
+ post({ type: 'set_accessories', accessories: accessoriesRef.current });
165
+ }
166
+ onReady?.();
167
+ } else if (msg.type === 'error') onError?.(msg.message);
168
+ else if (msg.type === 'log') console.log('[TalkingHead]', msg.message);
169
+ } catch (err) {
170
+ console.warn('[TalkingHead] Invalid message received from WebView:', err);
171
+ }
172
+ },
173
+ [onReady, onError, post],
174
+ );
175
+
176
+ return (
177
+ <View style={[styles.container, style]}>
178
+ <WebView
179
+ ref={webViewRef}
180
+ source={{ html }}
181
+ style={styles.webview}
182
+ javaScriptEnabled
183
+ domStorageEnabled
184
+ allowsInlineMediaPlayback
185
+ mediaPlaybackRequiresUserAction={false}
186
+ onMessage={onMessage}
187
+ originWhitelist={['*']}
188
+ mixedContentMode="always"
189
+ />
190
+ </View>
191
+ );
192
+ },
193
+ );
194
+
195
+ TalkingHead.displayName = 'TalkingHead';
196
+
197
+ const styles = StyleSheet.create({
198
+ container: {
199
+ overflow: 'hidden',
200
+ borderRadius: 12,
201
+ backgroundColor: 'transparent',
202
+ },
203
+ webview: {
204
+ flex: 1,
205
+ backgroundColor: 'transparent',
206
+ },
207
+ });
@@ -0,0 +1,210 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ } from 'react';
9
+ import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native';
10
+ import { buildAvatarHtml } from './html';
11
+
12
+ export type TalkingHeadMood =
13
+ | 'neutral'
14
+ | 'happy'
15
+ | 'sad'
16
+ | 'angry'
17
+ | 'excited'
18
+ | 'thinking'
19
+ | 'concerned'
20
+ | 'surprised';
21
+
22
+ export interface TalkingHeadAccessory {
23
+ id: string;
24
+ url: string;
25
+ bone: string;
26
+ position: [number, number, number];
27
+ rotation: [number, number, number];
28
+ scale: number;
29
+ }
30
+
31
+ export interface TalkingHeadProps {
32
+ avatarUrl: string;
33
+ authToken?: string | null;
34
+ mood?: TalkingHeadMood;
35
+ cameraView?: 'head' | 'upper' | 'full';
36
+ cameraDistance?: number;
37
+ hairColor?: string;
38
+ skinColor?: string;
39
+ eyeColor?: string;
40
+ accessories?: TalkingHeadAccessory[];
41
+ onReady?: () => void;
42
+ onError?: (message: string) => void;
43
+ style?: StyleProp<ViewStyle>;
44
+ }
45
+
46
+ export interface TalkingHeadRef {
47
+ sendAmplitude: (amplitude: number) => void;
48
+ setMood: (mood: TalkingHeadMood) => void;
49
+ setHairColor: (color: string) => void;
50
+ setSkinColor: (color: string) => void;
51
+ setEyeColor: (color: string) => void;
52
+ setAccessories: (accessories: TalkingHeadAccessory[]) => void;
53
+ }
54
+
55
+ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
56
+ (
57
+ {
58
+ avatarUrl,
59
+ authToken,
60
+ mood = 'neutral',
61
+ cameraView = 'upper',
62
+ cameraDistance = -0.5,
63
+ hairColor,
64
+ skinColor,
65
+ eyeColor,
66
+ accessories,
67
+ onReady,
68
+ onError,
69
+ style,
70
+ },
71
+ ref,
72
+ ) => {
73
+ const iframeRef = useRef<HTMLIFrameElement>(null);
74
+ const readyRef = useRef(false);
75
+ const accessoriesRef = useRef(accessories);
76
+
77
+ useEffect(() => {
78
+ accessoriesRef.current = accessories;
79
+ }, [accessories]);
80
+
81
+ const post = useCallback((msg: object) => {
82
+ iframeRef.current?.contentWindow?.postMessage(JSON.stringify(msg), '*');
83
+ }, []);
84
+
85
+ useImperativeHandle(
86
+ ref,
87
+ () => ({
88
+ sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
89
+ setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
90
+ setHairColor: (color) => post({ type: 'hair_color', value: color }),
91
+ setSkinColor: (color) => post({ type: 'skin_color', value: color }),
92
+ setEyeColor: (color) => post({ type: 'eye_color', value: color }),
93
+ setAccessories: (newAccessories) =>
94
+ post({ type: 'set_accessories', accessories: newAccessories }),
95
+ }),
96
+ [post],
97
+ );
98
+
99
+ useEffect(() => {
100
+ if (readyRef.current) post({ type: 'mood', value: mood });
101
+ }, [mood, post]);
102
+
103
+ useEffect(() => {
104
+ if (accessories && readyRef.current) {
105
+ post({ type: 'set_accessories', accessories });
106
+ }
107
+ }, [accessories, post]);
108
+
109
+ useEffect(() => {
110
+ if (hairColor && readyRef.current) post({ type: 'hair_color', value: hairColor });
111
+ }, [hairColor, post]);
112
+
113
+ useEffect(() => {
114
+ if (skinColor && readyRef.current) post({ type: 'skin_color', value: skinColor });
115
+ }, [skinColor, post]);
116
+
117
+ useEffect(() => {
118
+ if (eyeColor && readyRef.current) post({ type: 'eye_color', value: eyeColor });
119
+ }, [eyeColor, post]);
120
+
121
+ const [initialMood] = React.useState(mood);
122
+ const [initialHairColor] = React.useState(hairColor);
123
+ const [initialSkinColor] = React.useState(skinColor);
124
+ const [initialEyeColor] = React.useState(eyeColor);
125
+
126
+ const html = useMemo(
127
+ () =>
128
+ buildAvatarHtml({
129
+ avatarUrl,
130
+ authToken,
131
+ mood: initialMood,
132
+ cameraView,
133
+ cameraDistance,
134
+ initialHairColor,
135
+ initialSkinColor,
136
+ initialEyeColor,
137
+ }),
138
+ [
139
+ avatarUrl,
140
+ authToken,
141
+ cameraView,
142
+ cameraDistance,
143
+ initialMood,
144
+ initialHairColor,
145
+ initialSkinColor,
146
+ initialEyeColor,
147
+ ],
148
+ );
149
+
150
+ // The HTML references window.ReactNativeWebView.postMessage — we need to
151
+ // inject that shim so messages come back to us via window.addEventListener.
152
+ const srcdoc = useMemo(() => {
153
+ const shim = `<script>window.ReactNativeWebView = { postMessage: function(d) { window.parent.postMessage(d, '*'); } };</script>`;
154
+ // Insert the shim right after <head> so it's available before the module script runs
155
+ return html.replace('<head>', '<head>' + shim);
156
+ }, [html]);
157
+
158
+ useEffect(() => {
159
+ const onMessage = (event: MessageEvent) => {
160
+ // Only accept messages from our iframe
161
+ if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return;
162
+ try {
163
+ const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
164
+ if (msg.type === 'ready') {
165
+ readyRef.current = true;
166
+ if (accessoriesRef.current?.length) {
167
+ post({ type: 'set_accessories', accessories: accessoriesRef.current });
168
+ }
169
+ onReady?.();
170
+ } else if (msg.type === 'error') {
171
+ onError?.(msg.message);
172
+ } else if (msg.type === 'log') {
173
+ console.log('[TalkingHead]', msg.message);
174
+ }
175
+ } catch {
176
+ // ignore non-JSON messages
177
+ }
178
+ };
179
+ window.addEventListener('message', onMessage);
180
+ return () => window.removeEventListener('message', onMessage);
181
+ }, [onReady, onError, post]);
182
+
183
+ return (
184
+ <View style={[styles.container, style]}>
185
+ <iframe
186
+ ref={iframeRef as any}
187
+ srcDoc={srcdoc}
188
+ style={{
189
+ width: '100%',
190
+ height: '100%',
191
+ border: 'none',
192
+ backgroundColor: 'transparent',
193
+ }}
194
+ sandbox="allow-scripts allow-same-origin"
195
+ title="TalkingHead Avatar"
196
+ />
197
+ </View>
198
+ );
199
+ },
200
+ );
201
+
202
+ TalkingHead.displayName = 'TalkingHead';
203
+
204
+ const styles = StyleSheet.create({
205
+ container: {
206
+ overflow: 'hidden',
207
+ borderRadius: 12,
208
+ backgroundColor: 'transparent',
209
+ },
210
+ });
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react-native';
3
+ import { TalkingHead } from '../TalkingHead';
4
+
5
+ // Mock react-native-webview
6
+ jest.mock('react-native-webview', () => {
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ const React = require('react');
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ const { View } = require('react-native');
11
+
12
+ const MockWebView = React.forwardRef((props: { testID?: string }, ref: React.Ref<unknown>) => {
13
+ React.useImperativeHandle(ref, () => ({
14
+ postMessage: jest.fn(),
15
+ }));
16
+ return <View {...props} />;
17
+ });
18
+ MockWebView.displayName = 'MockWebView';
19
+
20
+ return { WebView: MockWebView };
21
+ });
22
+
23
+ describe('TalkingHead', () => {
24
+ it('renders without crashing', () => {
25
+ render(
26
+ <TalkingHead
27
+ avatarUrl="https://example.com/avatar.glb"
28
+ style={{ flex: 1 }}
29
+ />
30
+ );
31
+ });
32
+ });
@@ -0,0 +1,24 @@
1
+ import { ACCESSORY_CATEGORIES } from '../sketchfab';
2
+
3
+ describe('ACCESSORY_CATEGORIES', () => {
4
+ it('includes the full accessory category set used by the app browsers', () => {
5
+ expect(ACCESSORY_CATEGORIES.map((category) => category.id)).toEqual(
6
+ expect.arrayContaining([
7
+ 'hair',
8
+ 'hat',
9
+ 'glasses',
10
+ 'necklace',
11
+ 'handheld',
12
+ 'glove',
13
+ 'wings',
14
+ 'tail',
15
+ 'cape',
16
+ 'belt',
17
+ 'shoulder_pad',
18
+ 'top',
19
+ 'bottom',
20
+ 'footwear',
21
+ ]),
22
+ );
23
+ });
24
+ });
@@ -0,0 +1,94 @@
1
+ import { pickTargetForMaterialName } from './matchers';
2
+ import { type AvatarAppearance, normalizeAppearance } from './schema';
3
+
4
+ type MaterialLike = {
5
+ name?: string;
6
+ color?: {
7
+ set: (value: string) => void;
8
+ };
9
+ };
10
+
11
+ type MeshLike = {
12
+ isMesh?: boolean;
13
+ material?: MaterialLike | MaterialLike[];
14
+ geometry?: {
15
+ attributes?: {
16
+ position?: { count?: number };
17
+ };
18
+ };
19
+ };
20
+
21
+ type Object3DLike = {
22
+ traverse?: (visitor: (object: unknown) => void) => void;
23
+ };
24
+
25
+ function asMaterialArray(material: MeshLike['material']): MaterialLike[] {
26
+ if (!material) {
27
+ return [];
28
+ }
29
+
30
+ return Array.isArray(material) ? material : [material];
31
+ }
32
+
33
+ function getVertexCount(mesh: MeshLike): number {
34
+ return mesh.geometry?.attributes?.position?.count ?? 0;
35
+ }
36
+
37
+ export function applyAppearanceToObject3D(
38
+ object3d: Object3DLike,
39
+ appearance: AvatarAppearance,
40
+ ): void {
41
+ if (typeof object3d.traverse !== 'function') {
42
+ return;
43
+ }
44
+
45
+ const normalizedAppearance = normalizeAppearance(appearance);
46
+
47
+ // First pass: apply name-matched colors and track whether skin was matched,
48
+ // also track the largest mesh for fallback skin coloring.
49
+ let skinMatched = false;
50
+ let largestVertexCount = 0;
51
+ let largestMeshMaterial: MaterialLike | null = null;
52
+
53
+ object3d.traverse((object) => {
54
+ const mesh = object as MeshLike;
55
+ if (!mesh?.isMesh) {
56
+ return;
57
+ }
58
+
59
+ const vertexCount = getVertexCount(mesh);
60
+
61
+ for (const material of asMaterialArray(mesh.material)) {
62
+ if (!material || typeof material.name !== 'string') {
63
+ continue;
64
+ }
65
+
66
+ const target = pickTargetForMaterialName(material.name);
67
+ if (!target) {
68
+ // Track the largest unmatched mesh as a skin fallback candidate
69
+ if (vertexCount > largestVertexCount && material.color && typeof material.color.set === 'function') {
70
+ largestVertexCount = vertexCount;
71
+ largestMeshMaterial = material;
72
+ }
73
+ continue;
74
+ }
75
+
76
+ if (target === 'skinColor') {
77
+ skinMatched = true;
78
+ }
79
+
80
+ const color = normalizedAppearance[target];
81
+ if (!color || !material.color || typeof material.color.set !== 'function') {
82
+ continue;
83
+ }
84
+
85
+ material.color.set(color);
86
+ }
87
+ });
88
+
89
+ // Second pass: if no skin material was found by name, apply skin color to
90
+ // the largest mesh — skin is the largest organ after all.
91
+ if (!skinMatched && largestMeshMaterial && normalizedAppearance.skinColor) {
92
+ (largestMeshMaterial as MaterialLike).color!.set(normalizedAppearance.skinColor);
93
+ }
94
+ }
@@ -0,0 +1,4 @@
1
+ export { applyAppearanceToObject3D } from './apply';
2
+ export { pickTargetForMaterialName } from './matchers';
3
+ export { type AvatarAppearance, normalizeAppearance } from './schema';
4
+ export type { AppearanceTarget } from './matchers';