@lovelace_lol/loom3 1.0.35 → 1.0.37

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
@@ -79,6 +79,119 @@ function resolveCurveBalance(curveId, globalBalance, balanceMap) {
79
79
  }
80
80
  return clampBalance(globalBalance);
81
81
  }
82
+ var RUNTIME_CLIP_PREFIX = "__loom3_baked_partition__/";
83
+ var FACE_SAFE_TARGET_RE = /(head|neck|jaw|eye|brow|lid|mouth|lip|face|cheek|nose|tongue|teeth)/i;
84
+ var BODY_LIKE_TARGET_RE = /(root|armature|hips?|pelvis|spine|waist|chest|torso|shoulder|arm|forearm|hand|finger|leg|thigh|calf|knee|foot|toe|tail|wing|fin|body|abdomen|clavicle)/i;
85
+ var SCENE_LIKE_TARGET_RE = /(camera|cam|scene|world|global|origin|pivot|cube)/i;
86
+ var CHANNEL_ORDER = ["face", "body", "scene"];
87
+ function getRuntimeClipName(sourceClipName, channel) {
88
+ return `${RUNTIME_CLIP_PREFIX}${sourceClipName}/${channel}`;
89
+ }
90
+ function parseTrackTarget(trackName, model) {
91
+ let parsed;
92
+ try {
93
+ parsed = THREE.PropertyBinding.parseTrackName(trackName);
94
+ } catch {
95
+ return null;
96
+ }
97
+ const targetKey = parsed.objectName === "bones" && parsed.objectIndex ? String(parsed.objectIndex) : parsed.nodeName;
98
+ const target = targetKey ? model.getObjectByProperty("uuid", targetKey) ?? THREE.PropertyBinding.findNode(model, targetKey) : null;
99
+ return {
100
+ propertyName: parsed.propertyName,
101
+ target,
102
+ targetName: target?.name ?? parsed.nodeName ?? ""
103
+ };
104
+ }
105
+ function isSceneTrackTarget(target, targetName) {
106
+ if (!target) return true;
107
+ if (target.isCamera) return true;
108
+ return SCENE_LIKE_TARGET_RE.test(targetName);
109
+ }
110
+ function isFaceSafeTransformTarget(target, targetName, safeTransformTargets) {
111
+ if (target && safeTransformTargets.has(target)) {
112
+ return true;
113
+ }
114
+ if (!targetName) {
115
+ return false;
116
+ }
117
+ if (BODY_LIKE_TARGET_RE.test(targetName) || SCENE_LIKE_TARGET_RE.test(targetName)) {
118
+ return false;
119
+ }
120
+ return FACE_SAFE_TARGET_RE.test(targetName);
121
+ }
122
+ function classifyBakedTrack(track, model, bones) {
123
+ const parsed = parseTrackTarget(track.name, model);
124
+ if (!parsed) {
125
+ return "scene";
126
+ }
127
+ if (parsed.propertyName === "morphTargetInfluences" || parsed.propertyName === "weights") {
128
+ return "face";
129
+ }
130
+ if (isSceneTrackTarget(parsed.target, parsed.targetName)) {
131
+ return "scene";
132
+ }
133
+ if (parsed.propertyName === "quaternion") {
134
+ const safeTransformTargets = new Set(
135
+ Object.values(bones).map((entry) => entry?.obj).filter((entry) => !!entry)
136
+ );
137
+ if (isFaceSafeTransformTarget(parsed.target, parsed.targetName, safeTransformTargets)) {
138
+ return "face";
139
+ }
140
+ }
141
+ return "body";
142
+ }
143
+ function resolveBakedChannelBlendMode(channel, requestedBlendMode) {
144
+ if (channel === "face") {
145
+ return requestedBlendMode === "additive" ? "additive" : "replace";
146
+ }
147
+ if (channel === "body") {
148
+ return "replace";
149
+ }
150
+ return void 0;
151
+ }
152
+ function resolveBakedAggregateBlendMode(channels, requestedBlendMode) {
153
+ if (requestedBlendMode !== "additive") {
154
+ return "replace";
155
+ }
156
+ return channels.some((channel) => channel.channel === "face" && channel.playable && channel.trackCount > 0) ? "additive" : "replace";
157
+ }
158
+ function partitionBakedClip(clip, model, bones) {
159
+ const tracksByChannel = new Map(
160
+ CHANNEL_ORDER.map((channel) => [channel, []])
161
+ );
162
+ for (const track of clip.tracks) {
163
+ const channel = classifyBakedTrack(track, model, bones);
164
+ tracksByChannel.get(channel)?.push(track.clone());
165
+ }
166
+ const runtimeClips = [];
167
+ const channels = [];
168
+ for (const channel of CHANNEL_ORDER) {
169
+ const tracks = tracksByChannel.get(channel) ?? [];
170
+ if (tracks.length === 0) {
171
+ continue;
172
+ }
173
+ const playable = channel !== "scene";
174
+ const blendMode = resolveBakedChannelBlendMode(channel, "additive");
175
+ channels.push({
176
+ channel,
177
+ trackCount: tracks.length,
178
+ playable,
179
+ blendMode
180
+ });
181
+ if (!playable) {
182
+ continue;
183
+ }
184
+ runtimeClips.push({
185
+ channel,
186
+ clip: new THREE.AnimationClip(getRuntimeClipName(clip.name, channel), clip.duration, tracks)
187
+ });
188
+ }
189
+ return {
190
+ sourceClip: clip,
191
+ channels,
192
+ runtimeClips
193
+ };
194
+ }
82
195
 
83
196
  // src/engines/three/AnimationThree.ts
84
197
  var easeInOutQuad = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
