@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/dist/index.cjs +7 -2
- package/dist/index.js +1291 -1007
- package/package.json +1 -1
- package/src/components/AnimationSelector.jsx +419 -0
- package/src/index.js +1 -0
- package/src/lib/talkinghead.mjs +18 -2
package/package.json
CHANGED
|
@@ -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
|
|
package/src/lib/talkinghead.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|