retake-dev 0.4.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.
Files changed (43) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +198 -0
  3. package/bin/retake.js +319 -0
  4. package/package.json +87 -0
  5. package/src/code-versions.js +357 -0
  6. package/src/core.js +265 -0
  7. package/src/plugin.js +137 -0
  8. package/src/runtime/00-core.js +308 -0
  9. package/src/runtime/10-animations.js +362 -0
  10. package/src/runtime/20-media.js +157 -0
  11. package/src/runtime/30-recorder.js +315 -0
  12. package/src/runtime/32-state.js +315 -0
  13. package/src/runtime/35-network.js +789 -0
  14. package/src/runtime/36-scripts.js +129 -0
  15. package/src/runtime/37-next.js +150 -0
  16. package/src/runtime/38-observers.js +252 -0
  17. package/src/runtime/40-input.js +795 -0
  18. package/src/runtime/45-hover.js +78 -0
  19. package/src/runtime/50-engine.js +341 -0
  20. package/src/runtime/60-preview.js +437 -0
  21. package/src/runtime/65-timeline.js +289 -0
  22. package/src/runtime/66-activity.js +114 -0
  23. package/src/runtime/67-csssource.js +226 -0
  24. package/src/runtime/70-boot.js +460 -0
  25. package/src/server/api.js +285 -0
  26. package/src/server/child.js +174 -0
  27. package/src/server/detect.js +167 -0
  28. package/src/server/front.js +647 -0
  29. package/src/server/mcp.js +332 -0
  30. package/src/shell/00-state.js +85 -0
  31. package/src/shell/05-api.js +136 -0
  32. package/src/shell/10-dock.js +754 -0
  33. package/src/shell/12-checkpoint.js +121 -0
  34. package/src/shell/15-session.js +291 -0
  35. package/src/shell/20-timeline.js +734 -0
  36. package/src/shell/25-input.js +537 -0
  37. package/src/shell/30-notes.js +870 -0
  38. package/src/shell/40-code.js +80 -0
  39. package/src/shell/90-handle.js +15 -0
  40. package/src/shell/shell.css +295 -0
  41. package/src/shell/shell.html +59 -0
  42. package/types/client.d.ts +73 -0
  43. package/types/index.d.ts +111 -0
