@sage-rsc/talking-head-react 1.2.2 → 1.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.
- package/dist/index.cjs +2 -2
- package/dist/index.js +947 -890
- package/package.json +1 -1
- package/src/components/SimpleTalkingAvatar.jsx +116 -2
- package/src/utils/animationLoader.js +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
|
2
2
|
import { TalkingHead } from '../lib/talkinghead.mjs';
|
|
3
3
|
import { getActiveTTSConfig, ELEVENLABS_CONFIG, DEEPGRAM_CONFIG } from '../config/ttsConfig';
|
|
4
|
+
import { loadAnimationsFromManifest } from '../utils/animationLoader';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* SimpleTalkingAvatar - A simple React component for 3D talking avatars
|
|
@@ -27,7 +28,12 @@ import { getActiveTTSConfig, ELEVENLABS_CONFIG, DEEPGRAM_CONFIG } from '../confi
|
|
|
27
28
|
* @param {Function} props.onSpeechEnd - Callback when speech ends
|
|
28
29
|
* @param {string} props.className - Additional CSS classes
|
|
29
30
|
* @param {Object} props.style - Additional inline styles
|
|
30
|
-
* @param {Object} props.animations - Object mapping animation names to FBX file paths
|
|
31
|
+
* @param {Object} props.animations - Object mapping animation names to FBX file paths, or animation groups
|
|
32
|
+
* Can be: { "dance": "/animations/dance.fbx" } (single animation)
|
|
33
|
+
* Or: { "talking": ["/animations/talk1.fbx", "/animations/talk2.fbx"] } (group)
|
|
34
|
+
* Or: { "manifest": "/animations/manifest.json" } (load from manifest file)
|
|
35
|
+
* @param {string} props.autoAnimationGroup - Animation group to automatically play when speaking (e.g., "talking")
|
|
36
|
+
* @param {string} props.autoIdleGroup - Animation group to automatically play when idle (e.g., "idle")
|
|
31
37
|
* @param {boolean} props.autoSpeak - Whether to automatically speak the text prop when ready
|
|
32
38
|
* @param {Object} ref - Ref to access component methods
|
|
33
39
|
*/
|
|
@@ -51,6 +57,8 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
51
57
|
className = "",
|
|
52
58
|
style = {},
|
|
53
59
|
animations = {},
|
|
60
|
+
autoAnimationGroup = null, // e.g., "talking" - will randomly select from this group when speaking
|
|
61
|
+
autoIdleGroup = null, // e.g., "idle" - will randomly select from this group when idle
|
|
54
62
|
autoSpeak = false
|
|
55
63
|
}, ref) => {
|
|
56
64
|
const containerRef = useRef(null);
|
|
@@ -65,12 +73,34 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
65
73
|
const [error, setError] = useState(null);
|
|
66
74
|
const [isReady, setIsReady] = useState(false);
|
|
67
75
|
const [isPaused, setIsPaused] = useState(false);
|
|
76
|
+
const [loadedAnimations, setLoadedAnimations] = useState(animations);
|
|
77
|
+
const idleAnimationIntervalRef = useRef(null);
|
|
68
78
|
|
|
69
79
|
// Keep ref in sync with state
|
|
70
80
|
useEffect(() => {
|
|
71
81
|
isPausedRef.current = isPaused;
|
|
72
82
|
}, [isPaused]);
|
|
73
83
|
|
|
84
|
+
// Load animations from manifest if specified
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const loadManifestAnimations = async () => {
|
|
87
|
+
if (animations.manifest) {
|
|
88
|
+
try {
|
|
89
|
+
const manifestAnimations = await loadAnimationsFromManifest(animations.manifest);
|
|
90
|
+
setLoadedAnimations(manifestAnimations);
|
|
91
|
+
console.log('Loaded animations from manifest:', manifestAnimations);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Failed to load animation manifest:', error);
|
|
94
|
+
setLoadedAnimations(animations);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
setLoadedAnimations(animations);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
loadManifestAnimations();
|
|
102
|
+
}, [animations]);
|
|
103
|
+
|
|
74
104
|
// Update ref when prop changes
|
|
75
105
|
useEffect(() => {
|
|
76
106
|
showFullAvatarRef.current = showFullAvatar;
|
|
@@ -220,6 +250,44 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
220
250
|
}
|
|
221
251
|
}, []);
|
|
222
252
|
|
|
253
|
+
// Helper function to get random animation from a group
|
|
254
|
+
const getRandomAnimation = useCallback((groupName) => {
|
|
255
|
+
if (!loadedAnimations || !loadedAnimations[groupName]) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const group = loadedAnimations[groupName];
|
|
260
|
+
|
|
261
|
+
// If it's an array, randomly select one
|
|
262
|
+
if (Array.isArray(group) && group.length > 0) {
|
|
263
|
+
const randomIndex = Math.floor(Math.random() * group.length);
|
|
264
|
+
return group[randomIndex];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// If it's a string, return it directly
|
|
268
|
+
if (typeof group === 'string') {
|
|
269
|
+
return group;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return null;
|
|
273
|
+
}, [loadedAnimations]);
|
|
274
|
+
|
|
275
|
+
// Helper function to play random animation from a group
|
|
276
|
+
const playRandomAnimation = useCallback((groupName, disablePositionLock = false) => {
|
|
277
|
+
const animationPath = getRandomAnimation(groupName);
|
|
278
|
+
if (animationPath && talkingHeadRef.current) {
|
|
279
|
+
try {
|
|
280
|
+
talkingHeadRef.current.playAnimation(animationPath, null, 10, 0, 0.01, disablePositionLock);
|
|
281
|
+
console.log(`Playing random animation from "${groupName}" group:`, animationPath);
|
|
282
|
+
return animationPath;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.warn(`Failed to play random animation from "${groupName}" group:`, error);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}, [getRandomAnimation]);
|
|
290
|
+
|
|
223
291
|
// Speak text with proper callback handling
|
|
224
292
|
const speakText = useCallback(async (textToSpeak, options = {}) => {
|
|
225
293
|
if (!talkingHeadRef.current || !isReady) {
|
|
@@ -235,6 +303,13 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
235
303
|
// Always resume audio context first (required for user interaction)
|
|
236
304
|
await resumeAudioContext();
|
|
237
305
|
|
|
306
|
+
// Play random animation from autoAnimationGroup if specified
|
|
307
|
+
// Check both autoAnimationGroup prop and options.animationGroup
|
|
308
|
+
const animationGroup = options.animationGroup || autoAnimationGroup;
|
|
309
|
+
if (animationGroup && !options.skipAnimation) {
|
|
310
|
+
playRandomAnimation(animationGroup);
|
|
311
|
+
}
|
|
312
|
+
|
|
238
313
|
// Reset speech progress tracking
|
|
239
314
|
speechProgressRef.current = { remainingText: null, originalText: null, options: null };
|
|
240
315
|
originalSentencesRef.current = [];
|
|
@@ -279,7 +354,40 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
279
354
|
console.error('Error speaking text:', err);
|
|
280
355
|
setError(err.message || 'Failed to speak text');
|
|
281
356
|
}
|
|
282
|
-
}, [isReady, onSpeechEnd, resumeAudioContext]);
|
|
357
|
+
}, [isReady, onSpeechEnd, resumeAudioContext, autoAnimationGroup, playRandomAnimation]);
|
|
358
|
+
|
|
359
|
+
// Auto-play idle animations
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!isReady || !autoIdleGroup || !talkingHeadRef.current) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Clear any existing interval
|
|
366
|
+
if (idleAnimationIntervalRef.current) {
|
|
367
|
+
clearInterval(idleAnimationIntervalRef.current);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Play idle animation immediately
|
|
371
|
+
const playIdleAnimation = () => {
|
|
372
|
+
if (talkingHeadRef.current && !isPausedRef.current) {
|
|
373
|
+
playRandomAnimation(autoIdleGroup);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
playIdleAnimation();
|
|
378
|
+
|
|
379
|
+
// Set up interval to play idle animation every 10-15 seconds
|
|
380
|
+
idleAnimationIntervalRef.current = setInterval(() => {
|
|
381
|
+
playIdleAnimation();
|
|
382
|
+
}, 12000 + Math.random() * 3000); // Random interval between 12-15 seconds
|
|
383
|
+
|
|
384
|
+
return () => {
|
|
385
|
+
if (idleAnimationIntervalRef.current) {
|
|
386
|
+
clearInterval(idleAnimationIntervalRef.current);
|
|
387
|
+
idleAnimationIntervalRef.current = null;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}, [isReady, autoIdleGroup, playRandomAnimation]);
|
|
283
391
|
|
|
284
392
|
// Auto-speak text when ready and autoSpeak is true
|
|
285
393
|
useEffect(() => {
|
|
@@ -394,6 +502,12 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
394
502
|
talkingHeadRef.current.playAnimation(animationName, null, 10, 0, 0.01, disablePositionLock);
|
|
395
503
|
}
|
|
396
504
|
},
|
|
505
|
+
playRandomAnimation: (groupName, disablePositionLock = false) => {
|
|
506
|
+
return playRandomAnimation(groupName, disablePositionLock);
|
|
507
|
+
},
|
|
508
|
+
getRandomAnimation: (groupName) => {
|
|
509
|
+
return getRandomAnimation(groupName);
|
|
510
|
+
},
|
|
397
511
|
playReaction: (reactionType) => talkingHeadRef.current?.playReaction(reactionType),
|
|
398
512
|
playCelebration: () => talkingHeadRef.current?.playCelebration(),
|
|
399
513
|
setShowFullAvatar: (show) => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation Loader Utility
|
|
3
|
+
*
|
|
4
|
+
* Helps load animations from directories and create animation groups
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load animations from a manifest file
|
|
9
|
+
* @param {string} manifestPath - Path to manifest.json file
|
|
10
|
+
* @returns {Promise<Object>} Object with animation groups
|
|
11
|
+
*/
|
|
12
|
+
export async function loadAnimationsFromManifest(manifestPath) {
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(manifestPath);
|
|
15
|
+
const manifest = await response.json();
|
|
16
|
+
return manifest.animations || {};
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error('Failed to load animation manifest:', error);
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load animations using Vite's glob feature (only works in Vite)
|
|
25
|
+
* Note: This requires literal glob patterns at build time, not dynamic patterns
|
|
26
|
+
* Example usage in your code:
|
|
27
|
+
* const modules = import.meta.glob('/animations/talking/*.fbx');
|
|
28
|
+
* const animations = Object.keys(modules);
|
|
29
|
+
*/
|
|
30
|
+
export function processGlobAnimations(modules) {
|
|
31
|
+
const animations = {};
|
|
32
|
+
|
|
33
|
+
Object.keys(modules).forEach(path => {
|
|
34
|
+
// Extract filename without extension
|
|
35
|
+
const filename = path.split('/').pop().replace('.fbx', '');
|
|
36
|
+
animations[filename] = path;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return animations;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create animation groups from a directory listing
|
|
44
|
+
* @param {string} directoryPath - Path to animations directory
|
|
45
|
+
* @param {Object} groupMapping - Map of group names to subdirectories or patterns
|
|
46
|
+
* @returns {Promise<Object>} Object with animation groups
|
|
47
|
+
*
|
|
48
|
+
* Example:
|
|
49
|
+
* createAnimationGroups('/animations', {
|
|
50
|
+
* talking: '/animations/talking',
|
|
51
|
+
* idle: '/animations/idle'
|
|
52
|
+
* })
|
|
53
|
+
*/
|
|
54
|
+
export async function createAnimationGroups(directoryPath, groupMapping) {
|
|
55
|
+
const groups = {};
|
|
56
|
+
|
|
57
|
+
for (const [groupName, subPath] of Object.entries(groupMapping)) {
|
|
58
|
+
try {
|
|
59
|
+
// Try to fetch a directory listing (requires server support)
|
|
60
|
+
const listingPath = `${subPath}/.list.json`; // Server-generated file list
|
|
61
|
+
const response = await fetch(listingPath);
|
|
62
|
+
|
|
63
|
+
if (response.ok) {
|
|
64
|
+
const files = await response.json();
|
|
65
|
+
groups[groupName] = files
|
|
66
|
+
.filter(file => file.endsWith('.fbx'))
|
|
67
|
+
.map(file => `${subPath}/${file}`);
|
|
68
|
+
} else {
|
|
69
|
+
console.warn(`Could not load directory listing for ${subPath}`);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.warn(`Failed to load animations from ${subPath}:`, error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return groups;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get random animation from a group
|
|
81
|
+
* @param {Array|string} group - Animation group (array) or single animation (string)
|
|
82
|
+
* @returns {string|null} Random animation path or null
|
|
83
|
+
*/
|
|
84
|
+
export function getRandomAnimationFromGroup(group) {
|
|
85
|
+
if (Array.isArray(group) && group.length > 0) {
|
|
86
|
+
const randomIndex = Math.floor(Math.random() * group.length);
|
|
87
|
+
return group[randomIndex];
|
|
88
|
+
}
|
|
89
|
+
if (typeof group === 'string') {
|
|
90
|
+
return group;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|