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.mjs CHANGED
@@ -1,14 +1,19 @@
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
+ // WeakMap tracking for animations and media
15
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
16
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
12
17
  this._raf = requestAnimationFrame.bind(window);
13
18
  this._caf = cancelAnimationFrame.bind(window);
14
19
  this._setTimeout = setTimeout.bind(window);
@@ -18,19 +23,34 @@ var TimingController = class {
18
23
  this._perfNow = performance.now.bind(performance);
19
24
  this._dateNow = Date.now;
20
25
  this.realBaseline = this._perfNow();
26
+ this.dateRealBaseline = this._dateNow();
27
+ this.dateVirtualBaseline = this.dateRealBaseline;
21
28
  }
22
29
  getVirtualTime() {
23
30
  const realElapsed = this._perfNow() - this.realBaseline;
24
31
  return this.virtualBaseline + realElapsed * this.speed;
25
32
  }
33
+ getVirtualDateNow() {
34
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
35
+ return this.dateVirtualBaseline + realElapsed * this.speed;
36
+ }
26
37
  reanchor() {
27
38
  const virtualNow = this.getVirtualTime();
28
39
  this.realBaseline = this._perfNow();
29
40
  this.virtualBaseline = virtualNow;
41
+ const virtualDateNow = this.getVirtualDateNow();
42
+ this.dateRealBaseline = this._dateNow();
43
+ this.dateVirtualBaseline = virtualDateNow;
44
+ }
45
+ /** Effective speed divisor — avoids division by zero at speed=0. */
46
+ get speedDivisor() {
47
+ return this.speed || ZERO_SPEED_DIVISOR;
30
48
  }
31
49
  /** Install timing patches. Safe to call multiple times. */
