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

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.5",
3
+ "version": "1.1.7",
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",
@@ -5323,400 +5323,6 @@ class TalkingHead {
5323
5323
  * @param {number} [ndx=0] Index of the clip
5324
5324
  * @param {number} [scale=0.01] Position scale factor
5325
5325
  */
5326
- /**
5327
- * Get all bone names from the avatar's armature
5328
- * @returns {Set<string>} Set of bone names
5329
- */
5330
- getAvailableBoneNames() {
5331
- const boneNames = new Set();
5332
- if (!this.armature) return boneNames;
5333
-
5334
- this.armature.traverse((child) => {
5335
- if (child.isBone || child.type === 'Bone') {
5336
- boneNames.add(child.name);
5337
- }
5338
- });
5339
-
5340
- return boneNames;
5341
- }
5342
-
5343
- /**
5344
- * Map bone names from different naming conventions to avatar bone names
5345
- * @param {string} fbxBoneName - Bone name from FBX animation
5346
- * @param {Set<string>} availableBones - Set of available bone names in avatar
5347
- * @returns {string|null} Mapped bone name or null if no match found
5348
- */
5349
- mapBoneName(fbxBoneName, availableBones) {
5350
- // Direct match first
5351
- if (availableBones.has(fbxBoneName)) {
5352
- return fbxBoneName;
5353
- }
5354
-
5355
- // Remove common prefixes
5356
- let normalized = fbxBoneName;
5357
- const originalNormalized = normalized;
5358
-
5359
- // Remove CC_Base prefix (Character Creator)
5360
- if (normalized.startsWith('CC_Base_')) {
5361
- normalized = normalized.replace('CC_Base_', '');
5362
- }
5363
-
5364
- // Remove mixamorig prefix (Mixamo)
5365
- normalized = normalized.replace(/^mixamorig/i, '');
5366
-
5367
- // Try direct match after prefix removal
5368
- if (availableBones.has(normalized)) {
5369
- return normalized;
5370
- }
5371
-
5372
- // Debug: Log first few failed mappings to help identify patterns
5373
- if (!this._mappingDebugLog) {
5374
- this._mappingDebugLog = new Set();
5375
- }
5376
- if (this._mappingDebugLog.size < 5 && !this._mappingDebugLog.has(fbxBoneName)) {
5377
- this._mappingDebugLog.add(fbxBoneName);
5378
- console.debug(`Mapping attempt: "${fbxBoneName}" -> "${normalized}" (not found in available bones)`);
5379
- }
5380
-
5381
- // Handle numbered bones (e.g., Spine01 -> Spine1)
5382
- if (normalized.match(/^Spine\d+$/)) {
5383
- const num = normalized.match(/\d+/)?.[0];
5384
- if (num) {
5385
- const mapped = `Spine${parseInt(num)}`;
5386
- if (availableBones.has(mapped)) {
5387
- return mapped;
5388
- }
5389
- // Try Spine1 if Spine01, Spine2 if Spine02+
5390
- if (num === '01' && availableBones.has('Spine1')) {
5391
- return 'Spine1';
5392
- }
5393
- if (parseInt(num) >= 2 && availableBones.has('Spine2')) {
5394
- return 'Spine2';
5395
- }
5396
- }
5397
- }
5398
-
5399
- // Handle twist bones (ignore them, they're usually not in standard skeletons)
5400
- if (normalized.includes('Twist')) {
5401
- return null;
5402
- }
5403
-
5404
- // Mapping rules for common bone name patterns
5405
- const mappings = {
5406
- // Spine mapping
5407
- 'Spine01': 'Spine1',
5408
- 'Spine02': 'Spine2',
5409
- 'Spine03': 'Spine2',
5410
-
5411
- // Left arm mapping
5412
- 'L_Upperarm': 'LeftArm',
5413
- 'L_Forearm': 'LeftForeArm',
5414
- 'L_Hand': 'LeftHand',
5415
- 'L_Shoulder': 'LeftShoulder',
5416
- 'L_Index1': 'LeftHandIndex1',
5417
- 'L_Index2': 'LeftHandIndex2',
5418
- 'L_Index3': 'LeftHandIndex3',
5419
- 'L_Middle1': 'LeftHandMiddle1',
5420
- 'L_Middle2': 'LeftHandMiddle2',
5421
- 'L_Middle3': 'LeftHandMiddle3',
5422
- 'L_Mid1': 'LeftHandMiddle1',
5423
- 'L_Mid2': 'LeftHandMiddle2',
5424
- 'L_Mid3': 'LeftHandMiddle3',
5425
- 'L_Ring1': 'LeftHandRing1',
5426
- 'L_Ring2': 'LeftHandRing2',
5427
- 'L_Ring3': 'LeftHandRing3',
5428
- 'L_Pinky1': 'LeftHandPinky1',
5429
- 'L_Pinky2': 'LeftHandPinky2',
5430
- 'L_Pinky3': 'LeftHandPinky3',
5431
- 'L_Thumb1': 'LeftHandThumb1',
5432
- 'L_Thumb2': 'LeftHandThumb2',
5433
- 'L_Thumb3': 'LeftHandThumb3',
5434
-
5435
- // Right arm mapping
5436
- 'R_Upperarm': 'RightArm',
5437
- 'R_Forearm': 'RightForeArm',
5438
- 'R_Hand': 'RightHand',
5439
- 'R_Shoulder': 'RightShoulder',
5440
- 'R_Index1': 'RightHandIndex1',
5441
- 'R_Index2': 'RightHandIndex2',
5442
- 'R_Index3': 'RightHandIndex3',
5443
- 'R_Middle1': 'RightHandMiddle1',
5444
- 'R_Middle2': 'RightHandMiddle2',
5445
- 'R_Middle3': 'RightHandMiddle3',
5446
- 'R_Mid1': 'RightHandMiddle1',
5447
- 'R_Mid2': 'RightHandMiddle2',
5448
- 'R_Mid3': 'RightHandMiddle3',
5449
- 'R_Ring1': 'RightHandRing1',
5450
- 'R_Ring2': 'RightHandRing2',
5451
- 'R_Ring3': 'RightHandRing3',
5452
- 'R_Pinky1': 'RightHandPinky1',
5453
- 'R_Pinky2': 'RightHandPinky2',
5454
- 'R_Pinky3': 'RightHandPinky3',
5455
- 'R_Thumb1': 'RightHandThumb1',
5456
- 'R_Thumb2': 'RightHandThumb2',
5457
- 'R_Thumb3': 'RightHandThumb3',
5458
-
5459
- // Leg mapping
5460
- 'L_Thigh': 'LeftUpLeg',
5461
- 'L_Calf': 'LeftLeg',
5462
- 'L_Foot': 'LeftFoot',
5463
- 'R_Thigh': 'RightUpLeg',
5464
- 'R_Calf': 'RightLeg',
5465
- 'R_Foot': 'RightFoot',
5466
- };
5467
-
5468
- // Try mapping
5469
- if (mappings[normalized]) {
5470
- const mapped = mappings[normalized];
5471
- if (availableBones.has(mapped)) {
5472
- return mapped;
5473
- }
5474
- }
5475
-
5476
- // Pattern-based matching for CC_Base and similar naming conventions
5477
- const lowerNormalized = normalized.toLowerCase();
5478
- const upperFirst = normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase();
5479
-
5480
- // Pattern: R_Index1/2/3 or r_index1/2/3 -> RightHandIndex1/2/3
5481
- const indexMatch = lowerNormalized.match(/^[rl]_index(\d+)$/);
5482
- if (indexMatch) {
5483
- const digit = indexMatch[1];
5484
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5485
- const candidate = `${side}HandIndex${digit}`;
5486
- if (availableBones.has(candidate)) {
5487
- return candidate;
5488
- }
5489
- }
5490
-
5491
- // Pattern: R_Pinky1/2/3 -> RightHandPinky1/2/3
5492
- const pinkyMatch = lowerNormalized.match(/^[rl]_pinky(\d+)$/);
5493
- if (pinkyMatch) {
5494
- const digit = pinkyMatch[1];
5495
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5496
- const candidate = `${side}HandPinky${digit}`;
5497
- if (availableBones.has(candidate)) {
5498
- return candidate;
5499
- }
5500
- }
5501
-
5502
- // Pattern: R_Ring1/2/3 -> RightHandRing1/2/3
5503
- const ringMatch = lowerNormalized.match(/^[rl]_ring(\d+)$/);
5504
- if (ringMatch) {
5505
- const digit = ringMatch[1];
5506
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5507
- const candidate = `${side}HandRing${digit}`;
5508
- if (availableBones.has(candidate)) {
5509
- return candidate;
5510
- }
5511
- }
5512
-
5513
- // Pattern: R_Middle1/2/3 or R_Mid1/2/3 -> RightHandMiddle1/2/3
5514
- const middleMatch = lowerNormalized.match(/^[rl]_(?:middle|mid)(\d+)$/);
5515
- if (middleMatch) {
5516
- const digit = middleMatch[1];
5517
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5518
- const candidate = `${side}HandMiddle${digit}`;
5519
- if (availableBones.has(candidate)) {
5520
- return candidate;
5521
- }
5522
- }
5523
-
5524
- // Pattern: R_Thumb1/2/3 -> RightHandThumb1/2/3
5525
- const thumbMatch = lowerNormalized.match(/^[rl]_thumb(\d+)$/);
5526
- if (thumbMatch) {
5527
- const digit = thumbMatch[1];
5528
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5529
- const candidate = `${side}HandThumb${digit}`;
5530
- if (availableBones.has(candidate)) {
5531
- return candidate;
5532
- }
5533
- }
5534
-
5535
- // Pattern: R_Upperarm -> RightArm (case insensitive)
5536
- if (lowerNormalized.match(/^[rl]_upperarm/)) {
5537
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5538
- const candidate = `${side}Arm`;
5539
- if (availableBones.has(candidate)) {
5540
- return candidate;
5541
- }
5542
- }
5543
-
5544
- // Pattern: R_UpperarmTwist01/02 -> ignore (twist bones and other extra bones)
5545
- if (lowerNormalized.includes('upperarmtwist') ||
5546
- lowerNormalized.includes('forearmtwist') ||
5547
- lowerNormalized.includes('ribstwist') ||
5548
- lowerNormalized.includes('breast') ||
5549
- lowerNormalized.includes('twist')) {
5550
- return null;
5551
- }
5552
-
5553
- // Pattern: R_Forearm -> RightForeArm
5554
- if (lowerNormalized.match(/^[rl]_forearm/)) {
5555
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5556
- const candidate = `${side}ForeArm`;
5557
- if (availableBones.has(candidate)) {
5558
- return candidate;
5559
- }
5560
- }
5561
-
5562
- // Pattern: R_Hand -> RightHand
5563
- if (lowerNormalized.match(/^[rl]_hand$/)) {
5564
- const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5565
- const candidate = `${side}Hand`;
5566
- if (availableBones.has(candidate)) {
5567
- return candidate;
5568
- }
5569
- }
5570
-
5571
- // Try case-insensitive exact match
5572
- for (const boneName of availableBones) {
5573
- if (boneName.toLowerCase() === lowerNormalized) {
5574
- return boneName;
5575
- }
5576
- }
5577
-
5578
- return null; // No mapping found
5579
- }
5580
-
5581
- /**
5582
- * Filter animation tracks to only include bones that exist in the avatar
5583
- * Maps bone names from different naming conventions to avatar bone names
5584
- * @param {THREE.AnimationClip} clip - Animation clip to filter
5585
- * @param {Set<string>} availableBones - Set of available bone names
5586
- * @returns {THREE.AnimationClip} Filtered animation clip with mapped bone names
5587
- */
5588
- filterAnimationTracks(clip, availableBones) {
5589
- const validTracks = [];
5590
- const missingBones = new Set();
5591
- const mappedBones = new Map(); // Track mappings for logging
5592
-
5593
- // Debug: Log available bones (first time only)
5594
- if (!this._loggedAvailableBones) {
5595
- console.log('Available avatar bones:', Array.from(availableBones).sort().slice(0, 50).join(', '),
5596
- availableBones.size > 50 ? `... (${availableBones.size} total)` : '');
5597
- this._loggedAvailableBones = true;
5598
- }
5599
-
5600
- clip.tracks.forEach(track => {
5601
- // Extract bone name from track name (e.g., "CC_Base_R_Index3.position" -> "CC_Base_R_Index3")
5602
- const trackNameParts = track.name.split('.');
5603
- const fbxBoneName = trackNameParts[0];
5604
- const property = trackNameParts[1]; // position, quaternion, rotation, etc.
5605
-
5606
- // Try to map the bone name
5607
- const mappedBoneName = this.mapBoneName(fbxBoneName, availableBones);
5608
-
5609
- if (mappedBoneName) {
5610
- // Create a new track with the mapped bone name
5611
- const newTrackName = `${mappedBoneName}.${property}`;
5612
-
5613
- // Clone the track and update its name
5614
- const newTrack = track.clone();
5615
- newTrack.name = newTrackName;
5616
-
5617
- // Fix rotations for arm/hand bones that might be inverted
5618
- // If hands are folding behind instead of in front, we need to adjust rotations
5619
- const isArmBone = mappedBoneName.includes('Arm') || mappedBoneName.includes('Hand') || mappedBoneName.includes('Shoulder');
5620
- const isForearmBone = mappedBoneName.includes('ForeArm');
5621
-
5622
- if (isArmBone && (property === 'quaternion' || property === 'rotation')) {
5623
- // For quaternion tracks, we might need to adjust the rotation
5624
- // Check if this is a quaternion track
5625
- if (property === 'quaternion' && newTrack.values && newTrack.values.length >= 4) {
5626
- // Quaternion format: [x, y, z, w] per keyframe
5627
- // For arm bones, we might need to invert Y or Z rotation
5628
- // Adjust quaternion values to fix hand position
5629
- const numKeyframes = newTrack.times.length;
5630
- for (let i = 0; i < numKeyframes; i++) {
5631
- const baseIdx = i * 4;
5632
- if (baseIdx + 3 < newTrack.values.length) {
5633
- // Get quaternion values
5634
- let x = newTrack.values[baseIdx];
5635
- let y = newTrack.values[baseIdx + 1];
5636
- let z = newTrack.values[baseIdx + 2];
5637
- let w = newTrack.values[baseIdx + 3];
5638
-
5639
- // For arms, adjust rotation to flip hands from back to front
5640
- // This is a common fix for FBX animations with different coordinate systems
5641
- if (isForearmBone || mappedBoneName.includes('Hand')) {
5642
- // Rotate 180 degrees around X axis to flip hands from behind to in front
5643
- // Create a 180-degree rotation around X axis
5644
- const flipAngle = Math.PI;
5645
- const flipX = Math.cos(flipAngle / 2); // w component
5646
- const flipY = Math.sin(flipAngle / 2); // x component (axis X = 1,0,0)
5647
-
5648
- // Multiply quaternions: q_result = q_flip * q_original
5649
- // For rotation around X axis: q_flip = (sin(θ/2), 0, 0, cos(θ/2))
5650
- const qw = flipX * w - flipY * x;
5651
- const qx = flipX * x + flipY * w;
5652
- const qy = flipX * y - flipY * z;
5653
- const qz = flipX * z + flipY * y;
5654
-
5655
- x = qx;
5656
- y = qy;
5657
- z = qz;
5658
- w = qw;
5659
- }
5660
-
5661
- newTrack.values[baseIdx] = x;
5662
- newTrack.values[baseIdx + 1] = y;
5663
- newTrack.values[baseIdx + 2] = z;
5664
- newTrack.values[baseIdx + 3] = w;
5665
- }
5666
- }
5667
- } else if (property === 'rotation' && newTrack.values && newTrack.values.length >= 3) {
5668
- // Euler rotation format: [x, y, z] per keyframe
5669
- const numKeyframes = newTrack.times.length;
5670
- for (let i = 0; i < numKeyframes; i++) {
5671
- const baseIdx = i * 3;
5672
- if (baseIdx + 2 < newTrack.values.length) {
5673
- // For arm bones, adjust Y rotation to flip hands
5674
- if (isForearmBone || mappedBoneName.includes('Hand')) {
5675
- // Add 180 degrees (PI radians) to Y rotation
5676
- newTrack.values[baseIdx + 1] += Math.PI;
5677
- }
5678
- }
5679
- }
5680
- }
5681
- }
5682
-
5683
- validTracks.push(newTrack);
5684
-
5685
- // Track mappings for logging
5686
- if (fbxBoneName !== mappedBoneName) {
5687
- mappedBones.set(fbxBoneName, mappedBoneName);
5688
- }
5689
- } else {
5690
- missingBones.add(fbxBoneName);
5691
- }
5692
- });
5693
-
5694
- // Log results
5695
- if (mappedBones.size > 0) {
5696
- console.info(`FBX animation "${clip.name}": Mapped ${mappedBones.size} bone(s) to avatar skeleton:`,
5697
- Array.from(mappedBones.entries()).slice(0, 5).map(([from, to]) => `${from} → ${to}`).join(', '),
5698
- mappedBones.size > 5 ? '...' : '');
5699
- }
5700
-
5701
- if (missingBones.size > 0) {
5702
- console.warn(`FBX animation "${clip.name}" contains tracks for ${missingBones.size} bone(s) that couldn't be mapped:`,
5703
- Array.from(missingBones).slice(0, 10).join(', '),
5704
- missingBones.size > 10 ? '...' : '');
5705
- }
5706
-
5707
- if (validTracks.length > 0) {
5708
- console.info(`Filtered ${clip.tracks.length} tracks down to ${validTracks.length} valid tracks (${mappedBones.size} mapped)`);
5709
- } else {
5710
- console.error(`No valid tracks found for animation "${clip.name}". All bones are missing or couldn't be mapped.`);
5711
- }
5712
-
5713
- // Create a new clip with only valid tracks
5714
- if (validTracks.length === 0) {
5715
- return null;
5716
- }
5717
-
5718
- return new THREE.AnimationClip(clip.name, clip.duration, validTracks);
5719
- }
5720
5326
 
5721
5327
  async playAnimation(url, onprogress=null, dur=10, ndx=0, scale=0.01, disablePositionLock=false) {
5722
5328
  if ( !this.armature ) return;
@@ -5865,20 +5471,6 @@ class TalkingHead {
5865
5471
  if ( fbx && fbx.animations && fbx.animations[ndx] ) {
5866
5472
  let anim = fbx.animations[ndx];
5867
5473
 
5868
- // Get available bone names from avatar skeleton
5869
- const availableBones = this.getAvailableBoneNames();
5870
-
5871
- // Filter animation tracks to only include bones that exist
5872
- const filteredAnim = this.filterAnimationTracks(anim, availableBones);
5873
-
5874
- if (!filteredAnim) {
5875
- console.error(`Cannot play FBX animation "${url}": No compatible bones found.`);
5876
- return;
5877
- }
5878
-
5879
- // Use the filtered animation instead of the original
5880
- anim = filteredAnim;
5881
-
5882
5474
  // Rename and scale Mixamo tracks, create a pose
5883
5475
  const props = {};
5884
5476
  anim.tracks.forEach( t => {