@sage-rsc/talking-head-react 1.5.0 → 1.5.5

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.5.0",
3
+ "version": "1.5.5",
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",
@@ -0,0 +1,419 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { loadAnimationsFromManifest } from '../utils/animationLoader';
3
+
4
+ /**
5
+ * AnimationSelector Component
6
+ *
7
+ * Allows users to:
8
+ * 1. Load all animations from manifest
9
+ * 2. Display them as clickable buttons
10
+ * 3. Select animations to keep
11
+ * 4. Delete unselected animations
12
+ *
13
+ * @param {Object} props
14
+ * @param {string} props.manifestPath - Path to manifest.json file
15
+ * @param {string} props.avatarBody - Avatar body type ('M' or 'F') for gender-specific filtering
16
+ * @param {Function} props.onAnimationPlay - Callback when animation button is clicked (receives animation path)
17
+ * @param {Function} props.onAnimationsSelected - Callback when animations are selected (receives array of selected paths)
18
+ * @param {Function} props.onDeleteAnimations - Callback when delete is clicked (receives array of paths to delete)
19
+ * @param {Object} props.style - Additional styles
20
+ */
21
+ export function AnimationSelector({
22
+ manifestPath = "/animations/manifest.json",
23
+ avatarBody = "F",
24
+ onAnimationPlay = null,
25
+ onAnimationsSelected = null,
26
+ onDeleteAnimations = null,
27
+ style = {}
28
+ }) {
29
+ const [animations, setAnimations] = useState([]);
30
+ const [selectedAnimations, setSelectedAnimations] = useState(new Set());
31
+ const [isLoading, setIsLoading] = useState(true);
32
+ const [error, setError] = useState(null);
33
+ const [filterGroup, setFilterGroup] = useState('all'); // Filter by group
34
+ const [searchTerm, setSearchTerm] = useState('');
35
+
36
+ // Load animations from manifest
37
+ useEffect(() => {
38
+ const loadAnimations = async () => {
39
+ setIsLoading(true);
40
+ setError(null);
41
+
42
+ try {
43
+ const manifestData = await loadAnimationsFromManifest(manifestPath);
44
+
45
+ // Flatten animations into a list with metadata
46
+ const flatAnimations = [];
47
+
48
+ // Handle gender-specific animations
49
+ if (manifestData._genderSpecific) {
50
+ const avatarGender = avatarBody?.toUpperCase() || 'F';
51
+ const genderKey = avatarGender === 'M' ? 'male' : 'female';
52
+
53
+ // Add gender-specific animations
54
+ if (manifestData._genderSpecific[genderKey]) {
55
+ Object.entries(manifestData._genderSpecific[genderKey]).forEach(([groupName, anims]) => {
56
+ const animArray = Array.isArray(anims) ? anims : [anims];
57
+ animArray.forEach(animPath => {
58
+ flatAnimations.push({
59
+ path: animPath,
60
+ group: groupName,
61
+ gender: genderKey,
62
+ name: animPath.split('/').pop().replace('.fbx', '')
63
+ });
64
+ });
65
+ });
66
+ }
67
+
68
+ // Add shared animations
69
+ if (manifestData._genderSpecific.shared) {
70
+ Object.entries(manifestData._genderSpecific.shared).forEach(([groupName, anims]) => {
71
+ const animArray = Array.isArray(anims) ? anims : [anims];
72
+ animArray.forEach(animPath => {
73
+ flatAnimations.push({
74
+ path: animPath,
75
+ group: groupName,
76
+ gender: 'shared',
77
+ name: animPath.split('/').pop().replace('.fbx', '')
78
+ });
79
+ });
80
+ });
81
+ }
82
+ }
83
+
84
+ // Add root-level animations
85
+ Object.entries(manifestData).forEach(([key, value]) => {
86
+ if (key !== '_genderSpecific') {
87
+ const animArray = Array.isArray(value) ? value : [value];
88
+ animArray.forEach(animPath => {
89
+ if (typeof animPath === 'string') {
90
+ flatAnimations.push({
91
+ path: animPath,
92
+ group: key,
93
+ gender: 'root',
94
+ name: animPath.split('/').pop().replace('.fbx', '')
95
+ });
96
+ }
97
+ });
98
+ }
99
+ });
100
+
101
+ setAnimations(flatAnimations);
102
+ setIsLoading(false);
103
+ } catch (err) {
104
+ console.error('Failed to load animations:', err);
105
+ setError(err.message);
106
+ setIsLoading(false);
107
+ }
108
+ };
109
+
110
+ loadAnimations();
111
+ }, [manifestPath, avatarBody]);
112
+
113
+ // Get unique groups for filtering
114
+ const groups = ['all', ...new Set(animations.map(a => a.group))];
115
+
116
+ // Filter animations
117
+ const filteredAnimations = animations.filter(anim => {
118
+ const matchesGroup = filterGroup === 'all' || anim.group === filterGroup;
119
+ const matchesSearch = searchTerm === '' ||
120
+ anim.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
121
+ anim.path.toLowerCase().includes(searchTerm.toLowerCase());
122
+ return matchesGroup && matchesSearch;
123
+ });
124
+
125
+ // Toggle selection
126
+ const toggleSelection = (path) => {
127
+ const newSelected = new Set(selectedAnimations);
128
+ if (newSelected.has(path)) {
129
+ newSelected.delete(path);
130
+ } else {
131
+ newSelected.add(path);
132
+ }
133
+ setSelectedAnimations(newSelected);
134
+
135
+ if (onAnimationsSelected) {
136
+ onAnimationsSelected(Array.from(newSelected));
137
+ }
138
+ };
139
+
140
+ // Select all visible
141
+ const selectAll = () => {
142
+ const newSelected = new Set(selectedAnimations);
143
+ filteredAnimations.forEach(anim => {
144
+ newSelected.add(anim.path);
145
+ });
146
+ setSelectedAnimations(newSelected);
147
+
148
+ if (onAnimationsSelected) {
149
+ onAnimationsSelected(Array.from(newSelected));
150
+ }
151
+ };
152
+
153
+ // Deselect all visible
154
+ const deselectAll = () => {
155
+ const newSelected = new Set(selectedAnimations);
156
+ filteredAnimations.forEach(anim => {
157
+ newSelected.delete(anim.path);
158
+ });
159
+ setSelectedAnimations(newSelected);
160
+
161
+ if (onAnimationsSelected) {
162
+ onAnimationsSelected(Array.from(newSelected));
163
+ }
164
+ };
165
+
166
+ // Handle delete
167
+ const handleDelete = () => {
168
+ const toDelete = animations.filter(anim => !selectedAnimations.has(anim.path));
169
+ const deletePaths = toDelete.map(anim => anim.path);
170
+
171
+ if (deletePaths.length === 0) {
172
+ alert('No animations to delete. Select animations to keep, then delete will remove the unselected ones.');
173
+ return;
174
+ }
175
+
176
+ if (window.confirm(`Are you sure you want to delete ${deletePaths.length} animation(s)?\n\nThis will delete:\n${deletePaths.slice(0, 5).join('\n')}${deletePaths.length > 5 ? '\n...' : ''}`)) {
177
+ if (onDeleteAnimations) {
178
+ onDeleteAnimations(deletePaths);
179
+ }
180
+
181
+ // Remove deleted animations from state
182
+ setAnimations(animations.filter(anim => selectedAnimations.has(anim.path)));
183
+ setSelectedAnimations(new Set());
184
+ }
185
+ };
186
+
187
+ // Handle play animation
188
+ const handlePlay = (path) => {
189
+ if (onAnimationPlay) {
190
+ onAnimationPlay(path);
191
+ }
192
+ };
193
+
194
+ if (isLoading) {
195
+ return (
196
+ <div style={{ padding: '20px', textAlign: 'center', ...style }}>
197
+ <p>Loading animations...</p>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ if (error) {
203
+ return (
204
+ <div style={{ padding: '20px', color: 'red', ...style }}>
205
+ <p>Error loading animations: {error}</p>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ return (
211
+ <div style={{
212
+ padding: '20px',
213
+ backgroundColor: '#2a2a2a',
214
+ borderRadius: '8px',
215
+ color: '#fff',
216
+ ...style
217
+ }}>
218
+ <h2 style={{ marginTop: 0 }}>Animation Selector</h2>
219
+ <p style={{ color: '#aaa', fontSize: '14px' }}>
220
+ Click buttons to play animations. Select animations to keep, then delete will remove the rest.
221
+ </p>
222
+
223
+ {/* Controls */}
224
+ <div style={{
225
+ display: 'flex',
226
+ gap: '10px',
227
+ marginBottom: '20px',
228
+ flexWrap: 'wrap',
229
+ alignItems: 'center'
230
+ }}>
231
+ {/* Search */}
232
+ <input
233
+ type="text"
234
+ placeholder="Search animations..."
235
+ value={searchTerm}
236
+ onChange={(e) => setSearchTerm(e.target.value)}
237
+ style={{
238
+ padding: '8px 12px',
239
+ borderRadius: '6px',
240
+ border: '1px solid #555',
241
+ backgroundColor: '#333',
242
+ color: '#fff',
243
+ fontSize: '14px',
244
+ flex: '1',
245
+ minWidth: '200px'
246
+ }}
247
+ />
248
+
249
+ {/* Group Filter */}
250
+ <select
251
+ value={filterGroup}
252
+ onChange={(e) => setFilterGroup(e.target.value)}
253
+ style={{
254
+ padding: '8px 12px',
255
+ borderRadius: '6px',
256
+ border: '1px solid #555',
257
+ backgroundColor: '#333',
258
+ color: '#fff',
259
+ fontSize: '14px'
260
+ }}
261
+ >
262
+ {groups.map(group => (
263
+ <option key={group} value={group}>
264
+ {group === 'all' ? 'All Groups' : group}
265
+ </option>
266
+ ))}
267
+ </select>
268
+
269
+ {/* Selection Controls */}
270
+ <button
271
+ onClick={selectAll}
272
+ style={{
273
+ padding: '8px 16px',
274
+ backgroundColor: '#4CAF50',
275
+ color: 'white',
276
+ border: 'none',
277
+ borderRadius: '6px',
278
+ cursor: 'pointer',
279
+ fontSize: '14px'
280
+ }}
281
+ >
282
+ Select All Visible
283
+ </button>
284
+
285
+ <button
286
+ onClick={deselectAll}
287
+ style={{
288
+ padding: '8px 16px',
289
+ backgroundColor: '#666',
290
+ color: 'white',
291
+ border: 'none',
292
+ borderRadius: '6px',
293
+ cursor: 'pointer',
294
+ fontSize: '14px'
295
+ }}
296
+ >
297
+ Deselect All Visible
298
+ </button>
299
+
300
+ {/* Delete Button */}
301
+ <button
302
+ onClick={handleDelete}
303
+ style={{
304
+ padding: '8px 16px',
305
+ backgroundColor: '#f44336',
306
+ color: 'white',
307
+ border: 'none',
308
+ borderRadius: '6px',
309
+ cursor: 'pointer',
310
+ fontSize: '14px',
311
+ fontWeight: 'bold'
312
+ }}
313
+ >
314
+ Delete Unselected ({animations.length - selectedAnimations.size})
315
+ </button>
316
+ </div>
317
+
318
+ {/* Stats */}
319
+ <div style={{
320
+ marginBottom: '15px',
321
+ fontSize: '14px',
322
+ color: '#aaa'
323
+ }}>
324
+ Total: {animations.length} |
325
+ Selected: {selectedAnimations.size} |
326
+ Showing: {filteredAnimations.length}
327
+ </div>
328
+
329
+ {/* Animation Grid */}
330
+ <div style={{
331
+ display: 'grid',
332
+ gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
333
+ gap: '10px',
334
+ maxHeight: '500px',
335
+ overflowY: 'auto',
336
+ padding: '10px',
337
+ backgroundColor: '#1a1a1a',
338
+ borderRadius: '6px'
339
+ }}>
340
+ {filteredAnimations.map((anim, index) => {
341
+ const isSelected = selectedAnimations.has(anim.path);
342
+
343
+ return (
344
+ <div
345
+ key={`${anim.path}-${index}`}
346
+ style={{
347
+ border: `2px solid ${isSelected ? '#4CAF50' : '#555'}`,
348
+ borderRadius: '6px',
349
+ padding: '10px',
350
+ backgroundColor: isSelected ? '#2a4a2a' : '#222',
351
+ cursor: 'pointer',
352
+ transition: 'all 0.2s'
353
+ }}
354
+ onClick={() => toggleSelection(anim.path)}
355
+ >
356
+ {/* Checkbox */}
357
+ <div style={{ marginBottom: '8px' }}>
358
+ <input
359
+ type="checkbox"
360
+ checked={isSelected}
361
+ onChange={() => toggleSelection(anim.path)}
362
+ onClick={(e) => e.stopPropagation()}
363
+ style={{
364
+ marginRight: '8px',
365
+ cursor: 'pointer'
366
+ }}
367
+ />
368
+ <span style={{ fontSize: '12px', color: '#aaa' }}>
369
+ {anim.group} {anim.gender !== 'root' && `(${anim.gender})`}
370
+ </span>
371
+ </div>
372
+
373
+ {/* Animation Name */}
374
+ <div style={{
375
+ fontSize: '13px',
376
+ fontWeight: 'bold',
377
+ marginBottom: '8px',
378
+ wordBreak: 'break-word'
379
+ }}>
380
+ {anim.name}
381
+ </div>
382
+
383
+ {/* Play Button */}
384
+ <button
385
+ onClick={(e) => {
386
+ e.stopPropagation();
387
+ handlePlay(anim.path);
388
+ }}
389
+ style={{
390
+ width: '100%',
391
+ padding: '6px',
392
+ backgroundColor: '#2196F3',
393
+ color: 'white',
394
+ border: 'none',
395
+ borderRadius: '4px',
396
+ cursor: 'pointer',
397
+ fontSize: '12px'
398
+ }}
399
+ >
400
+ ▶ Play
401
+ </button>
402
+ </div>
403
+ );
404
+ })}
405
+ </div>
406
+
407
+ {filteredAnimations.length === 0 && (
408
+ <div style={{
409
+ padding: '40px',
410
+ textAlign: 'center',
411
+ color: '#aaa'
412
+ }}>
413
+ No animations found matching your filters.
414
+ </div>
415
+ )}
416
+ </div>
417
+ );
418
+ }
419
+
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export { default as TalkingHeadAvatar } from './components/TalkingHeadAvatar';
8
8
  export { default as TalkingHeadComponent } from './components/TalkingHeadComponent';
9
9
  export { default as SimpleTalkingAvatar } from './components/SimpleTalkingAvatar';
10
10
  export { default as CurriculumLearning } from './components/CurriculumLearning';
11
+ export { AnimationSelector } from './components/AnimationSelector';
11
12
  export { getActiveTTSConfig, getVoiceOptions } from './config/ttsConfig';
12
13
  export { animations, getAnimation, hasAnimation } from './config/animations';
13
14
 
@@ -1667,32 +1667,28 @@ class TalkingHead {
1667
1667
  const tempEuler = new THREE.Euler();
1668
1668
  const tempQuaternion = new THREE.Quaternion();
1669
1669
 
1670
- // Check if FBX animation is playing
1671
- const isFBXPlaying = this.mixer && this.currentFBXAction && this.currentFBXAction.isRunning();
1672
-
1673
- // Instead of forcing to a fixed value, reduce X rotation proportionally
1674
- // This maintains the relative relationship between shoulders and arms
1675
- // Natural relaxed shoulders: X rotation typically 0.3-0.6 radians
1676
- // High shoulders: X rotation > 0.8 radians
1677
- // We reduce high rotations by 40-50% to bring them down while maintaining arm relationships
1678
- const reductionFactor = isFBXPlaying ? 0.5 : 0.6; // Reduce by 50% during FBX, 40% otherwise
1679
- const maxAllowedX = 0.7; // Maximum allowed X rotation
1670
+ // Research-based relaxed shoulder rotation values
1671
+ // Natural relaxed shoulders: X rotation ~0.5-0.7 radians (much lower for natural look)
1672
+ // High/stiff shoulders: X rotation ~1.5-1.8 radians
1673
+ // We aggressively clamp to a very relaxed position
1674
+ const targetX = 0.6; // Target X rotation for relaxed, natural shoulders (radians) - lowered significantly
1675
+ const maxX = 0.7; // Maximum allowed X rotation - lowered significantly
1680
1676
 
1681
1677
  // Adjust left shoulder bone directly
1682
1678
  if (leftShoulderBone.quaternion) {
1683
1679
  tempEuler.setFromQuaternion(leftShoulderBone.quaternion, 'XYZ');
1684
1680
  const originalX = tempEuler.x;
1685
1681
 
1686
- // Reduce X rotation proportionally if it's too high
1687
- if (tempEuler.x > maxAllowedX) {
1688
- // Reduce by percentage to maintain arm-shoulder relationship
1689
- tempEuler.x = maxAllowedX + (tempEuler.x - maxAllowedX) * (1 - reductionFactor);
1690
- } else if (tempEuler.x > 0.5) {
1691
- // Slight reduction for moderately high shoulders
1692
- tempEuler.x = tempEuler.x * (1 - reductionFactor * 0.5);
1682
+ // Aggressively clamp X rotation to relaxed position
1683
+ if (tempEuler.x > maxX) {
1684
+ // Force to target relaxed position
1685
+ tempEuler.x = targetX;
1686
+ } else if (tempEuler.x > targetX) {
1687
+ // Smoothly reduce if slightly above target
1688
+ tempEuler.x = targetX + (tempEuler.x - targetX) * 0.2; // More aggressive reduction
1693
1689
  }
1694
1690
 
1695
- // Only update if we changed something significantly
1691
+ // Only update if we actually changed something
1696
1692
  if (Math.abs(tempEuler.x - originalX) > 0.01) {
1697
1693
  tempQuaternion.setFromEuler(tempEuler, 'XYZ');
1698
1694
  leftShoulderBone.quaternion.copy(tempQuaternion);
@@ -1705,16 +1701,16 @@ class TalkingHead {
1705
1701
  tempEuler.setFromQuaternion(rightShoulderBone.quaternion, 'XYZ');
1706
1702
  const originalX = tempEuler.x;
1707
1703
 
1708
- // Reduce X rotation proportionally if it's too high
1709
- if (tempEuler.x > maxAllowedX) {
1710
- // Reduce by percentage to maintain arm-shoulder relationship
1711
- tempEuler.x = maxAllowedX + (tempEuler.x - maxAllowedX) * (1 - reductionFactor);
1712
- } else if (tempEuler.x > 0.5) {
1713
- // Slight reduction for moderately high shoulders
1714
- tempEuler.x = tempEuler.x * (1 - reductionFactor * 0.5);
1704
+ // Aggressively clamp X rotation to relaxed position
1705
+ if (tempEuler.x > maxX) {
1706
+ // Force to target relaxed position
1707
+ tempEuler.x = targetX;
1708
+ } else if (tempEuler.x > targetX) {
1709
+ // Smoothly reduce if slightly above target
1710
+ tempEuler.x = targetX + (tempEuler.x - targetX) * 0.2; // More aggressive reduction
1715
1711
  }
1716
1712
 
1717
- // Only update if we changed something significantly
1713
+ // Only update if we actually changed something
1718
1714
  if (Math.abs(tempEuler.x - originalX) > 0.01) {
1719
1715
  tempQuaternion.setFromEuler(tempEuler, 'XYZ');
1720
1716
  rightShoulderBone.quaternion.copy(tempQuaternion);
@@ -5764,6 +5760,7 @@ class TalkingHead {
5764
5760
  // Filter and map animation tracks
5765
5761
  const mappedTracks = [];
5766
5762
  const unmappedBones = new Set();
5763
+ let filteredShoulderTracks = 0;
5767
5764
  anim.tracks.forEach(track => {
5768
5765
  // Remove mixamorig prefix first
5769
5766
  let trackName = track.name.replaceAll('mixamorig', '');
@@ -5774,8 +5771,13 @@ class TalkingHead {
5774
5771
  // Map bone name to avatar skeleton
5775
5772
  const mappedBoneName = mapBoneName(fbxBoneName);
5776
5773
 
5777
- // Note: We allow shoulder rotation tracks to play so arms position correctly
5778
- // The shoulder adjustment function will override them after animation updates
5774
+ // Filter out shoulder rotation tracks - we'll control shoulders manually
5775
+ if (mappedBoneName && (mappedBoneName === 'LeftShoulder' || mappedBoneName === 'RightShoulder')) {
5776
+ if (property === 'quaternion' || property === 'rotation') {
5777
+ filteredShoulderTracks++;
5778
+ return; // Skip this track - don't add it to mappedTracks
5779
+ }
5780
+ }
5779
5781
 
5780
5782
  if (mappedBoneName && property) {
5781
5783
  // Create new track with mapped bone name
@@ -5804,6 +5806,10 @@ class TalkingHead {
5804
5806
  }
5805
5807
  });
5806
5808
 
5809
+ if (filteredShoulderTracks > 0) {
5810
+ console.log(`✓ Filtered out ${filteredShoulderTracks} shoulder rotation track(s) to prevent high shoulders`);
5811
+ }
5812
+
5807
5813
  if (unmappedBones.size > 0) {
5808
5814
  console.warn(`⚠️ ${unmappedBones.size} bone(s) could not be mapped:`, Array.from(unmappedBones).sort().join(', '));
5809
5815
  }