pymations 0.0.0-stage → 0.1.0

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