talking-head-studio 0.3.8 → 0.4.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.
- package/dist/editor/AvatarCanvas.d.ts +1 -1
- package/dist/editor/AvatarCanvas.js +1 -1
- package/dist/filament/FilamentAvatar.d.ts +2 -2
- package/dist/filament/FilamentAvatar.js +63 -23
- package/dist/index.web.d.ts +2 -0
- package/dist/index.web.js +3 -1
- package/dist/wardrobe/useAvatarWardrobeHydration.js +1 -1
- package/dist/wgpu/WgpuAvatar.d.ts +36 -0
- package/dist/wgpu/WgpuAvatar.js +293 -0
- package/dist/wgpu/index.d.ts +2 -0
- package/dist/wgpu/index.js +5 -0
- package/package.json +11 -2
|
@@ -12,5 +12,5 @@ interface AvatarCanvasProps {
|
|
|
12
12
|
style?: React.CSSProperties;
|
|
13
13
|
className?: string;
|
|
14
14
|
}
|
|
15
|
-
export declare function AvatarCanvas({ avatarUrl,
|
|
15
|
+
export declare function AvatarCanvas({ avatarUrl, equipped, placements, editingAssetId, onPlacementChange, onSceneRef, style, className, }: AvatarCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
16
16
|
export {};
|
|
@@ -18,7 +18,7 @@ function SceneRefCapture({ onSceneRef }) {
|
|
|
18
18
|
}, [scene, onSceneRef]);
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
|
-
function AvatarCanvas({ avatarUrl,
|
|
21
|
+
function AvatarCanvas({ avatarUrl, equipped = [], placements = {}, editingAssetId = null, onPlacementChange, onSceneRef, style, className, }) {
|
|
22
22
|
const [skeleton, setSkeleton] = (0, react_1.useState)(null);
|
|
23
23
|
const [avatarScene, setAvatarScene] = (0, react_1.useState)(null);
|
|
24
24
|
const [cameraTarget, setCameraTarget] = (0, react_1.useState)([0, 1, 0]);
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
import React from 'react';
|
|
12
12
|
import { ViewStyle, StyleProp } from 'react-native';
|
|
13
13
|
import type { TalkingHeadViseme, TalkingHeadVisemeSchedule, TalkingHeadAccessory, TalkingHeadMood } from '../TalkingHead';
|
|
14
|
-
export declare const CAMERA_FOCAL_FULL =
|
|
15
|
-
export declare const CAMERA_FOCAL_PIP =
|
|
14
|
+
export declare const CAMERA_FOCAL_FULL = 50;
|
|
15
|
+
export declare const CAMERA_FOCAL_PIP = 85;
|
|
16
16
|
export interface FilamentAvatarRef {
|
|
17
17
|
sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
|
|
18
18
|
scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
|
|
@@ -40,6 +40,9 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
40
40
|
const react_1 = __importStar(require("react"));
|
|
41
41
|
const react_native_1 = require("react-native");
|
|
42
42
|
const react_native_filament_1 = require("react-native-filament");
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-require-imports
|
|
44
|
+
const { Skybox } = require('react-native-filament');
|
|
45
|
+
const expo_asset_1 = require("expo-asset");
|
|
43
46
|
const useAuthedFilamentUri_1 = require("./useAuthedFilamentUri");
|
|
44
47
|
const faceSqueezeAssets_1 = require("./faceSqueezeAssets");
|
|
45
48
|
const morphTables_1 = require("./morphTables");
|
|
@@ -91,14 +94,14 @@ function pickIdleEmotion() {
|
|
|
91
94
|
// Camera presets — tight head framing matching TalkingHead cameraView='head'
|
|
92
95
|
// Avatar head is at approximately y=1.62 in Ready Player Me scale.
|
|
93
96
|
// ---------------------------------------------------------------------------
|
|
94
|
-
//
|
|
95
|
-
const CAMERA_POSITION = [0, 1.
|
|
96
|
-
const CAMERA_TARGET = [0, 1.
|
|
97
|
+
// Camera sits ~1.0m back, targeting the face for a tight crop.
|
|
98
|
+
const CAMERA_POSITION = [0, 1.52, 0.85];
|
|
99
|
+
const CAMERA_TARGET = [0, 1.52, 0];
|
|
97
100
|
const CAMERA_UP = [0, 1, 0];
|
|
98
|
-
//
|
|
101
|
+
// 50mm gives natural portrait framing at 1.0m distance.
|
|
99
102
|
// Pip: 85mm telephoto for a tight face crop in the small bubble.
|
|
100
|
-
exports.CAMERA_FOCAL_FULL =
|
|
101
|
-
exports.CAMERA_FOCAL_PIP =
|
|
103
|
+
exports.CAMERA_FOCAL_FULL = 50;
|
|
104
|
+
exports.CAMERA_FOCAL_PIP = 85;
|
|
102
105
|
// ---------------------------------------------------------------------------
|
|
103
106
|
// Idle micro-expression cycles (MotionEngine-style procedural layer)
|
|
104
107
|
// Runs at ~60ms ticks, drives subtle head/face movement independent of speech
|
|
@@ -137,11 +140,15 @@ function buildVisemeMorphCache(dispatchMap) {
|
|
|
137
140
|
}
|
|
138
141
|
function FilamentAvatarInner({ localUri, mood: initialMood = 'neutral', hairColor: initialHairColor, skinColor: initialSkinColor, eyeColor: initialEyeColor, onReady, onError: _onError, // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
139
142
|
innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
const
|
|
143
|
+
// Memoize source object to avoid new reference on every render → useModel reload → SIGSEGV.
|
|
144
|
+
// When localUri is empty (fallback not yet resolved) we still pass a source so
|
|
145
|
+
// FilamentAvatarInner stays mounted and FilamentView's SwapChain stays alive.
|
|
146
|
+
// useModel will fail gracefully for the empty string; ModelRenderer is gated on state==='loaded'.
|
|
147
|
+
const modelSource = react_1.default.useMemo(() => (localUri ? { uri: localUri } : null), [localUri]);
|
|
148
|
+
(0, react_1.useEffect)(() => {
|
|
149
|
+
console.log('[FilamentAvatar] useModel source:', localUri || '(empty — waiting for fallback)');
|
|
150
|
+
}, [localUri]);
|
|
151
|
+
const model = (0, react_native_filament_1.useModel)(modelSource);
|
|
145
152
|
const modelRef = (0, react_1.useRef)(model);
|
|
146
153
|
modelRef.current = model;
|
|
147
154
|
const { renderableManager } = (0, react_native_filament_1.useFilamentContext)();
|
|
@@ -325,6 +332,7 @@ innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
|
|
|
325
332
|
prevDirtyRef.current = new Set();
|
|
326
333
|
return;
|
|
327
334
|
}
|
|
335
|
+
console.log('[FilamentAvatar] model loaded, boundingBox:', JSON.stringify(m.boundingBox));
|
|
328
336
|
const newDispatchMap = new Map();
|
|
329
337
|
const newWeightsMap = new Map();
|
|
330
338
|
const newEntityById = new Map();
|
|
@@ -682,29 +690,61 @@ innerRef, aspect, focalLength = exports.CAMERA_FOCAL_FULL, }) {
|
|
|
682
690
|
});
|
|
683
691
|
return () => sub.remove();
|
|
684
692
|
}, []);
|
|
685
|
-
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
|
|
693
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_filament_1.FilamentView, { ref: filamentViewRef, style: react_native_1.StyleSheet.absoluteFill, enableTransparentRendering: false, children: [(0, jsx_runtime_1.jsx)(Skybox, { colorInHex: "#1a1a2e" }), (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 }))] }));
|
|
686
694
|
}
|
|
687
695
|
// ---------------------------------------------------------------------------
|
|
688
696
|
// Outer component — handles URL resolution + FilamentScene wrapper
|
|
689
697
|
// ---------------------------------------------------------------------------
|
|
690
|
-
//
|
|
691
|
-
// (
|
|
692
|
-
|
|
693
|
-
|
|
698
|
+
// Resolve the fallback GLB to a file:// URI once at module load.
|
|
699
|
+
// useBuffer (inside useModel) resolves require() numbers via Image.resolveAssetSource
|
|
700
|
+
// which returns an http://localhost Metro URL in dev — Filament's native loader can't
|
|
701
|
+
// fetch that. We must pre-resolve to a file:// path via expo-asset.
|
|
702
|
+
let _fallbackUri = null;
|
|
703
|
+
const _fallbackReady = (async () => {
|
|
704
|
+
const a = expo_asset_1.Asset.fromModule(faceSqueezeAssets_1.FACE_SQUEEZE_LOCAL_MODULE);
|
|
705
|
+
await a.downloadAsync();
|
|
706
|
+
_fallbackUri = a.localUri ?? a.uri ?? '';
|
|
707
|
+
return _fallbackUri;
|
|
708
|
+
})();
|
|
709
|
+
exports.FilamentAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, aspect: aspectProp, focalLength, mood, hairColor, skinColor, eyeColor, accessories, onReady, onError }, ref) => {
|
|
710
|
+
// Only download avatarUrl if it's a real remote studio URL (http/https).
|
|
711
|
+
const remoteUrl = (avatarUrl && (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')))
|
|
712
|
+
? avatarUrl : null;
|
|
713
|
+
const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(remoteUrl);
|
|
694
714
|
const currentUri = fileResult?.uri ?? null;
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
715
|
+
// Fallback: resolved file:// URI of the bundled face-squeeze GLB.
|
|
716
|
+
// Starts null, populates async (fast — already kicked off at module load).
|
|
717
|
+
const [fallbackUri, setFallbackUri] = react_1.default.useState(_fallbackUri);
|
|
718
|
+
react_1.default.useEffect(() => {
|
|
719
|
+
if (!_fallbackUri) {
|
|
720
|
+
_fallbackReady.then(setFallbackUri).catch(() => { });
|
|
721
|
+
}
|
|
722
|
+
}, []);
|
|
723
|
+
// Lock-in URI: once we pick a URI for this mount, NEVER change it.
|
|
724
|
+
// Switching from fallback → studio mid-session causes useModel to reload,
|
|
725
|
+
// which tears down GPU assets while FEngine::loop still holds references → SIGSEGV.
|
|
726
|
+
// Strategy: if the studio URI is ready before/at mount time, use it directly.
|
|
727
|
+
// If not, use the fallback and stay on it for the lifetime of this component.
|
|
728
|
+
const lockedUriRef = react_1.default.useRef(null);
|
|
729
|
+
if (lockedUriRef.current === null) {
|
|
730
|
+
// First render: pick whichever URI is available, preferring studio.
|
|
731
|
+
lockedUriRef.current = currentUri ?? fallbackUri;
|
|
732
|
+
}
|
|
733
|
+
const localUri = lockedUriRef.current;
|
|
734
|
+
// Compute aspect ratio from layout if not provided by caller.
|
|
735
|
+
const [measuredAspect, setMeasuredAspect] = react_1.default.useState(undefined);
|
|
736
|
+
const aspect = aspectProp ?? measuredAspect;
|
|
701
737
|
// FilamentScene MUST stay mounted for the lifetime of this component.
|
|
702
738
|
// Unmounting it disposes GPU assets on the JS thread while Filament's native
|
|
703
739
|
// render thread (FEngine::loop) and surface thread (JNISurfaceTextu) still hold
|
|
704
740
|
// references to those objects → SIGSEGV / corrupted-PC use-after-free crashes.
|
|
705
741
|
// FilamentAvatarInner keeps FilamentView permanently mounted; only ModelRenderer
|
|
706
742
|
// is conditional on the model being ready.
|
|
707
|
-
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.fill],
|
|
743
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.fill], onLayout: (e) => {
|
|
744
|
+
const { width, height } = e.nativeEvent.layout;
|
|
745
|
+
if (height > 0)
|
|
746
|
+
setMeasuredAspect(width / height);
|
|
747
|
+
}, 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 }) }) }));
|
|
708
748
|
});
|
|
709
749
|
exports.FilamentAvatar.displayName = 'FilamentAvatar';
|
|
710
750
|
const styles = react_native_1.StyleSheet.create({
|
package/dist/index.web.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export { pickTargetForMaterialName } from './appearance/matchers';
|
|
|
5
5
|
export { normalizeAppearance } from './appearance/schema';
|
|
6
6
|
export type { AppearanceTarget } from './appearance/matchers';
|
|
7
7
|
export * from './api';
|
|
8
|
+
export { useDirectVisemeStream } from './tts/useDirectVisemeStream';
|
|
9
|
+
export type { VisemeStreamPayload } from './tts/useDirectVisemeStream';
|
package/dist/index.web.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.normalizeAppearance = exports.pickTargetForMaterialName = exports.applyAppearanceToObject3D = exports.TalkingHead = void 0;
|
|
17
|
+
exports.useDirectVisemeStream = exports.normalizeAppearance = exports.pickTargetForMaterialName = exports.applyAppearanceToObject3D = exports.TalkingHead = void 0;
|
|
18
18
|
var TalkingHead_web_1 = require("./TalkingHead.web");
|
|
19
19
|
Object.defineProperty(exports, "TalkingHead", { enumerable: true, get: function () { return TalkingHead_web_1.TalkingHead; } });
|
|
20
20
|
// Export appearance utilities, but exclude AvatarAppearance — the canonical
|
|
@@ -26,3 +26,5 @@ Object.defineProperty(exports, "pickTargetForMaterialName", { enumerable: true,
|
|
|
26
26
|
var schema_1 = require("./appearance/schema");
|
|
27
27
|
Object.defineProperty(exports, "normalizeAppearance", { enumerable: true, get: function () { return schema_1.normalizeAppearance; } });
|
|
28
28
|
__exportStar(require("./api"), exports);
|
|
29
|
+
var useDirectVisemeStream_1 = require("./tts/useDirectVisemeStream");
|
|
30
|
+
Object.defineProperty(exports, "useDirectVisemeStream", { enumerable: true, get: function () { return useDirectVisemeStream_1.useDirectVisemeStream; } });
|
|
@@ -30,6 +30,6 @@ function useAvatarWardrobeHydration({ avatarId, accessories, }) {
|
|
|
30
30
|
return () => {
|
|
31
31
|
cancelled = true;
|
|
32
32
|
};
|
|
33
|
-
}, [accessorySignature, avatarId, hydrateFromApi]);
|
|
33
|
+
}, [accessorySignature, avatarId, hydrateFromApi, accessories]);
|
|
34
34
|
}
|
|
35
35
|
exports.useAvatarWardrobeHydration = useAvatarWardrobeHydration;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WgpuAvatar — drop-in replacement for FilamentAvatar using react-three-fiber
|
|
3
|
+
* + react-native-wgpu (or expo-gl fallback). Exposes the same ref interface as
|
|
4
|
+
* FilamentAvatarRef so siteclaw can swap renderers without changing call sites.
|
|
5
|
+
*
|
|
6
|
+
* No SurfaceTexture, no choreographer, no JNI surface lifecycle bugs.
|
|
7
|
+
*
|
|
8
|
+
* Peer deps required by the host app:
|
|
9
|
+
* react-native-wgpu (or expo-gl for the expo-gl fallback canvas)
|
|
10
|
+
* @react-three/fiber >= 8
|
|
11
|
+
* @react-three/drei >= 9
|
|
12
|
+
* three >= 0.170
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
16
|
+
import type { TalkingHeadMood, TalkingHeadAccessory, TalkingHeadVisemeSchedule } from '../index';
|
|
17
|
+
export interface WgpuAvatarRef {
|
|
18
|
+
setMood: (mood: TalkingHeadMood) => void;
|
|
19
|
+
sendAmplitude: (amplitude: number) => void;
|
|
20
|
+
sendViseme: (viseme: string, weight?: number) => void;
|
|
21
|
+
scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
|
|
22
|
+
clearVisemes: () => void;
|
|
23
|
+
}
|
|
24
|
+
interface WgpuAvatarProps {
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
avatarUrl: string | null;
|
|
27
|
+
authToken?: string;
|
|
28
|
+
aspect?: number;
|
|
29
|
+
focalLength?: number;
|
|
30
|
+
mood?: TalkingHeadMood;
|
|
31
|
+
accessories?: TalkingHeadAccessory[];
|
|
32
|
+
onReady?: () => void;
|
|
33
|
+
onError?: (message: string) => void;
|
|
34
|
+
}
|
|
35
|
+
export declare const WgpuAvatar: React.ForwardRefExoticComponent<WgpuAvatarProps & React.RefAttributes<WgpuAvatarRef>>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,293 @@
|
|
|
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 (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.WgpuAvatar = void 0;
|
|
27
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
28
|
+
/**
|
|
29
|
+
* WgpuAvatar — drop-in replacement for FilamentAvatar using react-three-fiber
|
|
30
|
+
* + react-native-wgpu (or expo-gl fallback). Exposes the same ref interface as
|
|
31
|
+
* FilamentAvatarRef so siteclaw can swap renderers without changing call sites.
|
|
32
|
+
*
|
|
33
|
+
* No SurfaceTexture, no choreographer, no JNI surface lifecycle bugs.
|
|
34
|
+
*
|
|
35
|
+
* Peer deps required by the host app:
|
|
36
|
+
* react-native-wgpu (or expo-gl for the expo-gl fallback canvas)
|
|
37
|
+
* @react-three/fiber >= 8
|
|
38
|
+
* @react-three/drei >= 9
|
|
39
|
+
* three >= 0.170
|
|
40
|
+
*/
|
|
41
|
+
const react_1 = __importStar(require("react"));
|
|
42
|
+
const react_native_1 = require("react-native");
|
|
43
|
+
const THREE = __importStar(require("three"));
|
|
44
|
+
const native_1 = require("@react-three/fiber/native");
|
|
45
|
+
const native_2 = require("@react-three/drei/native");
|
|
46
|
+
const morphTables_1 = require("../filament/morphTables");
|
|
47
|
+
const useAuthedFilamentUri_1 = require("../filament/useAuthedFilamentUri");
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Camera defaults — match FilamentAvatar constants
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
const CAMERA_POSITION = [0, 1.52, 0.85];
|
|
52
|
+
const CAMERA_TARGET = new THREE.Vector3(0, 1.52, 0);
|
|
53
|
+
const CAMERA_FOV_FULL = 38; // rough Three.js FOV equiv to 50mm focal length
|
|
54
|
+
function buildMorphIndex(mesh) {
|
|
55
|
+
const map = new Map();
|
|
56
|
+
const dict = mesh.morphTargetDictionary;
|
|
57
|
+
if (!dict)
|
|
58
|
+
return map;
|
|
59
|
+
for (const [name, idx] of Object.entries(dict)) {
|
|
60
|
+
map.set(name.toLowerCase(), idx);
|
|
61
|
+
map.set(name, idx); // keep original casing too
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
function resolveIndex(morphIndex, aliases) {
|
|
66
|
+
for (const alias of aliases) {
|
|
67
|
+
const idx = morphIndex.get(alias) ?? morphIndex.get(alias.toLowerCase());
|
|
68
|
+
if (idx !== undefined)
|
|
69
|
+
return idx;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
function AvatarScene({ uri, morphStateRef, fov, onReady, onError }) {
|
|
74
|
+
const { camera } = (0, native_1.useThree)();
|
|
75
|
+
const gltf = (0, native_2.useGLTF)(uri);
|
|
76
|
+
const headMeshRef = (0, react_1.useRef)(null);
|
|
77
|
+
const morphIndexRef = (0, react_1.useRef)(new Map());
|
|
78
|
+
const readyFiredRef = (0, react_1.useRef)(false);
|
|
79
|
+
// Set up camera on mount
|
|
80
|
+
(0, react_1.useEffect)(() => {
|
|
81
|
+
if (!(camera instanceof THREE.PerspectiveCamera))
|
|
82
|
+
return;
|
|
83
|
+
camera.fov = fov;
|
|
84
|
+
camera.position.set(...CAMERA_POSITION);
|
|
85
|
+
camera.lookAt(CAMERA_TARGET);
|
|
86
|
+
camera.updateProjectionMatrix();
|
|
87
|
+
}, [camera, fov]);
|
|
88
|
+
// Find the head/face mesh with morph targets after GLTF loads
|
|
89
|
+
(0, react_1.useEffect)(() => {
|
|
90
|
+
if (!gltf?.scene)
|
|
91
|
+
return;
|
|
92
|
+
let bestMesh = null;
|
|
93
|
+
let bestCount = 0;
|
|
94
|
+
gltf.scene.traverse((node) => {
|
|
95
|
+
if (!(node instanceof THREE.Mesh))
|
|
96
|
+
return;
|
|
97
|
+
const count = Object.keys(node.morphTargetDictionary ?? {}).length;
|
|
98
|
+
if (count > bestCount) {
|
|
99
|
+
bestCount = count;
|
|
100
|
+
bestMesh = node;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (!bestMesh) {
|
|
104
|
+
onError('No mesh with morph targets found in GLB');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
headMeshRef.current = bestMesh;
|
|
108
|
+
morphIndexRef.current = buildMorphIndex(bestMesh);
|
|
109
|
+
if (!readyFiredRef.current) {
|
|
110
|
+
readyFiredRef.current = true;
|
|
111
|
+
onReady();
|
|
112
|
+
}
|
|
113
|
+
}, [gltf, onReady, onError]);
|
|
114
|
+
// Per-frame morph weight application
|
|
115
|
+
(0, native_1.useFrame)((_, delta) => {
|
|
116
|
+
const mesh = headMeshRef.current;
|
|
117
|
+
if (!mesh?.morphTargetInfluences)
|
|
118
|
+
return;
|
|
119
|
+
const state = morphStateRef.current;
|
|
120
|
+
const alpha = Math.min(1, state.alpha * delta * 60); // normalize to 60fps
|
|
121
|
+
// Merge mood base + viseme target
|
|
122
|
+
const combined = { ...state.moodBase };
|
|
123
|
+
for (const [name, w] of Object.entries(state.visemeTarget)) {
|
|
124
|
+
combined[name] = Math.max(combined[name] ?? 0, w);
|
|
125
|
+
}
|
|
126
|
+
// Smooth current toward combined
|
|
127
|
+
const allNames = new Set([
|
|
128
|
+
...Object.keys(state.current),
|
|
129
|
+
...Object.keys(combined),
|
|
130
|
+
]);
|
|
131
|
+
for (const name of allNames) {
|
|
132
|
+
const target = combined[name] ?? 0;
|
|
133
|
+
const cur = state.current[name] ?? 0;
|
|
134
|
+
const next = cur + (target - cur) * alpha;
|
|
135
|
+
state.current[name] = next;
|
|
136
|
+
const idx = resolveIndex(morphIndexRef.current, [name]);
|
|
137
|
+
if (idx !== undefined) {
|
|
138
|
+
mesh.morphTargetInfluences[idx] = next;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Decay viseme layer
|
|
142
|
+
for (const name of Object.keys(state.visemeTarget)) {
|
|
143
|
+
state.visemeTarget[name] *= Math.pow(0.1, delta); // ~10x decay/s
|
|
144
|
+
if (state.visemeTarget[name] < 0.001) {
|
|
145
|
+
delete state.visemeTarget[name];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.6 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [1, 3, 2], intensity: 1.2, castShadow: false }), (0, jsx_runtime_1.jsx)("primitive", { object: gltf.scene })] }));
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Error boundary so GLTF load failures surface to onError
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
class GltfErrorBoundary extends react_1.default.Component {
|
|
155
|
+
constructor(props) {
|
|
156
|
+
super(props);
|
|
157
|
+
this.state = { hasError: false };
|
|
158
|
+
}
|
|
159
|
+
static getDerivedStateFromError() { return { hasError: true }; }
|
|
160
|
+
componentDidCatch(err) { this.props.onError(err.message); }
|
|
161
|
+
render() {
|
|
162
|
+
if (this.state.hasError)
|
|
163
|
+
return null;
|
|
164
|
+
return this.props.children;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Viseme schedule player
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
function applyVisemeCue(morphState, visemeKey) {
|
|
171
|
+
const aliases = morphTables_1.VISEME_MORPH_ALIASES[visemeKey];
|
|
172
|
+
if (!aliases)
|
|
173
|
+
return;
|
|
174
|
+
const w = morphTables_1.VISEME_WEIGHTS[visemeKey] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
|
|
175
|
+
for (const alias of aliases) {
|
|
176
|
+
morphState.visemeTarget[alias] = w;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Main exported component
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, mood = 'neutral', onReady, onError, }, ref) => {
|
|
183
|
+
// Resolve authenticated file URI (same logic as FilamentAvatar)
|
|
184
|
+
const remoteUrl = avatarUrl && (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://'))
|
|
185
|
+
? avatarUrl
|
|
186
|
+
: null;
|
|
187
|
+
const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(remoteUrl);
|
|
188
|
+
// Lock-in URI on first resolve — never change mid-session
|
|
189
|
+
const lockedUriRef = (0, react_1.useRef)(null);
|
|
190
|
+
const currentUri = fileResult?.uri ?? null;
|
|
191
|
+
if (lockedUriRef.current === null && currentUri) {
|
|
192
|
+
lockedUriRef.current = currentUri;
|
|
193
|
+
}
|
|
194
|
+
const localUri = lockedUriRef.current;
|
|
195
|
+
const fov = focalLength
|
|
196
|
+
? Math.round(2 * Math.atan(21.634 / focalLength) * (180 / Math.PI))
|
|
197
|
+
: CAMERA_FOV_FULL;
|
|
198
|
+
// Shared morph state — mutated directly in useFrame, never causes re-renders
|
|
199
|
+
const morphStateRef = (0, react_1.useRef)({
|
|
200
|
+
current: {},
|
|
201
|
+
visemeTarget: {},
|
|
202
|
+
moodBase: {},
|
|
203
|
+
alpha: 0.18,
|
|
204
|
+
});
|
|
205
|
+
// Update mood baseline when mood prop changes
|
|
206
|
+
(0, react_1.useEffect)(() => {
|
|
207
|
+
morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[mood] ?? {}) };
|
|
208
|
+
}, [mood]);
|
|
209
|
+
// Pending viseme schedule
|
|
210
|
+
const scheduleRef = (0, react_1.useRef)(null);
|
|
211
|
+
const scheduleTimersRef = (0, react_1.useRef)([]);
|
|
212
|
+
const [isReady, setIsReady] = (0, react_1.useState)(false);
|
|
213
|
+
const clearScheduleTimers = (0, react_1.useCallback)(() => {
|
|
214
|
+
for (const t of scheduleTimersRef.current)
|
|
215
|
+
clearTimeout(t);
|
|
216
|
+
scheduleTimersRef.current = [];
|
|
217
|
+
}, []);
|
|
218
|
+
const handleReady = (0, react_1.useCallback)(() => {
|
|
219
|
+
setIsReady(true);
|
|
220
|
+
onReady?.();
|
|
221
|
+
// Flush any pending schedule
|
|
222
|
+
if (scheduleRef.current) {
|
|
223
|
+
const s = scheduleRef.current;
|
|
224
|
+
scheduleRef.current = null;
|
|
225
|
+
applySchedule(s);
|
|
226
|
+
}
|
|
227
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
+
}, [onReady]);
|
|
229
|
+
const handleError = (0, react_1.useCallback)((msg) => {
|
|
230
|
+
console.warn('[WgpuAvatar] GLTF error:', msg);
|
|
231
|
+
onError?.(msg);
|
|
232
|
+
}, [onError]);
|
|
233
|
+
const applySchedule = (0, react_1.useCallback)((schedule) => {
|
|
234
|
+
clearScheduleTimers();
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const startedAt = schedule.startedAtMs ?? now;
|
|
237
|
+
const offset = now - startedAt;
|
|
238
|
+
for (const cue of schedule.cues) {
|
|
239
|
+
const delay = cue.startMs - offset;
|
|
240
|
+
if (delay < -200)
|
|
241
|
+
continue; // already expired
|
|
242
|
+
const rhubarbKey = cue.viseme;
|
|
243
|
+
const visemeKey = morphTables_1.RHUBARB_TO_VISEME[rhubarbKey] ?? rhubarbKey;
|
|
244
|
+
const t = setTimeout(() => {
|
|
245
|
+
applyVisemeCue(morphStateRef.current, visemeKey);
|
|
246
|
+
}, Math.max(0, delay));
|
|
247
|
+
scheduleTimersRef.current.push(t);
|
|
248
|
+
}
|
|
249
|
+
}, [clearScheduleTimers]);
|
|
250
|
+
// Cleanup on unmount
|
|
251
|
+
(0, react_1.useEffect)(() => () => clearScheduleTimers(), [clearScheduleTimers]);
|
|
252
|
+
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
253
|
+
setMood: (m) => {
|
|
254
|
+
morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[m] ?? {}) };
|
|
255
|
+
},
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
257
|
+
sendAmplitude: (_amplitude) => {
|
|
258
|
+
// Optional: could drive jaw open with amplitude
|
|
259
|
+
// morphStateRef.current.visemeTarget['jawOpen'] = _amplitude * 0.3;
|
|
260
|
+
},
|
|
261
|
+
sendViseme: (viseme, weight) => {
|
|
262
|
+
const aliases = morphTables_1.VISEME_MORPH_ALIASES[viseme];
|
|
263
|
+
if (!aliases)
|
|
264
|
+
return;
|
|
265
|
+
const w = weight ?? morphTables_1.VISEME_WEIGHTS[viseme] ?? morphTables_1.DEFAULT_VISEME_WEIGHT;
|
|
266
|
+
for (const alias of aliases) {
|
|
267
|
+
morphStateRef.current.visemeTarget[alias] = w;
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
clearVisemes: () => {
|
|
271
|
+
clearScheduleTimers();
|
|
272
|
+
morphStateRef.current.visemeTarget = {};
|
|
273
|
+
},
|
|
274
|
+
scheduleVisemes: (schedule) => {
|
|
275
|
+
if (!isReady) {
|
|
276
|
+
scheduleRef.current = schedule;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
applySchedule(schedule);
|
|
280
|
+
},
|
|
281
|
+
}), [isReady, applySchedule, clearScheduleTimers]);
|
|
282
|
+
if (!localUri) {
|
|
283
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.placeholder, style] });
|
|
284
|
+
}
|
|
285
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.container, style], children: (0, jsx_runtime_1.jsx)(native_1.Canvas, { style: react_native_1.StyleSheet.absoluteFill, camera: { fov, position: CAMERA_POSITION, near: 0.01, far: 100 }, gl: { antialias: true, alpha: false }, onCreated: ({ gl }) => {
|
|
286
|
+
gl.setClearColor(new THREE.Color('#1a1a2e'));
|
|
287
|
+
}, children: (0, jsx_runtime_1.jsx)(GltfErrorBoundary, { onError: handleError, children: (0, jsx_runtime_1.jsx)(AvatarScene, { uri: localUri, morphStateRef: morphStateRef, fov: fov, onReady: handleReady, onError: handleError }) }) }) }));
|
|
288
|
+
});
|
|
289
|
+
exports.WgpuAvatar.displayName = 'WgpuAvatar';
|
|
290
|
+
const styles = react_native_1.StyleSheet.create({
|
|
291
|
+
container: { overflow: 'hidden', backgroundColor: '#1a1a2e' },
|
|
292
|
+
placeholder: { backgroundColor: '#1a1a2e' },
|
|
293
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WgpuAvatar = void 0;
|
|
4
|
+
var WgpuAvatar_1 = require("./WgpuAvatar");
|
|
5
|
+
Object.defineProperty(exports, "WgpuAvatar", { enumerable: true, get: function () { return WgpuAvatar_1.WgpuAvatar; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talking-head-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
|
|
5
5
|
"main": "dist/index.web.js",
|
|
6
6
|
"browser": "dist/index.web.js",
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"react-native": "./dist/filament/editor/index.js",
|
|
47
47
|
"types": "./dist/filament/editor/index.d.ts",
|
|
48
48
|
"default": "./dist/filament/editor/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./wgpu": {
|
|
51
|
+
"react-native": "./dist/wgpu/index.js",
|
|
52
|
+
"types": "./dist/wgpu/index.d.ts",
|
|
53
|
+
"default": "./dist/wgpu/index.js"
|
|
49
54
|
}
|
|
50
55
|
},
|
|
51
56
|
"files": [
|
|
@@ -111,6 +116,7 @@
|
|
|
111
116
|
"react-native": ">=0.73",
|
|
112
117
|
"react-native-filament": ">=1",
|
|
113
118
|
"react-native-webview": ">=13",
|
|
119
|
+
"react-native-wgpu": ">=0.1",
|
|
114
120
|
"three": ">=0.170"
|
|
115
121
|
},
|
|
116
122
|
"peerDependenciesMeta": {
|
|
@@ -123,6 +129,9 @@
|
|
|
123
129
|
"react-native-filament": {
|
|
124
130
|
"optional": true
|
|
125
131
|
},
|
|
132
|
+
"react-native-wgpu": {
|
|
133
|
+
"optional": true
|
|
134
|
+
},
|
|
126
135
|
"expo": {
|
|
127
136
|
"optional": true
|
|
128
137
|
},
|
|
@@ -178,7 +187,7 @@
|
|
|
178
187
|
"react-native-gesture-handler": "^2.30.0",
|
|
179
188
|
"react-native-reanimated": "^4.2.3",
|
|
180
189
|
"react-native-webview": "^13.16.0",
|
|
181
|
-
"react-test-renderer": "^
|
|
190
|
+
"react-test-renderer": "^18.3.1",
|
|
182
191
|
"three": "^0.180.0",
|
|
183
192
|
"ts-jest": "^29.4.6",
|
|
184
193
|
"typescript": "^5.3.3",
|