@sage-rsc/talking-head-react 1.1.1 → 1.1.3

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.1",
3
+ "version": "1.1.3",
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",
@@ -2253,11 +2253,11 @@ class TalkingHead {
2253
2253
  if (this.lockedPosition && this.armature) {
2254
2254
  // Enforce the locked position - keep avatar exactly where it was locked
2255
2255
  // This prevents FBX animations from moving the avatar
2256
- this.armature.position.set(
2257
- this.lockedPosition.x,
2256
+ this.armature.position.set(
2257
+ this.lockedPosition.x,
2258
2258
  this.lockedPosition.y,
2259
- this.lockedPosition.z
2260
- );
2259
+ this.lockedPosition.z
2260
+ );
2261
2261
  }
2262
2262
  }
2263
2263
 
@@ -5340,38 +5340,302 @@ class TalkingHead {
5340
5340
  return boneNames;
5341
5341
  }
5342
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_Ring1': 'LeftHandRing1',
5423
+ 'L_Ring2': 'LeftHandRing2',
5424
+ 'L_Ring3': 'LeftHandRing3',
5425
+ 'L_Pinky1': 'LeftHandPinky1',
5426
+ 'L_Pinky2': 'LeftHandPinky2',
5427
+ 'L_Pinky3': 'LeftHandPinky3',
5428
+ 'L_Thumb1': 'LeftHandThumb1',
5429
+ 'L_Thumb2': 'LeftHandThumb2',
5430
+ 'L_Thumb3': 'LeftHandThumb3',
5431
+
5432
+ // Right arm mapping
5433
+ 'R_Upperarm': 'RightArm',
5434
+ 'R_Forearm': 'RightForeArm',
5435
+ 'R_Hand': 'RightHand',
5436
+ 'R_Shoulder': 'RightShoulder',
5437
+ 'R_Index1': 'RightHandIndex1',
5438
+ 'R_Index2': 'RightHandIndex2',
5439
+ 'R_Index3': 'RightHandIndex3',
5440
+ 'R_Middle1': 'RightHandMiddle1',
5441
+ 'R_Middle2': 'RightHandMiddle2',
5442
+ 'R_Middle3': 'RightHandMiddle3',
5443
+ 'R_Ring1': 'RightHandRing1',
5444
+ 'R_Ring2': 'RightHandRing2',
5445
+ 'R_Ring3': 'RightHandRing3',
5446
+ 'R_Pinky1': 'RightHandPinky1',
5447
+ 'R_Pinky2': 'RightHandPinky2',
5448
+ 'R_Pinky3': 'RightHandPinky3',
5449
+ 'R_Thumb1': 'RightHandThumb1',
5450
+ 'R_Thumb2': 'RightHandThumb2',
5451
+ 'R_Thumb3': 'RightHandThumb3',
5452
+
5453
+ // Leg mapping
5454
+ 'L_Thigh': 'LeftUpLeg',
5455
+ 'L_Calf': 'LeftLeg',
5456
+ 'L_Foot': 'LeftFoot',
5457
+ 'R_Thigh': 'RightUpLeg',
5458
+ 'R_Calf': 'RightLeg',
5459
+ 'R_Foot': 'RightFoot',
5460
+ };
5461
+
5462
+ // Try mapping
5463
+ if (mappings[normalized]) {
5464
+ const mapped = mappings[normalized];
5465
+ if (availableBones.has(mapped)) {
5466
+ return mapped;
5467
+ }
5468
+ }
5469
+
5470
+ // Pattern-based matching for CC_Base and similar naming conventions
5471
+ const lowerNormalized = normalized.toLowerCase();
5472
+ const upperFirst = normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase();
5473
+
5474
+ // Pattern: R_Index1/2/3 or r_index1/2/3 -> RightHandIndex1/2/3
5475
+ const indexMatch = lowerNormalized.match(/^[rl]_index(\d+)$/);
5476
+ if (indexMatch) {
5477
+ const digit = indexMatch[1];
5478
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5479
+ const candidate = `${side}HandIndex${digit}`;
5480
+ if (availableBones.has(candidate)) {
5481
+ return candidate;
5482
+ }
5483
+ }
5484
+
5485
+ // Pattern: R_Pinky1/2/3 -> RightHandPinky1/2/3
5486
+ const pinkyMatch = lowerNormalized.match(/^[rl]_pinky(\d+)$/);
5487
+ if (pinkyMatch) {
5488
+ const digit = pinkyMatch[1];
5489
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5490
+ const candidate = `${side}HandPinky${digit}`;
5491
+ if (availableBones.has(candidate)) {
5492
+ return candidate;
5493
+ }
5494
+ }
5495
+
5496
+ // Pattern: R_Ring1/2/3 -> RightHandRing1/2/3
5497
+ const ringMatch = lowerNormalized.match(/^[rl]_ring(\d+)$/);
5498
+ if (ringMatch) {
5499
+ const digit = ringMatch[1];
5500
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5501
+ const candidate = `${side}HandRing${digit}`;
5502
+ if (availableBones.has(candidate)) {
5503
+ return candidate;
5504
+ }
5505
+ }
5506
+
5507
+ // Pattern: R_Middle1/2/3 -> RightHandMiddle1/2/3
5508
+ const middleMatch = lowerNormalized.match(/^[rl]_middle(\d+)$/);
5509
+ if (middleMatch) {
5510
+ const digit = middleMatch[1];
5511
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5512
+ const candidate = `${side}HandMiddle${digit}`;
5513
+ if (availableBones.has(candidate)) {
5514
+ return candidate;
5515
+ }
5516
+ }
5517
+
5518
+ // Pattern: R_Thumb1/2/3 -> RightHandThumb1/2/3
5519
+ const thumbMatch = lowerNormalized.match(/^[rl]_thumb(\d+)$/);
5520
+ if (thumbMatch) {
5521
+ const digit = thumbMatch[1];
5522
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5523
+ const candidate = `${side}HandThumb${digit}`;
5524
+ if (availableBones.has(candidate)) {
5525
+ return candidate;
5526
+ }
5527
+ }
5528
+
5529
+ // Pattern: R_Upperarm -> RightArm (case insensitive)
5530
+ if (lowerNormalized.match(/^[rl]_upperarm/)) {
5531
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5532
+ const candidate = `${side}Arm`;
5533
+ if (availableBones.has(candidate)) {
5534
+ return candidate;
5535
+ }
5536
+ }
5537
+
5538
+ // Pattern: R_UpperarmTwist01/02 -> ignore (twist bones)
5539
+ if (lowerNormalized.includes('upperarmtwist') || lowerNormalized.includes('forearmtwist')) {
5540
+ return null;
5541
+ }
5542
+
5543
+ // Pattern: R_Forearm -> RightForeArm
5544
+ if (lowerNormalized.match(/^[rl]_forearm/)) {
5545
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5546
+ const candidate = `${side}ForeArm`;
5547
+ if (availableBones.has(candidate)) {
5548
+ return candidate;
5549
+ }
5550
+ }
5551
+
5552
+ // Pattern: R_Hand -> RightHand
5553
+ if (lowerNormalized.match(/^[rl]_hand$/)) {
5554
+ const side = lowerNormalized.startsWith('r') ? 'Right' : 'Left';
5555
+ const candidate = `${side}Hand`;
5556
+ if (availableBones.has(candidate)) {
5557
+ return candidate;
5558
+ }
5559
+ }
5560
+
5561
+ // Try case-insensitive exact match
5562
+ for (const boneName of availableBones) {
5563
+ if (boneName.toLowerCase() === lowerNormalized) {
5564
+ return boneName;
5565
+ }
5566
+ }
5567
+
5568
+ return null; // No mapping found
5569
+ }
5570
+
5343
5571
  /**
5344
5572
  * Filter animation tracks to only include bones that exist in the avatar
5573
+ * Maps bone names from different naming conventions to avatar bone names
5345
5574
  * @param {THREE.AnimationClip} clip - Animation clip to filter
5346
5575
  * @param {Set<string>} availableBones - Set of available bone names
5347
- * @returns {THREE.AnimationClip} Filtered animation clip
5576
+ * @returns {THREE.AnimationClip} Filtered animation clip with mapped bone names
5348
5577
  */
