react-ai-avatar 0.1.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/LICENSE +21 -0
  2. package/README.md +310 -0
  3. package/dist/lib/GlbArkitAvatar-CcPWCsQV.cjs +1 -0
  4. package/dist/lib/GlbArkitAvatar-Dm9STiyR.js +232 -0
  5. package/dist/lib/VrmAvatar-CehRzj0J.js +224 -0
  6. package/dist/lib/VrmAvatar-D_jr2TOG.cjs +1 -0
  7. package/dist/lib/components/AudioVisualizer.d.ts +17 -0
  8. package/dist/lib/components/ContractAvatar.d.ts +25 -0
  9. package/dist/lib/components/DefaultAvatar.d.ts +37 -0
  10. package/dist/lib/components/DiceBearAvatar.d.ts +48 -0
  11. package/dist/lib/components/DiceBearThumb.d.ts +15 -0
  12. package/dist/lib/components/DoodleAvatar.d.ts +21 -0
  13. package/dist/lib/components/GeometricAvatar.d.ts +22 -0
  14. package/dist/lib/components/GlbArkitAvatar.d.ts +7 -0
  15. package/dist/lib/components/MemojiAvatar.d.ts +19 -0
  16. package/dist/lib/components/PixelArtAvatar.d.ts +23 -0
  17. package/dist/lib/components/RealtimeAvatar.d.ts +74 -0
  18. package/dist/lib/components/SquirrelAvatar.d.ts +29 -0
  19. package/dist/lib/components/VrmAvatar.d.ts +6 -0
  20. package/dist/lib/index.cjs +6 -0
  21. package/dist/lib/index.js +1231 -0
  22. package/dist/lib/lib/color.d.ts +6 -0
  23. package/dist/lib/lib/dicebear.d.ts +110 -0
  24. package/dist/lib/lib/index.d.ts +34 -0
  25. package/dist/lib/lib/mouthEngine.d.ts +37 -0
  26. package/dist/lib/lib/speechActivity.d.ts +51 -0
  27. package/dist/lib/lib/types.d.ts +22 -0
  28. package/dist/lib/lib/useAudioMouth.d.ts +20 -0
  29. package/dist/lib/lib/useAvatarRuntime.d.ts +39 -0
  30. package/dist/lib/lib/useReducedMotion.d.ts +5 -0
  31. package/dist/lib/lib/useStreamingTextActivity.d.ts +46 -0
  32. package/dist/lib/lib/vrm.d.ts +9 -0
  33. package/dist/lib/react-ai-avatar.css +1 -0
  34. package/dist/lib/useReducedMotion-BDcEizfP.js +104 -0
  35. package/dist/lib/useReducedMotion-BRDEmRNI.cjs +1 -0
  36. package/dist/lib/vrm.cjs +1 -0
  37. package/dist/lib/vrm.js +4 -0
  38. package/package.json +127 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ariel A.
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,310 @@
1
+ # react-ai-avatar
2
+
3
+ > A presentational React avatar for realtime LLM voice UIs — **you bring the connection, it brings the face.**
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A lightweight, MIT-licensed React library that renders an animated avatar reacting to your AI's conversation state and audio. It is **completely LLM-agnostic**: it doesn't know about Gemini, OpenAI or ElevenLabs. You pass two live things — a `state` and (optionally) a WebAudio `AnalyserNode` — and it does the rest.
8
+
9
+ ```tsx
10
+ import { RealtimeAvatar } from 'react-ai-avatar';
11
+ import 'react-ai-avatar/style.css';
12
+
13
+ // The whole thing, minimally. Everything but `state` has a sensible default.
14
+ <RealtimeAvatar state="speaking" />
15
+ ```
16
+
17
+ ## Philosophy
18
+
19
+ One thing, done well, embeddable in a few lines, no backend, MIT. The library handles exactly one step of your voice pipeline: turning audio amplitude + state changes into a face that visibly **listens, thinks and speaks**. Your host app keeps the microphone, the WebSocket and the AI provider — none of those dependencies enter your bundle.
20
+
21
+ ## Features
22
+
23
+ - 👄 **Audio-reactive mouth** — analyzes amplitude and frequency bands in real time. This is deliberately *not* phoneme-perfect "lip-sync": an `AnalyserNode` gives energy, not phonemes, and for flat avatars amplitude is what looks right.
24
+ - 🦺 **Graceful degradation** — `analyser={null}` while `state="speaking"`? The mouth animates with a synthetic speech-like pattern instead of freezing. Perfect for demos and non-WebRTC apps.
25
+ - ⌨️ **Text-streaming LLMs too** — no audio? Drive the mouth from *token cadence* with `createSpeechActivity()`. A text-only assistant (OpenAI-style `/chat/completions` or `/responses` with `stream: true`) gets a face that visibly tracks the stream — busy while tokens arrive, settling on pauses.
26
+ - 🧠 **A visible `thinking` state** — pulsing thought bubble + upward gaze. Your users *see* the LLM thinking, not just a color change.
27
+ - 🎨 **Own-design avatar catalog** — `geometric`, `memoji`, `pixelart`, `doodle`: four MIT, CC0-safe SVG presets. No third-party assets, no attribution headaches.
28
+ - 🎲 **DiceBear avatars (`dicebear`)** — generate deterministic [DiceBear](https://www.dicebear.com) avatars client-side, from a curated **CC0-only** style set (still no attribution). Animated with an audio-reactive bounce.
29
+ - 🔌 **Bring your own SVG (`byos`)** — any SVG implementing the small layer contract gets the full animation runtime for free. Your avatar, your license.
30
+ - ♿ **Production quality** — SSR-safe (Next.js friendly), honors `prefers-reduced-motion`, announces state changes via `aria-live`.
31
+ - 🧊 **Optional 3D (VRM)** — `variant="vrm"` renders VRoid/VRM models with visemes and gaze tracking. The three.js stack is an *optional* peer dependency, lazy-loaded only if you use it.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install react-ai-avatar motion
37
+ ```
38
+
39
+ `react`, `react-dom` and `motion` are peer dependencies. For the optional VRM variant, also install:
40
+
41
+ ```bash
42
+ npm install three @react-three/fiber @react-three/drei @pixiv/three-vrm
43
+ ```
44
+
45
+ ## Quick start
46
+
47
+ The only prop you *have* to pass is `state` — you resolve it in your app, the avatar never infers it. Everything else has a default, so this already works:
48
+
49
+ ```tsx
50
+ import { RealtimeAvatar } from 'react-ai-avatar';
51
+ import 'react-ai-avatar/style.css';
52
+
53
+ export default function App() {
54
+ // You resolve this in your app (Gemini, OpenAI Realtime, WebRTC, anything)
55
+ const aiState = 'speaking'; // 'idle' | 'listening' | 'thinking' | 'speaking'
56
+
57
+ return <RealtimeAvatar state={aiState} />;
58
+ }
59
+ ```
60
+
61
+ With no `analyser`, `speaking` falls back to a synthetic speech-like mouth — great for getting something on screen before the audio pipeline exists. Pass an `AnalyserNode` to make the mouth react to real audio (see [Getting an `AnalyserNode`](#getting-an-analysernode)).
62
+
63
+ ### Customizing further
64
+
65
+ Every default is overridable. Opt into as much as you need:
66
+
67
+ ```tsx
68
+ <RealtimeAvatar
69
+ state={aiState}
70
+ analyser={analyser} // AnalyserNode | null — real audio-reactive mouth
71
+ size={300} // default 280
72
+ variant="geometric" // 'geometric' | 'memoji' | 'pixelart' | 'doodle' | 'dicebear' | 'vrm' | 'glb' | 'byos'
73
+ customization={{ skinColor: '#f5c7a9', hairColor: '#2c2c2c', glasses: true, headphones: true }}
74
+ stateColors={{ idle: '#4b5563', listening: '#3b82f6', thinking: '#8b5cf6', speaking: '#10b981' }}
75
+ />
76
+ ```
77
+
78
+ ## The avatar catalog
79
+
80
+ | variant | style | notes |
81
+ |---|---|---|
82
+ | `geometric` | minimal flat geometry | the default; canonical layer-contract example |
83
+ | `memoji` | soft volumetric head | radial gradients, glossy eyes, blush |
84
+ | `pixelart` | retro 32×32 grid | mouth and pupils move in whole pixels |
85
+ | `doodle` | hand-drawn ink sketch | wobbly strokes, sketched thought bubble |
86
+ | `dicebear` | [DiceBear](https://www.dicebear.com) styles | optional, lazy-loaded; curated CC0 set; pass `dicebearCollection` / `dicebearSeed` |
87
+ | `vrm` | 3D VRoid/VRM model | optional, lazy-loaded; pass `vrmUrl` |
88
+ | `glb` | 3D glTF + ARKit blendshapes | optional, lazy-loaded; pass `glbUrl`. Works with [Microsoft Rocketbox](https://github.com/microsoft/Microsoft-Rocketbox) (MIT), Ready Player Me, or any `.glb` exposing the 52 ARKit morph targets |
89
+ | `byos` | **your** SVG | pass it as children; see the layer contract |
90
+
91
+ All built-in presets are original designs licensed MIT — nothing inside this package requires attribution.
92
+
93
+ ## DiceBear avatars (`dicebear`)
94
+
95
+ Generate [DiceBear](https://www.dicebear.com) avatars client-side — deterministic per `seed`, no network call. The packages are **optional** peer dependencies, lazy-loaded only when this variant renders:
96
+
97
+ ```bash
98
+ npm install @dicebear/core @dicebear/collection
99
+ ```
100
+
101
+ ```tsx
102
+ <RealtimeAvatar
103
+ state={aiState}
104
+ analyser={analyser}
105
+ variant="dicebear"
106
+ dicebearCollection="open-peeps" // curated CC0 style id
107
+ dicebearSeed="ada-lovelace" // same seed + style => same face
108
+ />
109
+ ```
110
+
111
+ **Licensing:** DiceBear ships ~30 styles under mixed licenses. This library's catalog (`DICEBEAR_STYLES`) is curated to **CC0 1.0** styles that have a face — `pixel-art`(+`-neutral`), `lorelei`(+`-neutral`), `notionists`(+`-neutral`), `open-peeps`, `thumbs` — so it keeps the same no-attribution promise as the built-in presets. You *can* pass any other DiceBear style id to `dicebearCollection`, but then its license (e.g. CC BY 4.0 for `adventurer`, or "free for personal and commercial use" for `bottts`) is your responsibility — same deal as `byos`.
112
+
113
+ **Animation:** DiceBear SVGs have no `#rra-*` hooks, but their *option API* lets us pick which mouth/eyes variant to render. So every curated style actually **talks**: it pre-generates a few frames of the same avatar (same seed ⇒ identical hair/skin/etc.) with closed / mid / open mouths — plus a blink frame where the style allows — and swaps which frame is shown per audio frame, with a subtle bounce on top. Real articulation via the supported API, no fragile path hacks. The per-style variant choices live in the exported `DICEBEAR_RIGS` map. (A non-rigged style id you pass yourself — e.g. a faceless abstract DiceBear style — falls back to a pure audio-reactive bounce.) State color and the thinking bubble still come from the surrounding `<RealtimeAvatar />` chrome.
114
+
115
+ ## 3D GLB + ARKit (`glb`)
116
+
117
+ Render any `.glb` that exposes the **52 [ARKit blendshapes](https://arkit-face-blendshapes.com/)** (the standard `jawOpen`, `mouthFunnel`, `eyeBlinkLeft`, … morph targets). Same shared mouth engine as the flat presets drives them, so the model talks, blinks and follows the cursor. The three.js stack is optional and lazy-loaded — same deal as `vrm`, minus `@pixiv/three-vrm`:
118
+
119
+ ```bash
120
+ npm install three @react-three/fiber @react-three/drei
121
+ ```
122
+
123
+ ```tsx
124
+ <RealtimeAvatar
125
+ state={aiState}
126
+ analyser={analyser}
127
+ variant="glb"
128
+ glbUrl="/models/rocketbox.glb" // CORS-enabled .glb with ARKit morph targets
129
+ />
130
+ ```
131
+
132
+ **Recommended example asset — Microsoft Rocketbox (MIT).** [Rocketbox](https://github.com/microsoft/Microsoft-Rocketbox) ships 115 rigged avatars with an ARKit-compatible blendshape variant, under the **MIT license** — the cleanest fit for this library's no-attribution-headaches philosophy. Rocketbox distributes `.fbx`, so convert one avatar to `.glb` once (offline, via [FBX2GLTF](https://github.com/facebookincubator/FBX2glTF) or Blender's glTF 2.0 export, keeping the blendshapes) and drop it in `public/models/`. Keep the MIT notice alongside it. [Ready Player Me](https://docs.readyplayer.me/ready-player-me/api-reference/avatars/morph-targets/apple-arkit) avatars (`?morphTargets=ARKit`) also work out of the box.
133
+
134
+ ## Bring your own SVG (`byos`)
135
+
136
+ Any SVG exposing these stable hooks is animated by the runtime — same blink, gaze, mouth and thinking behavior as the built-in presets:
137
+
138
+ | hook | part | the runtime drives |
139
+ |---|---|---|
140
+ | `#rra-ring` | state ring | `stroke` = `stateColors[state]` |
141
+ | `#rra-mouth` | mouth | ellipse: `ry`/`rx` · rect: `height` |
142
+ | `.rra-pupil` (×2) | pupils | circle: `cx`/`cy` · rect: `x`/`y` (mouse tracking, thinking gaze) |
143
+ | `.rra-lid` (×2) | eyelids | `height` (blink; 0 = open) |
144
+ | `#rra-think` | thought bubble | `opacity` + dots pulsing while `thinking` |
145
+
146
+ Optional data attributes: `data-base-x`/`data-base-y` (pupil rest position), `data-max-height` (closed lid height), `data-quantize` (snap motion to a grid — that's how the pixel-art preset stays chunky).
147
+
148
+ ```tsx
149
+ <RealtimeAvatar state={aiState} analyser={analyser} variant="byos">
150
+ <MyOwnSvgAvatar /> {/* exposes the #rra-* hooks; its license is your business */}
151
+ </RealtimeAvatar>
152
+ ```
153
+
154
+ ## API reference
155
+
156
+ ### `<RealtimeAvatar />`
157
+
158
+ - `state` (`'idle' | 'listening' | 'thinking' | 'speaking'`) — required. You resolve it; it is never inferred.
159
+ - `analyser` (`AnalyserNode | null`) — optional. Drives the mouth from audio. Omitted or `null`, speaking falls back to the synthetic pattern.
160
+ - `streamingText` (`string`) — optional. Declarative mouth driver: pass the accumulated assistant text (e.g. from `useChat`) and the avatar diffs its growth to drive the mouth. Takes precedence over `analyser`. See [Text-streaming LLMs](#text-streaming-llms-no-audio).
161
+ - `speechActivity` (`SpeechActivitySource`) — optional. Imperative token-rate mouth driver, from `createSpeechActivity()`. Takes precedence over both `streamingText` and `analyser` when set.
162
+ - `size` (`number`) — px, default `280`.
163
+ - `variant` — see catalog above. Default `'geometric'`.
164
+ - `children` — your SVG, for `variant="byos"`.
165
+ - `vrmUrl` (`string`) — CORS-enabled `.vrm` URL, for `variant="vrm"`.
166
+ - `glbUrl` (`string`) — CORS-enabled `.glb` URL with ARKit blendshapes, for `variant="glb"`.
167
+ - `dicebearCollection` (`string`) — DiceBear style id (curated CC0 set), for `variant="dicebear"`.
168
+ - `dicebearSeed` (`string`) — deterministic DiceBear seed, for `variant="dicebear"`.
169
+ - `subtitle` / `thought` (`string`) — optional movie-style captions and a thought bubble.
170
+ - `showGlow` / `showStatePill` / `showThought` / `showSubtitle` (`boolean`) — HUD satellites, each `true` by default. Set any to `false` to hide it individually: the reactive glow, the state pill, the thought bubble, and the subtitle respectively.
171
+ - `maxMouthOpening`, `mouseTrackingIntensity`, `blinkIntervalMin/Max`, `blinkDuration` — animation tuning.
172
+ - `stateColors`, `stateLabels` — theming; labels are announced via `aria-live`.
173
+ - `customization` — preset colors and accessories (skin, hair, clothing, glasses, headphones…).
174
+
175
+ ### Building blocks
176
+
177
+ Everything the runtime uses is exported, so you can compose your own:
178
+
179
+ - `ContractAvatar` — wraps any contract-compliant SVG with the runtime.
180
+ - `useAvatarRuntime(containerRef, options)` — the animation runtime itself.
181
+ - `createMouthEngine(source)` / `useAudioMouth(...)` — the source→mouth analysis (amplitude + A/E/O shapes), procedural fallback included. `source` is an `AnalyserNode`, a `SpeechActivitySource`, or `null`.
182
+ - `createSpeechActivity(options?)` — the token-rate mouth driver for text streams (`push` / `end` / `reset` / `sample`).
183
+ - `useStreamingTextActivity(text)` — declarative wrapper: diffs accumulated streaming text into a `SpeechActivitySource` for you (what the `streamingText` prop uses).
184
+ - `useReducedMotion()` — SSR-safe `prefers-reduced-motion` hook.
185
+ - `GeometricAvatar`, `MemojiAvatar`, `PixelArtAvatar`, `DoodleAvatar` — the raw presets.
186
+ - `AudioVisualizer` — Siri-style waveform telemetry strip.
187
+
188
+ ## Getting an `AnalyserNode`
189
+
190
+ The standard recipe for base64 PCM streams (what Gemini Live / OpenAI Realtime return):
191
+
192
+ ```ts
193
+ const audioCtx = new AudioContext({ sampleRate: 24000 });
194
+ const analyser = audioCtx.createAnalyser();
195
+ analyser.fftSize = 256;
196
+ analyser.connect(audioCtx.destination);
197
+
198
+ function playAudioChunk(pcmData: Float32Array) {
199
+ const buffer = audioCtx.createBuffer(1, pcmData.length, 24000);
200
+ buffer.getChannelData(0).set(pcmData);
201
+ const source = audioCtx.createBufferSource();
202
+ source.buffer = buffer;
203
+ source.connect(analyser); // <- the analyser you pass to <RealtimeAvatar />
204
+ source.start();
205
+ }
206
+ ```
207
+
208
+ ## Text-streaming LLMs (no audio)
209
+
210
+ Not every assistant speaks. For a text-only LLM that streams tokens — OpenAI-style `/chat/completions` or `/responses` with `stream: true`, or local servers like Ollama / LM Studio / vLLM — there's no `AnalyserNode` to read. Instead, drive the mouth from **token cadence**: the rhythm of arriving text becomes the same 0..1 energy signal the audio path produces. The mouth is busy while the model emits text and settles shut on pauses or when the stream ends. The library still never fetches anything — you own the stream, it owns the face.
211
+
212
+ There are two ways to feed it, matching the two ways React apps consume streams.
213
+
214
+ ### Declarative — `streamingText` (the easy path)
215
+
216
+ If you use a streaming chat hook — the [Vercel AI SDK](https://sdk.vercel.ai)'s `useChat` is the de-facto standard — you never see raw chunks: you get the **accumulated** assistant message (it grows each render) plus a `status`. Both map straight onto the avatar. Pass the text, the avatar diffs its growth internally and drives the mouth. No refs, no reader loop:
217
+
218
+ ```tsx
219
+ import { useChat } from '@ai-sdk/react';
220
+ import { RealtimeAvatar } from 'react-ai-avatar';
221
+ import 'react-ai-avatar/style.css';
222
+
223
+ function ChatAvatar() {
224
+ const { messages, status } = useChat();
225
+ const last = messages.at(-1);
226
+
227
+ return (
228
+ <RealtimeAvatar
229
+ // status: 'submitted' (awaiting first token) | 'streaming' | 'ready'
230
+ state={status === 'submitted' ? 'thinking' : status === 'streaming' ? 'speaking' : 'idle'}
231
+ streamingText={last?.role === 'assistant' ? last.text : ''}
232
+ />
233
+ );
234
+ }
235
+ ```
236
+
237
+ That's the whole integration. `streamingText` takes precedence over `analyser`; the ambient glow reacts to it too. Works with every variant — flat presets, DiceBear, VRM and GLB.
238
+
239
+ ### Imperative — `createSpeechActivity()` (you own the reader loop)
240
+
241
+ Hand-rolling `fetch` or driving the OpenAI SDK's `for await` yourself? Then you *do* have the raw chunks — feed their cadence directly with a `SpeechActivitySource`:
242
+
243
+ ```tsx
244
+ import { RealtimeAvatar, createSpeechActivity } from 'react-ai-avatar';
245
+ import 'react-ai-avatar/style.css';
246
+ import { useRef, useState } from 'react';
247
+
248
+ function TextAvatar() {
249
+ const speech = useRef(createSpeechActivity()).current;
250
+ const [state, setState] = useState<'idle' | 'thinking' | 'speaking'>('idle');
251
+ const [subtitle, setSubtitle] = useState('');
252
+
253
+ async function ask(prompt: string) {
254
+ setState('thinking');
255
+ speech.reset();
256
+ const res = await fetch('/api/chat', {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ messages: [{ role: 'user', content: prompt }] }),
260
+ });
261
+ const reader = res.body!.getReader();
262
+ const decoder = new TextDecoder();
263
+ let text = '';
264
+ for (;;) {
265
+ const { done, value } = await reader.read();
266
+ if (done) break;
267
+ const chunk = decoder.decode(value); // your SSE/delta parsing here
268
+ text += chunk;
269
+ speech.push(chunk); // <- feed token cadence to the mouth
270
+ setSubtitle(text);
271
+ setState('speaking');
272
+ }
273
+ speech.end();
274
+ setState('idle');
275
+ }
276
+
277
+ return <RealtimeAvatar state={state} speechActivity={speech} subtitle={subtitle} />;
278
+ }
279
+ ```
280
+
281
+ `createSpeechActivity(options?)` accepts `chargePerChar`, `decayMs` and `maxChargePerPush` to tune how wide / how fast the mouth reacts. The returned source has `push(chunk)`, `end()`, `reset()` (drop energy on an interrupted turn) and `sample()`. When `speechActivity` is provided it takes precedence over both `streamingText` and `analyser`. (`streamingText` is just this, with the diffing done for you — under the hood it's the exported `useStreamingTextActivity` hook.)
282
+
283
+ > The demo dashboard ships this end-to-end: toggle **TEXT (STREAM)** to talk to an OpenAI-compatible endpoint (set `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL`, or leave them unset / `MOCK_REALTIME=true` for a no-key mock). See `src/demo/useStreamingLLM.ts` and the `/api/chat` route in `server.ts`.
284
+
285
+ ## Positioning
286
+
287
+ The closest reference is [TalkingHead](https://github.com/met4citizen/TalkingHead) (3D, realistic lip-sync, Ready Player Me/Mixamo rigs). This library makes the opposite bet:
288
+
289
+ | | TalkingHead & co. | react-ai-avatar |
290
+ |---|---|---|
291
+ | Star of the show | the realistic human avatar | the LLM's speech + **state** flow |
292
+ | Avatar | 3D full-body rigged | flat SVG, minimal (3D optional, not the focus) |
293
+ | Technical focus | lip-sync fidelity | state + audio reactivity, simplicity |
294
+ | Makes visible | the voice | the *thinking* |
295
+ | Setup | avatar platform + Blender + rig | `npm i` + one component |
296
+
297
+ ## Demo / development
298
+
299
+ The repo ships a demo dashboard (Gemini Live + a no-API-key mock):
300
+
301
+ ```bash
302
+ npm install
303
+ npm run dev # starts the demo at :3000 (MOCK_REALTIME=true needs no API key)
304
+ npm test # vitest: engine, layer contract, SSR, parsers
305
+ npm run build:lib # builds the publishable package into dist/lib
306
+ ```
307
+
308
+ ## License
309
+
310
+ MIT — for the library, the runtime and all built-in presets. Use it commercially, fork it, reskin it. SVGs you bring via `byos` keep whatever license they had.
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const s=require("react/jsx-runtime"),n=require("react"),_=require("@react-three/fiber"),he=require("three/addons/loaders/GLTFLoader.js"),me=require("@react-three/drei"),de=require("three"),ae=require("./useReducedMotion-BRDEmRNI.cjs");function ge(l){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(l){for(const o in l)if(o!=="default"){const L=Object.getOwnPropertyDescriptor(l,o);Object.defineProperty(t,o,L.get?L:{enumerable:!0,get:()=>l[o]})}}return t.default=l,Object.freeze(t)}const Y=ge(de);class pe extends n.Component{constructor(t){super(t),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(t){this.props.onError((t==null?void 0:t.message)||String(t))}render(){return this.state.hasError?null:this.props.children}}function xe(l){const t=new Map,o=c=>c.toLowerCase().replace(/[^a-z]/g,"");l.traverse(c=>{const f=c,h=f.morphTargetDictionary;if(!(!h||!f.morphTargetInfluences))for(const i of Object.keys(h)){const g=h[i],r=o(i);t.has(r)||t.set(r,[]),t.get(r).push({mesh:f,index:g})}});const L=c=>{const f=o(c);if(t.has(f))return t.get(f);const h=[];for(const[i,g]of t)i.endsWith(f)&&h.push(...g);return h},S=new Map;return{has(c){return this.entries(c).length>0},entries(c){return S.has(c)||S.set(c,L(c)),S.get(c)},set(c,f){const h=this.entries(c),i=Math.max(0,Math.min(1,f));for(const{mesh:g,index:r}of h)g.morphTargetInfluences[r]=i},names:Array.from(t.keys())}}function be({url:l,state:t,analyser:o,maxMouthOpening:L=30,mouseTrackingIntensity:S=1,blinkIntervalMin:c=2e3,blinkIntervalMax:f=6e3,blinkDuration:h=100,reducedMotion:i=!1,onLoaded:g}){const r=_.useLoader(he.GLTFLoader,l),{camera:a,controls:G}=_.useThree(),p=r.scene,e=n.useMemo(()=>xe(p),[p]);n.useEffect(()=>{var D;g(!0),console.log("[GLB] Model loaded. ARKit blendshapes found:",e.names),p.traverse(B=>{B.frustumCulled=!1});const y=new Y.Box3().setFromObject(p),k=new Y.Vector3,u=new Y.Vector3;y.getSize(k),y.getCenter(u);const w=y.max.y-k.y*.08,Z=Math.max(.45,k.y*.42);a.position.set(u.x,w,y.max.z+Z),a.lookAt(u.x,w,u.z),a.updateProjectionMatrix();const R=G;R!=null&&R.target&&(R.target.set(u.x,w,u.z),(D=R.update)==null||D.call(R))},[p,e,a,G,g]);const v=n.useRef(0),T=n.useRef(0),z=n.useRef(0),E=n.useRef(0),A=n.useRef(0),d=n.useRef(0),m=n.useRef(0),x=n.useRef(0),$=n.useRef(t),V=n.useRef(0),q=n.useRef(0),W=n.useRef(0),X=n.useRef(0),J=n.useRef(Math.random()*3+2),b=n.useRef(0),I=n.useRef(!1),K=n.useRef(null),ee=n.useRef(null);return _.useFrame((y,k)=>{var ue,oe;const u=Y.MathUtils.lerp,w=y.clock.elapsedTime;if(t!==$.current&&(t==="listening"&&!i&&(x.current=1),$.current==="speaking"&&t!=="speaking"&&!i&&(q.current=2),$.current=t),x.current=Math.max(0,x.current-k*2.5),q.current=Math.max(0,q.current-k),!i)if(!I.current)J.current-=k,J.current<=0&&(I.current=!0);else{const j=1e3/(h||100);b.current+=k*j*2,b.current>=1&&(b.current=1,I.current=!1,J.current=Math.random()*((f-c)/1e3)+c/1e3)}if(!I.current&&b.current>0){const j=1e3/(h||100);b.current-=k*j*2,b.current<0&&(b.current=0)}e.set("eyeBlinkLeft",b.current),e.set("eyeBlinkRight",b.current);const Z=((ue=y.pointer)==null?void 0:ue.x)??0,R=((oe=y.pointer)==null?void 0:oe.y)??0,D=i?0:S;let B=Z*D,Q=R*D;t==="thinking"?(B=-.5*(i?0:1),Q=.6*(i?0:1)):i||(B+=Math.sin(w*.23)*.12,Q+=Math.sin(w*.31+1.3)*.08),W.current=u(W.current,B,.1),X.current=u(X.current,Q,.1);const N=W.current,F=X.current;e.set("eyeLookOutRight",Math.max(0,N)),e.set("eyeLookInLeft",Math.max(0,N)),e.set("eyeLookOutLeft",Math.max(0,-N)),e.set("eyeLookInRight",Math.max(0,-N)),e.set("eyeLookUpLeft",Math.max(0,F)),e.set("eyeLookUpRight",Math.max(0,F)),e.set("eyeLookDownLeft",Math.max(0,-F)),e.set("eyeLookDownRight",Math.max(0,-F));let P=0,te=0,ne=0;t==="listening"?(P=.35,te=.25):t==="thinking"?ne=.45:t==="speaking"?P=.12:P=.05,q.current>0&&t!=="speaking"&&(P=Math.max(P,.55));const le=t==="thinking"&&!i?1:0;A.current=u(A.current,P,.1),d.current=u(d.current,te,.1),m.current=u(m.current,ne,.1),V.current=u(V.current,le,.08);const M=A.current,H=V.current;e.set("mouthSmileLeft",M),e.set("mouthSmileRight",M),e.set("browInnerUp",d.current),e.set("browDownLeft",m.current),e.set("browDownRight",m.current),e.set("cheekSquintLeft",M*.6),e.set("cheekSquintRight",M*.6),e.set("mouthDimpleLeft",M*.5),e.set("mouthDimpleRight",M*.5),e.set("eyeSquintLeft",Math.max(M*.35,H*.45)),e.set("eyeSquintRight",Math.max(M*.35,H*.45)),e.set("mouthPressLeft",H*.4),e.set("mouthPressRight",H*.4),e.set("eyeWideLeft",x.current*.7),e.set("eyeWideRight",x.current*.7),e.set("browOuterUpLeft",x.current*.6),e.set("browOuterUpRight",x.current*.6);let U=0,re=0,se=0,ce=0;if(t==="speaking"){(!K.current||ee.current!==o)&&(K.current=ae.createMouthEngine(o),ee.current=o);const j=K.current.read(),fe=L/30,O=j.level*fe;j.shape==="o"?(re=O*.85,se=O*.6,U=O*.35):j.shape==="e"?(ce=O*.7,U=O*.3):j.shape==="a"&&(U=O*.9)}v.current=u(v.current,U,.25),T.current=u(T.current,re,.25),z.current=u(z.current,se,.25),E.current=u(E.current,ce,.25),e.set("jawOpen",v.current),e.set("mouthFunnel",T.current),e.set("mouthPucker",z.current),e.set("mouthStretchLeft",E.current),e.set("mouthStretchRight",E.current);const C=i||t==="speaking"||t==="thinking"?0:1,ie=Math.sin(w*.4)*.06*C;e.set("mouthLeft",Math.max(0,ie)),e.set("mouthRight",Math.max(0,-ie)),e.set("mouthRollLower",(Math.sin(w*.33)*.5+.5)*.07*C),e.set("mouthShrugUpper",(Math.sin(w*.7)*.5+.5)*.06*C)}),s.jsx("primitive",{object:p})}function ke({state:l,analyser:t,size:o=300,className:L="",style:S,maxMouthOpening:c,blinkIntervalMin:f,blinkIntervalMax:h,blinkDuration:i,mouseTrackingIntensity:g,stateColors:r,glbUrl:a}){const[G,p]=n.useState(!1),[e,v]=n.useState(null),[T,z]=n.useState(!1),E=ae.useReducedMotion();n.useEffect(()=>{if(p(!1),v(null),z(!1),!a)return;let d=!1;return fetch(a).then(m=>{const x=m.headers.get("content-type")||"";if(!m.ok)throw new Error(`HTTP ${m.status} for ${a}`);if(x.includes("text/html"))throw new Error(`No .glb found at ${a} (server returned HTML).`);d||z(!0)}).catch(m=>{d||v((m==null?void 0:m.message)||String(m))}),()=>{d=!0}},[a]);const A={idle:(r==null?void 0:r.idle)??"#4b5563",listening:(r==null?void 0:r.listening)??"#3b82f6",thinking:(r==null?void 0:r.thinking)??"#8b5cf6",speaking:(r==null?void 0:r.speaking)??"#10b981",working:(r==null?void 0:r.working)??"#f59e0b"};return s.jsxs("div",{className:`relative flex items-center justify-center rounded-3xl overflow-hidden border border-zinc-800/40 bg-zinc-950/40 ${L}`,style:{width:o,height:o,...S},children:[s.jsxs(_.Canvas,{camera:{position:[0,1.5,1],fov:35},shadows:!0,gl:{antialias:!0,alpha:!0,preserveDrawingBuffer:!0},style:{width:"100%",height:"100%",background:"transparent"},children:[s.jsx("ambientLight",{intensity:1.5}),s.jsx("spotLight",{position:[0,3,2],angle:.6,penumbra:1,intensity:3,color:A[l],castShadow:!0}),s.jsx("directionalLight",{position:[-2,2,-2],intensity:1.8,color:"#ffffff"}),s.jsx("directionalLight",{position:[2,2,2],intensity:2.2,color:"#ffffff"}),s.jsx(pe,{onError:d=>v(d),children:a&&T&&s.jsx(n.Suspense,{fallback:null,children:s.jsx(be,{url:a,state:l,analyser:t,maxMouthOpening:c,mouseTrackingIntensity:g,blinkIntervalMin:f,blinkIntervalMax:h,blinkDuration:i,reducedMotion:E,onLoaded:d=>p(d)})})}),s.jsx(me.OrbitControls,{makeDefault:!0,enableZoom:!1,enablePan:!1,minPolarAngle:Math.PI/2.6,maxPolarAngle:Math.PI/1.7,minAzimuthAngle:-Math.PI/4,maxAzimuthAngle:Math.PI/4})]}),!a&&s.jsxs("div",{className:"absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-20 p-4 text-center",children:[s.jsx("span",{className:"text-xs font-mono font-bold text-amber-400 uppercase tracking-wider mb-2",children:"Missing glbUrl"}),s.jsx("p",{className:"text-[10px] text-zinc-500 max-w-[200px] leading-relaxed",children:"Pass a CORS-enabled .glb URL (with ARKit blendshapes) via the glbUrl prop."})]}),a&&!G&&!e&&s.jsxs("div",{className:"absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/80 backdrop-blur-md z-20",children:[s.jsx("div",{className:"w-10 h-10 border-4 border-t-emerald-500 border-emerald-500/20 rounded-full animate-spin mb-3"}),s.jsx("span",{className:"text-[10px] font-mono font-bold tracking-widest text-zinc-400 animate-pulse",children:"LOADING GLB MODEL..."})]}),e&&s.jsxs("div",{className:"absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-20 p-4 text-center",children:[s.jsx("span",{className:"text-xs font-mono font-bold text-red-400 uppercase tracking-wider mb-2",children:"Failed to load GLB"}),s.jsx("p",{className:"text-[10px] text-zinc-500 max-w-[200px] leading-relaxed break-all",children:e})]})]})}exports.GlbArkitAvatar=ke;
@@ -0,0 +1,232 @@
1
+ import { jsxs as I, jsx as o } from "react/jsx-runtime";
2
+ import fe, { useState as ee, useEffect as le, Suspense as pe, useMemo as de, useRef as s } from "react";
3
+ import { Canvas as ge, useLoader as xe, useThree as ke, useFrame as be } from "@react-three/fiber";
4
+ import { GLTFLoader as we } from "three/addons/loaders/GLTFLoader.js";
5
+ import { OrbitControls as Le } from "@react-three/drei";
6
+ import * as H from "three";
7
+ import { u as ye, c as Me } from "./useReducedMotion-BDcEizfP.js";
8
+ class Re extends fe.Component {
9
+ constructor(t) {
10
+ super(t), this.state = { hasError: !1 };
11
+ }
12
+ static getDerivedStateFromError() {
13
+ return { hasError: !0 };
14
+ }
15
+ componentDidCatch(t) {
16
+ this.props.onError((t == null ? void 0 : t.message) || String(t));
17
+ }
18
+ render() {
19
+ return this.state.hasError ? null : this.props.children;
20
+ }
21
+ }
22
+ function ze(R) {
23
+ const t = /* @__PURE__ */ new Map(), p = (r) => r.toLowerCase().replace(/[^a-z]/g, "");
24
+ R.traverse((r) => {
25
+ const u = r, l = u.morphTargetDictionary;
26
+ if (!(!l || !u.morphTargetInfluences))
27
+ for (const c of Object.keys(l)) {
28
+ const f = l[c], n = p(c);
29
+ t.has(n) || t.set(n, []), t.get(n).push({ mesh: u, index: f });
30
+ }
31
+ });
32
+ const B = (r) => {
33
+ const u = p(r);
34
+ if (t.has(u)) return t.get(u);
35
+ const l = [];
36
+ for (const [c, f] of t)
37
+ c.endsWith(u) && l.push(...f);
38
+ return l;
39
+ }, z = /* @__PURE__ */ new Map();
40
+ return {
41
+ has(r) {
42
+ return this.entries(r).length > 0;
43
+ },
44
+ entries(r) {
45
+ return z.has(r) || z.set(r, B(r)), z.get(r);
46
+ },
47
+ set(r, u) {
48
+ const l = this.entries(r), c = Math.max(0, Math.min(1, u));
49
+ for (const { mesh: f, index: n } of l)
50
+ f.morphTargetInfluences[n] = c;
51
+ },
52
+ /** All normalized morph-target names found, for debugging. */
53
+ names: Array.from(t.keys())
54
+ };
55
+ }
56
+ function Se({
57
+ url: R,
58
+ state: t,
59
+ analyser: p,
60
+ maxMouthOpening: B = 30,
61
+ mouseTrackingIntensity: z = 1,
62
+ blinkIntervalMin: r = 2e3,
63
+ blinkIntervalMax: u = 6e3,
64
+ blinkDuration: l = 100,
65
+ reducedMotion: c = !1,
66
+ onLoaded: f
67
+ }) {
68
+ const n = xe(we, R), { camera: a, controls: O } = ke(), d = n.scene, e = de(() => ze(d), [d]);
69
+ le(() => {
70
+ var j;
71
+ f(!0), console.log("[GLB] Model loaded. ARKit blendshapes found:", e.names), d.traverse((G) => {
72
+ G.frustumCulled = !1;
73
+ });
74
+ const L = new H.Box3().setFromObject(d), k = new H.Vector3(), i = new H.Vector3();
75
+ L.getSize(k), L.getCenter(i);
76
+ const b = L.max.y - k.y * 0.08, Q = Math.max(0.45, k.y * 0.42);
77
+ a.position.set(i.x, b, L.max.z + Q), a.lookAt(i.x, b, i.z), a.updateProjectionMatrix();
78
+ const w = O;
79
+ w != null && w.target && (w.target.set(i.x, b, i.z), (j = w.update) == null || j.call(w));
80
+ }, [d, e, a, O, f]);
81
+ const S = s(0), D = s(0), v = s(0), E = s(0), T = s(0), m = s(0), h = s(0), g = s(0), V = s(t), W = s(0), N = s(0), X = s(0), J = s(0), K = s(Math.random() * 3 + 2), x = s(0), F = s(!1), Z = s(null), te = s(null);
82
+ return be((L, k) => {
83
+ var ae, ue;
84
+ const i = H.MathUtils.lerp, b = L.clock.elapsedTime;
85
+ if (t !== V.current && (t === "listening" && !c && (g.current = 1), V.current === "speaking" && t !== "speaking" && !c && (N.current = 2), V.current = t), g.current = Math.max(0, g.current - k * 2.5), N.current = Math.max(0, N.current - k), !c)
86
+ if (!F.current)
87
+ K.current -= k, K.current <= 0 && (F.current = !0);
88
+ else {
89
+ const M = 1e3 / (l || 100);
90
+ x.current += k * M * 2, x.current >= 1 && (x.current = 1, F.current = !1, K.current = Math.random() * ((u - r) / 1e3) + r / 1e3);
91
+ }
92
+ if (!F.current && x.current > 0) {
93
+ const M = 1e3 / (l || 100);
94
+ x.current -= k * M * 2, x.current < 0 && (x.current = 0);
95
+ }
96
+ e.set("eyeBlinkLeft", x.current), e.set("eyeBlinkRight", x.current);
97
+ const Q = ((ae = L.pointer) == null ? void 0 : ae.x) ?? 0, w = ((ue = L.pointer) == null ? void 0 : ue.y) ?? 0, j = c ? 0 : z;
98
+ let G = Q * j, _ = w * j;
99
+ t === "thinking" ? (G = -0.5 * (c ? 0 : 1), _ = 0.6 * (c ? 0 : 1)) : c || (G += Math.sin(b * 0.23) * 0.12, _ += Math.sin(b * 0.31 + 1.3) * 0.08), X.current = i(X.current, G, 0.1), J.current = i(J.current, _, 0.1);
100
+ const q = X.current, U = J.current;
101
+ e.set("eyeLookOutRight", Math.max(0, q)), e.set("eyeLookInLeft", Math.max(0, q)), e.set("eyeLookOutLeft", Math.max(0, -q)), e.set("eyeLookInRight", Math.max(0, -q)), e.set("eyeLookUpLeft", Math.max(0, U)), e.set("eyeLookUpRight", Math.max(0, U)), e.set("eyeLookDownLeft", Math.max(0, -U)), e.set("eyeLookDownRight", Math.max(0, -U));
102
+ let P = 0, ne = 0, re = 0;
103
+ t === "listening" ? (P = 0.35, ne = 0.25) : t === "thinking" ? re = 0.45 : t === "speaking" ? P = 0.12 : P = 0.05, N.current > 0 && t !== "speaking" && (P = Math.max(P, 0.55));
104
+ const he = t === "thinking" && !c ? 1 : 0;
105
+ T.current = i(T.current, P, 0.1), m.current = i(m.current, ne, 0.1), h.current = i(h.current, re, 0.1), W.current = i(W.current, he, 0.08);
106
+ const y = T.current, Y = W.current;
107
+ e.set("mouthSmileLeft", y), e.set("mouthSmileRight", y), e.set("browInnerUp", m.current), e.set("browDownLeft", h.current), e.set("browDownRight", h.current), e.set("cheekSquintLeft", y * 0.6), e.set("cheekSquintRight", y * 0.6), e.set("mouthDimpleLeft", y * 0.5), e.set("mouthDimpleRight", y * 0.5), e.set("eyeSquintLeft", Math.max(y * 0.35, Y * 0.45)), e.set("eyeSquintRight", Math.max(y * 0.35, Y * 0.45)), e.set("mouthPressLeft", Y * 0.4), e.set("mouthPressRight", Y * 0.4), e.set("eyeWideLeft", g.current * 0.7), e.set("eyeWideRight", g.current * 0.7), e.set("browOuterUpLeft", g.current * 0.6), e.set("browOuterUpRight", g.current * 0.6);
108
+ let $ = 0, ce = 0, se = 0, ie = 0;
109
+ if (t === "speaking") {
110
+ (!Z.current || te.current !== p) && (Z.current = Me(p), te.current = p);
111
+ const M = Z.current.read(), me = B / 30, A = M.level * me;
112
+ M.shape === "o" ? (ce = A * 0.85, se = A * 0.6, $ = A * 0.35) : M.shape === "e" ? (ie = A * 0.7, $ = A * 0.3) : M.shape === "a" && ($ = A * 0.9);
113
+ }
114
+ S.current = i(S.current, $, 0.25), D.current = i(D.current, ce, 0.25), v.current = i(v.current, se, 0.25), E.current = i(E.current, ie, 0.25), e.set("jawOpen", S.current), e.set("mouthFunnel", D.current), e.set("mouthPucker", v.current), e.set("mouthStretchLeft", E.current), e.set("mouthStretchRight", E.current);
115
+ const C = c || t === "speaking" || t === "thinking" ? 0 : 1, oe = Math.sin(b * 0.4) * 0.06 * C;
116
+ e.set("mouthLeft", Math.max(0, oe)), e.set("mouthRight", Math.max(0, -oe)), e.set("mouthRollLower", (Math.sin(b * 0.33) * 0.5 + 0.5) * 0.07 * C), e.set("mouthShrugUpper", (Math.sin(b * 0.7) * 0.5 + 0.5) * 0.06 * C);
117
+ }), /* @__PURE__ */ o("primitive", { object: d });
118
+ }
119
+ function Te({
120
+ state: R,
121
+ analyser: t,
122
+ size: p = 300,
123
+ className: B = "",
124
+ style: z,
125
+ maxMouthOpening: r,
126
+ blinkIntervalMin: u,
127
+ blinkIntervalMax: l,
128
+ blinkDuration: c,
129
+ mouseTrackingIntensity: f,
130
+ stateColors: n,
131
+ glbUrl: a
132
+ }) {
133
+ const [O, d] = ee(!1), [e, S] = ee(null), [D, v] = ee(!1), E = ye();
134
+ le(() => {
135
+ if (d(!1), S(null), v(!1), !a) return;
136
+ let m = !1;
137
+ return fetch(a).then((h) => {
138
+ const g = h.headers.get("content-type") || "";
139
+ if (!h.ok) throw new Error(`HTTP ${h.status} for ${a}`);
140
+ if (g.includes("text/html"))
141
+ throw new Error(`No .glb found at ${a} (server returned HTML).`);
142
+ m || v(!0);
143
+ }).catch((h) => {
144
+ m || S((h == null ? void 0 : h.message) || String(h));
145
+ }), () => {
146
+ m = !0;
147
+ };
148
+ }, [a]);
149
+ const T = {
150
+ idle: (n == null ? void 0 : n.idle) ?? "#4b5563",
151
+ listening: (n == null ? void 0 : n.listening) ?? "#3b82f6",
152
+ thinking: (n == null ? void 0 : n.thinking) ?? "#8b5cf6",
153
+ speaking: (n == null ? void 0 : n.speaking) ?? "#10b981",
154
+ working: (n == null ? void 0 : n.working) ?? "#f59e0b"
155
+ };
156
+ return /* @__PURE__ */ I(
157
+ "div",
158
+ {
159
+ className: `relative flex items-center justify-center rounded-3xl overflow-hidden border border-zinc-800/40 bg-zinc-950/40 ${B}`,
160
+ style: { width: p, height: p, ...z },
161
+ children: [
162
+ /* @__PURE__ */ I(
163
+ ge,
164
+ {
165
+ camera: { position: [0, 1.5, 1], fov: 35 },
166
+ shadows: !0,
167
+ gl: { antialias: !0, alpha: !0, preserveDrawingBuffer: !0 },
168
+ style: { width: "100%", height: "100%", background: "transparent" },
169
+ children: [
170
+ /* @__PURE__ */ o("ambientLight", { intensity: 1.5 }),
171
+ /* @__PURE__ */ o(
172
+ "spotLight",
173
+ {
174
+ position: [0, 3, 2],
175
+ angle: 0.6,
176
+ penumbra: 1,
177
+ intensity: 3,
178
+ color: T[R],
179
+ castShadow: !0
180
+ }
181
+ ),
182
+ /* @__PURE__ */ o("directionalLight", { position: [-2, 2, -2], intensity: 1.8, color: "#ffffff" }),
183
+ /* @__PURE__ */ o("directionalLight", { position: [2, 2, 2], intensity: 2.2, color: "#ffffff" }),
184
+ /* @__PURE__ */ o(Re, { onError: (m) => S(m), children: a && D && /* @__PURE__ */ o(pe, { fallback: null, children: /* @__PURE__ */ o(
185
+ Se,
186
+ {
187
+ url: a,
188
+ state: R,
189
+ analyser: t,
190
+ maxMouthOpening: r,
191
+ mouseTrackingIntensity: f,
192
+ blinkIntervalMin: u,
193
+ blinkIntervalMax: l,
194
+ blinkDuration: c,
195
+ reducedMotion: E,
196
+ onLoaded: (m) => d(m)
197
+ }
198
+ ) }) }),
199
+ /* @__PURE__ */ o(
200
+ Le,
201
+ {
202
+ makeDefault: !0,
203
+ enableZoom: !1,
204
+ enablePan: !1,
205
+ minPolarAngle: Math.PI / 2.6,
206
+ maxPolarAngle: Math.PI / 1.7,
207
+ minAzimuthAngle: -Math.PI / 4,
208
+ maxAzimuthAngle: Math.PI / 4
209
+ }
210
+ )
211
+ ]
212
+ }
213
+ ),
214
+ !a && /* @__PURE__ */ I("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-20 p-4 text-center", children: [
215
+ /* @__PURE__ */ o("span", { className: "text-xs font-mono font-bold text-amber-400 uppercase tracking-wider mb-2", children: "Missing glbUrl" }),
216
+ /* @__PURE__ */ o("p", { className: "text-[10px] text-zinc-500 max-w-[200px] leading-relaxed", children: "Pass a CORS-enabled .glb URL (with ARKit blendshapes) via the glbUrl prop." })
217
+ ] }),
218
+ a && !O && !e && /* @__PURE__ */ I("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/80 backdrop-blur-md z-20", children: [
219
+ /* @__PURE__ */ o("div", { className: "w-10 h-10 border-4 border-t-emerald-500 border-emerald-500/20 rounded-full animate-spin mb-3" }),
220
+ /* @__PURE__ */ o("span", { className: "text-[10px] font-mono font-bold tracking-widest text-zinc-400 animate-pulse", children: "LOADING GLB MODEL..." })
221
+ ] }),
222
+ e && /* @__PURE__ */ I("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-20 p-4 text-center", children: [
223
+ /* @__PURE__ */ o("span", { className: "text-xs font-mono font-bold text-red-400 uppercase tracking-wider mb-2", children: "Failed to load GLB" }),
224
+ /* @__PURE__ */ o("p", { className: "text-[10px] text-zinc-500 max-w-[200px] leading-relaxed break-all", children: e })
225
+ ] })
226
+ ]
227
+ }
228
+ );
229
+ }
230
+ export {
231
+ Te as GlbArkitAvatar
232
+ };