pymations 0.0.0-stage → 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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -2
  3. package/THIRD-PARTY-NOTICES +240 -0
  4. package/engine/LICENSE-polygon-clipping.txt +11 -0
  5. package/engine/anim.js +9 -0
  6. package/engine/fonts/KaTeX_AMS-Regular.ttf +0 -0
  7. package/engine/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  8. package/engine/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  9. package/engine/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  10. package/engine/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  11. package/engine/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  12. package/engine/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  13. package/engine/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  14. package/engine/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  15. package/engine/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  16. package/engine/fonts/KaTeX_Main-Bold.ttf +0 -0
  17. package/engine/fonts/KaTeX_Main-Bold.woff2 +0 -0
  18. package/engine/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  19. package/engine/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. package/engine/fonts/KaTeX_Main-Italic.ttf +0 -0
  21. package/engine/fonts/KaTeX_Main-Italic.woff2 +0 -0
  22. package/engine/fonts/KaTeX_Main-Regular.ttf +0 -0
  23. package/engine/fonts/KaTeX_Main-Regular.woff2 +0 -0
  24. package/engine/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  25. package/engine/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  26. package/engine/fonts/KaTeX_Math-Italic.ttf +0 -0
  27. package/engine/fonts/KaTeX_Math-Italic.woff2 +0 -0
  28. package/engine/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  29. package/engine/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  30. package/engine/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  31. package/engine/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  32. package/engine/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  33. package/engine/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  34. package/engine/fonts/KaTeX_Script-Regular.ttf +0 -0
  35. package/engine/fonts/KaTeX_Script-Regular.woff2 +0 -0
  36. package/engine/fonts/KaTeX_Size1-Regular.ttf +0 -0
  37. package/engine/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  38. package/engine/fonts/KaTeX_Size2-Regular.ttf +0 -0
  39. package/engine/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  40. package/engine/fonts/KaTeX_Size3-Regular.ttf +0 -0
  41. package/engine/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  42. package/engine/fonts/KaTeX_Size4-Regular.ttf +0 -0
  43. package/engine/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  44. package/engine/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  45. package/engine/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  46. package/engine/fonts/LICENSE_KATEX_FONTS.txt +106 -0
  47. package/engine/fonts/LICENSE_LIBERATION.txt +102 -0
  48. package/engine/fonts/LiberationSans-Bold.ttf +0 -0
  49. package/engine/fonts/LiberationSans-BoldItalic.ttf +0 -0
  50. package/engine/fonts/LiberationSans-Italic.ttf +0 -0
  51. package/engine/fonts/LiberationSans-Regular.ttf +0 -0
  52. package/engine/katex.min.css +1 -0
  53. package/engine/katex.min.js +1 -0
  54. package/engine/manifest.json +1 -0
  55. package/engine/opentype.min.js +2 -0
  56. package/package.json +34 -4
  57. package/pyctures.py +897 -0
  58. package/pymations.js +1818 -0
  59. package/pymations.py +5113 -0
