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/README.md +65 -2
- package/dist/core.cjs +267 -157
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +63 -11
- package/dist/core.d.ts +63 -11
- package/dist/core.mjs +264 -156
- package/dist/core.mjs.map +1 -1
- package/dist/index.cjs +299 -193
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -11
- package/dist/index.d.ts +37 -11
- package/dist/index.mjs +297 -193
- package/dist/index.mjs.map +1 -1
- package/dist/install.cjs +1846 -0
- package/dist/install.cjs.map +1 -0
- package/dist/install.d.cts +129 -0
- package/dist/install.d.ts +129 -0
- package/dist/install.mjs +1819 -0
- package/dist/install.mjs.map +1 -0
- package/package.json +12 -1
package/dist/index.cjs
CHANGED
|
@@ -24,6 +24,8 @@ __export(src_exports, {
|
|
|
24
24
|
Saccade: () => Saccade,
|
|
25
25
|
SaccadeEngine: () => SaccadeEngine,
|
|
26
26
|
SaccadeProvider: () => SaccadeProvider,
|
|
27
|
+
getSharedEngine: () => getSharedEngine,
|
|
28
|
+
resetSharedEngine: () => resetSharedEngine,
|
|
27
29
|
useSaccadeEngine: () => useSaccadeEngine,
|
|
28
30
|
useSpeed: () => useSpeed,
|
|
29
31
|
useTimeline: () => useTimeline
|
|
@@ -38,16 +40,22 @@ var import_react_dom = require("react-dom");
|
|
|
38
40
|
var import_react = require("react");
|
|
39
41
|
|
|
40
42
|
// src/core/timing.ts
|
|
43
|
+
var MEDIA_RATE_MIN = 0.0625;
|
|
44
|
+
var MEDIA_RATE_MAX = 16;
|
|
45
|
+
var ZERO_SPEED_DIVISOR = 1e-4;
|
|
41
46
|
var TimingController = class {
|
|
42
47
|
constructor() {
|
|
43
48
|
this.speed = 1;
|
|
44
49
|
this.virtualBaseline = 0;
|
|
45
50
|
this.intervalMap = /* @__PURE__ */ new Map();
|
|
46
51
|
this.nextIntervalId = 1e6;
|
|
47
|
-
this.mediaObserver = null;
|
|
48
|
-
this.animObserver = null;
|
|
49
52
|
this._origAnimate = null;
|
|
50
53
|
this.installed = false;
|
|
54
|
+
this.animPollId = 0;
|
|
55
|
+
this.gsapInstance = null;
|
|
56
|
+
// WeakMap tracking for animations and media
|
|
57
|
+
this.trackedAnims = /* @__PURE__ */ new WeakMap();
|
|
58
|
+
this.trackedMedia = /* @__PURE__ */ new WeakMap();
|
|
51
59
|
this._raf = requestAnimationFrame.bind(window);
|
|
52
60
|
this._caf = cancelAnimationFrame.bind(window);
|
|
53
61
|
this._setTimeout = setTimeout.bind(window);
|
|
@@ -57,19 +65,34 @@ var TimingController = class {
|
|
|
57
65
|
this._perfNow = performance.now.bind(performance);
|
|
58
66
|
this._dateNow = Date.now;
|
|
59
67
|
this.realBaseline = this._perfNow();
|
|
68
|
+
this.dateRealBaseline = this._dateNow();
|
|
69
|
+
this.dateVirtualBaseline = this.dateRealBaseline;
|
|
60
70
|
}
|
|
61
71
|
getVirtualTime() {
|
|
62
72
|
const realElapsed = this._perfNow() - this.realBaseline;
|
|
63
73
|
return this.virtualBaseline + realElapsed * this.speed;
|
|
64
74
|
}
|
|
75
|
+
getVirtualDateNow() {
|
|
76
|
+
const realElapsed = this._dateNow() - this.dateRealBaseline;
|
|
77
|
+
return this.dateVirtualBaseline + realElapsed * this.speed;
|
|
78
|
+
}
|
|
65
79
|
reanchor() {
|
|
66
80
|
const virtualNow = this.getVirtualTime();
|
|
67
81
|
this.realBaseline = this._perfNow();
|
|
68
82
|
this.virtualBaseline = virtualNow;
|
|
83
|
+
const virtualDateNow = this.getVirtualDateNow();
|
|
84
|
+
this.dateRealBaseline = this._dateNow();
|
|
85
|
+
this.dateVirtualBaseline = virtualDateNow;
|
|
86
|
+
}
|
|
87
|
+
/** Effective speed divisor — avoids division by zero at speed=0. */
|
|
88
|
+
get speedDivisor() {
|
|
89
|
+
return this.speed || ZERO_SPEED_DIVISOR;
|
|
69
90
|
}
|
|
70
91
|
/** Install timing patches. Safe to call multiple times. */
|
|
71
92
|
install() {
|
|
72
93
|
if (this.installed) return;
|
|
94
|
+
if (window.__saccadeInstalled) return;
|
|
95
|
+
window.__saccadeInstalled = true;
|
|
73
96
|
this.installed = true;
|
|
74
97
|
const self = this;
|
|
75
98
|
window.__LAPSE_ORIGINAL_RAF__ = this._raf;
|
|
@@ -78,21 +101,27 @@ var TimingController = class {
|
|
|
78
101
|
Element.prototype.animate = function(...args) {
|
|
79
102
|
const anim = origAnimate.apply(this, args);
|
|
80
103
|
if (self.speed !== 1) {
|
|
81
|
-
|
|
104
|
+
const originalRate = anim.playbackRate;
|
|
105
|
+
const applied = originalRate * (self.speed || 1e-3);
|
|
106
|
+
anim.playbackRate = applied;
|
|
107
|
+
self.trackedAnims.set(anim, { original: originalRate, applied });
|
|
82
108
|
}
|
|
83
109
|
return anim;
|
|
84
110
|
};
|
|
85
111
|
performance.now = () => self.getVirtualTime();
|
|
86
|
-
|
|
87
|
-
Date.now = () => dateBaseline + self.getVirtualTime();
|
|
112
|
+
Date.now = () => self.getVirtualDateNow();
|
|
88
113
|
window.requestAnimationFrame = (callback) => {
|
|
89
114
|
return self._raf(() => {
|
|
115
|
+
if (self.speed === 0) {
|
|
116
|
+
window.requestAnimationFrame(callback);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
90
119
|
callback(self.getVirtualTime());
|
|
91
120
|
});
|
|
92
121
|
};
|
|
93
122
|
window.cancelAnimationFrame = this._caf;
|
|
94
123
|
window.setTimeout = ((handler, delay, ...args) => {
|
|
95
|
-
const scaledDelay = (delay ?? 0) /
|
|
124
|
+
const scaledDelay = (delay ?? 0) / self.speedDivisor;
|
|
96
125
|
return self._setTimeout(handler, scaledDelay, ...args);
|
|
97
126
|
});
|
|
98
127
|
window.clearTimeout = this._clearTimeout;
|
|
@@ -100,7 +129,7 @@ var TimingController = class {
|
|
|
100
129
|
const id = self.nextIntervalId++;
|
|
101
130
|
const baseDelay = delay ?? 0;
|
|
102
131
|
function tick() {
|
|
103
|
-
const scaledDelay = baseDelay /
|
|
132
|
+
const scaledDelay = baseDelay / self.speedDivisor;
|
|
104
133
|
const realId = self._setTimeout(() => {
|
|
105
134
|
if (typeof handler === "function") {
|
|
106
135
|
;
|
|
@@ -125,61 +154,99 @@ var TimingController = class {
|
|
|
125
154
|
self._clearInterval(id);
|
|
126
155
|
}
|
|
127
156
|
});
|
|
128
|
-
this.
|
|
129
|
-
for (const mutation of mutations) {
|
|
130
|
-
for (const node of mutation.addedNodes) {
|
|
131
|
-
if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
|
|
132
|
-
node.playbackRate = self.speed;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
if (document.body) {
|
|
138
|
-
this.mediaObserver.observe(document.body, { childList: true, subtree: true });
|
|
139
|
-
}
|
|
157
|
+
this.startAnimationPoll();
|
|
140
158
|
}
|
|
141
159
|
/** Set playback speed. Requires install() first. */
|
|
142
160
|
setSpeed(newSpeed) {
|
|
143
161
|
if (!this.installed) this.install();
|
|
144
162
|
this.reanchor();
|
|
145
163
|
this.speed = newSpeed;
|
|
146
|
-
document.querySelectorAll("video, audio").forEach((el) => {
|
|
147
|
-
;
|
|
148
|
-
el.playbackRate = newSpeed;
|
|
149
|
-
});
|
|
150
164
|
this.patchAnimations();
|
|
165
|
+
this.patchMedia();
|
|
166
|
+
this.patchGSAP();
|
|
151
167
|
}
|
|
152
|
-
|
|
168
|
+
getSpeed() {
|
|
169
|
+
return this.speed;
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Animation polling — per-frame via original rAF
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
startAnimationPoll() {
|
|
175
|
+
const poll = () => {
|
|
176
|
+
if (!this.installed) return;
|
|
177
|
+
this.patchAnimations();
|
|
178
|
+
this.patchMedia();
|
|
179
|
+
this.animPollId = this._raf(poll);
|
|
180
|
+
};
|
|
181
|
+
this.animPollId = this._raf(poll);
|
|
182
|
+
}
|
|
183
|
+
/** Patch playbackRate on all active animations via WAAPI. */
|
|
153
184
|
patchAnimations() {
|
|
154
185
|
try {
|
|
155
186
|
const anims = document.getAnimations();
|
|
156
187
|
for (const anim of anims) {
|
|
157
188
|
const target = anim.effect?.target;
|
|
158
189
|
if (target?.closest?.("[data-lapse-panel]")) continue;
|
|
159
|
-
|
|
190
|
+
if (target?.closest?.("[data-saccade-exclude]")) continue;
|
|
191
|
+
const effectiveSpeed = this.speed || 1e-3;
|
|
192
|
+
let tracked = this.trackedAnims.get(anim);
|
|
193
|
+
if (!tracked) {
|
|
194
|
+
tracked = { original: anim.playbackRate, applied: anim.playbackRate };
|
|
195
|
+
this.trackedAnims.set(anim, tracked);
|
|
196
|
+
} else if (anim.playbackRate !== tracked.applied) {
|
|
197
|
+
tracked.original = anim.playbackRate;
|
|
198
|
+
}
|
|
199
|
+
const desired = tracked.original * effectiveSpeed;
|
|
200
|
+
if (anim.playbackRate !== desired) {
|
|
201
|
+
anim.playbackRate = desired;
|
|
202
|
+
tracked.applied = desired;
|
|
203
|
+
}
|
|
160
204
|
}
|
|
161
205
|
} catch {
|
|
162
206
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
207
|
+
}
|
|
208
|
+
/** Patch playbackRate on all video/audio elements. */
|
|
209
|
+
patchMedia() {
|
|
210
|
+
try {
|
|
211
|
+
document.querySelectorAll("video, audio").forEach((node) => {
|
|
212
|
+
const el = node;
|
|
213
|
+
if (el.closest?.("[data-lapse-panel]")) return;
|
|
214
|
+
if (el.closest?.("[data-saccade-exclude]")) return;
|
|
215
|
+
let tracked = this.trackedMedia.get(el);
|
|
216
|
+
if (!tracked) {
|
|
217
|
+
tracked = { original: el.playbackRate, applied: el.playbackRate };
|
|
218
|
+
this.trackedMedia.set(el, tracked);
|
|
219
|
+
} else if (el.playbackRate !== tracked.applied) {
|
|
220
|
+
tracked.original = el.playbackRate;
|
|
221
|
+
}
|
|
222
|
+
const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
|
|
223
|
+
if (el.playbackRate !== desired) {
|
|
224
|
+
el.playbackRate = desired;
|
|
225
|
+
tracked.applied = desired;
|
|
176
226
|
}
|
|
177
|
-
}
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
178
229
|
}
|
|
179
230
|
}
|
|
180
|
-
|
|
181
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Register a GSAP instance (for ES-module imports where window.gsap is
|
|
233
|
+
* undefined). Applies the current timeScale immediately if speed !== 1.
|
|
234
|
+
*/
|
|
235
|
+
registerGSAP(gsap) {
|
|
236
|
+
this.gsapInstance = gsap;
|
|
237
|
+
if (this.speed !== 1) this.patchGSAP();
|
|
182
238
|
}
|
|
239
|
+
/** Sync GSAP's global timeline if present. */
|
|
240
|
+
patchGSAP() {
|
|
241
|
+
try {
|
|
242
|
+
const gsap = this.gsapInstance ?? window.gsap;
|
|
243
|
+
gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Cleanup
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
183
250
|
/** Restore all patched APIs to originals. */
|
|
184
251
|
destroy() {
|
|
185
252
|
if (!this.installed) return;
|
|
@@ -199,19 +266,33 @@ var TimingController = class {
|
|
|
199
266
|
Element.prototype.animate = this._origAnimate;
|
|
200
267
|
this._origAnimate = null;
|
|
201
268
|
}
|
|
202
|
-
this.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this._clearInterval(this.animObserver);
|
|
206
|
-
this.animObserver = null;
|
|
269
|
+
if (this.animPollId) {
|
|
270
|
+
this._caf(this.animPollId);
|
|
271
|
+
this.animPollId = 0;
|
|
207
272
|
}
|
|
208
273
|
try {
|
|
209
274
|
for (const anim of document.getAnimations()) {
|
|
210
|
-
|
|
275
|
+
const tracked = this.trackedAnims.get(anim);
|
|
276
|
+
anim.playbackRate = tracked?.original ?? 1;
|
|
211
277
|
}
|
|
212
278
|
} catch {
|
|
213
279
|
}
|
|
280
|
+
try {
|
|
281
|
+
document.querySelectorAll("video, audio").forEach((node) => {
|
|
282
|
+
const el = node;
|
|
283
|
+
const tracked = this.trackedMedia.get(el);
|
|
284
|
+
el.playbackRate = tracked?.original ?? 1;
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const gsap = this.gsapInstance ?? window.gsap;
|
|
290
|
+
gsap?.globalTimeline?.timeScale(1);
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
this.gsapInstance = null;
|
|
214
294
|
delete window.__LAPSE_ORIGINAL_RAF__;
|
|
295
|
+
delete window.__saccadeInstalled;
|
|
215
296
|
this.installed = false;
|
|
216
297
|
}
|
|
217
298
|
};
|
|
@@ -281,6 +362,10 @@ var SNAPSHOT_ATTRS = [
|
|
|
281
362
|
"data-hover",
|
|
282
363
|
"data-at-boundary",
|
|
283
364
|
"data-scrubbing",
|
|
365
|
+
"data-starting-style",
|
|
366
|
+
"data-ending-style",
|
|
367
|
+
"data-panel-open",
|
|
368
|
+
"data-hidden",
|
|
284
369
|
"aria-checked",
|
|
285
370
|
"aria-selected",
|
|
286
371
|
"aria-expanded",
|
|
@@ -294,6 +379,7 @@ var SNAPSHOT_ATTRS = [
|
|
|
294
379
|
"checked",
|
|
295
380
|
"disabled",
|
|
296
381
|
"hidden",
|
|
382
|
+
"inert",
|
|
297
383
|
"value",
|
|
298
384
|
"class",
|
|
299
385
|
"style"
|
|
@@ -425,6 +511,8 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
425
511
|
// ---- WAAPI interception --------------------------------------------------
|
|
426
512
|
/** Animations captured via Element.prototype.animate monkey-patch. */
|
|
427
513
|
this.interceptedAnimations = [];
|
|
514
|
+
// ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
|
|
515
|
+
this.seekableClones = /* @__PURE__ */ new Map();
|
|
428
516
|
this.hiddenSince = null;
|
|
429
517
|
this.onVisibilityChange = null;
|
|
430
518
|
/** Set to true when the capture loop self-terminates due to limits. */
|
|
@@ -475,6 +563,7 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
475
563
|
this.portalIdCounter = 0;
|
|
476
564
|
this.currentPortalIds.clear();
|
|
477
565
|
this.capturedPortals.clear();
|
|
566
|
+
this.seekableClones.clear();
|
|
478
567
|
this.prevInlineStyles.clear();
|
|
479
568
|
this.jsAnimStartTimes.clear();
|
|
480
569
|
this.jsAnimLastSeen.clear();
|
|
@@ -828,6 +917,13 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
828
917
|
} catch (_) {
|
|
829
918
|
}
|
|
830
919
|
}
|
|
920
|
+
const cleanedKeyframes = keyframes2.map((kf) => {
|
|
921
|
+
const clean = {};
|
|
922
|
+
for (const [k, v] of Object.entries(kf)) {
|
|
923
|
+
if (k !== "computedOffset" && k !== "composite") clean[k] = v;
|
|
924
|
+
}
|
|
925
|
+
return clean;
|
|
926
|
+
});
|
|
831
927
|
this.animations.set(id, {
|
|
832
928
|
id,
|
|
833
929
|
name,
|
|
@@ -839,7 +935,9 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
839
935
|
type,
|
|
840
936
|
source,
|
|
841
937
|
resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
|
|
842
|
-
conflicts
|
|
938
|
+
conflicts,
|
|
939
|
+
rawKeyframes: cleanedKeyframes,
|
|
940
|
+
rawTiming: { ...timing, fill: "both" }
|
|
843
941
|
});
|
|
844
942
|
}
|
|
845
943
|
const keyframes = a.effect?.getKeyframes?.() || [];
|
|
@@ -1115,38 +1213,28 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
1115
1213
|
} catch (_) {
|
|
1116
1214
|
}
|
|
1117
1215
|
}, 0);
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if (
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
;
|
|
1141
|
-
el.value = value;
|
|
1142
|
-
} else if (value == null) {
|
|
1143
|
-
el.removeAttribute(attr);
|
|
1144
|
-
} else {
|
|
1145
|
-
el.setAttribute(attr, value);
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1216
|
+
this.seekableClones.clear();
|
|
1217
|
+
for (const [animId, animInfo] of this.animations) {
|
|
1218
|
+
if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
|
|
1219
|
+
if (animInfo.type === "JSAnimation") continue;
|
|
1220
|
+
const firstColon = animId.indexOf(":");
|
|
1221
|
+
const secondColon = animId.indexOf(":", firstColon + 1);
|
|
1222
|
+
const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
|
|
1223
|
+
const el = this.elements.get(elSelector);
|
|
1224
|
+
if (!el?.isConnected) continue;
|
|
1225
|
+
try {
|
|
1226
|
+
const clone = el.animate(animInfo.rawKeyframes, {
|
|
1227
|
+
...animInfo.rawTiming,
|
|
1228
|
+
fill: "both"
|
|
1229
|
+
});
|
|
1230
|
+
clone.pause();
|
|
1231
|
+
clone.currentTime = 0;
|
|
1232
|
+
this.seekableClones.set(animId, {
|
|
1233
|
+
animation: clone,
|
|
1234
|
+
element: el,
|
|
1235
|
+
effect: clone.effect
|
|
1236
|
+
});
|
|
1237
|
+
} catch (_) {
|
|
1150
1238
|
}
|
|
1151
1239
|
}
|
|
1152
1240
|
const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
|
|
@@ -1169,6 +1257,8 @@ var TimelineRecorder = _TimelineRecorder;
|
|
|
1169
1257
|
// src/core/scrubber.ts
|
|
1170
1258
|
var TimelineScrubber = class {
|
|
1171
1259
|
constructor(state) {
|
|
1260
|
+
/** Precomputed frame range per animation for O(1) before/after lookup. */
|
|
1261
|
+
this.animFrameRanges = /* @__PURE__ */ new Map();
|
|
1172
1262
|
/** Saved originals for restore on release */
|
|
1173
1263
|
this._originalAnimate = null;
|
|
1174
1264
|
this._originalRaf = null;
|
|
@@ -1178,36 +1268,24 @@ var TimelineScrubber = class {
|
|
|
1178
1268
|
this.frames = state.frames;
|
|
1179
1269
|
this.capturedPortals = state.capturedPortals;
|
|
1180
1270
|
this.interceptedAnimations = state.interceptedAnimations;
|
|
1181
|
-
this.
|
|
1271
|
+
this.seekableClones = state.seekableClones;
|
|
1182
1272
|
this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
|
|
1183
1273
|
this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
|
|
1184
1274
|
this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
|
|
1185
1275
|
this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
let current = el;
|
|
1195
|
-
for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
|
|
1196
|
-
const tag = current.tagName.toLowerCase();
|
|
1197
|
-
const parent = current.parentElement;
|
|
1198
|
-
if (parent) {
|
|
1199
|
-
const siblings = Array.from(parent.children);
|
|
1200
|
-
const idx = siblings.indexOf(current) + 1;
|
|
1201
|
-
parts.unshift(`${tag}:nth-child(${idx})`);
|
|
1202
|
-
} else {
|
|
1203
|
-
parts.unshift(tag);
|
|
1276
|
+
for (let i = 0; i < this.frames.length; i++) {
|
|
1277
|
+
for (const fa of this.frames[i].animations) {
|
|
1278
|
+
const range = this.animFrameRanges.get(fa.animationId);
|
|
1279
|
+
if (!range) {
|
|
1280
|
+
this.animFrameRanges.set(fa.animationId, { first: i, last: i });
|
|
1281
|
+
} else {
|
|
1282
|
+
range.last = i;
|
|
1283
|
+
}
|
|
1204
1284
|
}
|
|
1205
|
-
current = parent;
|
|
1206
1285
|
}
|
|
1207
|
-
return parts.join(" > ");
|
|
1208
1286
|
}
|
|
1209
1287
|
// ---------------------------------------------------------------------------
|
|
1210
|
-
// seekTo — scrub
|
|
1288
|
+
// seekTo — scrub to a specific timestamp using WAAPI-native seeking
|
|
1211
1289
|
// ---------------------------------------------------------------------------
|
|
1212
1290
|
seekTo(timeMs) {
|
|
1213
1291
|
if (!this.frames.length) return;
|
|
@@ -1255,6 +1333,34 @@ var TimelineScrubber = class {
|
|
|
1255
1333
|
}
|
|
1256
1334
|
}
|
|
1257
1335
|
}
|
|
1336
|
+
const activeAnimIds = /* @__PURE__ */ new Map();
|
|
1337
|
+
for (const fa of frame.animations || []) {
|
|
1338
|
+
activeAnimIds.set(fa.animationId, fa);
|
|
1339
|
+
}
|
|
1340
|
+
for (const [animId, clone] of this.seekableClones) {
|
|
1341
|
+
const frameAnim = activeAnimIds.get(animId);
|
|
1342
|
+
try {
|
|
1343
|
+
if (frameAnim) {
|
|
1344
|
+
if (!clone.animation.effect) {
|
|
1345
|
+
clone.animation.effect = clone.effect;
|
|
1346
|
+
}
|
|
1347
|
+
clone.animation.currentTime = frameAnim.currentTime;
|
|
1348
|
+
} else {
|
|
1349
|
+
const range = this.animFrameRanges.get(animId);
|
|
1350
|
+
if (!range || lo < range.first) {
|
|
1351
|
+
clone.animation.effect = null;
|
|
1352
|
+
} else {
|
|
1353
|
+
if (!clone.animation.effect) {
|
|
1354
|
+
clone.animation.effect = clone.effect;
|
|
1355
|
+
}
|
|
1356
|
+
const timing = clone.effect.getTiming();
|
|
1357
|
+
const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
|
|
1358
|
+
clone.animation.currentTime = endTime;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
} catch {
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1258
1364
|
for (const entry of this.interceptedAnimations) {
|
|
1259
1365
|
try {
|
|
1260
1366
|
const anim = entry.animation;
|
|
@@ -1269,34 +1375,13 @@ var TimelineScrubber = class {
|
|
|
1269
1375
|
const el = this.elements.get(sel);
|
|
1270
1376
|
if (!el || !el.isConnected) continue;
|
|
1271
1377
|
if (el.closest?.("[data-lapse-panel]")) continue;
|
|
1272
|
-
const hasAnimation = (frame.animations || []).some(
|
|
1273
|
-
(a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
|
|
1274
|
-
);
|
|
1275
1378
|
const snapTyped = snap;
|
|
1276
|
-
if (snapTyped.__styles) {
|
|
1277
|
-
for (const [prop, value] of Object.entries(snapTyped.__styles)) {
|
|
1278
|
-
if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
|
|
1279
|
-
el.style.setProperty(prop, value, "important");
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
1379
|
if (snapTyped.__attrs) {
|
|
1284
1380
|
for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
|
|
1381
|
+
if (attr === "class" || attr === "style") continue;
|
|
1285
1382
|
if (attr === "checked") {
|
|
1286
1383
|
;
|
|
1287
1384
|
el.checked = value === "true";
|
|
1288
|
-
} else if (attr === "class") {
|
|
1289
|
-
if (value != null) el.className = value;
|
|
1290
|
-
} else if (attr === "style") {
|
|
1291
|
-
if (value) {
|
|
1292
|
-
el.setAttribute("style", value);
|
|
1293
|
-
el.style.transition = "none";
|
|
1294
|
-
if (snapTyped.__styles) {
|
|
1295
|
-
for (const [prop, val] of Object.entries(snapTyped.__styles)) {
|
|
1296
|
-
el.style.setProperty(prop, val, "important");
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
1385
|
} else if (attr === "value") {
|
|
1301
1386
|
if (value != null) el.value = value;
|
|
1302
1387
|
} else if (value == null) {
|
|
@@ -1307,39 +1392,31 @@ var TimelineScrubber = class {
|
|
|
1307
1392
|
}
|
|
1308
1393
|
}
|
|
1309
1394
|
}
|
|
1310
|
-
for (const
|
|
1311
|
-
|
|
1312
|
-
const
|
|
1313
|
-
const
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
|
|
1395
|
+
for (const fa of frame.animations || []) {
|
|
1396
|
+
if (!fa.animationId.startsWith("JSAnimation:")) continue;
|
|
1397
|
+
const firstColon = fa.animationId.indexOf(":");
|
|
1398
|
+
const secondColon = fa.animationId.indexOf(":", firstColon + 1);
|
|
1399
|
+
const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
|
|
1400
|
+
const el = this.elements.get(elSel);
|
|
1401
|
+
if (!el || !el.isConnected) continue;
|
|
1402
|
+
for (const prop of fa.properties) {
|
|
1317
1403
|
if (prop.value) {
|
|
1318
|
-
|
|
1404
|
+
el.style.setProperty(prop.property, prop.value, "important");
|
|
1319
1405
|
}
|
|
1320
1406
|
}
|
|
1321
1407
|
}
|
|
1322
|
-
const animatedSels = /* @__PURE__ */ new Set();
|
|
1323
|
-
for (const anim of frame.animations || []) {
|
|
1324
|
-
const fc = anim.animationId.indexOf(":");
|
|
1325
|
-
const sc = anim.animationId.indexOf(":", fc + 1);
|
|
1326
|
-
if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
|
|
1327
|
-
}
|
|
1328
|
-
document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
|
|
1329
|
-
const el = rawEl;
|
|
1330
|
-
const sel = this.getSelector(el);
|
|
1331
|
-
if (sel && !animatedSels.has(sel)) {
|
|
1332
|
-
el.style.removeProperty("opacity");
|
|
1333
|
-
el.style.removeProperty("transform");
|
|
1334
|
-
el.style.removeProperty("filter");
|
|
1335
|
-
el.style.removeProperty("stroke-dashoffset");
|
|
1336
|
-
}
|
|
1337
|
-
});
|
|
1338
1408
|
}
|
|
1339
1409
|
// ---------------------------------------------------------------------------
|
|
1340
1410
|
// release — tear down all scrub state and restore the page to normal
|
|
1341
1411
|
// ---------------------------------------------------------------------------
|
|
1342
1412
|
release() {
|
|
1413
|
+
for (const [, clone] of this.seekableClones) {
|
|
1414
|
+
try {
|
|
1415
|
+
clone.animation.cancel();
|
|
1416
|
+
} catch {
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
this.seekableClones.clear();
|
|
1343
1420
|
for (const entry of this.interceptedAnimations) {
|
|
1344
1421
|
try {
|
|
1345
1422
|
entry.animation.cancel();
|
|
@@ -1401,6 +1478,7 @@ var TimelineScrubber = class {
|
|
|
1401
1478
|
this.elements.clear();
|
|
1402
1479
|
this.frames.length = 0;
|
|
1403
1480
|
this.capturedPortals.clear();
|
|
1481
|
+
this.animFrameRanges.clear();
|
|
1404
1482
|
}
|
|
1405
1483
|
};
|
|
1406
1484
|
|
|
@@ -1487,7 +1565,7 @@ function generateExport(animations, frames, timeMs, filter = "active") {
|
|
|
1487
1565
|
animations: animExports
|
|
1488
1566
|
};
|
|
1489
1567
|
}
|
|
1490
|
-
function formatExportForLLM(exp, detail = "
|
|
1568
|
+
function formatExportForLLM(exp, detail = "moderate") {
|
|
1491
1569
|
const lines = [];
|
|
1492
1570
|
const grouped = /* @__PURE__ */ new Map();
|
|
1493
1571
|
for (const anim of exp.animations) {
|
|
@@ -1502,7 +1580,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1502
1580
|
const [from, to] = prop.range.split(" \u2192 ");
|
|
1503
1581
|
return !(from && to && from.trim() === to.trim());
|
|
1504
1582
|
}
|
|
1505
|
-
if (detail === "
|
|
1583
|
+
if (detail === "brief") {
|
|
1506
1584
|
lines.push(`# Animation State at ${exp.timestamp}`);
|
|
1507
1585
|
lines.push("");
|
|
1508
1586
|
for (const [, group] of grouped) {
|
|
@@ -1527,7 +1605,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1527
1605
|
}
|
|
1528
1606
|
lines.push(`# Animation State at ${exp.timestamp}`);
|
|
1529
1607
|
lines.push("");
|
|
1530
|
-
if (detail === "
|
|
1608
|
+
if (detail === "granular") {
|
|
1531
1609
|
lines.push("**Environment:**");
|
|
1532
1610
|
lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
|
|
1533
1611
|
lines.push(`- URL: ${window.location.href}`);
|
|
@@ -1594,7 +1672,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1594
1672
|
lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
|
|
1595
1673
|
lines.push("");
|
|
1596
1674
|
for (const line of cssPropLines) lines.push(line);
|
|
1597
|
-
if (detail === "detailed" || detail === "
|
|
1675
|
+
if (detail === "detailed" || detail === "granular") {
|
|
1598
1676
|
const allVars = {};
|
|
1599
1677
|
for (const anim of cssAnims) {
|
|
1600
1678
|
if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
|
|
@@ -1649,6 +1727,22 @@ var SaccadeEngine = class {
|
|
|
1649
1727
|
getSpeed() {
|
|
1650
1728
|
return this.timing.getSpeed();
|
|
1651
1729
|
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Install the timing patches immediately, without changing speed.
|
|
1732
|
+
*
|
|
1733
|
+
* Call this as early as possible (before app code, GSAP, or Framer Motion
|
|
1734
|
+
* run) so they capture the patched time functions rather than the originals.
|
|
1735
|
+
* Idempotent and harmless to call more than once. `setSpeed` and
|
|
1736
|
+
* `startRecording` also install on demand, so this is only needed to win the
|
|
1737
|
+
* early-load race against libraries that cache `Date.now`/`performance.now`.
|
|
1738
|
+
*/
|
|
1739
|
+
install() {
|
|
1740
|
+
this.timing.install();
|
|
1741
|
+
}
|
|
1742
|
+
/** Register a module-imported GSAP instance so saccade can slow it. */
|
|
1743
|
+
registerGSAP(gsap) {
|
|
1744
|
+
this.timing.registerGSAP(gsap);
|
|
1745
|
+
}
|
|
1652
1746
|
// -- Timeline recording ---------------------------------------------------
|
|
1653
1747
|
startRecording(boundingBox) {
|
|
1654
1748
|
if (this._state !== "idle") return;
|
|
@@ -1673,7 +1767,7 @@ var SaccadeEngine = class {
|
|
|
1673
1767
|
try {
|
|
1674
1768
|
capture = this.recorder.stopRecording();
|
|
1675
1769
|
} catch (e) {
|
|
1676
|
-
console.error("[Saccade] stopRecording failed:", e);
|
|
1770
|
+
if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
|
|
1677
1771
|
document.getElementById("__lapse-scrub-blocker")?.remove();
|
|
1678
1772
|
document.getElementById("__lapse-no-transitions")?.remove();
|
|
1679
1773
|
document.getElementById("__lapse-state-rules")?.remove();
|
|
@@ -1694,7 +1788,8 @@ var SaccadeEngine = class {
|
|
|
1694
1788
|
frames: capture.frames,
|
|
1695
1789
|
capturedPortals: this.recorder.capturedPortalIds,
|
|
1696
1790
|
interceptedAnimations: this.recorder.interceptedAnimations,
|
|
1697
|
-
SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
|
|
1791
|
+
SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
|
|
1792
|
+
seekableClones: this.recorder.seekableClones
|
|
1698
1793
|
};
|
|
1699
1794
|
this.scrubber = new TimelineScrubber(scrubberState);
|
|
1700
1795
|
this._state = "scrubbing";
|
|
@@ -1722,7 +1817,7 @@ var SaccadeEngine = class {
|
|
|
1722
1817
|
filter
|
|
1723
1818
|
);
|
|
1724
1819
|
}
|
|
1725
|
-
exportForLLM(timeMs, filter = "active", detail = "
|
|
1820
|
+
exportForLLM(timeMs, filter = "active", detail = "moderate") {
|
|
1726
1821
|
const exp = this.generateExport(timeMs, filter);
|
|
1727
1822
|
if (!exp) return "";
|
|
1728
1823
|
return formatExportForLLM(exp, detail);
|
|
@@ -1747,13 +1842,29 @@ var SaccadeEngine = class {
|
|
|
1747
1842
|
}
|
|
1748
1843
|
};
|
|
1749
1844
|
|
|
1845
|
+
// src/core/shared.ts
|
|
1846
|
+
var KEY = "__saccadeSharedEngine__";
|
|
1847
|
+
function getSharedEngine() {
|
|
1848
|
+
const g = globalThis;
|
|
1849
|
+
if (!g[KEY]) g[KEY] = new SaccadeEngine();
|
|
1850
|
+
return g[KEY];
|
|
1851
|
+
}
|
|
1852
|
+
function resetSharedEngine() {
|
|
1853
|
+
const g = globalThis;
|
|
1854
|
+
g[KEY]?.destroy();
|
|
1855
|
+
g[KEY] = null;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1750
1858
|
// src/react/SaccadeContext.tsx
|
|
1751
1859
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
1752
1860
|
var SaccadeContext = (0, import_react.createContext)(null);
|
|
1753
|
-
function SaccadeProvider({
|
|
1861
|
+
function SaccadeProvider({
|
|
1862
|
+
children,
|
|
1863
|
+
engine
|
|
1864
|
+
}) {
|
|
1754
1865
|
const engineRef = (0, import_react.useRef)(null);
|
|
1755
1866
|
if (!engineRef.current) {
|
|
1756
|
-
engineRef.current =
|
|
1867
|
+
engineRef.current = engine ?? getSharedEngine();
|
|
1757
1868
|
}
|
|
1758
1869
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaccadeContext.Provider, { value: engineRef.current, children });
|
|
1759
1870
|
}
|
|
@@ -1770,10 +1881,10 @@ var import_react6 = require("react");
|
|
|
1770
1881
|
var import_react2 = require("react");
|
|
1771
1882
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1772
1883
|
var DETAIL_LABELS = {
|
|
1773
|
-
|
|
1774
|
-
|
|
1884
|
+
brief: "Brief",
|
|
1885
|
+
moderate: "Moderate",
|
|
1775
1886
|
detailed: "Detailed",
|
|
1776
|
-
|
|
1887
|
+
granular: "Granular"
|
|
1777
1888
|
};
|
|
1778
1889
|
function CopyCheckIcon({ copied }) {
|
|
1779
1890
|
const spring = "cubic-bezier(0.34, 1.15, 0.64, 1)";
|
|
@@ -1827,10 +1938,10 @@ function CopyCheckIcon({ copied }) {
|
|
|
1827
1938
|
] });
|
|
1828
1939
|
}
|
|
1829
1940
|
var DETAIL_BRIGHT_COUNT = {
|
|
1830
|
-
|
|
1831
|
-
|
|
1941
|
+
brief: 1,
|
|
1942
|
+
moderate: 2,
|
|
1832
1943
|
detailed: 3,
|
|
1833
|
-
|
|
1944
|
+
granular: 4
|
|
1834
1945
|
};
|
|
1835
1946
|
function DetailIcon({ level }) {
|
|
1836
1947
|
const bright = DETAIL_BRIGHT_COUNT[level];
|
|
@@ -2155,7 +2266,9 @@ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
|
|
|
2155
2266
|
|
|
2156
2267
|
// src/react/useTimeline.ts
|
|
2157
2268
|
var import_react4 = require("react");
|
|
2158
|
-
var
|
|
2269
|
+
var _realSetTimeout = setTimeout.bind(window);
|
|
2270
|
+
var _realClearTimeout = clearTimeout.bind(window);
|
|
2271
|
+
var DETAIL_LEVELS = ["brief", "moderate", "detailed", "granular"];
|
|
2159
2272
|
function useTimeline() {
|
|
2160
2273
|
const engine = useSaccadeEngine();
|
|
2161
2274
|
const state = (0, import_react4.useSyncExternalStore)(
|
|
@@ -2166,7 +2279,7 @@ function useTimeline() {
|
|
|
2166
2279
|
const [scrubTime, setScrubTime] = (0, import_react4.useState)(0);
|
|
2167
2280
|
const [copied, setCopied] = (0, import_react4.useState)(false);
|
|
2168
2281
|
const [exportFilter, setExportFilter] = (0, import_react4.useState)("all-animations");
|
|
2169
|
-
const [detailLevel, setDetailLevel] = (0, import_react4.useState)("
|
|
2282
|
+
const [detailLevel, setDetailLevel] = (0, import_react4.useState)("moderate");
|
|
2170
2283
|
const copiedTimeout = (0, import_react4.useRef)(null);
|
|
2171
2284
|
const pendingSeek = (0, import_react4.useRef)(null);
|
|
2172
2285
|
const rafId = (0, import_react4.useRef)(0);
|
|
@@ -2240,8 +2353,8 @@ function useTimeline() {
|
|
|
2240
2353
|
navigator.clipboard.writeText(text).catch(() => {
|
|
2241
2354
|
});
|
|
2242
2355
|
setCopied(true);
|
|
2243
|
-
if (copiedTimeout.current)
|
|
2244
|
-
copiedTimeout.current =
|
|
2356
|
+
if (copiedTimeout.current) _realClearTimeout(copiedTimeout.current);
|
|
2357
|
+
copiedTimeout.current = _realSetTimeout(() => setCopied(false), 1800);
|
|
2245
2358
|
return text;
|
|
2246
2359
|
},
|
|
2247
2360
|
[engine, capture, scrubTime, exportFilter, detailLevel]
|
|
@@ -2793,15 +2906,16 @@ var PANEL_STYLES = (
|
|
|
2793
2906
|
|
|
2794
2907
|
// src/react/Saccade.tsx
|
|
2795
2908
|
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
2796
|
-
function Saccade({ position = "bottom-left" }) {
|
|
2797
|
-
const hostRef = (0, import_react7.useRef)(null);
|
|
2909
|
+
function Saccade({ position = "bottom-left", engine }) {
|
|
2798
2910
|
const [shadowRoot, setShadowRoot] = (0, import_react7.useState)(null);
|
|
2911
|
+
const hostRef = (0, import_react7.useRef)(null);
|
|
2799
2912
|
(0, import_react7.useEffect)(() => {
|
|
2800
|
-
const host =
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2913
|
+
const host = document.createElement("div");
|
|
2914
|
+
host.setAttribute("data-lapse-panel", "");
|
|
2915
|
+
const positionOffset = position === "top-left" ? "top:20px;left:20px" : position === "top-right" ? "top:20px;right:20px" : position === "bottom-left" ? "bottom:20px;left:20px" : "bottom:20px;right:20px";
|
|
2916
|
+
host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
|
|
2917
|
+
document.body.appendChild(host);
|
|
2918
|
+
hostRef.current = host;
|
|
2805
2919
|
const shadow = host.attachShadow({ mode: "open" });
|
|
2806
2920
|
const style = document.createElement("style");
|
|
2807
2921
|
style.textContent = PANEL_STYLES;
|
|
@@ -2809,25 +2923,15 @@ function Saccade({ position = "bottom-left" }) {
|
|
|
2809
2923
|
const mount = document.createElement("div");
|
|
2810
2924
|
shadow.appendChild(mount);
|
|
2811
2925
|
setShadowRoot(shadow);
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
zIndex: 2147483647,
|
|
2822
|
-
// max int — must sit above the scrub blocker (z-index: 999999)
|
|
2823
|
-
pointerEvents: "auto",
|
|
2824
|
-
...positionOffset
|
|
2825
|
-
},
|
|
2826
|
-
children: shadowRoot && (0, import_react_dom.createPortal)(
|
|
2827
|
-
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadeProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadePanel, {}) }),
|
|
2828
|
-
shadowRoot.lastElementChild || shadowRoot
|
|
2829
|
-
)
|
|
2830
|
-
}
|
|
2926
|
+
return () => {
|
|
2927
|
+
host.remove();
|
|
2928
|
+
hostRef.current = null;
|
|
2929
|
+
};
|
|
2930
|
+
}, [position]);
|
|
2931
|
+
if (!shadowRoot) return null;
|
|
2932
|
+
return (0, import_react_dom.createPortal)(
|
|
2933
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadeProvider, { engine, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadePanel, {}) }),
|
|
2934
|
+
shadowRoot.lastElementChild || shadowRoot
|
|
2831
2935
|
);
|
|
2832
2936
|
}
|
|
2833
2937
|
// Annotate the CommonJS export names for ESM import in node:
|
|
@@ -2835,6 +2939,8 @@ function Saccade({ position = "bottom-left" }) {
|
|
|
2835
2939
|
Saccade,
|
|
2836
2940
|
SaccadeEngine,
|
|
2837
2941
|
SaccadeProvider,
|
|
2942
|
+
getSharedEngine,
|
|
2943
|
+
resetSharedEngine,
|
|
2838
2944
|
useSaccadeEngine,
|
|
2839
2945
|
useSpeed,
|
|
2840
2946
|
useTimeline
|