@sage-rsc/talking-head-react 1.8.0 → 1.8.2

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.8.0",
3
+ "version": "1.8.2",
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",
@@ -398,7 +398,8 @@ const SimpleTalkingAvatar = forwardRef(({
398
398
  }
399
399
  }, []);
400
400
 
401
- // Helper function to get all animations from a group (with gender support)
401
+ // Helper function to get all animations from a group (with unified gender support)
402
+ // This ensures both male and female avatars use the same fallback logic
402
403
  const getAllAnimationsFromGroup = useCallback((groupName) => {
403
404
  if (!loadedAnimations) {
404
405
  return [];
@@ -406,33 +407,47 @@ const SimpleTalkingAvatar = forwardRef(({
406
407
 
407
408
  let group = null;
408
409
 
409
- // Check if gender-specific animations are available
410
+ // Unified fallback logic for both genders:
411
+ // 1. Try gender-specific animations (male/talking or female/talking)
412
+ // 2. Try shared gender-specific animations (shared/talking)
413
+ // 3. Try root-level animations (talking)
414
+ // This ensures both genders get the same treatment
415
+
410
416
  if (loadedAnimations._genderSpecific) {
411
417
  const genderKey = getGenderKey(avatarBody);
412
418
 
419
+ // Try gender-specific first (e.g., male/talking or female/talking)
413
420
  const genderGroups = loadedAnimations._genderSpecific[genderKey];
414
-
415
- // Try gender-specific first
416
- if (genderGroups && genderGroups[groupName]) {
421
+ if (genderGroups && genderGroups[groupName] && Array.isArray(genderGroups[groupName]) && genderGroups[groupName].length > 0) {
417
422
  group = genderGroups[groupName];
418
- }
419
- // Fallback to shared gender-specific animations
420
- else if (loadedAnimations._genderSpecific.shared && loadedAnimations._genderSpecific.shared[groupName]) {
421
- group = loadedAnimations._genderSpecific.shared[groupName];
423
+ }
424
+
425
+ // Fallback to shared gender-specific animations (shared/talking)
426
+ if (!group && loadedAnimations._genderSpecific.shared && loadedAnimations._genderSpecific.shared[groupName]) {
427
+ const sharedGroup = loadedAnimations._genderSpecific.shared[groupName];
428
+ if (Array.isArray(sharedGroup) && sharedGroup.length > 0) {
429
+ group = sharedGroup;
430
+ }
422
431
  }
423
432
  }
424
433
 
425
434
  // Fallback to root-level animations if gender-specific not found
435
+ // This ensures both genders can use root-level animations
426
436
  if (!group && loadedAnimations[groupName]) {
427
- group = loadedAnimations[groupName];
437
+ const rootGroup = loadedAnimations[groupName];
438
+ if (Array.isArray(rootGroup) && rootGroup.length > 0) {
439
+ group = rootGroup;
440
+ } else if (typeof rootGroup === 'string') {
441
+ group = [rootGroup];
442
+ }
428
443
  }
429
444
 
430
- if (!group) {
445
+ if (!group || (Array.isArray(group) && group.length === 0)) {
431
446
  // Only log warning if animations were actually configured (not just empty object)
432
447
  const hasAnyAnimations = Object.keys(loadedAnimations).length > 0 ||
433
448
  (loadedAnimations._genderSpecific && Object.keys(loadedAnimations._genderSpecific).length > 0);
434
449
  if (hasAnyAnimations) {
435
- console.warn(`⚠️ No animations found for group "${groupName}". Make sure animations are configured correctly.`);
450
+ console.warn(`⚠️ No animations found for group "${groupName}" (gender: ${getGenderKey(avatarBody)}). Make sure animations are configured correctly.`);
436
451
  }
437
452
  return [];
438
453
  }
@@ -1004,6 +1019,10 @@ const SimpleTalkingAvatar = forwardRef(({
1004
1019
  // IMPORTANT: Ensure isSpeaking is false to prevent startSpeaking() from continuing
1005
1020
  talkingHeadRef.current.isSpeaking = false;
1006
1021
 
1022
+ // CRITICAL: Stop animations when pausing
1023
+ isSpeakingRef.current = false;
1024
+ currentAnimationGroupRef.current = null;
1025
+
1007
1026
  // If we have trimmed buffer, store speech queue to restore on resume
1008
1027
  // This way speech won't continue automatically, only when resumed
1009
1028
  if (pausedAudioData && pausedAudioData.audio && savedSpeechQueue) {
@@ -3171,6 +3171,27 @@ class TalkingHead {
3171
3171
  this.updatePoseBase(this.animClock);
3172
3172
  if ( this.mixer ) {
3173
3173
  this.mixer.update(dt / 1000 * this.mixer.timeScale);
3174
+
3175
+ // Manually check if current FBX animation has finished (backup to mixer 'finished' event)
3176
+ // This ensures the callback fires reliably even if the mixer event doesn't
3177
+ if (this.currentFBXActionForCallback && this.currentFBXActionForCallback.isRunning() && this.currentFBXActionClipDuration) {
3178
+ const actionTime = this.currentFBXActionForCallback.time;
3179
+ const clipDuration = this.currentFBXActionClipDuration;
3180
+
3181
+ // For LoopOnce, action.time goes from 0 to clipDuration
3182
+ // Check if animation has reached the end (with small tolerance for timing)
3183
+ // Also check if action is paused (time doesn't advance when paused)
3184
+ if (actionTime >= clipDuration - 0.05 || (actionTime > 0 && !this.currentFBXActionForCallback.paused && actionTime >= clipDuration - 0.1)) {
3185
+ // Animation finished - call callback manually
3186
+ if (this.currentFBXActionCallback) {
3187
+ this.currentFBXActionCallback();
3188
+ this.currentFBXActionCallback = null;
3189
+ }
3190
+ this.currentFBXActionForCallback = null;
3191
+ this.currentFBXActionStartTime = null;
3192
+ this.currentFBXActionClipDuration = null;
3193
+ }
3194
+ }
3174
3195
  }
3175
3196
  this.updatePoseDelta();
3176
3197
 
@@ -5556,8 +5577,6 @@ class TalkingHead {
5556
5577
  this.stopAnimation();
5557
5578
  };
5558
5579
 
5559
- this.mixer.addEventListener( 'finished', finishedHandler, { once: true });
5560
-
5561
5580
  // Play action with error handling
5562
5581
  // If dur is 0 or negative, play animation once (use clip's natural duration)
5563
5582
  const action = this.mixer.clipAction(item.clip);
@@ -5571,12 +5590,29 @@ class TalkingHead {
5571
5590
  }
5572
5591
  action.clampWhenFinished = true;
5573
5592
 
5593
+ // Set up callback to check when animation finishes
5594
+ // Use both mixer 'finished' event and manual time checking for reliability
5595
+ this.mixer.addEventListener( 'finished', finishedHandler, { once: true });
5596
+
5597
+ // Also track the action and check manually in animate loop for reliability
5598
+ // Store action reference for manual checking
5599
+ this.currentFBXActionForCallback = action;
5600
+ this.currentFBXActionCallback = finishedHandler;
5601
+ this.currentFBXActionClipDuration = item.clip.duration;
5602
+ this.currentFBXActionStartTime = null; // Will be set when action starts playing
5603
+
5574
5604
  // Fade out previous action smoothly if one exists and is running
5575
5605
  if (this.currentFBXAction && this.currentFBXAction.isRunning()) {
5576
5606
  this.currentFBXAction.fadeOut(0.3);
5577
5607
  // Small delay for smooth transition
5578
5608
  setTimeout(() => {
5579
5609
  this.currentFBXAction = action;
5610
+ // IMPORTANT: Update callback tracking variables inside setTimeout
5611
+ // This ensures manual checking works correctly for fade transitions
5612
+ this.currentFBXActionForCallback = action;
5613
+ this.currentFBXActionCallback = finishedHandler;
5614
+ this.currentFBXActionClipDuration = item.clip.duration;
5615
+ this.currentFBXActionStartTime = this.mixer.time; // Track when animation starts
5580
5616
  try {
5581
5617
  action.fadeIn(0.5).play();
5582
5618
  console.log('FBX animation started successfully (with fade transition):', url);
@@ -5590,6 +5626,7 @@ class TalkingHead {
5590
5626
 
5591
5627
  // Store the current FBX action for proper cleanup
5592
5628
  this.currentFBXAction = action;
5629
+ this.currentFBXActionStartTime = this.mixer.time; // Track when animation starts
5593
5630
 
5594
5631
  try {
5595
5632
  action.fadeIn(0.5).play();
@@ -5973,6 +6010,12 @@ class TalkingHead {
5973
6010
  this.currentFBXAction = null;
5974
6011
  }
5975
6012
 
6013
+ // Clear callback tracking
6014
+ this.currentFBXActionForCallback = null;
6015
+ this.currentFBXActionCallback = null;
6016
+ this.currentFBXActionStartTime = null;
6017
+ this.currentFBXActionClipDuration = null;
6018
+
5976
6019
  // Only destroy mixer if no other animations are running
5977
6020
  // This allows morph target animations (lip-sync) to continue
5978
6021
  if (this.mixer && this.mixer._actions.length === 0) {