package/pymations.js ADDED
@@ -0,0 +1,1818 @@
1
+ /**
2
+ * Pymations, the browser half.
3
+ *
4
+ * One file, used three ways:
5
+ * - `pymations run` the page talks to your own Python over a WebSocket
6
+ * - `pymations build` the page runs Python itself, with Pyodide, in a Web
7
+ * Worker (a folder, or one self-contained .html file)
8
+ * - on any web page `<div data-pymations-src="scene.py"></div>` plus
9
+ * `<script type="module" src=".../pymations.js"></script>`
10
+ *
11
+ * It is the Pyctures browser half (the same author, MIT) with the animation
12
+ * layer added. Beside this file:
13
+ * pyctures.py the Pyctures runtime the Python half sits on
14
+ * pymations.py the Python half (also the module `manim` in these pages)
15
+ * engine/ the animation engine: anim.js, KaTeX, opentype.js, fonts
16
+ *
17
+ * THE RENDERER keeps a handle table (number -> JavaScript value), replays the
18
+ * commands the Python half records, answers reads, and reports input. Fixed
19
+ * handles (they must match the Python half): 1 window, 2 canvas, 4 the 2D
20
+ * context (made on first use), 6 the animation engine.
21
+ *
22
+ * THE ANIMATION. Python only describes the scene: `play()` appends to a
23
+ * timeline in the engine and returns. The page owns the timeline's clock:
24
+ * each scene has a player that draws the engine's picture at time t on its own
25
+ * canvas, above the program's canvas, with a scrubber (play/pause, the
26
+ * sections as ticks, the time). A scene with no Python loop is idle in Python
27
+ * while it plays; a scene with one (draw(), await frame()) keeps running, and
28
+ * the two share the page.
29
+ *
30
+ * THE PAGE'S MAIN THREAD only draws: Python runs in your own Python (`run`) or
31
+ * in a worker (`build`), so a slow frame, Python's garbage collection or a
32
+ * stuck loop never holds up drawing or input.
33
+ *
34
+ * THE HOST LOOP owns requestAnimationFrame. Each refresh, if Python is live
35
+ * and free and fewer than two frames are waiting to be shown, it asks Python
36
+ * for the next frame (events and read answers in, commands out).
37
+ *
38
+ * A scene starts when it first scrolls into view, and pauses while it is out
39
+ * of view or the tab is hidden, so a page with several scenes stays light.
40
+ *
41
+ * IMAGES BY RELATIVE PATH (`load_image("pictures/photo.png")`) are relative to
42
+ * the scene's file: the packed copy in a single-file build, or the file beside
43
+ * the scene otherwise.
44
+ *
45
+ * Author: John Wylie. MIT licence.
46
+ */
47
+
48
+ const BASE = new URL('./', import.meta.url)
49
+ export const PYODIDE_URL = 'https://cdn.jsdelivr.net/pyodide/v0.29.4/full/'
50
+ const STUCK_MS = 3000
51
+ const MEASURE_KEY = 'pymations:measure'
52
+ /** A file that uses the animation module (the same test `pages.uses_anim` makes). */
53
+ export const ANIM_IMPORT = /^[ \t]*(?:from|import)[ \t]+(?:pymations|manim)\b/m
54
+
55
+ // ---------------------------------------------------------------- renderer
56
+
57
+ const KEEP_STATE = ['fillStyle', 'strokeStyle', 'lineWidth', 'lineCap', 'lineJoin', 'miterLimit', 'font', 'textAlign', 'textBaseline', 'globalAlpha', 'globalCompositeOperation', 'shadowBlur', 'shadowColor', 'shadowOffsetX', 'shadowOffsetY', 'imageSmoothingEnabled']
58
+ const camelCache = Object.create(null)
59
+
60
+ // fill_rect -> fillRect, but only when the snake name is not itself real.
61
+ function prop(obj, name) {
62
+ if (obj != null && !(name in Object(obj)) && name.indexOf('_') > 0) {
63
+ const c = camelCache[name] || (camelCache[name] = name.replace(/_([a-z0-9])/g, (_, ch) => ch.toUpperCase()))
64
+ if (c in Object(obj)) return c
65
+ }
66
+ return name
67
+ }
68
+
69
+ // ---------------------------------------------------------------- images by relative path
70
+
71
+ /** A relative path to a picture, a sound or a font file: no scheme, not absolute, a known extension (pages.IMAGE_PATH). */
72
+ const IMAGE_PATH = /^(?![a-z][a-z0-9+.-]*:|\/)(?:\.\/)?[^\s"'<>?#]+\.(png|jpe?g|gif|webp|svg|bmp|avif|mp3|wav|ogg|m4a|aac|flac|ttf|otf|woff2?)$/i
73
+ export function isRelativeImagePath(s) { return typeof s === 'string' && s.length < 300 && IMAGE_PATH.test(s) }
74
+ export const IMAGE_TYPES = {
75
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
76
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', avif: 'image/avif',
77
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac',
78
+ ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2',
79
+ }
80
+ /** The key an image is packed under: as written, without a leading "./". */
81
+ const packedKey = (s) => s.replace(/^(\.\/)+/, '')
82
+
83
+ /**
84
+ * Where a scene's relative image paths point: `packed` ({path: base64}, a
85
+ * single-file page's images) first, then the file beside the scene. Returns
86
+ * null when nothing needs changing (no packed images, and the scene is in the
87
+ * page's own folder), so most scenes pay nothing.
88
+ */
89
+ export function imageResolver(src, packed) {
90
+ const keys = packed ? Object.keys(packed).filter(isRelativeImagePath) : []
91
+ const scene = new URL(src || '', location.href)
92
+ const besidePage = new URL('./', scene).href === new URL('./', location.href).href
93
+ if (!keys.length && besidePage) return null
94
+ const done = new Map()
95
+ return (s) => {
96
+ if (!isRelativeImagePath(s)) return undefined
97
+ let url = done.get(s)
98
+ if (url === undefined) {
99
+ const key = packedKey(s)
100
+ if (packed && packed[key] != null) url = 'data:' + (IMAGE_TYPES[key.split('.').pop().toLowerCase()] || 'application/octet-stream') + ';base64,' + packed[key]
101
+ else url = besidePage ? null : new URL(s, scene).href
102
+ done.set(s, url)
103
+ }
104
+ return url || undefined
105
+ }
106
+ }
107
+
108
+ /** Swap every string in a frame's commands that `resolve` maps (in place). */
109
+ function swapStrings(v, resolve) {
110
+ for (let i = 0; i < v.length; i++) {
111
+ const x = v[i]
112
+ if (typeof x === 'string') { const u = resolve(x); if (u !== undefined) v[i] = u }
113
+ else if (x && typeof x === 'object') {
114
+ if (Array.isArray(x)) swapStrings(x, resolve)
115
+ else for (const k in x) {
116
+ const y = x[k]
117
+ if (typeof y === 'string') { const u = resolve(y); if (u !== undefined) x[k] = u }
118
+ else if (y && typeof y === 'object') swapStrings(Array.isArray(y) ? y : [y], resolve)
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The renderer for one canvas. `anim`: the scene's animation engine (handle
126
+ * 6), or null. `images`: a function from a string in the commands to the URL
127
+ * to use instead (see imageResolver), or null. `hooks`: onEvent(event),
128
+ * onSize(size), onApplied({reads, errors, ms, n, timing}), onReads(reads) (a
129
+ * promise has settled), isActive() (has the keyboard).
130
+ */
131
+ export function createRenderer(canvas, { anim = null, images = null, hooks }) {
132
+ const H = new Map()
133
+ let ctx2d = null
134
+ let dpr = 1, cssW = 0, cssH = 0
135
+ const reportedErrors = Object.create(null)
136
+ let timing = null
137
+ let pointerIsDown = false
138
+
139
+ function size() {
140
+ dpr = window.devicePixelRatio || 1
141
+ const r = canvas.getBoundingClientRect()
142
+ cssW = Math.max(1, Math.round(r.width)); cssH = Math.max(1, Math.round(r.height))
143
+ // The 2D context is scaled, so drawing is in CSS pixels and stays sharp.
144
+ {
145
+ const w = Math.round(cssW * dpr), h = Math.round(cssH * dpr)
146
+ if (canvas.width !== w || canvas.height !== h) {
147
+ // Resizing a canvas wipes it and resets its drawing state. A program
148
+ // that drew once (no draw()) would be left blank, so the picture is
149
+ // carried across, and the state the program set is put back.
150
+ let keep = null, state = null
151
+ if (ctx2d && canvas.width && canvas.height) {
152
+ keep = document.createElement('canvas')
153
+ keep.width = canvas.width; keep.height = canvas.height
154
+ keep.getContext('2d').drawImage(canvas, 0, 0)
155
+ state = {}
156
+ for (const k of KEEP_STATE) state[k] = ctx2d[k]
157
+ }
158
+ canvas.width = w; canvas.height = h
159
+ if (ctx2d) {
160
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0)
161
+ if (keep) ctx2d.drawImage(keep, 0, 0)
162
+ ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0)
163
+ if (state) for (const k in state) { try { ctx2d[k] = state[k] } catch (e) { /* read-only in this browser */ } }
164
+ }
165
+ }
166
+ }
167
+ hooks.onSize({ width: cssW, height: cssH, pixel_ratio: dpr })
168
+ }
169
+
170
+ function getCtx() {
171
+ if (!ctx2d) {
172
+ ctx2d = canvas.getContext('2d')
173
+ if (ctx2d) ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0)
174
+ }
175
+ return ctx2d
176
+ }
177
+
178
+ function lookup(h) {
179
+ if (h === 4) return getCtx()
180
+ if ((h === 3 || h === 5) && !H.has(h)) throw new Error('3D (THREE) is not part of Pymations pages; use the pyctures package for 3D scenes')
181
+ if (h === 6 && !H.has(h)) throw new Error('the animation engine is not loaded on this page')
182
+ if (!H.has(h)) throw new Error('that object no longer exists in the page (#' + h + ')')
183
+ return H.get(h)
184
+ }
185
+
186
+ function decode(v) {
187
+ if (v === null || typeof v !== 'object') return v
188
+ if (Array.isArray(v)) return v.map(decode)
189
+ if ('$h' in v) return lookup(v.$h)
190
+ if ('$e' in v) return evalExpr(v.$e)
191
+ if ('$cb' in v) {
192
+ const id = v.$cb
193
+ return (ev) => hooks.onEvent({ k: 'cb', id, e: summarize(ev) })
194
+ }
195
+ if ('$d' in v) {
196
+ const o = {}; for (const k in v.$d) o[k] = decode(v.$d[k]); return o
197
+ }
198
+ return v
199
+ }
200
+
201
+ function evalExpr(t) {
202
+ switch (t[0]) {
203
+ case 'v': return decode(t[1])
204
+ case 'num': return Number(t[1])
205
+ case 'ref': return decode(t[1])
206
+ case 'get': { const o = evalExpr(t[1]); return o[prop(o, t[2])] }
207
+ case 'neg': return -evalExpr(t[1])
208
+ case '+': return evalExpr(t[1]) + evalExpr(t[2])
209
+ case '-': return evalExpr(t[1]) - evalExpr(t[2])
210
+ case '*': return evalExpr(t[1]) * evalExpr(t[2])
211
+ case '/': return evalExpr(t[1]) / evalExpr(t[2])
212
+ case '%': return evalExpr(t[1]) % evalExpr(t[2])
213
+ case '**': return Math.pow(evalExpr(t[1]), evalExpr(t[2]))
214
+ case '//': return Math.floor(evalExpr(t[1]) / evalExpr(t[2]))
215
+ }
216
+ throw new Error('unknown expression ' + t[0])
217
+ }
218
+
219
+ // What Python gets back for a read or an event: plain data only.
220
+ function summarize(v, depth) {
221
+ depth = depth || 0
222
+ if (v === null || v === undefined) return null
223
+ const t = typeof v
224
+ if (t === 'number' || t === 'string' || t === 'boolean') return v
225
+ if (t === 'function') return '<function>'
226
+ if (Array.isArray(v) || ArrayBuffer.isView(v)) {
227
+ if (depth > 2) return null
228
+ const a = []; for (let i = 0; i < Math.min(v.length, 100000); i++) a.push(summarize(v[i], depth + 1)); return a
229
+ }
230
+ if (depth > 1) return null
231
+ const out = {}
232
+ if (typeof Event !== 'undefined' && v instanceof Event) {
233
+ const r = canvas.getBoundingClientRect()
234
+ if ('clientX' in v) { out.x = v.clientX - r.left; out.y = v.clientY - r.top }
235
+ }
236
+ for (const k in v) {
237
+ let x; try { x = v[k] } catch (e) { continue }
238
+ const tx = typeof x
239
+ if (x === null || tx === 'number' || tx === 'string' || tx === 'boolean') out[k] = x
240
+ else if (depth === 0 && tx === 'object' && !(x instanceof Node) && !(x instanceof Window)) {
241
+ const s = summarize(x, depth + 1); if (s !== null) out[k] = s
242
+ }
243
+ }
244
+ return out
245
+ }
246
+
247
+ function describe(obj, h) {
248
+ if (h === 4) return 'ctx'
249
+ if (h === 2) return 'canvas'
250
+ if (h === 6) return 'the animation engine'
251
+ if (h === 1) return 'js'
252
+ const n = obj && obj.constructor && obj.constructor.name
253
+ return n || 'this object'
254
+ }
255
+
256
+ function apply(cmds) {
257
+ const started = performance.now()
258
+ const reads = {}, errors = []
259
+ for (let i = 0; i < cmds.length; i++) {
260
+ const c = cmds[i]
261
+ try {
262
+ switch (c[0]) {
263
+ case 'get': { const o = lookup(c[3]); H.set(c[2], o[prop(o, c[4])]); break }
264
+ case 'set': { const o = lookup(c[2]); o[prop(o, c[3])] = decode(c[4]); break }
265
+ case 'call': {
266
+ const o = lookup(c[3]), name = prop(o, c[4]), fn = o == null ? undefined : o[name]
267
+ if (typeof fn !== 'function') throw new TypeError(describe(o, c[3]) + ' has no method "' + c[4] + '"')
268
+ const res = fn.apply(o, decode(c[5]))
269
+ if (c[2]) H.set(c[2], res)
270
+ break
271
+ }
272
+ case 'new': {
273
+ const o = lookup(c[3]), Ctor = o == null ? undefined : o[prop(o, c[4])]
274
+ if (typeof Ctor !== 'function') throw new TypeError(describe(o, c[3]) + ' has no "' + c[4] + '"')
275
+ const made = Reflect.construct(Ctor, decode(c[5]))
276
+ if (c[2]) H.set(c[2], made)
277
+ break
278
+ }
279
+ case 'fcall': { const f = lookup(c[3]); const r = f.apply(null, decode(c[4])); if (c[2]) H.set(c[2], r); break }
280
+ case 'fnew': { const F = lookup(c[3]); const m = Reflect.construct(F, decode(c[4])); if (c[2]) H.set(c[2], m); break }
281
+ case 'free': H.delete(c[2]); break
282
+ case 'read': {
283
+ const val = decode(c[3])
284
+ if (val && typeof val.then === 'function') settle(val, c[2], c[4], c[1])
285
+ else reads[c[2]] = summarize(val)
286
+ break
287
+ }
288
+ }
289
+ } catch (e) {
290
+ const msg = (e && e.message) || String(e)
291
+ const key = c[1] + '|' + msg
292
+ if (!reportedErrors[key]) { reportedErrors[key] = 1; errors.push({ line: c[1], message: (e && e.name ? e.name + ': ' : '') + msg }) }
293
+ if (c[0] === 'read') reads[c[2]] = null
294
+ }
295
+ }
296
+ hooks.onApplied({ reads, errors, ms: performance.now() - started, n: cmds.length, timing })
297
+ }
298
+
299
+ // A PROMISE is answered when it settles, in a later frame. An object result
300
+ // (a GPU adapter, a device) is kept under the handle Python reserved.
301
+ function settle(promise, rid, newH, line) {
302
+ promise.then((res) => {
303
+ const plain = res === null || res === undefined || typeof res !== 'object' || Array.isArray(res) ||
304
+ ArrayBuffer.isView(res) || Object.getPrototypeOf(res) === Object.prototype
305
+ let answer
306
+ if (plain || !newH) answer = summarize(res)
307
+ else { H.set(newH, res); answer = { $h: newH } }
308
+ if (hooks.onReads) hooks.onReads({ [rid]: answer })
309
+ }, (e) => {
310
+ if (hooks.onReads) hooks.onReads({ [rid]: { $error: 'line ' + line + ': ' + ((e && e.message) || String(e)) } })
311
+ })
312
+ }
313
+
314
+ function sendInput(e) {
315
+ const k = { type: e.type }
316
+ if (e.type === 'keydown' || e.type === 'keyup') {
317
+ if (!hooks.isActive()) return
318
+ if (e.repeat) return
319
+ k.key = e.key; k.code = e.code
320
+ if (e.key.indexOf('Arrow') === 0 || e.key === ' ') e.preventDefault()
321
+ } else {
322
+ if (e.type === 'pointerdown') { pointerIsDown = true; canvas.focus({ preventScroll: true }) }
323
+ else if (e.type === 'pointerup') { if (!pointerIsDown) return; pointerIsDown = false }
324
+ const r = canvas.getBoundingClientRect()
325
+ k.x = e.clientX - r.left; k.y = e.clientY - r.top; k.button = e.button
326
+ }
327
+ hooks.onEvent({ k: 'input', e: k })
328
+ }
329
+
330
+ H.set(1, window); H.set(2, canvas)
331
+ if (anim) H.set(6, anim)
332
+ window.addEventListener('keydown', sendInput)
333
+ window.addEventListener('keyup', sendInput)
334
+ canvas.addEventListener('pointerdown', sendInput)
335
+ canvas.addEventListener('pointermove', sendInput)
336
+ window.addEventListener('pointerup', sendInput)
337
+
338
+ // PRESENT ON THE VERTICAL BLANK. A frame's commands are held and drawn in
339
+ // the page's own animation frame, so every frame lands on a screen refresh.
340
+ // Batches are applied in order, all of them, because a batch can create
341
+ // objects a later one uses.
342
+ //
343
+ // ONE FRAME PER REFRESH, FROM A SHORT QUEUE. The host keeps a frame or two
344
+ // in hand, so a slow round trip to Python empties the queue instead of
345
+ // showing as a skipped refresh. If the queue ever runs long, it is caught up
346
+ // so latency cannot grow.
347
+ const pending = []
348
+ let scheduled = false
349
+ const abs = () => performance.timeOrigin + performance.now()
350
+ function present() {
351
+ scheduled = false
352
+ if (!pending.length) return
353
+ while (pending.length > 2) applyBatch(pending.shift())
354
+ applyBatch(pending.shift())
355
+ if (pending.length) { scheduled = true; requestAnimationFrame(present) }
356
+ }
357
+ function applyBatch(b) {
358
+ timing = b.at ? { deliver: b.recv - b.at, wait: abs() - b.recv } : null
359
+ apply(b.cmds)
360
+ }
361
+
362
+ const observer = new ResizeObserver(size)
363
+ observer.observe(canvas)
364
+ size()
365
+
366
+ return {
367
+ /** Queue one frame's commands; drawn on the next refresh. */
368
+ push(cmds, at) {
369
+ if (images && cmds) swapStrings(cmds, images)
370
+ pending.push({ cmds: cmds || [], at, recv: at ? abs() : 0 })
371
+ if (!scheduled) { scheduled = true; requestAnimationFrame(present) }
372
+ },
373
+ get size() { return { width: cssW, height: cssH, pixel_ratio: dpr } },
374
+ /** Frames waiting for a refresh. */
375
+ get waiting() { return pending.length },
376
+ destroy() {
377
+ observer.disconnect()
378
+ window.removeEventListener('keydown', sendInput)
379
+ window.removeEventListener('keyup', sendInput)
380
+ window.removeEventListener('pointerup', sendInput)
381
+ H.clear()
382
+ },
383
+ }
384
+ }
385
+
386
+ // ---------------------------------------------------------------- a single-file page
387
+ //
388
+ // `pymations build scene.py -o scene.html` writes one HTML file. What a folder
389
+ // would hold beside the page comes inside it instead, as JSON in
390
+ // <script type="application/json" id="pymations-inline">:
391
+ // {runtime, anim, sources: {path: text}, files: {path: base64},
392
+ // engine: {js, katex, katexCss, opentype, fonts: {name: base64}}}
393
+
394
+ let inlineData
395
+ function inline() {
396
+ if (inlineData === undefined) {
397
+ inlineData = null
398
+ try {
399
+ const el = typeof document !== 'undefined' && document.getElementById('pymations-inline')
400
+ if (el) inlineData = JSON.parse(el.textContent)
401
+ } catch (e) { /* not a single-file page */ }
402
+ }
403
+ return inlineData
404
+ }
405
+
406
+ function fromBase64(b64) {
407
+ const bin = atob(b64)
408
+ const bytes = new Uint8Array(bin.length)
409
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
410
+ return bytes
411
+ }
412
+
413
+ // ---------------------------------------------------------------- the animation engine
414
+ //
415
+ // engine/anim.js sets window.__cirAnim when it runs, and loads KaTeX,
416
+ // opentype.js and the fonts from the folder in window.__cirAnimBase as it
417
+ // needs them. Each scene runs the bundle once for an engine of its own, so
418
+ // scenes on one page never share a timeline.
419
+
420
+ const ENGINE_BASE = new URL('engine/', BASE).href
421
+
422
+ let unpacked = false
423
+ /**
424
+ * A single-file page carries the engine's helpers inside it: KaTeX and
425
+ * opentype.js run as page scripts (the engine finds them as globals), KaTeX's
426
+ * stylesheet has its fonts inline, and the engine's requests for font files
427
+ * are answered from the packed copies.
428
+ */
429
+ function unpackEngine(eng) {
430
+ if (unpacked) return
431
+ unpacked = true
432
+ const script = (text) => { const s = document.createElement('script'); s.textContent = text; document.head.appendChild(s) }
433
+ if (eng.katexCss) { const st = document.createElement('style'); st.textContent = eng.katexCss; document.head.appendChild(st) }
434
+ if (eng.katex && !window.katex) script(eng.katex)
435
+ if (eng.opentype && !window.opentype) script(eng.opentype)
436
+ const fonts = eng.fonts || {}
437
+ const prefix = ENGINE_BASE + 'fonts/'
438
+ const pageFetch = window.fetch.bind(window)
439
+ window.fetch = (input, init) => {
440
+ const url = typeof input === 'string' ? input : (input && input.url) || String(input)
441
+ if (url.startsWith(prefix)) {
442
+ const b64 = fonts[decodeURIComponent(url.slice(prefix.length).split(/[?#]/)[0])]
443
+ if (b64 != null) return Promise.resolve(new Response(fromBase64(b64), { headers: { 'Content-Type': 'font/ttf' } }))
444
+ }
445
+ return pageFetch(input, init)
446
+ }
447
+ }
448
+
449
+ let engineText = null
450
+ function loadEngineText() {
451
+ if (!engineText) {
452
+ const inl = inline()
453
+ if (inl && inl.engine) {
454
+ unpackEngine(inl.engine)
455
+ engineText = Promise.resolve(inl.engine.js)
456
+ } else {
457
+ engineText = fetch(ENGINE_BASE + 'anim.js').then((r) => {
458
+ if (!r.ok) throw new Error('engine/anim.js was not found beside pymations.js')
459
+ return r.text()
460
+ })
461
+ }
462
+ engineText.catch(() => { engineText = null })
463
+ }
464
+ return engineText
465
+ }
466
+
467
+ /** A new animation engine. */
468
+ export async function createEngine() {
469
+ const text = await loadEngineText()
470
+ window.__cirAnimBase = ENGINE_BASE
471
+ window.__cirAnim = null
472
+ // eslint-disable-next-line no-new-func
473
+ new Function(text)()
474
+ const engine = window.__cirAnim
475
+ if (!engine) throw new Error('the animation engine did not start')
476
+ return engine
477
+ }
478
+
479
+ // ---------------------------------------------------------------- transports
480
+ //
481
+ // A transport runs the Python half somewhere: start(source, info) and
482
+ // tick(payload) resolve to the runtime's JSON reply text; stop() ends the run.
483
+
484
+ /** Python in your own CPython, behind `pymations run` (one worker per scene). */
485
+ export function websocketTransport(src) {
486
+ const url = new URL('/_pymations/ws', location.href)
487
+ url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
488
+ url.searchParams.set('src', new URL(src, location.href).pathname)
489
+ const ws = new WebSocket(url)
490
+ let nextId = 1
491
+ const waiting = new Map()
492
+ let closed = null
493
+ const opened = new Promise((resolve, reject) => {
494
+ ws.onopen = resolve
495
+ ws.onerror = () => reject(new Error('Could not reach the pymations server.'))
496
+ })
497
+ ws.onmessage = (ev) => {
498
+ const m = JSON.parse(ev.data)
499
+ if (m.type === 'reload') { location.reload(); return }
500
+ const w = waiting.get(m.id)
501
+ if (!w) return
502
+ waiting.delete(m.id)
503
+ if (m.error) w.reject(new Error(m.error))
504
+ else w.resolve(m.reply)
505
+ }
506
+ ws.onclose = () => {
507
+ closed = new Error('The pymations server has stopped. Start it again with pymations run')
508
+ for (const w of waiting.values()) w.reject(closed)
509
+ waiting.clear()
510
+ }
511
+ async function call(msg) {
512
+ await opened
513
+ if (closed) throw closed
514
+ const id = nextId++
515
+ return new Promise((resolve, reject) => {
516
+ waiting.set(id, { resolve, reject })
517
+ ws.send(JSON.stringify({ id, ...msg }))
518
+ })
519
+ }
520
+ return {
521
+ label: 'your Python',
522
+ start: (_source, info) => call({ op: 'start', info }),
523
+ tick: (payload) => call({ op: 'tick', payload }),
524
+ stop: async () => { try { ws.close() } catch (e) { /* already closed */ } },
525
+ log: (text) => { if (ws.readyState === 1) ws.send(JSON.stringify({ op: 'log', text })) },
526
+ }
527
+ }
528
+
529
+ // The Python that hosts scenes in Pyodide: every scene gets its own copy of
530
+ // the runtime (as the module `pyctures`) and of the animation module (as
531
+ // `pymations`, and `manim` for pasted scripts), made current for the length
532
+ // of each call, so scenes cannot see each other.
533
+ const PY_HOST = `
534
+ import sys, types
535
+ _pymations_scenes = {}
536
+ def _pymations_new(sid):
537
+ rt = types.ModuleType('pyctures')
538
+ rt.__file__ = 'pyctures.py'
539
+ exec(compile(__pymations_runtime, 'pyctures.py', 'exec'), rt.__dict__)
540
+ sys.modules['pyctures'] = rt
541
+ an = types.ModuleType('pymations')
542
+ an.__file__ = 'pymations.py'
543
+ exec(compile(__pymations_anim, 'pymations.py', 'exec'), an.__dict__)
544
+ _pymations_scenes[sid] = (rt, an)
545
+ def _pymations_call(sid, op, a=None, b=None):
546
+ rt, an = _pymations_scenes[sid]
547
+ sys.modules['pyctures'] = rt
548
+ sys.modules['pymations'] = an
549
+ sys.modules['manim'] = an
550
+ if op == 'start':
551
+ return rt.start(a, b)
552
+ if op == 'tick':
553
+ return rt.tick(a)
554
+ rt.stop()
555
+ _pymations_scenes.pop(sid, None)
556
+ return 'stopped'
557
+ `
558
+
559
+ function pyodideBase() {
560
+ const url = (typeof window !== 'undefined' && window.PYMATIONS_PYODIDE_URL) || PYODIDE_URL
561
+ return new URL(url.replace(/\/?$/, '/'), location.href).href
562
+ }
563
+
564
+ let pythonPromise = null
565
+ /** The Python half (the runtime and the module): from a single-file page, or beside this file. */
566
+ function loadPython() {
567
+ if (!pythonPromise) {
568
+ const inl = inline()
569
+ const get = (name) => fetch(new URL(name, BASE)).then((r) => {
570
+ if (!r.ok) throw new Error(name + ' was not found beside pymations.js')
571
+ return r.text()
572
+ })
573
+ pythonPromise = inl && inl.runtime
574
+ ? Promise.resolve({ runtime: inl.runtime, anim: inl.anim })
575
+ : Promise.all([get('pyctures.py'), get('pymations.py')]).then(([runtime, anim]) => ({ runtime, anim }))
576
+ pythonPromise.catch(() => { pythonPromise = null })
577
+ }
578
+ return pythonPromise
579
+ }
580
+
581
+ /** The files a scene opens, as [{path, data}], from a single-file page or beside the scene. */
582
+ async function readFiles(src, files) {
583
+ const inl = inline()
584
+ const out = []
585
+ for (const f of files) {
586
+ const path = f.split('/').filter((p) => p && p !== '.').join('/')
587
+ let data
588
+ if (inl && inl.files && inl.files[f] != null) {
589
+ data = fromBase64(inl.files[f]).buffer
590
+ } else {
591
+ const res = await fetch(new URL(f, new URL(src, location.href)))
592
+ if (!res.ok) throw new Error(`Could not load ${f} (${res.status})`)
593
+ data = await res.arrayBuffer()
594
+ }
595
+ out.push({ path, data })
596
+ }
597
+ return out
598
+ }
599
+
600
+ /**
601
+ * THE WORKER. Python runs here, off the page's main thread, so a slow frame,
602
+ * Python's garbage collection or a loop that never waits cannot hold up
603
+ * drawing, scrolling or input. It only ever turns a request into a reply
604
+ * (the same JSON the other transports give). This function is the worker's
605
+ * whole code: it is turned into a Blob URL, so a page needs no extra file.
606
+ */
607
+ function workerMain() {
608
+ let py = null, booting = null, call = null
609
+ async function boot(m) {
610
+ importScripts(m.pyodide + 'pyodide.js')
611
+ // eslint-disable-next-line no-undef
612
+ py = await loadPyodide({ indexURL: m.pyodide })
613
+ py.globals.set('__pymations_runtime', m.runtime)
614
+ py.globals.set('__pymations_anim', m.anim)
615
+ py.runPython(m.host)
616
+ call = py.globals.get('_pymations_call')
617
+ }
618
+ function writeFiles(files) {
619
+ for (const f of files || []) {
620
+ const parts = f.path.split('/')
621
+ for (let i = 1; i < parts.length; i++) {
622
+ try { py.FS.mkdir(parts.slice(0, i).join('/')) } catch (e) { /* exists */ }
623
+ }
624
+ py.FS.writeFile(f.path, new Uint8Array(f.data))
625
+ }
626
+ }
627
+ self.onmessage = async (ev) => {
628
+ const m = ev.data
629
+ if (m.type === 'boot') { booting = boot(m); booting.catch(() => {}); return }
630
+ let reply = null, error = null
631
+ try {
632
+ try { await booting } catch (e) { throw new Error('Python could not load: ' + ((e && e.message) || e)) }
633
+ if (m.op === 'start') {
634
+ writeFiles(m.files)
635
+ await py.loadPackagesFromImports(m.source)
636
+ py.globals.get('_pymations_new')(m.sid)
637
+ reply = call(m.sid, 'start', m.source, m.info)
638
+ } else if (m.op === 'tick') {
639
+ reply = call(m.sid, 'tick', m.payload)
640
+ } else {
641
+ reply = call(m.sid, 'stop')
642
+ }
643
+ } catch (e) {
644
+ error = String((e && e.message) || e)
645
+ }
646
+ self.postMessage({ id: m.id, reply, error })
647
+ }
648
+ }
649
+
650
+ let pyWorker = null
651
+ /**
652
+ * The page's Python worker, started on first use and shared by every scene on
653
+ * the page (one copy of Python). terminate() ends it, for a scene stuck in a
654
+ * loop: every call still waiting is answered with an error marked
655
+ * `restarted`, and the next call starts a fresh worker.
656
+ */
657
+ function pythonWorker() {
658
+ if (pyWorker && !pyWorker.dead) return pyWorker
659
+ const url = URL.createObjectURL(new Blob(['(' + workerMain.toString() + ')()'], { type: 'text/javascript' }))
660
+ const w = new Worker(url)
661
+ const waiting = new Map()
662
+ let nextId = 1
663
+ const me = { dead: null }
664
+ const fail = (why) => {
665
+ for (const p of waiting.values()) { const e = new Error(why); e.restarted = true; p.reject(e) }
666
+ waiting.clear()
667
+ }
668
+ w.onmessage = (ev) => {
669
+ const m = ev.data
670
+ const p = waiting.get(m.id)
671
+ if (!p) return
672
+ waiting.delete(m.id)
673
+ if (m.error != null) p.reject(new Error(m.error))
674
+ else p.resolve(m.reply)
675
+ }
676
+ w.onerror = (ev) => {
677
+ ev.preventDefault()
678
+ for (const p of waiting.values()) p.reject(new Error('Python stopped: ' + (ev.message || 'the worker failed')))
679
+ waiting.clear()
680
+ }
681
+ const booted = Promise.all([loadPython(), pyodideBase()]).then(([python, base]) => {
682
+ w.postMessage({ type: 'boot', pyodide: base, runtime: python.runtime, anim: python.anim, host: PY_HOST })
683
+ })
684
+ me.call = async (msg, transfer) => {
685
+ await booted
686
+ if (me.dead) { const e = new Error(me.dead); e.restarted = true; throw e }
687
+ return new Promise((resolve, reject) => {
688
+ const id = nextId++
689
+ waiting.set(id, { resolve, reject })
690
+ w.postMessage({ id, ...msg }, transfer || [])
691
+ })
692
+ }
693
+ me.terminate = (why) => {
694
+ if (me.dead) return
695
+ me.dead = why || 'Python was restarted'
696
+ w.terminate()
697
+ URL.revokeObjectURL(url)
698
+ fail(me.dead)
699
+ if (pyWorker === me) pyWorker = null
700
+ }
701
+ pyWorker = me
702
+ return me
703
+ }
704
+
705
+ let nextSceneId = 1
706
+
707
+ /**
708
+ * Python in a Web Worker, with Pyodide: what built pages and scenes on a web
709
+ * page use. `files`: extra files to put beside the scene.
710
+ */
711
+ export function workerTransport(src, files = []) {
712
+ const sid = nextSceneId++
713
+ let worker = null
714
+ return {
715
+ label: 'Python in a worker',
716
+ async start(source, info) {
717
+ worker = pythonWorker()
718
+ const data = await readFiles(src, files)
719
+ return worker.call({ op: 'start', sid, source, info: JSON.stringify(info), files: data }, data.map((d) => d.data))
720
+ },
721
+ tick(payload) { return worker.call({ op: 'tick', sid, payload: JSON.stringify(payload) }) },
722
+ async stop() {
723
+ if (worker && !worker.dead) { try { await worker.call({ op: 'stop', sid }) } catch (e) { /* not running */ } }
724
+ },
725
+ /** Stop a scene that is stuck in a loop: ends the worker, and Python with it. */
726
+ hardStop() { if (worker) worker.terminate('Python was restarted') },
727
+ }
728
+ }
729
+
730
+ let pyodidePromise = null
731
+ /** Pyodide on the page's own thread, loaded once and shared by every scene on it. */
732
+ function loadPyodideOnce() {
733
+ if (!pyodidePromise) {
734
+ const base = pyodideBase()
735
+ pyodidePromise = (async () => {
736
+ const [{ loadPyodide }, python] = await Promise.all([import(base + 'pyodide.mjs'), loadPython()])
737
+ const py = await loadPyodide({ indexURL: base })
738
+ py.globals.set('__pymations_runtime', python.runtime)
739
+ py.globals.set('__pymations_anim', python.anim)
740
+ py.runPython(PY_HOST)
741
+ return py
742
+ })()
743
+ }
744
+ return pyodidePromise
745
+ }
746
+
747
+ /**
748
+ * Python on the page's own thread, with Pyodide: for a browser without
749
+ * workers, or `data-pymations-worker="off"`. A slow frame here holds up the
750
+ * page, so the worker is the better choice wherever it is available.
751
+ */
752
+ export function pyodideTransport(src, files = []) {
753
+ const sid = nextSceneId++
754
+ let py = null, callPy = null
755
+ return {
756
+ label: 'Python in the page',
757
+ async start(source, info) {
758
+ py = await loadPyodideOnce()
759
+ callPy = py.globals.get('_pymations_call')
760
+ for (const f of await readFiles(src, files)) {
761
+ const parts = f.path.split('/')
762
+ for (let i = 1; i < parts.length; i++) {
763
+ try { py.FS.mkdir(parts.slice(0, i).join('/')) } catch (e) { /* exists */ }
764
+ }
765
+ py.FS.writeFile(f.path, new Uint8Array(f.data))
766
+ }
767
+ await py.loadPackagesFromImports(source)
768
+ py.globals.get('_pymations_new')(sid)
769
+ return callPy(sid, 'start', source, JSON.stringify(info))
770
+ },
771
+ async tick(payload) { return callPy(sid, 'tick', JSON.stringify(payload)) },
772
+ async stop() { if (callPy) { try { callPy(sid, 'stop') } catch (e) { /* not running */ } } },
773
+ }
774
+ }
775
+
776
+ // ---------------------------------------------------------------- the page around a scene
777
+
778
+ const STYLE = `
779
+ .pymations-scene{position:relative;overflow:hidden;color-scheme:dark}
780
+ .pymations-scene>canvas{position:absolute;inset:0;width:100%;height:100%;display:block;outline:none;touch-action:none;-webkit-user-select:none;user-select:none}
781
+ .pymations-cover{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font:14px system-ui,sans-serif;color:#9aa3b2;pointer-events:none}
782
+ .pymations-error{position:absolute;inset:0;overflow:auto;padding:20px;box-sizing:border-box;background:rgba(20,10,14,.94);color:#f3f4f6;font:14px system-ui,sans-serif}
783
+ .pymations-error h2{margin:0 0 12px;font-size:17px;color:#ff8a8a}
784
+ .pymations-error pre{margin:0 0 14px;padding:12px;background:#0b0b0f;border-radius:6px;overflow:auto;font:12.5px/1.5 ui-monospace,Menlo,Consolas,monospace;white-space:pre}
785
+ .pymations-error .bad{background:#5a1d26;display:inline-block;min-width:100%}
786
+ .pymations-error button,.pymations-stuck button{font:inherit;background:#2a2f3a;color:#f3f4f6;border:1px solid #3a4150;border-radius:6px;padding:4px 12px;cursor:pointer}
787
+ .pymations-output{position:absolute;left:0;right:0;bottom:0;max-height:35%;overflow:auto;margin:0;padding:8px 12px;background:rgba(11,11,15,.88);color:#d1d5db;font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap}
788
+ .pymations-output .err{color:#ff8a8a}.pymations-output .info{color:#9aa3b2}
789
+ .pymations-stuck{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);z-index:3;display:flex;gap:12px;align-items:center;max-width:calc(100% - 32px);box-sizing:border-box;padding:10px 14px;border-radius:8px;background:rgba(20,10,14,.92);color:#f3f4f6;font:13px system-ui,sans-serif}
790
+ .pymations-anim{pointer-events:none;z-index:1}
791
+ .pymations-cover,.pymations-output{z-index:2}.pymations-error{z-index:4}
792
+ .pymations-bar{position:absolute;left:8px;right:8px;bottom:8px;z-index:2;display:flex;align-items:center;gap:10px;padding:4px 10px 4px 4px;border-radius:8px;background:rgba(11,11,15,.8);color:#e5e7eb;font:12px ui-monospace,Menlo,Consolas,monospace;transition:opacity .2s}
793
+ .pymations-bar.hide{opacity:0;pointer-events:none}
794
+ .pymations-play{flex:none;width:30px;height:30px;padding:7px;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer}
795
+ .pymations-play:hover{background:rgba(255,255,255,.12)}
796
+ .pymations-play svg{display:block;width:100%;height:100%;fill:currentColor}
797
+ .pymations-track{position:relative;flex:1;height:24px;cursor:pointer;touch-action:none}
798
+ .pymations-track::before{content:'';position:absolute;left:0;right:0;top:10px;height:4px;border-radius:2px;background:rgba(255,255,255,.22)}
799
+ .pymations-fill{position:absolute;left:0;top:10px;height:4px;border-radius:2px;background:#58c4dd}
800
+ .pymations-knob{position:absolute;top:6px;width:12px;height:12px;margin-left:-6px;border-radius:50%;background:#fff}
801
+ .pymations-ticks i{position:absolute;top:7px;width:2px;height:10px;margin-left:-1px;background:rgba(255,255,255,.65)}
802
+ .pymations-time{flex:none;min-width:9.5em;text-align:right;white-space:nowrap}
803
+ .pymations-measure{position:absolute;top:6px;left:6px;z-index:2;margin:0;padding:5px 8px;border-radius:6px;background:rgba(0,0,0,.66);color:#e5e7eb;font:11px/1.45 ui-monospace,Menlo,Consolas,monospace;white-space:pre;pointer-events:none}
804
+ `
805
+ let styled = false
806
+ function addStyle() {
807
+ if (styled) return
808
+ styled = true
809
+ const s = document.createElement('style')
810
+ s.textContent = STYLE
811
+ document.head.appendChild(s)
812
+ }
813
+
814
+ function measureSwitch(el) {
815
+ if (el && el.hasAttribute('data-pymations-measure') && el.getAttribute('data-pymations-measure') !== '0') return true
816
+ try {
817
+ if (new URLSearchParams(location.search).get('measure') === '1') return true
818
+ return localStorage.getItem(MEASURE_KEY) === '1'
819
+ } catch (e) { return false }
820
+ }
821
+
822
+ let activeScene = null
823
+
824
+
825
+ // ---------------------------------------------------------------- sound
826
+
827
+ /**
828
+ * SOUND (add_sound, music). The engine keeps the list (engine.sounds: {src,
829
+ * t0, volume, fade_in, fade_out, loop, t1?}); the page plays it with WebAudio,
830
+ * locked to the player's clock: playing schedules every sound from the
831
+ * player's time, a seek or a drift restarts them from there, and pausing
832
+ * silences them. Each file is decoded once per page, shared with the engine's
833
+ * wait_for_sound (window.__cirSoundDuration).
834
+ */
835
+ const decoded = new Map() // src -> Promise<AudioBuffer>
836
+ const buffers = new Map() // src -> AudioBuffer, once decoded
837
+ function decodeSound(src) {
838
+ let p = decoded.get(src)
839
+ if (!p) {
840
+ p = fetch(src).then((r) => {
841
+ if (!r.ok) throw new Error('could not read the sound ' + src)
842
+ return r.arrayBuffer()
843
+ }).then((data) => {
844
+ // An offline context decodes without waiting for a tap.
845
+ const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext
846
+ return new OAC(2, 1, 48000).decodeAudioData(data)
847
+ }).then((buf) => { buffers.set(src, buf); return buf })
848
+ decoded.set(src, p)
849
+ }
850
+ return p
851
+ }
852
+ if (typeof window !== 'undefined') window.__cirSoundDuration = (src) => decodeSound(src).then((b) => b.duration)
853
+
854
+ let audioCtx = null
855
+ function audioContext() {
856
+ const AC = window.AudioContext || window.webkitAudioContext
857
+ if (!AC) return null
858
+ if (!audioCtx) {
859
+ audioCtx = new AC()
860
+ // A browser that blocks sound until a tap: the next tap lets it play.
861
+ document.addEventListener('pointerdown', () => { if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {}) })
862
+ }
863
+ if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {})
864
+ return audioCtx
865
+ }
866
+
867
+ class SoundTrack {
868
+ constructor(player) {
869
+ this.player = player
870
+ this.live = []
871
+ this.key = ''
872
+ this.seen = -1
873
+ this.listKey = ''
874
+ this.at = 0
875
+ this.from = 0
876
+ this.pending = new Set()
877
+ }
878
+
879
+ list() { const l = this.player.engine.sounds; return Array.isArray(l) ? l : [] }
880
+
881
+ // A signature of the list, recomputed only when the timeline changes.
882
+ signature() {
883
+ const e = this.player.engine
884
+ if (e.version !== this.seen) {
885
+ this.seen = e.version
886
+ this.listKey = this.list().map((x) => [x.src, x.t0, x.volume, x.fade_in, x.fade_out, x.loop, x.t1].join(',')).join(';')
887
+ }
888
+ return this.listKey
889
+ }
890
+
891
+ stop() {
892
+ for (const s of this.live) {
893
+ try { s.node.stop() } catch (e) { /* already stopped */ }
894
+ try { s.gain.disconnect() } catch (e) { /* already gone */ }
895
+ }
896
+ this.live = []
897
+ this.key = ''
898
+ }
899
+
900
+ start(t) {
901
+ this.stop()
902
+ const list = this.list()
903
+ if (!list.length) return
904
+ const ctx = audioContext()
905
+ if (!ctx) return
906
+ // A small lead, so a sound due now starts on time rather than clipped.
907
+ this.at = ctx.currentTime + 0.05
908
+ this.from = t
909
+ this.key = this.signature()
910
+ const end = this.player.duration
911
+ const toCtx = (tau) => this.at + (tau - t)
912
+ for (const x of list) {
913
+ if (!x.src) continue
914
+ const buf = buffers.get(x.src)
915
+ if (!buf) {
916
+ if (!this.pending.has(x.src)) {
917
+ this.pending.add(x.src)
918
+ decodeSound(x.src).then(() => { if (this.player.playing) this.start(this.player.t) },
919
+ (e) => this.player.report('sound: ' + ((e && e.message) || e)))
920
+ }
921
+ continue
922
+ }
923
+ const t0 = +x.t0 || 0
924
+ let t1 = x.t1 != null ? +x.t1 : x.loop ? end : t0 + buf.duration
925
+ if (!x.loop) t1 = Math.min(t1, t0 + buf.duration)
926
+ if (t1 <= t || t1 <= t0) continue
927
+ const fi = +x.fade_in || 0, fo = +x.fade_out || 0
928
+ const gainAt = (tau) => {
929
+ let v = x.volume == null ? 1 : +x.volume
930
+ if (fi > 0) v *= Math.min(1, Math.max(0, (tau - t0) / fi))
931
+ if (fo > 0) v *= Math.min(1, Math.max(0, (t1 - tau) / fo))
932
+ return v
933
+ }
934
+ const ts = Math.max(t, t0)
935
+ const node = ctx.createBufferSource()
936
+ node.buffer = buf
937
+ node.loop = !!x.loop
938
+ const gain = ctx.createGain()
939
+ node.connect(gain)
940
+ gain.connect(ctx.destination)
941
+ gain.gain.setValueAtTime(gainAt(ts), toCtx(ts))
942
+ const marks = [t1]
943
+ if (fi > 0 && t0 + fi > ts) marks.push(t0 + fi)
944
+ if (fo > 0 && t1 - fo > ts) marks.push(t1 - fo)
945
+ marks.sort((a, b) => a - b)
946
+ for (const m of marks) if (m > ts && m <= t1) gain.gain.linearRampToValueAtTime(gainAt(m), toCtx(m))
947
+ let offset = ts - t0
948
+ if (x.loop && buf.duration > 0) offset %= buf.duration
949
+ node.start(toCtx(ts), offset)
950
+ node.stop(toCtx(t1))
951
+ this.live.push({ node, gain })
952
+ }
953
+ }
954
+
955
+ /** Each refresh: sounds follow the player, restarting after a seek, a stall or a change. */
956
+ sync() {
957
+ const p = this.player
958
+ if (!p.playing || p.waiting || p.holdUntil || !p.scene.visible || document.hidden) {
959
+ if (this.live.length || this.key) this.stop()
960
+ return
961
+ }
962
+ if (!this.list().length) { if (this.live.length) this.stop(); return }
963
+ if (this.signature() !== this.key || !audioCtx) { this.start(p.t); return }
964
+ const heard = this.from + (audioCtx.currentTime - this.at)
965
+ if (audioCtx.state === 'running' && Math.abs(heard - p.t) > 0.3) this.start(p.t)
966
+ }
967
+ }
968
+
969
+ // ---------------------------------------------------------------- the animation player
970
+
971
+ const ICON_PLAY = '<svg viewBox="0 0 16 16"><path d="M4 2.5l9.5 5.5L4 13.5z"/></svg>'
972
+ const ICON_PAUSE = '<svg viewBox="0 0 16 16"><rect x="3.5" y="2.5" width="3.2" height="11" rx="1"/><rect x="9.3" y="2.5" width="3.2" height="11" rx="1"/></svg>'
973
+ const LOOP_HOLD_MS = 1500
974
+
975
+ function clock(s) {
976
+ s = Math.max(0, s)
977
+ const m = Math.floor(s / 60), r = s - m * 60
978
+ return m + ':' + (r < 10 ? '0' : '') + r.toFixed(1)
979
+ }
980
+
981
+ /**
982
+ * THE PLAYER: the timeline's clock for one scene. It draws the engine's
983
+ * picture at time t on its own canvas (above the program's), advancing t with
984
+ * the page's refreshes while playing, and keeps the scrubber in step.
985
+ *
986
+ * What is on the timeline plays at once. Work the engine still has queued
987
+ * (fonts, equation outlines) only adds to the END of it, so the clock waits
988
+ * only when it reaches the end while the engine is busy ("Loading…"). At the
989
+ * end it stops, unless Python is still live (a draw() loop may add more) or
990
+ * the scene loops.
991
+ */
992
+ class Player {
993
+ constructor(scene, engine) {
994
+ this.scene = scene
995
+ this.engine = engine
996
+ this.canvas = document.createElement('canvas')
997
+ this.canvas.className = 'pymations-anim'
998
+ this.canvas.setAttribute('aria-hidden', 'true')
999
+ scene.el.appendChild(this.canvas)
1000
+ this.ctx = this.canvas.getContext('2d')
1001
+ this.tick = this.tick.bind(this)
1002
+ this.errors = Object.create(null)
1003
+ this.sound = new SoundTrack(this)
1004
+ this.reset()
1005
+ this.observer = new ResizeObserver(() => { this.drawn = ''; this.draw() })
1006
+ this.observer.observe(this.canvas)
1007
+ if (scene.opt.controls !== false) this.makeBar()
1008
+ this.onKey = (e) => this.key(e)
1009
+ window.addEventListener('keydown', this.onKey)
1010
+ // The engine's notices (a font or an equation still loading after a
1011
+ // while) and errors from work it finishes later, collected once a second.
1012
+ this.poll = setInterval(() => this.takeErrors(), 1000)
1013
+ }
1014
+
1015
+ /** A new run. Python's first command resets the engine itself. */
1016
+ reset() {
1017
+ if (this.sound) this.sound.stop()
1018
+ cancelAnimationFrame(this.raf)
1019
+ Object.assign(this, {
1020
+ t: 0, playing: false, started: false, starting: false, waiting: false, last: 0, raf: 0,
1021
+ holdUntil: 0, readyVersion: -1, waitingVersion: -1, drawn: '',
1022
+ })
1023
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0)
1024
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
1025
+ if (this.bar) this.hideBar()
1026
+ }
1027
+
1028
+ get duration() { return +this.engine.duration || 0 }
1029
+
1030
+ /**
1031
+ * What Python is told with each frame: the timeline time drawn now and the
1032
+ * value trackers' values at it, for updaters that run in Python and for
1033
+ * reading a tracker inside a draw() loop.
1034
+ */
1035
+ timeInfo() {
1036
+ const e = this.engine
1037
+ let v = {}
1038
+ try { v = (e.values_at || e.trackerValues || (() => ({}))).call(e, this.t) || {} } catch (err) { /* between runs */ }
1039
+ return { anim_t: this.t, anim_v: v }
1040
+ }
1041
+
1042
+ /** Stop the clock (the run was stopped); the picture stays. */
1043
+ halt() {
1044
+ this.sound.stop()
1045
+ this.playing = false
1046
+ cancelAnimationFrame(this.raf)
1047
+ this.raf = 0
1048
+ this.updateBar()
1049
+ }
1050
+
1051
+ destroy() {
1052
+ clearInterval(this.poll)
1053
+ this.halt()
1054
+ this.observer.disconnect()
1055
+ window.removeEventListener('keydown', this.onKey)
1056
+ this.canvas.remove()
1057
+ if (this.bar) this.bar.remove()
1058
+ }
1059
+
1060
+ engineReady() {
1061
+ const v = this.engine.version
1062
+ if (this.readyVersion === v) return true
1063
+ if (this.waitingVersion !== v) {
1064
+ this.waitingVersion = v
1065
+ const done = () => { this.readyVersion = v; this.takeErrors(); this.kick() }
1066
+ Promise.resolve().then(() => this.engine.ready()).then(done, done)
1067
+ }
1068
+ return false
1069
+ }
1070
+
1071
+ takeErrors() {
1072
+ const e = this.engine
1073
+ if (typeof e.takeNotices === 'function') {
1074
+ for (const n of e.takeNotices()) this.scene.log('info', 'animation: ' + ((n && n.message) || String(n)))
1075
+ }
1076
+ // Strings, or {message, line} with the Python line that made the command.
1077
+ for (const x of e.takeErrors ? e.takeErrors() : []) {
1078
+ if (x && typeof x === 'object') this.report((x.line ? 'line ' + x.line + ': ' : '') + (x.message || String(x)))
1079
+ else this.report(String(x))
1080
+ }
1081
+ }
1082
+
1083
+ report(msg) {
1084
+ msg = 'animation: ' + msg
1085
+ if (this.errors[msg]) return
1086
+ this.errors[msg] = 1
1087
+ this.scene.errors.push(msg)
1088
+ this.scene.log('err', msg)
1089
+ }
1090
+
1091
+ draw() {
1092
+ const dpr = window.devicePixelRatio || 1
1093
+ const r = this.canvas.getBoundingClientRect()
1094
+ const w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr))
1095
+ if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h }
1096
+ const e = this.engine
1097
+ const key = this.t + '|' + e.version + '|' + w + 'x' + h
1098
+ if (key === this.drawn) return
1099
+ this.drawn = key
1100
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0)
1101
+ this.ctx.clearRect(0, 0, w, h)
1102
+ if (!e.hasContent) return
1103
+ // The engine's background goes behind the program's canvas, which stays
1104
+ // transparent until the program draws.
1105
+ if (e.background && this.scene.el.style.background !== e.background) this.scene.el.style.background = e.background
1106
+ try { e.renderAt(this.ctx, this.t, w, h) } catch (err) { this.report((err && err.message) || String(err)) }
1107
+ }
1108
+
1109
+ kick() {
1110
+ if (!this.raf && this.started) this.raf = requestAnimationFrame(this.tick)
1111
+ }
1112
+
1113
+ tick(now) {
1114
+ this.raf = 0
1115
+ const scene = this.scene
1116
+ if (!scene.visible || document.hidden) { this.sound.stop(); return } // scene.resume() starts it again
1117
+ const dur = this.duration
1118
+ this.waiting = false
1119
+ if (this.playing) {
1120
+ if (this.holdUntil) {
1121
+ if (now >= this.holdUntil) { this.holdUntil = 0; this.t = 0; this.last = now }
1122
+ } else {
1123
+ if (this.last) this.t += Math.min(0.1, (now - this.last) / 1000)
1124
+ this.last = now
1125
+ if (this.t >= dur) {
1126
+ this.t = dur
1127
+ if (!this.engineReady()) this.waiting = true
1128
+ else if (scene.r.live) { /* Python may add more: wait at the end */ }
1129
+ else if (scene.opt.loop && dur > 0) this.holdUntil = now + LOOP_HOLD_MS
1130
+ else { this.playing = false; this.showBar() }
1131
+ }
1132
+ }
1133
+ }
1134
+ this.draw()
1135
+ this.updateBar()
1136
+ this.sound.sync()
1137
+ if (this.playing) this.kick()
1138
+ }
1139
+
1140
+ /** After each batch of commands is applied: the first content starts the clock. */
1141
+ afterBatch() {
1142
+ this.takeErrors()
1143
+ if (!this.started && this.engine.hasContent) {
1144
+ this.started = true
1145
+ this.playing = this.scene.opt.autoplay !== false
1146
+ this.last = 0
1147
+ if (!this.playing) this.showBar()
1148
+ }
1149
+ // Nothing to show yet because the first thing is still being made (a
1150
+ // text waiting for its font): start when it is.
1151
+ if (!this.started && !this.starting) {
1152
+ this.starting = true
1153
+ Promise.resolve().then(() => this.engine.ready()).then(() => {
1154
+ this.starting = false
1155
+ if (!this.started && this.engine.hasContent) this.afterBatch()
1156
+ else this.takeErrors()
1157
+ }, () => { this.starting = false })
1158
+ }
1159
+ if (this.playing) this.kick()
1160
+ else { this.draw(); this.updateBar() }
1161
+ }
1162
+
1163
+ setPlaying(on) {
1164
+ if (on && this.t >= this.duration) this.t = 0
1165
+ this.playing = on
1166
+ if (!on) this.sound.stop()
1167
+ this.waiting = false
1168
+ this.holdUntil = 0
1169
+ this.last = 0
1170
+ this.started = this.started || this.engine.hasContent
1171
+ this.kick()
1172
+ this.updateBar()
1173
+ }
1174
+
1175
+ seek(t) {
1176
+ this.t = Math.max(0, Math.min(this.duration, t))
1177
+ this.last = 0
1178
+ this.holdUntil = 0
1179
+ this.draw()
1180
+ this.updateBar()
1181
+ }
1182
+
1183
+ // Space plays and pauses, the arrow keys step 5 seconds: only while this
1184
+ // scene has the keyboard and Python is not reading it.
1185
+ key(e) {
1186
+ if (activeScene !== this.scene || this.scene.r.live || !this.started) return
1187
+ if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return
1188
+ if (e.key === ' ' || e.key === 'k') { e.preventDefault(); this.setPlaying(!this.playing) }
1189
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); this.seek(this.t - 5) }
1190
+ else if (e.key === 'ArrowRight') { e.preventDefault(); this.seek(this.t + 5) }
1191
+ else return
1192
+ this.showBar()
1193
+ }
1194
+
1195
+ // ---- The scrubber: play/pause, the track with a tick per section, the
1196
+ // time. Shown while paused, and on hover or a tap while playing; only for a
1197
+ // timeline that has length.
1198
+ makeBar() {
1199
+ const bar = document.createElement('div')
1200
+ bar.className = 'pymations-bar hide'
1201
+ bar.innerHTML = '<button type="button" class="pymations-play" aria-label="Play"></button>' +
1202
+ '<div class="pymations-track" role="slider" aria-label="Time" tabindex="-1"><div class="pymations-fill"></div>' +
1203
+ '<div class="pymations-ticks"></div><div class="pymations-knob"></div></div><span class="pymations-time"></span>'
1204
+ this.scene.el.appendChild(bar)
1205
+ this.bar = bar
1206
+ this.play = bar.querySelector('.pymations-play')
1207
+ this.track = bar.querySelector('.pymations-track')
1208
+ this.fill = bar.querySelector('.pymations-fill')
1209
+ this.knob = bar.querySelector('.pymations-knob')
1210
+ this.ticks = bar.querySelector('.pymations-ticks')
1211
+ this.time = bar.querySelector('.pymations-time')
1212
+ this.icon = ''
1213
+ this.ticksKey = ''
1214
+ let over = false, dragging = false, wasPlaying = false
1215
+ this.barBusy = () => over || dragging
1216
+ // The scrubber's own pointer events are not the program's input.
1217
+ const swallow = (e) => e.stopPropagation()
1218
+ bar.addEventListener('pointerdown', swallow)
1219
+ bar.addEventListener('pointerup', swallow)
1220
+ bar.addEventListener('pointerenter', () => { over = true })
1221
+ bar.addEventListener('pointerleave', () => { over = false })
1222
+ this.play.addEventListener('click', (e) => { e.stopPropagation(); this.setPlaying(!this.playing); this.showBar() })
1223
+ const seekTo = (x) => {
1224
+ const r = this.track.getBoundingClientRect()
1225
+ this.seek(r.width ? ((x - r.left) / r.width) * this.duration : 0)
1226
+ }
1227
+ this.track.addEventListener('pointerdown', (e) => {
1228
+ e.stopPropagation()
1229
+ dragging = true
1230
+ wasPlaying = this.playing
1231
+ this.playing = false
1232
+ try { this.track.setPointerCapture(e.pointerId) } catch (x) { /* not capturable */ }
1233
+ seekTo(e.clientX)
1234
+ })
1235
+ this.track.addEventListener('pointermove', (e) => { if (dragging) seekTo(e.clientX) })
1236
+ const end = (e) => {
1237
+ if (!dragging) return
1238
+ e.stopPropagation()
1239
+ dragging = false
1240
+ if (wasPlaying && this.t < this.duration) this.setPlaying(true)
1241
+ else this.updateBar()
1242
+ this.showBar()
1243
+ }
1244
+ this.track.addEventListener('pointerup', end)
1245
+ this.track.addEventListener('pointercancel', end)
1246
+ this.scene.el.addEventListener('pointermove', () => this.showBar())
1247
+ this.scene.el.addEventListener('pointerdown', () => this.showBar())
1248
+ }
1249
+
1250
+ showBar() {
1251
+ if (!this.bar || !(this.duration > 0)) return
1252
+ // Above the printed output, when the scene shows it.
1253
+ const out = this.scene.outEl
1254
+ this.bar.style.bottom = (out && out.offsetHeight ? out.offsetHeight + 8 : 8) + 'px'
1255
+ this.bar.classList.remove('hide')
1256
+ clearTimeout(this.hideTimer)
1257
+ const hide = () => {
1258
+ if (!this.playing || this.barBusy()) { this.hideTimer = setTimeout(hide, 800); return }
1259
+ this.bar.classList.add('hide')
1260
+ }
1261
+ this.hideTimer = setTimeout(hide, 2500)
1262
+ }
1263
+
1264
+ hideBar() {
1265
+ clearTimeout(this.hideTimer)
1266
+ this.bar.classList.add('hide')
1267
+ }
1268
+
1269
+ updateBar() {
1270
+ if (!this.bar) return
1271
+ const dur = this.duration
1272
+ const f = dur > 0 ? Math.min(1, this.t / dur) : 0
1273
+ this.fill.style.width = (f * 100) + '%'
1274
+ this.knob.style.left = (f * 100) + '%'
1275
+ this.track.setAttribute('aria-valuenow', this.t.toFixed(1))
1276
+ this.track.setAttribute('aria-valuemax', dur.toFixed(1))
1277
+ this.time.textContent = this.waiting ? 'Loading…' : clock(this.t) + ' / ' + clock(dur)
1278
+ const icon = this.playing ? 'pause' : 'play'
1279
+ if (icon !== this.icon) {
1280
+ this.icon = icon
1281
+ this.play.innerHTML = this.playing ? ICON_PAUSE : ICON_PLAY
1282
+ this.play.setAttribute('aria-label', this.playing ? 'Pause' : 'Play')
1283
+ }
1284
+ const secs = this.engine.sections || []
1285
+ const key = dur + '|' + secs.map((s) => s.name + '@' + s.t).join(',')
1286
+ if (key !== this.ticksKey) {
1287
+ this.ticksKey = key
1288
+ this.ticks.textContent = ''
1289
+ for (const s of secs) {
1290
+ if (!(dur > 0) || !(s.t > 0) || s.t >= dur) continue
1291
+ const i = document.createElement('i')
1292
+ i.style.left = (s.t / dur * 100) + '%'
1293
+ i.title = s.name
1294
+ this.ticks.appendChild(i)
1295
+ }
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+
1301
+ /**
1302
+ * One scene on the page, in `el`. Options:
1303
+ * src the scene's .py file (a URL, relative to the page)
1304
+ * transport from websocketTransport() or pyodideTransport()
1305
+ * fullPage the scene is the whole page: it has the keyboard from the start
1306
+ * output 'panel' shows printed text over the scene; otherwise the console
1307
+ * background the colour behind the canvas (an animation's own background wins)
1308
+ * autoStart false: wait for scene.start()
1309
+ * autoplay false: an animation waits for its play button
1310
+ * loop true: an animation starts again after its end
1311
+ * controls false: no scrubber
1312
+ * makeTransport a function that makes a fresh transport for each run
1313
+ */
1314
+ export class Scene {
1315
+ constructor(el, options) {
1316
+ addStyle()
1317
+ this.el = el
1318
+ this.opt = Object.assign({ output: 'console', background: '#0b0b0f', autoStart: true }, options)
1319
+ this.name = decodeURIComponent((this.opt.src || 'scene.py').split('/').pop())
1320
+ this.status = 'idle'
1321
+ this.errors = []
1322
+ this.r = {
1323
+ token: 0, live: false, busy: false, busySince: 0, warnedStuck: false, missedRefresh: false,
1324
+ sent: 0, shown: 0, clock: 0, period: 1000 / 60, lastRaf: 0, raf: 0,
1325
+ events: [], reads: {}, size: { width: 0, height: 0, pixel_ratio: 1 },
1326
+ measuring: false, measureSince: 0, lastShown: 0,
1327
+ acc: { frames: 0, shown: 0, missed: 0, worst: 0, cmds: 0, ms: {} },
1328
+ }
1329
+ this.visible = false
1330
+ el.classList.add('pymations-scene')
1331
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
1332
+ if (!this.opt.fullPage && el.clientHeight === 0) el.style.aspectRatio = el.style.aspectRatio || '16 / 9'
1333
+ if (!el.style.background) el.style.background = this.opt.background
1334
+ el.pymationsScene = this
1335
+ if (this.opt.fullPage) activeScene = this
1336
+
1337
+ // Start only once the scene is on screen, and pause while it is not.
1338
+ this.io = new IntersectionObserver((entries) => {
1339
+ const seen = entries[entries.length - 1].isIntersecting
1340
+ if (seen === this.visible) return
1341
+ this.visible = seen
1342
+ if (seen && this.status === 'idle' && this.opt.autoStart) this.start()
1343
+ else if (seen) this.resume()
1344
+ }, { rootMargin: '100px' })
1345
+ this.io.observe(el)
1346
+ this.onVisibility = () => { if (!document.hidden) this.resume() }
1347
+ document.addEventListener('visibilitychange', this.onVisibility)
1348
+ this.loop = this.loop.bind(this)
1349
+ }
1350
+
1351
+ // -------- output
1352
+ log(kind, text, line) {
1353
+ if (!text) return
1354
+ text = String(text).replace(/\n$/, '')
1355
+ if (kind === 'err') console.error(`[${this.name}] ${text}`)
1356
+ else if (kind === 'info') console.info(`[${this.name}] ${text}`)
1357
+ else console.log(text)
1358
+ if (kind !== 'out' && this.transport && this.transport.log) this.transport.log(text)
1359
+ if (this.opt.output !== 'panel') return
1360
+ if (!this.outEl) {
1361
+ this.outEl = document.createElement('pre')
1362
+ this.outEl.className = 'pymations-output'
1363
+ this.el.appendChild(this.outEl)
1364
+ }
1365
+ const span = document.createElement('span')
1366
+ span.className = kind
1367
+ span.textContent = text + '\n'
1368
+ this.outEl.appendChild(span)
1369
+ while (this.outEl.childNodes.length > 400) this.outEl.removeChild(this.outEl.firstChild)
1370
+ this.outEl.scrollTop = this.outEl.scrollHeight
1371
+ }
1372
+
1373
+ setStatus(s) {
1374
+ this.status = s
1375
+ this.el.dataset.pymationsStatus = s
1376
+ this.el.dispatchEvent(new CustomEvent('pymations-status', { detail: { status: s } }))
1377
+ }
1378
+
1379
+ cover(text) {
1380
+ if (!this.coverEl) {
1381
+ this.coverEl = document.createElement('div')
1382
+ this.coverEl.className = 'pymations-cover'
1383
+ this.el.appendChild(this.coverEl)
1384
+ }
1385
+ this.coverEl.textContent = text || ''
1386
+ this.coverEl.style.display = text ? '' : 'none'
1387
+ }
1388
+
1389
+ showError(message, line) {
1390
+ const box = document.createElement('div')
1391
+ box.className = 'pymations-error'
1392
+ const h = document.createElement('h2')
1393
+ h.textContent = line ? `Error on line ${line} of ${this.name}` : `Error in ${this.name}`
1394
+ box.appendChild(h)
1395
+ const pre = document.createElement('pre')
1396
+ pre.textContent = message
1397
+ box.appendChild(pre)
1398
+ const lines = (this.source || '').split('\n')
1399
+ if (line && lines[line - 1] !== undefined) {
1400
+ const code = document.createElement('pre')
1401
+ for (let n = Math.max(1, line - 3); n <= Math.min(lines.length, line + 3); n++) {
1402
+ const row = document.createElement('span')
1403
+ row.textContent = String(n).padStart(4) + ' ' + lines[n - 1]
1404
+ if (n === line) row.className = 'bad'
1405
+ code.appendChild(row)
1406
+ code.appendChild(document.createTextNode('\n'))
1407
+ }
1408
+ box.appendChild(code)
1409
+ }
1410
+ const again = document.createElement('button')
1411
+ again.textContent = 'Run again'
1412
+ again.onclick = () => this.start()
1413
+ box.appendChild(again)
1414
+ this.errorEl = box
1415
+ this.el.appendChild(box)
1416
+ this.el.dataset.pymationsError = (line ? `line ${line}: ` : '') + message.split('\n').pop()
1417
+ }
1418
+
1419
+ // -------- running
1420
+ async start() {
1421
+ await this.stop()
1422
+ const r = this.r
1423
+ const token = ++r.token
1424
+ if (!this.transport) this.transport = this.opt.makeTransport ? this.opt.makeTransport() : this.opt.transport
1425
+ this.setStatus('starting')
1426
+ if (this.errorEl) { this.errorEl.remove(); this.errorEl = null }
1427
+ if (this.outEl) this.outEl.textContent = ''
1428
+ delete this.el.dataset.pymationsError
1429
+ Object.assign(r, { events: [], reads: {}, busy: false, warnedStuck: false, sent: 0, shown: 0, clock: 0, lastRaf: 0 })
1430
+ this.cover('Starting Python…')
1431
+ try {
1432
+ const inl = inline()
1433
+ if (this.source == null && inl && inl.sources && inl.sources[this.opt.src] != null) this.source = inl.sources[this.opt.src]
1434
+ if (this.source == null) {
1435
+ const res = await fetch(this.opt.src, { cache: 'no-store' })
1436
+ if (!res.ok) throw new Error(`Could not load ${this.opt.src} (${res.status})`)
1437
+ this.source = await res.text()
1438
+ }
1439
+ const source = this.source
1440
+ // The animation engine, only for a file that imports the module. One
1441
+ // per scene, kept across runs, so fonts and equations are made once.
1442
+ let engine = null
1443
+ if (ANIM_IMPORT.test(source)) {
1444
+ this.cover('Loading the animation engine…')
1445
+ engine = this.engine || (this.engine = await createEngine())
1446
+ }
1447
+ if (token !== r.token) return
1448
+ // A fresh canvas for every run: an empty handle table, and no context
1449
+ // left over from the last run (a canvas keeps whichever it made first).
1450
+ if (this.canvas) this.canvas.remove()
1451
+ this.canvas = document.createElement('canvas')
1452
+ this.canvas.tabIndex = 0
1453
+ this.canvas.setAttribute('aria-label', this.name)
1454
+ this.el.insertBefore(this.canvas, this.el.firstChild)
1455
+ this.canvas.addEventListener('pointerdown', () => { activeScene = this })
1456
+ this.canvas.addEventListener('focus', () => { activeScene = this })
1457
+ if (engine) {
1458
+ if (!this.player) this.player = new Player(this, engine)
1459
+ this.player.reset()
1460
+ }
1461
+ this.cover('Starting Python…')
1462
+ this.renderer = createRenderer(this.canvas, {
1463
+ anim: engine,
1464
+ images: imageResolver(this.opt.src, inl && inl.files),
1465
+ hooks: {
1466
+ onEvent: (e) => { if (r.live) r.events.push(e) },
1467
+ onSize: (s) => { r.size = s },
1468
+ onApplied: (d) => { this.applied(d); if (this.player) this.player.afterBatch() },
1469
+ onReads: (reads) => { if (r.live) Object.assign(r.reads, reads) },
1470
+ isActive: () => activeScene === this,
1471
+ },
1472
+ })
1473
+ r.measuring = measureSwitch(this.el)
1474
+ this.watch()
1475
+ r.acc = { frames: 0, shown: 0, missed: 0, worst: 0, cmds: 0, ms: {} }
1476
+ r.lastShown = 0
1477
+ r.busy = true
1478
+ r.busySince = performance.now()
1479
+ this.setStatus('running')
1480
+ const info = { ...r.size, measure: r.measuring }
1481
+ if (this.player) Object.assign(info, { anim_t: 0, anim_v: {} })
1482
+ const text = await this.transport.start(source, info)
1483
+ r.busy = false
1484
+ if (r.warnedStuck) this.unstuck()
1485
+ if (token !== r.token) return
1486
+ this.cover('')
1487
+ if (this.handleReply(JSON.parse(text), token)) {
1488
+ r.live = true
1489
+ r.measureSince = performance.now()
1490
+ r.raf = requestAnimationFrame(this.loop)
1491
+ }
1492
+ } catch (error) {
1493
+ if (token !== r.token) return
1494
+ r.busy = false
1495
+ if (error && error.restarted) { this.start(); return } // another scene restarted Python
1496
+ this.cover('')
1497
+ const msg = String((error && error.message) || error)
1498
+ this.log('err', msg)
1499
+ this.showError(msg, 0)
1500
+ this.setStatus('error')
1501
+ }
1502
+ }
1503
+
1504
+ async stop() {
1505
+ const r = this.r
1506
+ const wasBusy = r.busy
1507
+ r.token++
1508
+ r.live = false
1509
+ cancelAnimationFrame(r.raf)
1510
+ this.unwatch()
1511
+ if (this.player) this.player.halt()
1512
+ if (this.status === 'running' || this.status === 'starting') this.setStatus('stopped')
1513
+ if (this.renderer) { this.renderer.destroy(); this.renderer = null }
1514
+ if (this.transport) {
1515
+ // Python in the middle of a frame cannot be asked to stop: a worker is
1516
+ // ended instead (and started afresh on the next run).
1517
+ if (wasBusy && this.transport.hardStop) this.transport.hardStop()
1518
+ else {
1519
+ try {
1520
+ await Promise.race([this.transport.stop(), new Promise((res) => setTimeout(res, 1000))])
1521
+ } catch (e) { /* best effort */ }
1522
+ }
1523
+ // A WebSocket transport is one run; make a new one next time.
1524
+ if (this.opt.makeTransport) this.transport = null
1525
+ }
1526
+ r.busy = false
1527
+ }
1528
+
1529
+ /** Remove the scene from the page for good. */
1530
+ async destroy() {
1531
+ await this.stop()
1532
+ this.io.disconnect()
1533
+ document.removeEventListener('visibilitychange', this.onVisibility)
1534
+ if (this.canvas) this.canvas.remove()
1535
+ if (this.player) { this.player.destroy(); this.player = null }
1536
+ }
1537
+
1538
+ // THE WATCHDOG. Python runs off the page's thread, so the page can still
1539
+ // notice when a frame (or the file's top level) has not come back for a
1540
+ // few seconds, say why, and offer to stop it.
1541
+ watch() {
1542
+ this.unwatch()
1543
+ this.watchdog = setInterval(() => {
1544
+ const r = this.r
1545
+ if (r.busy && !r.warnedStuck && performance.now() - r.busySince > STUCK_MS) {
1546
+ r.warnedStuck = true
1547
+ this.log('info', 'Python has not finished a frame for 3 seconds. A loop that never awaits frame() holds it.')
1548
+ this.showStuck()
1549
+ }
1550
+ }, 500)
1551
+ }
1552
+
1553
+ unwatch() {
1554
+ clearInterval(this.watchdog)
1555
+ this.watchdog = 0
1556
+ this.unstuck()
1557
+ }
1558
+
1559
+ showStuck() {
1560
+ if (this.stuckEl) return
1561
+ const box = document.createElement('div')
1562
+ box.className = 'pymations-stuck'
1563
+ const text = document.createElement('span')
1564
+ text.textContent = 'Python has not finished a frame for 3 seconds. Is there a loop that never waits?'
1565
+ const btn = document.createElement('button')
1566
+ btn.textContent = 'Stop'
1567
+ btn.onclick = () => this.halt()
1568
+ box.appendChild(text)
1569
+ box.appendChild(btn)
1570
+ this.stuckEl = box
1571
+ this.el.appendChild(box)
1572
+ }
1573
+
1574
+ unstuck() {
1575
+ this.r.warnedStuck = false
1576
+ if (this.stuckEl) { this.stuckEl.remove(); this.stuckEl = null }
1577
+ }
1578
+
1579
+ /** Stop a scene that is stuck, and say so. */
1580
+ async halt() {
1581
+ await this.stop()
1582
+ const msg = 'Stopped: Python did not finish a frame. A loop that never waits (for example while True: without await frame()) holds it.'
1583
+ this.log('err', msg)
1584
+ this.showError(msg, 0)
1585
+ this.setStatus('error')
1586
+ }
1587
+
1588
+ resume() {
1589
+ const r = this.r
1590
+ if (this.player && this.visible && !document.hidden) this.player.kick()
1591
+ if (!r.live || !this.visible || document.hidden) return
1592
+ cancelAnimationFrame(r.raf)
1593
+ r.clock = 0 // re-anchor: no giant step after a pause
1594
+ r.lastRaf = 0
1595
+ r.raf = requestAnimationFrame(this.loop)
1596
+ }
1597
+
1598
+ applied(d) {
1599
+ const r = this.r
1600
+ r.shown++
1601
+ if (r.measuring) {
1602
+ const m = r.acc.ms
1603
+ m.draw_in_page = (m.draw_in_page || 0) + (d.ms || 0)
1604
+ r.acc.shown++
1605
+ r.acc.cmds += d.n || 0
1606
+ }
1607
+ // A frame left the queue: keep one coming.
1608
+ if (r.live && !r.busy) this.requestFrame(performance.now())
1609
+ Object.assign(r.reads, d.reads)
1610
+ for (const e of d.errors || []) {
1611
+ const text = e.line ? `line ${e.line}: ${e.message}` : e.message
1612
+ this.errors.push(text)
1613
+ this.log('err', text, e.line)
1614
+ }
1615
+ }
1616
+
1617
+ handleReply(reply, token) {
1618
+ const r = this.r
1619
+ if (token !== r.token) return false
1620
+ if (reply.out) this.log('out', reply.out)
1621
+ if (reply.err) this.log('err', reply.err)
1622
+ // Always, even an empty frame: the renderer counts frames to pace them.
1623
+ r.sent++
1624
+ this.renderer.push(reply.cmds || [], r.measuring ? performance.timeOrigin + performance.now() : 0)
1625
+ if (reply.error) {
1626
+ r.live = false
1627
+ this.log('err', reply.error, reply.errorLine || undefined)
1628
+ this.showError(reply.error, reply.errorLine || 0)
1629
+ this.setStatus('error')
1630
+ return false
1631
+ }
1632
+ if (!reply.running) {
1633
+ r.live = false
1634
+ this.setStatus('finished')
1635
+ return false
1636
+ }
1637
+ return true
1638
+ }
1639
+
1640
+ /** Ask Python for the next frame. At most one is in flight. */
1641
+ requestFrame(now) {
1642
+ const r = this.r
1643
+ if (!r.live || r.busy || document.hidden || !this.visible) return
1644
+ // At most two frames in hand: enough to cover an uneven round trip, few
1645
+ // enough that input still feels immediate.
1646
+ if (r.sent - r.shown >= 2) return
1647
+ // THE GAME'S CLOCK ADVANCES ONE REFRESH PER FRAME. Only when Python has
1648
+ // truly fallen behind does it jump to real time, so a game never runs in
1649
+ // slow motion.
1650
+ const period = r.period
1651
+ let dt = period / 1000
1652
+ if (!r.clock) {
1653
+ r.clock = now
1654
+ } else {
1655
+ r.clock += period
1656
+ const behind = now - r.clock
1657
+ if (behind > period * 3) {
1658
+ dt = Math.min(0.1, (period + behind) / 1000)
1659
+ r.clock = now
1660
+ } else if (r.clock - now > period * 4) {
1661
+ r.clock = now
1662
+ }
1663
+ }
1664
+ r.missedRefresh = false
1665
+ const payload = { dt, events: r.events, reads: r.reads, ...r.size, measure: r.measuring }
1666
+ if (this.player) Object.assign(payload, this.player.timeInfo())
1667
+ r.events = []
1668
+ r.reads = {}
1669
+ r.busy = true
1670
+ r.busySince = now
1671
+ const token = r.token
1672
+ const t0 = performance.now()
1673
+ this.transport.tick(payload).then((text) => {
1674
+ if (token !== r.token) return
1675
+ r.busy = false
1676
+ if (r.warnedStuck) this.unstuck()
1677
+ const done = performance.now()
1678
+ const parsed = JSON.parse(text)
1679
+ if (r.measuring) {
1680
+ const m = r.acc.ms
1681
+ const python = (parsed.t && parsed.t.python) || 0
1682
+ m.round_trip = (m.round_trip || 0) + (done - t0)
1683
+ m.bridge = (m.bridge || 0) + Math.max(0, done - t0 - python)
1684
+ for (const k in parsed.t || {}) m[k] = (m[k] || 0) + parsed.t[k]
1685
+ r.acc.frames++
1686
+ }
1687
+ if (this.handleReply(parsed, token) && r.missedRefresh) this.requestFrame(done)
1688
+ }, (error) => {
1689
+ if (token !== r.token) return
1690
+ r.busy = false
1691
+ r.live = false
1692
+ if (error && error.restarted) { this.start(); return } // another scene restarted Python
1693
+ const msg = String((error && error.message) || error)
1694
+ this.log('err', msg)
1695
+ this.showError(msg, 0)
1696
+ this.setStatus('error')
1697
+ })
1698
+ }
1699
+
1700
+ loop(now) {
1701
+ const r = this.r
1702
+ if (!r.live) return
1703
+ if (!this.visible || document.hidden) { r.raf = 0; return } // paused; resume() restarts it
1704
+ r.raf = requestAnimationFrame(this.loop)
1705
+ // The display's refresh period (16.7 ms at 60 Hz, 8.3 at 120), learnt
1706
+ // from the animation frames themselves.
1707
+ if (r.lastRaf) {
1708
+ const delta = now - r.lastRaf
1709
+ if (delta > 4 && delta < 40) r.period = r.period * 0.9 + delta * 0.1
1710
+ }
1711
+ const gap = r.lastRaf ? now - r.lastRaf : 0
1712
+ r.lastRaf = now
1713
+ if (r.measuring) this.measureTick(now, gap)
1714
+ if (r.busy) {
1715
+ r.missedRefresh = true
1716
+ return
1717
+ }
1718
+ this.requestFrame(now)
1719
+ }
1720
+
1721
+ // MEASURE MODE: add ?measure=1 to the page's address, put
1722
+ // data-pymations-measure on the scene's div, or set
1723
+ // localStorage['pymations:measure'] = '1'. A box over the scene shows, each
1724
+ // second, the frame rate and where the frame time goes; the same summary is
1725
+ // logged and kept in window.__pymationsMeasure (and scene.measure).
1726
+ //
1727
+ // A MISSED REFRESH is a screen refresh that showed no new frame: either an
1728
+ // animation frame came with nothing new in the queue, or the page was held
1729
+ // so long that whole refreshes went by with no animation frame at all.
1730
+ measureTick(now, gap) {
1731
+ const r = this.r, a = r.acc
1732
+ if (r.shown === r.lastShown) a.missed++
1733
+ if (gap > r.period * 1.5) a.missed += Math.round(gap / r.period) - 1
1734
+ if (gap > a.worst) a.worst = gap
1735
+ r.lastShown = r.shown
1736
+ if (now - r.measureSince < 1000) return
1737
+ const ms = {}
1738
+ for (const k in a.ms) ms[k] = +(a.ms[k] / Math.max(1, k === 'draw_in_page' ? a.shown : a.frames)).toFixed(2)
1739
+ const summary = {
1740
+ scene: this.name, runtime: (this.transport && this.transport.label) || '',
1741
+ fps: Math.round((a.shown * 1000) / (now - r.measureSince)), ms,
1742
+ commands: Math.round(a.cmds / Math.max(1, a.shown)), queued: r.sent - r.shown, missedRefreshes: a.missed,
1743
+ longestRefresh: +a.worst.toFixed(1),
1744
+ }
1745
+ this.measure = summary
1746
+ window.__pymationsMeasure = summary
1747
+ console.info('[pymations measure]', JSON.stringify(summary))
1748
+ this.showMeasure(summary)
1749
+ r.acc = { frames: 0, shown: 0, missed: 0, worst: 0, cmds: 0, ms: {} }
1750
+ r.measureSince = now
1751
+ }
1752
+
1753
+ showMeasure(m) {
1754
+ if (!this.measureEl) {
1755
+ this.measureEl = document.createElement('pre')
1756
+ this.measureEl.className = 'pymations-measure'
1757
+ this.el.appendChild(this.measureEl)
1758
+ }
1759
+ const f = (v) => (v == null ? '–' : v.toFixed(1))
1760
+ this.measureEl.textContent =
1761
+ `${m.fps} fps missed ${m.missedRefreshes} longest ${m.longestRefresh.toFixed(0)} ms\n` +
1762
+ `python ${f(m.ms.python)} ms round trip ${f(m.ms.round_trip)} ms\n` +
1763
+ `draw ${f(m.ms.draw_in_page)} ms queued ${m.queued}\n` +
1764
+ m.runtime
1765
+ }
1766
+ }
1767
+
1768
+ // ---------------------------------------------------------------- embedding
1769
+
1770
+ function serverMode() {
1771
+ const m = document.querySelector('meta[name="pymations-server"]')
1772
+ return !!(m && m.content)
1773
+ }
1774
+
1775
+ /**
1776
+ * Show the scene `src` in `el`. Attributes on `el` (all optional):
1777
+ * data-pymations-src the .py file (required for auto-mount)
1778
+ * data-pymations-files extra files the scene opens, comma-separated
1779
+ * data-pymations-output "panel" to show print() over the scene
1780
+ * data-pymations-background a CSS colour
1781
+ * data-pymations-fullpage the scene is the whole page
1782
+ * data-pymations-measure show the measure box (frame rate and timings)
1783
+ * data-pymations-worker "off" runs Python on the page's own thread
1784
+ * data-pymations-autoplay "off": an animation waits for its play button
1785
+ * data-pymations-loop an animation starts again after its end
1786
+ * data-pymations-controls "off": no scrubber
1787
+ */
1788
+ export function mount(el, options = {}) {
1789
+ if (el.pymationsScene) return el.pymationsScene
1790
+ const src = options.src || el.dataset.pymationsSrc
1791
+ const files = options.files || (el.dataset.pymationsFiles || '').split(',').map((s) => s.trim()).filter(Boolean)
1792
+ const server = options.server !== undefined ? options.server : serverMode()
1793
+ const worker = options.worker !== undefined ? options.worker
1794
+ : el.dataset.pymationsWorker !== 'off' && typeof Worker !== 'undefined'
1795
+ const make = () => (server ? websocketTransport(src) : worker ? workerTransport(src, files) : pyodideTransport(src, files))
1796
+ const scene = new Scene(el, {
1797
+ src,
1798
+ fullPage: options.fullPage !== undefined ? options.fullPage : el.hasAttribute('data-pymations-fullpage'),
1799
+ output: options.output || el.dataset.pymationsOutput || 'console',
1800
+ background: options.background || el.dataset.pymationsBackground || '#0b0b0f',
1801
+ autoStart: options.autoStart !== false,
1802
+ autoplay: options.autoplay !== undefined ? options.autoplay : el.dataset.pymationsAutoplay !== 'off',
1803
+ loop: options.loop !== undefined ? options.loop : el.hasAttribute('data-pymations-loop'),
1804
+ controls: options.controls !== undefined ? options.controls : el.dataset.pymationsControls !== 'off',
1805
+ makeTransport: make,
1806
+ })
1807
+ return scene
1808
+ }
1809
+
1810
+ /** Mount every `[data-pymations-src]` element on the page. */
1811
+ export function mountAll(root = document) {
1812
+ return Array.from(root.querySelectorAll('[data-pymations-src]')).map((el) => mount(el))
1813
+ }
1814
+
1815
+ if (typeof document !== 'undefined' && !window.PYMATIONS_NO_AUTO) {
1816
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => mountAll())
1817
+ else mountAll()
1818
+ }