5349
5578
  filterAnimationTracks(clip, availableBones) {
5350
5579
  const validTracks = [];
5351
5580
  const missingBones = new Set();
5581
+ const mappedBones = new Map(); // Track mappings for logging
5582
+
5583
+ // Debug: Log available bones (first time only)
5584
+ if (!this._loggedAvailableBones) {
5585
+ console.log('Available avatar bones:', Array.from(availableBones).sort().slice(0, 50).join(', '),
5586
+ availableBones.size > 50 ? `... (${availableBones.size} total)` : '');
5587
+ this._loggedAvailableBones = true;
5588
+ }
5352
5589
 
5353
5590
  clip.tracks.forEach(track => {
5354
5591
  // Extract bone name from track name (e.g., "CC_Base_R_Index3.position" -> "CC_Base_R_Index3")
5355
5592
  const trackNameParts = track.name.split('.');
5356
- const boneName = trackNameParts[0];
5593
+ const fbxBoneName = trackNameParts[0];
5594
+ const property = trackNameParts[1]; // position, quaternion, rotation, etc.
5595
+
5596
+ // Try to map the bone name
5597
+ const mappedBoneName = this.mapBoneName(fbxBoneName, availableBones);
5357
5598
 
5358
- if (availableBones.has(boneName)) {
5359
- validTracks.push(track);
5599
+ if (mappedBoneName) {
5600
+ // Create a new track with the mapped bone name
5601
+ const newTrackName = `${mappedBoneName}.${property}`;
5602
+
5603
+ // Clone the track and update its name
5604
+ const newTrack = track.clone();
5605
+ newTrack.name = newTrackName;
5606
+
5607
+ validTracks.push(newTrack);
5608
+
5609
+ // Track mappings for logging
5610
+ if (fbxBoneName !== mappedBoneName) {
5611
+ mappedBones.set(fbxBoneName, mappedBoneName);
5612
+ }
5360
5613
  } else {
5361
- missingBones.add(boneName);
5614
+ missingBones.add(fbxBoneName);
5362
5615
  }
5363
5616
  });
5364
5617
 
5618
+ // Log results
5619
+ if (mappedBones.size > 0) {
5620
+ console.info(`FBX animation "${clip.name}": Mapped ${mappedBones.size} bone(s) to avatar skeleton:`,
5621
+ Array.from(mappedBones.entries()).slice(0, 5).map(([from, to]) => `${from} → ${to}`).join(', '),
5622
+ mappedBones.size > 5 ? '...' : '');
5623
+ }
5624
+
5365
5625
  if (missingBones.size > 0) {
5366
- console.warn(`FBX animation "${clip.name}" contains tracks for ${missingBones.size} bone(s) not found in avatar skeleton:`, Array.from(missingBones).slice(0, 10).join(', '), missingBones.size > 10 ? '...' : '');
5367
- console.info(`Filtered ${clip.tracks.length} tracks down to ${validTracks.length} valid tracks`);
5368
- } else if (validTracks.length > 0) {
5369
- console.info(`FBX animation "${clip.name}" is fully compatible: all ${validTracks.length} tracks match avatar skeleton`);
5626
+ console.warn(`FBX animation "${clip.name}" contains tracks for ${missingBones.size} bone(s) that couldn't be mapped:`,
5627
+ Array.from(missingBones).slice(0, 10).join(', '),
5628
+ missingBones.size > 10 ? '...' : '');
5629
+ }
5630
+
5631
+ if (validTracks.length > 0) {
5632
+ console.info(`Filtered ${clip.tracks.length} tracks down to ${validTracks.length} valid tracks (${mappedBones.size} mapped)`);
5633
+ } else {
5634
+ console.error(`No valid tracks found for animation "${clip.name}". All bones are missing or couldn't be mapped.`);
5370
5635
  }
5371
5636
 
5372
5637
  // Create a new clip with only valid tracks
5373
5638
  if (validTracks.length === 0) {
5374
- console.error(`No valid tracks found for animation "${clip.name}". All bones are missing from avatar skeleton.`);
5375
5639
  return null;
5376
5640
  }
5377
5641