talking-head-studio 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/TalkingHead.d.ts +8 -0
  2. package/dist/TalkingHead.js +104 -7
  3. package/dist/TalkingHead.web.d.ts +3 -2
  4. package/dist/TalkingHead.web.js +17 -2
  5. package/dist/TalkingHeadVisualization.d.ts +35 -0
  6. package/dist/TalkingHeadVisualization.js +277 -0
  7. package/dist/api/index.d.ts +2 -0
  8. package/dist/api/index.js +18 -0
  9. package/dist/api/studioApi.d.ts +38 -0
  10. package/dist/api/studioApi.js +235 -0
  11. package/dist/api/types.d.ts +87 -0
  12. package/dist/api/types.js +5 -0
  13. package/dist/assets/face-squeeze-local.glb +0 -0
  14. package/dist/filament/FilamentAvatar.d.ts +41 -0
  15. package/dist/filament/FilamentAvatar.js +737 -0
  16. package/dist/filament/faceSqueezeAssets.d.ts +1 -0
  17. package/dist/filament/faceSqueezeAssets.js +5 -0
  18. package/dist/filament/index.d.ts +5 -0
  19. package/dist/filament/index.js +22 -0
  20. package/dist/filament/morphTables.d.ts +5 -0
  21. package/dist/filament/morphTables.js +93 -0
  22. package/dist/filament/useAuthedFilamentUri.d.ts +11 -0
  23. package/dist/filament/useAuthedFilamentUri.js +126 -0
  24. package/dist/html.d.ts +7 -0
  25. package/dist/html.js +255 -56
  26. package/dist/index.d.ts +9 -2
  27. package/dist/index.js +13 -2
  28. package/dist/index.web.d.ts +6 -2
  29. package/dist/index.web.js +10 -2
  30. package/dist/utils/avatarUtils.d.ts +13 -0
  31. package/dist/utils/avatarUtils.js +56 -0
  32. package/dist/wardrobe/index.d.ts +2 -0
  33. package/dist/wardrobe/index.js +20 -0
  34. package/dist/wardrobe/useAvatarWardrobeHydration.d.ts +7 -0
  35. package/dist/wardrobe/useAvatarWardrobeHydration.js +34 -0
  36. package/dist/wardrobe/wardrobeStore.d.ts +30 -0
  37. package/dist/wardrobe/wardrobeStore.js +106 -0
  38. package/package.json +33 -4
