saccade 0.0.3 → 0.1.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
@@ -31,16 +31,21 @@ __export(core_exports, {
31
31
  module.exports = __toCommonJS(core_exports);
32
32
 
33
33
  // src/core/timing.ts
34
+ var MEDIA_RATE_MIN = 0.0625;
35
+ var MEDIA_RATE_MAX = 16;
36
+ var ZERO_SPEED_DIVISOR = 1e-4;
34
37
  var TimingController = class {
35
38
  constructor() {
36
39
  this.speed = 1;
37
40
  this.virtualBaseline = 0;
38
41
  this.intervalMap = /* @__PURE__ */ new Map();
39
42
  this.nextIntervalId = 1e6;
40
- this.mediaObserver = null;
41
- this.animObserver = null;
42
43
  this._origAnimate = null;
43
44
  this.installed = false;
45
+ this.animPollId = 0;
46
+ // WeakMap tracking for animations and media
47
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
48
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
44
49
  this._raf = requestAnimationFrame.bind(window);
45
50
  this._caf = cancelAnimationFrame.bind(window);
46
51
  this._setTimeout = setTimeout.bind(window);
@@ -50,19 +55,34 @@ var TimingController = class {
50
55
  this._perfNow = performance.now.bind(performance);
51
56
  this._dateNow = Date.now;
52
57
  this.realBaseline = this._perfNow();
58
+ this.dateRealBaseline = this._dateNow();
59
+ this.dateVirtualBaseline = this.dateRealBaseline;
53
60
  }
54
61
  getVirtualTime() {
55
62
  const realElapsed = this._perfNow() - this.realBaseline;
56
63
  return this.virtualBaseline + realElapsed * this.speed;
57
64
  }
65
+ getVirtualDateNow() {
66
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
67
+ return this.dateVirtualBaseline + realElapsed * this.speed;
68
+ }
58
69
  reanchor() {
59
70
  const virtualNow = this.getVirtualTime();
60
71
  this.realBaseline = this._perfNow();
61
72
  this.virtualBaseline = virtualNow;
73
+ const virtualDateNow = this.getVirtualDateNow();
74
+ this.dateRealBaseline = this._dateNow();
75
+ this.dateVirtualBaseline = virtualDateNow;
76
+ }
77
+ /** Effective speed divisor — avoids division by zero at speed=0. */
78
+ get speedDivisor() {
79
+ return this.speed || ZERO_SPEED_DIVISOR;
62
80
  }
63
81
  /** Install timing patches. Safe to call multiple times. */
64
82
  install() {
65
83
  if (this.installed) return;
84
+ if (window.__saccadeInstalled) return;
85
+ window.__saccadeInstalled = true;
66
86
  this.installed = true;
67
87
  const self = this;
68
88
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -71,21 +91,27 @@ var TimingController = class {
71
91
  Element.prototype.animate = function(...args) {
72
92
  const anim = origAnimate.apply(this, args);
73
93
  if (self.speed !== 1) {
74
- anim.playbackRate = self.speed || 1e-3;
94
+ const originalRate = anim.playbackRate;
95
+ const applied = originalRate * (self.speed || 1e-3);
96
+ anim.playbackRate = applied;
97
+ self.trackedAnims.set(anim, { original: originalRate, applied });
75
98
  }
76
99
  return anim;
77
100
  };
78
101
  performance.now = () => self.getVirtualTime();
79
- const dateBaseline = this._dateNow();
80
- Date.now = () => dateBaseline + self.getVirtualTime();
102
+ Date.now = () => self.getVirtualDateNow();
81
103
  window.requestAnimationFrame = (callback) => {
82
104
  return self._raf(() => {
105
+ if (self.speed === 0) {
106
+ window.requestAnimationFrame(callback);
107
+ return;
108
+ }
83
109
  callback(self.getVirtualTime());
84
110
  });
85
111
  };
86
112
  window.cancelAnimationFrame = this._caf;
87
113
  window.setTimeout = ((handler, delay, ...args) => {
88
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
114
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
89
115
  return self._setTimeout(handler, scaledDelay, ...args);
90
116
  });
91
117
  window.clearTimeout = this._clearTimeout;
@@ -93,7 +119,7 @@ var TimingController = class {
93
119
  const id = self.nextIntervalId++;
94
120
  const baseDelay = delay ?? 0;
95
121
  function tick() {
96
- const scaledDelay = baseDelay / (self.speed || 1);
122
+ const scaledDelay = baseDelay / self.speedDivisor;
97
123
  const realId = self._setTimeout(() => {
98
124
  if (typeof handler === "function") {
99
125
  ;
@@ -118,61 +144,93 @@ var TimingController = class {
118
144
  self._clearInterval(id);
119
145
  }
120
146
  });
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
- }
147
+ this.startAnimationPoll();
133
148
  }
134
149
  /** Set playback speed. Requires install() first. */
135
150
  setSpeed(newSpeed) {
136
151
  if (!this.installed) this.install();
137
152
  this.reanchor();
138
153
  this.speed = newSpeed;
139
- document.querySelectorAll("video, audio").forEach((el) => {
140
- ;
141
- el.playbackRate = newSpeed;
142
- });
143
154
  this.patchAnimations();
155
+ this.patchMedia();
156
+ this.patchGSAP();
144
157
  }
145
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
158
+ getSpeed() {
159
+ return this.speed;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Animation polling — per-frame via original rAF
163
+ // ---------------------------------------------------------------------------
164
+ startAnimationPoll() {
165
+ const poll = () => {
166
+ if (!this.installed) return;
167
+ this.patchAnimations();
168
+ this.patchMedia();
169
+ this.animPollId = this._raf(poll);
170
+ };
171
+ this.animPollId = this._raf(poll);
172
+ }
173
+ /** Patch playbackRate on all active animations via WAAPI. */
146
174
  patchAnimations() {
147
175
  try {
148
176
  const anims = document.getAnimations();
149
177
  for (const anim of anims) {
150
178
  const target = anim.effect?.target;
151
179
  if (target?.closest?.("[data-lapse-panel]")) continue;
152
- anim.playbackRate = this.speed || 1e-3;
180
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
181
+ const effectiveSpeed = this.speed || 1e-3;
182
+ let tracked = this.trackedAnims.get(anim);
183
+ if (!tracked) {
184
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
185
+ this.trackedAnims.set(anim, tracked);
186
+ } else if (anim.playbackRate !== tracked.applied) {
187
+ tracked.original = anim.playbackRate;
188
+ }
189
+ const desired = tracked.original * effectiveSpeed;
190
+ if (anim.playbackRate !== desired) {
191
+ anim.playbackRate = desired;
192
+ tracked.applied = desired;
193
+ }
153
194
  }
154
195
  } catch {
155
196
  }
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 {
197
+ }
198
+ /** Patch playbackRate on all video/audio elements. */
199
+ patchMedia() {
200
+ try {
201
+ document.querySelectorAll("video, audio").forEach((node) => {
202
+ const el = node;
203
+ if (el.closest?.("[data-lapse-panel]")) return;
204
+ if (el.closest?.("[data-saccade-exclude]")) return;
205
+ let tracked = this.trackedMedia.get(el);
206
+ if (!tracked) {
207
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
208
+ this.trackedMedia.set(el, tracked);
209
+ } else if (el.playbackRate !== tracked.applied) {
210
+ tracked.original = el.playbackRate;
211
+ }
212
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
213
+ if (el.playbackRate !== desired) {
214
+ el.playbackRate = desired;
215
+ tracked.applied = desired;
169
216
  }
170
- }, 100);
217
+ });
218
+ } catch {
171
219
  }
172
220
  }
173
- getSpeed() {
174
- return this.speed;
221
+ /** Sync GSAP's global timeline if present. */
222
+ patchGSAP() {
223
+ try {
224
+ const gsap = window.gsap;
225
+ if (gsap?.globalTimeline) {
226
+ gsap.globalTimeline.timeScale(this.speed || 1e-3);
227
+ }
228
+ } catch {
229
+ }
175
230
  }
231
+ // ---------------------------------------------------------------------------
232
+ // Cleanup
233
+ // ---------------------------------------------------------------------------
176
234
  /** Restore all patched APIs to originals. */
177
235
  destroy() {
178
236
  if (!this.installed) return;
@@ -192,19 +250,32 @@ var TimingController = class {
192
250
  Element.prototype.animate = this._origAnimate;
193
251
  this._origAnimate = null;
194
252
  }
195
- this.mediaObserver?.disconnect();
196
- this.mediaObserver = null;
197
- if (this.animObserver != null) {
198
- this._clearInterval(this.animObserver);
199
- this.animObserver = null;
253
+ if (this.animPollId) {
254
+ this._caf(this.animPollId);
255
+ this.animPollId = 0;
200
256
  }
201
257
  try {
202
258
  for (const anim of document.getAnimations()) {
203
- anim.playbackRate = 1;
259
+ const tracked = this.trackedAnims.get(anim);
260
+ anim.playbackRate = tracked?.original ?? 1;
204
261
  }
205
262
  } catch {
206
263
  }
264
+ try {
265
+ document.querySelectorAll("video, audio").forEach((node) => {
266
+ const el = node;
267
+ const tracked = this.trackedMedia.get(el);
268
+ el.playbackRate = tracked?.original ?? 1;
269
+ });
270
+ } catch {
271
+ }
272
+ try {
273
+ const gsap = window.gsap;
274
+ if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
275
+ } catch {
276
+ }
207
277
  delete window.__LAPSE_ORIGINAL_RAF__;
278
+ delete window.__saccadeInstalled;
208
279
  this.installed = false;
209
280
  }
210
281
  };
@@ -274,6 +345,10 @@ var SNAPSHOT_ATTRS = [
274
345
  "data-hover",
275
346
  "data-at-boundary",
276
347
  "data-scrubbing",
348
+ "data-starting-style",
349
+ "data-ending-style",
350
+ "data-panel-open",
351
+ "data-hidden",
277
352
  "aria-checked",
278
353
  "aria-selected",
279
354
  "aria-expanded",
@@ -287,6 +362,7 @@ var SNAPSHOT_ATTRS = [
287
362
  "checked",
288
363
  "disabled",
289
364
  "hidden",
365
+ "inert",
290
366
  "value",
291
367
  "class",
292
368
  "style"
@@ -418,6 +494,8 @@ var _TimelineRecorder = class _TimelineRecorder {
418
494
  // ---- WAAPI interception --------------------------------------------------
419
495
  /** Animations captured via Element.prototype.animate monkey-patch. */
420
496
  this.interceptedAnimations = [];
497
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
498
+ this.seekableClones = /* @__PURE__ */ new Map();
421
499
  this.hiddenSince = null;
422
500
  this.onVisibilityChange = null;
423
501
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -468,6 +546,7 @@ var _TimelineRecorder = class _TimelineRecorder {
468
546
  this.portalIdCounter = 0;
469
547
  this.currentPortalIds.clear();
470
548
  this.capturedPortals.clear();
549
+ this.seekableClones.clear();
471
550
  this.prevInlineStyles.clear();
472
551
  this.jsAnimStartTimes.clear();
473
552
  this.jsAnimLastSeen.clear();
@@ -821,6 +900,13 @@ var _TimelineRecorder = class _TimelineRecorder {
821
900
  } catch (_) {
822
901
  }
823
902
  }
903
+ const cleanedKeyframes = keyframes2.map((kf) => {
904
+ const clean = {};
905
+ for (const [k, v] of Object.entries(kf)) {
906
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
907
+ }
908
+ return clean;
909
+ });
824
910
  this.animations.set(id, {
825
911
  id,
826
912
  name,
@@ -832,7 +918,9 @@ var _TimelineRecorder = class _TimelineRecorder {
832
918
  type,
833
919
  source,
834
920
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
835
- conflicts
921
+ conflicts,
922
+ rawKeyframes: cleanedKeyframes,
923
+ rawTiming: { ...timing, fill: "both" }
836
924
  });
837
925
  }
838
926
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1108,38 +1196,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1108
1196
  } catch (_) {
1109
1197
  }
1110
1198
  }, 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
- }
1199
+ this.seekableClones.clear();
1200
+ for (const [animId, animInfo] of this.animations) {
1201
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1202
+ if (animInfo.type === "JSAnimation") continue;
1203
+ const firstColon = animId.indexOf(":");
1204
+ const secondColon = animId.indexOf(":", firstColon + 1);
1205
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1206
+ const el = this.elements.get(elSelector);
1207
+ if (!el?.isConnected) continue;
1208
+ try {
1209
+ const clone = el.animate(animInfo.rawKeyframes, {
1210
+ ...animInfo.rawTiming,
1211
+ fill: "both"
1212
+ });
1213
+ clone.pause();
1214
+ clone.currentTime = 0;
1215
+ this.seekableClones.set(animId, {
1216
+ animation: clone,
1217
+ element: el,
1218
+ effect: clone.effect
1219
+ });
1220
+ } catch (_) {
1143
1221
  }
1144
1222
  }
1145
1223
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1162,6 +1240,8 @@ var TimelineRecorder = _TimelineRecorder;
1162
1240
  // src/core/scrubber.ts
1163
1241
  var TimelineScrubber = class {
1164
1242
  constructor(state) {
1243
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1244
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1165
1245
  /** Saved originals for restore on release */
1166
1246
  this._originalAnimate = null;
1167
1247
  this._originalRaf = null;
@@ -1171,36 +1251,24 @@ var TimelineScrubber = class {
1171
1251
  this.frames = state.frames;
1172
1252
  this.capturedPortals = state.capturedPortals;
1173
1253
  this.interceptedAnimations = state.interceptedAnimations;
1174
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1254
+ this.seekableClones = state.seekableClones;
1175
1255
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1176
1256
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1177
1257
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1178
1258
  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);
1259
+ for (let i = 0; i < this.frames.length; i++) {
1260
+ for (const fa of this.frames[i].animations) {
1261
+ const range = this.animFrameRanges.get(fa.animationId);
1262
+ if (!range) {
1263
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1264
+ } else {
1265
+ range.last = i;
1266
+ }
1197
1267
  }
1198
- current = parent;
1199
1268
  }
1200
- return parts.join(" > ");
1201
1269
  }
1202
1270
  // ---------------------------------------------------------------------------
1203
- // seekTo — scrub the DOM to match a specific timestamp
1271
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1204
1272
  // ---------------------------------------------------------------------------
1205
1273
  seekTo(timeMs) {
1206
1274
  if (!this.frames.length) return;
@@ -1248,6 +1316,34 @@ var TimelineScrubber = class {
1248
1316
  }
1249
1317
  }
1250
1318
  }
1319
+ const activeAnimIds = /* @__PURE__ */ new Map();
1320
+ for (const fa of frame.animations || []) {
1321
+ activeAnimIds.set(fa.animationId, fa);
1322
+ }
1323
+ for (const [animId, clone] of this.seekableClones) {
1324
+ const frameAnim = activeAnimIds.get(animId);
1325
+ try {
1326
+ if (frameAnim) {
1327
+ if (!clone.animation.effect) {
1328
+ clone.animation.effect = clone.effect;
1329
+ }
1330
+ clone.animation.currentTime = frameAnim.currentTime;
1331
+ } else {
1332
+ const range = this.animFrameRanges.get(animId);
1333
+ if (!range || lo < range.first) {
1334
+ clone.animation.effect = null;
1335
+ } else {
1336
+ if (!clone.animation.effect) {
1337
+ clone.animation.effect = clone.effect;
1338
+ }
1339
+ const timing = clone.effect.getTiming();
1340
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1341
+ clone.animation.currentTime = endTime;
1342
+ }
1343
+ }
1344
+ } catch {
1345
+ }
1346
+ }
1251
1347
  for (const entry of this.interceptedAnimations) {
1252
1348
  try {
1253
1349
  const anim = entry.animation;
@@ -1262,34 +1358,13 @@ var TimelineScrubber = class {
1262
1358
  const el = this.elements.get(sel);
1263
1359
  if (!el || !el.isConnected) continue;
1264
1360
  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
1361
  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
1362
  if (snapTyped.__attrs) {
1277
1363
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1364
+ if (attr === "class" || attr === "style") continue;
1278
1365
  if (attr === "checked") {
1279
1366
  ;
1280
1367
  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
1368
  } else if (attr === "value") {
1294
1369
  if (value != null) el.value = value;
1295
1370
  } else if (value == null) {
@@ -1300,39 +1375,31 @@ var TimelineScrubber = class {
1300
1375
  }
1301
1376
  }
1302
1377
  }
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 || []) {
1378
+ for (const fa of frame.animations || []) {
1379
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1380
+ const firstColon = fa.animationId.indexOf(":");
1381
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1382
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1383
+ const el = this.elements.get(elSel);
1384
+ if (!el || !el.isConnected) continue;
1385
+ for (const prop of fa.properties) {
1310
1386
  if (prop.value) {
1311
- animEl.style.setProperty(prop.property, prop.value, "important");
1387
+ el.style.setProperty(prop.property, prop.value, "important");
1312
1388
  }
1313
1389
  }
1314
1390
  }
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
1391
  }
1332
1392
  // ---------------------------------------------------------------------------
1333
1393
  // release — tear down all scrub state and restore the page to normal
1334
1394
  // ---------------------------------------------------------------------------
1335
1395
  release() {
1396
+ for (const [, clone] of this.seekableClones) {
1397
+ try {
1398
+ clone.animation.cancel();
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ this.seekableClones.clear();
1336
1403
  for (const entry of this.interceptedAnimations) {
1337
1404
  try {
1338
1405
  entry.animation.cancel();
@@ -1394,6 +1461,7 @@ var TimelineScrubber = class {
1394
1461
  this.elements.clear();
1395
1462
  this.frames.length = 0;
1396
1463
  this.capturedPortals.clear();
1464
+ this.animFrameRanges.clear();
1397
1465
  }
1398
1466
  };
1399
1467
 
@@ -1666,7 +1734,7 @@ var SaccadeEngine = class {
1666
1734
  try {
1667
1735
  capture = this.recorder.stopRecording();
1668
1736
  } catch (e) {
1669
- console.error("[Saccade] stopRecording failed:", e);
1737
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1670
1738
  document.getElementById("__lapse-scrub-blocker")?.remove();
1671
1739
  document.getElementById("__lapse-no-transitions")?.remove();
1672
1740
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1687,7 +1755,8 @@ var SaccadeEngine = class {
1687
1755
  frames: capture.frames,
1688
1756
  capturedPortals: this.recorder.capturedPortalIds,
1689
1757
  interceptedAnimations: this.recorder.interceptedAnimations,
1690
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1758
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1759
+ seekableClones: this.recorder.seekableClones
1691
1760
  };
1692
1761
  this.scrubber = new TimelineScrubber(scrubberState);
1693
1762
  this._state = "scrubbing";