@lovelace_lol/loom3 1.0.41 → 1.0.43

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
@@ -60,6 +60,223 @@ function getCompositeAxisBinding(nodeKey, axisConfig, direction, getValue, auToB
60
60
  return null;
61
61
  }
62
62
 
63
+ // src/mappings/visemeSystem.ts
64
+ function hasOwn(value, key) {
65
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
66
+ }
67
+ function toSlotId(label, index) {
68
+ const normalized = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
69
+ return normalized || `viseme-${index}`;
70
+ }
71
+ function bindingTargets(binding) {
72
+ if (!binding) return [];
73
+ const targets = binding.targets?.map((target) => target.morph).filter((morph) => morph !== "");
74
+ if (targets && targets.length > 0) return targets;
75
+ return binding.morph !== void 0 && binding.morph !== "" ? [binding.morph] : [];
76
+ }
77
+ function getProfileVisemeSlots(profile) {
78
+ if (profile.visemeSlots && profile.visemeSlots.length > 0) {
79
+ return [...profile.visemeSlots].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
80
+ }
81
+ return (profile.visemeKeys || []).map((key, index) => {
82
+ const label = typeof key === "string" && key ? key : `Viseme ${index}`;
83
+ return {
84
+ id: toSlotId(label, index),
85
+ label,
86
+ order: index,
87
+ defaultJawAmount: profile.visemeJawAmounts?.[index]
88
+ };
89
+ });
90
+ }
91
+ function getVisemeSlotIndex(profile, slotId) {
92
+ return getProfileVisemeSlots(profile).findIndex((slot) => slot.id === slotId);
93
+ }
94
+ function compileVisemeKeys(profile) {
95
+ const slots = getProfileVisemeSlots(profile);
96
+ if (!profile.visemeBindings) return [...profile.visemeKeys || []];
97
+ return slots.map((slot, index) => {
98
+ const target = bindingTargets(profile.visemeBindings?.[slot.id])[0];
99
+ return target ?? profile.visemeKeys?.[index] ?? "";
100
+ });
101
+ }
102
+ function getVisemeJawAmounts(profile) {
103
+ const slots = getProfileVisemeSlots(profile);
104
+ if (slots.length === 0) return profile.visemeJawAmounts ? [...profile.visemeJawAmounts] : void 0;
105
+ return slots.map((slot, index) => slot.defaultJawAmount ?? profile.visemeJawAmounts?.[index] ?? 0);
106
+ }
107
+ function resolveVisemeMeshCategory(profile) {
108
+ const morphToMesh = profile.morphToMesh || {};
109
+ if (profile.visemeMeshCategory) return profile.visemeMeshCategory;
110
+ if (hasOwn(morphToMesh, "viseme")) return "viseme";
111
+ return "face";
112
+ }
113
+ function getMeshNamesForVisemeProfile(profile) {
114
+ const morphToMesh = profile.morphToMesh || {};
115
+ const category = resolveVisemeMeshCategory(profile);
116
+ if (hasOwn(morphToMesh, category)) {
117
+ return Array.isArray(morphToMesh[category]) ? [...morphToMesh[category]] : [];
118
+ }
119
+ return profile.visemeMeshCategory ? [] : [...morphToMesh.face || []];
120
+ }
121
+ function getMeshNamesForAUProfile(profile, auId) {
122
+ const morphToMesh = profile.morphToMesh || {};
123
+ const facePart = profile.auInfo?.[String(auId)]?.facePart;
124
+ const category = facePart ? profile.auFacePartToMeshCategory?.[facePart] : void 0;
125
+ if (category) return Array.isArray(morphToMesh[category]) ? [...morphToMesh[category]] : [];
126
+ return [...morphToMesh.face || []];
127
+ }
128
+ function compileMatcher(pattern) {
129
+ try {
130
+ return new RegExp(pattern, "i");
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+ function classifyVisemeMorph(morphName, profile) {
136
+ const slots = getProfileVisemeSlots(profile);
137
+ const matches = [];
138
+ for (const slot of slots) {
139
+ const explicitTargets = bindingTargets(profile.visemeBindings?.[slot.id]);
140
+ if (explicitTargets.some((target) => String(target).toLowerCase() === morphName.toLowerCase())) {
141
+ matches.push({
142
+ slotId: slot.id,
143
+ label: slot.label,
144
+ confidence: 1,
145
+ reason: "explicit"
146
+ });
147
+ continue;
148
+ }
149
+ for (const pattern of slot.matchers || []) {
150
+ const matcher = compileMatcher(pattern);
151
+ if (matcher?.test(morphName)) {
152
+ matches.push({
153
+ slotId: slot.id,
154
+ label: slot.label,
155
+ confidence: 0.75,
156
+ reason: "regex",
157
+ pattern
158
+ });
159
+ }
160
+ }
161
+ }
162
+ return matches;
163
+ }
164
+ function buildMappingEditorModel(profile, morphNames = []) {
165
+ const sections = /* @__PURE__ */ new Map();
166
+ const configuredSections = profile.mappingSections || [];
167
+ const configuredById = new Map(configuredSections.map((section) => [section.id, section]));
168
+ let nextOrder = configuredSections.length;
169
+ for (const section of configuredSections) {
170
+ sections.set(section.id, { ...section });
171
+ }
172
+ const getOrder = (id, fallback) => {
173
+ const configured = configuredById.get(id);
174
+ if (configured) return configured.order;
175
+ if (fallback !== void 0) return fallback;
176
+ const order = nextOrder;
177
+ nextOrder += 1;
178
+ return order;
179
+ };
180
+ const auSectionOrders = /* @__PURE__ */ new Map();
181
+ for (const [auId, info] of Object.entries(profile.auInfo || {})) {
182
+ const label = info.facePart || "Unmapped";
183
+ auSectionOrders.set(label, Math.min(auSectionOrders.get(label) ?? Number.MAX_SAFE_INTEGER, Number(auId)));
184
+ }
185
+ if (configuredSections.length === 0 && auSectionOrders.size > 0) {
186
+ nextOrder = Math.max(...auSectionOrders.values()) + 1;
187
+ }
188
+ for (const info of Object.values(profile.auInfo || {})) {
189
+ const label = info.facePart || "Unmapped";
190
+ const configured = configuredById.get(label);
191
+ const meshCategory = profile.auFacePartToMeshCategory?.[label] || "face";
192
+ sections.set(label, {
193
+ ...configured,
194
+ id: label,
195
+ label: configured?.label || label,
196
+ kind: "au",
197
+ order: getOrder(label, auSectionOrders.get(label)),
198
+ meshCategory: configured?.meshCategory || meshCategory,
199
+ facePart: label
200
+ });
201
+ }
202
+ const configuredVisemes = configuredById.get("Visemes");
203
+ sections.set("Visemes", {
204
+ ...configuredVisemes,
205
+ id: "Visemes",
206
+ label: configuredVisemes?.label || "Visemes",
207
+ kind: "viseme",
208
+ order: getOrder("Visemes"),
209
+ meshCategory: configuredVisemes?.meshCategory || resolveVisemeMeshCategory(profile)
210
+ });
211
+ if (hasOwn(profile.morphToMesh, "hair")) {
212
+ const configuredHair = configuredById.get("Hair");
213
+ sections.set("Hair", {
214
+ ...configuredHair,
215
+ id: "Hair",
216
+ label: configuredHair?.label || "Hair",
217
+ kind: "hair",
218
+ order: getOrder("Hair"),
219
+ meshCategory: configuredHair?.meshCategory || "hair"
220
+ });
221
+ }
222
+ const configuredUnmapped = configuredById.get("Unmapped");
223
+ sections.set("Unmapped", {
224
+ ...configuredUnmapped,
225
+ id: "Unmapped",
226
+ label: configuredUnmapped?.label || "Unmapped",
227
+ kind: "unmapped",
228
+ order: getOrder("Unmapped"),
229
+ meshCategory: configuredUnmapped?.meshCategory || "face"
230
+ });
231
+ const candidates = morphNames.map((morph) => {
232
+ const matches = classifyVisemeMorph(morph, profile);
233
+ if (matches.length === 0) {
234
+ return { morph, sectionId: "Unmapped", kind: "unmapped", matches };
235
+ }
236
+ const explicit = matches.filter((match) => match.reason === "explicit");
237
+ if (explicit.length > 0) {
238
+ return { morph, sectionId: "Visemes", kind: "explicit", matches: explicit };
239
+ }
240
+ return {
241
+ morph,
242
+ sectionId: "Visemes",
243
+ kind: matches.length > 1 ? "conflict" : "candidate",
244
+ matches
245
+ };
246
+ });
247
+ return {
248
+ sections: Array.from(sections.values()).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
249
+ candidates
250
+ };
251
+ }
252
+ function mapProviderVisemeToSlot(profile, event) {
253
+ const slots = getProfileVisemeSlots(profile);
254
+ const provider = event.provider.toLowerCase();
255
+ if (event.id !== void 0) {
256
+ const id = String(event.id);
257
+ const index = slots.findIndex(
258
+ (slot) => (slot.providerIds?.[provider] || []).some((candidate) => String(candidate) === id)
259
+ );
260
+ if (index >= 0) {
261
+ return { slotId: slots[index].id, index, confidence: 1, reason: "provider" };
262
+ }
263
+ }
264
+ if (event.phoneme) {
265
+ const phoneme = event.phoneme.toLowerCase();
266
+ const index = slots.findIndex(
267
+ (slot) => (slot.phonemes || []).some((candidate) => candidate.toLowerCase() === phoneme)
268
+ );
269
+ if (index >= 0) {
270
+ return { slotId: slots[index].id, index, confidence: 0.8, reason: "phoneme" };
271
+ }
272
+ }
273
+ const restIndex = slots.findIndex((slot) => slot.id === "rest" || slot.features?.lipClosed === 1);
274
+ if (restIndex >= 0) {
275
+ return { slotId: slots[restIndex].id, index: restIndex, confidence: 0.25, reason: "rest" };
276
+ }
277
+ return null;
278
+ }
279
+
63
280
  // src/engines/three/balanceUtils.ts
64
281
  function clampBalance(value) {
65
282
  if (!Number.isFinite(value)) return 0;
@@ -293,14 +510,19 @@ var Y_AXIS = new THREE2.Vector3(0, 1, 0);
293
510
  var Z_AXIS = new THREE2.Vector3(0, 0, 1);
294
511
  var CLIP_EVENT_METADATA_KEY = "__loom3ClipEvents";
295
512
  var CLIP_EVENT_EPSILON = 1e-4;
513
+ var ADDITIVE_BAKED_RUNTIME_CLIP_SUFFIX = "__loom3_additive_delta";
296
514
  var BakedAnimationController = class {
297
515
  constructor(host) {
298
516
  __publicField(this, "host");
517
+ // Clip-backed snippets need a later mixer pass so they can override baked additive tracks.
299
518
  __publicField(this, "animationMixer", null);
519
+ __publicField(this, "clipAnimationMixer", null);
300
520
  __publicField(this, "mixerFinishedListenerAttached", false);
521
+ __publicField(this, "clipMixerFinishedListenerAttached", false);
301
522
  __publicField(this, "animationClips", []);
302
523
  __publicField(this, "bakedSourceClips", /* @__PURE__ */ new Map());
303
524
  __publicField(this, "bakedRuntimeActions", /* @__PURE__ */ new Map());
525
+ __publicField(this, "bakedAdditiveRuntimeClips", /* @__PURE__ */ new Map());
304
526
  __publicField(this, "bakedActionGroups", /* @__PURE__ */ new Map());
305
527
  __publicField(this, "bakedRuntimeClipToSource", /* @__PURE__ */ new Map());
306
528
  __publicField(this, "animationActions", /* @__PURE__ */ new Map());
@@ -325,6 +547,173 @@ var BakedAnimationController = class {
325
547
  action.__actionId = actionId;
326
548
  return actionId;
327
549
  }
550
+ clearActionId(action) {
551
+ if (!action) return;
552
+ const actionId = this.getActionId(action);
553
+ if (actionId) {
554
+ this.actionIdToClip.delete(actionId);
555
+ }
556
+ this.actionIds.delete(action);
557
+ delete action.__actionId;
558
+ }
559
+ uncacheClip(clip, mixer = this.animationMixer) {
560
+ if (!clip || !mixer) return;
561
+ try {
562
+ mixer.uncacheClip(clip);
563
+ } catch {
564
+ }
565
+ }
566
+ uncacheAction(action, mixer = this.animationMixer) {
567
+ if (!action || !mixer) return;
568
+ try {
569
+ const clip = action.getClip();
570
+ if (clip) {
571
+ mixer.uncacheAction(clip);
572
+ mixer.uncacheClip(clip);
573
+ }
574
+ } catch {
575
+ }
576
+ }
577
+ releaseBakedRuntimeAction(runtimeClipName) {
578
+ const action = this.bakedRuntimeActions.get(runtimeClipName);
579
+ if (!action) return;
580
+ try {
581
+ action.stop();
582
+ } catch {
583
+ }
584
+ this.uncacheAction(action);
585
+ this.clearActionId(action);
586
+ this.bakedRuntimeActions.delete(runtimeClipName);
587
+ }
588
+ clearBakedAdditiveRuntimeClip(runtimeClipName) {
589
+ const clip = this.bakedAdditiveRuntimeClips.get(runtimeClipName);
590
+ if (!clip) return;
591
+ this.uncacheClip(clip);
592
+ this.bakedAdditiveRuntimeClips.delete(runtimeClipName);
593
+ }
594
+ clearAllBakedAdditiveRuntimeClips() {
595
+ for (const runtimeClipName of Array.from(this.bakedAdditiveRuntimeClips.keys())) {
596
+ this.clearBakedAdditiveRuntimeClip(runtimeClipName);
597
+ }
598
+ }
599
+ resolveTrackTarget(model, parsed) {
600
+ const targetKey = parsed.objectName === "bones" && parsed.objectIndex ? parsed.objectIndex : parsed.nodeName;
601
+ if (!targetKey) {
602
+ return null;
603
+ }
604
+ return model.getObjectByProperty("uuid", targetKey) ?? THREE2.PropertyBinding.findNode(model, targetKey) ?? null;
605
+ }
606
+ getMorphTrackBaseValue(target, propertyIndex) {
607
+ if (!target) {
608
+ return 0;
609
+ }
610
+ const meshTarget = target;
611
+ const influences = meshTarget.morphTargetInfluences;
612
+ if (!influences) {
613
+ return 0;
614
+ }
615
+ let morphIndex;
616
+ if (typeof propertyIndex === "number" && Number.isInteger(propertyIndex)) {
617
+ morphIndex = propertyIndex;
618
+ } else if (typeof propertyIndex === "string") {
619
+ if (/^\d+$/.test(propertyIndex)) {
620
+ morphIndex = Number(propertyIndex);
621
+ } else {
622
+ morphIndex = meshTarget.morphTargetDictionary?.[propertyIndex];
623
+ }
624
+ }
625
+ if (morphIndex === void 0) {
626
+ return 0;
627
+ }
628
+ return influences[morphIndex] ?? 0;
629
+ }
630
+ canCreateFirstFrameReferenceTrack(track) {
631
+ const valueSize = track.getValueSize();
632
+ if (!Number.isFinite(valueSize) || valueSize <= 0 || track.values.length < valueSize) {
633
+ return false;
634
+ }
635
+ return track.ValueTypeName === "number" || track.ValueTypeName === "quaternion" || track.ValueTypeName === "vector";
636
+ }
637
+ createFirstFrameReferenceTrack(track) {
638
+ const valueSize = track.getValueSize();
639
+ if (!this.canCreateFirstFrameReferenceTrack(track)) {
640
+ return null;
641
+ }
642
+ const values = Array.from(track.values.slice(0, valueSize));
643
+ if (track.ValueTypeName === "number") {
644
+ return new THREE2.NumberKeyframeTrack(track.name, [0], values);
645
+ }
646
+ if (track.ValueTypeName === "quaternion") {
647
+ return new THREE2.QuaternionKeyframeTrack(track.name, [0], values);
648
+ }
649
+ if (track.ValueTypeName === "vector") {
650
+ return new THREE2.VectorKeyframeTrack(track.name, [0], values);
651
+ }
652
+ return null;
653
+ }
654
+ createAdditiveReferenceTrack(track, model) {
655
+ const trackName = typeof track?.name === "string" ? track.name : "";
656
+ if (!trackName) {
657
+ return null;
658
+ }
659
+ let parsed;
660
+ try {
661
+ parsed = THREE2.PropertyBinding.parseTrackName(trackName);
662
+ } catch {
663
+ return null;
664
+ }
665
+ const target = this.resolveTrackTarget(model, parsed);
666
+ if (parsed.propertyName === "morphTargetInfluences") {
667
+ return new THREE2.NumberKeyframeTrack(
668
+ track.name,
669
+ [0],
670
+ [this.getMorphTrackBaseValue(target, parsed.propertyIndex)]
671
+ );
672
+ }
673
+ return this.createFirstFrameReferenceTrack(track);
674
+ }
675
+ createAdditiveRuntimeClip(runtimeClip) {
676
+ const model = this.host.getModel();
677
+ if (!model) {
678
+ return null;
679
+ }
680
+ const additiveTracks = [];
681
+ const referenceTracks = [];
682
+ for (const track of runtimeClip.tracks) {
683
+ const referenceTrack = this.createAdditiveReferenceTrack(track, model);
684
+ if (!referenceTrack) {
685
+ continue;
686
+ }
687
+ additiveTracks.push(track.clone());
688
+ referenceTracks.push(referenceTrack);
689
+ }
690
+ const additiveClip = new THREE2.AnimationClip(
691
+ `${runtimeClip.name}${ADDITIVE_BAKED_RUNTIME_CLIP_SUFFIX}`,
692
+ runtimeClip.duration,
693
+ additiveTracks
694
+ );
695
+ if (additiveTracks.length > 0) {
696
+ const referenceClip = new THREE2.AnimationClip(
697
+ `${runtimeClip.name}${ADDITIVE_BAKED_RUNTIME_CLIP_SUFFIX}_reference`,
698
+ 0,
699
+ referenceTracks
700
+ );
701
+ THREE2.AnimationUtils.makeClipAdditive(additiveClip, 0, referenceClip);
702
+ }
703
+ return additiveClip;
704
+ }
705
+ getOrCreateBakedAdditiveRuntimeClip(runtimeClip) {
706
+ const cached = this.bakedAdditiveRuntimeClips.get(runtimeClip.name);
707
+ if (cached) {
708
+ return cached;
709
+ }
710
+ const additiveClip = this.createAdditiveRuntimeClip(runtimeClip);
711
+ if (!additiveClip) {
712
+ return null;
713
+ }
714
+ this.bakedAdditiveRuntimeClips.set(runtimeClip.name, additiveClip);
715
+ return additiveClip;
716
+ }
328
717
  setClipEventMetadata(clip, metadata) {
329
718
  const userData = clip.userData ?? (clip.userData = {});
330
719
  userData[CLIP_EVENT_METADATA_KEY] = metadata;
@@ -610,21 +999,28 @@ var BakedAnimationController = class {
610
999
  }
611
1000
  return 0;
612
1001
  }
613
- getOrCreateBakedRuntimeAction(sourceClipName, channel) {
1002
+ getOrCreateBakedRuntimeAction(sourceClipName, channel, blendMode = "replace") {
614
1003
  const bakedClip = this.getBakedSourceClip(sourceClipName);
615
1004
  const runtimeClip = bakedClip?.runtimeClips.find((entry) => entry.channel === channel)?.clip;
616
1005
  if (!runtimeClip) {
617
1006
  return null;
618
1007
  }
1008
+ const desiredClip = blendMode === "additive" ? this.getOrCreateBakedAdditiveRuntimeClip(runtimeClip) : runtimeClip;
1009
+ if (!desiredClip) {
1010
+ return null;
1011
+ }
619
1012
  const existing = this.bakedRuntimeActions.get(runtimeClip.name);
620
- if (existing) {
1013
+ if (existing?.getClip() === desiredClip) {
621
1014
  return existing;
622
1015
  }
623
1016
  this.ensureMixer();
624
1017
  if (!this.animationMixer) {
625
1018
  return null;
626
1019
  }
627
- const action = this.animationMixer.clipAction(runtimeClip);
1020
+ if (existing) {
1021
+ this.releaseBakedRuntimeAction(runtimeClip.name);
1022
+ }
1023
+ const action = this.animationMixer.clipAction(desiredClip);
628
1024
  this.bakedRuntimeActions.set(runtimeClip.name, action);
629
1025
  return action;
630
1026
  }
@@ -642,7 +1038,15 @@ var BakedAnimationController = class {
642
1038
  }
643
1039
  const channelActions = /* @__PURE__ */ new Map();
644
1040
  for (const runtimeClip of bakedClip.runtimeClips) {
645
- const action = this.getOrCreateBakedRuntimeAction(clipName, runtimeClip.channel);
1041
+ const channelBlendMode = resolveBakedChannelBlendMode(
1042
+ runtimeClip.channel,
1043
+ playbackState.requestedBlendMode
1044
+ ) ?? "replace";
1045
+ const action = this.getOrCreateBakedRuntimeAction(
1046
+ clipName,
1047
+ runtimeClip.channel,
1048
+ channelBlendMode
1049
+ );
646
1050
  if (action) {
647
1051
  channelActions.set(runtimeClip.channel, action);
648
1052
  }
@@ -670,12 +1074,7 @@ var BakedAnimationController = class {
670
1074
  if (typeof this.host.getMeshNamesForAU === "function") {
671
1075
  return this.host.getMeshNamesForAU(auId) || [];
672
1076
  }
673
- const facePart = config.auInfo?.[String(auId)]?.facePart;
674
- if (facePart) {
675
- const category = config.auFacePartToMeshCategory?.[facePart];
676
- if (category) return config.morphToMesh?.[category] || [];
677
- }
678
- return config.morphToMesh?.face || [];
1077
+ return getMeshNamesForAUProfile(config, auId);
679
1078
  }
680
1079
  getMeshNamesForViseme(config, explicitMeshNames) {
681
1080
  if (explicitMeshNames && explicitMeshNames.length > 0) {
@@ -684,18 +1083,41 @@ var BakedAnimationController = class {
684
1083
  if (typeof this.host.getMeshNamesForViseme === "function") {
685
1084
  return this.host.getMeshNamesForViseme() || [];
686
1085
  }
687
- const category = config.visemeMeshCategory || (config.morphToMesh?.viseme ? "viseme" : "face");
688
- const visemeMeshes = config.morphToMesh?.[category];
689
- if (visemeMeshes && visemeMeshes.length > 0) return visemeMeshes;
690
- return config.morphToMesh?.face || [];
1086
+ return getMeshNamesForVisemeProfile(config);
1087
+ }
1088
+ hasActiveAdditivePlayback() {
1089
+ for (const [clipName, group] of this.bakedActionGroups) {
1090
+ const state = this.playbackState.get(clipName);
1091
+ if (state?.blendMode !== "additive") {
1092
+ continue;
1093
+ }
1094
+ for (const action of group.channelActions.values()) {
1095
+ if (action.isRunning() && !action.paused) {
1096
+ return true;
1097
+ }
1098
+ }
1099
+ }
1100
+ for (const [clipName, action] of this.animationActions) {
1101
+ const state = this.playbackState.get(clipName);
1102
+ if (state?.blendMode !== "additive") {
1103
+ continue;
1104
+ }
1105
+ if (action.isRunning() && !action.paused) {
1106
+ return true;
1107
+ }
1108
+ }
1109
+ return false;
691
1110
  }
692
1111
  update(dtSeconds) {
693
1112
  if (this.animationMixer) {
1113
+ this.animationMixer.update(dtSeconds);
1114
+ }
1115
+ if (this.clipAnimationMixer) {
694
1116
  const snapshots = Array.from(this.clipMonitors.values()).map((monitor) => ({
695
1117
  actionId: monitor.actionId,
696
1118
  previousTime: monitor.action.time
697
1119
  }));
698
- this.animationMixer.update(dtSeconds);
1120
+ this.clipAnimationMixer.update(dtSeconds);
699
1121
  for (const { actionId, previousTime } of snapshots) {
700
1122
  const monitor = this.clipMonitors.get(actionId);
701
1123
  if (!monitor) continue;
@@ -714,16 +1136,27 @@ var BakedAnimationController = class {
714
1136
  }
715
1137
  }
716
1138
  }
1139
+ if (this.hasActiveAdditivePlayback()) {
1140
+ this.host.reapplyProceduralState?.();
1141
+ }
717
1142
  }
718
1143
  dispose() {
719
1144
  this.stopAllAnimations();
1145
+ this.clearAllBakedAdditiveRuntimeClips();
720
1146
  if (this.animationMixer) {
721
1147
  this.animationMixer.stopAllAction();
722
1148
  this.animationMixer = null;
723
1149
  }
1150
+ if (this.clipAnimationMixer) {
1151
+ this.clipAnimationMixer.stopAllAction();
1152
+ this.clipAnimationMixer = null;
1153
+ }
1154
+ this.mixerFinishedListenerAttached = false;
1155
+ this.clipMixerFinishedListenerAttached = false;
724
1156
  this.animationClips = [];
725
1157
  this.bakedSourceClips.clear();
726
1158
  this.bakedRuntimeActions.clear();
1159
+ this.bakedAdditiveRuntimeClips.clear();
727
1160
  this.bakedActionGroups.clear();
728
1161
  this.bakedRuntimeClipToSource.clear();
729
1162
  this.animationActions.clear();
@@ -746,6 +1179,8 @@ var BakedAnimationController = class {
746
1179
  if (this.animationMixer) {
747
1180
  for (const bakedClip of this.bakedSourceClips.values()) {
748
1181
  for (const runtimeClip of bakedClip.runtimeClips) {
1182
+ this.releaseBakedRuntimeAction(runtimeClip.clip.name);
1183
+ this.clearBakedAdditiveRuntimeClip(runtimeClip.clip.name);
749
1184
  try {
750
1185
  this.animationMixer.uncacheAction(runtimeClip.clip);
751
1186
  } catch {
@@ -757,6 +1192,7 @@ var BakedAnimationController = class {
757
1192
  }
758
1193
  }
759
1194
  }
1195
+ this.clearAllBakedAdditiveRuntimeClips();
760
1196
  for (const clipName of this.bakedSourceClips.keys()) {
761
1197
  this.playbackState.delete(clipName);
762
1198
  this.clipSources.delete(clipName);
@@ -797,6 +1233,8 @@ var BakedAnimationController = class {
797
1233
  if (this.animationMixer) {
798
1234
  for (const runtimeClip of bakedClip.runtimeClips) {
799
1235
  const action = this.bakedRuntimeActions.get(runtimeClip.clip.name);
1236
+ this.releaseBakedRuntimeAction(runtimeClip.clip.name);
1237
+ this.clearBakedAdditiveRuntimeClip(runtimeClip.clip.name);
800
1238
  try {
801
1239
  this.animationMixer.uncacheAction(runtimeClip.clip);
802
1240
  } catch {
@@ -805,7 +1243,6 @@ var BakedAnimationController = class {
805
1243
  this.animationMixer.uncacheClip(runtimeClip.clip);
806
1244
  } catch {
807
1245
  }
808
- this.bakedRuntimeActions.delete(runtimeClip.clip.name);
809
1246
  this.bakedRuntimeClipToSource.delete(runtimeClip.clip.name);
810
1247
  const actionId = this.getActionId(action);
811
1248
  if (actionId && action) {
@@ -814,6 +1251,10 @@ var BakedAnimationController = class {
814
1251
  }
815
1252
  }
816
1253
  }
1254
+ for (const runtimeClip of bakedClip.runtimeClips) {
1255
+ this.clearBakedAdditiveRuntimeClip(runtimeClip.clip.name);
1256
+ this.bakedRuntimeActions.delete(runtimeClip.clip.name);
1257
+ }
817
1258
  this.animationClips = this.animationClips.filter((entry) => entry.name !== clipName);
818
1259
  this.bakedSourceClips.delete(clipName);
819
1260
  this.bakedActionGroups.delete(clipName);
@@ -874,12 +1315,12 @@ var BakedAnimationController = class {
874
1315
  const actionId = this.getActionId(action);
875
1316
  const isBaked = (this.clipSources.get(clipName) ?? "baked") === "baked";
876
1317
  action.stop();
877
- if (!isBaked && this.animationMixer) {
1318
+ if (!isBaked && this.clipAnimationMixer) {
878
1319
  try {
879
1320
  const clip = action.getClip();
880
1321
  if (clip) {
881
- this.animationMixer.uncacheAction(clip);
882
- this.animationMixer.uncacheClip(clip);
1322
+ this.clipAnimationMixer.uncacheAction(clip);
1323
+ this.clipAnimationMixer.uncacheClip(clip);
883
1324
  }
884
1325
  } catch {
885
1326
  }
@@ -901,11 +1342,11 @@ var BakedAnimationController = class {
901
1342
  const actionId = this.getActionId(clipAction);
902
1343
  try {
903
1344
  clipAction.stop();
904
- if (this.animationMixer) {
1345
+ if (this.clipAnimationMixer) {
905
1346
  const clip = clipAction.getClip();
906
1347
  if (clip) {
907
- this.animationMixer.uncacheAction(clip);
908
- this.animationMixer.uncacheClip(clip);
1348
+ this.clipAnimationMixer.uncacheAction(clip);
1349
+ this.clipAnimationMixer.uncacheClip(clip);
909
1350
  }
910
1351
  }
911
1352
  } catch {
@@ -1109,8 +1550,23 @@ var BakedAnimationController = class {
1109
1550
  next.blendMode = this.getBakedAggregateBlendMode(clipName, next);
1110
1551
  const bakedGroup = this.bakedActionGroups.get(clipName);
1111
1552
  if (bakedGroup) {
1112
- for (const [channel, action2] of bakedGroup.channelActions) {
1553
+ for (const [channel, currentAction] of Array.from(bakedGroup.channelActions)) {
1554
+ const channelBlendMode = resolveBakedChannelBlendMode(channel, next.requestedBlendMode) ?? "replace";
1555
+ const previousTime = currentAction.time;
1556
+ const wasActive = currentAction.isRunning() || currentAction.paused;
1557
+ const wasPaused = currentAction.paused;
1558
+ const action2 = this.getOrCreateBakedRuntimeAction(clipName, channel, channelBlendMode);
1559
+ if (!action2) {
1560
+ bakedGroup.channelActions.delete(channel);
1561
+ continue;
1562
+ }
1113
1563
  this.applyPlaybackStateToBakedAction(action2, next, channel);
1564
+ action2.time = Math.max(0, Math.min(action2.getClip().duration, previousTime));
1565
+ if (action2 !== currentAction && wasActive) {
1566
+ action2.play();
1567
+ }
1568
+ action2.paused = wasPaused;
1569
+ bakedGroup.channelActions.set(channel, action2);
1114
1570
  }
1115
1571
  }
1116
1572
  this.setPlaybackState(clipName, next);
@@ -1125,6 +1581,10 @@ var BakedAnimationController = class {
1125
1581
  seekAnimation(clipName, time) {
1126
1582
  const bakedGroup = this.bakedActionGroups.get(clipName);
1127
1583
  if (bakedGroup) {
1584
+ const state = this.getPlaybackStateSnapshot(clipName, {
1585
+ loop: true,
1586
+ source: this.clipSources.get(clipName) ?? "baked"
1587
+ });
1128
1588
  const duration2 = this.getBakedSourceClip(clipName)?.sourceClip.duration ?? 0;
1129
1589
  const clamped = Math.max(0, Math.min(duration2, Number.isFinite(time) ? time : 0));
1130
1590
  for (const action2 of bakedGroup.channelActions.values()) {
@@ -1132,6 +1592,10 @@ var BakedAnimationController = class {
1132
1592
  }
1133
1593
  try {
1134
1594
  this.animationMixer?.update(0);
1595
+ this.clipAnimationMixer?.update(0);
1596
+ if (state.blendMode === "additive") {
1597
+ this.host.reapplyProceduralState?.();
1598
+ }
1135
1599
  } catch {
1136
1600
  }
1137
1601
  return;
@@ -1141,7 +1605,11 @@ var BakedAnimationController = class {
1141
1605
  const duration = action.getClip().duration;
1142
1606
  action.time = Math.max(0, Math.min(duration, Number.isFinite(time) ? time : 0));
1143
1607
  try {
1144
- this.animationMixer?.update(0);
1608
+ this.clipAnimationMixer?.update(0);
1609
+ const state = this.playbackState.get(clipName);
1610
+ if (state?.blendMode === "additive") {
1611
+ this.host.reapplyProceduralState?.();
1612
+ }
1145
1613
  } catch {
1146
1614
  }
1147
1615
  }
@@ -1149,6 +1617,9 @@ var BakedAnimationController = class {
1149
1617
  if (this.animationMixer) {
1150
1618
  this.animationMixer.timeScale = timeScale;
1151
1619
  }
1620
+ if (this.clipAnimationMixer) {
1621
+ this.clipAnimationMixer.timeScale = timeScale;
1622
+ }
1152
1623
  }
1153
1624
  getAnimationState(clipName) {
1154
1625
  const bakedClip = this.getBakedSourceClip(clipName);
@@ -1478,8 +1949,8 @@ var BakedAnimationController = class {
1478
1949
  return clip;
1479
1950
  }
1480
1951
  playClip(clip, options) {
1481
- this.ensureMixer();
1482
- if (!this.animationMixer) {
1952
+ const mixer = this.ensureClipMixer();
1953
+ if (!mixer) {
1483
1954
  console.warn("[Loom3] playClip: No model loaded, cannot create mixer");
1484
1955
  return null;
1485
1956
  }
@@ -1497,7 +1968,7 @@ var BakedAnimationController = class {
1497
1968
  actionId = this.setActionId(action, clip.name);
1498
1969
  }
1499
1970
  if (!action) {
1500
- action = this.animationMixer.clipAction(clip);
1971
+ action = mixer.clipAction(clip);
1501
1972
  actionId = this.setActionId(action, clip.name);
1502
1973
  }
1503
1974
  const existingClip = this.animationClips.find((c) => c.name === clip.name);
@@ -1558,15 +2029,13 @@ var BakedAnimationController = class {
1558
2029
  },
1559
2030
  stop: () => {
1560
2031
  action.stop();
1561
- if (this.animationMixer) {
1562
- try {
1563
- this.animationMixer.uncacheAction(clip);
1564
- } catch {
1565
- }
1566
- try {
1567
- this.animationMixer.uncacheClip(clip);
1568
- } catch {
1569
- }
2032
+ try {
2033
+ mixer.uncacheAction(clip);
2034
+ } catch {
2035
+ }
2036
+ try {
2037
+ mixer.uncacheClip(clip);
2038
+ } catch {
1570
2039
  }
1571
2040
  this.clipActions.delete(clip.name);
1572
2041
  this.animationActions.delete(clip.name);
@@ -1607,7 +2076,7 @@ var BakedAnimationController = class {
1607
2076
  const clamped = Math.max(0, Math.min(clip.duration, t));
1608
2077
  action.time = clamped;
1609
2078
  try {
1610
- this.animationMixer?.update(0);
2079
+ mixer.update(0);
1611
2080
  } catch {
1612
2081
  }
1613
2082
  this.syncClipMonitorTime(monitor, clamped, true);
@@ -1640,16 +2109,16 @@ var BakedAnimationController = class {
1640
2109
  return this.playClip(clip, { ...options, source: options?.source ?? "clip" });
1641
2110
  }
1642
2111
  cleanupSnippet(name) {
1643
- if (!this.animationMixer || !this.host.getModel()) return;
2112
+ if (!this.host.getModel()) return;
1644
2113
  for (const [clipName, action] of Array.from(this.clipActions.entries())) {
1645
2114
  if (clipName === name || clipName.startsWith(`${name}_`)) {
1646
2115
  const actionId = this.getActionId(action);
1647
2116
  try {
1648
2117
  action.stop();
1649
2118
  const clip = action.getClip();
1650
- if (clip) {
1651
- this.animationMixer.uncacheAction(clip);
1652
- this.animationMixer.uncacheClip(clip);
2119
+ if (clip && this.clipAnimationMixer) {
2120
+ this.clipAnimationMixer.uncacheAction(clip);
2121
+ this.clipAnimationMixer.uncacheClip(clip);
1653
2122
  }
1654
2123
  } catch {
1655
2124
  }
@@ -1677,7 +2146,10 @@ var BakedAnimationController = class {
1677
2146
  clipActions: Array.from(this.clipActions.entries()).map(([k, a]) => ({ name: k, actionId: this.getActionId(a) })),
1678
2147
  animationActions: Array.from(this.animationActions.entries()).map(([k, a]) => ({ name: k, actionId: this.getActionId(a) })),
1679
2148
  clipHandles: Array.from(this.clipHandles.entries()).map(([k, h]) => ({ name: k, actionId: h.actionId })),
1680
- mixerActions: (this.animationMixer?._actions || []).map((a) => ({ name: a?.getClip?.()?.name || "", actionId: this.getActionId(a) }))
2149
+ mixerActions: [
2150
+ ...this.animationMixer?._actions || [],
2151
+ ...this.clipAnimationMixer?._actions || []
2152
+ ].map((a) => ({ name: a?.getClip?.()?.name || "", actionId: this.getActionId(a) }))
1681
2153
  });
1682
2154
  console.log("[Loom3] updateClipParams start", debugSnapshot());
1683
2155
  const apply = (action) => {
@@ -1740,7 +2212,7 @@ var BakedAnimationController = class {
1740
2212
  }
1741
2213
  addMorphTracks(tracks, morphKey, keyframes, intensityScale, meshNames) {
1742
2214
  const config = this.host.getConfig();
1743
- const hasExplicitMeshes = !!(meshNames && meshNames.length > 0);
2215
+ const hasExplicitMeshes = meshNames !== void 0;
1744
2216
  const targetMeshNames = hasExplicitMeshes ? meshNames : config.morphToMesh?.face || [];
1745
2217
  const targetMeshes = targetMeshNames.length ? targetMeshNames.map((name) => this.host.getMeshByName(name)).filter(Boolean) : [];
1746
2218
  const addTrackForMesh = (mesh) => {
@@ -1764,7 +2236,7 @@ var BakedAnimationController = class {
1764
2236
  addMorphIndexTracks(tracks, morphIndex, keyframes, intensityScale, meshNames) {
1765
2237
  if (!Number.isInteger(morphIndex) || morphIndex < 0) return;
1766
2238
  const config = this.host.getConfig();
1767
- const hasExplicitMeshes = !!(meshNames && meshNames.length > 0);
2239
+ const hasExplicitMeshes = meshNames !== void 0;
1768
2240
  const targetMeshNames = hasExplicitMeshes ? meshNames : config.morphToMesh?.face || [];
1769
2241
  const targetMeshes = targetMeshNames.length ? targetMeshNames.map((name) => this.host.getMeshByName(name)).filter(Boolean) : [];
1770
2242
  const addTrackForMesh = (mesh) => {
@@ -1791,35 +2263,48 @@ var BakedAnimationController = class {
1791
2263
  this.animationMixer = new THREE2.AnimationMixer(model);
1792
2264
  }
1793
2265
  if (this.animationMixer && !this.mixerFinishedListenerAttached) {
1794
- this.animationMixer.addEventListener("finished", (event) => {
1795
- const action = event.action;
1796
- const actionId = this.getActionId(action);
1797
- if (actionId) {
1798
- const monitor = this.clipMonitors.get(actionId);
1799
- if (monitor) {
1800
- monitor.finishedPending = true;
1801
- return;
1802
- }
1803
- }
1804
- const clip = action.getClip();
1805
- const bakedRuntime = this.bakedRuntimeClipToSource.get(clip.name);
1806
- if (bakedRuntime) {
1807
- const group = this.bakedActionGroups.get(bakedRuntime.sourceClipName);
1808
- if (group && group.pendingFinishedChannels.delete(bakedRuntime.channel) && group.pendingFinishedChannels.size === 0) {
1809
- group.resolveFinished();
1810
- }
1811
- return;
1812
- }
1813
- const callback = this.animationFinishedCallbacks.get(clip.name);
1814
- if (callback) {
1815
- callback();
1816
- this.animationFinishedCallbacks.delete(clip.name);
1817
- }
1818
- });
2266
+ this.animationMixer.addEventListener("finished", (event) => this.handleMixerFinished(event));
1819
2267
  this.mixerFinishedListenerAttached = true;
1820
2268
  }
1821
2269
  return this.animationMixer;
1822
2270
  }
2271
+ ensureClipMixer() {
2272
+ const model = this.host.getModel();
2273
+ if (!model) return null;
2274
+ if (!this.clipAnimationMixer) {
2275
+ this.clipAnimationMixer = new THREE2.AnimationMixer(model);
2276
+ }
2277
+ if (this.clipAnimationMixer && !this.clipMixerFinishedListenerAttached) {
2278
+ this.clipAnimationMixer.addEventListener("finished", (event) => this.handleMixerFinished(event));
2279
+ this.clipMixerFinishedListenerAttached = true;
2280
+ }
2281
+ return this.clipAnimationMixer;
2282
+ }
2283
+ handleMixerFinished(event) {
2284
+ const action = event.action;
2285
+ const actionId = this.getActionId(action);
2286
+ if (actionId) {
2287
+ const monitor = this.clipMonitors.get(actionId);
2288
+ if (monitor) {
2289
+ monitor.finishedPending = true;
2290
+ return;
2291
+ }
2292
+ }
2293
+ const clip = action.getClip();
2294
+ const bakedRuntime = this.bakedRuntimeClipToSource.get(clip.name);
2295
+ if (bakedRuntime) {
2296
+ const group = this.bakedActionGroups.get(bakedRuntime.sourceClipName);
2297
+ if (group && group.pendingFinishedChannels.delete(bakedRuntime.channel) && group.pendingFinishedChannels.size === 0) {
2298
+ group.resolveFinished();
2299
+ }
2300
+ return;
2301
+ }
2302
+ const callback = this.animationFinishedCallbacks.get(clip.name);
2303
+ if (callback) {
2304
+ callback();
2305
+ this.animationFinishedCallbacks.delete(clip.name);
2306
+ }
2307
+ }
1823
2308
  createAnimationHandle(clipName, action, finishedPromise) {
1824
2309
  return {
1825
2310
  actionId: this.getActionId(action),
@@ -2398,6 +2883,159 @@ var VISEME_JAW_AMOUNTS = [
2398
2883
  0.5
2399
2884
  // 14: W_OO
2400
2885
  ];
2886
+ var CC4_VISEME_SYSTEM_ID = "cc4-arkit-15";
2887
+ var CC4_VISEME_SLOTS = [
2888
+ {
2889
+ id: "ae",
2890
+ label: "AE",
2891
+ order: 0,
2892
+ providerIds: { azure: [4], sapi: [4] },
2893
+ phonemes: ["AE", "EH", "EY", "UH"],
2894
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(ae|eh|ey)([_ .-]|$)"],
2895
+ features: { jawOpen: 0.75, lipSpread: 0.35 },
2896
+ defaultJawAmount: VISEME_JAW_AMOUNTS[0]
2897
+ },
2898
+ {
2899
+ id: "ah",
2900
+ label: "Ah",
2901
+ order: 1,
2902
+ providerIds: { azure: [1, 2, 9, 11, 12], sapi: [1, 2, 9, 11, 12] },
2903
+ phonemes: ["AA", "AE", "AH", "AX", "AW", "AY", "HH"],
2904
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(ah|aa|aah|open)([_ .-]|$)"],
2905
+ features: { jawOpen: 0.8 },
2906
+ defaultJawAmount: VISEME_JAW_AMOUNTS[1]
2907
+ },
2908
+ {
2909
+ id: "b-m-p",
2910
+ label: "B_M_P",
2911
+ order: 2,
2912
+ providerIds: { azure: [0, 21], sapi: [0, 21] },
2913
+ phonemes: ["B", "M", "P"],
2914
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(b[_ .-]?m[_ .-]?p|bmp|closed|sil|rest)([_ .-]|$)"],
2915
+ features: { jawOpen: 0, lipClosed: 1 },
2916
+ defaultJawAmount: VISEME_JAW_AMOUNTS[2]
2917
+ },
2918
+ {
2919
+ id: "ch-j",
2920
+ label: "Ch_J",
2921
+ order: 3,
2922
+ providerIds: { azure: [16], sapi: [16] },
2923
+ phonemes: ["CH", "JH", "SH", "ZH"],
2924
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(ch|j|sh|zh)([_ .-]|$)"],
2925
+ features: { jawOpen: 0.3, fricative: 0.6 },
2926
+ defaultJawAmount: VISEME_JAW_AMOUNTS[3]
2927
+ },
2928
+ {
2929
+ id: "ee",
2930
+ label: "EE",
2931
+ order: 4,
2932
+ providerIds: { azure: [6], sapi: [6] },
2933
+ phonemes: ["IY", "IH", "IX", "Y"],
2934
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(ee|iy|y)([_ .-]|$)"],
2935
+ features: { jawOpen: 0.2, lipSpread: 0.8 },
2936
+ defaultJawAmount: VISEME_JAW_AMOUNTS[4]
2937
+ },
2938
+ {
2939
+ id: "er",
2940
+ label: "Er",
2941
+ order: 5,
2942
+ providerIds: { azure: [5], sapi: [5] },
2943
+ phonemes: ["ER"],
2944
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(er)([_ .-]|$)"],
2945
+ features: { jawOpen: 0.35, lipRound: 0.35 },
2946
+ defaultJawAmount: VISEME_JAW_AMOUNTS[5]
2947
+ },
2948
+ {
2949
+ id: "f-v",
2950
+ label: "F_V",
2951
+ order: 6,
2952
+ providerIds: { azure: [18], sapi: [18] },
2953
+ phonemes: ["F", "V"],
2954
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(f[_ .-]?v|fv)([_ .-]|$)"],
2955
+ features: { jawOpen: 0.1, fricative: 1 },
2956
+ defaultJawAmount: VISEME_JAW_AMOUNTS[6]
2957
+ },
2958
+ {
2959
+ id: "ih",
2960
+ label: "Ih",
2961
+ order: 7,
2962
+ providerIds: { azure: [6], sapi: [6] },
2963
+ phonemes: ["IH", "IX"],
2964
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(ih|ix)([_ .-]|$)"],
2965
+ features: { jawOpen: 0.2, lipSpread: 0.55 },
2966
+ defaultJawAmount: VISEME_JAW_AMOUNTS[7]
2967
+ },
2968
+ {
2969
+ id: "k-g-h-ng",
2970
+ label: "K_G_H_NG",
2971
+ order: 8,
2972
+ providerIds: { azure: [20], sapi: [20] },
2973
+ phonemes: ["K", "G", "NG"],
2974
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(k[_ .-]?g[_ .-]?h?[_ .-]?ng|kg|ng)([_ .-]|$)"],
2975
+ features: { jawOpen: 0.35 },
2976
+ defaultJawAmount: VISEME_JAW_AMOUNTS[8]
2977
+ },
2978
+ {
2979
+ id: "oh",
2980
+ label: "Oh",
2981
+ order: 9,
2982
+ providerIds: { azure: [3, 8, 10], sapi: [3, 8, 10] },
2983
+ phonemes: ["AO", "OW", "OY"],
2984
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(oh|ao|ow|oy)([_ .-]|$)"],
2985
+ features: { jawOpen: 0.6, lipRound: 0.8 },
2986
+ defaultJawAmount: VISEME_JAW_AMOUNTS[9]
2987
+ },
2988
+ {
2989
+ id: "r",
2990
+ label: "R",
2991
+ order: 10,
2992
+ providerIds: { azure: [13], sapi: [13] },
2993
+ phonemes: ["R"],
2994
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(r)([_ .-]|$)"],
2995
+ features: { jawOpen: 0.35, lipRound: 0.5 },
2996
+ defaultJawAmount: VISEME_JAW_AMOUNTS[10]
2997
+ },
2998
+ {
2999
+ id: "s-z",
3000
+ label: "S_Z",
3001
+ order: 11,
3002
+ providerIds: { azure: [15], sapi: [15] },
3003
+ phonemes: ["S", "Z"],
3004
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(s[_ .-]?z|sz)([_ .-]|$)"],
3005
+ features: { jawOpen: 0.1, fricative: 1 },
3006
+ defaultJawAmount: VISEME_JAW_AMOUNTS[11]
3007
+ },
3008
+ {
3009
+ id: "t-l-d-n",
3010
+ label: "T_L_D_N",
3011
+ order: 12,
3012
+ providerIds: { azure: [14, 19], sapi: [14, 19] },
3013
+ phonemes: ["T", "L", "D", "N"],
3014
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(t[_ .-]?l[_ .-]?d[_ .-]?n|tldn|l)([_ .-]|$)"],
3015
+ features: { jawOpen: 0.3, tongueTip: 1 },
3016
+ defaultJawAmount: VISEME_JAW_AMOUNTS[12]
3017
+ },
3018
+ {
3019
+ id: "th",
3020
+ label: "Th",
3021
+ order: 13,
3022
+ providerIds: { azure: [17], sapi: [17] },
3023
+ phonemes: ["TH", "DH"],
3024
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(th|dh)([_ .-]|$)"],
3025
+ features: { jawOpen: 0.15, tongueTip: 0.8, fricative: 0.8 },
3026
+ defaultJawAmount: VISEME_JAW_AMOUNTS[13]
3027
+ },
3028
+ {
3029
+ id: "w-oo",
3030
+ label: "W_OO",
3031
+ order: 14,
3032
+ providerIds: { azure: [7], sapi: [7] },
3033
+ phonemes: ["W", "UW"],
3034
+ matchers: ["(^|[_ .-])(v|viseme)?[_ .-]?(w[_ .-]?oo|woo|uw|oo)([_ .-]|$)"],
3035
+ features: { jawOpen: 0.5, lipRound: 1 },
3036
+ defaultJawAmount: VISEME_JAW_AMOUNTS[14]
3037
+ }
3038
+ ];
2401
3039
  var isMixedAU = (id) => {
2402
3040
  const morphs = AU_TO_MORPHS[id];
2403
3041
  const hasMorphs = !!(morphs?.left?.length || morphs?.right?.length || morphs?.center?.length);
@@ -2709,6 +3347,23 @@ var AU_FACEPART_TO_MESH_CATEGORY = {
2709
3347
  Eyelids: "eye",
2710
3348
  Tongue: "tongue"
2711
3349
  };
3350
+ var CC4_MAPPING_SECTIONS = [
3351
+ { id: "Forehead", label: "Forehead", kind: "au", order: 0, meshCategory: "face", facePart: "Forehead" },
3352
+ { id: "Eyelids", label: "Eyelids", kind: "au", order: 1, meshCategory: "eye", facePart: "Eyelids" },
3353
+ { id: "Eyes", label: "Eyes", kind: "au", order: 2, meshCategory: "eye", facePart: "Eyes" },
3354
+ { id: "Cheeks", label: "Cheeks", kind: "au", order: 3, meshCategory: "face", facePart: "Cheeks" },
3355
+ { id: "Nose", label: "Nose", kind: "au", order: 4, meshCategory: "face", facePart: "Nose" },
3356
+ { id: "Mouth", label: "Mouth", kind: "au", order: 5, meshCategory: "face", facePart: "Mouth" },
3357
+ { id: "Chin", label: "Chin", kind: "au", order: 6, meshCategory: "face", facePart: "Chin" },
3358
+ { id: "Jaw", label: "Jaw", kind: "au", order: 7, meshCategory: "face", facePart: "Jaw" },
3359
+ { id: "Tongue", label: "Tongue", kind: "au", order: 8, meshCategory: "tongue", facePart: "Tongue" },
3360
+ { id: "Head", label: "Head", kind: "au", order: 9, meshCategory: "face", facePart: "Head" },
3361
+ { id: "Joint Controls", label: "Joint Controls", kind: "au", order: 10, meshCategory: "face", facePart: "Joint Controls" },
3362
+ { id: "Eye", label: "Eye", kind: "au", order: 11, meshCategory: "eye", facePart: "Eye" },
3363
+ { id: "Hair", label: "Hair", kind: "hair", order: 12, meshCategory: "hair" },
3364
+ { id: "Visemes", label: "Visemes", kind: "viseme", order: 13, meshCategory: "viseme" },
3365
+ { id: "Unmapped", label: "Unmapped", kind: "unmapped", order: 14, meshCategory: "face" }
3366
+ ];
2712
3367
  var CC4_HAIR_PHYSICS = {
2713
3368
  stiffness: 7.5,
2714
3369
  damping: 0.18,
@@ -2755,7 +3410,10 @@ var CC4_PRESET = {
2755
3410
  suffixPattern: CC4_SUFFIX_PATTERN,
2756
3411
  morphToMesh: MORPH_TO_MESH,
2757
3412
  auFacePartToMeshCategory: AU_FACEPART_TO_MESH_CATEGORY,
3413
+ mappingSections: CC4_MAPPING_SECTIONS,
2758
3414
  visemeKeys: VISEME_KEYS,
3415
+ visemeSystemId: CC4_VISEME_SYSTEM_ID,
3416
+ visemeSlots: CC4_VISEME_SLOTS,
2759
3417
  visemeMeshCategory: "viseme",
2760
3418
  visemeJawAmounts: VISEME_JAW_AMOUNTS,
2761
3419
  auMixDefaults: AU_MIX_DEFAULTS,
@@ -3642,8 +4300,13 @@ function extendPresetWithProfile(base, extension) {
3642
4300
  boneNodes: mergeRecord(base.boneNodes, extension.boneNodes),
3643
4301
  morphToMesh: mergeRecord(base.morphToMesh, extension.morphToMesh),
3644
4302
  auFacePartToMeshCategory: base.auFacePartToMeshCategory || extension.auFacePartToMeshCategory ? mergeRecord(base.auFacePartToMeshCategory || {}, extension.auFacePartToMeshCategory || {}) : void 0,
4303
+ mappingSections: extension.mappingSections ? [...extension.mappingSections] : base.mappingSections ? [...base.mappingSections] : void 0,
3645
4304
  visemeKeys: extension.visemeKeys ? [...extension.visemeKeys] : [...base.visemeKeys],
4305
+ visemeSystemId: extension.visemeSystemId ?? base.visemeSystemId,
4306
+ visemeSlots: extension.visemeSlots ? [...extension.visemeSlots] : base.visemeSlots ? [...base.visemeSlots] : void 0,
4307
+ visemeBindings: base.visemeBindings || extension.visemeBindings ? mergeRecord(base.visemeBindings || {}, extension.visemeBindings || {}) : void 0,
3646
4308
  visemeMeshCategory: extension.visemeMeshCategory ?? base.visemeMeshCategory,
4309
+ visemeJawAmounts: extension.visemeJawAmounts ? [...extension.visemeJawAmounts] : base.visemeJawAmounts ? [...base.visemeJawAmounts] : void 0,
3647
4310
  auMixDefaults: base.auMixDefaults || extension.auMixDefaults ? mergeRecord(base.auMixDefaults || {}, extension.auMixDefaults || {}) : void 0,
3648
4311
  auInfo: base.auInfo || extension.auInfo ? mergeRecord(base.auInfo || {}, extension.auInfo || {}) : void 0,
3649
4312
  eyeMeshNodes: extension.eyeMeshNodes ?? base.eyeMeshNodes,
@@ -4605,7 +5268,8 @@ var _Loom3 = class _Loom3 {
4605
5268
  getCompositeRotations: () => this.compositeRotations,
4606
5269
  computeSideValues: (base, balance) => this.computeSideValues(base, balance),
4607
5270
  getAUMixWeight: (auId) => this.getAUMixWeight(auId),
4608
- isMixedAU: (auId) => this.isMixedAU(auId)
5271
+ isMixedAU: (auId) => this.isMixedAU(auId),
5272
+ reapplyProceduralState: () => this.reapplyProceduralStateAfterBakedUpdate()
4609
5273
  });
4610
5274
  this.hairPhysics = new HairPhysicsController({
4611
5275
  getMeshByName: (name) => this.meshByName.get(name),
@@ -5209,6 +5873,21 @@ var _Loom3 = class _Loom3 {
5209
5873
  const jawHandle = this.transitionBoneRotation("JAW", "pitch", jawAmount, durationMs);
5210
5874
  return this.combineHandles([morphHandle, jawHandle]);
5211
5875
  }
5876
+ setVisemeById(slotId, value, jawScale = 1) {
5877
+ const index = getVisemeSlotIndex(this.config, slotId);
5878
+ if (index < 0) return;
5879
+ this.setViseme(index, value, jawScale);
5880
+ }
5881
+ transitionVisemeById(slotId, to, durationMs = 80, jawScale = 1) {
5882
+ const index = getVisemeSlotIndex(this.config, slotId);
5883
+ if (index < 0) {
5884
+ return { promise: Promise.resolve(), pause: () => {
5885
+ }, resume: () => {
5886
+ }, cancel: () => {
5887
+ } };
5888
+ }
5889
+ return this.transitionViseme(index, to, durationMs, jawScale);
5890
+ }
5212
5891
  // ============================================================================
5213
5892
  // MIX WEIGHT CONTROL
5214
5893
  // ============================================================================
@@ -5301,6 +5980,30 @@ var _Loom3 = class _Loom3 {
5301
5980
  this.model.updateMatrixWorld(true);
5302
5981
  }
5303
5982
  }
5983
+ reapplyProceduralStateAfterBakedUpdate() {
5984
+ if (!this.model) {
5985
+ return;
5986
+ }
5987
+ let hasActiveOverrides = false;
5988
+ for (const [auIdStr, value] of Object.entries(this.auValues)) {
5989
+ if (value <= 0) continue;
5990
+ const auId = Number(auIdStr);
5991
+ if (Number.isNaN(auId)) continue;
5992
+ hasActiveOverrides = true;
5993
+ this.setAU(auId, value, this.auBalances[auId]);
5994
+ }
5995
+ for (let visemeIndex = 0; visemeIndex < this.visemeValues.length; visemeIndex += 1) {
5996
+ const value = this.visemeValues[visemeIndex] ?? 0;
5997
+ if (value <= 0) continue;
5998
+ hasActiveOverrides = true;
5999
+ this.setViseme(visemeIndex, value, this.visemeJawScales[visemeIndex] ?? 1);
6000
+ }
6001
+ if (!hasActiveOverrides) {
6002
+ return;
6003
+ }
6004
+ this.flushPendingComposites();
6005
+ this.model.updateMatrixWorld(true);
6006
+ }
5304
6007
  // ============================================================================
5305
6008
  // MESH CONTROL
5306
6009
  // ============================================================================
@@ -5554,21 +6257,10 @@ var _Loom3 = class _Loom3 {
5554
6257
  * Routing is driven by `auFacePartToMeshCategory` in profile config.
5555
6258
  */
5556
6259
  getMeshNamesForAU(auId) {
5557
- const m = this.config.morphToMesh;
5558
- const info = this.config.auInfo?.[String(auId)];
5559
- const facePart = info?.facePart;
5560
- if (facePart) {
5561
- const category = this.config.auFacePartToMeshCategory?.[facePart];
5562
- if (category) {
5563
- return m?.[category] || [];
5564
- }
5565
- }
5566
- return m?.face || [];
6260
+ return getMeshNamesForAUProfile(this.config, auId);
5567
6261
  }
5568
6262
  getMeshNamesForViseme() {
5569
- const m = this.config.morphToMesh;
5570
- const category = this.config.visemeMeshCategory || (m?.viseme ? "viseme" : "face");
5571
- return m?.[category] || m?.face || [];
6263
+ return getMeshNamesForVisemeProfile(this.config);
5572
6264
  }
5573
6265
  // ============================================================================
5574
6266
  // HAIR PHYSICS
@@ -5836,7 +6528,7 @@ var _Loom3 = class _Loom3 {
5836
6528
  );
5837
6529
  }
5838
6530
  getVisemeJawAmount(visemeIndex) {
5839
- return this.config.visemeJawAmounts?.[visemeIndex] ?? _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] ?? 0;
6531
+ return getVisemeJawAmounts(this.config)?.[visemeIndex] ?? this.config.visemeJawAmounts?.[visemeIndex] ?? _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] ?? 0;
5840
6532
  }
5841
6533
  collectResolvedExpressionMorphTargets() {
5842
6534
  const targets = [];
@@ -6314,7 +7006,11 @@ var PROFILE_OVERRIDE_KEYS = [
6314
7006
  "rightMorphSuffixes",
6315
7007
  "morphToMesh",
6316
7008
  "auFacePartToMeshCategory",
7009
+ "mappingSections",
6317
7010
  "visemeKeys",
7011
+ "visemeSystemId",
7012
+ "visemeSlots",
7013
+ "visemeBindings",
6318
7014
  "visemeMeshCategory",
6319
7015
  "visemeJawAmounts",
6320
7016
  "auMixDefaults",
@@ -7414,6 +8110,33 @@ function validateMappingConfig(config) {
7414
8110
  }
7415
8111
  visemeSeen.add(key);
7416
8112
  }
8113
+ const morphCategories = new Set(Object.keys(config.morphToMesh || {}));
8114
+ if (config.visemeMeshCategory && !morphCategories.has(config.visemeMeshCategory)) {
8115
+ push(
8116
+ "error",
8117
+ "VISEME_MESH_CATEGORY_MISSING",
8118
+ `visemeMeshCategory "${config.visemeMeshCategory}" is not present in morphToMesh`,
8119
+ { category: config.visemeMeshCategory }
8120
+ );
8121
+ }
8122
+ for (const [facePart, category] of Object.entries(config.auFacePartToMeshCategory || {})) {
8123
+ if (!morphCategories.has(category)) {
8124
+ push(
8125
+ "error",
8126
+ "AU_MESH_CATEGORY_MISSING",
8127
+ `AU facePart "${facePart}" routes to missing morphToMesh category "${category}"`,
8128
+ { facePart, category }
8129
+ );
8130
+ }
8131
+ }
8132
+ if (config.visemeJawAmounts && config.visemeJawAmounts.length !== (config.visemeKeys || []).length) {
8133
+ push(
8134
+ "warning",
8135
+ "VISEME_JAW_AMOUNT_LENGTH_MISMATCH",
8136
+ "visemeJawAmounts length does not match visemeKeys length",
8137
+ { visemeKeys: (config.visemeKeys || []).length, visemeJawAmounts: config.visemeJawAmounts.length }
8138
+ );
8139
+ }
7417
8140
  if (config.auMixDefaults) {
7418
8141
  for (const key of Object.keys(config.auMixDefaults)) {
7419
8142
  const auId = Number(key);
@@ -7878,9 +8601,12 @@ exports.BONE_AU_TO_BINDINGS = BONE_AU_TO_BINDINGS;
7878
8601
  exports.CC4_BONE_NODES = CC4_BONE_NODES;
7879
8602
  exports.CC4_BONE_PREFIX = CC4_BONE_PREFIX;
7880
8603
  exports.CC4_EYE_MESH_NODES = CC4_EYE_MESH_NODES;
8604
+ exports.CC4_MAPPING_SECTIONS = CC4_MAPPING_SECTIONS;
7881
8605
  exports.CC4_MESHES = CC4_MESHES;
7882
8606
  exports.CC4_PRESET = CC4_PRESET;
7883
8607
  exports.CC4_SUFFIX_PATTERN = CC4_SUFFIX_PATTERN;
8608
+ exports.CC4_VISEME_SLOTS = CC4_VISEME_SLOTS;
8609
+ exports.CC4_VISEME_SYSTEM_ID = CC4_VISEME_SYSTEM_ID;
7884
8610
  exports.COMPOSITE_ROTATIONS = COMPOSITE_ROTATIONS;
7885
8611
  exports.CONTINUUM_LABELS = CONTINUUM_LABELS;
7886
8612
  exports.CONTINUUM_PAIRS_MAP = CONTINUUM_PAIRS_MAP;
@@ -7895,7 +8621,9 @@ exports.VISEME_JAW_AMOUNTS = VISEME_JAW_AMOUNTS;
7895
8621
  exports.VISEME_KEYS = VISEME_KEYS;
7896
8622
  exports.analyzeModel = analyzeModel;
7897
8623
  exports.applyCharacterProfileToPreset = applyCharacterProfileToPreset;
8624
+ exports.buildMappingEditorModel = buildMappingEditorModel;
7898
8625
  exports.collectMorphMeshes = collectMorphMeshes;
8626
+ exports.compileVisemeKeys = compileVisemeKeys;
7899
8627
  exports.computeCameraRelativeGazeOffset = computeCameraRelativeGazeOffset;
7900
8628
  exports.detectFacingDirection = detectFacingDirection;
7901
8629
  exports.extendCharacterConfigWithPreset = extendCharacterConfigWithPreset;
@@ -7906,18 +8634,25 @@ exports.extractProfileOverrides = extractProfileOverrides;
7906
8634
  exports.findFaceCenter = findFaceCenter;
7907
8635
  exports.fuzzyNameMatch = fuzzyNameMatch;
7908
8636
  exports.generateMappingCorrections = generateMappingCorrections;
8637
+ exports.getMeshNamesForAUProfile = getMeshNamesForAUProfile;
8638
+ exports.getMeshNamesForVisemeProfile = getMeshNamesForVisemeProfile;
7909
8639
  exports.getModelForwardDirection = getModelForwardDirection;
7910
8640
  exports.getPreset = getPreset;
7911
8641
  exports.getPresetWithProfile = getPresetWithProfile;
8642
+ exports.getProfileVisemeSlots = getProfileVisemeSlots;
8643
+ exports.getVisemeJawAmounts = getVisemeJawAmounts;
8644
+ exports.getVisemeSlotIndex = getVisemeSlotIndex;
7912
8645
  exports.hasLeftRightMorphs = hasLeftRightMorphs;
7913
8646
  exports.isMixedAU = isMixedAU;
7914
8647
  exports.isPresetCompatible = isPresetCompatible;
8648
+ exports.mapProviderVisemeToSlot = mapProviderVisemeToSlot;
7915
8649
  exports.mergeCharacterRegionsByName = mergeRegionsByName;
7916
8650
  exports.resolveBoneName = resolveBoneName;
7917
8651
  exports.resolveBoneNames = resolveBoneNames;
7918
8652
  exports.resolveFaceCenter = resolveFaceCenter;
7919
8653
  exports.resolvePreset = resolvePreset;
7920
8654
  exports.resolvePresetWithOverrides = resolvePresetWithOverrides;
8655
+ exports.resolveVisemeMeshCategory = resolveVisemeMeshCategory;
7921
8656
  exports.suggestBestPreset = suggestBestPreset;
7922
8657
  exports.validateMappingConfig = validateMappingConfig;
7923
8658
  exports.validateMappings = validateMappings;