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.
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/THIRD-PARTY-NOTICES +240 -0
- package/engine/LICENSE-polygon-clipping.txt +11 -0
- package/engine/anim.js +9 -0
- package/engine/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/engine/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/engine/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/engine/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/engine/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/engine/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/engine/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/engine/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/engine/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/engine/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/engine/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/engine/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/engine/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/engine/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/engine/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/engine/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/engine/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/engine/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/engine/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/engine/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/engine/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/engine/fonts/LICENSE_KATEX_FONTS.txt +106 -0
- package/engine/fonts/LICENSE_LIBERATION.txt +102 -0
- package/engine/fonts/LiberationSans-Bold.ttf +0 -0
- package/engine/fonts/LiberationSans-BoldItalic.ttf +0 -0
- package/engine/fonts/LiberationSans-Italic.ttf +0 -0
- package/engine/fonts/LiberationSans-Regular.ttf +0 -0
- package/engine/katex.min.css +1 -0
- package/engine/katex.min.js +1 -0
- package/engine/manifest.json +1 -0
- package/engine/opentype.min.js +2 -0
- package/package.json +34 -4
- package/pyctures.py +897 -0
- package/pymations.js +1818 -0
- 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
|
+
}
|