@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-rsc/talking-head-react",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
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
@@ -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 (!animations || !animations[groupName]) {
255
+ if (!loadedAnimations || !loadedAnimations[groupName]) {
230
256
  return null;
231
257
  }
232
258
 
233
- const group = animations[groupName];
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
- }, [animations]);
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
+