@@ -0,0 +1,737 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FilamentAvatar = exports.CAMERA_FOCAL_PIP = exports.CAMERA_FOCAL_FULL = void 0;
37
+ const jsx_runtime_1 = require("react/jsx-runtime");
38
+ /* eslint-disable @typescript-eslint/no-unused-expressions */
39
+ /* eslint-disable react-hooks/refs */
40
+ /**
41
+ * FilamentAvatar — native Filament-based avatar renderer.
42
+ *
43
+ * Replaces the TalkingHead WebView on iOS/Android with direct Filament
44
+ * morph target writes — no WebView bridge, no JS→native→JS roundtrip,
45
+ * visemes applied at 60fps in React Native JS.
46
+ *
47
+ * Implements the full TalkingHeadRef interface so it can be swapped in
48
+ * transparently for the WebView renderer.
49
+ */
50
+ const react_1 = __importStar(require("react"));
51
+ const react_native_1 = require("react-native");
52
+ const react_native_filament_1 = require("react-native-filament");
53
+ const useAuthedFilamentUri_1 = require("./useAuthedFilamentUri");
54
+ const expo_asset_1 = require("expo-asset");
55
+ const faceSqueezeAssets_1 = require("./faceSqueezeAssets");
56
+ const morphTables_1 = require("./morphTables");
57
+ // ---------------------------------------------------------------------------
58
+ // Material keyword lists for color tinting
59
+ // ---------------------------------------------------------------------------
60
+ const SKIN_MATERIAL_KEYWORDS = ['skin', 'body', 'flesh', 'face', 'head'];
61
+ const HAIR_MATERIAL_KEYWORDS = ['hair', 'beard', 'eyebrow', 'eyelash', 'brow', 'lash'];
62
+ const EYE_MATERIAL_KEYWORDS = ['eye', 'iris', 'cornea', 'sclera', 'pupil'];
63
+ // ---------------------------------------------------------------------------
64
+ // Idle blink timing
65
+ // ---------------------------------------------------------------------------
66
+ const BLINK_INTERVAL_MIN_MS = 2800;
67
+ const BLINK_INTERVAL_MAX_MS = 6000;
68
+ const BLINK_HOLD_MS = 80;
69
+ // ---------------------------------------------------------------------------
70
+ // Idle emotion bursts — matches FaceSqueezeEditor's weighted random system
71
+ // ---------------------------------------------------------------------------
72
+ const IDLE_EMOTION_PRESETS = {
73
+ happy: { mouthSmileLeft: 0.85, mouthSmileRight: 0.85, cheekSquintLeft: 0.6, cheekSquintRight: 0.6, mouthDimpleLeft: 0.4, mouthDimpleRight: 0.4 },
74
+ surprised: { eyeWideLeft: 0.9, eyeWideRight: 0.9, browInnerUp: 0.8, browOuterUpLeft: 0.7, browOuterUpRight: 0.7, jawOpen: 0.4, mouthFunnel: 0.3 },
75
+ smirk: { mouthSmileLeft: 0.9, mouthSmileRight: 0.1, cheekSquintLeft: 0.5, mouthDimpleLeft: 0.35 },
76
+ sad: { browInnerUp: 0.9, browDownLeft: 0.3, browDownRight: 0.3, mouthFrownLeft: 0.8, mouthFrownRight: 0.8, eyeLookDownLeft: 0.5, eyeLookDownRight: 0.5 },
77
+ wink: { eyeBlinkLeft: 1.0, mouthSmileLeft: 0.5, mouthSmileRight: 0.3, cheekSquintLeft: 0.4 },
78
+ sleepy: { eyeBlinkLeft: 0.7, eyeBlinkRight: 0.7, browDownLeft: 0.4, browDownRight: 0.4, mouthShrugLower: 0.3, jawOpen: 0.15 },
79
+ contempt: { mouthSmileLeft: 0.6, mouthPressRight: 0.5, mouthStretchRight: 0.3, browDownRight: 0.4, eyeSquintRight: 0.3 },
80
+ };
81
+ const IDLE_EMOTIONS_WEIGHTED = [
82
+ { key: 'happy', weight: 5 }, { key: 'surprised', weight: 2 },
83
+ { key: 'smirk', weight: 2 }, { key: 'sad', weight: 1 },
84
+ { key: 'wink', weight: 2 }, { key: 'sleepy', weight: 1 },
85
+ { key: 'contempt', weight: 1 },
86
+ ];
87
+ const IDLE_EMOTION_INTERVAL_MS = 6000;
88
+ const IDLE_EMOTION_INITIAL_DELAY_MS = 2000;
89
+ const IDLE_EMOTION_BLEND_MS = 300;
90
+ const IDLE_EMOTION_HOLD_MS = 1500;
91
+ function pickIdleEmotion() {
92
+ const total = IDLE_EMOTIONS_WEIGHTED.reduce((s, e) => s + e.weight, 0);
93
+ let r = Math.random() * total;
94
+ for (const e of IDLE_EMOTIONS_WEIGHTED) {
95
+ r -= e.weight;
96
+ if (r <= 0)
97
+ return e.key;
98
+ }
99
+ return 'happy';
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Camera presets — tight head framing matching TalkingHead cameraView='head'
103
+ // Avatar head is at approximately y=1.62 in Ready Player Me scale.
104
+ // ---------------------------------------------------------------------------
105
+ // z=1.4 gives head+shoulders framing at 50mm; z=1.05 was too tight (face only).
106
+ const CAMERA_POSITION = [0, 1.55, 1.4];
107
+ const CAMERA_TARGET = [0, 1.55, 0];
108
+ const CAMERA_UP = [0, 1, 0];
109
+ // Full/assistant view: 50mm is a natural portrait focal length — head is not distorted.
110
+ // Pip: 85mm telephoto for a tight face crop in the small bubble.
111
+ exports.CAMERA_FOCAL_FULL = 28;
112
+ exports.CAMERA_FOCAL_PIP = 50;
113
+ // ---------------------------------------------------------------------------
114
+ // Idle micro-expression cycles (MotionEngine-style procedural layer)
115
+ // Runs at ~60ms ticks, drives subtle head/face movement independent of speech
116
+ // ---------------------------------------------------------------------------
117
+ // Micro-sway: slow sinusoidal variation on subtle morphs to suggest breathing/life
118
+ const IDLE_BREATHE_PERIOD_MS = 4200; // one breath cycle
119
+ // ---------------------------------------------------------------------------
120
+ // Morph helpers
121
+ // ---------------------------------------------------------------------------
122
+ function normalizeName(name) {
123
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '');
124
+ }
125
+ function hexToFloat4(hex) {
126
+ const cleaned = hex.replace('#', '').padEnd(6, '0');
127
+ return [
128
+ parseInt(cleaned.slice(0, 2), 16) / 255,
129
+ parseInt(cleaned.slice(2, 4), 16) / 255,
130
+ parseInt(cleaned.slice(4, 6), 16) / 255,
131
+ 1.0,
132
+ ];
133
+ }
134
+ function buildVisemeMorphCache(dispatchMap) {
135
+ const cache = new Map();
136
+ for (const [visemeKey, aliases] of Object.entries(morphTables_1.VISEME_MORPH_ALIASES)) {
137
+ if (visemeKey === 'sil')
138
+ continue;
139
+ for (const alias of aliases) {
140
+ const entries = dispatchMap.get(alias) ?? dispatchMap.get(normalizeName(alias));
141
+ if (entries && entries.length > 0) {
142
+ cache.set(visemeKey, entries);
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ return cache;
148
+ }
149
+ function FilamentAvatarInner({ localUri, mood: initialMood = 'neutral', hairColor: initialHairColor, skinColor: initialSkinColor, eyeColor: initialEyeColor, onReady, onError: _onError, // eslint-disable-line @typescript-eslint/no-unused-vars
150
+ innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
151
+ // When localUri is empty (model not yet downloaded), pass a stable placeholder
152
+ // so useModel doesn't error. ModelRenderer is gated on model.state === 'loaded'
153
+ // so nothing will render until the real URI arrives.
154
+ const model = (0, react_native_filament_1.useModel)({ uri: localUri || '' });
155
+ const modelRef = (0, react_1.useRef)(model);
156
+ modelRef.current = model;
157
+ const { renderableManager } = (0, react_native_filament_1.useFilamentContext)();
158
+ // morph dispatch map — keyed by name and normalized name
159
+ const dispatchMap = (0, react_1.useRef)(new Map());
160
+ const visemeCacheRef = (0, react_1.useRef)(new Map());
161
+ const weightsMap = (0, react_1.useRef)(new Map());
162
+ const entityById = (0, react_1.useRef)(new Map());
163
+ // viseme scheduler state
164
+ const visemeState = (0, react_1.useRef)({});
165
+ const visemeTimers = (0, react_1.useRef)([]);
166
+ const visemeModeUntil = (0, react_1.useRef)(0);
167
+ const activeScheduleId = (0, react_1.useRef)(0);
168
+ const tickIntervalRef = (0, react_1.useRef)(null);
169
+ // last received schedule — replayed if model loads after the schedule arrives
170
+ const lastScheduleRef = (0, react_1.useRef)(null);
171
+ // stable ref to scheduleVisemes so the model-load effect can call it after definition
172
+ const scheduleVisemesRef = (0, react_1.useRef)(null);
173
+ // amplitude fallback
174
+ const amplitudeRef = (0, react_1.useRef)(0);
175
+ // mood baseline weights
176
+ const moodWeightsRef = (0, react_1.useRef)(morphTables_1.MOOD_MORPHS[initialMood] ?? {});
177
+ // idle breathing phase (radians, incremented per tick)
178
+ const breathPhaseRef = (0, react_1.useRef)(0);
179
+ // blink
180
+ const blinkTimerRef = (0, react_1.useRef)(null);
181
+ const isBlinkingRef = (0, react_1.useRef)(false);
182
+ // idle emotion burst — blended overlay on top of mood baseline
183
+ const idleEmotionWeightsRef = (0, react_1.useRef)({});
184
+ const idleEmotionTimerRef = (0, react_1.useRef)(null);
185
+ const idleEmotionIntervalRef = (0, react_1.useRef)(null);
186
+ // mounted guard — prevents renderableManager calls after unmount.
187
+ // useLayoutEffect so it fires synchronously before any pending timers/intervals.
188
+ const mountedRef = (0, react_1.useRef)(true);
189
+ (0, react_1.useLayoutEffect)(() => {
190
+ mountedRef.current = true;
191
+ return () => {
192
+ mountedRef.current = false;
193
+ // Synchronous cleanup — kill timers immediately on unmount
194
+ for (const id of visemeTimers.current)
195
+ clearTimeout(id);
196
+ visemeTimers.current = [];
197
+ if (tickIntervalRef.current) {
198
+ clearInterval(tickIntervalRef.current);
199
+ tickIntervalRef.current = null;
200
+ }
201
+ if (blinkTimerRef.current) {
202
+ clearTimeout(blinkTimerRef.current);
203
+ blinkTimerRef.current = null;
204
+ }
205
+ if (idleEmotionIntervalRef.current) {
206
+ clearInterval(idleEmotionIntervalRef.current);
207
+ idleEmotionIntervalRef.current = null;
208
+ }
209
+ if (idleEmotionTimerRef.current) {
210
+ clearTimeout(idleEmotionTimerRef.current);
211
+ idleEmotionTimerRef.current = null;
212
+ }
213
+ if (blendIntervalRef.current) {
214
+ clearInterval(blendIntervalRef.current);
215
+ blendIntervalRef.current = null;
216
+ }
217
+ };
218
+ }, []);
219
+ // ---------------------------------------------------------------------------
220
+ // Apply weights to Filament meshes
221
+ // ---------------------------------------------------------------------------
222
+ // Entities that were non-zero last tick — must be flushed even if zero this tick
223
+ const prevDirtyRef = (0, react_1.useRef)(new Set());
224
+ /**
225
+ * Zero all weight arrays, compose layers, flush to Filament once.
226
+ * Always flushes entities that were dirty last tick so zeroed values
227
+ * actually reach the GPU (prevents one-frame stale morph flashes).
228
+ */
229
+ const flushWeights = (0, react_1.useCallback)((morphTargets, visemeTargets) => {
230
+ if (!mountedRef.current)
231
+ return;
232
+ // Zero all weight arrays
233
+ for (const arr of weightsMap.current.values())
234
+ arr.fill(0);
235
+ const dirty = new Set(prevDirtyRef.current);
236
+ // Helper: write a morph-name keyed entry (direct dispatchMap lookup)
237
+ const writeMorph = (name, weight) => {
238
+ const dispatch = dispatchMap.current.get(name) ?? dispatchMap.current.get(normalizeName(name));
239
+ if (!dispatch)
240
+ return;
241
+ const clamped = Math.max(0, Math.min(1, weight));
242
+ for (const entry of dispatch) {
243
+ const arr = weightsMap.current.get(entry.entityId);
244
+ if (!arr)
245
+ continue;
246
+ arr[entry.index] = Math.max(arr[entry.index], clamped);
247
+ dirty.add(entry.entityId);
248
+ }
249
+ };
250
+ // Layer 1+2: mood + breathe + blink (morph target names)
251
+ for (const [name, weight] of Object.entries(morphTargets)) {
252
+ writeMorph(name, weight);
253
+ }
254
+ // Layer 3: viseme state (viseme keys → viseme cache)
255
+ for (const [visemeKey, weight] of Object.entries(visemeTargets)) {
256
+ if (weight <= 0)
257
+ continue;
258
+ const entries = visemeCacheRef.current.get(visemeKey);
259
+ if (!entries)
260
+ continue;
261
+ const clamped = Math.min(1, weight);
262
+ for (const entry of entries) {
263
+ const arr = weightsMap.current.get(entry.entityId);
264
+ if (!arr)
265
+ continue;
266
+ arr[entry.index] = Math.max(arr[entry.index], clamped);
267
+ dirty.add(entry.entityId);
268
+ }
269
+ }
270
+ // Single flush per entity — includes prev-dirty so zeroed morphs reach GPU
271
+ for (const id of dirty) {
272
+ const entity = entityById.current.get(id);
273
+ const arr = weightsMap.current.get(id);
274
+ if (entity && arr) {
275
+ try {
276
+ renderableManager.setMorphWeights(entity, arr, 0);
277
+ }
278
+ catch { /* noop */ }
279
+ }
280
+ }
281
+ prevDirtyRef.current = dirty;
282
+ }, [renderableManager]);
283
+ // ---------------------------------------------------------------------------
284
+ // Apply material color tint to a keyword-matched set of materials
285
+ // ---------------------------------------------------------------------------
286
+ const applyMaterialColor = (0, react_1.useCallback)((keywords, color) => {
287
+ const m = modelRef.current;
288
+ if (m.state !== 'loaded')
289
+ return;
290
+ const float4 = hexToFloat4(color);
291
+ const entities = m.asset.getRenderableEntities();
292
+ let matched = false;
293
+ for (const entity of entities) {
294
+ const count = renderableManager.getPrimitiveCount(entity);
295
+ for (let i = 0; i < count; i++) {
296
+ try {
297
+ const mi = renderableManager.getMaterialInstanceAt(entity, i);
298
+ const lower = (mi.name ?? '').toLowerCase();
299
+ if (keywords.some((k) => lower.includes(k))) {
300
+ mi.setFloat4Parameter('baseColorFactor', float4);
301
+ matched = true;
302
+ }
303
+ }
304
+ catch { /* noop — some entities may not have materials */ }
305
+ }
306
+ }
307
+ // Fallback: tint everything if no keyword match
308
+ if (!matched) {
309
+ for (const entity of entities) {
310
+ const count = renderableManager.getPrimitiveCount(entity);
311
+ for (let i = 0; i < count; i++) {
312
+ try {
313
+ const mi = renderableManager.getMaterialInstanceAt(entity, i);
314
+ mi.setFloat4Parameter('baseColorFactor', float4);
315
+ }
316
+ catch { /* noop */ }
317
+ }
318
+ }
319
+ }
320
+ }, [renderableManager]);
321
+ // ---------------------------------------------------------------------------
322
+ // Build morph cache when model loads
323
+ // ---------------------------------------------------------------------------
324
+ (0, react_1.useEffect)(() => {
325
+ const m = modelRef.current;
326
+ if (m.state !== 'loaded') {
327
+ // Model is unloading or reloading — clear stale entity refs immediately so
328
+ // the 16ms tick interval never calls setMorphWeights on freed entities.
329
+ // Leaving stale entity IDs in the maps causes use-after-free in FEngine::loop.
330
+ dispatchMap.current = new Map();
331
+ weightsMap.current = new Map();
332
+ entityById.current = new Map();
333
+ visemeCacheRef.current = new Map();
334
+ visemeState.current = {};
335
+ prevDirtyRef.current = new Set();
336
+ return;
337
+ }
338
+ const newDispatchMap = new Map();
339
+ const newWeightsMap = new Map();
340
+ const newEntityById = new Map();
341
+ const entities = m.asset.getRenderableEntities();
342
+ for (const entity of entities) {
343
+ let count = 0;
344
+ try {
345
+ count = m.asset.getMorphTargetCountAt(entity);
346
+ }
347
+ catch { /* noop */ }
348
+ if (count <= 0) {
349
+ try {
350
+ count = renderableManager.getMorphTargetCount(entity);
351
+ }
352
+ catch { /* noop */ }
353
+ }
354
+ if (count === 0)
355
+ continue;
356
+ newWeightsMap.set(entity.id, new Array(count).fill(0));
357
+ newEntityById.set(entity.id, entity);
358
+ for (let i = 0; i < count; i++) {
359
+ let name = '';
360
+ try {
361
+ name = m.asset.getMorphTargetNameAt(entity, i) ?? '';
362
+ }
363
+ catch { /* noop */ }
364
+ if (!name) {
365
+ try {
366
+ name = renderableManager.getMorphTargetNameAt(entity, i) ?? '';
367
+ }
368
+ catch { /* noop */ }
369
+ }
370
+ if (!name)
371
+ continue;
372
+ const entry = { entityId: entity.id, index: i };
373
+ const addEntry = (key) => {
374
+ const list = newDispatchMap.get(key);
375
+ if (list)
376
+ list.push(entry);
377
+ else
378
+ newDispatchMap.set(key, [entry]);
379
+ };
380
+ addEntry(name);
381
+ addEntry(normalizeName(name));
382
+ }
383
+ }
384
+ dispatchMap.current = newDispatchMap;
385
+ weightsMap.current = newWeightsMap;
386
+ entityById.current = newEntityById;
387
+ visemeCacheRef.current = buildVisemeMorphCache(newDispatchMap);
388
+ visemeState.current = {};
389
+ // Apply initial appearance
390
+ if (initialSkinColor)
391
+ applyMaterialColor(SKIN_MATERIAL_KEYWORDS, initialSkinColor);
392
+ if (initialHairColor)
393
+ applyMaterialColor(HAIR_MATERIAL_KEYWORDS, initialHairColor);
394
+ if (initialEyeColor)
395
+ applyMaterialColor(EYE_MATERIAL_KEYWORDS, initialEyeColor);
396
+ onReady?.();
397
+ // Replay any pending schedule that arrived before the model was ready.
398
+ // (scheduleVisemes stores to lastScheduleRef even when morphMapSize is 0)
399
+ const pending = lastScheduleRef.current;
400
+ if (pending && pending.cues && pending.cues.length > 0) {
401
+ const startedAt = pending.startedAtMs ?? Date.now();
402
+ const durationMs = pending.durationMs ?? 0;
403
+ const ageMs = Date.now() - startedAt;
404
+ // Only replay if audio is likely still playing
405
+ if (durationMs <= 0 || ageMs < durationMs + 300) {
406
+ __DEV__ && console.log('[FilamentAvatar] replaying pending schedule after model load', {
407
+ requestId: pending.requestId ?? null,
408
+ ageMs,
409
+ cues: pending.cues.length,
410
+ });
411
+ const scheduleToReplay = pending;
412
+ lastScheduleRef.current = null;
413
+ // scheduleVisemesRef.current is populated during render (before effects run)
414
+ scheduleVisemesRef.current?.(scheduleToReplay);
415
+ }
416
+ else {
417
+ lastScheduleRef.current = null;
418
+ }
419
+ }
420
+ // eslint-disable-next-line react-hooks/exhaustive-deps
421
+ }, [model.state]);
422
+ // ---------------------------------------------------------------------------
423
+ // Viseme tick — runs every 16ms, decays state and writes to morphs
424
+ // ---------------------------------------------------------------------------
425
+ const tickViseme = (0, react_1.useCallback)(() => {
426
+ const state = visemeState.current;
427
+ const now = Date.now();
428
+ const isScheduled = now < visemeModeUntil.current;
429
+ if (!isScheduled) {
430
+ // Amplitude fallback: jaw bobble when no viseme schedule active
431
+ const amp = amplitudeRef.current;
432
+ if (amp > 0.01) {
433
+ state['aa'] = amp * 0.6;
434
+ }
435
+ else {
436
+ for (const key of Object.keys(state)) {
437
+ const decayed = (state[key] ?? 0) * 0.82;
438
+ state[key] = decayed < 0.01 ? 0 : decayed;
439
+ }
440
+ }
441
+ }
442
+ // Idle breathing — subtle cheek/nose puff on inhale
443
+ breathPhaseRef.current += (2 * Math.PI * 16) / IDLE_BREATHE_PERIOD_MS;
444
+ const breathe = Math.max(0, Math.sin(breathPhaseRef.current)) * 0.06;
445
+ // Mood baseline + breathe + blink (morph target names)
446
+ const morphTargets = { ...moodWeightsRef.current };
447
+ if (breathe > 0.005) {
448
+ morphTargets['noseSneerLeft'] = Math.max(morphTargets['noseSneerLeft'] ?? 0, breathe);
449
+ morphTargets['noseSneerRight'] = Math.max(morphTargets['noseSneerRight'] ?? 0, breathe);
450
+ morphTargets['cheekPuff'] = Math.max(morphTargets['cheekPuff'] ?? 0, breathe * 0.5);
451
+ }
452
+ if (isBlinkingRef.current) {
453
+ morphTargets['eyeBlinkLeft'] = 1;
454
+ morphTargets['eyeBlinkRight'] = 1;
455
+ }
456
+ // Idle emotion overlay — blend on top of mood baseline
457
+ for (const [name, weight] of Object.entries(idleEmotionWeightsRef.current)) {
458
+ if (weight > 0)
459
+ morphTargets[name] = Math.max(morphTargets[name] ?? 0, weight);
460
+ }
461
+ // Single flush: zeros all arrays, writes mood/breathe, writes visemes on top, pushes to GPU once
462
+ flushWeights(morphTargets, state);
463
+ }, [flushWeights]);
464
+ (0, react_1.useEffect)(() => {
465
+ tickIntervalRef.current = setInterval(tickViseme, 16);
466
+ return () => {
467
+ if (tickIntervalRef.current)
468
+ clearInterval(tickIntervalRef.current);
469
+ };
470
+ }, [tickViseme]);
471
+ // ---------------------------------------------------------------------------
472
+ // Viseme scheduler
473
+ // ---------------------------------------------------------------------------
474
+ const clearScheduledVisemes = (0, react_1.useCallback)(() => {
475
+ for (const id of visemeTimers.current)
476
+ clearTimeout(id);
477
+ visemeTimers.current = [];
478
+ visemeModeUntil.current = 0;
479
+ visemeState.current = {};
480
+ }, []);
481
+ const scheduleVisemes = (0, react_1.useCallback)((schedule) => {
482
+ const modelLoaded = modelRef.current.state === 'loaded';
483
+ const morphMapSize = dispatchMap.current.size;
484
+ __DEV__ && console.log('[FilamentAvatar] scheduleVisemes', {
485
+ cues: schedule.cues?.length ?? 0,
486
+ modelLoaded,
487
+ morphMapSize,
488
+ requestId: schedule.requestId ?? null,
489
+ });
490
+ // Always save — if model isn't loaded yet, we replay when it loads
491
+ lastScheduleRef.current = schedule;
492
+ clearScheduledVisemes();
493
+ activeScheduleId.current += 1;
494
+ const myId = activeScheduleId.current;
495
+ if (!schedule.cues || schedule.cues.length === 0)
496
+ return;
497
+ const startedAt = schedule.startedAtMs ?? Date.now();
498
+ const durationMs = schedule.durationMs ?? 0;
499
+ const now = Date.now();
500
+ const elapsedMs = Math.max(0, now - startedAt);
501
+ visemeModeUntil.current = now + Math.max(0, durationMs - elapsedMs) + 200;
502
+ for (const cue of schedule.cues) {
503
+ const delay = cue.startMs - elapsedMs;
504
+ // Skip cues that are more than one cue-duration in the past
505
+ if (delay < -(cue.endMs - cue.startMs) - 50)
506
+ continue;
507
+ const visemeKey = morphTables_1.RHUBARB_TO_VISEME[cue.viseme] ?? 'sil';
508
+ const weight = morphTables_1.VISEME_WEIGHTS[visemeKey] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
509
+ const applyId = setTimeout(() => {
510
+ if (activeScheduleId.current !== myId)
511
+ return;
512
+ for (const k of Object.keys(visemeState.current))
513
+ visemeState.current[k] = 0;
514
+ if (visemeKey !== 'sil')
515
+ visemeState.current[visemeKey] = weight;
516
+ }, Math.max(0, delay));
517
+ const clearId = setTimeout(() => {
518
+ if (activeScheduleId.current !== myId)
519
+ return;
520
+ if (visemeState.current[visemeKey] !== undefined)
521
+ visemeState.current[visemeKey] = 0;
522
+ }, Math.max(0, delay + (cue.endMs - cue.startMs)));
523
+ visemeTimers.current.push(applyId, clearId);
524
+ }
525
+ // Silence at end — always schedule a hard reset
526
+ const endDelay = durationMs > 0
527
+ ? Math.max(0, durationMs - (Date.now() - startedAt)) + 100
528
+ : (schedule.cues[schedule.cues.length - 1]?.endMs ?? 0) - elapsedMs + 100;
529
+ visemeTimers.current.push(setTimeout(() => {
530
+ if (activeScheduleId.current !== myId)
531
+ return;
532
+ // Hard reset: zero all viseme state and exit scheduled mode
533
+ visemeState.current = {};
534
+ visemeModeUntil.current = 0;
535
+ }, Math.max(0, endDelay)));
536
+ }, [clearScheduledVisemes]);
537
+ // Keep ref in sync so the model-load effect can call scheduleVisemes
538
+ scheduleVisemesRef.current = scheduleVisemes;
539
+ // Single-shot viseme (for real-time streaming TTS like ElevenLabs)
540
+ const sendViseme = (0, react_1.useCallback)((viseme, weight = morphTables_1.DEFAULT_VISEME_WEIGHT) => {
541
+ for (const k of Object.keys(visemeState.current))
542
+ visemeState.current[k] = 0;
543
+ if (viseme !== 'sil')
544
+ visemeState.current[viseme] = morphTables_1.VISEME_WEIGHTS[viseme] ?? weight;
545
+ visemeModeUntil.current = Date.now() + 120; // hold for ~120ms
546
+ }, []);
547
+ // ---------------------------------------------------------------------------
548
+ // Idle blink
549
+ // ---------------------------------------------------------------------------
550
+ const scheduleBlink = (0, react_1.useCallback)(() => {
551
+ const delay = BLINK_INTERVAL_MIN_MS + Math.random() * (BLINK_INTERVAL_MAX_MS - BLINK_INTERVAL_MIN_MS);
552
+ blinkTimerRef.current = setTimeout(() => {
553
+ isBlinkingRef.current = true;
554
+ setTimeout(() => {
555
+ isBlinkingRef.current = false;
556
+ scheduleBlink();
557
+ }, BLINK_HOLD_MS);
558
+ }, delay);
559
+ }, []);
560
+ (0, react_1.useEffect)(() => {
561
+ if (model.state !== 'loaded')
562
+ return;
563
+ scheduleBlink();
564
+ return () => {
565
+ if (blinkTimerRef.current)
566
+ clearTimeout(blinkTimerRef.current);
567
+ };
568
+ }, [model.state, scheduleBlink]);
569
+ // ---------------------------------------------------------------------------
570
+ // Idle emotion bursts — weighted random expressions matching FaceSqueezeEditor
571
+ // ---------------------------------------------------------------------------
572
+ // Tracks the active blend-in/blend-out intervals so they can be cleaned up on unmount
573
+ const blendIntervalRef = (0, react_1.useRef)(null);
574
+ const playIdleEmotion = (0, react_1.useCallback)((key) => {
575
+ if (!mountedRef.current)
576
+ return;
577
+ const target = IDLE_EMOTION_PRESETS[key] ?? {};
578
+ const steps = Math.max(1, Math.round(IDLE_EMOTION_BLEND_MS / 16));
579
+ // Clear any in-flight blend interval from a previous emotion
580
+ if (blendIntervalRef.current) {
581
+ clearInterval(blendIntervalRef.current);
582
+ blendIntervalRef.current = null;
583
+ }
584
+ // Blend in
585
+ let step = 0;
586
+ blendIntervalRef.current = setInterval(() => {
587
+ if (!mountedRef.current) {
588
+ clearInterval(blendIntervalRef.current);
589
+ blendIntervalRef.current = null;
590
+ return;
591
+ }
592
+ step++;
593
+ const t = Math.min(1, step / steps);
594
+ const blended = {};
595
+ for (const [k, v] of Object.entries(target))
596
+ blended[k] = v * t;
597
+ idleEmotionWeightsRef.current = blended;
598
+ if (step >= steps) {
599
+ clearInterval(blendIntervalRef.current);
600
+ blendIntervalRef.current = null;
601
+ }
602
+ }, 16);
603
+ // Hold then blend out
604
+ idleEmotionTimerRef.current = setTimeout(() => {
605
+ if (!mountedRef.current)
606
+ return;
607
+ // Clear blend-in if it somehow outlasted the hold period
608
+ if (blendIntervalRef.current) {
609
+ clearInterval(blendIntervalRef.current);
610
+ blendIntervalRef.current = null;
611
+ }
612
+ let outStep = 0;
613
+ blendIntervalRef.current = setInterval(() => {
614
+ if (!mountedRef.current) {
615
+ clearInterval(blendIntervalRef.current);
616
+ blendIntervalRef.current = null;
617
+ return;
618
+ }
619
+ outStep++;
620
+ const t = Math.max(0, 1 - outStep / steps);
621
+ const blended = {};
622
+ for (const [k, v] of Object.entries(target))
623
+ blended[k] = v * t;
624
+ idleEmotionWeightsRef.current = blended;
625
+ if (outStep >= steps) {
626
+ clearInterval(blendIntervalRef.current);
627
+ blendIntervalRef.current = null;
628
+ idleEmotionWeightsRef.current = {};
629
+ }
630
+ }, 16);
631
+ }, IDLE_EMOTION_HOLD_MS);
632
+ }, []);
633
+ (0, react_1.useEffect)(() => {
634
+ if (model.state !== 'loaded')
635
+ return;
636
+ const initial = setTimeout(() => playIdleEmotion(pickIdleEmotion()), IDLE_EMOTION_INITIAL_DELAY_MS);
637
+ idleEmotionIntervalRef.current = setInterval(() => playIdleEmotion(pickIdleEmotion()), IDLE_EMOTION_INTERVAL_MS);
638
+ return () => {
639
+ clearTimeout(initial);
640
+ if (idleEmotionIntervalRef.current)
641
+ clearInterval(idleEmotionIntervalRef.current);
642
+ if (idleEmotionTimerRef.current)
643
+ clearTimeout(idleEmotionTimerRef.current);
644
+ if (blendIntervalRef.current) {
645
+ clearInterval(blendIntervalRef.current);
646
+ blendIntervalRef.current = null;
647
+ }
648
+ idleEmotionWeightsRef.current = {};
649
+ };
650
+ }, [model.state, playIdleEmotion]);
651
+ // ---------------------------------------------------------------------------
652
+ // Imperative ref — full TalkingHeadRef parity
653
+ // ---------------------------------------------------------------------------
654
+ (0, react_1.useImperativeHandle)(innerRef, () => ({
655
+ sendViseme,
656
+ scheduleVisemes,
657
+ clearVisemes: clearScheduledVisemes,
658
+ sendAmplitude: (amp) => { amplitudeRef.current = amp; },
659
+ setMood: (mood) => { moodWeightsRef.current = morphTables_1.MOOD_MORPHS[mood] ?? {}; },
660
+ setHairColor: (color) => applyMaterialColor(HAIR_MATERIAL_KEYWORDS, color),
661
+ setSkinColor: (color) => applyMaterialColor(SKIN_MATERIAL_KEYWORDS, color),
662
+ setEyeColor: (color) => applyMaterialColor(EYE_MATERIAL_KEYWORDS, color),
663
+ setAccessories: (_accessories) => {
664
+ // Future: load accessory GLBs and attach to skeleton bones
665
+ },
666
+ }), [sendViseme, scheduleVisemes, clearScheduledVisemes, applyMaterialColor]);
667
+ // ---------------------------------------------------------------------------
668
+ // Cleanup
669
+ // ---------------------------------------------------------------------------
670
+ (0, react_1.useEffect)(() => {
671
+ return () => {
672
+ clearScheduledVisemes();
673
+ if (blinkTimerRef.current)
674
+ clearTimeout(blinkTimerRef.current);
675
+ if (tickIntervalRef.current)
676
+ clearInterval(tickIntervalRef.current);
677
+ };
678
+ }, [clearScheduledVisemes]);
679
+ // Pause Filament render loop when app backgrounds to prevent native
680
+ // render thread from accessing freed GPU/surface resources → SIGSEGV.
681
+ const filamentViewRef = (0, react_1.useRef)(null);
682
+ (0, react_1.useEffect)(() => {
683
+ const sub = react_native_1.AppState.addEventListener('change', (state) => {
684
+ if (!filamentViewRef.current)
685
+ return;
686
+ if (state === 'background' || state === 'inactive') {
687
+ filamentViewRef.current.pause?.();
688
+ }
689
+ else if (state === 'active') {
690
+ filamentViewRef.current.resume?.();
691
+ }
692
+ });
693
+ return () => sub.remove();
694
+ }, []);
695
+ return ((0, jsx_runtime_1.jsxs)(react_native_filament_1.FilamentView, { ref: filamentViewRef, style: react_native_1.StyleSheet.absoluteFill, children: [(0, jsx_runtime_1.jsx)(react_native_filament_1.DefaultLight, {}), (0, jsx_runtime_1.jsx)(react_native_filament_1.Camera, { cameraPosition: CAMERA_POSITION, cameraTarget: CAMERA_TARGET, cameraUp: CAMERA_UP, aspect: aspect ?? 1, focalLengthInMillimeters: focalLength }), model.state === 'loaded' && ((0, jsx_runtime_1.jsx)(react_native_filament_1.ModelRenderer, { model: model, multiplyWithCurrentTransform: false }))] }));
696
+ }
697
+ // ---------------------------------------------------------------------------
698
+ // Outer component — handles URL resolution + FilamentScene wrapper
699
+ // ---------------------------------------------------------------------------
700
+ let _fallbackGlbUri = null;
701
+ async function getFallbackGlbUri() {
702
+ if (_fallbackGlbUri)
703
+ return _fallbackGlbUri;
704
+ const asset = expo_asset_1.Asset.fromModule(faceSqueezeAssets_1.FACE_SQUEEZE_LOCAL_MODULE);
705
+ await asset.downloadAsync();
706
+ _fallbackGlbUri = asset.localUri ?? asset.uri ?? '';
707
+ return _fallbackGlbUri;
708
+ }
709
+ exports.FilamentAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, aspect, focalLength, mood, hairColor, skinColor, eyeColor, accessories, onReady, onError }, ref) => {
710
+ const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(avatarUrl ?? null);
711
+ const currentUri = fileResult?.uri ?? null;
712
+ const [fallbackUri, setFallbackUri] = react_1.default.useState(_fallbackGlbUri);
713
+ react_1.default.useEffect(() => {
714
+ getFallbackGlbUri().then(setFallbackUri).catch((e) => console.error('[FilamentAvatar] fallback uri failed:', e));
715
+ }, []);
716
+ // Stabilize localUri: once a valid file URI resolves, keep it until a new one
717
+ // arrives. Never let it drop to null between URL transitions — that would cause
718
+ // the conditional below to unmount FilamentScene mid-render.
719
+ const stableUriRef = react_1.default.useRef(null);
720
+ if (currentUri)
721
+ stableUriRef.current = currentUri;
722
+ const localUri = stableUriRef.current ?? fallbackUri;
723
+ // FilamentScene MUST stay mounted for the lifetime of this component.
724
+ // Unmounting it disposes GPU assets on the JS thread while Filament's native
725
+ // render thread (FEngine::loop) and surface thread (JNISurfaceTextu) still hold
726
+ // references to those objects → SIGSEGV / corrupted-PC use-after-free crashes.
727
+ // We always render FilamentScene and conditionally render FilamentAvatarInner
728
+ // inside it once the local file URI is ready.
729
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.fill], children: (0, jsx_runtime_1.jsx)(react_native_filament_1.FilamentScene, { children: (0, jsx_runtime_1.jsx)(FilamentAvatarInner, { localUri: localUri ?? '', avatarUrl: avatarUrl, aspect: aspect, focalLength: focalLength, mood: mood, hairColor: hairColor, skinColor: skinColor, eyeColor: eyeColor, accessories: accessories, innerRef: ref, onReady: onReady, onError: onError }) }) }));
730
+ });
731
+ exports.FilamentAvatar.displayName = 'FilamentAvatar';
732
+ const styles = react_native_1.StyleSheet.create({
733
+ fill: { flex: 1 },
734
+ empty: { backgroundColor: 'transparent' },
735
+ center: { alignItems: 'center', justifyContent: 'center' },
736
+ label: { color: 'rgba(226,232,240,0.6)', fontSize: 13 },
737
+ });