talking-head-studio 0.4.5 → 0.4.8
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/TalkingHead.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface TalkingHeadProps {
|
|
|
61
61
|
onReady?: () => void;
|
|
62
62
|
onError?: (message: string) => void;
|
|
63
63
|
onAvatarState?: (state: string) => void;
|
|
64
|
+
onVoiceMood?: (mood: TalkingHeadMood) => void;
|
|
64
65
|
style?: StyleProp<ViewStyle>;
|
|
65
66
|
/** Base URL for vendored assets. When set, replaces all cdn.jsdelivr.net references. */
|
|
66
67
|
vendorBaseUrl?: string | null;
|
package/dist/TalkingHead.js
CHANGED
|
@@ -6,7 +6,7 @@ const react_1 = require("react");
|
|
|
6
6
|
const react_native_1 = require("react-native");
|
|
7
7
|
const react_native_webview_1 = require("react-native-webview");
|
|
8
8
|
const html_1 = require("./html");
|
|
9
|
-
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, style, vendorBaseUrl, }, ref) => {
|
|
9
|
+
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, onVoiceMood, style, vendorBaseUrl, }, ref) => {
|
|
10
10
|
const webViewRef = (0, react_1.useRef)(null);
|
|
11
11
|
const readyRef = (0, react_1.useRef)(false);
|
|
12
12
|
const firstMessageSeenRef = (0, react_1.useRef)(false);
|
|
@@ -25,6 +25,8 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
25
25
|
onErrorRef.current = onError;
|
|
26
26
|
const onAvatarStateRef = (0, react_1.useRef)(onAvatarState);
|
|
27
27
|
onAvatarStateRef.current = onAvatarState;
|
|
28
|
+
const onVoiceMoodRef = (0, react_1.useRef)(onVoiceMood);
|
|
29
|
+
onVoiceMoodRef.current = onVoiceMood;
|
|
28
30
|
// The WebView HTML is built once from stable initial values.
|
|
29
31
|
// avatarUrl + authToken changing causes a controlled key-based remount.
|
|
30
32
|
// All other prop changes (mood, colors, accessories) go via postMessage.
|
|
@@ -226,6 +228,9 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
226
228
|
else if (msg.type === 'avatarState') {
|
|
227
229
|
onAvatarStateRef.current?.(msg.state);
|
|
228
230
|
}
|
|
231
|
+
else if (msg.type === 'voiceMood') {
|
|
232
|
+
onVoiceMoodRef.current?.(msg.mood);
|
|
233
|
+
}
|
|
229
234
|
else if (msg.type === 'log') {
|
|
230
235
|
console.log('[TalkingHead]', msg.message);
|
|
231
236
|
}
|
|
@@ -16,6 +16,7 @@ interface TalkingHeadVisualizationProps {
|
|
|
16
16
|
requestId: string | null;
|
|
17
17
|
appliedAtMs: number;
|
|
18
18
|
}) => void;
|
|
19
|
+
onVoiceMood?: (mood: TalkingHeadMood) => void;
|
|
19
20
|
vendorBaseUrl?: string | null;
|
|
20
21
|
}
|
|
21
22
|
export interface TalkingHeadVisualizationRef {
|
|
@@ -24,6 +25,8 @@ export interface TalkingHeadVisualizationRef {
|
|
|
24
25
|
sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
|
|
25
26
|
scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
|
|
26
27
|
clearVisemes: () => void;
|
|
28
|
+
/** Trigger a named motion on the avatar (e.g. 'celebrate', 'groove', 'wave') */
|
|
29
|
+
playMotion: (name: string) => void;
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
29
32
|
* TalkingHeadVisualization — optimized component for rendering the 3D avatar.
|
|
@@ -44,10 +44,12 @@ function getLoadingLabel(stage) {
|
|
|
44
44
|
* On native: uses WgpuAvatar (direct morph writes, no WebView bridge).
|
|
45
45
|
* On web: uses TalkingHead WebView renderer.
|
|
46
46
|
*/
|
|
47
|
-
exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, vendorBaseUrl }, ref) => {
|
|
47
|
+
exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, onVoiceMood, vendorBaseUrl }, ref) => {
|
|
48
48
|
const avatarRef = (0, react_1.useRef)(null);
|
|
49
|
-
// On native, WgpuAvatar ref is
|
|
50
|
-
//
|
|
49
|
+
// On native, WgpuAvatar ref is stored here so scheduleVisemes / sendAmplitude can route to it.
|
|
50
|
+
// useImperativeHandle is the sole owner of the forwarded ref on all platforms —
|
|
51
|
+
// do NOT manually forward ref inside the WgpuAvatar callback ref, as that bypasses
|
|
52
|
+
// the dedup key check in scheduleVisemes and causes double-fire.
|
|
51
53
|
const wgpuRef = (0, react_1.useRef)(null);
|
|
52
54
|
// Unified accessor — WgpuAvatar on native, WebView on web
|
|
53
55
|
const activeAvatar = (0, react_1.useCallback)(() => (wgpuRef.current ?? avatarRef.current), []);
|
|
@@ -109,6 +111,7 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
|
|
|
109
111
|
sendAmplitude: (a) => activeAvatar()?.sendAmplitude(a),
|
|
110
112
|
sendViseme: (viseme, weight) => activeAvatar()?.sendViseme(viseme, weight),
|
|
111
113
|
clearVisemes: () => activeAvatar()?.clearVisemes(),
|
|
114
|
+
playMotion: (name) => avatarRef.current?.dispatchMotion(name),
|
|
112
115
|
scheduleVisemes: (schedule) => {
|
|
113
116
|
const scheduleKey = `${schedule.requestId ?? 'anonymous'}:${schedule.startedAtMs ?? 0}`;
|
|
114
117
|
if (lastScheduledVisemeKeyRef.current === scheduleKey)
|
|
@@ -182,26 +185,15 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
|
|
|
182
185
|
lastScheduledVisemeKeyRef.current = scheduleKey;
|
|
183
186
|
}, [isAvatarReady, onVisemeScheduleApplied, visemeSchedule, activeAvatar]);
|
|
184
187
|
// On native use WgpuAvatar — direct morph writes, no WebView bridge.
|
|
188
|
+
// NOTE: Only store wgpuRef here — do NOT forward `ref` manually. useImperativeHandle
|
|
189
|
+
// above is the sole owner of the forwarded ref and handles the dedup key check.
|
|
185
190
|
if (react_native_1.Platform.OS !== 'web') {
|
|
186
|
-
return ((0, jsx_runtime_1.jsx)(WgpuAvatar_1.WgpuAvatar, { focalLength: focalLength, ref: (fr) => {
|
|
187
|
-
wgpuRef.current = fr;
|
|
188
|
-
if (typeof ref === 'function')
|
|
189
|
-
ref(fr);
|
|
190
|
-
else if (ref)
|
|
191
|
-
ref.current = fr;
|
|
192
|
-
if (fr && pendingVisemeScheduleRef.current) {
|
|
193
|
-
fr.scheduleVisemes(pendingVisemeScheduleRef.current);
|
|
194
|
-
pendingVisemeScheduleRef.current = null;
|
|
195
|
-
}
|
|
196
|
-
}, style: style, avatarUrl: avatarUrl ?? null, aspect: aspect, mood: mood, accessories: accessories, onReady: handleReady, onError: handleError }));
|
|
191
|
+
return ((0, jsx_runtime_1.jsx)(WgpuAvatar_1.WgpuAvatar, { focalLength: focalLength, ref: (fr) => { wgpuRef.current = fr; }, style: style, avatarUrl: avatarUrl ?? null, aspect: aspect, mood: mood, accessories: accessories, onReady: handleReady, onError: handleError }));
|
|
197
192
|
}
|
|
198
193
|
if (!effectiveAvatarUrl) {
|
|
199
194
|
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.placeholder] }));
|
|
200
195
|
}
|
|
201
|
-
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.
|
|
202
|
-
styles.progressFill,
|
|
203
|
-
{ width: `${Math.max(6, loadingState.progress)}%` },
|
|
204
|
-
] }) })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: "Preparing the avatar scene\u2026" }))] }) }))] }));
|
|
196
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, onVoiceMood: onVoiceMood, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.progressTrack, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: Math.max(6, loadingState.progress ?? 0), height: '100%', borderRadius: 999, backgroundColor: '#5eead4' } }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: 100 - Math.max(6, loadingState.progress ?? 0) } })] })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: "Preparing the avatar scene\u2026" }))] }) }))] }));
|
|
205
197
|
});
|
|
206
198
|
exports.TalkingHeadVisualization.displayName = 'TalkingHeadVisualization';
|
|
207
199
|
const styles = react_native_1.StyleSheet.create({
|
|
@@ -263,11 +255,7 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
263
255
|
backgroundColor: 'rgba(148, 163, 184, 0.18)',
|
|
264
256
|
marginTop: 12,
|
|
265
257
|
overflow: 'hidden',
|
|
266
|
-
|
|
267
|
-
progressFill: {
|
|
268
|
-
height: '100%',
|
|
269
|
-
borderRadius: 999,
|
|
270
|
-
backgroundColor: '#5eead4',
|
|
258
|
+
flexDirection: 'row',
|
|
271
259
|
},
|
|
272
260
|
placeholder: {
|
|
273
261
|
backgroundColor: 'rgba(30,34,53,0.5)',
|
package/dist/html.js
CHANGED
|
@@ -40,25 +40,26 @@ function buildAvatarHtml(config) {
|
|
|
40
40
|
}
|
|
41
41
|
</script>
|
|
42
42
|
<script>
|
|
43
|
-
window.ReactNativeWebView
|
|
43
|
+
function rnPost(d) { window.ReactNativeWebView && window.ReactNativeWebView.postMessage(d); }
|
|
44
|
+
rnPost(
|
|
44
45
|
JSON.stringify({ type: 'log', message: '[bootstrap] inline script start' })
|
|
45
46
|
);
|
|
46
47
|
function postBootstrapError(kind, message) {
|
|
47
|
-
|
|
48
|
+
rnPost(
|
|
48
49
|
JSON.stringify({ type: 'error', message: '[' + kind + '] ' + String(message || 'Unknown error') })
|
|
49
50
|
);
|
|
50
51
|
}
|
|
51
52
|
document.addEventListener('DOMContentLoaded', function() {
|
|
52
|
-
|
|
53
|
+
rnPost(
|
|
53
54
|
JSON.stringify({ type: 'log', message: '[bootstrap] DOMContentLoaded' })
|
|
54
55
|
);
|
|
55
56
|
});
|
|
56
57
|
window.addEventListener('error', function(event) {
|
|
57
|
-
postBootstrapError('window.error', event
|
|
58
|
+
postBootstrapError('window.error', (event && event.message) || (event && event.error && event.error.message) || 'Script error');
|
|
58
59
|
});
|
|
59
60
|
window.addEventListener('unhandledrejection', function(event) {
|
|
60
|
-
const reason = event
|
|
61
|
-
postBootstrapError('unhandledrejection', reason
|
|
61
|
+
const reason = event && event.reason;
|
|
62
|
+
postBootstrapError('unhandledrejection', (reason && reason.message) || reason || 'Unhandled promise rejection');
|
|
62
63
|
});
|
|
63
64
|
</script>
|
|
64
65
|
</head>
|
|
@@ -66,7 +67,7 @@ window.addEventListener('unhandledrejection', function(event) {
|
|
|
66
67
|
<div id="avatar"></div>
|
|
67
68
|
<script type="module">
|
|
68
69
|
(async function() {
|
|
69
|
-
|
|
70
|
+
rnPost(
|
|
70
71
|
JSON.stringify({ type: 'log', message: '[module] script start' })
|
|
71
72
|
);
|
|
72
73
|
const AUTH_TOKEN = ${JSON.stringify(config.authToken ?? null)};
|
|
@@ -94,11 +95,11 @@ let staticModel = null;
|
|
|
94
95
|
let initStarted = false;
|
|
95
96
|
|
|
96
97
|
function log(msg) {
|
|
97
|
-
|
|
98
|
+
rnPost(JSON.stringify({ type: 'log', message: msg }));
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function emitLoading(stage, progress = null) {
|
|
101
|
-
|
|
102
|
+
rnPost(
|
|
102
103
|
JSON.stringify({ type: 'loading', stage, progress }),
|
|
103
104
|
);
|
|
104
105
|
}
|
|
@@ -125,13 +126,13 @@ function applyColorOverrides() {
|
|
|
125
126
|
mats.forEach((mat) => {
|
|
126
127
|
const name = (mat.name || '').toLowerCase();
|
|
127
128
|
if (HAIR_COLOR && (name.includes('hair') || name.includes('fur'))) {
|
|
128
|
-
mat.color
|
|
129
|
+
mat.color && mat.color.set(HAIR_COLOR);
|
|
129
130
|
}
|
|
130
131
|
if (SKIN_COLOR && (name.includes('skin') || name.includes('body') || name.includes('face'))) {
|
|
131
|
-
mat.color
|
|
132
|
+
mat.color && mat.color.set(SKIN_COLOR);
|
|
132
133
|
}
|
|
133
134
|
if (EYE_COLOR && (name.includes('eye') || name.includes('iris'))) {
|
|
134
|
-
mat.color
|
|
135
|
+
mat.color && mat.color.set(EYE_COLOR);
|
|
135
136
|
}
|
|
136
137
|
});
|
|
137
138
|
});
|
|
@@ -204,7 +205,7 @@ async function loadStaticFallback(loadedAvatarUrl) {
|
|
|
204
205
|
applyAccessories(pendingAccessoriesList);
|
|
205
206
|
motionInitFromRoot(staticModel);
|
|
206
207
|
emitLoading('ready', 100);
|
|
207
|
-
|
|
208
|
+
rnPost(JSON.stringify({ type: 'ready' }));
|
|
208
209
|
|
|
209
210
|
window.addEventListener('resize', () => {
|
|
210
211
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
@@ -228,11 +229,11 @@ async function loadStaticFallback(loadedAvatarUrl) {
|
|
|
228
229
|
}
|
|
229
230
|
}, (err) => {
|
|
230
231
|
log('Fallback Error: ' + err.message);
|
|
231
|
-
|
|
232
|
+
rnPost(JSON.stringify({ type: 'error', message: err.message }));
|
|
232
233
|
});
|
|
233
234
|
} catch (err) {
|
|
234
235
|
log('Fallback setup error: ' + err.message);
|
|
235
|
-
|
|
236
|
+
rnPost(JSON.stringify({ type: 'error', message: err.message }));
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
239
|
|
|
@@ -299,7 +300,7 @@ async function init() {
|
|
|
299
300
|
jawMorphCache = null;
|
|
300
301
|
visemeMorphCache = null;
|
|
301
302
|
rhubarbMorphCache = null;
|
|
302
|
-
head.armature
|
|
303
|
+
head.armature && head.armature.traverse(function(child) {
|
|
303
304
|
if (child.isMesh && child.morphTargetDictionary && child.morphTargetInfluences) {
|
|
304
305
|
mouthMeshes.push(child);
|
|
305
306
|
}
|
|
@@ -338,17 +339,17 @@ async function init() {
|
|
|
338
339
|
applyAccessories(pendingAccessoriesList);
|
|
339
340
|
if (head && head.armature) motionInitFromRoot(head.armature);
|
|
340
341
|
emitLoading('ready', 100);
|
|
341
|
-
|
|
342
|
+
rnPost(JSON.stringify({ type: 'ready' }));
|
|
342
343
|
}
|
|
343
344
|
} catch (err) {
|
|
344
345
|
initStarted = false; // allow retry on error
|
|
345
346
|
log('Error: ' + err.message);
|
|
346
|
-
|
|
347
|
+
rnPost(JSON.stringify({ type: 'error', message: err.message }));
|
|
347
348
|
}
|
|
348
349
|
}
|
|
349
350
|
|
|
350
351
|
function startAudioInterception() {
|
|
351
|
-
if (!head
|
|
352
|
+
if (!head || !head.audioCtx || !head.audioSpeechGainNode) return;
|
|
352
353
|
const audioCtx = head.audioCtx;
|
|
353
354
|
const gainNode = head.audioSpeechGainNode;
|
|
354
355
|
const connected = new WeakSet();
|
|
@@ -384,6 +385,33 @@ function startAudioInterception() {
|
|
|
384
385
|
let amplitudeDecay = 0;
|
|
385
386
|
let jawMorphCache = null;
|
|
386
387
|
let visemeMorphCache = null;
|
|
388
|
+
|
|
389
|
+
// Voice-driven mood detection
|
|
390
|
+
let voiceEnergySmoothed = 0;
|
|
391
|
+
let voiceMoodCurrent = 'neutral';
|
|
392
|
+
let voiceMoodFramesAbove = 0;
|
|
393
|
+
let voiceMoodFramesBelow = 0;
|
|
394
|
+
const VOICE_ENERGY_EXCITED_THRESH = 0.45;
|
|
395
|
+
const VOICE_ENERGY_NEUTRAL_THRESH = 0.15;
|
|
396
|
+
const VOICE_MOOD_FRAMES_REQUIRED = 12; // ~0.2s at 60fps
|
|
397
|
+
function updateVoiceMood(rawAmplitude) {
|
|
398
|
+
voiceEnergySmoothed = voiceEnergySmoothed * 0.85 + rawAmplitude * 0.15;
|
|
399
|
+
if (voiceEnergySmoothed > VOICE_ENERGY_EXCITED_THRESH) {
|
|
400
|
+
voiceMoodFramesAbove++;
|
|
401
|
+
voiceMoodFramesBelow = 0;
|
|
402
|
+
if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
|
|
403
|
+
voiceMoodCurrent = 'excited';
|
|
404
|
+
rnPost(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
|
|
405
|
+
}
|
|
406
|
+
} else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
|
|
407
|
+
voiceMoodFramesBelow++;
|
|
408
|
+
voiceMoodFramesAbove = 0;
|
|
409
|
+
if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
|
|
410
|
+
voiceMoodCurrent = 'neutral';
|
|
411
|
+
rnPost(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
387
415
|
const visemeState = {};
|
|
388
416
|
|
|
389
417
|
const VISEME_MORPH_ALIASES = {
|
|
@@ -551,7 +579,7 @@ function clearScheduledVisemes() {
|
|
|
551
579
|
for (const key of Object.keys(visemeState)) visemeState[key] = 0;
|
|
552
580
|
}
|
|
553
581
|
|
|
554
|
-
function tickVisemeDecay(deltaSeconds
|
|
582
|
+
function tickVisemeDecay(deltaSeconds) {
|
|
555
583
|
if (!visemeMorphCache) return;
|
|
556
584
|
|
|
557
585
|
const isScheduled = Date.now() < visemeModeUntil;
|
|
@@ -587,7 +615,7 @@ function tickVisemeDecay(deltaSeconds?: number) {
|
|
|
587
615
|
// Use realtime (not newvalue) — newvalue is consumed and cleared after
|
|
588
616
|
// a single frame, so scheduled visemes would vanish immediately.
|
|
589
617
|
// realtime persists until explicitly set to null.
|
|
590
|
-
if (head
|
|
618
|
+
if (head && head.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
|
|
591
619
|
const mt = head.mtAvatar[e.morphName];
|
|
592
620
|
mt.realtime = targetWeight > 0 ? targetWeight : null;
|
|
593
621
|
mt.needsUpdate = true;
|
|
@@ -771,7 +799,7 @@ async function applyAccessories(accessoriesList) {
|
|
|
771
799
|
if (loadedUrl.startsWith('blob:')) URL.revokeObjectURL(loadedUrl);
|
|
772
800
|
log('[ACC] GLB loaded OK for ' + accData.id);
|
|
773
801
|
const model = gltf.scene;
|
|
774
|
-
const latestData = currentAccessories[accData.id]
|
|
802
|
+
const latestData = (currentAccessories[accData.id] && currentAccessories[accData.id].latestData) || accData;
|
|
775
803
|
let targetBone = null;
|
|
776
804
|
let prefixCandidate = null;
|
|
777
805
|
root.traverse((child) => {
|
|
@@ -1109,6 +1137,7 @@ function onIncomingMessage(event) {
|
|
|
1109
1137
|
msg.value * RHUBARB_FALLBACK_AMPLITUDE_GAIN,
|
|
1110
1138
|
);
|
|
1111
1139
|
amplitudeDecay = Math.max(amplitudeDecay * 0.7, val);
|
|
1140
|
+
updateVoiceMood(msg.value);
|
|
1112
1141
|
for (let i = 0; i < jawMorphCache.length; i++) jawMorphCache[i].influences[jawMorphCache[i].idx] = amplitudeDecay;
|
|
1113
1142
|
} else if (msg.type === 'viseme') {
|
|
1114
1143
|
applyViseme(msg.viseme, msg.weight !== undefined ? msg.weight : 1.0);
|
|
@@ -1130,7 +1159,7 @@ function onIncomingMessage(event) {
|
|
|
1130
1159
|
if (typeof window.playMotion === 'function') {
|
|
1131
1160
|
window.playMotion(msg.name);
|
|
1132
1161
|
}
|
|
1133
|
-
|
|
1162
|
+
rnPost(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
|
|
1134
1163
|
log('motion dispatched: ' + msg.name);
|
|
1135
1164
|
}
|
|
1136
1165
|
} catch (err) {
|
package/dist/wgpu/WgpuAvatar.js
CHANGED
|
@@ -232,7 +232,7 @@ exports.WgpuAvatar = (0, react_1.forwardRef)(({ style, avatarUrl, focalLength, m
|
|
|
232
232
|
(0, react_1.useEffect)(() => () => clearScheduleTimers(), [clearScheduleTimers]);
|
|
233
233
|
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
234
234
|
setMood: (m) => { morphStateRef.current.moodBase = { ...(morphTables_1.MOOD_MORPHS[m] ?? {}) }; },
|
|
235
|
-
sendAmplitude: (
|
|
235
|
+
sendAmplitude: () => { },
|
|
236
236
|
sendViseme: (viseme, weight) => {
|
|
237
237
|
const aliases = morphTables_1.VISEME_MORPH_ALIASES[viseme];
|
|
238
238
|
if (!aliases)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talking-head-studio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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",
|