@lovelace_lol/loom3 1.0.36 → 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/README.md CHANGED
@@ -1846,6 +1846,7 @@ Loom3 can convert AU/morph curves into AnimationMixer clips for smooth, mixer-on
1846
1846
  Key APIs:
1847
1847
  - `snippetToClip(name, curves, options)` builds an AnimationClip from curves.
1848
1848
  - `playClip(clip, options)` returns a ClipHandle you can pause/resume/stop.
1849
+ - `clipHandle.subscribe(listener)` streams lifecycle events from the runtime update loop.
1849
1850
  - `clipHandle.stop()` now resolves cleanly (no rejected promise).
1850
1851
 
1851
1852
  ```typescript
@@ -1856,10 +1857,24 @@ const clip = loom.snippetToClip('gaze', {
1856
1857
 
1857
1858
  if (clip) {
1858
1859
  const handle = loom.playClip(clip, { loop: false, speed: 1 });
1860
+ const unsubscribe = handle?.subscribe?.((event) => {
1861
+ if (event.type === 'keyframe') {
1862
+ console.log(event.currentTime, event.keyframeIndex);
1863
+ }
1864
+ });
1865
+
1859
1866
  await handle.finished;
1867
+ unsubscribe?.();
1860
1868
  }
1861
1869
  ```
1862
1870
 
1871
+ Clip stream events are discrete runtime events, not a polling surface:
1872
+
1873
+ - `keyframe` fires when playback crosses an authored keyframe.
1874
+ - `loop` fires when looping playback starts another iteration.
1875
+ - `seek` fires when `setTime()` scrubs the clip.
1876
+ - `completed` fires when non-looping playback reaches its terminal state.
1877
+
1863
1878
  ### Playing a snippet directly
1864
1879
 
1865
1880
  If you already have a named snippet object, you can skip manual clip creation:
