@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-rsc/talking-head-react",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "A reusable React component for 3D talking avatars with lip-sync and text-to-speech",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -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
+