@sage-rsc/talking-head-react 1.1.7 → 1.1.9

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.1.7",
3
+ "version": "1.1.9",
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",
@@ -5471,6 +5471,279 @@ class TalkingHead {
5471
5471
  if ( fbx && fbx.animations && fbx.animations[ndx] ) {
5472
5472
  let anim = fbx.animations[ndx];
5473
5473
 
5474
+ // Get available bone names from avatar skeleton for mapping
5475
+ const availableBones = new Set();
5476
+ if (this.armature) {
5477
+ this.armature.traverse((child) => {
5478
+ if (child.isBone || child.type === 'Bone') {
5479
+ availableBones.add(child.name);
5480
+ }
5481
+ });
5482
+ }
5483
+
5484
+ // Map bone names from FBX to avatar skeleton
5485
+ const boneNameMap = new Map();
5486
+ const mapBoneName = (fbxBoneName) => {
5487
+ // Direct match
5488
+ if (availableBones.has(fbxBoneName)) {
5489
+ return fbxBoneName;
5490
+ }
5491
+
5492
+ // Remove common prefixes (mixamorig, CC_Base_, etc.)
5493
+ let normalized = fbxBoneName
5494
+ .replace(/^mixamorig/i, '')
5495
+ .replace(/^CC_Base_/i, '')
5496
+ .replace(/^RPM_/i, '');
5497
+
5498
+ if (availableBones.has(normalized)) {
5499
+ return normalized;
5500
+ }
5501
+
5502
+ // Pattern-based matching for Ready Player Me / Mixamo
5503
+ const lowerNormalized = normalized.toLowerCase();
5504
+
5505
+ // Arm bones - pattern matching
5506
+ if (lowerNormalized.includes('left') && lowerNormalized.includes('arm')) {
5507
+ if (lowerNormalized.includes('fore') || lowerNormalized.includes('lower')) {
5508
+ if (availableBones.has('LeftForeArm')) return 'LeftForeArm';
5509
+ if (availableBones.has('LeftForearm')) return 'LeftForearm';
5510
+ } else if (!lowerNormalized.includes('fore') && !lowerNormalized.includes('hand')) {
5511
+ if (availableBones.has('LeftArm')) return 'LeftArm';
5512
+ }
5513
+ }
5514
+
5515
+ if (lowerNormalized.includes('right') && lowerNormalized.includes('arm')) {
5516
+ if (lowerNormalized.includes('fore') || lowerNormalized.includes('lower')) {
5517
+ if (availableBones.has('RightForeArm')) return 'RightForeArm';
5518
+ if (availableBones.has('RightForearm')) return 'RightForearm';
5519
+ } else if (!lowerNormalized.includes('fore') && !lowerNormalized.includes('hand')) {
5520
+ if (availableBones.has('RightArm')) return 'RightArm';
5521
+ }
5522
+ }
5523
+
5524
+ // Hand bones
5525
+ if (lowerNormalized.includes('left') && lowerNormalized.includes('hand') &&
5526
+ !lowerNormalized.includes('index') && !lowerNormalized.includes('thumb') &&
5527
+ !lowerNormalized.includes('middle') && !lowerNormalized.includes('ring') &&
5528
+ !lowerNormalized.includes('pinky')) {
5529
+ if (availableBones.has('LeftHand')) return 'LeftHand';
5530
+ }
5531
+
5532
+ if (lowerNormalized.includes('right') && lowerNormalized.includes('hand') &&
5533
+ !lowerNormalized.includes('index') && !lowerNormalized.includes('thumb') &&
5534
+ !lowerNormalized.includes('middle') && !lowerNormalized.includes('ring') &&
5535
+ !lowerNormalized.includes('pinky')) {
5536
+ if (availableBones.has('RightHand')) return 'RightHand';
5537
+ }
5538
+
5539
+ // Shoulder bones
5540
+ if (lowerNormalized.includes('left') && (lowerNormalized.includes('shoulder') || lowerNormalized.includes('clavicle'))) {
5541
+ if (availableBones.has('LeftShoulder')) return 'LeftShoulder';
5542
+ }
5543
+
5544
+ if (lowerNormalized.includes('right') && (lowerNormalized.includes('shoulder') || lowerNormalized.includes('clavicle'))) {
5545
+ if (availableBones.has('RightShoulder')) return 'RightShoulder';
5546
+ }
5547
+
5548
+ // Common bone name mappings
5549
+ const mappings = {
5550
+ // Arm bones - exact matches
5551
+ 'LeftArm': 'LeftArm',
5552
+ 'leftArm': 'LeftArm',
5553
+ 'LEFTARM': 'LeftArm',
5554
+ 'RightArm': 'RightArm',
5555
+ 'rightArm': 'RightArm',
5556
+ 'RIGHTARM': 'RightArm',
5557
+ 'LeftForeArm': 'LeftForeArm',
5558
+ 'leftForeArm': 'LeftForeArm',
5559
+ 'leftForearm': 'LeftForeArm',
5560
+ 'LeftForearm': 'LeftForeArm',
5561
+ 'RightForeArm': 'RightForeArm',
5562
+ 'rightForeArm': 'RightForeArm',
5563
+ 'rightForearm': 'RightForeArm',
5564
+ 'RightForearm': 'RightForeArm',
5565
+ 'LeftHand': 'LeftHand',
5566
+ 'leftHand': 'LeftHand',
5567
+ 'RightHand': 'RightHand',
5568
+ 'rightHand': 'RightHand',
5569
+ 'LeftShoulder': 'LeftShoulder',
5570
+ 'leftShoulder': 'LeftShoulder',
5571
+ 'RightShoulder': 'RightShoulder',
5572
+ 'rightShoulder': 'RightShoulder',
5573
+ // Spine
5574
+ 'Spine': 'Spine1',
5575
+ 'spine': 'Spine1',
5576
+ 'Spine1': 'Spine1',
5577
+ 'Spine2': 'Spine2',
5578
+ // Head/Neck
5579
+ 'Head': 'Head',
5580
+ 'head': 'Head',
5581
+ 'Neck': 'Neck',
5582
+ 'neck': 'Neck',
5583
+ // Hips
5584
+ 'Hips': 'Hips',
5585
+ 'hips': 'Hips',
5586
+ 'Root': 'Hips',
5587
+ 'root': 'Hips',
5588
+ };
5589
+
5590
+ if (mappings[normalized]) {
5591
+ const mapped = mappings[normalized];
5592
+ if (availableBones.has(mapped)) {
5593
+ return mapped;
5594
+ }
5595
+ }
5596
+
5597
+ // Try case-insensitive match
5598
+ for (const boneName of availableBones) {
5599
+ if (boneName.toLowerCase() === lowerNormalized) {
5600
+ return boneName;
5601
+ }
5602
+ }
5603
+
5604
+ // Try partial match (contains)
5605
+ for (const boneName of availableBones) {
5606
+ const boneLower = boneName.toLowerCase();
5607
+ // Match if normalized contains key parts of bone name
5608
+ if ((lowerNormalized.includes('left') && boneLower.includes('left')) ||
5609
+ (lowerNormalized.includes('right') && boneLower.includes('right'))) {
5610
+ if ((lowerNormalized.includes('arm') && boneLower.includes('arm') && !boneLower.includes('fore')) ||
5611
+ (lowerNormalized.includes('forearm') && boneLower.includes('forearm')) ||
5612
+ (lowerNormalized.includes('hand') && boneLower.includes('hand') && !boneLower.includes('index') && !boneLower.includes('thumb')) ||
5613
+ (lowerNormalized.includes('shoulder') && boneLower.includes('shoulder'))) {
5614
+ return boneName;
5615
+ }
5616
+ }
5617
+ }
5618
+
5619
+ return null; // No mapping found
5620
+ };
5621
+
5622
+ // Debug: Log FBX bone names and avatar bone names for comparison
5623
+ const fbxBoneNames = new Set();
5624
+ anim.tracks.forEach(track => {
5625
+ const trackParts = track.name.split('.');
5626
+ fbxBoneNames.add(trackParts[0]);
5627
+ });
5628
+
5629
+ console.log('=== Ready Player Me Animation Bone Analysis ===');
5630
+ console.log('FBX bone names:', Array.from(fbxBoneNames).sort().join(', '));
5631
+ console.log('Avatar skeleton bone names:', Array.from(availableBones).sort().join(', '));
5632
+
5633
+ // Check for arm bones specifically
5634
+ const fbxArmBones = Array.from(fbxBoneNames).filter(b =>
5635
+ b.toLowerCase().includes('arm') ||
5636
+ b.toLowerCase().includes('hand') ||
5637
+ b.toLowerCase().includes('shoulder')
5638
+ );
5639
+ const avatarArmBones = Array.from(availableBones).filter(b =>
5640
+ b.includes('Arm') ||
5641
+ b.includes('Hand') ||
5642
+ b.includes('Shoulder')
5643
+ );
5644
+ console.log('FBX arm/hand/shoulder bones:', fbxArmBones.sort().join(', '));
5645
+ console.log('Avatar arm/hand/shoulder bones:', avatarArmBones.sort().join(', '));
5646
+
5647
+ // Filter and map animation tracks
5648
+ const mappedTracks = [];
5649
+ const unmappedBones = new Set();
5650
+ anim.tracks.forEach(track => {
5651
+ // Remove mixamorig prefix first
5652
+ let trackName = track.name.replaceAll('mixamorig', '');
5653
+ const trackParts = trackName.split('.');
5654
+ const fbxBoneName = trackParts[0];
5655
+ const property = trackParts[1];
5656
+
5657
+ // Map bone name to avatar skeleton
5658
+ const mappedBoneName = mapBoneName(fbxBoneName);
5659
+
5660
+ if (mappedBoneName && property) {
5661
+ // Create new track with mapped bone name
5662
+ const newTrackName = `${mappedBoneName}.${property}`;
5663
+ const newTrack = track.clone();
5664
+ newTrack.name = newTrackName;
5665
+
5666
+ // Fix rotations for Ready Player Me arm bones (coordinate system correction)
5667
+ const isArmBone = mappedBoneName.includes('Arm') || mappedBoneName.includes('Hand') || mappedBoneName.includes('Shoulder');
5668
+ const isLeftArm = mappedBoneName.includes('Left');
5669
+
5670
+ if (isArmBone && (property === 'quaternion' || property === 'rotation')) {
5671
+ if (property === 'quaternion' && newTrack.values && newTrack.values.length >= 4) {
5672
+ // Adjust quaternion rotations for Ready Player Me coordinate system
5673
+ const numKeyframes = newTrack.times.length;
5674
+ for (let i = 0; i < numKeyframes; i++) {
5675
+ const baseIdx = i * 4;
5676
+ if (baseIdx + 3 < newTrack.values.length) {
5677
+ let x = newTrack.values[baseIdx];
5678
+ let y = newTrack.values[baseIdx + 1];
5679
+ let z = newTrack.values[baseIdx + 2];
5680
+ let w = newTrack.values[baseIdx + 3];
5681
+
5682
+ // For Ready Player Me, we may need to flip certain axes
5683
+ // Try rotating 180 degrees around Y axis for left arm
5684
+ if (isLeftArm && mappedBoneName.includes('ForeArm')) {
5685
+ // Left forearm might need Y-axis flip
5686
+ const flipY = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
5687
+ const q = new THREE.Quaternion(x, y, z, w);
5688
+ q.multiply(flipY);
5689
+ x = q.x;
5690
+ y = q.y;
5691
+ z = q.z;
5692
+ w = q.w;
5693
+ }
5694
+
5695
+ newTrack.values[baseIdx] = x;
5696
+ newTrack.values[baseIdx + 1] = y;
5697
+ newTrack.values[baseIdx + 2] = z;
5698
+ newTrack.values[baseIdx + 3] = w;
5699
+ }
5700
+ }
5701
+ }
5702
+ }
5703
+
5704
+ mappedTracks.push(newTrack);
5705
+
5706
+ // Store mapping for logging
5707
+ if (fbxBoneName !== mappedBoneName) {
5708
+ boneNameMap.set(fbxBoneName, mappedBoneName);
5709
+ }
5710
+ } else {
5711
+ unmappedBones.add(fbxBoneName);
5712
+ // Log unmapped bones (especially arm bones)
5713
+ if (fbxBoneName.toLowerCase().includes('arm') ||
5714
+ fbxBoneName.toLowerCase().includes('hand') ||
5715
+ fbxBoneName.toLowerCase().includes('shoulder')) {
5716
+ console.warn(`⚠️ Arm bone "${fbxBoneName}" could not be mapped to avatar skeleton`);
5717
+ }
5718
+ }
5719
+ });
5720
+
5721
+ if (unmappedBones.size > 0) {
5722
+ console.warn(`⚠️ ${unmappedBones.size} bone(s) could not be mapped:`, Array.from(unmappedBones).sort().join(', '));
5723
+ }
5724
+
5725
+ // Use mapped tracks if we have any, otherwise use original
5726
+ if (mappedTracks.length > 0) {
5727
+ anim = new THREE.AnimationClip(anim.name, anim.duration, mappedTracks);
5728
+ console.log(`✓ Created animation with ${mappedTracks.length} mapped tracks (from ${anim.tracks.length} original tracks)`);
5729
+ if (boneNameMap.size > 0) {
5730
+ console.log(`✓ Mapped ${boneNameMap.size} bone(s):`,
5731
+ Array.from(boneNameMap.entries()).map(([from, to]) => `${from}→${to}`).join(', '));
5732
+ }
5733
+
5734
+ // Check if arm bones were mapped
5735
+ const mappedArmBones = Array.from(boneNameMap.values()).filter(b =>
5736
+ b.includes('Arm') || b.includes('Hand') || b.includes('Shoulder')
5737
+ );
5738
+ if (mappedArmBones.length > 0) {
5739
+ console.log(`✓ Arm bones mapped: ${mappedArmBones.join(', ')}`);
5740
+ } else {
5741
+ console.warn('⚠️ No arm bones were mapped! This may cause arm rigging issues.');
5742
+ }
5743
+ } else {
5744
+ console.error('❌ No tracks could be mapped! Animation may not work correctly.');
5745
+ }
5746
+
5474
5747
  // Rename and scale Mixamo tracks, create a pose
5475
5748
  const props = {};
5476
5749
  anim.tracks.forEach( t => {