@sage-rsc/talking-head-react 1.5.1 → 1.5.6

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.1",
3
+ "version": "1.5.6",
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
 
@@ -1692,7 +1692,15 @@ class TalkingHead {
1692
1692
  if (Math.abs(tempEuler.x - originalX) > 0.01) {
1693
1693
  tempQuaternion.setFromEuler(tempEuler, 'XYZ');
1694
1694
  leftShoulderBone.quaternion.copy(tempQuaternion);
1695
- leftShoulderBone.updateMatrixWorld(true);
1695
+ // Update only the shoulder bone's matrix, NOT children
1696
+ // This prevents interference with arm/hand animations
1697
+ leftShoulderBone.updateMatrix();
1698
+ if (leftShoulderBone.parent) {
1699
+ leftShoulderBone.matrixWorld.multiplyMatrices(leftShoulderBone.parent.matrixWorld, leftShoulderBone.matrix);
1700
+ } else {
1701
+ leftShoulderBone.matrixWorld.copy(leftShoulderBone.matrix);
1702
+ }
1703
+ // DO NOT call updateMatrixWorld(true) as it propagates to children and interferes with FBX animations
1696
1704
  }
1697
1705
  }
1698
1706
 
@@ -1714,7 +1722,15 @@ class TalkingHead {
1714
1722
  if (Math.abs(tempEuler.x - originalX) > 0.01) {
1715
1723
  tempQuaternion.setFromEuler(tempEuler, 'XYZ');
1716
1724
  rightShoulderBone.quaternion.copy(tempQuaternion);
1717
- rightShoulderBone.updateMatrixWorld(true);
1725
+ // Update only the shoulder bone's matrix, NOT children
1726
+ // This prevents interference with arm/hand animations
1727
+ rightShoulderBone.updateMatrix();
1728
+ if (rightShoulderBone.parent) {
1729
+ rightShoulderBone.matrixWorld.multiplyMatrices(rightShoulderBone.parent.matrixWorld, rightShoulderBone.matrix);
1730
+ } else {
1731
+ rightShoulderBone.matrixWorld.copy(rightShoulderBone.matrix);
1732
+ }
1733
+ // DO NOT call updateMatrixWorld(true) as it propagates to children and interferes with FBX animations
1718
1734
  }
1719
1735
  }
1720
1736
  }