saccade 0.0.3 → 0.2.0

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/core.mjs CHANGED
@@ -1,14 +1,20 @@
1
1
  // src/core/timing.ts
2
+ var MEDIA_RATE_MIN = 0.0625;
3
+ var MEDIA_RATE_MAX = 16;
4
+ var ZERO_SPEED_DIVISOR = 1e-4;
2
5
  var TimingController = class {
3
6
  constructor() {
4
7
  this.speed = 1;
5
8
  this.virtualBaseline = 0;
6
9
  this.intervalMap = /* @__PURE__ */ new Map();
7
10
  this.nextIntervalId = 1e6;
8
- this.mediaObserver = null;
9
- this.animObserver = null;
10
11
  this._origAnimate = null;
11
12
  this.installed = false;
13
+ this.animPollId = 0;
14
+ this.gsapInstance = null;
15
+ // WeakMap tracking for animations and media
16
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
17
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
12
18
  this._raf = requestAnimationFrame.bind(window);
13
19
  this._caf = cancelAnimationFrame.bind(window);
14
20
  this._setTimeout = setTimeout.bind(window);
@@ -18,19 +24,34 @@ var TimingController = class {
18
24
  this._perfNow = performance.now.bind(performance);
19
25
  this._dateNow = Date.now;
20
26
  this.realBaseline = this._perfNow();
27
+ this.dateRealBaseline = this._dateNow();
28
+ this.dateVirtualBaseline = this.dateRealBaseline;
21
29
  }
22
30
  getVirtualTime() {
23
31
  const realElapsed = this._perfNow() - this.realBaseline;
24
32
  return this.virtualBaseline + realElapsed * this.speed;
25
33
  }
34
+ getVirtualDateNow() {
35
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
36
+ return this.dateVirtualBaseline + realElapsed * this.speed;
37
+ }
26
38
  reanchor() {
27
39
  const virtualNow = this.getVirtualTime();
28
40
  this.realBaseline = this._perfNow();
29
41
  this.virtualBaseline = virtualNow;
42
+ const virtualDateNow = this.getVirtualDateNow();
43
+ this.dateRealBaseline = this._dateNow();
44
+ this.dateVirtualBaseline = virtualDateNow;
45
+ }
46
+ /** Effective speed divisor — avoids division by zero at speed=0. */
47
+ get speedDivisor() {
48
+ return this.speed || ZERO_SPEED_DIVISOR;
30
49
  }
31
50
  /** Install timing patches. Safe to call multiple times. */
32
51
  install() {
33
52
  if (this.installed) return;
53
+ if (window.__saccadeInstalled) return;
54
+ window.__saccadeInstalled = true;
34
55
  this.installed = true;
35
56
  const self = this;
36
57
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -39,21 +60,27 @@ var TimingController = class {
39
60
  Element.prototype.animate = function(...args) {
40
61
  const anim = origAnimate.apply(this, args);
41
62
  if (self.speed !== 1) {
42
- anim.playbackRate = self.speed || 1e-3;
63
+ const originalRate = anim.playbackRate;
64
+ const applied = originalRate * (self.speed || 1e-3);
65
+ anim.playbackRate = applied;
66
+ self.trackedAnims.set(anim, { original: originalRate, applied });
43
67
  }
44
68
  return anim;
45
69
  };
46
70
  performance.now = () => self.getVirtualTime();
47
- const dateBaseline = this._dateNow();
48
- Date.now = () => dateBaseline + self.getVirtualTime();
71
+ Date.now = () => self.getVirtualDateNow();
49
72
  window.requestAnimationFrame = (callback) => {
50
73
  return self._raf(() => {
74
+ if (self.speed === 0) {
75
+ window.requestAnimationFrame(callback);
76
+ return;
77
+ }
51
78
  callback(self.getVirtualTime());
52
79
  });
53
80
  };
54
81
  window.cancelAnimationFrame = this._caf;
55
82
  window.setTimeout = ((handler, delay, ...args) => {
56
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
83
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
57
84
  return self._setTimeout(handler, scaledDelay, ...args);
58
85
  });
59
86
  window.clearTimeout = this._clearTimeout;
@@ -61,7 +88,7 @@ var TimingController = class {
61
88
  const id = self.nextIntervalId++;
62
89
  const baseDelay = delay ?? 0;
63
90
  function tick() {
64
- const scaledDelay = baseDelay / (self.speed || 1);
91
+ const scaledDelay = baseDelay / self.speedDivisor;
65
92
  const realId = self._setTimeout(() => {
66
93
  if (typeof handler === "function") {
67
94
  ;
@@ -86,61 +113,99 @@ var TimingController = class {
86
113
  self._clearInterval(id);
87
114
  }
88
115
  });
89
- this.mediaObserver = new MutationObserver((mutations) => {
90
- for (const mutation of mutations) {
91
- for (const node of mutation.addedNodes) {
92
- if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
93
- node.playbackRate = self.speed;
94
- }
95
- }
96
- }
97
- });
98
- if (document.body) {
99
- this.mediaObserver.observe(document.body, { childList: true, subtree: true });
100
- }
116
+ this.startAnimationPoll();
101
117
  }
102
118
  /** Set playback speed. Requires install() first. */
103
119
  setSpeed(newSpeed) {
104
120
  if (!this.installed) this.install();
105
121
  this.reanchor();
106
122
  this.speed = newSpeed;
107
- document.querySelectorAll("video, audio").forEach((el) => {
108
- ;
109
- el.playbackRate = newSpeed;
110
- });
111
123
  this.patchAnimations();
124
+ this.patchMedia();
125
+ this.patchGSAP();
126
+ }
127
+ getSpeed() {
128
+ return this.speed;
112
129
  }
113
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
130
+ // ---------------------------------------------------------------------------
131
+ // Animation polling — per-frame via original rAF
132
+ // ---------------------------------------------------------------------------
133
+ startAnimationPoll() {
134
+ const poll = () => {
135
+ if (!this.installed) return;
136
+ this.patchAnimations();
137
+ this.patchMedia();
138
+ this.animPollId = this._raf(poll);
139
+ };
140
+ this.animPollId = this._raf(poll);
141
+ }
142
+ /** Patch playbackRate on all active animations via WAAPI. */
114
143
  patchAnimations() {
115
144
  try {
116
145
  const anims = document.getAnimations();
117
146
  for (const anim of anims) {
118
147
  const target = anim.effect?.target;
119
148
  if (target?.closest?.("[data-lapse-panel]")) continue;
120
- anim.playbackRate = this.speed || 1e-3;
149
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
150
+ const effectiveSpeed = this.speed || 1e-3;
151
+ let tracked = this.trackedAnims.get(anim);
152
+ if (!tracked) {
153
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
154
+ this.trackedAnims.set(anim, tracked);
155
+ } else if (anim.playbackRate !== tracked.applied) {
156
+ tracked.original = anim.playbackRate;
157
+ }
158
+ const desired = tracked.original * effectiveSpeed;
159
+ if (anim.playbackRate !== desired) {
160
+ anim.playbackRate = desired;
161
+ tracked.applied = desired;
162
+ }
121
163
  }
122
164
  } catch {
123
165
  }
124
- if (!this.animObserver) {
125
- this.animObserver = this._setInterval(() => {
126
- if (!this.installed) return;
127
- try {
128
- const anims = document.getAnimations();
129
- for (const anim of anims) {
130
- const target = anim.effect?.target;
131
- if (target?.closest?.("[data-lapse-panel]")) continue;
132
- if (anim.playbackRate !== this.speed) {
133
- anim.playbackRate = this.speed || 1e-3;
134
- }
135
- }
136
- } catch {
166
+ }
167
+ /** Patch playbackRate on all video/audio elements. */
168
+ patchMedia() {
169
+ try {
170
+ document.querySelectorAll("video, audio").forEach((node) => {
171
+ const el = node;
172
+ if (el.closest?.("[data-lapse-panel]")) return;
173
+ if (el.closest?.("[data-saccade-exclude]")) return;
174
+ let tracked = this.trackedMedia.get(el);
175
+ if (!tracked) {
176
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
177
+ this.trackedMedia.set(el, tracked);
178
+ } else if (el.playbackRate !== tracked.applied) {
179
+ tracked.original = el.playbackRate;
137
180
  }
138
- }, 100);
181
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
182
+ if (el.playbackRate !== desired) {
183
+ el.playbackRate = desired;
184
+ tracked.applied = desired;
185
+ }
186
+ });
187
+ } catch {
139
188
  }
140
189
  }
141
- getSpeed() {
142
- return this.speed;
190
+ /**
191
+ * Register a GSAP instance (for ES-module imports where window.gsap is
192
+ * undefined). Applies the current timeScale immediately if speed !== 1.
193
+ */
194
+ registerGSAP(gsap) {
195
+ this.gsapInstance = gsap;
196
+ if (this.speed !== 1) this.patchGSAP();
197
+ }
198
+ /** Sync GSAP's global timeline if present. */
199
+ patchGSAP() {
200
+ try {
201
+ const gsap = this.gsapInstance ?? window.gsap;
202
+ gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
203
+ } catch {
204
+ }
143
205
  }
206
+ // ---------------------------------------------------------------------------
207
+ // Cleanup
208
+ // ---------------------------------------------------------------------------
144
209
  /** Restore all patched APIs to originals. */
145
210
  destroy() {
146
211
  if (!this.installed) return;
@@ -160,19 +225,33 @@ var TimingController = class {
160
225
  Element.prototype.animate = this._origAnimate;
161
226
  this._origAnimate = null;
162
227
  }
163
- this.mediaObserver?.disconnect();
164
- this.mediaObserver = null;
165
- if (this.animObserver != null) {
166
- this._clearInterval(this.animObserver);
167
- this.animObserver = null;
228
+ if (this.animPollId) {
229
+ this._caf(this.animPollId);
230
+ this.animPollId = 0;
168
231
  }
169
232
  try {
170
233
  for (const anim of document.getAnimations()) {
171
- anim.playbackRate = 1;
234
+ const tracked = this.trackedAnims.get(anim);
235
+ anim.playbackRate = tracked?.original ?? 1;
172
236
  }
173
237
  } catch {
174
238
  }
239
+ try {
240
+ document.querySelectorAll("video, audio").forEach((node) => {
241
+ const el = node;
242
+ const tracked = this.trackedMedia.get(el);
243
+ el.playbackRate = tracked?.original ?? 1;
244
+ });
245
+ } catch {
246
+ }
247
+ try {
248
+ const gsap = this.gsapInstance ?? window.gsap;
249
+ gsap?.globalTimeline?.timeScale(1);
250
+ } catch {
251
+ }
252
+ this.gsapInstance = null;
175
253
  delete window.__LAPSE_ORIGINAL_RAF__;
254
+ delete window.__saccadeInstalled;
176
255
  this.installed = false;
177
256
  }
178
257
  };
@@ -242,6 +321,10 @@ var SNAPSHOT_ATTRS = [
242
321
  "data-hover",
243
322
  "data-at-boundary",
244
323
  "data-scrubbing",
324
+ "data-starting-style",
325
+ "data-ending-style",
326
+ "data-panel-open",
327
+ "data-hidden",
245
328
  "aria-checked",
246
329
  "aria-selected",
247
330
  "aria-expanded",
@@ -255,6 +338,7 @@ var SNAPSHOT_ATTRS = [
255
338
  "checked",
256
339
  "disabled",
257
340
  "hidden",
341
+ "inert",
258
342
  "value",
259
343
  "class",
260
344
  "style"
@@ -386,6 +470,8 @@ var _TimelineRecorder = class _TimelineRecorder {
386
470
  // ---- WAAPI interception --------------------------------------------------
387
471
  /** Animations captured via Element.prototype.animate monkey-patch. */
388
472
  this.interceptedAnimations = [];
473
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
474
+ this.seekableClones = /* @__PURE__ */ new Map();
389
475
  this.hiddenSince = null;
390
476
  this.onVisibilityChange = null;
391
477
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -436,6 +522,7 @@ var _TimelineRecorder = class _TimelineRecorder {
436
522
  this.portalIdCounter = 0;
437
523
  this.currentPortalIds.clear();
438
524
  this.capturedPortals.clear();
525
+ this.seekableClones.clear();
439
526
  this.prevInlineStyles.clear();
440
527
  this.jsAnimStartTimes.clear();
441
528
  this.jsAnimLastSeen.clear();
@@ -789,6 +876,13 @@ var _TimelineRecorder = class _TimelineRecorder {
789
876
  } catch (_) {
790
877
  }
791
878
  }
879
+ const cleanedKeyframes = keyframes2.map((kf) => {
880
+ const clean = {};
881
+ for (const [k, v] of Object.entries(kf)) {
882
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
883
+ }
884
+ return clean;
885
+ });
792
886
  this.animations.set(id, {
793
887
  id,
794
888
  name,
@@ -800,7 +894,9 @@ var _TimelineRecorder = class _TimelineRecorder {
800
894
  type,
801
895
  source,
802
896
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
803
- conflicts
897
+ conflicts,
898
+ rawKeyframes: cleanedKeyframes,
899
+ rawTiming: { ...timing, fill: "both" }
804
900
  });
805
901
  }
806
902
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1076,38 +1172,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1076
1172
  } catch (_) {
1077
1173
  }
1078
1174
  }, 0);
1079
- if (this.frames.length > 0) {
1080
- const frame0 = this.frames[0];
1081
- if (frame0.elementSnapshots) {
1082
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1083
- const el = this.elements.get(sel);
1084
- if (!el || !el.isConnected) continue;
1085
- if (snap.__styles) {
1086
- for (const [prop, value] of Object.entries(snap.__styles)) {
1087
- if (SAFE_PROPS_SET.has(prop)) {
1088
- el.style.setProperty(prop, value, "important");
1089
- }
1090
- }
1091
- }
1092
- if (snap.__attrs) {
1093
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1094
- if (attr === "checked") {
1095
- ;
1096
- el.checked = value === "true";
1097
- } else if (attr === "class" && value != null) {
1098
- el.className = value;
1099
- } else if (attr === "style") {
1100
- } else if (attr === "value" && value != null) {
1101
- ;
1102
- el.value = value;
1103
- } else if (value == null) {
1104
- el.removeAttribute(attr);
1105
- } else {
1106
- el.setAttribute(attr, value);
1107
- }
1108
- }
1109
- }
1110
- }
1175
+ this.seekableClones.clear();
1176
+ for (const [animId, animInfo] of this.animations) {
1177
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1178
+ if (animInfo.type === "JSAnimation") continue;
1179
+ const firstColon = animId.indexOf(":");
1180
+ const secondColon = animId.indexOf(":", firstColon + 1);
1181
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1182
+ const el = this.elements.get(elSelector);
1183
+ if (!el?.isConnected) continue;
1184
+ try {
1185
+ const clone = el.animate(animInfo.rawKeyframes, {
1186
+ ...animInfo.rawTiming,
1187
+ fill: "both"
1188
+ });
1189
+ clone.pause();
1190
+ clone.currentTime = 0;
1191
+ this.seekableClones.set(animId, {
1192
+ animation: clone,
1193
+ element: el,
1194
+ effect: clone.effect
1195
+ });
1196
+ } catch (_) {
1111
1197
  }
1112
1198
  }
1113
1199
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1130,6 +1216,8 @@ var TimelineRecorder = _TimelineRecorder;
1130
1216
  // src/core/scrubber.ts
1131
1217
  var TimelineScrubber = class {
1132
1218
  constructor(state) {
1219
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1220
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1133
1221
  /** Saved originals for restore on release */
1134
1222
  this._originalAnimate = null;
1135
1223
  this._originalRaf = null;
@@ -1139,36 +1227,24 @@ var TimelineScrubber = class {
1139
1227
  this.frames = state.frames;
1140
1228
  this.capturedPortals = state.capturedPortals;
1141
1229
  this.interceptedAnimations = state.interceptedAnimations;
1142
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1230
+ this.seekableClones = state.seekableClones;
1143
1231
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1144
1232
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1145
1233
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1146
1234
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1147
- }
1148
- // ---------------------------------------------------------------------------
1149
- // Selector helper mirrors the recorder's getSelector so we can look up
1150
- // elements by the same key the recorder used in frame.animations[].animationId
1151
- // ---------------------------------------------------------------------------
1152
- getSelector(el) {
1153
- if (!el || !el.tagName) return null;
1154
- const parts = [];
1155
- let current = el;
1156
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1157
- const tag = current.tagName.toLowerCase();
1158
- const parent = current.parentElement;
1159
- if (parent) {
1160
- const siblings = Array.from(parent.children);
1161
- const idx = siblings.indexOf(current) + 1;
1162
- parts.unshift(`${tag}:nth-child(${idx})`);
1163
- } else {
1164
- parts.unshift(tag);
1235
+ for (let i = 0; i < this.frames.length; i++) {
1236
+ for (const fa of this.frames[i].animations) {
1237
+ const range = this.animFrameRanges.get(fa.animationId);
1238
+ if (!range) {
1239
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1240
+ } else {
1241
+ range.last = i;
1242
+ }
1165
1243
  }
1166
- current = parent;
1167
1244
  }
1168
- return parts.join(" > ");
1169
1245
  }
1170
1246
  // ---------------------------------------------------------------------------
1171
- // seekTo — scrub the DOM to match a specific timestamp
1247
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1172
1248
  // ---------------------------------------------------------------------------
1173
1249
  seekTo(timeMs) {
1174
1250
  if (!this.frames.length) return;
@@ -1216,6 +1292,34 @@ var TimelineScrubber = class {
1216
1292
  }
1217
1293
  }
1218
1294
  }
1295
+ const activeAnimIds = /* @__PURE__ */ new Map();
1296
+ for (const fa of frame.animations || []) {
1297
+ activeAnimIds.set(fa.animationId, fa);
1298
+ }
1299
+ for (const [animId, clone] of this.seekableClones) {
1300
+ const frameAnim = activeAnimIds.get(animId);
1301
+ try {
1302
+ if (frameAnim) {
1303
+ if (!clone.animation.effect) {
1304
+ clone.animation.effect = clone.effect;
1305
+ }
1306
+ clone.animation.currentTime = frameAnim.currentTime;
1307
+ } else {
1308
+ const range = this.animFrameRanges.get(animId);
1309
+ if (!range || lo < range.first) {
1310
+ clone.animation.effect = null;
1311
+ } else {
1312
+ if (!clone.animation.effect) {
1313
+ clone.animation.effect = clone.effect;
1314
+ }
1315
+ const timing = clone.effect.getTiming();
1316
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1317
+ clone.animation.currentTime = endTime;
1318
+ }
1319
+ }
1320
+ } catch {
1321
+ }
1322
+ }
1219
1323
  for (const entry of this.interceptedAnimations) {
1220
1324
  try {
1221
1325
  const anim = entry.animation;
@@ -1230,34 +1334,13 @@ var TimelineScrubber = class {
1230
1334
  const el = this.elements.get(sel);
1231
1335
  if (!el || !el.isConnected) continue;
1232
1336
  if (el.closest?.("[data-lapse-panel]")) continue;
1233
- const hasAnimation = (frame.animations || []).some(
1234
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1235
- );
1236
1337
  const snapTyped = snap;
1237
- if (snapTyped.__styles) {
1238
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1239
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1240
- el.style.setProperty(prop, value, "important");
1241
- }
1242
- }
1243
- }
1244
1338
  if (snapTyped.__attrs) {
1245
1339
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1340
+ if (attr === "class" || attr === "style") continue;
1246
1341
  if (attr === "checked") {
1247
1342
  ;
1248
1343
  el.checked = value === "true";
1249
- } else if (attr === "class") {
1250
- if (value != null) el.className = value;
1251
- } else if (attr === "style") {
1252
- if (value) {
1253
- el.setAttribute("style", value);
1254
- el.style.transition = "none";
1255
- if (snapTyped.__styles) {
1256
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1257
- el.style.setProperty(prop, val, "important");
1258
- }
1259
- }
1260
- }
1261
1344
  } else if (attr === "value") {
1262
1345
  if (value != null) el.value = value;
1263
1346
  } else if (value == null) {
@@ -1268,39 +1351,31 @@ var TimelineScrubber = class {
1268
1351
  }
1269
1352
  }
1270
1353
  }
1271
- for (const anim of frame.animations || []) {
1272
- const firstColon = anim.animationId.indexOf(":");
1273
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1274
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1275
- const animEl = this.elements.get(animSel);
1276
- if (!animEl || !animEl.isConnected) continue;
1277
- for (const prop of anim.properties || []) {
1354
+ for (const fa of frame.animations || []) {
1355
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1356
+ const firstColon = fa.animationId.indexOf(":");
1357
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1358
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1359
+ const el = this.elements.get(elSel);
1360
+ if (!el || !el.isConnected) continue;
1361
+ for (const prop of fa.properties) {
1278
1362
  if (prop.value) {
1279
- animEl.style.setProperty(prop.property, prop.value, "important");
1363
+ el.style.setProperty(prop.property, prop.value, "important");
1280
1364
  }
1281
1365
  }
1282
1366
  }
1283
- const animatedSels = /* @__PURE__ */ new Set();
1284
- for (const anim of frame.animations || []) {
1285
- const fc = anim.animationId.indexOf(":");
1286
- const sc = anim.animationId.indexOf(":", fc + 1);
1287
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1288
- }
1289
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1290
- const el = rawEl;
1291
- const sel = this.getSelector(el);
1292
- if (sel && !animatedSels.has(sel)) {
1293
- el.style.removeProperty("opacity");
1294
- el.style.removeProperty("transform");
1295
- el.style.removeProperty("filter");
1296
- el.style.removeProperty("stroke-dashoffset");
1297
- }
1298
- });
1299
1367
  }
1300
1368
  // ---------------------------------------------------------------------------
1301
1369
  // release — tear down all scrub state and restore the page to normal
1302
1370
  // ---------------------------------------------------------------------------
1303
1371
  release() {
1372
+ for (const [, clone] of this.seekableClones) {
1373
+ try {
1374
+ clone.animation.cancel();
1375
+ } catch {
1376
+ }
1377
+ }
1378
+ this.seekableClones.clear();
1304
1379
  for (const entry of this.interceptedAnimations) {
1305
1380
  try {
1306
1381
  entry.animation.cancel();
@@ -1362,6 +1437,7 @@ var TimelineScrubber = class {
1362
1437
  this.elements.clear();
1363
1438
  this.frames.length = 0;
1364
1439
  this.capturedPortals.clear();
1440
+ this.animFrameRanges.clear();
1365
1441
  }
1366
1442
  };
1367
1443
 
@@ -1448,7 +1524,7 @@ function generateExport(animations, frames, timeMs, filter = "active") {
1448
1524
  animations: animExports
1449
1525
  };
1450
1526
  }
1451
- function formatExportForLLM(exp, detail = "standard") {
1527
+ function formatExportForLLM(exp, detail = "moderate") {
1452
1528
  const lines = [];
1453
1529
  const grouped = /* @__PURE__ */ new Map();
1454
1530
  for (const anim of exp.animations) {
@@ -1463,7 +1539,7 @@ function formatExportForLLM(exp, detail = "standard") {
1463
1539
  const [from, to] = prop.range.split(" \u2192 ");
1464
1540
  return !(from && to && from.trim() === to.trim());
1465
1541
  }
1466
- if (detail === "compact") {
1542
+ if (detail === "brief") {
1467
1543
  lines.push(`# Animation State at ${exp.timestamp}`);
1468
1544
  lines.push("");
1469
1545
  for (const [, group] of grouped) {
@@ -1488,7 +1564,7 @@ function formatExportForLLM(exp, detail = "standard") {
1488
1564
  }
1489
1565
  lines.push(`# Animation State at ${exp.timestamp}`);
1490
1566
  lines.push("");
1491
- if (detail === "forensic") {
1567
+ if (detail === "granular") {
1492
1568
  lines.push("**Environment:**");
1493
1569
  lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
1494
1570
  lines.push(`- URL: ${window.location.href}`);
@@ -1555,7 +1631,7 @@ function formatExportForLLM(exp, detail = "standard") {
1555
1631
  lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
1556
1632
  lines.push("");
1557
1633
  for (const line of cssPropLines) lines.push(line);
1558
- if (detail === "detailed" || detail === "forensic") {
1634
+ if (detail === "detailed" || detail === "granular") {
1559
1635
  const allVars = {};
1560
1636
  for (const anim of cssAnims) {
1561
1637
  if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
@@ -1610,6 +1686,22 @@ var SaccadeEngine = class {
1610
1686
  getSpeed() {
1611
1687
  return this.timing.getSpeed();
1612
1688
  }
1689
+ /**
1690
+ * Install the timing patches immediately, without changing speed.
1691
+ *
1692
+ * Call this as early as possible (before app code, GSAP, or Framer Motion
1693
+ * run) so they capture the patched time functions rather than the originals.
1694
+ * Idempotent and harmless to call more than once. `setSpeed` and
1695
+ * `startRecording` also install on demand, so this is only needed to win the
1696
+ * early-load race against libraries that cache `Date.now`/`performance.now`.
1697
+ */
1698
+ install() {
1699
+ this.timing.install();
1700
+ }
1701
+ /** Register a module-imported GSAP instance so saccade can slow it. */
1702
+ registerGSAP(gsap) {
1703
+ this.timing.registerGSAP(gsap);
1704
+ }
1613
1705
  // -- Timeline recording ---------------------------------------------------
1614
1706
  startRecording(boundingBox) {
1615
1707
  if (this._state !== "idle") return;
@@ -1634,7 +1726,7 @@ var SaccadeEngine = class {
1634
1726
  try {
1635
1727
  capture = this.recorder.stopRecording();
1636
1728
  } catch (e) {
1637
- console.error("[Saccade] stopRecording failed:", e);
1729
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1638
1730
  document.getElementById("__lapse-scrub-blocker")?.remove();
1639
1731
  document.getElementById("__lapse-no-transitions")?.remove();
1640
1732
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1655,7 +1747,8 @@ var SaccadeEngine = class {
1655
1747
  frames: capture.frames,
1656
1748
  capturedPortals: this.recorder.capturedPortalIds,
1657
1749
  interceptedAnimations: this.recorder.interceptedAnimations,
1658
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1750
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1751
+ seekableClones: this.recorder.seekableClones
1659
1752
  };
1660
1753
  this.scrubber = new TimelineScrubber(scrubberState);
1661
1754
  this._state = "scrubbing";
@@ -1683,7 +1776,7 @@ var SaccadeEngine = class {
1683
1776
  filter
1684
1777
  );
1685
1778
  }
1686
- exportForLLM(timeMs, filter = "active", detail = "standard") {
1779
+ exportForLLM(timeMs, filter = "active", detail = "moderate") {
1687
1780
  const exp = this.generateExport(timeMs, filter);
1688
1781
  if (!exp) return "";
1689
1782
  return formatExportForLLM(exp, detail);
@@ -1707,6 +1800,19 @@ var SaccadeEngine = class {
1707
1800
  this._state = "idle";
1708
1801
  }
1709
1802
  };
1803
+
1804
+ // src/core/shared.ts
1805
+ var KEY = "__saccadeSharedEngine__";
1806
+ function getSharedEngine() {
1807
+ const g = globalThis;
1808
+ if (!g[KEY]) g[KEY] = new SaccadeEngine();
1809
+ return g[KEY];
1810
+ }
1811
+ function resetSharedEngine() {
1812
+ const g = globalThis;
1813
+ g[KEY]?.destroy();
1814
+ g[KEY] = null;
1815
+ }
1710
1816
  export {
1711
1817
  SaccadeEngine,
1712
1818
  TimelineRecorder,
@@ -1714,6 +1820,8 @@ export {
1714
1820
  TimingController,
1715
1821
  formatExportForLLM,
1716
1822
  generateExport,
1717
- getFrameAtTime
1823
+ getFrameAtTime,
1824
+ getSharedEngine,
1825
+ resetSharedEngine
1718
1826
  };
1719
1827
  //# sourceMappingURL=core.mjs.map