@sage-rsc/talking-head-react 1.2.3 → 1.3.1
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 +855 -822
- package/package.json +1 -1
- package/src/components/SimpleTalkingAvatar.jsx +62 -3
- 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
|
|
@@ -30,7 +31,9 @@ import { getActiveTTSConfig, ELEVENLABS_CONFIG, DEEPGRAM_CONFIG } from '../confi
|
|
|
30
31
|
* @param {Object} props.animations - Object mapping animation names to FBX file paths, or animation groups
|
|
31
32
|
* Can be: { "dance": "/animations/dance.fbx" } (single animation)
|
|
32
33
|
* Or: { "talking": ["/animations/talk1.fbx", "/animations/talk2.fbx"] } (group)
|
|
34
|
+
* Or: { "manifest": "/animations/manifest.json" } (load from manifest file)
|
|
33
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")
|
|
34
37
|
* @param {boolean} props.autoSpeak - Whether to automatically speak the text prop when ready
|
|
35
38
|
* @param {Object} ref - Ref to access component methods
|
|
36
39
|
*/
|
|
@@ -55,6 +58,7 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
55
58
|
style = {},
|
|
56
59
|
animations = {},
|
|
57
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
|
|
58
62
|
autoSpeak = false
|
|
59
63
|
}, ref) => {
|
|
60
64
|
const containerRef = useRef(null);
|
|
@@ -69,12 +73,34 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
69
73
|
const [error, setError] = useState(null);
|
|
70
74
|
const [isReady, setIsReady] = useState(false);
|
|
71
75
|
const [isPaused, setIsPaused] = useState(false);
|
|
76
|
+
const [loadedAnimations, setLoadedAnimations] = useState(animations);
|
|
77
|
+
const idleAnimationIntervalRef = useRef(null);
|
|
72
78
|
|
|
73
79
|
// Keep ref in sync with state
|
|
74
80
|
useEffect(() => {
|
|
75
81
|
isPausedRef.current = isPaused;
|
|
76
82
|
}, [isPaused]);
|
|
77
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
|
+
|
|
78
104
|
// Update ref when prop changes
|
|
79
105
|
useEffect(() => {
|
|
80
106
|
showFullAvatarRef.current = showFullAvatar;
|
|
@@ -226,11 +252,11 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
226
252
|
|
|
227
253
|
// Helper function to get random animation from a group
|
|
228
254
|
const getRandomAnimation = useCallback((groupName) => {
|
|
229
|
-
if (!
|
|
255
|
+
if (!loadedAnimations || !loadedAnimations[groupName]) {
|
|
230
256
|
return null;
|
|
231
257
|
}
|
|
232
258
|
|
|
233
|
-
const group =
|
|
259
|
+
const group = loadedAnimations[groupName];
|
|
234
260
|
|
|
235
261
|
// If it's an array, randomly select one
|
|
236
262
|
if (Array.isArray(group) && group.length > 0) {
|
|
@@ -244,7 +270,7 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
244
270
|
}
|
|
245
271
|
|
|
246
272
|
return null;
|
|
247
|
-
}, [
|
|
273
|
+
}, [loadedAnimations]);
|
|
248
274
|
|
|
249
275
|
// Helper function to play random animation from a group
|
|
250
276
|
const playRandomAnimation = useCallback((groupName, disablePositionLock = false) => {
|
|
@@ -330,6 +356,39 @@ const SimpleTalkingAvatar = forwardRef(({
|
|
|
330
356
|
}
|
|
331
357
|
}, [isReady, onSpeechEnd, resumeAudioContext, autoAnimationGroup, playRandomAnimation]);
|
|
332
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]);
|
|
391
|
+
|
|
333
392
|
// Auto-speak text when ready and autoSpeak is true
|
|
334
393
|
useEffect(() => {
|
|
335
394
|
if (isReady && text && autoSpeak && talkingHeadRef.current) {
|
|
@@ -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
|
+
|