@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/dist/index.cjs +7 -2
- package/dist/index.js +1446 -1157
- package/package.json +1 -1
- package/src/components/AnimationSelector.jsx +419 -0
- package/src/index.js +1 -0
- package/src/lib/talkinghead.mjs +34 -28
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
|
@@ -1667,32 +1667,28 @@ class TalkingHead {
|
|
|
1667
1667
|
const tempEuler = new THREE.Euler();
|
|
1668
1668
|
const tempQuaternion = new THREE.Quaternion();
|
|
1669
1669
|
|
|
1670
|
-
//
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
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
|
-
//
|
|
1687
|
-
if (tempEuler.x >
|
|
1688
|
-
//
|
|
1689
|
-
tempEuler.x =
|
|
1690
|
-
} else if (tempEuler.x >
|
|
1691
|
-
//
|
|
1692
|
-
tempEuler.x = tempEuler.x
|
|
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
|
|
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
|
-
//
|
|
1709
|
-
if (tempEuler.x >
|
|
1710
|
-
//
|
|
1711
|
-
tempEuler.x =
|
|
1712
|
-
} else if (tempEuler.x >
|
|
1713
|
-
//
|
|
1714
|
-
tempEuler.x = tempEuler.x
|
|
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
|
|
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
|
-
//
|
|
5778
|
-
|
|
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
|
}
|