@@ -178,12 +291,18 @@ var makeActionId = () => `act_${Math.random().toString(36).slice(2, 8)}_${Date.n
178
291
  var X_AXIS = new THREE.Vector3(1, 0, 0);
179
292
  var Y_AXIS = new THREE.Vector3(0, 1, 0);
180
293
  var Z_AXIS = new THREE.Vector3(0, 0, 1);
294
+ var CLIP_EVENT_METADATA_KEY = "__loom3ClipEvents";
295
+ var CLIP_EVENT_EPSILON = 1e-4;
181
296
  var BakedAnimationController = class {
182
297
  constructor(host) {
183
298
  __publicField(this, "host");
184
299
  __publicField(this, "animationMixer", null);
185
300
  __publicField(this, "mixerFinishedListenerAttached", false);
186
301
  __publicField(this, "animationClips", []);
302
+ __publicField(this, "bakedSourceClips", /* @__PURE__ */ new Map());
303
+ __publicField(this, "bakedRuntimeActions", /* @__PURE__ */ new Map());
304
+ __publicField(this, "bakedActionGroups", /* @__PURE__ */ new Map());
305
+ __publicField(this, "bakedRuntimeClipToSource", /* @__PURE__ */ new Map());
187
306
  __publicField(this, "animationActions", /* @__PURE__ */ new Map());
188
307
  __publicField(this, "animationFinishedCallbacks", /* @__PURE__ */ new Map());
189
308
  __publicField(this, "clipActions", /* @__PURE__ */ new Map());
@@ -192,6 +311,7 @@ var BakedAnimationController = class {
192
311
  __publicField(this, "playbackState", /* @__PURE__ */ new Map());
193
312
  __publicField(this, "actionIds", /* @__PURE__ */ new WeakMap());
194
313
  __publicField(this, "actionIdToClip", /* @__PURE__ */ new Map());
314
+ __publicField(this, "clipMonitors", /* @__PURE__ */ new Map());
195
315
  this.host = host;
196
316
  }
197
317
  getActionId(action) {
@@ -205,6 +325,161 @@ var BakedAnimationController = class {
205
325
  action.__actionId = actionId;
206
326
  return actionId;
207
327
  }
328
+ setClipEventMetadata(clip, metadata) {
329
+ const userData = clip.userData ?? (clip.userData = {});
330
+ userData[CLIP_EVENT_METADATA_KEY] = metadata;
331
+ }
332
+ getClipEventMetadata(clip) {
333
+ const userData = clip.userData;
334
+ const keyframeTimes = Array.isArray(userData?.[CLIP_EVENT_METADATA_KEY]?.keyframeTimes) ? userData[CLIP_EVENT_METADATA_KEY].keyframeTimes.filter((time) => Number.isFinite(time)) : [];
335
+ return { keyframeTimes };
336
+ }
337
+ getKeyframeIndex(times, currentTime) {
338
+ if (!times.length) return -1;
339
+ const target = Math.max(0, currentTime) + 1e-3;
340
+ let lo = 0;
341
+ let hi = times.length - 1;
342
+ let idx = 0;
343
+ while (lo <= hi) {
344
+ const mid = lo + hi >>> 1;
345
+ if (times[mid] <= target) {
346
+ idx = mid;
347
+ lo = mid + 1;
348
+ } else {
349
+ hi = mid - 1;
350
+ }
351
+ }
352
+ return idx;
353
+ }
354
+ emitClipEvent(monitor, event) {
355
+ for (const listener of Array.from(monitor.listeners)) {
356
+ try {
357
+ listener(event);
358
+ } catch (error) {
359
+ console.error("[Loom3] clip event listener failed", error);
360
+ }
361
+ }
362
+ }
363
+ emitKeyframesForRange(monitor, startTime, endTime, direction, includeStart) {
364
+ if (!monitor.keyframeTimes.length) return;
365
+ const times = direction === 1 ? monitor.keyframeTimes : [...monitor.keyframeTimes].reverse();
366
+ for (const time of times) {
367
+ const matchesForward = direction === 1 && (includeStart ? time >= startTime - CLIP_EVENT_EPSILON : time > startTime + CLIP_EVENT_EPSILON) && time <= endTime + CLIP_EVENT_EPSILON;
368
+ const matchesReverse = direction === -1 && (includeStart ? time <= startTime + CLIP_EVENT_EPSILON : time < startTime - CLIP_EVENT_EPSILON) && time >= endTime - CLIP_EVENT_EPSILON;
369
+ if (!matchesForward && !matchesReverse) continue;
370
+ const keyframeIndex = monitor.keyframeTimes.indexOf(time);
371
+ monitor.lastKeyframeIndex = keyframeIndex;
372
+ this.emitClipEvent(monitor, {
373
+ type: "keyframe",
374
+ clipName: monitor.clipName,
375
+ keyframeIndex,
376
+ totalKeyframes: monitor.keyframeTimes.length,
377
+ currentTime: time,
378
+ duration: monitor.duration,
379
+ iteration: monitor.iteration
380
+ });
381
+ }
382
+ }
383
+ resetClipMonitor(monitor, currentTime) {
384
+ monitor.iteration = 0;
385
+ monitor.direction = monitor.initialDirection;
386
+ monitor.lastTime = currentTime;
387
+ monitor.lastKeyframeIndex = this.getKeyframeIndex(monitor.keyframeTimes, currentTime);
388
+ monitor.finishedPending = false;
389
+ }
390
+ syncClipMonitorTime(monitor, currentTime, emitSeek = false) {
391
+ const clamped = Math.max(0, Math.min(monitor.duration, currentTime));
392
+ monitor.lastTime = clamped;
393
+ monitor.lastKeyframeIndex = this.getKeyframeIndex(monitor.keyframeTimes, clamped);
394
+ if (emitSeek) {
395
+ this.emitClipEvent(monitor, {
396
+ type: "seek",
397
+ clipName: monitor.clipName,
398
+ currentTime: clamped,
399
+ duration: monitor.duration,
400
+ iteration: monitor.iteration
401
+ });
402
+ }
403
+ }
404
+ cleanupClipMonitor(actionId) {
405
+ const monitor = this.clipMonitors.get(actionId);
406
+ if (!monitor || monitor.cleanedUp) return;
407
+ monitor.cleanedUp = true;
408
+ try {
409
+ monitor.action.paused = true;
410
+ } catch {
411
+ }
412
+ monitor.resolveFinished();
413
+ monitor.listeners.clear();
414
+ this.clipMonitors.delete(actionId);
415
+ this.actionIdToClip.delete(actionId);
416
+ }
417
+ advanceClipMonitor(monitor, previousTime) {
418
+ if (monitor.cleanedUp || monitor.action.paused && !monitor.finishedPending) return;
419
+ const currentTime = Math.max(0, Math.min(monitor.duration, monitor.action.time));
420
+ const delta = currentTime - previousTime;
421
+ if (monitor.loopMode === "pingpong") {
422
+ const movingForward = monitor.direction === 1;
423
+ const bouncedAtEnd = movingForward && delta < -CLIP_EVENT_EPSILON;
424
+ const bouncedAtStart = !movingForward && delta > CLIP_EVENT_EPSILON;
425
+ if (bouncedAtEnd) {
426
+ this.emitKeyframesForRange(monitor, previousTime, monitor.duration, 1, false);
427
+ monitor.direction = -1;
428
+ this.emitKeyframesForRange(monitor, monitor.duration, currentTime, -1, false);
429
+ } else if (bouncedAtStart) {
430
+ this.emitKeyframesForRange(monitor, previousTime, 0, -1, false);
431
+ monitor.direction = 1;
432
+ monitor.iteration += 1;
433
+ this.emitClipEvent(monitor, {
434
+ type: "loop",
435
+ clipName: monitor.clipName,
436
+ iteration: monitor.iteration,
437
+ currentTime: 0,
438
+ duration: monitor.duration
439
+ });
440
+ this.emitKeyframesForRange(monitor, 0, currentTime, 1, false);
441
+ } else if (delta > CLIP_EVENT_EPSILON) {
442
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, 1, false);
443
+ monitor.direction = 1;
444
+ } else if (delta < -CLIP_EVENT_EPSILON) {
445
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, -1, false);
446
+ monitor.direction = -1;
447
+ }
448
+ } else if (monitor.direction === 1) {
449
+ const wrapped = currentTime + CLIP_EVENT_EPSILON < previousTime;
450
+ if (wrapped) {
451
+ this.emitKeyframesForRange(monitor, previousTime, monitor.duration, 1, false);
452
+ monitor.iteration += 1;
453
+ this.emitClipEvent(monitor, {
454
+ type: "loop",
455
+ clipName: monitor.clipName,
456
+ iteration: monitor.iteration,
457
+ currentTime: 0,
458
+ duration: monitor.duration
459
+ });
460
+ this.emitKeyframesForRange(monitor, 0, currentTime, 1, true);
461
+ } else if (delta > CLIP_EVENT_EPSILON) {
462
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, 1, false);
463
+ }
464
+ } else {
465
+ const wrapped = currentTime > previousTime + CLIP_EVENT_EPSILON;
466
+ if (wrapped) {
467
+ this.emitKeyframesForRange(monitor, previousTime, 0, -1, false);
468
+ monitor.iteration += 1;
469
+ this.emitClipEvent(monitor, {
470
+ type: "loop",
471
+ clipName: monitor.clipName,
472
+ iteration: monitor.iteration,
473
+ currentTime: monitor.duration,
474
+ duration: monitor.duration
475
+ });
476
+ this.emitKeyframesForRange(monitor, monitor.duration, currentTime, -1, true);
477
+ } else if (delta < -CLIP_EVENT_EPSILON) {
478
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, -1, false);
479
+ }
480
+ }
481
+ this.syncClipMonitorTime(monitor, currentTime);
482
+ }
208
483
  normalizePlaybackOptions(options, defaults) {
209
484
  const clipOptions = options;
210
485
  const rawRate = options?.playbackRate ?? options?.speed ?? 1;
@@ -212,6 +487,7 @@ var BakedAnimationController = class {
212
487
  const rawWeight = options?.weight ?? options?.intensity ?? clipOptions?.mixerWeight ?? 1;
213
488
  const weight = Number.isFinite(rawWeight) ? Math.max(0, rawWeight) : 1;
214
489
  const loopMode = options?.loopMode ?? (typeof options?.loop === "boolean" ? options.loop ? "repeat" : "once" : defaults.loop ? "repeat" : "once");
490
+ const requestedBlendMode = options?.blendMode ?? (clipOptions?.mixerAdditive ? "additive" : "replace");
215
491
  return {
216
492
  source: options?.source ?? defaults.source,
217
493
  loop: loopMode !== "once",
@@ -221,7 +497,8 @@ var BakedAnimationController = class {
221
497
  playbackRate,
222
498
  weight,
223
499
  balance: Number.isFinite(options?.balance) ? options?.balance ?? 0 : 0,
224
- blendMode: options?.blendMode ?? (clipOptions?.mixerAdditive ? "additive" : "replace"),
500
+ requestedBlendMode,
501
+ blendMode: requestedBlendMode,
225
502
  easing: options?.easing ?? "linear"
226
503
  };
227
504
  }
@@ -281,15 +558,49 @@ var BakedAnimationController = class {
281
558
  next.balance = Math.max(-1, Math.min(1, options.balance));
282
559
  }
283
560
  if (options.blendMode) {
284
- next.blendMode = options.blendMode;
561
+ next.requestedBlendMode = options.blendMode;
285
562
  } else if (typeof clipOptions?.mixerAdditive === "boolean") {
286
- next.blendMode = clipOptions.mixerAdditive ? "additive" : "replace";
563
+ next.requestedBlendMode = clipOptions.mixerAdditive ? "additive" : "replace";
287
564
  }
565
+ next.blendMode = next.requestedBlendMode;
288
566
  if (options.easing) {
289
567
  next.easing = options.easing;
290
568
  }
291
569
  return next;
292
570
  }
571
+ isBakedSourceClip(clipName) {
572
+ return this.bakedSourceClips.has(clipName);
573
+ }
574
+ getBakedSourceClip(clipName) {
575
+ return this.bakedSourceClips.get(clipName);
576
+ }
577
+ getBakedChannelInfo(clipName, playbackState) {
578
+ const bakedClip = this.getBakedSourceClip(clipName);
579
+ if (!bakedClip) {
580
+ return void 0;
581
+ }
582
+ const requestedBlendMode = playbackState?.requestedBlendMode ?? "replace";
583
+ return bakedClip.channels.map((channel) => ({
584
+ ...channel,
585
+ blendMode: resolveBakedChannelBlendMode(channel.channel, requestedBlendMode)
586
+ }));
587
+ }
588
+ getBakedAggregateBlendMode(clipName, playbackState) {
589
+ const channels = this.getBakedChannelInfo(clipName, playbackState);
590
+ if (!channels) {
591
+ return playbackState?.requestedBlendMode ?? playbackState?.blendMode ?? "replace";
592
+ }
593
+ return resolveBakedAggregateBlendMode(
594
+ channels,
595
+ playbackState?.requestedBlendMode ?? "replace"
596
+ );
597
+ }
598
+ applyPlaybackStateToBakedAction(action, state, channel) {
599
+ this.applyPlaybackState(action, {
600
+ ...state,
601
+ blendMode: resolveBakedChannelBlendMode(channel, state.requestedBlendMode) ?? "replace"
602
+ });
603
+ }
293
604
  resolveStartTime(duration, state, explicitStartTime) {
294
605
  if (typeof explicitStartTime === "number" && Number.isFinite(explicitStartTime)) {
295
606
  return Math.max(0, Math.min(duration, explicitStartTime));
@@ -299,8 +610,13 @@ var BakedAnimationController = class {
299
610
  }
300
611
  return 0;
301
612
  }
302
- getOrCreateBakedAction(clipName) {
303
- const existing = this.animationActions.get(clipName);
613
+ getOrCreateBakedRuntimeAction(sourceClipName, channel) {
614
+ const bakedClip = this.getBakedSourceClip(sourceClipName);
615
+ const runtimeClip = bakedClip?.runtimeClips.find((entry) => entry.channel === channel)?.clip;
616
+ if (!runtimeClip) {
617
+ return null;
618
+ }
619
+ const existing = this.bakedRuntimeActions.get(runtimeClip.name);
304
620
  if (existing) {
305
621
  return existing;
306
622
  }
@@ -308,13 +624,44 @@ var BakedAnimationController = class {
308
624
  if (!this.animationMixer) {
309
625
  return null;
310
626
  }
311
- const clip = this.animationClips.find((entry) => entry.name === clipName);
312
- if (!clip || (this.clipSources.get(clipName) ?? "baked") !== "baked") {
627
+ const action = this.animationMixer.clipAction(runtimeClip);
628
+ this.bakedRuntimeActions.set(runtimeClip.name, action);
629
+ return action;
630
+ }
631
+ getRepresentativeBakedAction(clipName) {
632
+ const group = this.bakedActionGroups.get(clipName);
633
+ if (!group) {
313
634
  return null;
314
635
  }
315
- const action = this.animationMixer.clipAction(clip);
316
- this.animationActions.set(clipName, action);
317
- return action;
636
+ return group.channelActions.values().next().value ?? null;
637
+ }
638
+ createBakedActionGroup(clipName, playbackState) {
639
+ const bakedClip = this.getBakedSourceClip(clipName);
640
+ if (!bakedClip) {
641
+ return null;
642
+ }
643
+ const channelActions = /* @__PURE__ */ new Map();
644
+ for (const runtimeClip of bakedClip.runtimeClips) {
645
+ const action = this.getOrCreateBakedRuntimeAction(clipName, runtimeClip.channel);
646
+ if (action) {
647
+ channelActions.set(runtimeClip.channel, action);
648
+ }
649
+ }
650
+ if (channelActions.size === 0) {
651
+ return null;
652
+ }
653
+ let resolveFinished = () => {
654
+ };
655
+ const finishedPromise = new Promise((resolve) => {
656
+ resolveFinished = resolve;
657
+ });
658
+ return {
659
+ actionId: makeActionId(),
660
+ channelActions,
661
+ pendingFinishedChannels: playbackState.loopMode === "once" ? new Set(channelActions.keys()) : /* @__PURE__ */ new Set(),
662
+ finishedPromise,
663
+ resolveFinished
664
+ };
318
665
  }
319
666
  getMeshNamesForAU(auId, config, explicitMeshNames) {
320
667
  if (explicitMeshNames && explicitMeshNames.length > 0) {
@@ -344,7 +691,28 @@ var BakedAnimationController = class {
344
691
  }
345
692
  update(dtSeconds) {
346
693
  if (this.animationMixer) {
694
+ const snapshots = Array.from(this.clipMonitors.values()).map((monitor) => ({
695
+ actionId: monitor.actionId,
696
+ previousTime: monitor.action.time
697
+ }));
347
698
  this.animationMixer.update(dtSeconds);
699
+ for (const { actionId, previousTime } of snapshots) {
700
+ const monitor = this.clipMonitors.get(actionId);
701
+ if (!monitor) continue;
702
+ this.advanceClipMonitor(monitor, previousTime);
703
+ if (monitor.finishedPending) {
704
+ const finalTime = Math.max(0, Math.min(monitor.duration, monitor.action.time));
705
+ this.syncClipMonitorTime(monitor, finalTime);
706
+ this.emitClipEvent(monitor, {
707
+ type: "completed",
708
+ clipName: monitor.clipName,
709
+ currentTime: finalTime,
710
+ duration: monitor.duration,
711
+ iteration: monitor.iteration
712
+ });
713
+ this.cleanupClipMonitor(actionId);
714
+ }
715
+ }
348
716
  }
349
717
  }
350
718
  dispose() {
@@ -354,25 +722,60 @@ var BakedAnimationController = class {
354
722
  this.animationMixer = null;
355
723
  }
356
724
  this.animationClips = [];
725
+ this.bakedSourceClips.clear();
726
+ this.bakedRuntimeActions.clear();
727
+ this.bakedActionGroups.clear();
728
+ this.bakedRuntimeClipToSource.clear();
357
729
  this.animationActions.clear();
358
730
  this.animationFinishedCallbacks.clear();
359
731
  this.clipActions.clear();
360
732
  this.clipHandles.clear();
361
733
  this.clipSources.clear();
362
734
  this.playbackState.clear();
735
+ this.clipMonitors.clear();
363
736
  }
364
737
  loadAnimationClips(clips) {
365
- if (!this.host.getModel()) {
738
+ const model = this.host.getModel();
739
+ if (!model) {
366
740
  console.warn("Loom3: Cannot load animation clips before calling onReady()");
367
741
  return;
368
742
  }
743
+ for (const clipName of this.bakedSourceClips.keys()) {
744
+ this.stopAnimation(clipName);
745
+ }
746
+ if (this.animationMixer) {
747
+ for (const bakedClip of this.bakedSourceClips.values()) {
748
+ for (const runtimeClip of bakedClip.runtimeClips) {
749
+ try {
750
+ this.animationMixer.uncacheAction(runtimeClip.clip);
751
+ } catch {
752
+ }
753
+ try {
754
+ this.animationMixer.uncacheClip(runtimeClip.clip);
755
+ } catch {
756
+ }
757
+ }
758
+ }
759
+ }
760
+ for (const clipName of this.bakedSourceClips.keys()) {
761
+ this.playbackState.delete(clipName);
762
+ this.clipSources.delete(clipName);
763
+ }
764
+ this.bakedSourceClips.clear();
765
+ this.bakedRuntimeActions.clear();
766
+ this.bakedActionGroups.clear();
767
+ this.bakedRuntimeClipToSource.clear();
369
768
  this.ensureMixer();
370
- this.animationClips = clips;
371
- for (const clip of this.animationClips) {
372
- this.clipSources.set(clip.name, "baked");
373
- if (!this.animationActions.has(clip.name) && this.animationMixer) {
374
- const action = this.animationMixer.clipAction(clip);
375
- this.animationActions.set(clip.name, action);
769
+ const partitionedClips = clips.map((clip) => partitionBakedClip(clip, model, this.host.getBones()));
770
+ this.animationClips = partitionedClips.map((clip) => clip.sourceClip);
771
+ for (const bakedClip of partitionedClips) {
772
+ this.bakedSourceClips.set(bakedClip.sourceClip.name, bakedClip);
773
+ this.clipSources.set(bakedClip.sourceClip.name, "baked");
774
+ for (const runtimeClip of bakedClip.runtimeClips) {
775
+ this.bakedRuntimeClipToSource.set(runtimeClip.clip.name, {
776
+ sourceClipName: bakedClip.sourceClip.name,
777
+ channel: runtimeClip.channel
778
+ });
376
779
  }
377
780
  }
378
781
  }
@@ -381,86 +784,94 @@ var BakedAnimationController = class {
381
784
  name: clip.name,
382
785
  duration: clip.duration,
383
786
  trackCount: clip.tracks.length,
384
- source: this.clipSources.get(clip.name) ?? "baked"
787
+ source: this.clipSources.get(clip.name) ?? "baked",
788
+ channels: this.getBakedSourceClip(clip.name)?.channels
385
789
  }));
386
790
  }
387
791
  removeAnimationClip(clipName) {
388
- const clip = this.animationClips.find((entry) => entry.name === clipName);
389
- if (!clip || (this.clipSources.get(clipName) ?? "baked") !== "baked") {
792
+ const bakedClip = this.getBakedSourceClip(clipName);
793
+ if (!bakedClip) {
390
794
  return false;
391
795
  }
392
- const relatedActions = /* @__PURE__ */ new Set();
393
- const bakedAction = this.animationActions.get(clipName);
394
- const clipAction = this.clipActions.get(clipName);
395
- if (bakedAction) relatedActions.add(bakedAction);
396
- if (clipAction) relatedActions.add(clipAction);
397
796
  this.stopAnimation(clipName);
398
797
  if (this.animationMixer) {
399
- for (const action of relatedActions) {
798
+ for (const runtimeClip of bakedClip.runtimeClips) {
799
+ const action = this.bakedRuntimeActions.get(runtimeClip.clip.name);
400
800
  try {
401
- this.animationMixer.uncacheAction(clip);
801
+ this.animationMixer.uncacheAction(runtimeClip.clip);
402
802
  } catch {
403
803
  }
404
804
  try {
405
- this.animationMixer.uncacheClip(clip);
805
+ this.animationMixer.uncacheClip(runtimeClip.clip);
406
806
  } catch {
407
807
  }
808
+ this.bakedRuntimeActions.delete(runtimeClip.clip.name);
809
+ this.bakedRuntimeClipToSource.delete(runtimeClip.clip.name);
408
810
  const actionId = this.getActionId(action);
409
- if (actionId) {
811
+ if (actionId && action) {
410
812
  this.actionIdToClip.delete(actionId);
813
+ this.actionIds.delete(action);
411
814
  }
412
- this.actionIds.delete(action);
413
815
  }
414
816
  }
415
817
  this.animationClips = this.animationClips.filter((entry) => entry.name !== clipName);
416
- this.animationActions.delete(clipName);
417
- this.clipActions.delete(clipName);
418
- this.clipHandles.delete(clipName);
419
- this.animationFinishedCallbacks.delete(clipName);
818
+ this.bakedSourceClips.delete(clipName);
819
+ this.bakedActionGroups.delete(clipName);
420
820
  this.playbackState.delete(clipName);
421
821
  this.clipSources.delete(clipName);
422
822
  return true;
423
823
  }
424
824
  playAnimation(clipName, options = {}) {
425
- const action = this.getOrCreateBakedAction(clipName);
426
- if (!action) {
825
+ const bakedClip = this.getBakedSourceClip(clipName);
826
+ if (!bakedClip) {
427
827
  console.warn(`Loom3: Animation clip "${clipName}" not found`);
428
828
  return null;
429
829
  }
430
- if (!this.getActionId(action)) {
431
- this.setActionId(action, clipName);
432
- }
433
830
  const playbackState = this.mergePlaybackOptions(
434
831
  this.getPlaybackStateSnapshot(clipName, { loop: true, source: "baked" }),
435
832
  options
436
833
  );
834
+ playbackState.blendMode = this.getBakedAggregateBlendMode(clipName, playbackState);
835
+ const actionGroup = this.createBakedActionGroup(clipName, playbackState);
836
+ if (!actionGroup) {
837
+ console.warn(`Loom3: Animation clip "${clipName}" has no character-runtime channels to play`);
838
+ return null;
839
+ }
437
840
  const crossfadeDuration = options.crossfadeDuration ?? 0;
438
841
  const clampWhenFinished = options.clampWhenFinished ?? playbackState.loopMode === "once";
439
- const startTime = this.resolveStartTime(action.getClip().duration, playbackState, options.startTime);
440
- this.applyPlaybackState(action, playbackState);
441
- action.clampWhenFinished = clampWhenFinished;
442
- if (crossfadeDuration > 0) {
443
- action.reset();
444
- action.fadeIn(crossfadeDuration);
445
- } else {
446
- action.reset();
842
+ const startTime = this.resolveStartTime(bakedClip.sourceClip.duration, playbackState, options.startTime);
843
+ for (const [channel, action] of actionGroup.channelActions) {
844
+ this.applyPlaybackStateToBakedAction(action, playbackState, channel);
845
+ action.clampWhenFinished = clampWhenFinished;
846
+ if (crossfadeDuration > 0) {
847
+ action.reset();
848
+ action.fadeIn(crossfadeDuration);
849
+ } else {
850
+ action.reset();
851
+ }
852
+ action.time = startTime;
853
+ action.play();
447
854
  }
448
- action.time = startTime;
449
- action.play();
450
- this.animationActions.set(clipName, action);
855
+ this.bakedActionGroups.set(clipName, actionGroup);
451
856
  this.setPlaybackState(clipName, playbackState);
452
- let resolveFinished;
453
- const finishedPromise = new Promise((resolve) => {
454
- resolveFinished = resolve;
455
- });
456
- if (playbackState.loopMode === "once") {
457
- this.animationFinishedCallbacks.set(clipName, () => resolveFinished());
458
- }
459
- return this.createAnimationHandle(clipName, action, finishedPromise);
857
+ return this.createBakedAnimationHandle(clipName, actionGroup);
460
858
  }
461
859
  stopAnimation(clipName) {
860
+ const bakedGroup = this.bakedActionGroups.get(clipName);
861
+ if (bakedGroup) {
862
+ for (const action2 of bakedGroup.channelActions.values()) {
863
+ action2.stop();
864
+ try {
865
+ action2.paused = false;
866
+ } catch {
867
+ }
868
+ }
869
+ this.bakedActionGroups.delete(clipName);
870
+ return;
871
+ }
462
872
  const action = this.animationActions.get(clipName);
463
873
  if (action) {
874
+ const actionId = this.getActionId(action);
464
875
  const isBaked = (this.clipSources.get(clipName) ?? "baked") === "baked";
465
876
  action.stop();
466
877
  if (!isBaked && this.animationMixer) {
@@ -483,9 +894,11 @@ var BakedAnimationController = class {
483
894
  }
484
895
  }
485
896
  this.animationFinishedCallbacks.delete(clipName);
897
+ if (actionId) this.cleanupClipMonitor(actionId);
486
898
  }
487
899
  const clipAction = this.clipActions.get(clipName);
488
900
  if (clipAction && clipAction !== action) {
901
+ const actionId = this.getActionId(clipAction);
489
902
  try {
490
903
  clipAction.stop();
491
904
  if (this.animationMixer) {
@@ -498,6 +911,7 @@ var BakedAnimationController = class {
498
911
  } catch {
499
912
  }
500
913
  this.clipActions.delete(clipName);
914
+ if (actionId) this.cleanupClipMonitor(actionId);
501
915
  }
502
916
  if (this.clipActions.get(clipName) === action) {
503
917
  this.clipActions.delete(clipName);
@@ -506,6 +920,7 @@ var BakedAnimationController = class {
506
920
  }
507
921
  stopAllAnimations() {
508
922
  for (const clipName of /* @__PURE__ */ new Set([
923
+ ...this.bakedActionGroups.keys(),
509
924
  ...this.animationActions.keys(),
510
925
  ...this.clipActions.keys()
511
926
  ])) {
@@ -513,18 +928,39 @@ var BakedAnimationController = class {
513
928
  }
514
929
  }
515
930
  pauseAnimation(clipName) {
931
+ const bakedGroup = this.bakedActionGroups.get(clipName);
932
+ if (bakedGroup) {
933
+ for (const action2 of bakedGroup.channelActions.values()) {
934
+ action2.paused = true;
935
+ }
936
+ return;
937
+ }
516
938
  const action = this.animationActions.get(clipName);
517
939
  if (action) {
518
940
  action.paused = true;
519
941
  }
520
942
  }
521
943
  resumeAnimation(clipName) {
944
+ const bakedGroup = this.bakedActionGroups.get(clipName);
945
+ if (bakedGroup) {
946
+ for (const action2 of bakedGroup.channelActions.values()) {
947
+ action2.paused = false;
948
+ }
949
+ return;
950
+ }
522
951
  const action = this.animationActions.get(clipName);
523
952
  if (action) {
524
953
  action.paused = false;
525
954
  }
526
955
  }
527
956
  pauseAllAnimations() {
957
+ for (const group of this.bakedActionGroups.values()) {
958
+ for (const action of group.channelActions.values()) {
959
+ if (action.isRunning()) {
960
+ action.paused = true;
961
+ }
962
+ }
963
+ }
528
964
  for (const action of this.animationActions.values()) {
529
965
  if (action.isRunning()) {
530
966
  action.paused = true;
@@ -532,6 +968,13 @@ var BakedAnimationController = class {
532
968
  }
533
969
  }
534
970
  resumeAllAnimations() {
971
+ for (const group of this.bakedActionGroups.values()) {
972
+ for (const action of group.channelActions.values()) {
973
+ if (action.paused) {
974
+ action.paused = false;
975
+ }
976
+ }
977
+ }
535
978
  for (const action of this.animationActions.values()) {
536
979
  if (action.paused) {
537
980
  action.paused = false;
@@ -539,76 +982,161 @@ var BakedAnimationController = class {
539
982
  }
540
983
  }
541
984
  setAnimationSpeed(clipName, speed) {
542
- const action = this.getOrCreateBakedAction(clipName);
543
- if (action) {
985
+ if (this.isBakedSourceClip(clipName)) {
544
986
  const next = this.getPlaybackStateSnapshot(clipName, {
545
987
  loop: true,
546
988
  source: this.clipSources.get(clipName) ?? "baked"
547
989
  });
548
990
  next.playbackRate = Number.isFinite(speed) ? Math.max(0, Math.abs(speed)) : 1;
991
+ const bakedGroup = this.bakedActionGroups.get(clipName);
992
+ if (bakedGroup) {
993
+ for (const [channel, action2] of bakedGroup.channelActions) {
994
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
995
+ }
996
+ }
997
+ this.setPlaybackState(clipName, next);
998
+ return;
999
+ }
1000
+ const action = this.animationActions.get(clipName);
1001
+ if (action) {
1002
+ const next = this.getPlaybackStateSnapshot(clipName, {
1003
+ loop: true,
1004
+ source: this.clipSources.get(clipName) ?? "clip"
1005
+ });
1006
+ next.playbackRate = Number.isFinite(speed) ? Math.max(0, Math.abs(speed)) : 1;
549
1007
  this.applyPlaybackState(action, next);
550
1008
  this.setPlaybackState(clipName, next);
551
1009
  }
552
1010
  }
553
1011
  setAnimationIntensity(clipName, intensity) {
554
- const action = this.getOrCreateBakedAction(clipName);
555
- if (action) {
1012
+ if (this.isBakedSourceClip(clipName)) {
556
1013
  const next = this.getPlaybackStateSnapshot(clipName, {
557
1014
  loop: true,
558
1015
  source: this.clipSources.get(clipName) ?? "baked"
559
1016
  });
560
1017
  next.weight = Number.isFinite(intensity) ? Math.max(0, intensity) : 1;
1018
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1019
+ if (bakedGroup) {
1020
+ for (const [channel, action2] of bakedGroup.channelActions) {
1021
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1022
+ }
1023
+ }
1024
+ this.setPlaybackState(clipName, next);
1025
+ return;
1026
+ }
1027
+ const action = this.animationActions.get(clipName);
1028
+ if (action) {
1029
+ const next = this.getPlaybackStateSnapshot(clipName, {
1030
+ loop: true,
1031
+ source: this.clipSources.get(clipName) ?? "clip"
1032
+ });
1033
+ next.weight = Number.isFinite(intensity) ? Math.max(0, intensity) : 1;
561
1034
  action.setEffectiveWeight(next.weight);
562
1035
  this.setPlaybackState(clipName, next);
563
1036
  }
564
1037
  }
565
1038
  setAnimationLoopMode(clipName, loopMode) {
566
- const action = this.getOrCreateBakedAction(clipName);
567
- if (!action) return;
568
1039
  const next = this.getPlaybackStateSnapshot(clipName, {
569
1040
  loop: true,
570
- source: this.clipSources.get(clipName) ?? "baked"
1041
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
571
1042
  });
572
1043
  next.loopMode = loopMode;
573
1044
  next.loop = loopMode !== "once";
1045
+ if (this.isBakedSourceClip(clipName)) {
1046
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1047
+ if (bakedGroup) {
1048
+ for (const [channel, action2] of bakedGroup.channelActions) {
1049
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1050
+ }
1051
+ }
1052
+ this.setPlaybackState(clipName, next);
1053
+ return;
1054
+ }
1055
+ const action = this.animationActions.get(clipName);
1056
+ if (!action) return;
574
1057
  this.applyPlaybackState(action, next);
575
1058
  this.setPlaybackState(clipName, next);
576
1059
  }
577
1060
  setAnimationRepeatCount(clipName, repeatCount) {
578
- const action = this.getOrCreateBakedAction(clipName);
579
- if (!action) return;
580
1061
  const next = this.getPlaybackStateSnapshot(clipName, {
581
1062
  loop: true,
582
- source: this.clipSources.get(clipName) ?? "baked"
1063
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
583
1064
  });
584
1065
  next.repeatCount = typeof repeatCount === "number" && Number.isFinite(repeatCount) ? Math.max(0, repeatCount) : void 0;
1066
+ if (this.isBakedSourceClip(clipName)) {
1067
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1068
+ if (bakedGroup) {
1069
+ for (const [channel, action2] of bakedGroup.channelActions) {
1070
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1071
+ }
1072
+ }
1073
+ this.setPlaybackState(clipName, next);
1074
+ return;
1075
+ }
1076
+ const action = this.animationActions.get(clipName);
1077
+ if (!action) return;
585
1078
  this.applyPlaybackState(action, next);
586
1079
  this.setPlaybackState(clipName, next);
587
1080
  }
588
1081
  setAnimationReverse(clipName, reverse) {
589
- const action = this.getOrCreateBakedAction(clipName);
590
- if (!action) return;
591
1082
  const next = this.getPlaybackStateSnapshot(clipName, {
592
1083
  loop: true,
593
- source: this.clipSources.get(clipName) ?? "baked"
1084
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
594
1085
  });
595
1086
  next.reverse = !!reverse;
1087
+ if (this.isBakedSourceClip(clipName)) {
1088
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1089
+ if (bakedGroup) {
1090
+ for (const [channel, action2] of bakedGroup.channelActions) {
1091
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1092
+ }
1093
+ }
1094
+ this.setPlaybackState(clipName, next);
1095
+ return;
1096
+ }
1097
+ const action = this.animationActions.get(clipName);
1098
+ if (!action) return;
596
1099
  this.applyPlaybackState(action, next);
597
1100
  this.setPlaybackState(clipName, next);
598
1101
  }
599
1102
  setAnimationBlendMode(clipName, blendMode) {
600
- const action = this.getOrCreateBakedAction(clipName);
601
- if (!action) return;
602
1103
  const next = this.getPlaybackStateSnapshot(clipName, {
603
1104
  loop: true,
604
- source: this.clipSources.get(clipName) ?? "baked"
1105
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
605
1106
  });
1107
+ next.requestedBlendMode = blendMode;
1108
+ if (this.isBakedSourceClip(clipName)) {
1109
+ next.blendMode = this.getBakedAggregateBlendMode(clipName, next);
1110
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1111
+ if (bakedGroup) {
1112
+ for (const [channel, action2] of bakedGroup.channelActions) {
1113
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1114
+ }
1115
+ }
1116
+ this.setPlaybackState(clipName, next);
1117
+ return;
1118
+ }
606
1119
  next.blendMode = blendMode;
1120
+ const action = this.animationActions.get(clipName);
1121
+ if (!action) return;
607
1122
  this.applyPlaybackState(action, next);
608
1123
  this.setPlaybackState(clipName, next);
609
1124
  }
610
1125
  seekAnimation(clipName, time) {
611
- const action = this.getOrCreateBakedAction(clipName) ?? this.animationActions.get(clipName);
1126
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1127
+ if (bakedGroup) {
1128
+ const duration2 = this.getBakedSourceClip(clipName)?.sourceClip.duration ?? 0;
1129
+ const clamped = Math.max(0, Math.min(duration2, Number.isFinite(time) ? time : 0));
1130
+ for (const action2 of bakedGroup.channelActions.values()) {
1131
+ action2.time = clamped;
1132
+ }
1133
+ try {
1134
+ this.animationMixer?.update(0);
1135
+ } catch {
1136
+ }
1137
+ return;
1138
+ }
1139
+ const action = this.animationActions.get(clipName);
612
1140
  if (!action) return;
613
1141
  const duration = action.getClip().duration;
614
1142
  action.time = Math.max(0, Math.min(duration, Number.isFinite(time) ? time : 0));
@@ -623,6 +1151,40 @@ var BakedAnimationController = class {
623
1151
  }
624
1152
  }
625
1153
  getAnimationState(clipName) {
1154
+ const bakedClip = this.getBakedSourceClip(clipName);
1155
+ if (bakedClip) {
1156
+ const state2 = this.playbackState.get(clipName);
1157
+ const action2 = this.getRepresentativeBakedAction(clipName);
1158
+ if (!state2 && !action2) {
1159
+ return null;
1160
+ }
1161
+ const loopMode2 = state2?.loopMode ?? (action2?.loop === THREE.LoopPingPong ? "pingpong" : action2?.loop === THREE.LoopOnce ? "once" : "repeat");
1162
+ const playbackRate2 = state2?.playbackRate ?? Math.abs(action2?.getEffectiveTimeScale?.() ?? 1);
1163
+ const reverse2 = state2?.reverse ?? (action2?.getEffectiveTimeScale?.() ?? 1) < 0;
1164
+ const pausedValues = this.bakedActionGroups.get(clipName) ? Array.from(this.bakedActionGroups.get(clipName).channelActions.values()).map((entry) => entry.paused) : [];
1165
+ return {
1166
+ name: bakedClip.sourceClip.name,
1167
+ actionId: this.bakedActionGroups.get(clipName)?.actionId,
1168
+ source: state2?.source ?? this.clipSources.get(clipName) ?? "baked",
1169
+ isPlaying: this.bakedActionGroups.get(clipName) ? Array.from(this.bakedActionGroups.get(clipName).channelActions.values()).some((entry) => entry.isRunning() && !entry.paused) : false,
1170
+ isPaused: pausedValues.length > 0 ? pausedValues.every(Boolean) : false,
1171
+ time: action2?.time ?? 0,
1172
+ duration: bakedClip.sourceClip.duration,
1173
+ speed: playbackRate2,
1174
+ playbackRate: playbackRate2,
1175
+ reverse: reverse2,
1176
+ weight: state2?.weight ?? action2?.getEffectiveWeight?.() ?? 1,
1177
+ balance: state2?.balance ?? 0,
1178
+ requestedBlendMode: state2?.requestedBlendMode ?? "replace",
1179
+ blendMode: this.getBakedAggregateBlendMode(clipName, state2),
1180
+ channels: this.getBakedChannelInfo(clipName, state2),
1181
+ easing: state2?.easing ?? "linear",
1182
+ loop: loopMode2 !== "once",
1183
+ loopMode: loopMode2,
1184
+ repeatCount: state2?.repeatCount,
1185
+ isLooping: loopMode2 !== "once"
1186
+ };
1187
+ }
626
1188
  const action = this.animationActions.get(clipName);
627
1189
  if (!action) return null;
628
1190
  const clip = action.getClip();
@@ -643,7 +1205,9 @@ var BakedAnimationController = class {
643
1205
  reverse,
644
1206
  weight: state?.weight ?? action.getEffectiveWeight(),
645
1207
  balance: state?.balance ?? 0,
1208
+ requestedBlendMode: state?.requestedBlendMode ?? state?.blendMode ?? "replace",
646
1209
  blendMode: state?.blendMode ?? "replace",
1210
+ channels: state?.source === "baked" ? this.getBakedChannelInfo(clipName, state) : void 0,
647
1211
  easing: state?.easing ?? "linear",
648
1212
  loop: loopMode !== "once",
649
1213
  loopMode,
@@ -653,6 +1217,12 @@ var BakedAnimationController = class {
653
1217
  }
654
1218
  getPlayingAnimations() {
655
1219
  const playing = [];
1220
+ for (const name of this.bakedActionGroups.keys()) {
1221
+ const state = this.getAnimationState(name);
1222
+ if (state?.isPlaying) {
1223
+ playing.push(state);
1224
+ }
1225
+ }
656
1226
  for (const [name, action] of this.animationActions) {
657
1227
  if (action.isRunning()) {
658
1228
  const state = this.getAnimationState(name);
@@ -662,6 +1232,13 @@ var BakedAnimationController = class {
662
1232
  return playing;
663
1233
  }
664
1234
  crossfadeTo(clipName, duration = 0.3, options = {}) {
1235
+ for (const group of this.bakedActionGroups.values()) {
1236
+ for (const action of group.channelActions.values()) {
1237
+ if (action.isRunning()) {
1238
+ action.fadeOut(duration);
1239
+ }
1240
+ }
1241
+ }
665
1242
  for (const action of this.animationActions.values()) {
666
1243
  if (action.isRunning()) {
667
1244
  action.fadeOut(duration);
@@ -896,6 +1473,7 @@ var BakedAnimationController = class {
896
1473
  return null;
897
1474
  }
898
1475
  const clip = new THREE.AnimationClip(clipName, maxTime, tracks);
1476
+ this.setClipEventMetadata(clip, { keyframeTimes });
899
1477
  console.log(`[Loom3] snippetToClip: Created clip "${clipName}" with ${tracks.length} tracks, duration ${maxTime.toFixed(2)}s`);
900
1478
  return clip;
901
1479
  }
@@ -927,28 +1505,38 @@ var BakedAnimationController = class {
927
1505
  this.animationClips.push(clip);
928
1506
  }
929
1507
  this.applyPlaybackState(action, playbackState);
1508
+ if (actionId) {
1509
+ this.cleanupClipMonitor(actionId);
1510
+ }
930
1511
  let resolveFinished;
931
1512
  const finishedPromise = new Promise((resolve) => {
932
1513
  resolveFinished = resolve;
933
1514
  });
934
- const cleanup = () => {
935
- try {
936
- this.animationFinishedCallbacks.delete(clip.name);
937
- } catch {
938
- }
939
- try {
940
- action.paused = true;
941
- } catch {
942
- }
1515
+ const keyframeTimes = this.getClipEventMetadata(clip).keyframeTimes;
1516
+ const initialDirection = playbackState.reverse ? -1 : 1;
1517
+ const monitor = {
1518
+ action,
1519
+ actionId,
1520
+ clip,
1521
+ clipName: clip.name,
1522
+ duration: clip.duration,
1523
+ keyframeTimes,
1524
+ listeners: /* @__PURE__ */ new Set(),
1525
+ initialDirection,
1526
+ direction: initialDirection,
1527
+ iteration: 0,
1528
+ lastTime: Math.max(0, Math.min(clip.duration, action.time)),
1529
+ lastKeyframeIndex: this.getKeyframeIndex(keyframeTimes, action.time),
1530
+ loopMode: playbackState.loopMode,
1531
+ finishedPending: false,
1532
+ cleanedUp: false,
1533
+ resolveFinished
943
1534
  };
944
- this.animationFinishedCallbacks.set(clip.name, () => {
945
- resolveFinished();
946
- cleanup();
947
- });
948
- finishedPromise.catch(() => cleanup());
1535
+ this.clipMonitors.set(actionId, monitor);
949
1536
  action.reset();
950
1537
  action.time = startTime;
951
1538
  action.play();
1539
+ this.resetClipMonitor(monitor, action.time);
952
1540
  this.clipActions.set(clip.name, action);
953
1541
  this.animationActions.set(clip.name, action);
954
1542
  this.setPlaybackState(clip.name, playbackState);
@@ -966,6 +1554,7 @@ var BakedAnimationController = class {
966
1554
  })
967
1555
  );
968
1556
  action.play();
1557
+ this.resetClipMonitor(monitor, action.time);
969
1558
  },
970
1559
  stop: () => {
971
1560
  action.stop();
@@ -983,8 +1572,7 @@ var BakedAnimationController = class {
983
1572
  this.animationActions.delete(clip.name);
984
1573
  this.animationFinishedCallbacks.delete(clip.name);
985
1574
  this.playbackState.delete(clip.name);
986
- resolveFinished();
987
- cleanup();
1575
+ this.cleanupClipMonitor(actionId);
988
1576
  },
989
1577
  pause: () => {
990
1578
  action.paused = true;
@@ -1002,6 +1590,8 @@ var BakedAnimationController = class {
1002
1590
  const next = this.playbackState.get(clip.name) ?? playbackState;
1003
1591
  next.playbackRate = Number.isFinite(r) ? Math.max(0, Math.abs(r)) : 1;
1004
1592
  this.applyPlaybackState(action, next);
1593
+ monitor.direction = next.reverse ? -1 : 1;
1594
+ monitor.initialDirection = monitor.direction;
1005
1595
  this.setPlaybackState(clip.name, next);
1006
1596
  },
1007
1597
  setLoop: (mode, repeatCount) => {
@@ -1010,6 +1600,7 @@ var BakedAnimationController = class {
1010
1600
  next.loop = mode !== "once";
1011
1601
  next.repeatCount = repeatCount;
1012
1602
  this.applyPlaybackState(action, next);
1603
+ monitor.loopMode = mode;
1013
1604
  this.setPlaybackState(clip.name, next);
1014
1605
  },
1015
1606
  setTime: (t) => {
@@ -1019,9 +1610,16 @@ var BakedAnimationController = class {
1019
1610
  this.animationMixer?.update(0);
1020
1611
  } catch {
1021
1612
  }
1613
+ this.syncClipMonitorTime(monitor, clamped, true);
1022
1614
  },
1023
1615
  getTime: () => action.time,
1024
1616
  getDuration: () => clip.duration,
1617
+ subscribe: (listener) => {
1618
+ monitor.listeners.add(listener);
1619
+ return () => {
1620
+ monitor.listeners.delete(listener);
1621
+ };
1622
+ },
1025
1623
  finished: finishedPromise
1026
1624
  };
1027
1625
  this.clipHandles.set(clip.name, handle);
@@ -1045,6 +1643,7 @@ var BakedAnimationController = class {
1045
1643
  if (!this.animationMixer || !this.host.getModel()) return;
1046
1644
  for (const [clipName, action] of Array.from(this.clipActions.entries())) {
1047
1645
  if (clipName === name || clipName.startsWith(`${name}_`)) {
1646
+ const actionId = this.getActionId(action);
1048
1647
  try {
1049
1648
  action.stop();
1050
1649
  const clip = action.getClip();
@@ -1059,6 +1658,7 @@ var BakedAnimationController = class {
1059
1658
  this.clipHandles.delete(clipName);
1060
1659
  this.animationFinishedCallbacks.delete(clipName);
1061
1660
  this.playbackState.delete(clipName);
1661
+ if (actionId) this.cleanupClipMonitor(actionId);
1062
1662
  }
1063
1663
  }
1064
1664
  }
@@ -1082,6 +1682,8 @@ var BakedAnimationController = class {
1082
1682
  console.log("[Loom3] updateClipParams start", debugSnapshot());
1083
1683
  const apply = (action) => {
1084
1684
  if (!action) return;
1685
+ const actionId = this.getActionId(action);
1686
+ const monitor = actionId ? this.clipMonitors.get(actionId) : void 0;
1085
1687
  const clipName = action.getClip().name;
1086
1688
  const next = this.playbackState.get(clipName) ?? this.normalizePlaybackOptions(void 0, { loop: false, source: this.clipSources.get(clipName) ?? "clip" });
1087
1689
  try {
@@ -1100,6 +1702,10 @@ var BakedAnimationController = class {
1100
1702
  }
1101
1703
  const signedRate = next.reverse ? -next.playbackRate : next.playbackRate;
1102
1704
  action.setEffectiveTimeScale(signedRate);
1705
+ if (monitor) {
1706
+ monitor.direction = next.reverse ? -1 : 1;
1707
+ monitor.initialDirection = monitor.direction;
1708
+ }
1103
1709
  updated = true;
1104
1710
  }
1105
1711
  if (typeof params.loop === "boolean" || params.loopMode || params.repeatCount !== void 0) {
@@ -1107,6 +1713,7 @@ var BakedAnimationController = class {
1107
1713
  next.loop = next.loopMode !== "once";
1108
1714
  next.repeatCount = params.repeatCount;
1109
1715
  this.applyPlaybackState(action, next);
1716
+ if (monitor) monitor.loopMode = next.loopMode;
1110
1717
  updated = true;
1111
1718
  }
1112
1719
  this.setPlaybackState(clipName, next);
@@ -1186,7 +1793,23 @@ var BakedAnimationController = class {
1186
1793
  if (this.animationMixer && !this.mixerFinishedListenerAttached) {
1187
1794
  this.animationMixer.addEventListener("finished", (event) => {
1188
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
+ }
1189
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
+ }
1190
1813
  const callback = this.animationFinishedCallbacks.get(clip.name);
1191
1814
  if (callback) {
1192
1815
  callback();
@@ -1211,6 +1834,20 @@ var BakedAnimationController = class {
1211
1834
  finished: finishedPromise
1212
1835
  };
1213
1836
  }
1837
+ createBakedAnimationHandle(clipName, group) {
1838
+ return {
1839
+ actionId: group.actionId,
1840
+ stop: () => this.stopAnimation(clipName),
1841
+ pause: () => this.pauseAnimation(clipName),
1842
+ resume: () => this.resumeAnimation(clipName),
1843
+ setSpeed: (speed) => this.setAnimationSpeed(clipName, speed),
1844
+ setWeight: (weight) => this.setAnimationIntensity(clipName, weight),
1845
+ seekTo: (time) => this.seekAnimation(clipName, time),
1846
+ getState: () => this.getAnimationState(clipName),
1847
+ crossfadeTo: (targetClip, dur) => this.crossfadeTo(targetClip, dur),
1848
+ finished: group.finishedPromise
1849
+ };
1850
+ }
1214
1851
  };
1215
1852
 
1216
1853
  // src/presets/cc4.ts
@@ -3870,9 +4507,11 @@ function getPreset(presetType) {
3870
4507
  return CC4_PRESET;
3871
4508
  }
3872
4509
  }
4510
+ var resolvePreset = getPreset;
3873
4511
  function getPresetWithProfile(presetType, profile) {
3874
4512
  return extendPresetWithProfile(getPreset(presetType), profile);
3875
4513
  }
4514
+ var resolvePresetWithOverrides = getPresetWithProfile;
3876
4515
 
3877
4516
  // src/engines/three/Loom3.ts
3878
4517
  var deg2rad = (d) => d * Math.PI / 180;
@@ -7028,6 +7667,8 @@ exports.mergeCharacterRegionsByName = mergeRegionsByName;
7028
7667
  exports.resolveBoneName = resolveBoneName;
7029
7668
  exports.resolveBoneNames = resolveBoneNames;
7030
7669
  exports.resolveFaceCenter = resolveFaceCenter;
7670
+ exports.resolvePreset = resolvePreset;
7671
+ exports.resolvePresetWithOverrides = resolvePresetWithOverrides;
7031
7672
  exports.suggestBestPreset = suggestBestPreset;
7032
7673
  exports.validateMappingConfig = validateMappingConfig;
7033
7674
  exports.validateMappings = validateMappings;