32
50
  install() {
33
51
  if (this.installed) return;
52
+ if (window.__saccadeInstalled) return;
53
+ window.__saccadeInstalled = true;
34
54
  this.installed = true;
35
55
  const self = this;
36
56
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -39,21 +59,27 @@ var TimingController = class {
39
59
  Element.prototype.animate = function(...args) {
40
60
  const anim = origAnimate.apply(this, args);
41
61
  if (self.speed !== 1) {
42
- anim.playbackRate = self.speed || 1e-3;
62
+ const originalRate = anim.playbackRate;
63
+ const applied = originalRate * (self.speed || 1e-3);
64
+ anim.playbackRate = applied;
65
+ self.trackedAnims.set(anim, { original: originalRate, applied });
43
66
  }
44
67
  return anim;
45
68
  };
46
69
  performance.now = () => self.getVirtualTime();
47
- const dateBaseline = this._dateNow();
48
- Date.now = () => dateBaseline + self.getVirtualTime();
70
+ Date.now = () => self.getVirtualDateNow();
49
71
  window.requestAnimationFrame = (callback) => {
50
72
  return self._raf(() => {
73
+ if (self.speed === 0) {
74
+ window.requestAnimationFrame(callback);
75
+ return;
76
+ }
51
77
  callback(self.getVirtualTime());
52
78
  });
53
79
  };
54
80
  window.cancelAnimationFrame = this._caf;
55
81
  window.setTimeout = ((handler, delay, ...args) => {
56
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
82
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
57
83
  return self._setTimeout(handler, scaledDelay, ...args);
58
84
  });
59
85
  window.clearTimeout = this._clearTimeout;
@@ -61,7 +87,7 @@ var TimingController = class {
61
87
  const id = self.nextIntervalId++;
62
88
  const baseDelay = delay ?? 0;
63
89
  function tick() {
64
- const scaledDelay = baseDelay / (self.speed || 1);
90
+ const scaledDelay = baseDelay / self.speedDivisor;
65
91
  const realId = self._setTimeout(() => {
66
92
  if (typeof handler === "function") {
67
93
  ;
@@ -86,61 +112,93 @@ var TimingController = class {
86
112
  self._clearInterval(id);
87
113
  }
88
114
  });
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
- }
115
+ this.startAnimationPoll();
101
116
  }
102
117
  /** Set playback speed. Requires install() first. */
103
118
  setSpeed(newSpeed) {
104
119
  if (!this.installed) this.install();
105
120
  this.reanchor();
106
121
  this.speed = newSpeed;
107
- document.querySelectorAll("video, audio").forEach((el) => {
108
- ;
109
- el.playbackRate = newSpeed;
110
- });
111
122
  this.patchAnimations();
123
+ this.patchMedia();
124
+ this.patchGSAP();
112
125
  }
113
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
126
+ getSpeed() {
127
+ return this.speed;
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Animation polling — per-frame via original rAF
131
+ // ---------------------------------------------------------------------------
132
+ startAnimationPoll() {
133
+ const poll = () => {
134
+ if (!this.installed) return;
135
+ this.patchAnimations();
136
+ this.patchMedia();
137
+ this.animPollId = this._raf(poll);
138
+ };
139
+ this.animPollId = this._raf(poll);
140
+ }
141
+ /** Patch playbackRate on all active animations via WAAPI. */
114
142
  patchAnimations() {
115
143
  try {
116
144
  const anims = document.getAnimations();
117
145
  for (const anim of anims) {
118
146
  const target = anim.effect?.target;
119
147
  if (target?.closest?.("[data-lapse-panel]")) continue;
120
- anim.playbackRate = this.speed || 1e-3;
148
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
149
+ const effectiveSpeed = this.speed || 1e-3;
150
+ let tracked = this.trackedAnims.get(anim);
151
+ if (!tracked) {
152
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
153
+ this.trackedAnims.set(anim, tracked);
154
+ } else if (anim.playbackRate !== tracked.applied) {
155
+ tracked.original = anim.playbackRate;
156
+ }
157
+ const desired = tracked.original * effectiveSpeed;
158
+ if (anim.playbackRate !== desired) {
159
+ anim.playbackRate = desired;
160
+ tracked.applied = desired;
161
+ }
121
162
  }
122
163
  } catch {
123
164
  }
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 {
165
+ }
166
+ /** Patch playbackRate on all video/audio elements. */
167
+ patchMedia() {
168
+ try {
169
+ document.querySelectorAll("video, audio").forEach((node) => {
170
+ const el = node;
171
+ if (el.closest?.("[data-lapse-panel]")) return;
172
+ if (el.closest?.("[data-saccade-exclude]")) return;
173
+ let tracked = this.trackedMedia.get(el);
174
+ if (!tracked) {
175
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
176
+ this.trackedMedia.set(el, tracked);
177
+ } else if (el.playbackRate !== tracked.applied) {
178
+ tracked.original = el.playbackRate;
179
+ }
180
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
181
+ if (el.playbackRate !== desired) {
182
+ el.playbackRate = desired;
183
+ tracked.applied = desired;
137
184
  }
138
- }, 100);
185
+ });
186
+ } catch {
139
187
  }
140
188
  }
141
- getSpeed() {
142
- return this.speed;
189
+ /** Sync GSAP's global timeline if present. */
190
+ patchGSAP() {
191
+ try {
192
+ const gsap = window.gsap;
193
+ if (gsap?.globalTimeline) {
194
+ gsap.globalTimeline.timeScale(this.speed || 1e-3);
195
+ }
196
+ } catch {
197
+ }
143
198
  }
199
+ // ---------------------------------------------------------------------------
200
+ // Cleanup
201
+ // ---------------------------------------------------------------------------
144
202
  /** Restore all patched APIs to originals. */
145
203
  destroy() {
146
204
  if (!this.installed) return;
@@ -160,19 +218,32 @@ var TimingController = class {
160
218
  Element.prototype.animate = this._origAnimate;
161
219
  this._origAnimate = null;
162
220
  }
163
- this.mediaObserver?.disconnect();
164
- this.mediaObserver = null;
165
- if (this.animObserver != null) {
166
- this._clearInterval(this.animObserver);
167
- this.animObserver = null;
221
+ if (this.animPollId) {
222
+ this._caf(this.animPollId);
223
+ this.animPollId = 0;
168
224
  }
169
225
  try {
170
226
  for (const anim of document.getAnimations()) {
171
- anim.playbackRate = 1;
227
+ const tracked = this.trackedAnims.get(anim);
228
+ anim.playbackRate = tracked?.original ?? 1;
172
229
  }
173
230
  } catch {
174
231
  }
232
+ try {
233
+ document.querySelectorAll("video, audio").forEach((node) => {
234
+ const el = node;
235
+ const tracked = this.trackedMedia.get(el);
236
+ el.playbackRate = tracked?.original ?? 1;
237
+ });
238
+ } catch {
239
+ }
240
+ try {
241
+ const gsap = window.gsap;
242
+ if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
243
+ } catch {
244
+ }
175
245
  delete window.__LAPSE_ORIGINAL_RAF__;
246
+ delete window.__saccadeInstalled;
176
247
  this.installed = false;
177
248
  }
178
249
  };
@@ -242,6 +313,10 @@ var SNAPSHOT_ATTRS = [
242
313
  "data-hover",
243
314
  "data-at-boundary",
244
315
  "data-scrubbing",
316
+ "data-starting-style",
317
+ "data-ending-style",
318
+ "data-panel-open",
319
+ "data-hidden",
245
320
  "aria-checked",
246
321
  "aria-selected",
247
322
  "aria-expanded",
@@ -255,6 +330,7 @@ var SNAPSHOT_ATTRS = [
255
330
  "checked",
256
331
  "disabled",
257
332
  "hidden",
333
+ "inert",
258
334
  "value",
259
335
  "class",
260
336
  "style"
@@ -386,6 +462,8 @@ var _TimelineRecorder = class _TimelineRecorder {
386
462
  // ---- WAAPI interception --------------------------------------------------
387
463
  /** Animations captured via Element.prototype.animate monkey-patch. */
388
464
  this.interceptedAnimations = [];
465
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
466
+ this.seekableClones = /* @__PURE__ */ new Map();
389
467
  this.hiddenSince = null;
390
468
  this.onVisibilityChange = null;
391
469
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -436,6 +514,7 @@ var _TimelineRecorder = class _TimelineRecorder {
436
514
  this.portalIdCounter = 0;
437
515
  this.currentPortalIds.clear();
438
516
  this.capturedPortals.clear();
517
+ this.seekableClones.clear();
439
518
  this.prevInlineStyles.clear();
440
519
  this.jsAnimStartTimes.clear();
441
520
  this.jsAnimLastSeen.clear();
@@ -789,6 +868,13 @@ var _TimelineRecorder = class _TimelineRecorder {
789
868
  } catch (_) {
790
869
  }
791
870
  }
871
+ const cleanedKeyframes = keyframes2.map((kf) => {
872
+ const clean = {};
873
+ for (const [k, v] of Object.entries(kf)) {
874
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
875
+ }
876
+ return clean;
877
+ });
792
878
  this.animations.set(id, {
793
879
  id,
794
880
  name,
@@ -800,7 +886,9 @@ var _TimelineRecorder = class _TimelineRecorder {
800
886
  type,
801
887
  source,
802
888
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
803
- conflicts
889
+ conflicts,
890
+ rawKeyframes: cleanedKeyframes,
891
+ rawTiming: { ...timing, fill: "both" }
804
892
  });
805
893
  }
806
894
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1076,38 +1164,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1076
1164
  } catch (_) {
1077
1165
  }
1078
1166
  }, 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
- }
1167
+ this.seekableClones.clear();
1168
+ for (const [animId, animInfo] of this.animations) {
1169
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1170
+ if (animInfo.type === "JSAnimation") continue;
1171
+ const firstColon = animId.indexOf(":");
1172
+ const secondColon = animId.indexOf(":", firstColon + 1);
1173
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1174
+ const el = this.elements.get(elSelector);
1175
+ if (!el?.isConnected) continue;
1176
+ try {
1177
+ const clone = el.animate(animInfo.rawKeyframes, {
1178
+ ...animInfo.rawTiming,
1179
+ fill: "both"
1180
+ });
1181
+ clone.pause();
1182
+ clone.currentTime = 0;
1183
+ this.seekableClones.set(animId, {
1184
+ animation: clone,
1185
+ element: el,
1186
+ effect: clone.effect
1187
+ });
1188
+ } catch (_) {
1111
1189
  }
1112
1190
  }
1113
1191
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1130,6 +1208,8 @@ var TimelineRecorder = _TimelineRecorder;
1130
1208
  // src/core/scrubber.ts
1131
1209
  var TimelineScrubber = class {
1132
1210
  constructor(state) {
1211
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1212
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1133
1213
  /** Saved originals for restore on release */
1134
1214
  this._originalAnimate = null;
1135
1215
  this._originalRaf = null;
@@ -1139,36 +1219,24 @@ var TimelineScrubber = class {
1139
1219
  this.frames = state.frames;
1140
1220
  this.capturedPortals = state.capturedPortals;
1141
1221
  this.interceptedAnimations = state.interceptedAnimations;
1142
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1222
+ this.seekableClones = state.seekableClones;
1143
1223
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1144
1224
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1145
1225
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1146
1226
  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);
1227
+ for (let i = 0; i < this.frames.length; i++) {
1228
+ for (const fa of this.frames[i].animations) {
1229
+ const range = this.animFrameRanges.get(fa.animationId);
1230
+ if (!range) {
1231
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1232
+ } else {
1233
+ range.last = i;
1234
+ }
1165
1235
  }
1166
- current = parent;
1167
1236
  }
1168
- return parts.join(" > ");
1169
1237
  }
1170
1238
  // ---------------------------------------------------------------------------
1171
- // seekTo — scrub the DOM to match a specific timestamp
1239
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1172
1240
  // ---------------------------------------------------------------------------
1173
1241
  seekTo(timeMs) {
1174
1242
  if (!this.frames.length) return;
@@ -1216,6 +1284,34 @@ var TimelineScrubber = class {
1216
1284
  }
1217
1285
  }
1218
1286
  }
1287
+ const activeAnimIds = /* @__PURE__ */ new Map();
1288
+ for (const fa of frame.animations || []) {
1289
+ activeAnimIds.set(fa.animationId, fa);
1290
+ }
1291
+ for (const [animId, clone] of this.seekableClones) {
1292
+ const frameAnim = activeAnimIds.get(animId);
1293
+ try {
1294
+ if (frameAnim) {
1295
+ if (!clone.animation.effect) {
1296
+ clone.animation.effect = clone.effect;
1297
+ }
1298
+ clone.animation.currentTime = frameAnim.currentTime;
1299
+ } else {
1300
+ const range = this.animFrameRanges.get(animId);
1301
+ if (!range || lo < range.first) {
1302
+ clone.animation.effect = null;
1303
+ } else {
1304
+ if (!clone.animation.effect) {
1305
+ clone.animation.effect = clone.effect;
1306
+ }
1307
+ const timing = clone.effect.getTiming();
1308
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1309
+ clone.animation.currentTime = endTime;
1310
+ }
1311
+ }
1312
+ } catch {
1313
+ }
1314
+ }
1219
1315
  for (const entry of this.interceptedAnimations) {
1220
1316
  try {
1221
1317
  const anim = entry.animation;
@@ -1230,34 +1326,13 @@ var TimelineScrubber = class {
1230
1326
  const el = this.elements.get(sel);
1231
1327
  if (!el || !el.isConnected) continue;
1232
1328
  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
1329
  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
1330
  if (snapTyped.__attrs) {
1245
1331
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1332
+ if (attr === "class" || attr === "style") continue;
1246
1333
  if (attr === "checked") {
1247
1334
  ;
1248
1335
  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
1336
  } else if (attr === "value") {
1262
1337
  if (value != null) el.value = value;
1263
1338
  } else if (value == null) {
@@ -1268,39 +1343,31 @@ var TimelineScrubber = class {
1268
1343
  }
1269
1344
  }
1270
1345
  }
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 || []) {
1346
+ for (const fa of frame.animations || []) {
1347
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1348
+ const firstColon = fa.animationId.indexOf(":");
1349
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1350
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1351
+ const el = this.elements.get(elSel);
1352
+ if (!el || !el.isConnected) continue;
1353
+ for (const prop of fa.properties) {
1278
1354
  if (prop.value) {
1279
- animEl.style.setProperty(prop.property, prop.value, "important");
1355
+ el.style.setProperty(prop.property, prop.value, "important");
1280
1356
  }
1281
1357
  }
1282
1358
  }
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
1359
  }
1300
1360
  // ---------------------------------------------------------------------------
1301
1361
  // release — tear down all scrub state and restore the page to normal
1302
1362
  // ---------------------------------------------------------------------------
1303
1363
  release() {
1364
+ for (const [, clone] of this.seekableClones) {
1365
+ try {
1366
+ clone.animation.cancel();
1367
+ } catch {
1368
+ }
1369
+ }
1370
+ this.seekableClones.clear();
1304
1371
  for (const entry of this.interceptedAnimations) {
1305
1372
  try {
1306
1373
  entry.animation.cancel();
@@ -1362,6 +1429,7 @@ var TimelineScrubber = class {
1362
1429
  this.elements.clear();
1363
1430
  this.frames.length = 0;
1364
1431
  this.capturedPortals.clear();
1432
+ this.animFrameRanges.clear();
1365
1433
  }
1366
1434
  };
1367
1435
 
@@ -1634,7 +1702,7 @@ var SaccadeEngine = class {
1634
1702
  try {
1635
1703
  capture = this.recorder.stopRecording();
1636
1704
  } catch (e) {
1637
- console.error("[Saccade] stopRecording failed:", e);
1705
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1638
1706
  document.getElementById("__lapse-scrub-blocker")?.remove();
1639
1707
  document.getElementById("__lapse-no-transitions")?.remove();
1640
1708
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1655,7 +1723,8 @@ var SaccadeEngine = class {
1655
1723
  frames: capture.frames,
1656
1724
  capturedPortals: this.recorder.capturedPortalIds,
1657
1725
  interceptedAnimations: this.recorder.interceptedAnimations,
1658
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1726
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1727
+ seekableClones: this.recorder.seekableClones
1659
1728
  };
1660
1729
  this.scrubber = new TimelineScrubber(scrubberState);
1661
1730
  this._state = "scrubbing";