@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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as THREE from 'three';
2
- import { Vector3, Clock, Box3, Quaternion, AdditiveAnimationBlendMode, NormalAnimationBlendMode, LoopPingPong, LoopOnce, LoopRepeat, QuaternionKeyframeTrack, NumberKeyframeTrack, AnimationClip, AnimationMixer, Mesh } from 'three';
2
+ import { Vector3, Clock, Box3, Quaternion, AdditiveAnimationBlendMode, NormalAnimationBlendMode, LoopPingPong, LoopOnce, LoopRepeat, QuaternionKeyframeTrack, NumberKeyframeTrack, AnimationClip, AnimationMixer, Mesh, PropertyBinding } from 'three';
3
3
 
4
4
  var __defProp = Object.defineProperty;
5
5
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -58,6 +58,119 @@ function resolveCurveBalance(curveId, globalBalance, balanceMap) {
58
58
  }
59
59
  return clampBalance(globalBalance);
60
60
  }
61
+ var RUNTIME_CLIP_PREFIX = "__loom3_baked_partition__/";
62
+ var FACE_SAFE_TARGET_RE = /(head|neck|jaw|eye|brow|lid|mouth|lip|face|cheek|nose|tongue|teeth)/i;
63
+ 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;
64
+ var SCENE_LIKE_TARGET_RE = /(camera|cam|scene|world|global|origin|pivot|cube)/i;
65
+ var CHANNEL_ORDER = ["face", "body", "scene"];
66
+ function getRuntimeClipName(sourceClipName, channel) {
67
+ return `${RUNTIME_CLIP_PREFIX}${sourceClipName}/${channel}`;
68
+ }
69
+ function parseTrackTarget(trackName, model) {
70
+ let parsed;
71
+ try {
72
+ parsed = PropertyBinding.parseTrackName(trackName);
73
+ } catch {
74
+ return null;
75
+ }
76
+ const targetKey = parsed.objectName === "bones" && parsed.objectIndex ? String(parsed.objectIndex) : parsed.nodeName;
77
+ const target = targetKey ? model.getObjectByProperty("uuid", targetKey) ?? PropertyBinding.findNode(model, targetKey) : null;
78
+ return {
79
+ propertyName: parsed.propertyName,
80
+ target,
81
+ targetName: target?.name ?? parsed.nodeName ?? ""
82
+ };
83
+ }
84
+ function isSceneTrackTarget(target, targetName) {
85
+ if (!target) return true;
86
+ if (target.isCamera) return true;
87
+ return SCENE_LIKE_TARGET_RE.test(targetName);
88
+ }
89
+ function isFaceSafeTransformTarget(target, targetName, safeTransformTargets) {
90
+ if (target && safeTransformTargets.has(target)) {
91
+ return true;
92
+ }
93
+ if (!targetName) {
94
+ return false;
95
+ }
96
+ if (BODY_LIKE_TARGET_RE.test(targetName) || SCENE_LIKE_TARGET_RE.test(targetName)) {
97
+ return false;
98
+ }
99
+ return FACE_SAFE_TARGET_RE.test(targetName);
100
+ }
101
+ function classifyBakedTrack(track, model, bones) {
102
+ const parsed = parseTrackTarget(track.name, model);
103
+ if (!parsed) {
104
+ return "scene";
105
+ }
106
+ if (parsed.propertyName === "morphTargetInfluences" || parsed.propertyName === "weights") {
107
+ return "face";
108
+ }
109
+ if (isSceneTrackTarget(parsed.target, parsed.targetName)) {
110
+ return "scene";
111
+ }
112
+ if (parsed.propertyName === "quaternion") {
113
+ const safeTransformTargets = new Set(
114
+ Object.values(bones).map((entry) => entry?.obj).filter((entry) => !!entry)
115
+ );
116
+ if (isFaceSafeTransformTarget(parsed.target, parsed.targetName, safeTransformTargets)) {
117
+ return "face";
118
+ }
119
+ }
120
+ return "body";
121
+ }
122
+ function resolveBakedChannelBlendMode(channel, requestedBlendMode) {
123
+ if (channel === "face") {
124
+ return requestedBlendMode === "additive" ? "additive" : "replace";
125
+ }
126
+ if (channel === "body") {
127
+ return "replace";
128
+ }
129
+ return void 0;
130
+ }
131
+ function resolveBakedAggregateBlendMode(channels, requestedBlendMode) {
132
+ if (requestedBlendMode !== "additive") {
133
+ return "replace";
134
+ }
135
+ return channels.some((channel) => channel.channel === "face" && channel.playable && channel.trackCount > 0) ? "additive" : "replace";
136
+ }
137
+ function partitionBakedClip(clip, model, bones) {
138
+ const tracksByChannel = new Map(
139
+ CHANNEL_ORDER.map((channel) => [channel, []])
140
+ );
141
+ for (const track of clip.tracks) {
142
+ const channel = classifyBakedTrack(track, model, bones);
143
+ tracksByChannel.get(channel)?.push(track.clone());
144
+ }
145
+ const runtimeClips = [];
146
+ const channels = [];
147
+ for (const channel of CHANNEL_ORDER) {
148
+ const tracks = tracksByChannel.get(channel) ?? [];
149
+ if (tracks.length === 0) {
150
+ continue;
151
+ }
152
+ const playable = channel !== "scene";
153
+ const blendMode = resolveBakedChannelBlendMode(channel, "additive");
154
+ channels.push({
155
+ channel,
156
+ trackCount: tracks.length,
157
+ playable,
158
+ blendMode
159
+ });
160
+ if (!playable) {
161
+ continue;
162
+ }
163
+ runtimeClips.push({
164
+ channel,
165
+ clip: new AnimationClip(getRuntimeClipName(clip.name, channel), clip.duration, tracks)
166
+ });
167
+ }
168
+ return {
169
+ sourceClip: clip,
170
+ channels,
171
+ runtimeClips
172
+ };
173
+ }
61
174
 
62
175
  // src/engines/three/AnimationThree.ts
63
176
  var easeInOutQuad = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
