saccade 0.0.2 → 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();
125
+ }
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);
112
140
  }
113
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
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;
137
179
  }
138
- }, 100);
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;
184
+ }
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?.() || [];
@@ -1021,96 +1109,83 @@ var _TimelineRecorder = class _TimelineRecorder {
1021
1109
  blocker.title = "Clear the timeline to interact with the page";
1022
1110
  document.body.appendChild(blocker);
1023
1111
  this.blockerEl = blocker;
1024
- try {
1025
- const lapseStyle = document.createElement("style");
1026
- lapseStyle.id = "__lapse-state-rules";
1027
- let allCss = "";
1028
- for (const style of document.querySelectorAll("style")) {
1029
- if (style.id === "__lapse-state-rules") continue;
1030
- allCss += style.textContent + "\n";
1031
- }
1032
- for (const sheet of document.styleSheets) {
1033
- try {
1034
- let walk2 = function(rules) {
1035
- for (const rule of rules) {
1036
- if (rule.cssRules) {
1037
- walk2(rule.cssRules);
1038
- continue;
1039
- }
1040
- const t = rule.cssText;
1041
- if (t && (t.includes(":hover") || t.includes(":focus"))) {
1042
- allCss += t + "\n";
1112
+ setTimeout(() => {
1113
+ try {
1114
+ const lapseStyle = document.createElement("style");
1115
+ lapseStyle.id = "__lapse-state-rules";
1116
+ const hoverFocusRules = [];
1117
+ for (const sheet of document.styleSheets) {
1118
+ try {
1119
+ const walk = (rules) => {
1120
+ for (const rule of rules) {
1121
+ if (rule.cssRules) {
1122
+ walk(rule.cssRules);
1123
+ continue;
1124
+ }
1125
+ const t = rule.cssText;
1126
+ if (t && (t.includes(":hover") || t.includes(":focus"))) {
1127
+ hoverFocusRules.push(t);
1128
+ }
1043
1129
  }
1044
- }
1045
- };
1046
- var walk = walk2;
1047
- walk2(sheet.cssRules);
1048
- } catch (_) {
1049
- }
1050
- }
1051
- const stateRegex = /([^{}]*(?::hover|:focus-visible|:focus-within|:focus(?!-))[^{}]*)\{([^{}]*)\}/g;
1052
- let match;
1053
- while ((match = stateRegex.exec(allCss)) !== null) {
1054
- const selector = match[1].trim();
1055
- const body = match[2].trim();
1056
- if (!body) continue;
1057
- const newBody = body.replace(
1058
- /([^;:]+):\s*([^;]+)(;|$)/g,
1059
- (m, prop, val, end) => {
1060
- if (val.includes("!important")) return m;
1061
- return prop + ": " + val.trim() + " !important" + end;
1130
+ };
1131
+ walk(sheet.cssRules);
1132
+ } catch (_) {
1062
1133
  }
1063
- );
1064
- if (selector.includes(":hover")) {
1065
- lapseStyle.textContent += selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }\n";
1066
1134
  }
1067
- if (selector.includes(":focus-visible")) {
1068
- lapseStyle.textContent += selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1069
- } else if (selector.includes(":focus-within")) {
1070
- lapseStyle.textContent += selector.replace(
1071
- /:focus-within/g,
1072
- ":has([data-lapse-focus])"
1073
- ) + " { " + newBody + " }\n";
1074
- } else if (selector.includes(":focus")) {
1075
- lapseStyle.textContent += selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1076
- }
1077
- }
1078
- document.head.appendChild(lapseStyle);
1079
- this.lapseStyleEl = lapseStyle;
1080
- } catch (_) {
1081
- }
1082
- if (this.frames.length > 0) {
1083
- const frame0 = this.frames[0];
1084
- if (frame0.elementSnapshots) {
1085
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1086
- const el = this.elements.get(sel);
1087
- if (!el || !el.isConnected) continue;
1088
- if (snap.__styles) {
1089
- for (const [prop, value] of Object.entries(snap.__styles)) {
1090
- if (SAFE_PROPS_SET.has(prop)) {
1091
- el.style.setProperty(prop, value, "important");
1092
- }
1135
+ const parts = [];
1136
+ for (const ruleText of hoverFocusRules) {
1137
+ const braceIdx = ruleText.indexOf("{");
1138
+ if (braceIdx === -1) continue;
1139
+ const selector = ruleText.slice(0, braceIdx).trim();
1140
+ const bodyEnd = ruleText.lastIndexOf("}");
1141
+ const body = ruleText.slice(braceIdx + 1, bodyEnd).trim();
1142
+ if (!body) continue;
1143
+ const newBody = body.replace(
1144
+ /([^;:]+):\s*([^;]+)(;|$)/g,
1145
+ (m, prop, val, end) => {
1146
+ if (val.includes("!important")) return m;
1147
+ return prop + ": " + val.trim() + " !important" + end;
1093
1148
  }
1149
+ );
1150
+ if (selector.includes(":hover")) {
1151
+ parts.push(selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }");
1094
1152
  }
1095
- if (snap.__attrs) {
1096
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1097
- if (attr === "checked") {
1098
- ;
1099
- el.checked = value === "true";
1100
- } else if (attr === "class" && value != null) {
1101
- el.className = value;
1102
- } else if (attr === "style") {
1103
- } else if (attr === "value" && value != null) {
1104
- ;
1105
- el.value = value;
1106
- } else if (value == null) {
1107
- el.removeAttribute(attr);
1108
- } else {
1109
- el.setAttribute(attr, value);
1110
- }
1111
- }
1153
+ if (selector.includes(":focus-visible")) {
1154
+ parts.push(selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }");
1155
+ } else if (selector.includes(":focus-within")) {
1156
+ parts.push(selector.replace(/:focus-within/g, ":has([data-lapse-focus])") + " { " + newBody + " }");
1157
+ } else if (selector.includes(":focus")) {
1158
+ parts.push(selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }");
1112
1159
  }
1113
1160
  }
1161
+ lapseStyle.textContent = parts.join("\n");
1162
+ document.head.appendChild(lapseStyle);
1163
+ this.lapseStyleEl = lapseStyle;
1164
+ } catch (_) {
1165
+ }
1166
+ }, 0);
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 (_) {
1114
1189
  }
1115
1190
  }
1116
1191
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1133,6 +1208,8 @@ var TimelineRecorder = _TimelineRecorder;
1133
1208
  // src/core/scrubber.ts
1134
1209
  var TimelineScrubber = class {
1135
1210
  constructor(state) {
1211
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1212
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1136
1213
  /** Saved originals for restore on release */
1137
1214
  this._originalAnimate = null;
1138
1215
  this._originalRaf = null;
@@ -1142,36 +1219,24 @@ var TimelineScrubber = class {
1142
1219
  this.frames = state.frames;
1143
1220
  this.capturedPortals = state.capturedPortals;
1144
1221
  this.interceptedAnimations = state.interceptedAnimations;
1145
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1222
+ this.seekableClones = state.seekableClones;
1146
1223
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1147
1224
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1148
1225
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1149
1226
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1150
- }
1151
- // ---------------------------------------------------------------------------
1152
- // Selector helper mirrors the recorder's getSelector so we can look up
1153
- // elements by the same key the recorder used in frame.animations[].animationId
1154
- // ---------------------------------------------------------------------------
1155
- getSelector(el) {
1156
- if (!el || !el.tagName) return null;
1157
- const parts = [];
1158
- let current = el;
1159
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1160
- const tag = current.tagName.toLowerCase();
1161
- const parent = current.parentElement;
1162
- if (parent) {
1163
- const siblings = Array.from(parent.children);
1164
- const idx = siblings.indexOf(current) + 1;
1165
- parts.unshift(`${tag}:nth-child(${idx})`);
1166
- } else {
1167
- 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
+ }
1168
1235
  }
1169
- current = parent;
1170
1236
  }
1171
- return parts.join(" > ");
1172
1237
  }
1173
1238
  // ---------------------------------------------------------------------------
1174
- // seekTo — scrub the DOM to match a specific timestamp
1239
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1175
1240
  // ---------------------------------------------------------------------------
1176
1241
  seekTo(timeMs) {
1177
1242
  if (!this.frames.length) return;
@@ -1219,6 +1284,34 @@ var TimelineScrubber = class {
1219
1284
  }
1220
1285
  }
1221
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
+ }
1222
1315
  for (const entry of this.interceptedAnimations) {
1223
1316
  try {
1224
1317
  const anim = entry.animation;
@@ -1233,34 +1326,13 @@ var TimelineScrubber = class {
1233
1326
  const el = this.elements.get(sel);
1234
1327
  if (!el || !el.isConnected) continue;
1235
1328
  if (el.closest?.("[data-lapse-panel]")) continue;
1236
- const hasAnimation = (frame.animations || []).some(
1237
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1238
- );
1239
1329
  const snapTyped = snap;
1240
- if (snapTyped.__styles) {
1241
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1242
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1243
- el.style.setProperty(prop, value, "important");
1244
- }
1245
- }
1246
- }
1247
1330
  if (snapTyped.__attrs) {
1248
1331
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1332
+ if (attr === "class" || attr === "style") continue;
1249
1333
  if (attr === "checked") {
1250
1334
  ;
1251
1335
  el.checked = value === "true";
1252
- } else if (attr === "class") {
1253
- if (value != null) el.className = value;
1254
- } else if (attr === "style") {
1255
- if (value) {
1256
- el.setAttribute("style", value);
1257
- el.style.transition = "none";
1258
- if (snapTyped.__styles) {
1259
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1260
- el.style.setProperty(prop, val, "important");
1261
- }
1262
- }
1263
- }
1264
1336
  } else if (attr === "value") {
1265
1337
  if (value != null) el.value = value;
1266
1338
  } else if (value == null) {
@@ -1271,39 +1343,31 @@ var TimelineScrubber = class {
1271
1343
  }
1272
1344
  }
1273
1345
  }
1274
- for (const anim of frame.animations || []) {
1275
- const firstColon = anim.animationId.indexOf(":");
1276
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1277
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1278
- const animEl = this.elements.get(animSel);
1279
- if (!animEl || !animEl.isConnected) continue;
1280
- 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) {
1281
1354
  if (prop.value) {
1282
- animEl.style.setProperty(prop.property, prop.value, "important");
1355
+ el.style.setProperty(prop.property, prop.value, "important");
1283
1356
  }
1284
1357
  }
1285
1358
  }
1286
- const animatedSels = /* @__PURE__ */ new Set();
1287
- for (const anim of frame.animations || []) {
1288
- const fc = anim.animationId.indexOf(":");
1289
- const sc = anim.animationId.indexOf(":", fc + 1);
1290
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1291
- }
1292
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1293
- const el = rawEl;
1294
- const sel = this.getSelector(el);
1295
- if (sel && !animatedSels.has(sel)) {
1296
- el.style.removeProperty("opacity");
1297
- el.style.removeProperty("transform");
1298
- el.style.removeProperty("filter");
1299
- el.style.removeProperty("stroke-dashoffset");
1300
- }
1301
- });
1302
1359
  }
1303
1360
  // ---------------------------------------------------------------------------
1304
1361
  // release — tear down all scrub state and restore the page to normal
1305
1362
  // ---------------------------------------------------------------------------
1306
1363
  release() {
1364
+ for (const [, clone] of this.seekableClones) {
1365
+ try {
1366
+ clone.animation.cancel();
1367
+ } catch {
1368
+ }
1369
+ }
1370
+ this.seekableClones.clear();
1307
1371
  for (const entry of this.interceptedAnimations) {
1308
1372
  try {
1309
1373
  entry.animation.cancel();
@@ -1365,6 +1429,7 @@ var TimelineScrubber = class {
1365
1429
  this.elements.clear();
1366
1430
  this.frames.length = 0;
1367
1431
  this.capturedPortals.clear();
1432
+ this.animFrameRanges.clear();
1368
1433
  }
1369
1434
  };
1370
1435
 
@@ -1591,7 +1656,7 @@ function formatExportForLLM(exp, detail = "standard") {
1591
1656
  }
1592
1657
 
1593
1658
  // src/core/engine.ts
1594
- var LapseEngine = class {
1659
+ var SaccadeEngine = class {
1595
1660
  constructor() {
1596
1661
  this.timing = new TimingController();
1597
1662
  this.recorder = new TimelineRecorder();
@@ -1633,14 +1698,33 @@ var LapseEngine = class {
1633
1698
  boundingBox: null
1634
1699
  };
1635
1700
  }
1636
- const capture = this.recorder.stopRecording();
1701
+ let capture;
1702
+ try {
1703
+ capture = this.recorder.stopRecording();
1704
+ } catch (e) {
1705
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1706
+ document.getElementById("__lapse-scrub-blocker")?.remove();
1707
+ document.getElementById("__lapse-no-transitions")?.remove();
1708
+ document.getElementById("__lapse-state-rules")?.remove();
1709
+ this._state = "idle";
1710
+ this.notify();
1711
+ return {
1712
+ startTime: 0,
1713
+ endTime: 0,
1714
+ duration: 0,
1715
+ animations: [],
1716
+ frames: [],
1717
+ boundingBox: null
1718
+ };
1719
+ }
1637
1720
  this.capture = capture;
1638
1721
  const scrubberState = {
1639
1722
  elements: this.recorder.elements,
1640
1723
  frames: capture.frames,
1641
1724
  capturedPortals: this.recorder.capturedPortalIds,
1642
1725
  interceptedAnimations: this.recorder.interceptedAnimations,
1643
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1726
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1727
+ seekableClones: this.recorder.seekableClones
1644
1728
  };
1645
1729
  this.scrubber = new TimelineScrubber(scrubberState);
1646
1730
  this._state = "scrubbing";
@@ -1693,7 +1777,7 @@ var LapseEngine = class {
1693
1777
  }
1694
1778
  };
1695
1779
  export {
1696
- LapseEngine,
1780
+ SaccadeEngine,
1697
1781
  TimelineRecorder,
1698
1782
  TimelineScrubber,
1699
1783
  TimingController,