@@ -2138,7 +2153,7 @@ This is a compact reference for the public surface exported by `@lovelace_lol/lo
2138
2153
 
2139
2154
  ### Types and lower-level exports
2140
2155
 
2141
- - Configuration/types: `Profile`, `MeshInfo`, `BlendingMode`, `TransitionHandle`, `ClipHandle`, `Snippet`, `AnimationState`, `AnimationClipInfo`.
2156
+ - Configuration/types: `Profile`, `MeshInfo`, `BlendingMode`, `TransitionHandle`, `ClipEvent`, `ClipEventListener`, `ClipHandle`, `Snippet`, `AnimationState`, `AnimationClipInfo`.
2142
2157
  - Standalone implementations: `AnimationThree`, `HairPhysics`, `BLENDING_MODES`.
2143
2158
  - Region and geometry helpers: `resolveBoneName()`, `resolveBoneNames()`, `resolveFaceCenter()`, `findFaceCenter()`, `getModelForwardDirection()`, `detectFacingDirection()`.
2144
2159
 
package/dist/index.cjs CHANGED
@@ -291,6 +291,8 @@ var makeActionId = () => `act_${Math.random().toString(36).slice(2, 8)}_${Date.n
291
291
  var X_AXIS = new THREE.Vector3(1, 0, 0);
292
292
  var Y_AXIS = new THREE.Vector3(0, 1, 0);
293
293
  var Z_AXIS = new THREE.Vector3(0, 0, 1);
294
+ var CLIP_EVENT_METADATA_KEY = "__loom3ClipEvents";
295
+ var CLIP_EVENT_EPSILON = 1e-4;
294
296
  var BakedAnimationController = class {
295
297
  constructor(host) {
296
298
  __publicField(this, "host");
@@ -309,6 +311,7 @@ var BakedAnimationController = class {
309
311
  __publicField(this, "playbackState", /* @__PURE__ */ new Map());
310
312
  __publicField(this, "actionIds", /* @__PURE__ */ new WeakMap());
311
313
  __publicField(this, "actionIdToClip", /* @__PURE__ */ new Map());
314
+ __publicField(this, "clipMonitors", /* @__PURE__ */ new Map());
312
315
  this.host = host;
313
316
  }
314
317
  getActionId(action) {
@@ -322,6 +325,161 @@ var BakedAnimationController = class {
322
325
  action.__actionId = actionId;
323
326
  return actionId;
324
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
+ }
325
483
  normalizePlaybackOptions(options, defaults) {
326
484
  const clipOptions = options;
327
485
  const rawRate = options?.playbackRate ?? options?.speed ?? 1;
@@ -533,7 +691,28 @@ var BakedAnimationController = class {
533
691
  }
534
692
  update(dtSeconds) {
535
693
  if (this.animationMixer) {
694
+ const snapshots = Array.from(this.clipMonitors.values()).map((monitor) => ({
695
+ actionId: monitor.actionId,
696
+ previousTime: monitor.action.time
697
+ }));
536
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
+ }
537
716
  }
538
717
  }
539
718
  dispose() {
@@ -553,6 +732,7 @@ var BakedAnimationController = class {
553
732
  this.clipHandles.clear();
554
733
  this.clipSources.clear();
555
734
  this.playbackState.clear();
735
+ this.clipMonitors.clear();
556
736
  }
557
737
  loadAnimationClips(clips) {
558
738
  const model = this.host.getModel();
@@ -691,6 +871,7 @@ var BakedAnimationController = class {
691
871
  }
692
872
  const action = this.animationActions.get(clipName);
693
873
  if (action) {
874
+ const actionId = this.getActionId(action);
694
875
  const isBaked = (this.clipSources.get(clipName) ?? "baked") === "baked";
695
876
  action.stop();
696
877
  if (!isBaked && this.animationMixer) {
@@ -713,9 +894,11 @@ var BakedAnimationController = class {
713
894
  }
714
895
  }
715
896
  this.animationFinishedCallbacks.delete(clipName);
897
+ if (actionId) this.cleanupClipMonitor(actionId);
716
898
  }
717
899
  const clipAction = this.clipActions.get(clipName);
718
900
  if (clipAction && clipAction !== action) {
901
+ const actionId = this.getActionId(clipAction);
719
902
  try {
720
903
  clipAction.stop();
721
904
  if (this.animationMixer) {
@@ -728,6 +911,7 @@ var BakedAnimationController = class {
728
911
  } catch {
729
912
  }
730
913
  this.clipActions.delete(clipName);
914
+ if (actionId) this.cleanupClipMonitor(actionId);
731
915
  }
732
916
  if (this.clipActions.get(clipName) === action) {
733
917
  this.clipActions.delete(clipName);
@@ -1289,6 +1473,7 @@ var BakedAnimationController = class {
1289
1473
  return null;
1290
1474
  }
1291
1475
  const clip = new THREE.AnimationClip(clipName, maxTime, tracks);
1476
+ this.setClipEventMetadata(clip, { keyframeTimes });
1292
1477
  console.log(`[Loom3] snippetToClip: Created clip "${clipName}" with ${tracks.length} tracks, duration ${maxTime.toFixed(2)}s`);
1293
1478
  return clip;
1294
1479
  }
@@ -1320,28 +1505,38 @@ var BakedAnimationController = class {
1320
1505
  this.animationClips.push(clip);
1321
1506
  }
1322
1507
  this.applyPlaybackState(action, playbackState);
1508
+ if (actionId) {
1509
+ this.cleanupClipMonitor(actionId);
1510
+ }
1323
1511
  let resolveFinished;
1324
1512
  const finishedPromise = new Promise((resolve) => {
1325
1513
  resolveFinished = resolve;
1326
1514
  });
1327
- const cleanup = () => {
1328
- try {
1329
- this.animationFinishedCallbacks.delete(clip.name);
1330
- } catch {
1331
- }
1332
- try {
1333
- action.paused = true;
1334
- } catch {
1335
- }
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
1336
1534
  };
1337
- this.animationFinishedCallbacks.set(clip.name, () => {
1338
- resolveFinished();
1339
- cleanup();
1340
- });
1341
- finishedPromise.catch(() => cleanup());
1535
+ this.clipMonitors.set(actionId, monitor);
1342
1536
  action.reset();
1343
1537
  action.time = startTime;
1344
1538
  action.play();
1539
+ this.resetClipMonitor(monitor, action.time);
1345
1540
  this.clipActions.set(clip.name, action);
1346
1541
  this.animationActions.set(clip.name, action);
1347
1542
  this.setPlaybackState(clip.name, playbackState);
@@ -1359,6 +1554,7 @@ var BakedAnimationController = class {
1359
1554
  })
1360
1555
  );
1361
1556
  action.play();
1557
+ this.resetClipMonitor(monitor, action.time);
1362
1558
  },
1363
1559
  stop: () => {
1364
1560
  action.stop();
@@ -1376,8 +1572,7 @@ var BakedAnimationController = class {
1376
1572
  this.animationActions.delete(clip.name);
1377
1573
  this.animationFinishedCallbacks.delete(clip.name);
1378
1574
  this.playbackState.delete(clip.name);
1379
- resolveFinished();
1380
- cleanup();
1575
+ this.cleanupClipMonitor(actionId);
1381
1576
  },
1382
1577
  pause: () => {
1383
1578
  action.paused = true;
@@ -1395,6 +1590,8 @@ var BakedAnimationController = class {
1395
1590
  const next = this.playbackState.get(clip.name) ?? playbackState;
1396
1591
  next.playbackRate = Number.isFinite(r) ? Math.max(0, Math.abs(r)) : 1;
1397
1592
  this.applyPlaybackState(action, next);
1593
+ monitor.direction = next.reverse ? -1 : 1;
1594
+ monitor.initialDirection = monitor.direction;
1398
1595
  this.setPlaybackState(clip.name, next);
1399
1596
  },
1400
1597
  setLoop: (mode, repeatCount) => {
@@ -1403,6 +1600,7 @@ var BakedAnimationController = class {
1403
1600
  next.loop = mode !== "once";
1404
1601
  next.repeatCount = repeatCount;
1405
1602
  this.applyPlaybackState(action, next);
1603
+ monitor.loopMode = mode;
1406
1604
  this.setPlaybackState(clip.name, next);
1407
1605
  },
1408
1606
  setTime: (t) => {
@@ -1412,9 +1610,16 @@ var BakedAnimationController = class {
1412
1610
  this.animationMixer?.update(0);
1413
1611
  } catch {
1414
1612
  }
1613
+ this.syncClipMonitorTime(monitor, clamped, true);
1415
1614
  },
1416
1615
  getTime: () => action.time,
1417
1616
  getDuration: () => clip.duration,
1617
+ subscribe: (listener) => {
1618
+ monitor.listeners.add(listener);
1619
+ return () => {
1620
+ monitor.listeners.delete(listener);
1621
+ };
1622
+ },
1418
1623
  finished: finishedPromise
1419
1624
  };
1420
1625
  this.clipHandles.set(clip.name, handle);
@@ -1438,6 +1643,7 @@ var BakedAnimationController = class {
1438
1643
  if (!this.animationMixer || !this.host.getModel()) return;
1439
1644
  for (const [clipName, action] of Array.from(this.clipActions.entries())) {
1440
1645
  if (clipName === name || clipName.startsWith(`${name}_`)) {
1646
+ const actionId = this.getActionId(action);
1441
1647
  try {
1442
1648
  action.stop();
1443
1649
  const clip = action.getClip();
@@ -1452,6 +1658,7 @@ var BakedAnimationController = class {
1452
1658
  this.clipHandles.delete(clipName);
1453
1659
  this.animationFinishedCallbacks.delete(clipName);
1454
1660
  this.playbackState.delete(clipName);
1661
+ if (actionId) this.cleanupClipMonitor(actionId);
1455
1662
  }
1456
1663
  }
1457
1664
  }
@@ -1475,6 +1682,8 @@ var BakedAnimationController = class {
1475
1682
  console.log("[Loom3] updateClipParams start", debugSnapshot());
1476
1683
  const apply = (action) => {
1477
1684
  if (!action) return;
1685
+ const actionId = this.getActionId(action);
1686
+ const monitor = actionId ? this.clipMonitors.get(actionId) : void 0;
1478
1687
  const clipName = action.getClip().name;
1479
1688
  const next = this.playbackState.get(clipName) ?? this.normalizePlaybackOptions(void 0, { loop: false, source: this.clipSources.get(clipName) ?? "clip" });
1480
1689
  try {
@@ -1493,6 +1702,10 @@ var BakedAnimationController = class {
1493
1702
  }
1494
1703
  const signedRate = next.reverse ? -next.playbackRate : next.playbackRate;
1495
1704
  action.setEffectiveTimeScale(signedRate);
1705
+ if (monitor) {
1706
+ monitor.direction = next.reverse ? -1 : 1;
1707
+ monitor.initialDirection = monitor.direction;
1708
+ }
1496
1709
  updated = true;
1497
1710
  }
1498
1711
  if (typeof params.loop === "boolean" || params.loopMode || params.repeatCount !== void 0) {
@@ -1500,6 +1713,7 @@ var BakedAnimationController = class {
1500
1713
  next.loop = next.loopMode !== "once";
1501
1714
  next.repeatCount = params.repeatCount;
1502
1715
  this.applyPlaybackState(action, next);
1716
+ if (monitor) monitor.loopMode = next.loopMode;
1503
1717
  updated = true;
1504
1718
  }
1505
1719
  this.setPlaybackState(clipName, next);
@@ -1579,6 +1793,14 @@ var BakedAnimationController = class {
1579
1793
  if (this.animationMixer && !this.mixerFinishedListenerAttached) {
1580
1794
  this.animationMixer.addEventListener("finished", (event) => {
1581
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
+ }
1582
1804
  const clip = action.getClip();
1583
1805
  const bakedRuntime = this.bakedRuntimeClipToSource.get(clip.name);
1584
1806
  if (bakedRuntime) {
@@ -4285,9 +4507,11 @@ function getPreset(presetType) {
4285
4507
  return CC4_PRESET;
4286
4508
  }
4287
4509
  }
4510
+ var resolvePreset = getPreset;
4288
4511
  function getPresetWithProfile(presetType, profile) {
4289
4512
  return extendPresetWithProfile(getPreset(presetType), profile);
4290
4513
  }
4514
+ var resolvePresetWithOverrides = getPresetWithProfile;
4291
4515
 
4292
4516
  // src/engines/three/Loom3.ts
4293
4517
  var deg2rad = (d) => d * Math.PI / 180;
@@ -7443,6 +7667,8 @@ exports.mergeCharacterRegionsByName = mergeRegionsByName;
7443
7667
  exports.resolveBoneName = resolveBoneName;
7444
7668
  exports.resolveBoneNames = resolveBoneNames;
7445
7669
  exports.resolveFaceCenter = resolveFaceCenter;
7670
+ exports.resolvePreset = resolvePreset;
7671
+ exports.resolvePresetWithOverrides = resolvePresetWithOverrides;
7446
7672
  exports.suggestBestPreset = suggestBestPreset;
7447
7673
  exports.validateMappingConfig = validateMappingConfig;
7448
7674
  exports.validateMappings = validateMappings;