@@ -157,12 +270,18 @@ var makeActionId = () => `act_${Math.random().toString(36).slice(2, 8)}_${Date.n
157
270
  var X_AXIS = new Vector3(1, 0, 0);
158
271
  var Y_AXIS = new Vector3(0, 1, 0);
159
272
  var Z_AXIS = new Vector3(0, 0, 1);
273
+ var CLIP_EVENT_METADATA_KEY = "__loom3ClipEvents";
274
+ var CLIP_EVENT_EPSILON = 1e-4;
160
275
  var BakedAnimationController = class {
161
276
  constructor(host) {
162
277
  __publicField(this, "host");
163
278
  __publicField(this, "animationMixer", null);
164
279
  __publicField(this, "mixerFinishedListenerAttached", false);
165
280
  __publicField(this, "animationClips", []);
281
+ __publicField(this, "bakedSourceClips", /* @__PURE__ */ new Map());
282
+ __publicField(this, "bakedRuntimeActions", /* @__PURE__ */ new Map());
283
+ __publicField(this, "bakedActionGroups", /* @__PURE__ */ new Map());
284
+ __publicField(this, "bakedRuntimeClipToSource", /* @__PURE__ */ new Map());
166
285
  __publicField(this, "animationActions", /* @__PURE__ */ new Map());
167
286
  __publicField(this, "animationFinishedCallbacks", /* @__PURE__ */ new Map());
168
287
  __publicField(this, "clipActions", /* @__PURE__ */ new Map());
@@ -171,6 +290,7 @@ var BakedAnimationController = class {
171
290
  __publicField(this, "playbackState", /* @__PURE__ */ new Map());
172
291
  __publicField(this, "actionIds", /* @__PURE__ */ new WeakMap());
173
292
  __publicField(this, "actionIdToClip", /* @__PURE__ */ new Map());
293
+ __publicField(this, "clipMonitors", /* @__PURE__ */ new Map());
174
294
  this.host = host;
175
295
  }
176
296
  getActionId(action) {
@@ -184,6 +304,161 @@ var BakedAnimationController = class {
184
304
  action.__actionId = actionId;
185
305
  return actionId;
186
306
  }
307
+ setClipEventMetadata(clip, metadata) {
308
+ const userData = clip.userData ?? (clip.userData = {});
309
+ userData[CLIP_EVENT_METADATA_KEY] = metadata;
310
+ }
311
+ getClipEventMetadata(clip) {
312
+ const userData = clip.userData;
313
+ const keyframeTimes = Array.isArray(userData?.[CLIP_EVENT_METADATA_KEY]?.keyframeTimes) ? userData[CLIP_EVENT_METADATA_KEY].keyframeTimes.filter((time) => Number.isFinite(time)) : [];
314
+ return { keyframeTimes };
315
+ }
316
+ getKeyframeIndex(times, currentTime) {
317
+ if (!times.length) return -1;
318
+ const target = Math.max(0, currentTime) + 1e-3;
319
+ let lo = 0;
320
+ let hi = times.length - 1;
321
+ let idx = 0;
322
+ while (lo <= hi) {
323
+ const mid = lo + hi >>> 1;
324
+ if (times[mid] <= target) {
325
+ idx = mid;
326
+ lo = mid + 1;
327
+ } else {
328
+ hi = mid - 1;
329
+ }
330
+ }
331
+ return idx;
332
+ }
333
+ emitClipEvent(monitor, event) {
334
+ for (const listener of Array.from(monitor.listeners)) {
335
+ try {
336
+ listener(event);
337
+ } catch (error) {
338
+ console.error("[Loom3] clip event listener failed", error);
339
+ }
340
+ }
341
+ }
342
+ emitKeyframesForRange(monitor, startTime, endTime, direction, includeStart) {
343
+ if (!monitor.keyframeTimes.length) return;
344
+ const times = direction === 1 ? monitor.keyframeTimes : [...monitor.keyframeTimes].reverse();
345
+ for (const time of times) {
346
+ const matchesForward = direction === 1 && (includeStart ? time >= startTime - CLIP_EVENT_EPSILON : time > startTime + CLIP_EVENT_EPSILON) && time <= endTime + CLIP_EVENT_EPSILON;
347
+ const matchesReverse = direction === -1 && (includeStart ? time <= startTime + CLIP_EVENT_EPSILON : time < startTime - CLIP_EVENT_EPSILON) && time >= endTime - CLIP_EVENT_EPSILON;
348
+ if (!matchesForward && !matchesReverse) continue;
349
+ const keyframeIndex = monitor.keyframeTimes.indexOf(time);
350
+ monitor.lastKeyframeIndex = keyframeIndex;
351
+ this.emitClipEvent(monitor, {
352
+ type: "keyframe",
353
+ clipName: monitor.clipName,
354
+ keyframeIndex,
355
+ totalKeyframes: monitor.keyframeTimes.length,
356
+ currentTime: time,
357
+ duration: monitor.duration,
358
+ iteration: monitor.iteration
359
+ });
360
+ }
361
+ }
362
+ resetClipMonitor(monitor, currentTime) {
363
+ monitor.iteration = 0;
364
+ monitor.direction = monitor.initialDirection;
365
+ monitor.lastTime = currentTime;
366
+ monitor.lastKeyframeIndex = this.getKeyframeIndex(monitor.keyframeTimes, currentTime);
367
+ monitor.finishedPending = false;
368
+ }
369
+ syncClipMonitorTime(monitor, currentTime, emitSeek = false) {
370
+ const clamped = Math.max(0, Math.min(monitor.duration, currentTime));
371
+ monitor.lastTime = clamped;
372
+ monitor.lastKeyframeIndex = this.getKeyframeIndex(monitor.keyframeTimes, clamped);
373
+ if (emitSeek) {
374
+ this.emitClipEvent(monitor, {
375
+ type: "seek",
376
+ clipName: monitor.clipName,
377
+ currentTime: clamped,
378
+ duration: monitor.duration,
379
+ iteration: monitor.iteration
380
+ });
381
+ }
382
+ }
383
+ cleanupClipMonitor(actionId) {
384
+ const monitor = this.clipMonitors.get(actionId);
385
+ if (!monitor || monitor.cleanedUp) return;
386
+ monitor.cleanedUp = true;
387
+ try {
388
+ monitor.action.paused = true;
389
+ } catch {
390
+ }
391
+ monitor.resolveFinished();
392
+ monitor.listeners.clear();
393
+ this.clipMonitors.delete(actionId);
394
+ this.actionIdToClip.delete(actionId);
395
+ }
396
+ advanceClipMonitor(monitor, previousTime) {
397
+ if (monitor.cleanedUp || monitor.action.paused && !monitor.finishedPending) return;
398
+ const currentTime = Math.max(0, Math.min(monitor.duration, monitor.action.time));
399
+ const delta = currentTime - previousTime;
400
+ if (monitor.loopMode === "pingpong") {
401
+ const movingForward = monitor.direction === 1;
402
+ const bouncedAtEnd = movingForward && delta < -CLIP_EVENT_EPSILON;
403
+ const bouncedAtStart = !movingForward && delta > CLIP_EVENT_EPSILON;
404
+ if (bouncedAtEnd) {
405
+ this.emitKeyframesForRange(monitor, previousTime, monitor.duration, 1, false);
406
+ monitor.direction = -1;
407
+ this.emitKeyframesForRange(monitor, monitor.duration, currentTime, -1, false);
408
+ } else if (bouncedAtStart) {
409
+ this.emitKeyframesForRange(monitor, previousTime, 0, -1, false);
410
+ monitor.direction = 1;
411
+ monitor.iteration += 1;
412
+ this.emitClipEvent(monitor, {
413
+ type: "loop",
414
+ clipName: monitor.clipName,
415
+ iteration: monitor.iteration,
416
+ currentTime: 0,
417
+ duration: monitor.duration
418
+ });
419
+ this.emitKeyframesForRange(monitor, 0, currentTime, 1, false);
420
+ } else if (delta > CLIP_EVENT_EPSILON) {
421
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, 1, false);
422
+ monitor.direction = 1;
423
+ } else if (delta < -CLIP_EVENT_EPSILON) {
424
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, -1, false);
425
+ monitor.direction = -1;
426
+ }
427
+ } else if (monitor.direction === 1) {
428
+ const wrapped = currentTime + CLIP_EVENT_EPSILON < previousTime;
429
+ if (wrapped) {
430
+ this.emitKeyframesForRange(monitor, previousTime, monitor.duration, 1, false);
431
+ monitor.iteration += 1;
432
+ this.emitClipEvent(monitor, {
433
+ type: "loop",
434
+ clipName: monitor.clipName,
435
+ iteration: monitor.iteration,
436
+ currentTime: 0,
437
+ duration: monitor.duration
438
+ });
439
+ this.emitKeyframesForRange(monitor, 0, currentTime, 1, true);
440
+ } else if (delta > CLIP_EVENT_EPSILON) {
441
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, 1, false);
442
+ }
443
+ } else {
444
+ const wrapped = currentTime > previousTime + CLIP_EVENT_EPSILON;
445
+ if (wrapped) {
446
+ this.emitKeyframesForRange(monitor, previousTime, 0, -1, false);
447
+ monitor.iteration += 1;
448
+ this.emitClipEvent(monitor, {
449
+ type: "loop",
450
+ clipName: monitor.clipName,
451
+ iteration: monitor.iteration,
452
+ currentTime: monitor.duration,
453
+ duration: monitor.duration
454
+ });
455
+ this.emitKeyframesForRange(monitor, monitor.duration, currentTime, -1, true);
456
+ } else if (delta < -CLIP_EVENT_EPSILON) {
457
+ this.emitKeyframesForRange(monitor, previousTime, currentTime, -1, false);
458
+ }
459
+ }
460
+ this.syncClipMonitorTime(monitor, currentTime);
461
+ }
187
462
  normalizePlaybackOptions(options, defaults) {
188
463
  const clipOptions = options;
189
464
  const rawRate = options?.playbackRate ?? options?.speed ?? 1;
@@ -191,6 +466,7 @@ var BakedAnimationController = class {
191
466
  const rawWeight = options?.weight ?? options?.intensity ?? clipOptions?.mixerWeight ?? 1;
192
467
  const weight = Number.isFinite(rawWeight) ? Math.max(0, rawWeight) : 1;
193
468
  const loopMode = options?.loopMode ?? (typeof options?.loop === "boolean" ? options.loop ? "repeat" : "once" : defaults.loop ? "repeat" : "once");
469
+ const requestedBlendMode = options?.blendMode ?? (clipOptions?.mixerAdditive ? "additive" : "replace");
194
470
  return {
195
471
  source: options?.source ?? defaults.source,
196
472
  loop: loopMode !== "once",
@@ -200,7 +476,8 @@ var BakedAnimationController = class {
200
476
  playbackRate,
201
477
  weight,
202
478
  balance: Number.isFinite(options?.balance) ? options?.balance ?? 0 : 0,
203
- blendMode: options?.blendMode ?? (clipOptions?.mixerAdditive ? "additive" : "replace"),
479
+ requestedBlendMode,
480
+ blendMode: requestedBlendMode,
204
481
  easing: options?.easing ?? "linear"
205
482
  };
206
483
  }
@@ -260,15 +537,49 @@ var BakedAnimationController = class {
260
537
  next.balance = Math.max(-1, Math.min(1, options.balance));
261
538
  }
262
539
  if (options.blendMode) {
263
- next.blendMode = options.blendMode;
540
+ next.requestedBlendMode = options.blendMode;
264
541
  } else if (typeof clipOptions?.mixerAdditive === "boolean") {
265
- next.blendMode = clipOptions.mixerAdditive ? "additive" : "replace";
542
+ next.requestedBlendMode = clipOptions.mixerAdditive ? "additive" : "replace";
266
543
  }
544
+ next.blendMode = next.requestedBlendMode;
267
545
  if (options.easing) {
268
546
  next.easing = options.easing;
269
547
  }
270
548
  return next;
271
549
  }
550
+ isBakedSourceClip(clipName) {
551
+ return this.bakedSourceClips.has(clipName);
552
+ }
553
+ getBakedSourceClip(clipName) {
554
+ return this.bakedSourceClips.get(clipName);
555
+ }
556
+ getBakedChannelInfo(clipName, playbackState) {
557
+ const bakedClip = this.getBakedSourceClip(clipName);
558
+ if (!bakedClip) {
559
+ return void 0;
560
+ }
561
+ const requestedBlendMode = playbackState?.requestedBlendMode ?? "replace";
562
+ return bakedClip.channels.map((channel) => ({
563
+ ...channel,
564
+ blendMode: resolveBakedChannelBlendMode(channel.channel, requestedBlendMode)
565
+ }));
566
+ }
567
+ getBakedAggregateBlendMode(clipName, playbackState) {
568
+ const channels = this.getBakedChannelInfo(clipName, playbackState);
569
+ if (!channels) {
570
+ return playbackState?.requestedBlendMode ?? playbackState?.blendMode ?? "replace";
571
+ }
572
+ return resolveBakedAggregateBlendMode(
573
+ channels,
574
+ playbackState?.requestedBlendMode ?? "replace"
575
+ );
576
+ }
577
+ applyPlaybackStateToBakedAction(action, state, channel) {
578
+ this.applyPlaybackState(action, {
579
+ ...state,
580
+ blendMode: resolveBakedChannelBlendMode(channel, state.requestedBlendMode) ?? "replace"
581
+ });
582
+ }
272
583
  resolveStartTime(duration, state, explicitStartTime) {
273
584
  if (typeof explicitStartTime === "number" && Number.isFinite(explicitStartTime)) {
274
585
  return Math.max(0, Math.min(duration, explicitStartTime));
@@ -278,8 +589,13 @@ var BakedAnimationController = class {
278
589
  }
279
590
  return 0;
280
591
  }
281
- getOrCreateBakedAction(clipName) {
282
- const existing = this.animationActions.get(clipName);
592
+ getOrCreateBakedRuntimeAction(sourceClipName, channel) {
593
+ const bakedClip = this.getBakedSourceClip(sourceClipName);
594
+ const runtimeClip = bakedClip?.runtimeClips.find((entry) => entry.channel === channel)?.clip;
595
+ if (!runtimeClip) {
596
+ return null;
597
+ }
598
+ const existing = this.bakedRuntimeActions.get(runtimeClip.name);
283
599
  if (existing) {
284
600
  return existing;
285
601
  }
@@ -287,13 +603,44 @@ var BakedAnimationController = class {
287
603
  if (!this.animationMixer) {
288
604
  return null;
289
605
  }
290
- const clip = this.animationClips.find((entry) => entry.name === clipName);
291
- if (!clip || (this.clipSources.get(clipName) ?? "baked") !== "baked") {
606
+ const action = this.animationMixer.clipAction(runtimeClip);
607
+ this.bakedRuntimeActions.set(runtimeClip.name, action);
608
+ return action;
609
+ }
610
+ getRepresentativeBakedAction(clipName) {
611
+ const group = this.bakedActionGroups.get(clipName);
612
+ if (!group) {
292
613
  return null;
293
614
  }
294
- const action = this.animationMixer.clipAction(clip);
295
- this.animationActions.set(clipName, action);
296
- return action;
615
+ return group.channelActions.values().next().value ?? null;
616
+ }
617
+ createBakedActionGroup(clipName, playbackState) {
618
+ const bakedClip = this.getBakedSourceClip(clipName);
619
+ if (!bakedClip) {
620
+ return null;
621
+ }
622
+ const channelActions = /* @__PURE__ */ new Map();
623
+ for (const runtimeClip of bakedClip.runtimeClips) {
624
+ const action = this.getOrCreateBakedRuntimeAction(clipName, runtimeClip.channel);
625
+ if (action) {
626
+ channelActions.set(runtimeClip.channel, action);
627
+ }
628
+ }
629
+ if (channelActions.size === 0) {
630
+ return null;
631
+ }
632
+ let resolveFinished = () => {
633
+ };
634
+ const finishedPromise = new Promise((resolve) => {
635
+ resolveFinished = resolve;
636
+ });
637
+ return {
638
+ actionId: makeActionId(),
639
+ channelActions,
640
+ pendingFinishedChannels: playbackState.loopMode === "once" ? new Set(channelActions.keys()) : /* @__PURE__ */ new Set(),
641
+ finishedPromise,
642
+ resolveFinished
643
+ };
297
644
  }
298
645
  getMeshNamesForAU(auId, config, explicitMeshNames) {
299
646
  if (explicitMeshNames && explicitMeshNames.length > 0) {
@@ -323,7 +670,28 @@ var BakedAnimationController = class {
323
670
  }
324
671
  update(dtSeconds) {
325
672
  if (this.animationMixer) {
673
+ const snapshots = Array.from(this.clipMonitors.values()).map((monitor) => ({
674
+ actionId: monitor.actionId,
675
+ previousTime: monitor.action.time
676
+ }));
326
677
  this.animationMixer.update(dtSeconds);
678
+ for (const { actionId, previousTime } of snapshots) {
679
+ const monitor = this.clipMonitors.get(actionId);
680
+ if (!monitor) continue;
681
+ this.advanceClipMonitor(monitor, previousTime);
682
+ if (monitor.finishedPending) {
683
+ const finalTime = Math.max(0, Math.min(monitor.duration, monitor.action.time));
684
+ this.syncClipMonitorTime(monitor, finalTime);
685
+ this.emitClipEvent(monitor, {
686
+ type: "completed",
687
+ clipName: monitor.clipName,
688
+ currentTime: finalTime,
689
+ duration: monitor.duration,
690
+ iteration: monitor.iteration
691
+ });
692
+ this.cleanupClipMonitor(actionId);
693
+ }
694
+ }
327
695
  }
328
696
  }
329
697
  dispose() {
@@ -333,25 +701,60 @@ var BakedAnimationController = class {
333
701
  this.animationMixer = null;
334
702
  }
335
703
  this.animationClips = [];
704
+ this.bakedSourceClips.clear();
705
+ this.bakedRuntimeActions.clear();
706
+ this.bakedActionGroups.clear();
707
+ this.bakedRuntimeClipToSource.clear();
336
708
  this.animationActions.clear();
337
709
  this.animationFinishedCallbacks.clear();
338
710
  this.clipActions.clear();
339
711
  this.clipHandles.clear();
340
712
  this.clipSources.clear();
341
713
  this.playbackState.clear();
714
+ this.clipMonitors.clear();
342
715
  }
343
716
  loadAnimationClips(clips) {
344
- if (!this.host.getModel()) {
717
+ const model = this.host.getModel();
718
+ if (!model) {
345
719
  console.warn("Loom3: Cannot load animation clips before calling onReady()");
346
720
  return;
347
721
  }
722
+ for (const clipName of this.bakedSourceClips.keys()) {
723
+ this.stopAnimation(clipName);
724
+ }
725
+ if (this.animationMixer) {
726
+ for (const bakedClip of this.bakedSourceClips.values()) {
727
+ for (const runtimeClip of bakedClip.runtimeClips) {
728
+ try {
729
+ this.animationMixer.uncacheAction(runtimeClip.clip);
730
+ } catch {
731
+ }
732
+ try {
733
+ this.animationMixer.uncacheClip(runtimeClip.clip);
734
+ } catch {
735
+ }
736
+ }
737
+ }
738
+ }
739
+ for (const clipName of this.bakedSourceClips.keys()) {
740
+ this.playbackState.delete(clipName);
741
+ this.clipSources.delete(clipName);
742
+ }
743
+ this.bakedSourceClips.clear();
744
+ this.bakedRuntimeActions.clear();
745
+ this.bakedActionGroups.clear();
746
+ this.bakedRuntimeClipToSource.clear();
348
747
  this.ensureMixer();
349
- this.animationClips = clips;
350
- for (const clip of this.animationClips) {
351
- this.clipSources.set(clip.name, "baked");
352
- if (!this.animationActions.has(clip.name) && this.animationMixer) {
353
- const action = this.animationMixer.clipAction(clip);
354
- this.animationActions.set(clip.name, action);
748
+ const partitionedClips = clips.map((clip) => partitionBakedClip(clip, model, this.host.getBones()));
749
+ this.animationClips = partitionedClips.map((clip) => clip.sourceClip);
750
+ for (const bakedClip of partitionedClips) {
751
+ this.bakedSourceClips.set(bakedClip.sourceClip.name, bakedClip);
752
+ this.clipSources.set(bakedClip.sourceClip.name, "baked");
753
+ for (const runtimeClip of bakedClip.runtimeClips) {
754
+ this.bakedRuntimeClipToSource.set(runtimeClip.clip.name, {
755
+ sourceClipName: bakedClip.sourceClip.name,
756
+ channel: runtimeClip.channel
757
+ });
355
758
  }
356
759
  }
357
760
  }
@@ -360,86 +763,94 @@ var BakedAnimationController = class {
360
763
  name: clip.name,
361
764
  duration: clip.duration,
362
765
  trackCount: clip.tracks.length,
363
- source: this.clipSources.get(clip.name) ?? "baked"
766
+ source: this.clipSources.get(clip.name) ?? "baked",
767
+ channels: this.getBakedSourceClip(clip.name)?.channels
364
768
  }));
365
769
  }
366
770
  removeAnimationClip(clipName) {
367
- const clip = this.animationClips.find((entry) => entry.name === clipName);
368
- if (!clip || (this.clipSources.get(clipName) ?? "baked") !== "baked") {
771
+ const bakedClip = this.getBakedSourceClip(clipName);
772
+ if (!bakedClip) {
369
773
  return false;
370
774
  }
371
- const relatedActions = /* @__PURE__ */ new Set();
372
- const bakedAction = this.animationActions.get(clipName);
373
- const clipAction = this.clipActions.get(clipName);
374
- if (bakedAction) relatedActions.add(bakedAction);
375
- if (clipAction) relatedActions.add(clipAction);
376
775
  this.stopAnimation(clipName);
377
776
  if (this.animationMixer) {
378
- for (const action of relatedActions) {
777
+ for (const runtimeClip of bakedClip.runtimeClips) {
778
+ const action = this.bakedRuntimeActions.get(runtimeClip.clip.name);
379
779
  try {
380
- this.animationMixer.uncacheAction(clip);
780
+ this.animationMixer.uncacheAction(runtimeClip.clip);
381
781
  } catch {
382
782
  }
383
783
  try {
384
- this.animationMixer.uncacheClip(clip);
784
+ this.animationMixer.uncacheClip(runtimeClip.clip);
385
785
  } catch {
386
786
  }
787
+ this.bakedRuntimeActions.delete(runtimeClip.clip.name);
788
+ this.bakedRuntimeClipToSource.delete(runtimeClip.clip.name);
387
789
  const actionId = this.getActionId(action);
388
- if (actionId) {
790
+ if (actionId && action) {
389
791
  this.actionIdToClip.delete(actionId);
792
+ this.actionIds.delete(action);
390
793
  }
391
- this.actionIds.delete(action);
392
794
  }
393
795
  }
394
796
  this.animationClips = this.animationClips.filter((entry) => entry.name !== clipName);
395
- this.animationActions.delete(clipName);
396
- this.clipActions.delete(clipName);
397
- this.clipHandles.delete(clipName);
398
- this.animationFinishedCallbacks.delete(clipName);
797
+ this.bakedSourceClips.delete(clipName);
798
+ this.bakedActionGroups.delete(clipName);
399
799
  this.playbackState.delete(clipName);
400
800
  this.clipSources.delete(clipName);
401
801
  return true;
402
802
  }
403
803
  playAnimation(clipName, options = {}) {
404
- const action = this.getOrCreateBakedAction(clipName);
405
- if (!action) {
804
+ const bakedClip = this.getBakedSourceClip(clipName);
805
+ if (!bakedClip) {
406
806
  console.warn(`Loom3: Animation clip "${clipName}" not found`);
407
807
  return null;
408
808
  }
409
- if (!this.getActionId(action)) {
410
- this.setActionId(action, clipName);
411
- }
412
809
  const playbackState = this.mergePlaybackOptions(
413
810
  this.getPlaybackStateSnapshot(clipName, { loop: true, source: "baked" }),
414
811
  options
415
812
  );
813
+ playbackState.blendMode = this.getBakedAggregateBlendMode(clipName, playbackState);
814
+ const actionGroup = this.createBakedActionGroup(clipName, playbackState);
815
+ if (!actionGroup) {
816
+ console.warn(`Loom3: Animation clip "${clipName}" has no character-runtime channels to play`);
817
+ return null;
818
+ }
416
819
  const crossfadeDuration = options.crossfadeDuration ?? 0;
417
820
  const clampWhenFinished = options.clampWhenFinished ?? playbackState.loopMode === "once";
418
- const startTime = this.resolveStartTime(action.getClip().duration, playbackState, options.startTime);
419
- this.applyPlaybackState(action, playbackState);
420
- action.clampWhenFinished = clampWhenFinished;
421
- if (crossfadeDuration > 0) {
422
- action.reset();
423
- action.fadeIn(crossfadeDuration);
424
- } else {
425
- action.reset();
821
+ const startTime = this.resolveStartTime(bakedClip.sourceClip.duration, playbackState, options.startTime);
822
+ for (const [channel, action] of actionGroup.channelActions) {
823
+ this.applyPlaybackStateToBakedAction(action, playbackState, channel);
824
+ action.clampWhenFinished = clampWhenFinished;
825
+ if (crossfadeDuration > 0) {
826
+ action.reset();
827
+ action.fadeIn(crossfadeDuration);
828
+ } else {
829
+ action.reset();
830
+ }
831
+ action.time = startTime;
832
+ action.play();
426
833
  }
427
- action.time = startTime;
428
- action.play();
429
- this.animationActions.set(clipName, action);
834
+ this.bakedActionGroups.set(clipName, actionGroup);
430
835
  this.setPlaybackState(clipName, playbackState);
431
- let resolveFinished;
432
- const finishedPromise = new Promise((resolve) => {
433
- resolveFinished = resolve;
434
- });
435
- if (playbackState.loopMode === "once") {
436
- this.animationFinishedCallbacks.set(clipName, () => resolveFinished());
437
- }
438
- return this.createAnimationHandle(clipName, action, finishedPromise);
836
+ return this.createBakedAnimationHandle(clipName, actionGroup);
439
837
  }
440
838
  stopAnimation(clipName) {
839
+ const bakedGroup = this.bakedActionGroups.get(clipName);
840
+ if (bakedGroup) {
841
+ for (const action2 of bakedGroup.channelActions.values()) {
842
+ action2.stop();
843
+ try {
844
+ action2.paused = false;
845
+ } catch {
846
+ }
847
+ }
848
+ this.bakedActionGroups.delete(clipName);
849
+ return;
850
+ }
441
851
  const action = this.animationActions.get(clipName);
442
852
  if (action) {
853
+ const actionId = this.getActionId(action);
443
854
  const isBaked = (this.clipSources.get(clipName) ?? "baked") === "baked";
444
855
  action.stop();
445
856
  if (!isBaked && this.animationMixer) {
@@ -462,9 +873,11 @@ var BakedAnimationController = class {
462
873
  }
463
874
  }
464
875
  this.animationFinishedCallbacks.delete(clipName);
876
+ if (actionId) this.cleanupClipMonitor(actionId);
465
877
  }
466
878
  const clipAction = this.clipActions.get(clipName);
467
879
  if (clipAction && clipAction !== action) {
880
+ const actionId = this.getActionId(clipAction);
468
881
  try {
469
882
  clipAction.stop();
470
883
  if (this.animationMixer) {
@@ -477,6 +890,7 @@ var BakedAnimationController = class {
477
890
  } catch {
478
891
  }
479
892
  this.clipActions.delete(clipName);
893
+ if (actionId) this.cleanupClipMonitor(actionId);
480
894
  }
481
895
  if (this.clipActions.get(clipName) === action) {
482
896
  this.clipActions.delete(clipName);
@@ -485,6 +899,7 @@ var BakedAnimationController = class {
485
899
  }
486
900
  stopAllAnimations() {
487
901
  for (const clipName of /* @__PURE__ */ new Set([
902
+ ...this.bakedActionGroups.keys(),
488
903
  ...this.animationActions.keys(),
489
904
  ...this.clipActions.keys()
490
905
  ])) {
@@ -492,18 +907,39 @@ var BakedAnimationController = class {
492
907
  }
493
908
  }
494
909
  pauseAnimation(clipName) {
910
+ const bakedGroup = this.bakedActionGroups.get(clipName);
911
+ if (bakedGroup) {
912
+ for (const action2 of bakedGroup.channelActions.values()) {
913
+ action2.paused = true;
914
+ }
915
+ return;
916
+ }
495
917
  const action = this.animationActions.get(clipName);
496
918
  if (action) {
497
919
  action.paused = true;
498
920
  }
499
921
  }
500
922
  resumeAnimation(clipName) {
923
+ const bakedGroup = this.bakedActionGroups.get(clipName);
924
+ if (bakedGroup) {
925
+ for (const action2 of bakedGroup.channelActions.values()) {
926
+ action2.paused = false;
927
+ }
928
+ return;
929
+ }
501
930
  const action = this.animationActions.get(clipName);
502
931
  if (action) {
503
932
  action.paused = false;
504
933
  }
505
934
  }
506
935
  pauseAllAnimations() {
936
+ for (const group of this.bakedActionGroups.values()) {
937
+ for (const action of group.channelActions.values()) {
938
+ if (action.isRunning()) {
939
+ action.paused = true;
940
+ }
941
+ }
942
+ }
507
943
  for (const action of this.animationActions.values()) {
508
944
  if (action.isRunning()) {
509
945
  action.paused = true;
@@ -511,6 +947,13 @@ var BakedAnimationController = class {
511
947
  }
512
948
  }
513
949
  resumeAllAnimations() {
950
+ for (const group of this.bakedActionGroups.values()) {
951
+ for (const action of group.channelActions.values()) {
952
+ if (action.paused) {
953
+ action.paused = false;
954
+ }
955
+ }
956
+ }
514
957
  for (const action of this.animationActions.values()) {
515
958
  if (action.paused) {
516
959
  action.paused = false;
@@ -518,76 +961,161 @@ var BakedAnimationController = class {
518
961
  }
519
962
  }
520
963
  setAnimationSpeed(clipName, speed) {
521
- const action = this.getOrCreateBakedAction(clipName);
522
- if (action) {
964
+ if (this.isBakedSourceClip(clipName)) {
523
965
  const next = this.getPlaybackStateSnapshot(clipName, {
524
966
  loop: true,
525
967
  source: this.clipSources.get(clipName) ?? "baked"
526
968
  });
527
969
  next.playbackRate = Number.isFinite(speed) ? Math.max(0, Math.abs(speed)) : 1;
970
+ const bakedGroup = this.bakedActionGroups.get(clipName);
971
+ if (bakedGroup) {
972
+ for (const [channel, action2] of bakedGroup.channelActions) {
973
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
974
+ }
975
+ }
976
+ this.setPlaybackState(clipName, next);
977
+ return;
978
+ }
979
+ const action = this.animationActions.get(clipName);
980
+ if (action) {
981
+ const next = this.getPlaybackStateSnapshot(clipName, {
982
+ loop: true,
983
+ source: this.clipSources.get(clipName) ?? "clip"
984
+ });
985
+ next.playbackRate = Number.isFinite(speed) ? Math.max(0, Math.abs(speed)) : 1;
528
986
  this.applyPlaybackState(action, next);
529
987
  this.setPlaybackState(clipName, next);
530
988
  }
531
989
  }
532
990
  setAnimationIntensity(clipName, intensity) {
533
- const action = this.getOrCreateBakedAction(clipName);
534
- if (action) {
991
+ if (this.isBakedSourceClip(clipName)) {
535
992
  const next = this.getPlaybackStateSnapshot(clipName, {
536
993
  loop: true,
537
994
  source: this.clipSources.get(clipName) ?? "baked"
538
995
  });
539
996
  next.weight = Number.isFinite(intensity) ? Math.max(0, intensity) : 1;
997
+ const bakedGroup = this.bakedActionGroups.get(clipName);
998
+ if (bakedGroup) {
999
+ for (const [channel, action2] of bakedGroup.channelActions) {
1000
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1001
+ }
1002
+ }
1003
+ this.setPlaybackState(clipName, next);
1004
+ return;
1005
+ }
1006
+ const action = this.animationActions.get(clipName);
1007
+ if (action) {
1008
+ const next = this.getPlaybackStateSnapshot(clipName, {
1009
+ loop: true,
1010
+ source: this.clipSources.get(clipName) ?? "clip"
1011
+ });
1012
+ next.weight = Number.isFinite(intensity) ? Math.max(0, intensity) : 1;
540
1013
  action.setEffectiveWeight(next.weight);
541
1014
  this.setPlaybackState(clipName, next);
542
1015
  }
543
1016
  }
544
1017
  setAnimationLoopMode(clipName, loopMode) {
545
- const action = this.getOrCreateBakedAction(clipName);
546
- if (!action) return;
547
1018
  const next = this.getPlaybackStateSnapshot(clipName, {
548
1019
  loop: true,
549
- source: this.clipSources.get(clipName) ?? "baked"
1020
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
550
1021
  });
551
1022
  next.loopMode = loopMode;
552
1023
  next.loop = loopMode !== "once";
1024
+ if (this.isBakedSourceClip(clipName)) {
1025
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1026
+ if (bakedGroup) {
1027
+ for (const [channel, action2] of bakedGroup.channelActions) {
1028
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1029
+ }
1030
+ }
1031
+ this.setPlaybackState(clipName, next);
1032
+ return;
1033
+ }
1034
+ const action = this.animationActions.get(clipName);
1035
+ if (!action) return;
553
1036
  this.applyPlaybackState(action, next);
554
1037
  this.setPlaybackState(clipName, next);
555
1038
  }
556
1039
  setAnimationRepeatCount(clipName, repeatCount) {
557
- const action = this.getOrCreateBakedAction(clipName);
558
- if (!action) return;
559
1040
  const next = this.getPlaybackStateSnapshot(clipName, {
560
1041
  loop: true,
561
- source: this.clipSources.get(clipName) ?? "baked"
1042
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
562
1043
  });
563
1044
  next.repeatCount = typeof repeatCount === "number" && Number.isFinite(repeatCount) ? Math.max(0, repeatCount) : void 0;
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;
564
1057
  this.applyPlaybackState(action, next);
565
1058
  this.setPlaybackState(clipName, next);
566
1059
  }
567
1060
  setAnimationReverse(clipName, reverse) {
568
- const action = this.getOrCreateBakedAction(clipName);
569
- if (!action) return;
570
1061
  const next = this.getPlaybackStateSnapshot(clipName, {
571
1062
  loop: true,
572
- source: this.clipSources.get(clipName) ?? "baked"
1063
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
573
1064
  });
574
1065
  next.reverse = !!reverse;
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;
575
1078
  this.applyPlaybackState(action, next);
576
1079
  this.setPlaybackState(clipName, next);
577
1080
  }
578
1081
  setAnimationBlendMode(clipName, blendMode) {
579
- const action = this.getOrCreateBakedAction(clipName);
580
- if (!action) return;
581
1082
  const next = this.getPlaybackStateSnapshot(clipName, {
582
1083
  loop: true,
583
- source: this.clipSources.get(clipName) ?? "baked"
1084
+ source: this.clipSources.get(clipName) ?? (this.isBakedSourceClip(clipName) ? "baked" : "clip")
584
1085
  });
1086
+ next.requestedBlendMode = blendMode;
1087
+ if (this.isBakedSourceClip(clipName)) {
1088
+ next.blendMode = this.getBakedAggregateBlendMode(clipName, next);
1089
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1090
+ if (bakedGroup) {
1091
+ for (const [channel, action2] of bakedGroup.channelActions) {
1092
+ this.applyPlaybackStateToBakedAction(action2, next, channel);
1093
+ }
1094
+ }
1095
+ this.setPlaybackState(clipName, next);
1096
+ return;
1097
+ }
585
1098
  next.blendMode = blendMode;
1099
+ const action = this.animationActions.get(clipName);
1100
+ if (!action) return;
586
1101
  this.applyPlaybackState(action, next);
587
1102
  this.setPlaybackState(clipName, next);
588
1103
  }
589
1104
  seekAnimation(clipName, time) {
590
- const action = this.getOrCreateBakedAction(clipName) ?? this.animationActions.get(clipName);
1105
+ const bakedGroup = this.bakedActionGroups.get(clipName);
1106
+ if (bakedGroup) {
1107
+ const duration2 = this.getBakedSourceClip(clipName)?.sourceClip.duration ?? 0;
1108
+ const clamped = Math.max(0, Math.min(duration2, Number.isFinite(time) ? time : 0));
1109
+ for (const action2 of bakedGroup.channelActions.values()) {
1110
+ action2.time = clamped;
1111
+ }
1112
+ try {
1113
+ this.animationMixer?.update(0);
1114
+ } catch {
1115
+ }
1116
+ return;
1117
+ }
1118
+ const action = this.animationActions.get(clipName);
591
1119
  if (!action) return;
592
1120
  const duration = action.getClip().duration;
593
1121
  action.time = Math.max(0, Math.min(duration, Number.isFinite(time) ? time : 0));
@@ -602,6 +1130,40 @@ var BakedAnimationController = class {
602
1130
  }
603
1131
  }
604
1132
  getAnimationState(clipName) {
1133
+ const bakedClip = this.getBakedSourceClip(clipName);
1134
+ if (bakedClip) {
1135
+ const state2 = this.playbackState.get(clipName);
1136
+ const action2 = this.getRepresentativeBakedAction(clipName);
1137
+ if (!state2 && !action2) {
1138
+ return null;
1139
+ }
1140
+ const loopMode2 = state2?.loopMode ?? (action2?.loop === LoopPingPong ? "pingpong" : action2?.loop === LoopOnce ? "once" : "repeat");
1141
+ const playbackRate2 = state2?.playbackRate ?? Math.abs(action2?.getEffectiveTimeScale?.() ?? 1);
1142
+ const reverse2 = state2?.reverse ?? (action2?.getEffectiveTimeScale?.() ?? 1) < 0;
1143
+ const pausedValues = this.bakedActionGroups.get(clipName) ? Array.from(this.bakedActionGroups.get(clipName).channelActions.values()).map((entry) => entry.paused) : [];
1144
+ return {
1145
+ name: bakedClip.sourceClip.name,
1146
+ actionId: this.bakedActionGroups.get(clipName)?.actionId,
1147
+ source: state2?.source ?? this.clipSources.get(clipName) ?? "baked",
1148
+ isPlaying: this.bakedActionGroups.get(clipName) ? Array.from(this.bakedActionGroups.get(clipName).channelActions.values()).some((entry) => entry.isRunning() && !entry.paused) : false,
1149
+ isPaused: pausedValues.length > 0 ? pausedValues.every(Boolean) : false,
1150
+ time: action2?.time ?? 0,
1151
+ duration: bakedClip.sourceClip.duration,
1152
+ speed: playbackRate2,
1153
+ playbackRate: playbackRate2,
1154
+ reverse: reverse2,
1155
+ weight: state2?.weight ?? action2?.getEffectiveWeight?.() ?? 1,
1156
+ balance: state2?.balance ?? 0,
1157
+ requestedBlendMode: state2?.requestedBlendMode ?? "replace",
1158
+ blendMode: this.getBakedAggregateBlendMode(clipName, state2),
1159
+ channels: this.getBakedChannelInfo(clipName, state2),
1160
+ easing: state2?.easing ?? "linear",
1161
+ loop: loopMode2 !== "once",
1162
+ loopMode: loopMode2,
1163
+ repeatCount: state2?.repeatCount,
1164
+ isLooping: loopMode2 !== "once"
1165
+ };
1166
+ }
605
1167
  const action = this.animationActions.get(clipName);
606
1168
  if (!action) return null;
607
1169
  const clip = action.getClip();
@@ -622,7 +1184,9 @@ var BakedAnimationController = class {
622
1184
  reverse,
623
1185
  weight: state?.weight ?? action.getEffectiveWeight(),
624
1186
  balance: state?.balance ?? 0,
1187
+ requestedBlendMode: state?.requestedBlendMode ?? state?.blendMode ?? "replace",
625
1188
  blendMode: state?.blendMode ?? "replace",
1189
+ channels: state?.source === "baked" ? this.getBakedChannelInfo(clipName, state) : void 0,
626
1190
  easing: state?.easing ?? "linear",
627
1191
  loop: loopMode !== "once",
628
1192
  loopMode,
@@ -632,6 +1196,12 @@ var BakedAnimationController = class {
632
1196
  }
633
1197
  getPlayingAnimations() {
634
1198
  const playing = [];
1199
+ for (const name of this.bakedActionGroups.keys()) {
1200
+ const state = this.getAnimationState(name);
1201
+ if (state?.isPlaying) {
1202
+ playing.push(state);
1203
+ }
1204
+ }
635
1205
  for (const [name, action] of this.animationActions) {
636
1206
  if (action.isRunning()) {
637
1207
  const state = this.getAnimationState(name);
@@ -641,6 +1211,13 @@ var BakedAnimationController = class {
641
1211
  return playing;
642
1212
  }
643
1213
  crossfadeTo(clipName, duration = 0.3, options = {}) {
1214
+ for (const group of this.bakedActionGroups.values()) {
1215
+ for (const action of group.channelActions.values()) {
1216
+ if (action.isRunning()) {
1217
+ action.fadeOut(duration);
1218
+ }
1219
+ }
1220
+ }
644
1221
  for (const action of this.animationActions.values()) {
645
1222
  if (action.isRunning()) {
646
1223
  action.fadeOut(duration);
@@ -875,6 +1452,7 @@ var BakedAnimationController = class {
875
1452
  return null;
876
1453
  }
877
1454
  const clip = new AnimationClip(clipName, maxTime, tracks);
1455
+ this.setClipEventMetadata(clip, { keyframeTimes });
878
1456
  console.log(`[Loom3] snippetToClip: Created clip "${clipName}" with ${tracks.length} tracks, duration ${maxTime.toFixed(2)}s`);
879
1457
  return clip;
880
1458
  }
@@ -906,28 +1484,38 @@ var BakedAnimationController = class {
906
1484
  this.animationClips.push(clip);
907
1485
  }
908
1486
  this.applyPlaybackState(action, playbackState);
1487
+ if (actionId) {
1488
+ this.cleanupClipMonitor(actionId);
1489
+ }
909
1490
  let resolveFinished;
910
1491
  const finishedPromise = new Promise((resolve) => {
911
1492
  resolveFinished = resolve;
912
1493
  });
913
- const cleanup = () => {
914
- try {
915
- this.animationFinishedCallbacks.delete(clip.name);
916
- } catch {
917
- }
918
- try {
919
- action.paused = true;
920
- } catch {
921
- }
1494
+ const keyframeTimes = this.getClipEventMetadata(clip).keyframeTimes;
1495
+ const initialDirection = playbackState.reverse ? -1 : 1;
1496
+ const monitor = {
1497
+ action,
1498
+ actionId,
1499
+ clip,
1500
+ clipName: clip.name,
1501
+ duration: clip.duration,
1502
+ keyframeTimes,
1503
+ listeners: /* @__PURE__ */ new Set(),
1504
+ initialDirection,
1505
+ direction: initialDirection,
1506
+ iteration: 0,
1507
+ lastTime: Math.max(0, Math.min(clip.duration, action.time)),
1508
+ lastKeyframeIndex: this.getKeyframeIndex(keyframeTimes, action.time),
1509
+ loopMode: playbackState.loopMode,
1510
+ finishedPending: false,
1511
+ cleanedUp: false,
1512
+ resolveFinished
922
1513
  };
923
- this.animationFinishedCallbacks.set(clip.name, () => {
924
- resolveFinished();
925
- cleanup();
926
- });
927
- finishedPromise.catch(() => cleanup());
1514
+ this.clipMonitors.set(actionId, monitor);
928
1515
  action.reset();
929
1516
  action.time = startTime;
930
1517
  action.play();
1518
+ this.resetClipMonitor(monitor, action.time);
931
1519
  this.clipActions.set(clip.name, action);
932
1520
  this.animationActions.set(clip.name, action);
933
1521
  this.setPlaybackState(clip.name, playbackState);
@@ -945,6 +1533,7 @@ var BakedAnimationController = class {
945
1533
  })
946
1534
  );
947
1535
  action.play();
1536
+ this.resetClipMonitor(monitor, action.time);
948
1537
  },
949
1538
  stop: () => {
950
1539
  action.stop();
@@ -962,8 +1551,7 @@ var BakedAnimationController = class {
962
1551
  this.animationActions.delete(clip.name);
963
1552
  this.animationFinishedCallbacks.delete(clip.name);
964
1553
  this.playbackState.delete(clip.name);
965
- resolveFinished();
966
- cleanup();
1554
+ this.cleanupClipMonitor(actionId);
967
1555
  },
968
1556
  pause: () => {
969
1557
  action.paused = true;
@@ -981,6 +1569,8 @@ var BakedAnimationController = class {
981
1569
  const next = this.playbackState.get(clip.name) ?? playbackState;
982
1570
  next.playbackRate = Number.isFinite(r) ? Math.max(0, Math.abs(r)) : 1;
983
1571
  this.applyPlaybackState(action, next);
1572
+ monitor.direction = next.reverse ? -1 : 1;
1573
+ monitor.initialDirection = monitor.direction;
984
1574
  this.setPlaybackState(clip.name, next);
985
1575
  },
986
1576
  setLoop: (mode, repeatCount) => {
@@ -989,6 +1579,7 @@ var BakedAnimationController = class {
989
1579
  next.loop = mode !== "once";
990
1580
  next.repeatCount = repeatCount;
991
1581
  this.applyPlaybackState(action, next);
1582
+ monitor.loopMode = mode;
992
1583
  this.setPlaybackState(clip.name, next);
993
1584
  },
994
1585
  setTime: (t) => {
@@ -998,9 +1589,16 @@ var BakedAnimationController = class {
998
1589
  this.animationMixer?.update(0);
999
1590
  } catch {
1000
1591
  }
1592
+ this.syncClipMonitorTime(monitor, clamped, true);
1001
1593
  },
1002
1594
  getTime: () => action.time,
1003
1595
  getDuration: () => clip.duration,
1596
+ subscribe: (listener) => {
1597
+ monitor.listeners.add(listener);
1598
+ return () => {
1599
+ monitor.listeners.delete(listener);
1600
+ };
1601
+ },
1004
1602
  finished: finishedPromise
1005
1603
  };
1006
1604
  this.clipHandles.set(clip.name, handle);
@@ -1024,6 +1622,7 @@ var BakedAnimationController = class {
1024
1622
  if (!this.animationMixer || !this.host.getModel()) return;
1025
1623
  for (const [clipName, action] of Array.from(this.clipActions.entries())) {
1026
1624
  if (clipName === name || clipName.startsWith(`${name}_`)) {
1625
+ const actionId = this.getActionId(action);
1027
1626
  try {
1028
1627
  action.stop();
1029
1628
  const clip = action.getClip();
@@ -1038,6 +1637,7 @@ var BakedAnimationController = class {
1038
1637
  this.clipHandles.delete(clipName);
1039
1638
  this.animationFinishedCallbacks.delete(clipName);
1040
1639
  this.playbackState.delete(clipName);
1640
+ if (actionId) this.cleanupClipMonitor(actionId);
1041
1641
  }
1042
1642
  }
1043
1643
  }
@@ -1061,6 +1661,8 @@ var BakedAnimationController = class {
1061
1661
  console.log("[Loom3] updateClipParams start", debugSnapshot());
1062
1662
  const apply = (action) => {
1063
1663
  if (!action) return;
1664
+ const actionId = this.getActionId(action);
1665
+ const monitor = actionId ? this.clipMonitors.get(actionId) : void 0;
1064
1666
  const clipName = action.getClip().name;
1065
1667
  const next = this.playbackState.get(clipName) ?? this.normalizePlaybackOptions(void 0, { loop: false, source: this.clipSources.get(clipName) ?? "clip" });
1066
1668
  try {
@@ -1079,6 +1681,10 @@ var BakedAnimationController = class {
1079
1681
  }
1080
1682
  const signedRate = next.reverse ? -next.playbackRate : next.playbackRate;
1081
1683
  action.setEffectiveTimeScale(signedRate);
1684
+ if (monitor) {
1685
+ monitor.direction = next.reverse ? -1 : 1;
1686
+ monitor.initialDirection = monitor.direction;
1687
+ }
1082
1688
  updated = true;
1083
1689
  }
1084
1690
  if (typeof params.loop === "boolean" || params.loopMode || params.repeatCount !== void 0) {
@@ -1086,6 +1692,7 @@ var BakedAnimationController = class {
1086
1692
  next.loop = next.loopMode !== "once";
1087
1693
  next.repeatCount = params.repeatCount;
1088
1694
  this.applyPlaybackState(action, next);
1695
+ if (monitor) monitor.loopMode = next.loopMode;
1089
1696
  updated = true;
1090
1697
  }
1091
1698
  this.setPlaybackState(clipName, next);
@@ -1165,7 +1772,23 @@ var BakedAnimationController = class {
1165
1772
  if (this.animationMixer && !this.mixerFinishedListenerAttached) {
1166
1773
  this.animationMixer.addEventListener("finished", (event) => {
1167
1774
  const action = event.action;
1775
+ const actionId = this.getActionId(action);
1776
+ if (actionId) {
1777
+ const monitor = this.clipMonitors.get(actionId);
1778
+ if (monitor) {
1779
+ monitor.finishedPending = true;
1780
+ return;
1781
+ }
1782
+ }
1168
1783
  const clip = action.getClip();
1784
+ const bakedRuntime = this.bakedRuntimeClipToSource.get(clip.name);
1785
+ if (bakedRuntime) {
1786
+ const group = this.bakedActionGroups.get(bakedRuntime.sourceClipName);
1787
+ if (group && group.pendingFinishedChannels.delete(bakedRuntime.channel) && group.pendingFinishedChannels.size === 0) {
1788
+ group.resolveFinished();
1789
+ }
1790
+ return;
1791
+ }
1169
1792
  const callback = this.animationFinishedCallbacks.get(clip.name);
1170
1793
  if (callback) {
1171
1794
  callback();
@@ -1190,6 +1813,20 @@ var BakedAnimationController = class {
1190
1813
  finished: finishedPromise
1191
1814
  };
1192
1815
  }
1816
+ createBakedAnimationHandle(clipName, group) {
1817
+ return {
1818
+ actionId: group.actionId,
1819
+ stop: () => this.stopAnimation(clipName),
1820
+ pause: () => this.pauseAnimation(clipName),
1821
+ resume: () => this.resumeAnimation(clipName),
1822
+ setSpeed: (speed) => this.setAnimationSpeed(clipName, speed),
1823
+ setWeight: (weight) => this.setAnimationIntensity(clipName, weight),
1824
+ seekTo: (time) => this.seekAnimation(clipName, time),
1825
+ getState: () => this.getAnimationState(clipName),
1826
+ crossfadeTo: (targetClip, dur) => this.crossfadeTo(targetClip, dur),
1827
+ finished: group.finishedPromise
1828
+ };
1829
+ }
1193
1830
  };
1194
1831
 
1195
1832
  // src/presets/cc4.ts
@@ -3849,9 +4486,11 @@ function getPreset(presetType) {
3849
4486
  return CC4_PRESET;
3850
4487
  }
3851
4488
  }
4489
+ var resolvePreset = getPreset;
3852
4490
  function getPresetWithProfile(presetType, profile) {
3853
4491
  return extendPresetWithProfile(getPreset(presetType), profile);
3854
4492
  }
4493
+ var resolvePresetWithOverrides = getPresetWithProfile;
3855
4494
 
3856
4495
  // src/engines/three/Loom3.ts
3857
4496
  var deg2rad = (d) => d * Math.PI / 180;
@@ -6959,6 +7598,6 @@ async function analyzeModel(options) {
6959
7598
  };
6960
7599
  }
6961
7600
 
6962
- export { AU_INFO, AU_MAPPING_CONFIG, AU_MIX_DEFAULTS, AU_TO_MORPHS, AnimationThree, BETTA_FISH_PRESET, BLENDING_MODES, BONE_AU_TO_BINDINGS, CC4_BONE_NODES, CC4_BONE_PREFIX, CC4_EYE_MESH_NODES, CC4_MESHES, CC4_PRESET, CC4_SUFFIX_PATTERN, COMPOSITE_ROTATIONS, CONTINUUM_LABELS, CONTINUUM_PAIRS_MAP, DEFAULT_HAIR_PHYSICS_CONFIG, FISH_AU_MAPPING_CONFIG, HairPhysics, Loom3, Loom3 as Loom3Three, Loom3 as LoomLargeThree, MORPH_TO_MESH, VISEME_JAW_AMOUNTS, VISEME_KEYS, analyzeModel, applyCharacterProfileToPreset, collectMorphMeshes, detectFacingDirection, extendCharacterConfigWithPreset, extendPresetWithProfile, extractFromGLTF, extractModelData, extractProfileOverrides, findFaceCenter, fuzzyNameMatch, generateMappingCorrections, getModelForwardDirection, getPreset, getPresetWithProfile, hasLeftRightMorphs, isMixedAU, isPresetCompatible, mergeRegionsByName as mergeCharacterRegionsByName, resolveBoneName, resolveBoneNames, resolveFaceCenter, suggestBestPreset, validateMappingConfig, validateMappings };
7601
+ export { AU_INFO, AU_MAPPING_CONFIG, AU_MIX_DEFAULTS, AU_TO_MORPHS, AnimationThree, BETTA_FISH_PRESET, BLENDING_MODES, BONE_AU_TO_BINDINGS, CC4_BONE_NODES, CC4_BONE_PREFIX, CC4_EYE_MESH_NODES, CC4_MESHES, CC4_PRESET, CC4_SUFFIX_PATTERN, COMPOSITE_ROTATIONS, CONTINUUM_LABELS, CONTINUUM_PAIRS_MAP, DEFAULT_HAIR_PHYSICS_CONFIG, FISH_AU_MAPPING_CONFIG, HairPhysics, Loom3, Loom3 as Loom3Three, Loom3 as LoomLargeThree, MORPH_TO_MESH, VISEME_JAW_AMOUNTS, VISEME_KEYS, analyzeModel, applyCharacterProfileToPreset, collectMorphMeshes, detectFacingDirection, extendCharacterConfigWithPreset, extendPresetWithProfile, extractFromGLTF, extractModelData, extractProfileOverrides, findFaceCenter, fuzzyNameMatch, generateMappingCorrections, getModelForwardDirection, getPreset, getPresetWithProfile, hasLeftRightMorphs, isMixedAU, isPresetCompatible, mergeRegionsByName as mergeCharacterRegionsByName, resolveBoneName, resolveBoneNames, resolveFaceCenter, resolvePreset, resolvePresetWithOverrides, suggestBestPreset, validateMappingConfig, validateMappings };
6963
7602
  //# sourceMappingURL=index.js.map
6964
7603
  //# sourceMappingURL=index.js.map