@lovelace_lol/loom3 1.0.42 → 1.0.44

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 CHANGED
@@ -74,6 +74,9 @@ function bindingTargets(binding) {
74
74
  if (targets && targets.length > 0) return targets;
75
75
  return binding.morph !== void 0 && binding.morph !== "" ? [binding.morph] : [];
76
76
  }
77
+ function normalizeBindingWeight(weight) {
78
+ return Number.isFinite(weight) ? Math.max(0, weight ?? 1) : 1;
79
+ }
77
80
  function getProfileVisemeSlots(profile) {
78
81
  if (profile.visemeSlots && profile.visemeSlots.length > 0) {
79
82
  return [...profile.visemeSlots].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
@@ -99,6 +102,23 @@ function compileVisemeKeys(profile) {
99
102
  return target ?? profile.visemeKeys?.[index] ?? "";
100
103
  });
101
104
  }
105
+ function getVisemeBindingTargets(profile, visemeIndex) {
106
+ const slots = getProfileVisemeSlots(profile);
107
+ const slot = slots[visemeIndex];
108
+ const binding = slot ? profile.visemeBindings?.[slot.id] : void 0;
109
+ const boundTargets = binding?.targets?.filter((target) => target.morph !== void 0 && target.morph !== "").map((target) => ({
110
+ morph: target.morph,
111
+ weight: normalizeBindingWeight(target.weight)
112
+ }));
113
+ if (boundTargets && boundTargets.length > 0) {
114
+ return boundTargets;
115
+ }
116
+ if (binding?.morph !== void 0 && binding.morph !== "") {
117
+ return [{ morph: binding.morph, weight: 1 }];
118
+ }
119
+ const legacyTarget = profile.visemeKeys?.[visemeIndex];
120
+ return legacyTarget !== void 0 && legacyTarget !== "" ? [{ morph: legacyTarget, weight: 1 }] : [];
121
+ }
102
122
  function getVisemeJawAmounts(profile) {
103
123
  const slots = getProfileVisemeSlots(profile);
104
124
  if (slots.length === 0) return profile.visemeJawAmounts ? [...profile.visemeJawAmounts] : void 0;
@@ -510,14 +530,19 @@ var Y_AXIS = new THREE2.Vector3(0, 1, 0);
510
530
  var Z_AXIS = new THREE2.Vector3(0, 0, 1);
511
531
  var CLIP_EVENT_METADATA_KEY = "__loom3ClipEvents";
512
532
  var CLIP_EVENT_EPSILON = 1e-4;
533
+ var ADDITIVE_BAKED_RUNTIME_CLIP_SUFFIX = "__loom3_additive_delta";
513
534
  var BakedAnimationController = class {
514
535
  constructor(host) {
515
536
  __publicField(this, "host");
537
+ // Clip-backed snippets need a later mixer pass so they can override baked additive tracks.
516
538
  __publicField(this, "animationMixer", null);
539
+ __publicField(this, "clipAnimationMixer", null);
517
540
  __publicField(this, "mixerFinishedListenerAttached", false);
541
+ __publicField(this, "clipMixerFinishedListenerAttached", false);
518
542
  __publicField(this, "animationClips", []);
519
543
  __publicField(this, "bakedSourceClips", /* @__PURE__ */ new Map());
520
544
  __publicField(this, "bakedRuntimeActions", /* @__PURE__ */ new Map());
545
+ __publicField(this, "bakedAdditiveRuntimeClips", /* @__PURE__ */ new Map());
521
546
  __publicField(this, "bakedActionGroups", /* @__PURE__ */ new Map());
522
547
  __publicField(this, "bakedRuntimeClipToSource", /* @__PURE__ */ new Map());
523
548
  __publicField(this, "animationActions", /* @__PURE__ */ new Map());
@@ -542,6 +567,173 @@ var BakedAnimationController = class {
542
567
  action.__actionId = actionId;
543
568
  return actionId;
544
569
  }
570
+ clearActionId(action) {
571
+ if (!action) return;
572
+ const actionId = this.getActionId(action);
573
+ if (actionId) {
574
+ this.actionIdToClip.delete(actionId);
575
+ }
576
+ this.actionIds.delete(action);
577
+ delete action.__actionId;
578
+ }
579
+ uncacheClip(clip, mixer = this.animationMixer) {
580
+ if (!clip || !mixer) return;
581
+ try {
582
+ mixer.uncacheClip(clip);
583
+ } catch {
584
+ }
585
+ }
586
+ uncacheAction(action, mixer = this.animationMixer) {
587
+ if (!action || !mixer) return;
588
+ try {
589
+ const clip = action.getClip();
590
+ if (clip) {
591
+ mixer.uncacheAction(clip);
592
+ mixer.uncacheClip(clip);
593
+ }
594
+ } catch {
595
+ }
596
+ }
597
+ releaseBakedRuntimeAction(runtimeClipName) {
598
+ const action = this.bakedRuntimeActions.get(runtimeClipName);
599
+ if (!action) return;
600
+ try {
601
+ action.stop();
602
+ } catch {
603
+ }
604
+ this.uncacheAction(action);
605
+ this.clearActionId(action);
606
+ this.bakedRuntimeActions.delete(runtimeClipName);
607
+ }
608
+ clearBakedAdditiveRuntimeClip(runtimeClipName) {
609
+ const clip = this.bakedAdditiveRuntimeClips.get(runtimeClipName);
610
+ if (!clip) return;
611
+ this.uncacheClip(clip);
612
+ this.bakedAdditiveRuntimeClips.delete(runtimeClipName);
613
+ }
614
+ clearAllBakedAdditiveRuntimeClips() {
615
+ for (const runtimeClipName of Array.from(this.bakedAdditiveRuntimeClips.keys())) {
616
+ this.clearBakedAdditiveRuntimeClip(runtimeClipName);
617
+ }
618
+ }
619
+ resolveTrackTarget(model, parsed) {
620
+ const targetKey = parsed.objectName === "bones" && parsed.objectIndex ? parsed.objectIndex : parsed.nodeName;
621
+ if (!targetKey) {
622
+ return null;
623
+ }
624
+ return model.getObjectByProperty("uuid", targetKey) ?? THREE2.PropertyBinding.findNode(model, targetKey) ?? null;
625
+ }
626
+ getMorphTrackBaseValue(target, propertyIndex) {
627
+ if (!target) {
628
+ return 0;
629
+ }
630
+ const meshTarget = target;
631
+ const influences = meshTarget.morphTargetInfluences;
632
+ if (!influences) {
633
+ return 0;
634
+ }
635
+ let morphIndex;
636
+ if (typeof propertyIndex === "number" && Number.isInteger(propertyIndex)) {
637
+ morphIndex = propertyIndex;
638
+ } else if (typeof propertyIndex === "string") {
639
+ if (/^\d+$/.test(propertyIndex)) {
640
+ morphIndex = Number(propertyIndex);
641
+ } else {
642
+ morphIndex = meshTarget.morphTargetDictionary?.[propertyIndex];
643
+ }
644
+ }
645
+ if (morphIndex === void 0) {
646
+ return 0;
647
+ }
648
+ return influences[morphIndex] ?? 0;
649
+ }
650
+ canCreateFirstFrameReferenceTrack(track) {
651
+ const valueSize = track.getValueSize();
652
+ if (!Number.isFinite(valueSize) || valueSize <= 0 || track.values.length < valueSize) {
653
+ return false;
654
+ }
655
+ return track.ValueTypeName === "number" || track.ValueTypeName === "quaternion" || track.ValueTypeName === "vector";
656
+ }
657
+ createFirstFrameReferenceTrack(track) {
658
+ const valueSize = track.getValueSize();
659
+ if (!this.canCreateFirstFrameReferenceTrack(track)) {
660
+ return null;
661
+ }
662
+ const values = Array.from(track.values.slice(0, valueSize));
663
+ if (track.ValueTypeName === "number") {
664
+ return new THREE2.NumberKeyframeTrack(track.name, [0], values);
665
+ }
666
+ if (track.ValueTypeName === "quaternion") {
667
+ return new THREE2.QuaternionKeyframeTrack(track.name, [0], values);
668
+ }
669
+ if (track.ValueTypeName === "vector") {
670
+ return new THREE2.VectorKeyframeTrack(track.name, [0], values);
671
+ }
672
+ return null;
673
+ }
674
+ createAdditiveReferenceTrack(track, model) {
675
+ const trackName = typeof track?.name === "string" ? track.name : "";
676
+ if (!trackName) {
677
+ return null;
678
+ }
679
+ let parsed;
680
+ try {
681
+ parsed = THREE2.PropertyBinding.parseTrackName(trackName);
682
+ } catch {
683
+ return null;
684
+ }
685
+ const target = this.resolveTrackTarget(model, parsed);
686
+ if (parsed.propertyName === "morphTargetInfluences") {
687
+ return new THREE2.NumberKeyframeTrack(
688
+ track.name,
689
+ [0],
690
+ [this.getMorphTrackBaseValue(target, parsed.propertyIndex)]
691
+ );
692
+ }
693
+ return this.createFirstFrameReferenceTrack(track);
694
+ }
695
+ createAdditiveRuntimeClip(runtimeClip) {
696
+ const model = this.host.getModel();
697
+ if (!model) {
698
+ return null;
699
+ }
700
+ const additiveTracks = [];
701
+ const referenceTracks = [];
702
+ for (const track of runtimeClip.tracks) {
703
+ const referenceTrack = this.createAdditiveReferenceTrack(track, model);
704
+ if (!referenceTrack) {
705
+ continue;
706
+ }
707
+ additiveTracks.push(track.clone());
708
+ referenceTracks.push(referenceTrack);
709
+ }
710
+ const additiveClip = new THREE2.AnimationClip(
711
+ `${runtimeClip.name}${ADDITIVE_BAKED_RUNTIME_CLIP_SUFFIX}`,
712
+ runtimeClip.duration,
713
+ additiveTracks
714
+ );
715
+ if (additiveTracks.length > 0) {
716
+ const referenceClip = new THREE2.AnimationClip(
717
+ `${runtimeClip.name}${ADDITIVE_BAKED_RUNTIME_CLIP_SUFFIX}_reference`,
718
+ 0,
719
+ referenceTracks
720
+ );
721
+ THREE2.AnimationUtils.makeClipAdditive(additiveClip, 0, referenceClip);
722
+ }
723
+ return additiveClip;
724
+ }
725
+ getOrCreateBakedAdditiveRuntimeClip(runtimeClip) {
726
+ const cached = this.bakedAdditiveRuntimeClips.get(runtimeClip.name);
727
+ if (cached) {
728
+ return cached;
729
+ }
730
+ const additiveClip = this.createAdditiveRuntimeClip(runtimeClip);
731
+ if (!additiveClip) {
732
+ return null;
733
+ }
734
+ this.bakedAdditiveRuntimeClips.set(runtimeClip.name, additiveClip);
735
+ return additiveClip;
736
+ }
545
737
  setClipEventMetadata(clip, metadata) {
546
738
  const userData = clip.userData ?? (clip.userData = {});
547
739
  userData[CLIP_EVENT_METADATA_KEY] = metadata;
@@ -827,21 +1019,28 @@ var BakedAnimationController = class {
827
1019
  }
828
1020
  return 0;
829
1021
  }
830
- getOrCreateBakedRuntimeAction(sourceClipName, channel) {
1022
+ getOrCreateBakedRuntimeAction(sourceClipName, channel, blendMode = "replace") {
831
1023
  const bakedClip = this.getBakedSourceClip(sourceClipName);
832
1024
  const runtimeClip = bakedClip?.runtimeClips.find((entry) => entry.channel === channel)?.clip;
833
1025
  if (!runtimeClip) {
834
1026
  return null;
835
1027
  }
1028
+ const desiredClip = blendMode === "additive" ? this.getOrCreateBakedAdditiveRuntimeClip(runtimeClip) : runtimeClip;
1029
+ if (!desiredClip) {
1030
+ return null;
1031
+ }
836
1032
  const existing = this.bakedRuntimeActions.get(runtimeClip.name);
837
- if (existing) {
1033
+ if (existing?.getClip() === desiredClip) {
838
1034
  return existing;
839
1035
  }
840
1036
  this.ensureMixer();
841
1037
  if (!this.animationMixer) {
842
1038
  return null;
843
1039
  }
844
- const action = this.animationMixer.clipAction(runtimeClip);
1040
+ if (existing) {
1041
+ this.releaseBakedRuntimeAction(runtimeClip.name);
1042
+ }
1043
+ const action = this.animationMixer.clipAction(desiredClip);
845
1044
  this.bakedRuntimeActions.set(runtimeClip.name, action);
846
1045
  return action;
847
1046
  }
@@ -859,7 +1058,15 @@ var BakedAnimationController = class {
859
1058
  }
860
1059
  const channelActions = /* @__PURE__ */ new Map();
861
1060
  for (const runtimeClip of bakedClip.runtimeClips) {
862
- const action = this.getOrCreateBakedRuntimeAction(clipName, runtimeClip.channel);
1061
+ const channelBlendMode = resolveBakedChannelBlendMode(
1062
+ runtimeClip.channel,
1063
+ playbackState.requestedBlendMode
1064
+ ) ?? "replace";
1065
+ const action = this.getOrCreateBakedRuntimeAction(
1066
+ clipName,
1067
+ runtimeClip.channel,
1068
+ channelBlendMode
1069
+ );
863
1070
  if (action) {
864
1071
  channelActions.set(runtimeClip.channel, action);
865
1072
  }
@@ -898,13 +1105,39 @@ var BakedAnimationController = class {
898
1105
  }
899
1106
  return getMeshNamesForVisemeProfile(config);
900
1107
  }
1108
+ hasActiveAdditivePlayback() {
1109
+ for (const [clipName, group] of this.bakedActionGroups) {
1110
+ const state = this.playbackState.get(clipName);
1111
+ if (state?.blendMode !== "additive") {
1112
+ continue;
1113
+ }
1114
+ for (const action of group.channelActions.values()) {
1115
+ if (action.isRunning() && !action.paused) {
1116
+ return true;
1117
+ }
1118
+ }
1119
+ }
1120
+ for (const [clipName, action] of this.animationActions) {
1121
+ const state = this.playbackState.get(clipName);
1122
+ if (state?.blendMode !== "additive") {
1123
+ continue;
1124
+ }
1125
+ if (action.isRunning() && !action.paused) {
1126
+ return true;
1127
+ }
1128
+ }
1129
+ return false;
1130
+ }
901
1131
  update(dtSeconds) {
902
1132
  if (this.animationMixer) {
1133
+ this.animationMixer.update(dtSeconds);
1134
+ }
1135
+ if (this.clipAnimationMixer) {
903
1136
  const snapshots = Array.from(this.clipMonitors.values()).map((monitor) => ({
904
1137
  actionId: monitor.actionId,
905
1138
  previousTime: monitor.action.time
906
1139
  }));
907
- this.animationMixer.update(dtSeconds);
1140
+ this.clipAnimationMixer.update(dtSeconds);
908
1141
  for (const { actionId, previousTime } of snapshots) {
909
1142
  const monitor = this.clipMonitors.get(actionId);
910
1143
  if (!monitor) continue;
@@ -923,16 +1156,27 @@ var BakedAnimationController = class {
923
1156
  }
924
1157
  }
925
1158
  }
1159
+ if (this.hasActiveAdditivePlayback()) {
1160
+ this.host.reapplyProceduralState?.();
1161
+ }
926
1162
  }
927
1163
  dispose() {
928
1164
  this.stopAllAnimations();
1165
+ this.clearAllBakedAdditiveRuntimeClips();
929
1166
  if (this.animationMixer) {
930
1167
  this.animationMixer.stopAllAction();
931
1168
  this.animationMixer = null;
932
1169
  }
1170
+ if (this.clipAnimationMixer) {
1171
+ this.clipAnimationMixer.stopAllAction();
1172
+ this.clipAnimationMixer = null;
1173
+ }
1174
+ this.mixerFinishedListenerAttached = false;
1175
+ this.clipMixerFinishedListenerAttached = false;
933
1176
  this.animationClips = [];
934
1177
  this.bakedSourceClips.clear();
935
1178
  this.bakedRuntimeActions.clear();
1179
+ this.bakedAdditiveRuntimeClips.clear();
936
1180
  this.bakedActionGroups.clear();
937
1181
  this.bakedRuntimeClipToSource.clear();
938
1182
  this.animationActions.clear();
@@ -955,6 +1199,8 @@ var BakedAnimationController = class {
955
1199
  if (this.animationMixer) {
956
1200
  for (const bakedClip of this.bakedSourceClips.values()) {
957
1201
  for (const runtimeClip of bakedClip.runtimeClips) {
1202
+ this.releaseBakedRuntimeAction(runtimeClip.clip.name);
1203
+ this.clearBakedAdditiveRuntimeClip(runtimeClip.clip.name);
958
1204
  try {
959
1205
  this.animationMixer.uncacheAction(runtimeClip.clip);
960
1206
  } catch {
@@ -966,6 +1212,7 @@ var BakedAnimationController = class {
966
1212
  }
967
1213
  }
968
1214
  }
1215
+ this.clearAllBakedAdditiveRuntimeClips();
969
1216
  for (const clipName of this.bakedSourceClips.keys()) {
970
1217
  this.playbackState.delete(clipName);
971
1218
  this.clipSources.delete(clipName);
@@ -1006,6 +1253,8 @@ var BakedAnimationController = class {
1006
1253
  if (this.animationMixer) {
1007
1254
  for (const runtimeClip of bakedClip.runtimeClips) {
1008
1255
  const action = this.bakedRuntimeActions.get(runtimeClip.clip.name);
1256
+ this.releaseBakedRuntimeAction(runtimeClip.clip.name);
1257
+ this.clearBakedAdditiveRuntimeClip(runtimeClip.clip.name);
1009
1258
  try {
1010
1259
  this.animationMixer.uncacheAction(runtimeClip.clip);
1011
1260
  } catch {
@@ -1014,7 +1263,6 @@ var BakedAnimationController = class {
1014
1263
  this.animationMixer.uncacheClip(runtimeClip.clip);
1015
1264
  } catch {
1016
1265
  }
1017
- this.bakedRuntimeActions.delete(runtimeClip.clip.name);
1018
1266
  this.bakedRuntimeClipToSource.delete(runtimeClip.clip.name);
1019
1267
  const actionId = this.getActionId(action);
1020
1268
  if (actionId && action) {
@@ -1023,6 +1271,10 @@ var BakedAnimationController = class {
1023
1271
  }
1024
1272
  }
1025
1273
  }
1274
+ for (const runtimeClip of bakedClip.runtimeClips) {
1275
+ this.clearBakedAdditiveRuntimeClip(runtimeClip.clip.name);
1276
+ this.bakedRuntimeActions.delete(runtimeClip.clip.name);
1277
+ }
1026
1278
  this.animationClips = this.animationClips.filter((entry) => entry.name !== clipName);
1027
1279
  this.bakedSourceClips.delete(clipName);
1028
1280
  this.bakedActionGroups.delete(clipName);
@@ -1083,12 +1335,12 @@ var BakedAnimationController = class {
1083
1335
  const actionId = this.getActionId(action);
1084
1336
  const isBaked = (this.clipSources.get(clipName) ?? "baked") === "baked";
1085
1337
  action.stop();
1086
- if (!isBaked && this.animationMixer) {
1338
+ if (!isBaked && this.clipAnimationMixer) {
1087
1339
  try {
1088
1340
  const clip = action.getClip();
1089
1341
  if (clip) {
1090
- this.animationMixer.uncacheAction(clip);
1091
- this.animationMixer.uncacheClip(clip);
1342
+ this.clipAnimationMixer.uncacheAction(clip);
1343
+ this.clipAnimationMixer.uncacheClip(clip);
1092
1344
  }
1093
1345
  } catch {
1094
1346
  }
@@ -1110,11 +1362,11 @@ var BakedAnimationController = class {
1110
1362
  const actionId = this.getActionId(clipAction);
1111
1363
  try {
1112
1364
  clipAction.stop();
1113
- if (this.animationMixer) {
1365
+ if (this.clipAnimationMixer) {
1114
1366
  const clip = clipAction.getClip();
1115
1367
  if (clip) {
1116
- this.animationMixer.uncacheAction(clip);
1117
- this.animationMixer.uncacheClip(clip);
1368
+ this.clipAnimationMixer.uncacheAction(clip);
1369
+ this.clipAnimationMixer.uncacheClip(clip);
1118
1370
  }
1119
1371
  }
1120
1372
  } catch {
@@ -1318,8 +1570,23 @@ var BakedAnimationController = class {
1318
1570
  next.blendMode = this.getBakedAggregateBlendMode(clipName, next);
1319
1571
  const bakedGroup = this.bakedActionGroups.get(clipName);
1320
1572
  if (bakedGroup) {
1321
- for (const [channel, action2] of bakedGroup.channelActions) {
1573
+ for (const [channel, currentAction] of Array.from(bakedGroup.channelActions)) {
1574
+ const channelBlendMode = resolveBakedChannelBlendMode(channel, next.requestedBlendMode) ?? "replace";
1575
+ const previousTime = currentAction.time;
1576
+ const wasActive = currentAction.isRunning() || currentAction.paused;
1577
+ const wasPaused = currentAction.paused;
1578
+ const action2 = this.getOrCreateBakedRuntimeAction(clipName, channel, channelBlendMode);
1579
+ if (!action2) {
1580
+ bakedGroup.channelActions.delete(channel);
1581
+ continue;
1582
+ }
1322
1583
  this.applyPlaybackStateToBakedAction(action2, next, channel);
1584
+ action2.time = Math.max(0, Math.min(action2.getClip().duration, previousTime));
1585
+ if (action2 !== currentAction && wasActive) {
1586
+ action2.play();
1587
+ }
1588
+ action2.paused = wasPaused;
1589
+ bakedGroup.channelActions.set(channel, action2);
1323
1590
  }
1324
1591
  }
1325
1592
  this.setPlaybackState(clipName, next);
@@ -1334,6 +1601,10 @@ var BakedAnimationController = class {
1334
1601
  seekAnimation(clipName, time) {
1335
1602
  const bakedGroup = this.bakedActionGroups.get(clipName);
1336
1603
  if (bakedGroup) {
1604
+ const state = this.getPlaybackStateSnapshot(clipName, {
1605
+ loop: true,
1606
+ source: this.clipSources.get(clipName) ?? "baked"
1607
+ });
1337
1608
  const duration2 = this.getBakedSourceClip(clipName)?.sourceClip.duration ?? 0;
1338
1609
  const clamped = Math.max(0, Math.min(duration2, Number.isFinite(time) ? time : 0));
1339
1610
  for (const action2 of bakedGroup.channelActions.values()) {
@@ -1341,6 +1612,10 @@ var BakedAnimationController = class {
1341
1612
  }
1342
1613
  try {
1343
1614
  this.animationMixer?.update(0);
1615
+ this.clipAnimationMixer?.update(0);
1616
+ if (state.blendMode === "additive") {
1617
+ this.host.reapplyProceduralState?.();
1618
+ }
1344
1619
  } catch {
1345
1620
  }
1346
1621
  return;
@@ -1350,7 +1625,11 @@ var BakedAnimationController = class {
1350
1625
  const duration = action.getClip().duration;
1351
1626
  action.time = Math.max(0, Math.min(duration, Number.isFinite(time) ? time : 0));
1352
1627
  try {
1353
- this.animationMixer?.update(0);
1628
+ this.clipAnimationMixer?.update(0);
1629
+ const state = this.playbackState.get(clipName);
1630
+ if (state?.blendMode === "additive") {
1631
+ this.host.reapplyProceduralState?.();
1632
+ }
1354
1633
  } catch {
1355
1634
  }
1356
1635
  }
@@ -1358,6 +1637,9 @@ var BakedAnimationController = class {
1358
1637
  if (this.animationMixer) {
1359
1638
  this.animationMixer.timeScale = timeScale;
1360
1639
  }
1640
+ if (this.clipAnimationMixer) {
1641
+ this.clipAnimationMixer.timeScale = timeScale;
1642
+ }
1361
1643
  }
1362
1644
  getAnimationState(clipName) {
1363
1645
  const bakedClip = this.getBakedSourceClip(clipName);
@@ -1473,12 +1755,13 @@ var BakedAnimationController = class {
1473
1755
  const globalBalance = options?.balance ?? 0;
1474
1756
  const balanceMap = options?.balanceMap;
1475
1757
  const meshNames = options?.meshNames;
1758
+ const visemeSlotCount = getProfileVisemeSlots(config).length;
1476
1759
  let maxTime = 0;
1477
1760
  const isNumericAU = (id) => /^\d+$/.test(id);
1478
1761
  const isVisemeIndex = (id) => {
1479
1762
  if (options?.snippetCategory !== "visemeSnippet") return false;
1480
1763
  const num = Number(id);
1481
- return !Number.isNaN(num) && num >= 0 && num < config.visemeKeys.length;
1764
+ return !Number.isNaN(num) && num >= 0 && num < visemeSlotCount;
1482
1765
  };
1483
1766
  const sampleAt = (arr, t) => {
1484
1767
  if (!arr.length) return 0;
@@ -1516,11 +1799,13 @@ var BakedAnimationController = class {
1516
1799
  const auId = Number(curveId);
1517
1800
  if (isVisemeIndex(curveId)) {
1518
1801
  const visemeMeshNames = this.getMeshNamesForViseme(config, meshNames);
1519
- const visemeKey = config.visemeKeys[auId];
1520
- if (typeof visemeKey === "number") {
1521
- this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
1522
- } else if (visemeKey) {
1523
- this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
1802
+ for (const target of getVisemeBindingTargets(config, auId)) {
1803
+ const effectiveScale = intensityScale * target.weight;
1804
+ if (typeof target.morph === "number") {
1805
+ this.addMorphIndexTracks(tracks, target.morph, keyframes, effectiveScale, visemeMeshNames);
1806
+ } else if (target.morph) {
1807
+ this.addMorphTracks(tracks, target.morph, keyframes, effectiveScale, visemeMeshNames);
1808
+ }
1524
1809
  }
1525
1810
  } else {
1526
1811
  const auMeshNames = this.getMeshNamesForAU(auId, config, meshNames);
@@ -1563,7 +1848,7 @@ var BakedAnimationController = class {
1563
1848
  }
1564
1849
  const autoVisemeJaw = options?.autoVisemeJaw !== false;
1565
1850
  const jawScale = options?.jawScale ?? 1;
1566
- const visemeJawAmounts = config.visemeJawAmounts;
1851
+ const visemeJawAmounts = getVisemeJawAmounts(config);
1567
1852
  if (autoVisemeJaw && jawScale > 0 && visemeJawAmounts && options?.snippetCategory === "visemeSnippet" && keyframeTimes.length > 0) {
1568
1853
  const bones = this.host.getBones();
1569
1854
  const jawEntry = bones["JAW"];
@@ -1571,7 +1856,7 @@ var BakedAnimationController = class {
1571
1856
  const jawValues = [];
1572
1857
  for (const t of keyframeTimes) {
1573
1858
  let jawAmount = 0;
1574
- for (let visemeIdx = 0; visemeIdx < config.visemeKeys.length; visemeIdx++) {
1859
+ for (let visemeIdx = 0; visemeIdx < visemeSlotCount; visemeIdx++) {
1575
1860
  const visemeCurve = curves[String(visemeIdx)];
1576
1861
  if (!visemeCurve) continue;
1577
1862
  const visemeValue = clampIntensity(sampleAt(visemeCurve, t) * intensityScale);
@@ -1687,8 +1972,8 @@ var BakedAnimationController = class {
1687
1972
  return clip;
1688
1973
  }
1689
1974
  playClip(clip, options) {
1690
- this.ensureMixer();
1691
- if (!this.animationMixer) {
1975
+ const mixer = this.ensureClipMixer();
1976
+ if (!mixer) {
1692
1977
  console.warn("[Loom3] playClip: No model loaded, cannot create mixer");
1693
1978
  return null;
1694
1979
  }
@@ -1706,7 +1991,7 @@ var BakedAnimationController = class {
1706
1991
  actionId = this.setActionId(action, clip.name);
1707
1992
  }
1708
1993
  if (!action) {
1709
- action = this.animationMixer.clipAction(clip);
1994
+ action = mixer.clipAction(clip);
1710
1995
  actionId = this.setActionId(action, clip.name);
1711
1996
  }
1712
1997
  const existingClip = this.animationClips.find((c) => c.name === clip.name);
@@ -1767,15 +2052,13 @@ var BakedAnimationController = class {
1767
2052
  },
1768
2053
  stop: () => {
1769
2054
  action.stop();
1770
- if (this.animationMixer) {
1771
- try {
1772
- this.animationMixer.uncacheAction(clip);
1773
- } catch {
1774
- }
1775
- try {
1776
- this.animationMixer.uncacheClip(clip);
1777
- } catch {
1778
- }
2055
+ try {
2056
+ mixer.uncacheAction(clip);
2057
+ } catch {
2058
+ }
2059
+ try {
2060
+ mixer.uncacheClip(clip);
2061
+ } catch {
1779
2062
  }
1780
2063
  this.clipActions.delete(clip.name);
1781
2064
  this.animationActions.delete(clip.name);
@@ -1816,7 +2099,7 @@ var BakedAnimationController = class {
1816
2099
  const clamped = Math.max(0, Math.min(clip.duration, t));
1817
2100
  action.time = clamped;
1818
2101
  try {
1819
- this.animationMixer?.update(0);
2102
+ mixer.update(0);
1820
2103
  } catch {
1821
2104
  }
1822
2105
  this.syncClipMonitorTime(monitor, clamped, true);
@@ -1849,16 +2132,16 @@ var BakedAnimationController = class {
1849
2132
  return this.playClip(clip, { ...options, source: options?.source ?? "clip" });
1850
2133
  }
1851
2134
  cleanupSnippet(name) {
1852
- if (!this.animationMixer || !this.host.getModel()) return;
2135
+ if (!this.host.getModel()) return;
1853
2136
  for (const [clipName, action] of Array.from(this.clipActions.entries())) {
1854
2137
  if (clipName === name || clipName.startsWith(`${name}_`)) {
1855
2138
  const actionId = this.getActionId(action);
1856
2139
  try {
1857
2140
  action.stop();
1858
2141
  const clip = action.getClip();
1859
- if (clip) {
1860
- this.animationMixer.uncacheAction(clip);
1861
- this.animationMixer.uncacheClip(clip);
2142
+ if (clip && this.clipAnimationMixer) {
2143
+ this.clipAnimationMixer.uncacheAction(clip);
2144
+ this.clipAnimationMixer.uncacheClip(clip);
1862
2145
  }
1863
2146
  } catch {
1864
2147
  }
@@ -1886,7 +2169,10 @@ var BakedAnimationController = class {
1886
2169
  clipActions: Array.from(this.clipActions.entries()).map(([k, a]) => ({ name: k, actionId: this.getActionId(a) })),
1887
2170
  animationActions: Array.from(this.animationActions.entries()).map(([k, a]) => ({ name: k, actionId: this.getActionId(a) })),
1888
2171
  clipHandles: Array.from(this.clipHandles.entries()).map(([k, h]) => ({ name: k, actionId: h.actionId })),
1889
- mixerActions: (this.animationMixer?._actions || []).map((a) => ({ name: a?.getClip?.()?.name || "", actionId: this.getActionId(a) }))
2172
+ mixerActions: [
2173
+ ...this.animationMixer?._actions || [],
2174
+ ...this.clipAnimationMixer?._actions || []
2175
+ ].map((a) => ({ name: a?.getClip?.()?.name || "", actionId: this.getActionId(a) }))
1890
2176
  });
1891
2177
  console.log("[Loom3] updateClipParams start", debugSnapshot());
1892
2178
  const apply = (action) => {
@@ -2000,35 +2286,48 @@ var BakedAnimationController = class {
2000
2286
  this.animationMixer = new THREE2.AnimationMixer(model);
2001
2287
  }
2002
2288
  if (this.animationMixer && !this.mixerFinishedListenerAttached) {
2003
- this.animationMixer.addEventListener("finished", (event) => {
2004
- const action = event.action;
2005
- const actionId = this.getActionId(action);
2006
- if (actionId) {
2007
- const monitor = this.clipMonitors.get(actionId);
2008
- if (monitor) {
2009
- monitor.finishedPending = true;
2010
- return;
2011
- }
2012
- }
2013
- const clip = action.getClip();
2014
- const bakedRuntime = this.bakedRuntimeClipToSource.get(clip.name);
2015
- if (bakedRuntime) {
2016
- const group = this.bakedActionGroups.get(bakedRuntime.sourceClipName);
2017
- if (group && group.pendingFinishedChannels.delete(bakedRuntime.channel) && group.pendingFinishedChannels.size === 0) {
2018
- group.resolveFinished();
2019
- }
2020
- return;
2021
- }
2022
- const callback = this.animationFinishedCallbacks.get(clip.name);
2023
- if (callback) {
2024
- callback();
2025
- this.animationFinishedCallbacks.delete(clip.name);
2026
- }
2027
- });
2289
+ this.animationMixer.addEventListener("finished", (event) => this.handleMixerFinished(event));
2028
2290
  this.mixerFinishedListenerAttached = true;
2029
2291
  }
2030
2292
  return this.animationMixer;
2031
2293
  }
2294
+ ensureClipMixer() {
2295
+ const model = this.host.getModel();
2296
+ if (!model) return null;
2297
+ if (!this.clipAnimationMixer) {
2298
+ this.clipAnimationMixer = new THREE2.AnimationMixer(model);
2299
+ }
2300
+ if (this.clipAnimationMixer && !this.clipMixerFinishedListenerAttached) {
2301
+ this.clipAnimationMixer.addEventListener("finished", (event) => this.handleMixerFinished(event));
2302
+ this.clipMixerFinishedListenerAttached = true;
2303
+ }
2304
+ return this.clipAnimationMixer;
2305
+ }
2306
+ handleMixerFinished(event) {
2307
+ const action = event.action;
2308
+ const actionId = this.getActionId(action);
2309
+ if (actionId) {
2310
+ const monitor = this.clipMonitors.get(actionId);
2311
+ if (monitor) {
2312
+ monitor.finishedPending = true;
2313
+ return;
2314
+ }
2315
+ }
2316
+ const clip = action.getClip();
2317
+ const bakedRuntime = this.bakedRuntimeClipToSource.get(clip.name);
2318
+ if (bakedRuntime) {
2319
+ const group = this.bakedActionGroups.get(bakedRuntime.sourceClipName);
2320
+ if (group && group.pendingFinishedChannels.delete(bakedRuntime.channel) && group.pendingFinishedChannels.size === 0) {
2321
+ group.resolveFinished();
2322
+ }
2323
+ return;
2324
+ }
2325
+ const callback = this.animationFinishedCallbacks.get(clip.name);
2326
+ if (callback) {
2327
+ callback();
2328
+ this.animationFinishedCallbacks.delete(clip.name);
2329
+ }
2330
+ }
2032
2331
  createAnimationHandle(clipName, action, finishedPromise) {
2033
2332
  return {
2034
2333
  actionId: this.getActionId(action),
@@ -4992,7 +5291,8 @@ var _Loom3 = class _Loom3 {
4992
5291
  getCompositeRotations: () => this.compositeRotations,
4993
5292
  computeSideValues: (base, balance) => this.computeSideValues(base, balance),
4994
5293
  getAUMixWeight: (auId) => this.getAUMixWeight(auId),
4995
- isMixedAU: (auId) => this.isMixedAU(auId)
5294
+ isMixedAU: (auId) => this.isMixedAU(auId),
5295
+ reapplyProceduralState: () => this.reapplyProceduralStateAfterBakedUpdate()
4996
5296
  });
4997
5297
  this.hairPhysics = new HairPhysicsController({
4998
5298
  getMeshByName: (name) => this.meshByName.get(name),
@@ -5084,10 +5384,15 @@ var _Loom3 = class _Loom3 {
5084
5384
  };
5085
5385
  this.resolvedAUMorphTargets.set(auId, resolved);
5086
5386
  }
5087
- for (let i = 0; i < (this.config.visemeKeys || []).length; i += 1) {
5088
- const key = this.config.visemeKeys[i];
5387
+ for (let i = 0; i < getProfileVisemeSlots(this.config).length; i += 1) {
5089
5388
  const visemeMeshNames = this.getMeshNamesForViseme();
5090
- const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key, visemeMeshNames) : this.resolveMorphTargets(key, visemeMeshNames);
5389
+ const targets = [];
5390
+ for (const bindingTarget of getVisemeBindingTargets(this.config, i)) {
5391
+ const resolved = typeof bindingTarget.morph === "number" ? this.resolveMorphTargetsByIndex(bindingTarget.morph, visemeMeshNames) : this.resolveMorphTargets(bindingTarget.morph, visemeMeshNames);
5392
+ for (const target of resolved) {
5393
+ targets.push({ ...target, weight: bindingTarget.weight });
5394
+ }
5395
+ }
5091
5396
  this.resolvedVisemeTargets[i] = targets;
5092
5397
  }
5093
5398
  }
@@ -5555,46 +5860,33 @@ var _Loom3 = class _Loom3 {
5555
5860
  // VISEME CONTROL
5556
5861
  // ============================================================================
5557
5862
  setViseme(visemeIndex, value, jawScale = 1) {
5558
- if (visemeIndex < 0 || visemeIndex >= this.config.visemeKeys.length) return;
5863
+ if (visemeIndex < 0 || visemeIndex >= this.visemeValues.length) return;
5559
5864
  const val = clamp012(value);
5560
5865
  this.visemeValues[visemeIndex] = val;
5561
5866
  this.visemeJawScales[visemeIndex] = jawScale;
5562
- const targets = this.resolvedVisemeTargets[visemeIndex];
5563
- if (targets && targets.length > 0) {
5564
- this.applyMorphTargets(targets, val);
5565
- } else {
5566
- const morphKey = this.config.visemeKeys[visemeIndex];
5567
- const visemeMeshNames = this.getMeshNamesForViseme();
5568
- if (typeof morphKey === "number") {
5569
- this.setMorphInfluence(morphKey, val, visemeMeshNames);
5570
- } else if (typeof morphKey === "string") {
5571
- this.setMorph(morphKey, val, visemeMeshNames);
5572
- }
5573
- }
5574
- const jawAmount = this.getVisemeJawAmount(visemeIndex) * val * jawScale;
5575
- if (Math.abs(jawScale) > 1e-6 && Math.abs(jawAmount) > 1e-6) {
5576
- this.updateBoneRotation("JAW", "pitch", jawAmount);
5577
- }
5867
+ this.applyVisemeRuntimeState();
5578
5868
  }
5579
5869
  transitionViseme(visemeIndex, to, durationMs = 80, jawScale = 1) {
5580
- if (visemeIndex < 0 || visemeIndex >= this.config.visemeKeys.length) {
5870
+ if (visemeIndex < 0 || visemeIndex >= this.visemeValues.length) {
5581
5871
  return { promise: Promise.resolve(), pause: () => {
5582
5872
  }, resume: () => {
5583
5873
  }, cancel: () => {
5584
5874
  } };
5585
5875
  }
5586
- const morphKey = this.config.visemeKeys[visemeIndex];
5587
5876
  const target = clamp012(to);
5588
- this.visemeValues[visemeIndex] = target;
5877
+ const from = this.visemeValues[visemeIndex] ?? 0;
5589
5878
  this.visemeJawScales[visemeIndex] = jawScale;
5590
- const visemeMeshNames = this.getMeshNamesForViseme();
5591
- const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs, visemeMeshNames) : this.transitionMorph(morphKey, target, durationMs, visemeMeshNames);
5592
- const jawAmount = this.getVisemeJawAmount(visemeIndex) * target * jawScale;
5593
- if (Math.abs(jawScale) <= 1e-6 || Math.abs(jawAmount) <= 1e-6) {
5594
- return morphHandle;
5595
- }
5596
- const jawHandle = this.transitionBoneRotation("JAW", "pitch", jawAmount, durationMs);
5597
- return this.combineHandles([morphHandle, jawHandle]);
5879
+ return this.animation.addTransition(
5880
+ `viseme_value_${visemeIndex}`,
5881
+ from,
5882
+ target,
5883
+ durationMs,
5884
+ (value) => {
5885
+ this.visemeValues[visemeIndex] = clamp012(value);
5886
+ this.visemeJawScales[visemeIndex] = jawScale;
5887
+ this.applyVisemeRuntimeState();
5888
+ }
5889
+ );
5598
5890
  }
5599
5891
  setVisemeById(slotId, value, jawScale = 1) {
5600
5892
  const index = getVisemeSlotIndex(this.config, slotId);
@@ -5658,8 +5950,9 @@ var _Loom3 = class _Loom3 {
5658
5950
  }
5659
5951
  resetToNeutral() {
5660
5952
  this.auValues = {};
5661
- this.visemeValues = new Array(this.config.visemeKeys.length).fill(0);
5662
- this.visemeJawScales = new Array(this.config.visemeKeys.length).fill(1);
5953
+ const visemeCount = getProfileVisemeSlots(this.config).length;
5954
+ this.visemeValues = new Array(visemeCount).fill(0);
5955
+ this.visemeJawScales = new Array(visemeCount).fill(1);
5663
5956
  this.translations = {};
5664
5957
  this.initBoneRotations();
5665
5958
  this.clearTransitions();
@@ -5693,15 +5986,35 @@ var _Loom3 = class _Loom3 {
5693
5986
  if (Number.isNaN(auId)) continue;
5694
5987
  this.setAU(auId, value, this.auBalances[auId]);
5695
5988
  }
5989
+ this.applyVisemeRuntimeState();
5990
+ if (this.model) {
5991
+ this.flushPendingComposites();
5992
+ this.model.updateMatrixWorld(true);
5993
+ }
5994
+ }
5995
+ reapplyProceduralStateAfterBakedUpdate() {
5996
+ if (!this.model) {
5997
+ return;
5998
+ }
5999
+ let hasActiveOverrides = false;
6000
+ for (const [auIdStr, value] of Object.entries(this.auValues)) {
6001
+ if (value <= 0) continue;
6002
+ const auId = Number(auIdStr);
6003
+ if (Number.isNaN(auId)) continue;
6004
+ hasActiveOverrides = true;
6005
+ this.setAU(auId, value, this.auBalances[auId]);
6006
+ }
5696
6007
  for (let visemeIndex = 0; visemeIndex < this.visemeValues.length; visemeIndex += 1) {
5697
6008
  const value = this.visemeValues[visemeIndex] ?? 0;
5698
6009
  if (value <= 0) continue;
6010
+ hasActiveOverrides = true;
5699
6011
  this.setViseme(visemeIndex, value, this.visemeJawScales[visemeIndex] ?? 1);
5700
6012
  }
5701
- if (this.model) {
5702
- this.flushPendingComposites();
5703
- this.model.updateMatrixWorld(true);
6013
+ if (!hasActiveOverrides) {
6014
+ return;
5704
6015
  }
6016
+ this.flushPendingComposites();
6017
+ this.model.updateMatrixWorld(true);
5705
6018
  }
5706
6019
  // ============================================================================
5707
6020
  // MESH CONTROL
@@ -6031,6 +6344,37 @@ var _Loom3 = class _Loom3 {
6031
6344
  target.infl[target.idx] = val;
6032
6345
  }
6033
6346
  }
6347
+ applyVisemeRuntimeState() {
6348
+ for (const targets of this.resolvedVisemeTargets) {
6349
+ for (const target of targets || []) {
6350
+ if (target.idx < target.infl.length) {
6351
+ target.infl[target.idx] = 0;
6352
+ }
6353
+ }
6354
+ }
6355
+ for (let index = 0; index < this.visemeValues.length; index += 1) {
6356
+ const value = clamp012(this.visemeValues[index] ?? 0);
6357
+ if (value <= 1e-6) continue;
6358
+ const targets = this.resolvedVisemeTargets[index] || [];
6359
+ for (const target of targets) {
6360
+ if (target.idx >= target.infl.length) continue;
6361
+ const weighted = clamp012(value * target.weight);
6362
+ target.infl[target.idx] = Math.max(target.infl[target.idx] ?? 0, weighted);
6363
+ }
6364
+ }
6365
+ this.updateBoneRotation("JAW", "pitch", this.getActiveVisemeJawAmount());
6366
+ }
6367
+ getActiveVisemeJawAmount() {
6368
+ let jawAmount = 0;
6369
+ for (let index = 0; index < this.visemeValues.length; index += 1) {
6370
+ const value = clamp012(this.visemeValues[index] ?? 0);
6371
+ if (value <= 1e-6) continue;
6372
+ const jawScale = this.visemeJawScales[index] ?? 1;
6373
+ if (Math.abs(jawScale) <= 1e-6) continue;
6374
+ jawAmount = Math.max(jawAmount, this.getVisemeJawAmount(index) * value * jawScale);
6375
+ }
6376
+ return jawAmount;
6377
+ }
6034
6378
  getMorphValue(key) {
6035
6379
  if (this.faceMesh) {
6036
6380
  const dict = this.faceMesh.morphTargetDictionary;
@@ -6216,7 +6560,7 @@ var _Loom3 = class _Loom3 {
6216
6560
  return meshNames?.length ? `idx:${index}@${meshNames.join(",")}` : `idx:${index}`;
6217
6561
  }
6218
6562
  syncVisemeRuntimeState() {
6219
- const visemeCount = this.config.visemeKeys.length;
6563
+ const visemeCount = getProfileVisemeSlots(this.config).length;
6220
6564
  this.visemeValues = Array.from(
6221
6565
  { length: visemeCount },
6222
6566
  (_, index) => this.visemeValues[index] ?? 0
@@ -8339,6 +8683,7 @@ exports.getModelForwardDirection = getModelForwardDirection;
8339
8683
  exports.getPreset = getPreset;
8340
8684
  exports.getPresetWithProfile = getPresetWithProfile;
8341
8685
  exports.getProfileVisemeSlots = getProfileVisemeSlots;
8686
+ exports.getVisemeBindingTargets = getVisemeBindingTargets;
8342
8687
  exports.getVisemeJawAmounts = getVisemeJawAmounts;
8343
8688
  exports.getVisemeSlotIndex = getVisemeSlotIndex;
8344
8689
  exports.hasLeftRightMorphs = hasLeftRightMorphs;