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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SiteBay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,459 @@
1
+ # talking-head-studio
2
+
3
+ **Drop a talking, lip-syncing 3D avatar into any React app -- native or web -- in under five minutes.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/talking-head-studio.svg)](https://www.npmjs.com/package/talking-head-studio)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![Build Status](https://github.com/sitebay/react-native-talking-head/actions/workflows/ci.yml/badge.svg)](https://github.com/sitebay/react-native-talking-head/actions)
8
+
9
+ ---
10
+
11
+ ## Why this?
12
+
13
+ - **Truly cross-platform.** One component, two renderers. React Native gets a WebView; React on web gets an iframe with `srcdoc`. Same API, same props, same ref.
14
+ - **Bring any GLB.** Rigged models with ARKit/Oculus blend shapes get full phoneme-based lip-sync via HeadAudio. Non-rigged models still work -- they get a static viewer with amplitude-driven jaw animation as a fallback. No vendor lock-in to a single avatar format.
15
+ - **Built for LLM voice pipelines.** Wire `sendAmplitude` to LiveKit, Web Audio, ElevenLabs, or any audio source. The avatar speaks when your AI speaks.
16
+ - **Accessory system.** Attach hats, glasses, backpacks, or any GLB to any bone at runtime. Position, rotate, and scale each piece independently.
17
+
18
+ ---
19
+
20
+ ## Table of Contents
21
+
22
+ - [Installation](#installation)
23
+ - [Quick Start](#quick-start)
24
+ - [Subpath Exports](#subpath-exports)
25
+ - [Props](#props)
26
+ - [Ref API](#ref-api)
27
+ - [Accessories](#accessories)
28
+ - [Color Customization](#color-customization)
29
+ - [Voice Pipeline Integration](#voice-pipeline-integration)
30
+ - [GLB Compatibility](#glb-compatibility)
31
+ - [Plain React / Next.js](#plain-react--nextjs)
32
+ - [MotionEngine (Upcoming)](#motionengine-upcoming)
33
+ - [Contributing](#contributing)
34
+ - [Credits](#credits)
35
+ - [License](#license)
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ### React Native / Expo
42
+
43
+ ```bash
44
+ npm install talking-head-studio react-native-webview
45
+ ```
46
+
47
+ `react-native-webview` is a peer dependency. If you are using Expo, it is available as a built-in package.
48
+
49
+ ### Web only (React, Next.js, Vite)
50
+
51
+ ```bash
52
+ npm install talking-head-studio
53
+ ```
54
+
55
+ No WebView dependency needed. The package ships a `.web.tsx` entry point that renders via `<iframe srcdoc>` automatically when bundled for web targets.
56
+
57
+ ---
58
+
59
+ ## Quick Start
60
+
61
+ ```tsx
62
+ import { useRef } from 'react';
63
+ import { TalkingHead, type TalkingHeadRef } from 'talking-head-studio';
64
+
65
+ export default function Avatar() {
66
+ const ref = useRef<TalkingHeadRef>(null);
67
+
68
+ return (
69
+ <TalkingHead
70
+ ref={ref}
71
+ avatarUrl="https://models.readyplayer.me/your-model.glb"
72
+ mood="happy"
73
+ cameraView="upper"
74
+ hairColor="#1a1a2e"
75
+ skinColor="#e0a370"
76
+ accessories={[
77
+ {
78
+ id: 'sunglasses',
79
+ url: 'https://example.com/sunglasses.glb',
80
+ bone: 'Head',
81
+ position: [0, 0.08, 0.12],
82
+ rotation: [0, 0, 0],
83
+ scale: 1.0,
84
+ },
85
+ ]}
86
+ style={{ width: 400, height: 600 }}
87
+ onReady={() => console.log('Avatar loaded')}
88
+ onError={(msg) => console.error('Load failed:', msg)}
89
+ />
90
+ );
91
+ }
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Subpath Exports
97
+
98
+ The package ships five independent entry points. Import only what you need — each subpath has its own optional peer dependencies.
99
+
100
+ ### `talking-head-studio` — Live talking avatar
101
+ ```tsx
102
+ import { TalkingHead } from 'talking-head-studio';
103
+ // Peer deps: react, react-native (optional), react-native-webview (optional)
104
+ ```
105
+
106
+ ### `talking-head-studio/editor` — 3D editor with gizmo (web)
107
+ R3F-based canvas with PivotControls gizmo for placing accessories on an avatar. Web only.
108
+ ```tsx
109
+ import { AvatarCanvas } from 'talking-head-studio/editor';
110
+ // Peer deps: @react-three/fiber, @react-three/drei, three
111
+ ```
112
+
113
+ ### `talking-head-studio/appearance` — Material color system
114
+ Apply skin/hair/eye colors to any GLB avatar. Works in both the live view and the 3D editor.
115
+ ```tsx
116
+ import { applyAppearanceToObject3D, type AvatarAppearance } from 'talking-head-studio/appearance';
117
+ // No extra peer deps
118
+ ```
119
+
120
+ ### `talking-head-studio/voice` — Audio recording hooks
121
+ Headless hooks for recording voice samples (WebM→WAV conversion included). Backend-agnostic — send audio wherever you want (Qwen3-TTS, ElevenLabs, Groq, etc).
122
+ ```tsx
123
+ import { useAudioRecording, useAudioPlayer } from 'talking-head-studio/voice';
124
+ // No extra peer deps (browser APIs only)
125
+ ```
126
+
127
+ ### `talking-head-studio/sketchfab` — Sketchfab search & download
128
+ Headless hooks and utilities for searching and downloading GLB models from Sketchfab. Bring your own UI and API key.
129
+ ```tsx
130
+ import { useSketchfabSearch, ACCESSORY_CATEGORIES, downloadModel } from 'talking-head-studio/sketchfab';
131
+ // No extra peer deps
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Props
137
+
138
+ | Prop | Type | Default | Description |
139
+ |------|------|---------|-------------|
140
+ | `avatarUrl` | `string` | **required** | URL to any `.glb` model. Rigged or non-rigged. |
141
+ | `authToken` | `string \| null` | `null` | Bearer token sent when fetching the model URL. CDN URLs are excluded automatically. |
142
+ | `mood` | `TalkingHeadMood` | `'neutral'` | Avatar expression. See [Moods](#moods) below. |
143
+ | `cameraView` | `'head' \| 'upper' \| 'full'` | `'upper'` | Camera framing preset. |
144
+ | `cameraDistance` | `number` | `-0.5` | Camera zoom offset. Negative values zoom in. |
145
+ | `hairColor` | `string` | -- | CSS color applied to materials whose name contains `hair` or `fur`. |
146
+ | `skinColor` | `string` | -- | CSS color applied to materials whose name contains `skin`, `body`, or `face`. |
147
+ | `eyeColor` | `string` | -- | CSS color applied to materials whose name contains `eye` or `iris`. |
148
+ | `accessories` | `TalkingHeadAccessory[]` | `[]` | Array of GLB items to attach to bones. See [Accessories](#accessories). |
149
+ | `onReady` | `() => void` | -- | Fires once the avatar and scene are fully loaded. |
150
+ | `onError` | `(message: string) => void` | -- | Fires on load failure. |
151
+ | `style` | `ViewStyle` | -- | Container style (works on both native and web). |
152
+
153
+ ### Moods
154
+
155
+ The `mood` prop accepts one of:
156
+
157
+ ```
158
+ neutral | happy | sad | angry | excited | thinking | concerned | surprised
159
+ ```
160
+
161
+ Mood can be changed at any time via props or the ref API. On rigged models, mood maps to blend shape expressions. On non-rigged models, mood is a no-op.
162
+
163
+ ---
164
+
165
+ ## Ref API
166
+
167
+ Access runtime controls through a React ref. Every method is safe to call at any time -- calls made before the avatar is ready are silently dropped.
168
+
169
+ ```tsx
170
+ const ref = useRef<TalkingHeadRef>(null);
171
+
172
+ // Drive lip-sync from an audio amplitude value (0..1)
173
+ ref.current?.sendAmplitude(0.7);
174
+
175
+ // Change expression
176
+ ref.current?.setMood('excited');
177
+
178
+ // Change colors at runtime
179
+ ref.current?.setHairColor('#ff0000');
180
+ ref.current?.setSkinColor('#8d5524');
181
+ ref.current?.setEyeColor('#2e86de');
182
+
183
+ // Swap accessories without re-mounting the component
184
+ ref.current?.setAccessories([
185
+ {
186
+ id: 'crown',
187
+ url: 'https://example.com/crown.glb',
188
+ bone: 'Head',
189
+ position: [0, 0.22, 0],
190
+ rotation: [0, 0, 0],
191
+ scale: 0.8,
192
+ },
193
+ ]);
194
+ ```
195
+
196
+ ### Ref Methods
197
+
198
+ | Method | Signature | Description |
199
+ |--------|-----------|-------------|
200
+ | `sendAmplitude` | `(amplitude: number) => void` | Feed audio amplitude (0 to 1) for jaw animation. |
201
+ | `setMood` | `(mood: TalkingHeadMood) => void` | Change avatar expression at runtime. |
202
+ | `setHairColor` | `(color: string) => void` | Update hair material color. |
203
+ | `setSkinColor` | `(color: string) => void` | Update skin material color. |
204
+ | `setEyeColor` | `(color: string) => void` | Update eye/iris material color. |
205
+ | `setAccessories` | `(accessories: TalkingHeadAccessory[]) => void` | Replace the entire accessory set. Handles loading, diffing, and cleanup automatically. |
206
+
207
+ ---
208
+
209
+ ## Accessories
210
+
211
+ Attach any GLB model to any bone on the avatar skeleton. The system handles loading, disposal, and transform updates.
212
+
213
+ ### Accessory shape
214
+
215
+ ```ts
216
+ interface TalkingHeadAccessory {
217
+ id: string; // Unique identifier for diffing
218
+ url: string; // URL to a .glb file
219
+ bone: string; // Target bone name (e.g. "Head", "RightHand", "Spine")
220
+ position: [number, number, number]; // Offset from the bone origin
221
+ rotation: [number, number, number]; // Euler rotation in radians
222
+ scale: number; // Uniform scale factor
223
+ }
224
+ ```
225
+
226
+ ### Example: hat + glasses + backpack
227
+
228
+ ```tsx
229
+ <TalkingHead
230
+ avatarUrl="https://example.com/avatar.glb"
231
+ accessories={[
232
+ {
233
+ id: 'cowboy-hat',
234
+ url: '/models/cowboy-hat.glb',
235
+ bone: 'Head',
236
+ position: [0, 0.18, 0],
237
+ rotation: [0, 0, 0],
238
+ scale: 1.2,
239
+ },
240
+ {
241
+ id: 'aviators',
242
+ url: '/models/aviator-glasses.glb',
243
+ bone: 'Head',
244
+ position: [0, 0.06, 0.11],
245
+ rotation: [0, 0, 0],
246
+ scale: 1.0,
247
+ },
248
+ {
249
+ id: 'backpack',
250
+ url: '/models/backpack.glb',
251
+ bone: 'Spine1',
252
+ position: [0, 0, -0.15],
253
+ rotation: [0, Math.PI, 0],
254
+ scale: 0.9,
255
+ },
256
+ ]}
257
+ />
258
+ ```
259
+
260
+ ### Common bone names
261
+
262
+ Mixamo-rigged models typically expose these bones:
263
+
264
+ ```
265
+ Head, Neck, Spine, Spine1, Spine2,
266
+ LeftShoulder, LeftArm, LeftForeArm, LeftHand,
267
+ RightShoulder, RightArm, RightForeArm, RightHand,
268
+ LeftUpLeg, LeftLeg, LeftFoot,
269
+ RightUpLeg, RightLeg, RightFoot
270
+ ```
271
+
272
+ Bone matching is flexible -- if an exact match is not found, the component tries a prefix match (useful for Sketchfab exports like `Head_5`). If no bone matches, the accessory falls back to the scene root.
273
+
274
+ ### Runtime accessory swaps
275
+
276
+ ```tsx
277
+ // Remove all accessories
278
+ ref.current?.setAccessories([]);
279
+
280
+ // Swap glasses for a monocle
281
+ ref.current?.setAccessories([
282
+ { id: 'monocle', url: '/models/monocle.glb', bone: 'Head', position: [0.03, 0.07, 0.11], rotation: [0, 0, 0], scale: 0.6 },
283
+ ]);
284
+ ```
285
+
286
+ Accessories that were previously loaded but are absent from the new array are automatically disposed (geometry, materials, textures).
287
+
288
+ ---
289
+
290
+ ## Color Customization
291
+
292
+ Colors can be set via props (applied on initial load) or via the ref API (applied at runtime without reloading the model).
293
+
294
+ The system matches material names against known keywords:
295
+
296
+ | Target | Material name keywords |
297
+ |--------|----------------------|
298
+ | Hair | `hair`, `fur` |
299
+ | Skin | `skin`, `body`, `face` |
300
+ | Eyes | `eye`, `iris` |
301
+
302
+ ```tsx
303
+ // Via props
304
+ <TalkingHead hairColor="#2d1b00" skinColor="#f0c8a0" eyeColor="#3d6b4f" />
305
+
306
+ // Via ref (runtime)
307
+ ref.current?.setHairColor('#ff4500');
308
+ ref.current?.setSkinColor('#c68642');
309
+ ref.current?.setEyeColor('#1abc9c');
310
+ ```
311
+
312
+ This works on both rigged and non-rigged models -- any GLB with appropriately named materials will respond to color changes.
313
+
314
+ ---
315
+
316
+ ## Voice Pipeline Integration
317
+
318
+ The component is designed to sit at the end of a voice pipeline. Feed it audio amplitude and it handles the rest.
319
+
320
+ ### Primary: HeadAudio phoneme lip-sync
321
+
322
+ On rigged models in browser contexts with Web Audio available, [HeadAudio](https://github.com/met4citizen/HeadAudio) provides phoneme-level lip-sync automatically. Audio elements in the page are intercepted and routed through the lip-sync engine -- no wiring required on your end.
323
+
324
+ ### Fallback: amplitude-driven jaw
325
+
326
+ When phoneme-level lip-sync is unavailable (React Native WebView, non-rigged models, or missing blend shapes), `sendAmplitude` drives jaw movement directly via morph targets.
327
+
328
+ ### LiveKit integration
329
+
330
+ ```tsx
331
+ import { useDataChannel } from '@livekit/components-react';
332
+
333
+ function AvatarWithLiveKit() {
334
+ const ref = useRef<TalkingHeadRef>(null);
335
+
336
+ useDataChannel('agent_speaking', (data) => {
337
+ if (data.amplitude !== undefined) {
338
+ ref.current?.sendAmplitude(data.amplitude);
339
+ }
340
+ });
341
+
342
+ return <TalkingHead ref={ref} avatarUrl="..." />;
343
+ }
344
+ ```
345
+
346
+ ### Web Audio analyser
347
+
348
+ ```tsx
349
+ const audioCtx = new AudioContext();
350
+ const analyser = audioCtx.createAnalyser();
351
+ const buf = new Uint8Array(analyser.frequencyBinCount);
352
+
353
+ // Connect your audio source to the analyser
354
+ source.connect(analyser);
355
+
356
+ // Poll amplitude and feed the avatar
357
+ const interval = setInterval(() => {
358
+ analyser.getByteFrequencyData(buf);
359
+ const amplitude = buf.reduce((a, b) => a + b, 0) / buf.length / 255;
360
+ ref.current?.sendAmplitude(amplitude);
361
+ }, 50);
362
+ ```
363
+
364
+ ### Any audio source
365
+
366
+ The only contract is a number between 0 and 1, called at roughly 20 Hz. This works with ElevenLabs, OpenAI Realtime, Deepgram, Whisper, or any other TTS/STT pipeline.
367
+
368
+ ---
369
+
370
+ ## GLB Compatibility
371
+
372
+ ### Rigged models (full feature set)
373
+
374
+ For the complete experience -- phoneme lip-sync, expressions, moods, gestures -- your GLB should have:
375
+
376
+ - A **Mixamo-compatible armature** (the component expects standard bone names)
377
+ - **ARKit blend shapes** and/or **Oculus viseme blend shapes** for lip-sync
378
+ - Standard Three.js-compatible GLB format
379
+
380
+ Models from [Ready Player Me](https://readyplayer.me/), [Avaturn](https://avaturn.me/), or any Mixamo-rigged source work out of the box.
381
+
382
+ ### Non-rigged models (static fallback)
383
+
384
+ Any valid GLB loads successfully. Non-rigged models get:
385
+
386
+ - Auto-framing and centering in the viewport
387
+ - Orbit controls for rotation
388
+ - Embedded animation playback (walk cycles, idle loops, etc.)
389
+ - Amplitude-driven jaw via morph targets (if the model has `jawOpen`, `mouthOpen`, or `viseme_aa` blend shapes)
390
+ - Color customization (if materials are named appropriately)
391
+ - Accessory attachment (falls back to scene root if no bones exist)
392
+
393
+ ### Upstream documentation
394
+
395
+ For detailed model authoring guidance, see the [TalkingHead documentation](https://github.com/met4citizen/TalkingHead).
396
+
397
+ ---
398
+
399
+ ## Plain React / Next.js
400
+
401
+ Despite the package name, this works on the web without React Native installed. The `react-native` and `react-native-webview` peer dependencies are both marked optional.
402
+
403
+ On web, the component renders an `<iframe>` with `srcdoc` containing the full Three.js scene. No WebView, no native modules, no build plugins.
404
+
405
+ ```tsx
406
+ // Works in any React 18+ web app
407
+ import { TalkingHead } from 'talking-head-studio';
408
+
409
+ export default function Page() {
410
+ return (
411
+ <TalkingHead
412
+ avatarUrl="/models/avatar.glb"
413
+ mood="happy"
414
+ style={{ width: 600, height: 800 }}
415
+ />
416
+ );
417
+ }
418
+ ```
419
+
420
+ Bundlers that support the `react-native` platform field (Metro, Expo) resolve `TalkingHead.tsx` (WebView). Standard web bundlers (webpack, Vite, esbuild) resolve `TalkingHead.web.tsx` (iframe). The API is identical.
421
+
422
+ ---
423
+
424
+ ## MotionEngine (Upcoming)
425
+
426
+ [MotionEngine](https://github.com/lhupyn/motion-engine) integration is in development. This will add real-time body tracking and gesture replay to the avatar, driven by webcam or motion capture data.
427
+
428
+ Stay tuned.
429
+
430
+ ---
431
+
432
+ ## Contributing
433
+
434
+ Contributions are welcome. Please open an issue to discuss your idea before submitting a pull request.
435
+
436
+ ```bash
437
+ git clone https://github.com/sitebay/react-native-talking-head.git
438
+ cd react-native-talking-head
439
+ npm install
440
+ npm run typecheck
441
+ npm test
442
+ ```
443
+
444
+ ---
445
+
446
+ ## Credits
447
+
448
+ This project builds on excellent open-source work:
449
+
450
+ - [met4citizen/TalkingHead](https://github.com/met4citizen/TalkingHead) -- The 3D avatar engine powering model loading, rigging, and expression systems.
451
+ - [met4citizen/HeadAudio](https://github.com/met4citizen/HeadAudio) -- Phoneme-based lip-sync from audio streams using AudioWorklet.
452
+ - [lhupyn/motion-engine](https://github.com/lhupyn/motion-engine) -- Real-time body motion tracking (upcoming integration).
453
+ - [Three.js](https://threejs.org/) -- 3D rendering, loaded via CDN at runtime.
454
+
455
+ ---
456
+
457
+ ## License
458
+
459
+ MIT
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ export type TalkingHeadMood = 'neutral' | 'happy' | 'sad' | 'angry' | 'excited' | 'thinking' | 'concerned' | 'surprised';
4
+ export interface TalkingHeadAccessory {
5
+ id: string;
6
+ url: string;
7
+ bone: string;
8
+ position: [number, number, number];
9
+ rotation: [number, number, number];
10
+ scale: number;
11
+ }
12
+ export interface TalkingHeadProps {
13
+ avatarUrl: string;
14
+ authToken?: string | null;
15
+ mood?: TalkingHeadMood;
16
+ cameraView?: 'head' | 'upper' | 'full';
17
+ cameraDistance?: number;
18
+ hairColor?: string;
19
+ skinColor?: string;
20
+ eyeColor?: string;
21
+ accessories?: TalkingHeadAccessory[];
22
+ onReady?: () => void;
23
+ onError?: (message: string) => void;
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+ export interface TalkingHeadRef {
27
+ sendAmplitude: (amplitude: number) => void;
28
+ setMood: (mood: TalkingHeadMood) => void;
29
+ setHairColor: (color: string) => void;
30
+ setSkinColor: (color: string) => void;
31
+ setEyeColor: (color: string) => void;
32
+ setAccessories: (accessories: TalkingHeadAccessory[]) => void;
33
+ }
34
+ export declare const TalkingHead: React.ForwardRefExoticComponent<TalkingHeadProps & React.RefAttributes<TalkingHeadRef>>;
35
+ //# sourceMappingURL=TalkingHead.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TalkingHead.d.ts","sourceRoot":"","sources":["../src/TalkingHead.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,KAAK,SAAS,EAAoB,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAIhF,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,OAAO,GACP,KAAK,GACL,OAAO,GACP,SAAS,GACT,UAAU,GACV,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,OAAO,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAC;IACzC,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,cAAc,EAAE,CAAC,WAAW,EAAE,oBAAoB,EAAE,KAAK,IAAI,CAAC;CAC/D;AAED,eAAO,MAAM,WAAW,yFAyIvB,CAAC"}
@@ -0,0 +1,107 @@
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { WebView } from 'react-native-webview';
4
+ import { buildAvatarHtml } from './html';
5
+ export const TalkingHead = forwardRef(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onReady, onError, style, }, ref) => {
6
+ const webViewRef = useRef(null);
7
+ const post = useCallback((msg) => {
8
+ webViewRef.current?.postMessage(JSON.stringify(msg));
9
+ }, []);
10
+ useImperativeHandle(ref, () => ({
11
+ sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
12
+ setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
13
+ setHairColor: (color) => post({ type: 'hair_color', value: color }),
14
+ setSkinColor: (color) => post({ type: 'skin_color', value: color }),
15
+ setEyeColor: (color) => post({ type: 'eye_color', value: color }),
16
+ setAccessories: (newAccessories) => post({ type: 'set_accessories', accessories: newAccessories }),
17
+ }), [post]);
18
+ useEffect(() => {
19
+ if (readyRef.current)
20
+ post({ type: 'mood', value: mood });
21
+ }, [mood, post]);
22
+ // Track whether the WebView JS is ready to receive messages
23
+ const readyRef = useRef(false);
24
+ // Always hold the latest accessories so the ready handler can send them
25
+ const accessoriesRef = useRef(accessories);
26
+ useEffect(() => {
27
+ accessoriesRef.current = accessories;
28
+ }, [accessories]);
29
+ useEffect(() => {
30
+ // Only post if the WebView is already ready; otherwise the ready handler sends them
31
+ if (accessories && readyRef.current) {
32
+ post({ type: 'set_accessories', accessories });
33
+ }
34
+ }, [accessories, post]);
35
+ useEffect(() => {
36
+ if (hairColor && readyRef.current)
37
+ post({ type: 'hair_color', value: hairColor });
38
+ }, [hairColor, post]);
39
+ useEffect(() => {
40
+ if (skinColor && readyRef.current)
41
+ post({ type: 'skin_color', value: skinColor });
42
+ }, [skinColor, post]);
43
+ useEffect(() => {
44
+ if (eyeColor && readyRef.current)
45
+ post({ type: 'eye_color', value: eyeColor });
46
+ }, [eyeColor, post]);
47
+ // Color props are intentionally excluded from deps — live updates go via postMessage.
48
+ // Only avatarUrl, authToken, cameraView, cameraDistance cause a full WebView reload.
49
+ const [initialMood] = React.useState(mood);
50
+ const [initialHairColor] = React.useState(hairColor);
51
+ const [initialSkinColor] = React.useState(skinColor);
52
+ const [initialEyeColor] = React.useState(eyeColor);
53
+ const html = useMemo(() => buildAvatarHtml({
54
+ avatarUrl,
55
+ authToken,
56
+ mood: initialMood,
57
+ cameraView,
58
+ cameraDistance,
59
+ initialHairColor: initialHairColor,
60
+ initialSkinColor: initialSkinColor,
61
+ initialEyeColor: initialEyeColor,
62
+ }), [
63
+ avatarUrl,
64
+ authToken,
65
+ cameraView,
66
+ cameraDistance,
67
+ initialMood,
68
+ initialHairColor,
69
+ initialSkinColor,
70
+ initialEyeColor,
71
+ ]);
72
+ const onMessage = useCallback((event) => {
73
+ try {
74
+ const msg = JSON.parse(event.nativeEvent.data);
75
+ if (msg.type === 'ready') {
76
+ readyRef.current = true;
77
+ // Flush any accessories that arrived before the WebView was ready
78
+ if (accessoriesRef.current?.length) {
79
+ post({ type: 'set_accessories', accessories: accessoriesRef.current });
80
+ }
81
+ onReady?.();
82
+ }
83
+ else if (msg.type === 'error')
84
+ onError?.(msg.message);
85
+ else if (msg.type === 'log')
86
+ console.log('[TalkingHead]', msg.message);
87
+ }
88
+ catch (err) {
89
+ console.warn('[TalkingHead] Invalid message received from WebView:', err);
90
+ }
91
+ }, [onReady, onError, post]);
92
+ return (<View style={[styles.container, style]}>
93
+ <WebView ref={webViewRef} source={{ html }} style={styles.webview} javaScriptEnabled domStorageEnabled allowsInlineMediaPlayback mediaPlaybackRequiresUserAction={false} onMessage={onMessage} originWhitelist={['*']} mixedContentMode="always"/>
94
+ </View>);
95
+ });
96
+ TalkingHead.displayName = 'TalkingHead';
97
+ const styles = StyleSheet.create({
98
+ container: {
99
+ overflow: 'hidden',
100
+ borderRadius: 12,
101
+ backgroundColor: 'transparent',
102
+ },
103
+ webview: {
104
+ flex: 1,
105
+ backgroundColor: 'transparent',
106
+ },
107
+ });
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ export type TalkingHeadMood = 'neutral' | 'happy' | 'sad' | 'angry' | 'excited' | 'thinking' | 'concerned' | 'surprised';
4
+ export interface TalkingHeadAccessory {
5
+ id: string;
6
+ url: string;
7
+ bone: string;
8
+ position: [number, number, number];
9
+ rotation: [number, number, number];
10
+ scale: number;
11
+ }
12
+ export interface TalkingHeadProps {
13
+ avatarUrl: string;
14
+ authToken?: string | null;
15
+ mood?: TalkingHeadMood;
16
+ cameraView?: 'head' | 'upper' | 'full';
17
+ cameraDistance?: number;
18
+ hairColor?: string;
19
+ skinColor?: string;
20
+ eyeColor?: string;
21
+ accessories?: TalkingHeadAccessory[];
22
+ onReady?: () => void;
23
+ onError?: (message: string) => void;
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+ export interface TalkingHeadRef {
27
+ sendAmplitude: (amplitude: number) => void;
28
+ setMood: (mood: TalkingHeadMood) => void;
29
+ setHairColor: (color: string) => void;
30
+ setSkinColor: (color: string) => void;
31
+ setEyeColor: (color: string) => void;
32
+ setAccessories: (accessories: TalkingHeadAccessory[]) => void;
33
+ }
34
+ export declare const TalkingHead: React.ForwardRefExoticComponent<TalkingHeadProps & React.RefAttributes<TalkingHeadRef>>;
35
+ //# sourceMappingURL=TalkingHead.web.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TalkingHead.web.d.ts","sourceRoot":"","sources":["../src/TalkingHead.web.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,KAAK,SAAS,EAAoB,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAGhF,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,OAAO,GACP,KAAK,GACL,OAAO,GACP,SAAS,GACT,UAAU,GACV,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,OAAO,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAC;IACzC,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,cAAc,EAAE,CAAC,WAAW,EAAE,oBAAoB,EAAE,KAAK,IAAI,CAAC;CAC/D;AAED,eAAO,MAAM,WAAW,yFAiJvB,CAAC"}