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.cjs CHANGED
@@ -26,21 +26,29 @@ __export(core_exports, {
26
26
  TimingController: () => TimingController,
27
27
  formatExportForLLM: () => formatExportForLLM,
28
28
  generateExport: () => generateExport,
29
- getFrameAtTime: () => getFrameAtTime
29
+ getFrameAtTime: () => getFrameAtTime,
30
+ getSharedEngine: () => getSharedEngine,
31
+ resetSharedEngine: () => resetSharedEngine
30
32
  });
31
33
  module.exports = __toCommonJS(core_exports);
32
34
 
33
35
  // src/core/timing.ts
36
+ var MEDIA_RATE_MIN = 0.0625;
37
+ var MEDIA_RATE_MAX = 16;
38
+ var ZERO_SPEED_DIVISOR = 1e-4;
34
39
  var TimingController = class {
35
40
  constructor() {
36
41
  this.speed = 1;
37
42
  this.virtualBaseline = 0;
38
43
  this.intervalMap = /* @__PURE__ */ new Map();
39
44
  this.nextIntervalId = 1e6;
40
- this.mediaObserver = null;
41
- this.animObserver = null;
42
45
  this._origAnimate = null;
43
46
  this.installed = false;
47
+ this.animPollId = 0;
48
+ this.gsapInstance = null;
49
+ // WeakMap tracking for animations and media
50
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
51
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
44
52
  this._raf = requestAnimationFrame.bind(window);
45
53
  this._caf = cancelAnimationFrame.bind(window);
46
54
  this._setTimeout = setTimeout.bind(window);
@@ -50,19 +58,34 @@ var TimingController = class {
50
58
  this._perfNow = performance.now.bind(performance);
51
59
  this._dateNow = Date.now;
52
60
  this.realBaseline = this._perfNow();
61
+ this.dateRealBaseline = this._dateNow();
62
+ this.dateVirtualBaseline = this.dateRealBaseline;
53
63
  }
54
64
  getVirtualTime() {
55
65
  const realElapsed = this._perfNow() - this.realBaseline;
56
66
  return this.virtualBaseline + realElapsed * this.speed;
57
67
  }
68
+ getVirtualDateNow() {
69
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
70
+ return this.dateVirtualBaseline + realElapsed * this.speed;
71
+ }
58
72
  reanchor() {
59
73
  const virtualNow = this.getVirtualTime();
60
74
  this.realBaseline = this._perfNow();
61
75
  this.virtualBaseline = virtualNow;
76
+ const virtualDateNow = this.getVirtualDateNow();
77
+ this.dateRealBaseline = this._dateNow();
78
+ this.dateVirtualBaseline = virtualDateNow;
79
+ }
80
+ /** Effective speed divisor — avoids division by zero at speed=0. */
81
+ get speedDivisor() {
82
+ return this.speed || ZERO_SPEED_DIVISOR;
62
83
  }
63
84
  /** Install timing patches. Safe to call multiple times. */
64
85
  install() {
65
86
  if (this.installed) return;
87
+ if (window.__saccadeInstalled) return;
88
+ window.__saccadeInstalled = true;
66
89
  this.installed = true;
67
90
  const self = this;
68
91
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -71,21 +94,27 @@ var TimingController = class {
71
94
  Element.prototype.animate = function(...args) {
72
95
  const anim = origAnimate.apply(this, args);
73
96
  if (self.speed !== 1) {
74
- anim.playbackRate = self.speed || 1e-3;
97
+ const originalRate = anim.playbackRate;
98
+ const applied = originalRate * (self.speed || 1e-3);
99
+ anim.playbackRate = applied;
100
+ self.trackedAnims.set(anim, { original: originalRate, applied });
75
101
  }
76
102
  return anim;
77
103
  };
78
104
  performance.now = () => self.getVirtualTime();
79
- const dateBaseline = this._dateNow();
80
- Date.now = () => dateBaseline + self.getVirtualTime();
105
+ Date.now = () => self.getVirtualDateNow();
81
106
  window.requestAnimationFrame = (callback) => {
82
107
  return self._raf(() => {
108
+ if (self.speed === 0) {
109
+ window.requestAnimationFrame(callback);
110
+ return;
111
+ }
83
112
  callback(self.getVirtualTime());
84
113
  });
85
114
  };
86
115
  window.cancelAnimationFrame = this._caf;
87
116
  window.setTimeout = ((handler, delay, ...args) => {
88
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
117
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
89
118
  return self._setTimeout(handler, scaledDelay, ...args);
90
119
  });
91
120
  window.clearTimeout = this._clearTimeout;
@@ -93,7 +122,7 @@ var TimingController = class {
93
122
  const id = self.nextIntervalId++;
94
123
  const baseDelay = delay ?? 0;
95
124
  function tick() {
96
- const scaledDelay = baseDelay / (self.speed || 1);
125
+ const scaledDelay = baseDelay / self.speedDivisor;
97
126
  const realId = self._setTimeout(() => {
98
127
  if (typeof handler === "function") {
99
128
  ;
@@ -118,61 +147,99 @@ var TimingController = class {
118
147
  self._clearInterval(id);
119
148
  }
120
149
  });
121
- this.mediaObserver = new MutationObserver((mutations) => {
122
- for (const mutation of mutations) {
123
- for (const node of mutation.addedNodes) {
124
- if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
125
- node.playbackRate = self.speed;
126
- }
127
- }
128
- }
129
- });
130
- if (document.body) {
131
- this.mediaObserver.observe(document.body, { childList: true, subtree: true });
132
- }
150
+ this.startAnimationPoll();
133
151
  }
134
152
  /** Set playback speed. Requires install() first. */
135
153
  setSpeed(newSpeed) {
136
154
  if (!this.installed) this.install();
137
155
  this.reanchor();
138
156
  this.speed = newSpeed;
139
- document.querySelectorAll("video, audio").forEach((el) => {
140
- ;
141
- el.playbackRate = newSpeed;
142
- });
143
157
  this.patchAnimations();
158
+ this.patchMedia();
159
+ this.patchGSAP();
160
+ }
161
+ getSpeed() {
162
+ return this.speed;
144
163
  }
145
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
164
+ // ---------------------------------------------------------------------------
165
+ // Animation polling — per-frame via original rAF
166
+ // ---------------------------------------------------------------------------
167
+ startAnimationPoll() {
168
+ const poll = () => {
169
+ if (!this.installed) return;
170
+ this.patchAnimations();
171
+ this.patchMedia();
172
+ this.animPollId = this._raf(poll);
173
+ };
174
+ this.animPollId = this._raf(poll);
175
+ }
176
+ /** Patch playbackRate on all active animations via WAAPI. */
146
177
  patchAnimations() {
147
178
  try {
148
179
  const anims = document.getAnimations();
149
180
  for (const anim of anims) {
150
181
  const target = anim.effect?.target;
151
182
  if (target?.closest?.("[data-lapse-panel]")) continue;
152
- anim.playbackRate = this.speed || 1e-3;
183
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
184
+ const effectiveSpeed = this.speed || 1e-3;
185
+ let tracked = this.trackedAnims.get(anim);
186
+ if (!tracked) {
187
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
188
+ this.trackedAnims.set(anim, tracked);
189
+ } else if (anim.playbackRate !== tracked.applied) {
190
+ tracked.original = anim.playbackRate;
191
+ }
192
+ const desired = tracked.original * effectiveSpeed;
193
+ if (anim.playbackRate !== desired) {
194
+ anim.playbackRate = desired;
195
+ tracked.applied = desired;
196
+ }
153
197
  }
154
198
  } catch {
155
199
  }
156
- if (!this.animObserver) {
157
- this.animObserver = this._setInterval(() => {
158
- if (!this.installed) return;
159
- try {
160
- const anims = document.getAnimations();
161
- for (const anim of anims) {
162
- const target = anim.effect?.target;
163
- if (target?.closest?.("[data-lapse-panel]")) continue;
164
- if (anim.playbackRate !== this.speed) {
165
- anim.playbackRate = this.speed || 1e-3;
166
- }
167
- }
168
- } catch {
200
+ }
201
+ /** Patch playbackRate on all video/audio elements. */
202
+ patchMedia() {
203
+ try {
204
+ document.querySelectorAll("video, audio").forEach((node) => {
205
+ const el = node;
206
+ if (el.closest?.("[data-lapse-panel]")) return;
207
+ if (el.closest?.("[data-saccade-exclude]")) return;
208
+ let tracked = this.trackedMedia.get(el);
209
+ if (!tracked) {
210
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
211
+ this.trackedMedia.set(el, tracked);
212
+ } else if (el.playbackRate !== tracked.applied) {
213
+ tracked.original = el.playbackRate;
169
214
  }
170
- }, 100);
215
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
216
+ if (el.playbackRate !== desired) {
217
+ el.playbackRate = desired;
218
+ tracked.applied = desired;
219
+ }
220
+ });
221
+ } catch {
171
222
  }
172
223
  }
173
- getSpeed() {
174
- return this.speed;
224
+ /**
225
+ * Register a GSAP instance (for ES-module imports where window.gsap is
226
+ * undefined). Applies the current timeScale immediately if speed !== 1.
227
+ */
228
+ registerGSAP(gsap) {
229
+ this.gsapInstance = gsap;
230
+ if (this.speed !== 1) this.patchGSAP();
231
+ }
232
+ /** Sync GSAP's global timeline if present. */
233
+ patchGSAP() {
234
+ try {
235
+ const gsap = this.gsapInstance ?? window.gsap;
236
+ gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
237
+ } catch {
238
+ }
175
239
  }
240
+ // ---------------------------------------------------------------------------
241
+ // Cleanup
242
+ // ---------------------------------------------------------------------------
176
243
  /** Restore all patched APIs to originals. */
177
244
  destroy() {
178
245
  if (!this.installed) return;
@@ -192,19 +259,33 @@ var TimingController = class {
192
259
  Element.prototype.animate = this._origAnimate;
193
260
  this._origAnimate = null;
194
261
  }
195
- this.mediaObserver?.disconnect();
196
- this.mediaObserver = null;
197
- if (this.animObserver != null) {
198
- this._clearInterval(this.animObserver);
199
- this.animObserver = null;
262
+ if (this.animPollId) {
263
+ this._caf(this.animPollId);
264
+ this.animPollId = 0;
200
265
  }
201
266
  try {
202
267
  for (const anim of document.getAnimations()) {
203
- anim.playbackRate = 1;
268
+ const tracked = this.trackedAnims.get(anim);
269
+ anim.playbackRate = tracked?.original ?? 1;
204
270
  }
205
271
  } catch {
206
272
  }
273
+ try {
274
+ document.querySelectorAll("video, audio").forEach((node) => {
275
+ const el = node;
276
+ const tracked = this.trackedMedia.get(el);
277
+ el.playbackRate = tracked?.original ?? 1;
278
+ });
279
+ } catch {
280
+ }
281
+ try {
282
+ const gsap = this.gsapInstance ?? window.gsap;
283
+ gsap?.globalTimeline?.timeScale(1);
284
+ } catch {
285
+ }
286
+ this.gsapInstance = null;
207
287
  delete window.__LAPSE_ORIGINAL_RAF__;
288
+ delete window.__saccadeInstalled;
208
289
  this.installed = false;
209
290
  }
210
291
  };
@@ -274,6 +355,10 @@ var SNAPSHOT_ATTRS = [
274
355
  "data-hover",
275
356
  "data-at-boundary",
276
357
  "data-scrubbing",
358
+ "data-starting-style",
359
+ "data-ending-style",
360
+ "data-panel-open",
361
+ "data-hidden",
277
362
  "aria-checked",
278
363
  "aria-selected",
279
364
  "aria-expanded",
@@ -287,6 +372,7 @@ var SNAPSHOT_ATTRS = [
287
372
  "checked",
288
373
  "disabled",
289
374
  "hidden",
375
+ "inert",
290
376
  "value",
291
377
  "class",
292
378
  "style"
@@ -418,6 +504,8 @@ var _TimelineRecorder = class _TimelineRecorder {
418
504
  // ---- WAAPI interception --------------------------------------------------
419
505
  /** Animations captured via Element.prototype.animate monkey-patch. */
420
506
  this.interceptedAnimations = [];
507
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
508
+ this.seekableClones = /* @__PURE__ */ new Map();
421
509
  this.hiddenSince = null;
422
510
  this.onVisibilityChange = null;
423
511
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -468,6 +556,7 @@ var _TimelineRecorder = class _TimelineRecorder {
468
556
  this.portalIdCounter = 0;
469
557
  this.currentPortalIds.clear();
470
558
  this.capturedPortals.clear();
559
+ this.seekableClones.clear();
471
560
  this.prevInlineStyles.clear();
472
561
  this.jsAnimStartTimes.clear();
473
562
  this.jsAnimLastSeen.clear();
@@ -821,6 +910,13 @@ var _TimelineRecorder = class _TimelineRecorder {
821
910
  } catch (_) {
822
911
  }
823
912
  }
913
+ const cleanedKeyframes = keyframes2.map((kf) => {
914
+ const clean = {};
915
+ for (const [k, v] of Object.entries(kf)) {
916
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
917
+ }
918
+ return clean;
919
+ });
824
920
  this.animations.set(id, {
825
921
  id,
826
922
  name,
@@ -832,7 +928,9 @@ var _TimelineRecorder = class _TimelineRecorder {
832
928
  type,
833
929
  source,
834
930
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
835
- conflicts
931
+ conflicts,
932
+ rawKeyframes: cleanedKeyframes,
933
+ rawTiming: { ...timing, fill: "both" }
836
934
  });
837
935
  }
838
936
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1108,38 +1206,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1108
1206
  } catch (_) {
1109
1207
  }
1110
1208
  }, 0);
1111
- if (this.frames.length > 0) {
1112
- const frame0 = this.frames[0];
1113
- if (frame0.elementSnapshots) {
1114
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1115
- const el = this.elements.get(sel);
1116
- if (!el || !el.isConnected) continue;
1117
- if (snap.__styles) {
1118
- for (const [prop, value] of Object.entries(snap.__styles)) {
1119
- if (SAFE_PROPS_SET.has(prop)) {
1120
- el.style.setProperty(prop, value, "important");
1121
- }
1122
- }
1123
- }
1124
- if (snap.__attrs) {
1125
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1126
- if (attr === "checked") {
1127
- ;
1128
- el.checked = value === "true";
1129
- } else if (attr === "class" && value != null) {
1130
- el.className = value;
1131
- } else if (attr === "style") {
1132
- } else if (attr === "value" && value != null) {
1133
- ;
1134
- el.value = value;
1135
- } else if (value == null) {
1136
- el.removeAttribute(attr);
1137
- } else {
1138
- el.setAttribute(attr, value);
1139
- }
1140
- }
1141
- }
1142
- }
1209
+ this.seekableClones.clear();
1210
+ for (const [animId, animInfo] of this.animations) {
1211
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1212
+ if (animInfo.type === "JSAnimation") continue;
1213
+ const firstColon = animId.indexOf(":");
1214
+ const secondColon = animId.indexOf(":", firstColon + 1);
1215
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1216
+ const el = this.elements.get(elSelector);
1217
+ if (!el?.isConnected) continue;
1218
+ try {
1219
+ const clone = el.animate(animInfo.rawKeyframes, {
1220
+ ...animInfo.rawTiming,
1221
+ fill: "both"
1222
+ });
1223
+ clone.pause();
1224
+ clone.currentTime = 0;
1225
+ this.seekableClones.set(animId, {
1226
+ animation: clone,
1227
+ element: el,
1228
+ effect: clone.effect
1229
+ });
1230
+ } catch (_) {
1143
1231
  }
1144
1232
  }
1145
1233
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1162,6 +1250,8 @@ var TimelineRecorder = _TimelineRecorder;
1162
1250
  // src/core/scrubber.ts
1163
1251
  var TimelineScrubber = class {
1164
1252
  constructor(state) {
1253
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1254
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1165
1255
  /** Saved originals for restore on release */
1166
1256
  this._originalAnimate = null;
1167
1257
  this._originalRaf = null;
@@ -1171,36 +1261,24 @@ var TimelineScrubber = class {
1171
1261
  this.frames = state.frames;
1172
1262
  this.capturedPortals = state.capturedPortals;
1173
1263
  this.interceptedAnimations = state.interceptedAnimations;
1174
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1264
+ this.seekableClones = state.seekableClones;
1175
1265
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1176
1266
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1177
1267
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1178
1268
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1179
- }
1180
- // ---------------------------------------------------------------------------
1181
- // Selector helper mirrors the recorder's getSelector so we can look up
1182
- // elements by the same key the recorder used in frame.animations[].animationId
1183
- // ---------------------------------------------------------------------------
1184
- getSelector(el) {
1185
- if (!el || !el.tagName) return null;
1186
- const parts = [];
1187
- let current = el;
1188
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1189
- const tag = current.tagName.toLowerCase();
1190
- const parent = current.parentElement;
1191
- if (parent) {
1192
- const siblings = Array.from(parent.children);
1193
- const idx = siblings.indexOf(current) + 1;
1194
- parts.unshift(`${tag}:nth-child(${idx})`);
1195
- } else {
1196
- parts.unshift(tag);
1269
+ for (let i = 0; i < this.frames.length; i++) {
1270
+ for (const fa of this.frames[i].animations) {
1271
+ const range = this.animFrameRanges.get(fa.animationId);
1272
+ if (!range) {
1273
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1274
+ } else {
1275
+ range.last = i;
1276
+ }
1197
1277
  }
1198
- current = parent;
1199
1278
  }
1200
- return parts.join(" > ");
1201
1279
  }
1202
1280
  // ---------------------------------------------------------------------------
1203
- // seekTo — scrub the DOM to match a specific timestamp
1281
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1204
1282
  // ---------------------------------------------------------------------------
1205
1283
  seekTo(timeMs) {
1206
1284
  if (!this.frames.length) return;
@@ -1248,6 +1326,34 @@ var TimelineScrubber = class {
1248
1326
  }
1249
1327
  }
1250
1328
  }
1329
+ const activeAnimIds = /* @__PURE__ */ new Map();
1330
+ for (const fa of frame.animations || []) {
1331
+ activeAnimIds.set(fa.animationId, fa);
1332
+ }
1333
+ for (const [animId, clone] of this.seekableClones) {
1334
+ const frameAnim = activeAnimIds.get(animId);
1335
+ try {
1336
+ if (frameAnim) {
1337
+ if (!clone.animation.effect) {
1338
+ clone.animation.effect = clone.effect;
1339
+ }
1340
+ clone.animation.currentTime = frameAnim.currentTime;
1341
+ } else {
1342
+ const range = this.animFrameRanges.get(animId);
1343
+ if (!range || lo < range.first) {
1344
+ clone.animation.effect = null;
1345
+ } else {
1346
+ if (!clone.animation.effect) {
1347
+ clone.animation.effect = clone.effect;
1348
+ }
1349
+ const timing = clone.effect.getTiming();
1350
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1351
+ clone.animation.currentTime = endTime;
1352
+ }
1353
+ }
1354
+ } catch {
1355
+ }
1356
+ }
1251
1357
  for (const entry of this.interceptedAnimations) {
1252
1358
  try {
1253
1359
  const anim = entry.animation;
@@ -1262,34 +1368,13 @@ var TimelineScrubber = class {
1262
1368
  const el = this.elements.get(sel);
1263
1369
  if (!el || !el.isConnected) continue;
1264
1370
  if (el.closest?.("[data-lapse-panel]")) continue;
1265
- const hasAnimation = (frame.animations || []).some(
1266
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1267
- );
1268
1371
  const snapTyped = snap;
1269
- if (snapTyped.__styles) {
1270
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1271
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1272
- el.style.setProperty(prop, value, "important");
1273
- }
1274
- }
1275
- }
1276
1372
  if (snapTyped.__attrs) {
1277
1373
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1374
+ if (attr === "class" || attr === "style") continue;
1278
1375
  if (attr === "checked") {
1279
1376
  ;
1280
1377
  el.checked = value === "true";
1281
- } else if (attr === "class") {
1282
- if (value != null) el.className = value;
1283
- } else if (attr === "style") {
1284
- if (value) {
1285
- el.setAttribute("style", value);
1286
- el.style.transition = "none";
1287
- if (snapTyped.__styles) {
1288
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1289
- el.style.setProperty(prop, val, "important");
1290
- }
1291
- }
1292
- }
1293
1378
  } else if (attr === "value") {
1294
1379
  if (value != null) el.value = value;
1295
1380
  } else if (value == null) {
@@ -1300,39 +1385,31 @@ var TimelineScrubber = class {
1300
1385
  }
1301
1386
  }
1302
1387
  }
1303
- for (const anim of frame.animations || []) {
1304
- const firstColon = anim.animationId.indexOf(":");
1305
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1306
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1307
- const animEl = this.elements.get(animSel);
1308
- if (!animEl || !animEl.isConnected) continue;
1309
- for (const prop of anim.properties || []) {
1388
+ for (const fa of frame.animations || []) {
1389
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1390
+ const firstColon = fa.animationId.indexOf(":");
1391
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1392
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1393
+ const el = this.elements.get(elSel);
1394
+ if (!el || !el.isConnected) continue;
1395
+ for (const prop of fa.properties) {
1310
1396
  if (prop.value) {
1311
- animEl.style.setProperty(prop.property, prop.value, "important");
1397
+ el.style.setProperty(prop.property, prop.value, "important");
1312
1398
  }
1313
1399
  }
1314
1400
  }
1315
- const animatedSels = /* @__PURE__ */ new Set();
1316
- for (const anim of frame.animations || []) {
1317
- const fc = anim.animationId.indexOf(":");
1318
- const sc = anim.animationId.indexOf(":", fc + 1);
1319
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1320
- }
1321
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1322
- const el = rawEl;
1323
- const sel = this.getSelector(el);
1324
- if (sel && !animatedSels.has(sel)) {
1325
- el.style.removeProperty("opacity");
1326
- el.style.removeProperty("transform");
1327
- el.style.removeProperty("filter");
1328
- el.style.removeProperty("stroke-dashoffset");
1329
- }
1330
- });
1331
1401
  }
1332
1402
  // ---------------------------------------------------------------------------
1333
1403
  // release — tear down all scrub state and restore the page to normal
1334
1404
  // ---------------------------------------------------------------------------
1335
1405
  release() {
1406
+ for (const [, clone] of this.seekableClones) {
1407
+ try {
1408
+ clone.animation.cancel();
1409
+ } catch {
1410
+ }
1411
+ }
1412
+ this.seekableClones.clear();
1336
1413
  for (const entry of this.interceptedAnimations) {
1337
1414
  try {
1338
1415
  entry.animation.cancel();
@@ -1394,6 +1471,7 @@ var TimelineScrubber = class {
1394
1471
  this.elements.clear();
1395
1472
  this.frames.length = 0;
1396
1473
  this.capturedPortals.clear();
1474
+ this.animFrameRanges.clear();
1397
1475
  }
1398
1476
  };
1399
1477
 
@@ -1480,7 +1558,7 @@ function generateExport(animations, frames, timeMs, filter = "active") {
1480
1558
  animations: animExports
1481
1559
  };
1482
1560
  }
1483
- function formatExportForLLM(exp, detail = "standard") {
1561
+ function formatExportForLLM(exp, detail = "moderate") {
1484
1562
  const lines = [];
1485
1563
  const grouped = /* @__PURE__ */ new Map();
1486
1564
  for (const anim of exp.animations) {
@@ -1495,7 +1573,7 @@ function formatExportForLLM(exp, detail = "standard") {
1495
1573
  const [from, to] = prop.range.split(" \u2192 ");
1496
1574
  return !(from && to && from.trim() === to.trim());
1497
1575
  }
1498
- if (detail === "compact") {
1576
+ if (detail === "brief") {
1499
1577
  lines.push(`# Animation State at ${exp.timestamp}`);
1500
1578
  lines.push("");
1501
1579
  for (const [, group] of grouped) {
@@ -1520,7 +1598,7 @@ function formatExportForLLM(exp, detail = "standard") {
1520
1598
  }
1521
1599
  lines.push(`# Animation State at ${exp.timestamp}`);
1522
1600
  lines.push("");
1523
- if (detail === "forensic") {
1601
+ if (detail === "granular") {
1524
1602
  lines.push("**Environment:**");
1525
1603
  lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
1526
1604
  lines.push(`- URL: ${window.location.href}`);
@@ -1587,7 +1665,7 @@ function formatExportForLLM(exp, detail = "standard") {
1587
1665
  lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
1588
1666
  lines.push("");
1589
1667
  for (const line of cssPropLines) lines.push(line);
1590
- if (detail === "detailed" || detail === "forensic") {
1668
+ if (detail === "detailed" || detail === "granular") {
1591
1669
  const allVars = {};
1592
1670
  for (const anim of cssAnims) {
1593
1671
  if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
@@ -1642,6 +1720,22 @@ var SaccadeEngine = class {
1642
1720
  getSpeed() {
1643
1721
  return this.timing.getSpeed();
1644
1722
  }
1723
+ /**
1724
+ * Install the timing patches immediately, without changing speed.
1725
+ *
1726
+ * Call this as early as possible (before app code, GSAP, or Framer Motion
1727
+ * run) so they capture the patched time functions rather than the originals.
1728
+ * Idempotent and harmless to call more than once. `setSpeed` and
1729
+ * `startRecording` also install on demand, so this is only needed to win the
1730
+ * early-load race against libraries that cache `Date.now`/`performance.now`.
1731
+ */
1732
+ install() {
1733
+ this.timing.install();
1734
+ }
1735
+ /** Register a module-imported GSAP instance so saccade can slow it. */
1736
+ registerGSAP(gsap) {
1737
+ this.timing.registerGSAP(gsap);
1738
+ }
1645
1739
  // -- Timeline recording ---------------------------------------------------
1646
1740
  startRecording(boundingBox) {
1647
1741
  if (this._state !== "idle") return;
@@ -1666,7 +1760,7 @@ var SaccadeEngine = class {
1666
1760
  try {
1667
1761
  capture = this.recorder.stopRecording();
1668
1762
  } catch (e) {
1669
- console.error("[Saccade] stopRecording failed:", e);
1763
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1670
1764
  document.getElementById("__lapse-scrub-blocker")?.remove();
1671
1765
  document.getElementById("__lapse-no-transitions")?.remove();
1672
1766
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1687,7 +1781,8 @@ var SaccadeEngine = class {
1687
1781
  frames: capture.frames,
1688
1782
  capturedPortals: this.recorder.capturedPortalIds,
1689
1783
  interceptedAnimations: this.recorder.interceptedAnimations,
1690
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1784
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1785
+ seekableClones: this.recorder.seekableClones
1691
1786
  };
1692
1787
  this.scrubber = new TimelineScrubber(scrubberState);
1693
1788
  this._state = "scrubbing";
@@ -1715,7 +1810,7 @@ var SaccadeEngine = class {
1715
1810
  filter
1716
1811
  );
1717
1812
  }
1718
- exportForLLM(timeMs, filter = "active", detail = "standard") {
1813
+ exportForLLM(timeMs, filter = "active", detail = "moderate") {
1719
1814
  const exp = this.generateExport(timeMs, filter);
1720
1815
  if (!exp) return "";
1721
1816
  return formatExportForLLM(exp, detail);
@@ -1739,6 +1834,19 @@ var SaccadeEngine = class {
1739
1834
  this._state = "idle";
1740
1835
  }
1741
1836
  };
1837
+
1838
+ // src/core/shared.ts
1839
+ var KEY = "__saccadeSharedEngine__";
1840
+ function getSharedEngine() {
1841
+ const g = globalThis;
1842
+ if (!g[KEY]) g[KEY] = new SaccadeEngine();
1843
+ return g[KEY];
1844
+ }
1845
+ function resetSharedEngine() {
1846
+ const g = globalThis;
1847
+ g[KEY]?.destroy();
1848
+ g[KEY] = null;
1849
+ }
1742
1850
  // Annotate the CommonJS export names for ESM import in node:
1743
1851
  0 && (module.exports = {
1744
1852
  SaccadeEngine,
@@ -1747,6 +1855,8 @@ var SaccadeEngine = class {
1747
1855
  TimingController,
1748
1856
  formatExportForLLM,
1749
1857
  generateExport,
1750
- getFrameAtTime
1858
+ getFrameAtTime,
1859
+ getSharedEngine,
1860
+ resetSharedEngine
1751
1861
  });
1752
1862
  //# sourceMappingURL=core.cjs.map