@@ -0,0 +1,308 @@
1
+ // Virtual clock. Every app-visible notion of time (timers, rAF, performance.now,
2
+ // Date) reads from `clock.now`, which only the engine advances. All runtime
3
+ // files are concatenated into one IIFE by the Vite plugin, in filename order.
4
+
5
+ // W is the window the runtime patches (its timers, Date, network...), so its
6
+ // globals take shapes lib.dom doesn't describe: untyped for the type check.
7
+ const W = /** @type {any} */ (window)
8
+ /** @type {any} the runtime API (70-boot.js fills it in) */
9
+ const PT = (W.__retake = { emit() {} })
10
+ const real = {
11
+ setTimeout: W.setTimeout.bind(W),
12
+ clearTimeout: W.clearTimeout.bind(W),
13
+ setInterval: W.setInterval.bind(W),
14
+ raf: W.requestAnimationFrame.bind(W),
15
+ perfNow: performance.now.bind(performance),
16
+ Date: W.Date,
17
+ random: Math.random,
18
+ fetch: W.fetch.bind(W),
19
+ idle: W.requestIdleCallback ? W.requestIdleCallback.bind(W) : null,
20
+ local: W.localStorage,
21
+ session: W.sessionStorage,
22
+ }
23
+
24
+ const KEY = "retake:"
25
+ // performance.now() and rAF timestamps are offset so they never read 0 (lots of
26
+ // code treats a 0 timestamp as "unset").
27
+ const PERF_BASE = 1000
28
+ // Timers created by the dev server itself must keep real time, or HMR stalls
29
+ // whenever the clock is paused. (Turbopack's HMR client is a chunk of its own.)
30
+ const EXEMPT = /@vite\/client|@react-refresh|vite\/dist\/client|dev_hmr-client_hmr-client/
31
+ // The dev server's own traffic, by URL (RT.exemptUrls, picked by the server
32
+ // for the framework): its HMR socket, overlay and hot-update requests. A call
33
+ // stack can't always tell (Turbopack bundles Next's router and its HMR client
34
+ // together), a URL can. Exempt traffic is never recorded, held or replayed.
35
+ let exemptUrlRe = null
36
+ try {
37
+ if (RT.exemptUrls && RT.exemptUrls.length) exemptUrlRe = new RegExp(RT.exemptUrls.join("|"))
38
+ } catch {}
39
+ function isExemptUrl(url) {
40
+ if (!exemptUrlRe) return false
41
+ try {
42
+ const u = new URL(String(url), location.href)
43
+ return exemptUrlRe.test(u.pathname + u.search)
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ const clock = { now: 0, rate: 1, playing: false, seeking: false, booted: false }
50
+
51
+ // ---- timers ---------------------------------------------------------------
52
+
53
+ const timers = new Map()
54
+ let timerSeq = 1e9
55
+
56
+ function isExempt() {
57
+ return EXEMPT.test(new Error().stack || "")
58
+ }
59
+
60
+ // While rebuilding, React's per-element console tasks (async stack tagging
61
+ // for devtools) are skipped. Its per-element Error stacks are NOT: notes read
62
+ // an element's source file:line from them, and notes are made in rebuilt frames.
63
+ const realCreateTask = console.createTask
64
+ if (realCreateTask) {
65
+ const noTask = { run: (fn) => fn() }
66
+ console.createTask = function (name) {
67
+ return clock.seeking ? noTask : realCreateTask.call(console, name)
68
+ }
69
+ }
70
+
71
+ function addTimer(fn, delay, args, repeat) {
72
+ const id = timerSeq++
73
+ const d = Math.max(repeat ? 4 : 0, Number(delay) || 0)
74
+ timers.set(id, { id, fn, args, due: clock.now + d, delay: d, repeat })
75
+ return id
76
+ }
77
+
78
+ function nextTimer() {
79
+ let best = null
80
+ for (const t of timers.values()) if (!best || t.due < best.due) best = t
81
+ return best
82
+ }
83
+
84
+ // Debug aid: `window.__retakeTrace = []` before load collects a log of
85
+ // what ran when, to diff a recording against its replay.
86
+ const trace = (...entry) => W.__retakeTrace && W.__retakeTrace.push([clock.now, ...entry])
87
+
88
+ function runTimer(t) {
89
+ trace("timer", t.due, String(t.fn).slice(0, 80))
90
+ if (t.repeat) t.due += t.delay
91
+ else timers.delete(t.id)
92
+ safeCall(t.fn, t.args)
93
+ }
94
+
95
+ function safeCall(fn, args) {
96
+ try {
97
+ fn.apply(W, args)
98
+ } catch (err) {
99
+ // Rethrow outside our loop so the app's error overlay still sees it.
100
+ real.setTimeout(() => {
101
+ throw err
102
+ })
103
+ }
104
+ }
105
+
106
+ W.setTimeout = function (fn, delay, ...args) {
107
+ if (typeof fn !== "function" || isExempt()) return real.setTimeout(fn, delay, ...args)
108
+ return addTimer(fn, delay, args, false)
109
+ }
110
+ W.setInterval = function (fn, delay, ...args) {
111
+ if (typeof fn !== "function" || isExempt()) return real.setInterval(fn, delay, ...args)
112
+ return addTimer(fn, delay, args, true)
113
+ }
114
+ W.clearTimeout = W.clearInterval = function (id) {
115
+ if (timers.has(id)) timers.delete(id)
116
+ else real.clearTimeout(id)
117
+ }
118
+
119
+ // requestIdleCallback runs on the virtual clock too: "idle" is right after
120
+ // the current moment's work.
121
+ if (W.requestIdleCallback) {
122
+ W.requestIdleCallback = function (cb, opts) {
123
+ if (typeof cb !== "function" || isExempt()) return real.setTimeout(cb, 1)
124
+ return addTimer(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1, [], false)
125
+ }
126
+ W.cancelIdleCallback = function (id) {
127
+ W.clearTimeout(id)
128
+ }
129
+ }
130
+
131
+ // ---- animation frames -----------------------------------------------------
132
+
133
+ let rafQueue = new Map()
134
+ let rafSeq = 1
135
+
136
+ W.requestAnimationFrame = function (cb) {
137
+ if (W.__retakeTrace) trace("req", (new Error().stack || "").split("\n")[2])
138
+ const id = rafSeq++
139
+ rafQueue.set(id, cb)
140
+ return id
141
+ }
142
+ W.cancelAnimationFrame = function (id) {
143
+ rafQueue.delete(id)
144
+ }
145
+
146
+ function runRaf() {
147
+ if (!rafQueue.size) return
148
+ const queue = rafQueue
149
+ rafQueue = new Map()
150
+ trace("raf", queue.size)
151
+ for (const cb of queue.values()) safeCall(cb, [clock.now + PERF_BASE])
152
+ }
153
+
154
+ // ---- performance.now / Date -----------------------------------------------
155
+
156
+ performance.now = () => clock.now + PERF_BASE
157
+ try {
158
+ Object.defineProperty(document.timeline, "currentTime", {
159
+ get: () => clock.now + PERF_BASE,
160
+ configurable: true,
161
+ })
162
+ } catch {}
163
+
164
+ let epoch = real.Date.now()
165
+ function VDate(...args) {
166
+ if (!new.target) return new real.Date(epoch + clock.now).toString()
167
+ return args.length ? new real.Date(...args) : new real.Date(epoch + clock.now)
168
+ }
169
+ VDate.prototype = real.Date.prototype
170
+ VDate.now = () => epoch + clock.now
171
+ VDate.parse = real.Date.parse
172
+ VDate.UTC = real.Date.UTC
173
+ W.Date = VDate
174
+
175
+ // ---- seeded randomness ----------------------------------------------------
176
+
177
+ let seed = 0
178
+ let byteState = 0
179
+ function seedRandom(s) {
180
+ seed = s >>> 0
181
+ byteState = (seed ^ 0x9e3779b9) >>> 0
182
+ let state = seed
183
+ Math.random = function () {
184
+ state = (state + 0x6d2b79f5) >>> 0
185
+ let x = state
186
+ x = Math.imul(x ^ (x >>> 15), x | 1)
187
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61)
188
+ return ((x ^ (x >>> 14)) >>> 0) / 4294967296
189
+ }
190
+ }
191
+ // crypto.getRandomValues from its own seeded stream, so ids made with it
192
+ // (nanoid, uuid v4) come out the same on replay without shifting Math.random.
193
+ function nextByteWord() {
194
+ byteState = (byteState + 0x6d2b79f5) >>> 0
195
+ let x = byteState
196
+ x = Math.imul(x ^ (x >>> 15), x | 1)
197
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61)
198
+ return (x ^ (x >>> 14)) >>> 0
199
+ }
200
+ if (W.crypto && crypto.getRandomValues) {
201
+ const realGetRandomValues = crypto.getRandomValues.bind(crypto)
202
+ crypto.getRandomValues = function (arr) {
203
+ if (!arr || !ArrayBuffer.isView(arr) || isExempt()) return realGetRandomValues(arr)
204
+ if (arr.byteLength > 65536) throw new DOMException("The ArrayBufferView's byte length exceeds 65536", "QuotaExceededError")
205
+ const bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength)
206
+ for (let i = 0; i < bytes.length; i += 4) {
207
+ const w = nextByteWord()
208
+ for (let j = 0; j < 4 && i + j < bytes.length; j++) bytes[i + j] = (w >>> (j * 8)) & 255
209
+ }
210
+ return arr
211
+ }
212
+ }
213
+ if (W.crypto && crypto.randomUUID) {
214
+ crypto.randomUUID = /** @type {Crypto["randomUUID"]} */ (function () {
215
+ const h = () => Math.floor(Math.random() * 16).toString(16)
216
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) =>
217
+ c === "x" ? h() : ((Math.random() * 4) | 8).toString(16),
218
+ )
219
+ })
220
+ }
221
+
222
+ // ---- event-loop yield (no 4ms clamp, unlike setTimeout) -------------------
223
+
224
+ const yieldChannel = new MessageChannel()
225
+ const yieldQueue = []
226
+ yieldChannel.port1.onmessage = () => yieldQueue.shift()()
227
+ function yieldTask() {
228
+ return new Promise((resolve) => {
229
+ yieldQueue.push(resolve)
230
+ yieldChannel.port2.postMessage(0)
231
+ })
232
+ }
233
+ // React's scheduler (and similar) queue work through MessageChannel. Counting
234
+ // in-flight messages on app channels tells us when the app has gone idle, so
235
+ // time only advances once the previous moment's renders and effects are done.
236
+ // Recording and replay both wait this way, which is what makes replay exact.
237
+ let appMessages = 0
238
+ const appPorts = new WeakSet()
239
+ const RealChannel = W.MessageChannel
240
+ W.MessageChannel = function MessageChannel() {
241
+ const ch = new RealChannel()
242
+ appPorts.add(ch.port1)
243
+ appPorts.add(ch.port2)
244
+ return ch
245
+ }
246
+ W.MessageChannel.prototype = RealChannel.prototype
247
+ const MPP = MessagePort.prototype
248
+ const portPost = MPP.postMessage
249
+ MPP.postMessage = function (...args) {
250
+ if (appPorts.has(this)) appMessages++
251
+ return portPost.apply(this, args)
252
+ }
253
+ const portOnMessage = Object.getOwnPropertyDescriptor(MPP, "onmessage")
254
+ Object.defineProperty(MPP, "onmessage", {
255
+ configurable: true,
256
+ get() {
257
+ return portOnMessage.get.call(this)
258
+ },
259
+ set(fn) {
260
+ if (!appPorts.has(this) || typeof fn !== "function") return portOnMessage.set.call(this, fn)
261
+ portOnMessage.set.call(this, function (e) {
262
+ if (appMessages > 0) appMessages--
263
+ return fn.call(this, e)
264
+ })
265
+ },
266
+ })
267
+
268
+ const stats = { settles: 0, stuck: 0, yields: 0, idbWaits: 0, fastSettles: 0 }
269
+ // Set when a replay hands the app something that may finish in real time (a
270
+ // network reply, a worker message, media readiness): the next settle then
271
+ // yields a real task, whatever else is true.
272
+ let dispatchedSinceYield = true
273
+ // Wait until the app is idle: no pending MessageChannel work (React's
274
+ // scheduler) and no IndexedDB work or replayed script load in flight (they
275
+ // answer in real time).
276
+ // fastOk (replay only): when the app has nothing queued, a few microtask turns
277
+ // do instead of a task round trip, which is most of a canvas-heavy page's
278
+ // rebuild time. The dock's `fastReplay = false` turns it off (a kill switch).
279
+ async function settle(fastOk) {
280
+ stats.settles++
281
+ if (fastOk && clock.seeking && !dispatchedSinceYield && appMessages === 0 && !otherBusy() && !(shell && shell.fastReplay === false)) {
282
+ for (let i = 0; i < 8; i++) await null
283
+ if (appMessages === 0) {
284
+ stats.fastSettles++
285
+ return
286
+ }
287
+ }
288
+ const deadline = real.perfNow() + 2000
289
+ for (let round = 0; round < 20; round++) {
290
+ let i = 0
291
+ for (; i < 60; i++) {
292
+ await yieldTask()
293
+ dispatchedSinceYield = false
294
+ stats.yields++
295
+ if (appMessages === 0) break
296
+ }
297
+ if (i === 60) {
298
+ stats.stuck++
299
+ appMessages = 0 // lost count (a port we can't see); don't stall forever
300
+ }
301
+ if (!otherBusy() || real.perfNow() > deadline) return
302
+ stats.idbWaits++
303
+ while (otherBusy() && real.perfNow() < deadline) await new Promise((r) => real.setTimeout(r, 0))
304
+ }
305
+ }
306
+ // Work that answers in real time and that time waits for: IndexedDB requests
307
+ // (32-state.js) and, replaying, scripts the recording loaded at this moment (36-scripts.js).
308
+ const otherBusy = () => (typeof idbBusy !== "undefined" && idbBusy > 0) || (typeof scriptBusy !== "undefined" && scriptBusy > 0)
@@ -0,0 +1,362 @@
1
+ // Web Animations + CSS animations/transitions. Every animation is held paused
2
+ // underneath and its currentTime is driven from the virtual clock, so pausing,
3
+ // slow motion and seeking all apply. App-facing calls (play, pause, startTime…)
4
+ // are translated so libraries like motion still see a normal running animation.
5
+
6
+ const managed = new Map() // Animation -> { t, v, userPaused, done }
7
+ const AP = Animation.prototype
8
+ const orig = {
9
+ play: AP.play,
10
+ pause: AP.pause,
11
+ finish: AP.finish,
12
+ cancel: AP.cancel,
13
+ reverse: AP.reverse,
14
+ updatePlaybackRate: AP.updatePlaybackRate,
15
+ currentTime: Object.getOwnPropertyDescriptor(AP, "currentTime"),
16
+ startTime: Object.getOwnPropertyDescriptor(AP, "startTime"),
17
+ playbackRate: Object.getOwnPropertyDescriptor(AP, "playbackRate"),
18
+ playState: Object.getOwnPropertyDescriptor(AP, "playState"),
19
+ animate: Element.prototype.animate,
20
+ }
21
+ const rateOf = (a) => orig.playbackRate.get.call(a)
22
+ const stateOf = (a) => orig.playState.get.call(a)
23
+ const endOf = (a) => (a.effect ? a.effect.getComputedTiming().endTime : Infinity)
24
+
25
+ // created: the app just made it (element.animate()), rather than it being
26
+ // found on a scan (a CSS transition or animation, seen a frame after its cause).
27
+ function adopt(a, created) {
28
+ if (managed.has(a)) return managed.get(a)
29
+ const ps = stateOf(a)
30
+ if (ps === "idle") return null
31
+ // A running animation seen for the first time starts from 0 at this
32
+ // boundary, whatever real time the browser gave it before we saw it: live,
33
+ // a CSS transition has usually run a few real ms by then, in a rebuild
34
+ // none, and the two must agree.
35
+ // Replaying: if the recording has this animation, it starts when it did
36
+ // live. (Live, which frame first sees a new CSS transition races with the
37
+ // browser's own scheduling; the recorded start settles it.)
38
+ const recorded = ps === "running" && (hasFuture() || clock.seeking) ? recordedStart(a) : null
39
+ const s = {
40
+ t: recorded != null ? (clock.now - recorded) * (rateOf(a) || 1) : ps === "running" ? 0 : Number(orig.currentTime.get.call(a)) || 0,
41
+ v: clock.now,
42
+ userPaused: ps === "paused",
43
+ done: ps === "finished",
44
+ native: false,
45
+ checks: 0,
46
+ }
47
+ managed.set(a, s)
48
+ if (!s.done && !s.userPaused) orig.pause.call(a)
49
+ // The browser drops finished animations once a later one covers them; the
50
+ // timeline still needs them to show earlier moments.
51
+ if (a.persist) a.persist()
52
+ logAnim(a, s, !!created)
53
+ return s
54
+ }
55
+
56
+ // A CSS animation paused via `animation-play-state` stays frozen.
57
+ function cssPaused(a) {
58
+ if (typeof CSSAnimation === "undefined" || !(a instanceof CSSAnimation)) return false
59
+ const el = a.effect && /** @type {KeyframeEffect} */ (a.effect).target
60
+ if (!el) return false
61
+ const cs = getComputedStyle(el)
62
+ const names = cs.animationName.split(", ")
63
+ const states = cs.animationPlayState.split(", ")
64
+ const i = names.indexOf(a.animationName)
65
+ return i >= 0 && states[i % states.length] === "paused"
66
+ }
67
+
68
+ function catchUp(a, s) {
69
+ if (!s.done && !s.userPaused && !cssPaused(a)) s.t += (clock.now - s.v) * rateOf(a)
70
+ s.v = clock.now
71
+ }
72
+
73
+ let syncCount = 0
74
+ // scan: look for new animations too. A new CSS animation needs a style
75
+ // change, which comes from a DOM change, input, a timer or a frame callback;
76
+ // when none of those happened, only the running ones need driving.
77
+ function syncAnimations(scan = true) {
78
+ // While previewing another moment, the preview owns every animation.
79
+ if (previewing) return
80
+ syncSmil(clock.now)
81
+ if (scan) for (const a of document.getAnimations()) adopt(a)
82
+ // Finished animations are only checked for removal now and then; a long
83
+ // session collects hundreds of them.
84
+ const sweep = ++syncCount % 30 === 0
85
+ for (const [a, s] of managed) {
86
+ if (s.done && !sweep) continue
87
+ if (stateOf(a) === "idle") {
88
+ managed.delete(a)
89
+ endAnim(a)
90
+ continue
91
+ }
92
+ if (s.done) continue
93
+ catchUp(a, s)
94
+ const rate = rateOf(a)
95
+ // An endless loop (a spinner, a shimmer) runs natively on the compositor
96
+ // while recording at normal pace, lined up with the virtual clock and
97
+ // re-lined if it drifts; the clock takes it back on pause, seek or preview.
98
+ const endless = endOf(a) === Infinity && !s.userPaused && !cssPaused(a)
99
+ const native = endless && clock.playing && !clock.seeking && clock.rate === 1 && !hasFuture()
100
+ if (native) {
101
+ if (!s.native) {
102
+ orig.currentTime.set.call(a, s.t)
103
+ orig.play.call(a)
104
+ orig.currentTime.set.call(a, s.t)
105
+ s.native = true
106
+ } else if (++s.checks % 30 === 0 && Math.abs((Number(orig.currentTime.get.call(a)) || 0) - s.t) > 20) {
107
+ orig.currentTime.set.call(a, s.t)
108
+ }
109
+ continue
110
+ }
111
+ if (s.native) {
112
+ orig.pause.call(a)
113
+ s.native = false
114
+ }
115
+ s.checks = 0
116
+ if ((rate > 0 && s.t >= endOf(a)) || (rate < 0 && s.t <= 0)) {
117
+ finishNow(a, s)
118
+ } else {
119
+ orig.currentTime.set.call(a, s.t)
120
+ }
121
+ }
122
+ }
123
+
124
+ // ---- SMIL ---------------------------------------------------------------------
125
+ // SVG's own animations (<animate>, <set>, <animateMotion>, <animateTransform>)
126
+ // aren't on the document's animation timeline: they run on their outermost
127
+ // <svg>'s clock, from when it came onto the page. Each of those clocks is held
128
+ // paused and set from the virtual one, so pausing pauses them, a rebuild lands
129
+ // them where they were, and a preview shows them at t (instead of every frame
130
+ // swapped in starting them again from 0).
131
+ const SVG_NS = "http://www.w3.org/2000/svg"
132
+ const SMIL = "animate, set, animateMotion, animateTransform"
133
+ const smilSvgs = new Set() // outermost <svg>s with SMIL animations in them, as found
134
+ const smilBegin = new WeakMap() // outermost <svg> -> the virtual ms its clock started at
135
+ const smilShown = new WeakMap() // outermost <svg> -> the clock time (s) last set
136
+ // Look for SMIL in a subtree: the document at boot, then what the app adds
137
+ // (logMutations), so nothing walks the whole page every frame.
138
+ function findSmil(root) {
139
+ if (!root || !root.querySelectorAll || (root.nodeType === 1 && root.namespaceURI !== SVG_NS && !root.firstElementChild)) return
140
+ let els
141
+ try {
142
+ els = [...root.querySelectorAll(SMIL)]
143
+ if (root.nodeType === 1 && root.matches(SMIL)) els.push(root)
144
+ } catch {
145
+ return
146
+ }
147
+ for (const el of els) {
148
+ if (el.namespaceURI !== SVG_NS) continue
149
+ let svg = el.ownerSVGElement
150
+ while (svg && svg.ownerSVGElement) svg = svg.ownerSVGElement
151
+ if (svg) smilSvgs.add(svg)
152
+ }
153
+ }
154
+ // Show every SMIL clock at virtual time `at` (only those inside `scope`).
155
+ // A clock seen for the first time starts at `begin`: the page's start for the
156
+ // page's own svgs (at boot), else now (one the app put in since).
157
+ function syncSmil(at, scope, begin = clock.now) {
158
+ if (!smilSvgs.size) return
159
+ for (const svg of smilSvgs) {
160
+ if (!svg.isConnected || (scope && !scope.contains(svg))) continue
161
+ let b = smilBegin.get(svg)
162
+ if (b == null) {
163
+ if (previewing) continue // never seen live: nothing known to show
164
+ smilBegin.set(svg, (b = begin))
165
+ try {
166
+ svg.pauseAnimations()
167
+ } catch {}
168
+ }
169
+ if (clock.seeking) continue // set where the seek lands
170
+ const sec = Math.max(0, at - b) / 1000
171
+ if (smilShown.get(svg) === sec) continue
172
+ smilShown.set(svg, sec)
173
+ try {
174
+ svg.setCurrentTime(sec)
175
+ } catch {}
176
+ }
177
+ }
178
+
179
+ // The clock stopped or jumped: endless loops come back under its control now.
180
+ function reclaimNative() {
181
+ for (const [a, s] of managed) {
182
+ if (!s.native) continue
183
+ orig.pause.call(a)
184
+ s.native = false
185
+ orig.currentTime.set.call(a, s.t)
186
+ }
187
+ }
188
+
189
+ Element.prototype.animate = function (...args) {
190
+ const a = orig.animate.apply(this, args)
191
+ adopt(a, true)
192
+ return a
193
+ }
194
+
195
+ AP.play = function () {
196
+ const s = managed.get(this)
197
+ if (!s) return orig.play.call(this)
198
+ catchUp(this, s)
199
+ const rate = rateOf(this)
200
+ if (rate > 0 && (s.done || s.t >= endOf(this))) s.t = 0
201
+ if (rate < 0 && (s.done || s.t <= 0)) s.t = endOf(this)
202
+ s.done = false
203
+ s.userPaused = false
204
+ orig.play.call(this) // renews the `finished` promise
205
+ orig.pause.call(this)
206
+ orig.currentTime.set.call(this, s.t)
207
+ }
208
+ AP.pause = function () {
209
+ const s = managed.get(this)
210
+ if (!s) return orig.pause.call(this)
211
+ catchUp(this, s)
212
+ s.userPaused = true
213
+ }
214
+ AP.finish = function () {
215
+ const s = managed.get(this)
216
+ if (!s) return orig.finish.call(this)
217
+ finishNow(this, s)
218
+ }
219
+ AP.cancel = function () {
220
+ managed.delete(this)
221
+ endAnim(this)
222
+ return orig.cancel.call(this)
223
+ }
224
+ AP.reverse = function () {
225
+ const s = managed.get(this)
226
+ if (!s) return orig.reverse.call(this)
227
+ catchUp(this, s)
228
+ orig.playbackRate.set.call(this, -rateOf(this))
229
+ s.t = Math.min(Math.max(s.t, 0), endOf(this))
230
+ s.done = false
231
+ s.userPaused = false
232
+ }
233
+ AP.updatePlaybackRate = function (r) {
234
+ const s = managed.get(this)
235
+ if (!s) return orig.updatePlaybackRate.call(this, r)
236
+ catchUp(this, s)
237
+ orig.playbackRate.set.call(this, r)
238
+ }
239
+
240
+ Object.defineProperty(AP, "currentTime", {
241
+ configurable: true,
242
+ get() {
243
+ return orig.currentTime.get.call(this)
244
+ },
245
+ set(value) {
246
+ const s = managed.get(this)
247
+ if (s && value != null) {
248
+ s.t = Number(value)
249
+ s.v = clock.now
250
+ s.done = false
251
+ }
252
+ orig.currentTime.set.call(this, value)
253
+ },
254
+ })
255
+ Object.defineProperty(AP, "startTime", {
256
+ configurable: true,
257
+ get() {
258
+ const s = managed.get(this)
259
+ if (!s) return orig.startTime.get.call(this)
260
+ catchUp(this, s)
261
+ return clock.now + PERF_BASE - s.t / (rateOf(this) || 1)
262
+ },
263
+ set(value) {
264
+ const s = managed.get(this) || adopt(this, true)
265
+ if (!s || value == null) return orig.startTime.set.call(this, value)
266
+ s.t = (clock.now + PERF_BASE - Number(value)) * rateOf(this)
267
+ s.v = clock.now
268
+ s.done = false
269
+ s.userPaused = false
270
+ orig.currentTime.set.call(this, s.t)
271
+ },
272
+ })
273
+ Object.defineProperty(AP, "playbackRate", {
274
+ configurable: true,
275
+ get() {
276
+ return rateOf(this)
277
+ },
278
+ set(value) {
279
+ const s = managed.get(this)
280
+ if (s) catchUp(this, s)
281
+ orig.playbackRate.set.call(this, value)
282
+ },
283
+ })
284
+ Object.defineProperty(AP, "playState", {
285
+ configurable: true,
286
+ get() {
287
+ const s = managed.get(this)
288
+ const ps = stateOf(this)
289
+ if (!s || ps === "idle" || ps === "finished") return ps
290
+ return s.userPaused ? "paused" : "running"
291
+ },
292
+ })
293
+
294
+ // ---- deterministic end events -----------------------------------------------
295
+ // The browser delivers `finish`, `animationend` and `transitionend` on its next
296
+ // rendering step, i.e. at a real-time moment. We fire them ourselves, right when
297
+ // the virtual clock finishes the animation, and drop the browser's copies.
298
+
299
+ const finishHandlers = new WeakMap() // Animation -> { prop, listeners: Set }
300
+ const handlersOf = (a) => {
301
+ if (!finishHandlers.has(a)) finishHandlers.set(a, { prop: null, listeners: new Set() })
302
+ return finishHandlers.get(a)
303
+ }
304
+ Object.defineProperty(AP, "onfinish", {
305
+ configurable: true,
306
+ get() {
307
+ return finishHandlers.get(this)?.prop ?? null
308
+ },
309
+ set(fn) {
310
+ handlersOf(this).prop = typeof fn === "function" ? fn : null
311
+ },
312
+ })
313
+ const animAdd = AP.addEventListener
314
+ const animRemove = AP.removeEventListener
315
+ AP.addEventListener = function (type, fn, opts) {
316
+ if (type !== "finish" || !fn) return animAdd.call(this, type, fn, opts)
317
+ handlersOf(this).listeners.add(fn)
318
+ }
319
+ AP.removeEventListener = function (type, fn, opts) {
320
+ if (type !== "finish") return animRemove.call(this, type, fn, opts)
321
+ finishHandlers.get(this)?.listeners.delete(fn)
322
+ }
323
+
324
+ let synthesizing = 0
325
+ function finishNow(a, s) {
326
+ if (s.done) return
327
+ s.done = true
328
+ orig.finish.call(a)
329
+ const h = finishHandlers.get(a)
330
+ const ev = new AnimationPlaybackEvent("finish", { currentTime: a.currentTime, timelineTime: clock.now + PERF_BASE })
331
+ if (h) {
332
+ for (const fn of [h.prop, ...h.listeners]) {
333
+ if (!fn) continue
334
+ safeCall(typeof fn === "function" ? fn : fn.handleEvent.bind(fn), [ev])
335
+ }
336
+ }
337
+ const target = a.effect && a.effect.target
338
+ if (!target) return
339
+ let dom = null
340
+ if (typeof CSSAnimation !== "undefined" && a instanceof CSSAnimation) {
341
+ dom = new AnimationEvent("animationend", { bubbles: true, animationName: a.animationName, elapsedTime: endOf(a) / 1000 })
342
+ } else if (typeof CSSTransition !== "undefined" && a instanceof CSSTransition) {
343
+ dom = new TransitionEvent("transitionend", { bubbles: true, propertyName: a.transitionProperty, elapsedTime: endOf(a) / 1000 })
344
+ }
345
+ if (!dom) return
346
+ synthesizing++
347
+ try {
348
+ target.dispatchEvent(dom)
349
+ } finally {
350
+ synthesizing--
351
+ }
352
+ }
353
+
354
+ for (const type of ["animationend", "transitionend"]) {
355
+ W.addEventListener(
356
+ type,
357
+ (e) => {
358
+ if (e.isTrusted && !synthesizing) e.stopImmediatePropagation()
359
+ },
360
+ true,
361
+ )
362
+ }