@moood/render 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/cli.js +34 -0
- package/engine/actions.js +69 -0
- package/engine/captions.js +20 -0
- package/engine/directions.js +44 -0
- package/engine/engine.js +1456 -0
- package/engine/engine.json +1 -0
- package/engine/gl.js +1027 -0
- package/engine/kit.js +142 -0
- package/engine/live.js +23 -0
- package/engine/mic.js +76 -0
- package/engine/music.css +28 -0
- package/engine/music.js +279 -0
- package/engine/nav.css +66 -0
- package/engine/render.html +50 -0
- package/engine/scene.css +124 -0
- package/engine/scenestate.js +220 -0
- package/engine/select.js +301 -0
- package/engine/speech.js +150 -0
- package/engine/strip.js +153 -0
- package/engine/viewer.js +216 -0
- package/engine/volume.js +56 -0
- package/engine/widgets.js +88 -0
- package/index.js +70 -0
- package/package.json +13 -6
- package/scene.js +43 -0
package/engine/viewer.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Looking at a scene without leaving the page: a picture of a scene (or of one being drawn) clicked
|
|
2
|
+
// grows, from where it is, into a view nearly the size of the window — the scene itself, alive
|
|
3
|
+
// (its page in a frame: ?frame), its name, the scenes beside it to go through (← →), the way to
|
|
4
|
+
// its own page; Esc, ✕ or a click beside it puts it back. The address shows the scene's while it is
|
|
5
|
+
// open, so "back" closes it too.
|
|
6
|
+
// Links marked data-view (its value: the name shown) open in it; the set to go through is the
|
|
7
|
+
// marked links of the nearest [data-views] around (a strip), or of the whole page.
|
|
8
|
+
// The scene is laid out once, at the view's own size, and never resized: it grows by scale alone.
|
|
9
|
+
// Where its picture is in the view is where engine.js puts it: the frame is the view's shape held
|
|
10
|
+
// between 4:3 and 21:9 (bars beyond), the picture its 16:9 at the frame's full height, centred — so
|
|
11
|
+
// the view starts scaled to the picture's height, cut to the part of it that shows, right over it.
|
|
12
|
+
// Until the scene has drawn itself (the frame says "moood:shown"), only its picture is seen — the view
|
|
13
|
+
// is cut to it, wherever it grows; then the cut opens to the whole view and the picture goes. So the
|
|
14
|
+
// frame is never seen before there is something in it. Closing, the picture comes back over the scene
|
|
15
|
+
// as the view shrinks — it lands as the picture it left.
|
|
16
|
+
// A link that knows the content its picture shows (data-key: the scene's content key) opens that
|
|
17
|
+
// content in the front's frame.html — a file beside this one, under the same fingerprint: the browser
|
|
18
|
+
// keeps it for good, and the content too (a key's content never changes); opened again, nothing is asked. If the scene is not there yet when the view has grown, a quiet
|
|
19
|
+
// sign of waiting stands over its picture.
|
|
20
|
+
// Every scene shown gets a frame of its own, made after the address changed and gone once it is
|
|
21
|
+
// closed: so no step of the browser's history holds a frame — going back never puts one back
|
|
22
|
+
// (empty, or a scene from before) while the view is still there.
|
|
23
|
+
(() => {
|
|
24
|
+
const MOVE = 320, ROUND = 6; // ms, as in nav.css; a picture's corners
|
|
25
|
+
const STAGE = 16 / 9, NARROWEST = 4 / 3, WIDEST = 21 / 9; // as engine.js frames a scene
|
|
26
|
+
const FRAME = new URL('../site/frame.html', document.currentScript.src); // (beside this file)
|
|
27
|
+
let box = null, veil = null, bar = null, frame = null, still = null, wait = null, name = null, page = null, prev = null, next = null;
|
|
28
|
+
let items = [], at = 0, pushed = false, closing = 0, landing = 0, waiting = 0, ready = false;
|
|
29
|
+
|
|
30
|
+
function build() {
|
|
31
|
+
veil = document.createElement('div');
|
|
32
|
+
veil.className = 'viewer-veil';
|
|
33
|
+
veil.onclick = () => close();
|
|
34
|
+
box = document.createElement('div');
|
|
35
|
+
box.className = 'viewer';
|
|
36
|
+
bar = document.createElement('div');
|
|
37
|
+
bar.className = 'viewer-bar';
|
|
38
|
+
prev = button('←', 'предыдущая (←)', () => go(at - 1));
|
|
39
|
+
next = button('→', 'следующая (→)', () => go(at + 1));
|
|
40
|
+
name = document.createElement('span');
|
|
41
|
+
name.className = 'viewer-name';
|
|
42
|
+
page = document.createElement('a');
|
|
43
|
+
page.className = 'viewer-page';
|
|
44
|
+
page.textContent = 'открыть страницей';
|
|
45
|
+
bar.append(prev, name, next, page, button('✕', 'закрыть (Esc)', () => close()));
|
|
46
|
+
still = document.createElement('img');
|
|
47
|
+
still.className = 'viewer-still';
|
|
48
|
+
still.alt = '';
|
|
49
|
+
still.onerror = () => { still.hidden = true; };
|
|
50
|
+
wait = document.createElement('span');
|
|
51
|
+
wait.className = 'viewer-wait';
|
|
52
|
+
box.append(bar, still, wait);
|
|
53
|
+
document.body.append(veil, box);
|
|
54
|
+
addEventListener('keydown', e => {
|
|
55
|
+
if (!box.classList.contains('open')) return;
|
|
56
|
+
if (e.key === 'Escape') close();
|
|
57
|
+
else if (e.key === 'ArrowLeft') go(at - 1);
|
|
58
|
+
else if (e.key === 'ArrowRight') go(at + 1);
|
|
59
|
+
});
|
|
60
|
+
addEventListener('popstate', () => { if (box.classList.contains('open')) { pushed = false; close(); } });
|
|
61
|
+
addEventListener('resize', () => { if (box.classList.contains('open')) { size(); fitStill(); } });
|
|
62
|
+
// the scene in the frame has drawn itself: all of the view shows, its picture goes
|
|
63
|
+
addEventListener('message', e => {
|
|
64
|
+
if (!frame || e.source !== frame.contentWindow || e.data !== 'moood:shown') return;
|
|
65
|
+
ready = true;
|
|
66
|
+
clearTimeout(waiting);
|
|
67
|
+
box.classList.remove('waiting');
|
|
68
|
+
still.classList.add('gone');
|
|
69
|
+
box.classList.add('moving');
|
|
70
|
+
settle();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function button(text, title, onclick) {
|
|
75
|
+
const b = document.createElement('button');
|
|
76
|
+
b.type = 'button';
|
|
77
|
+
b.textContent = text;
|
|
78
|
+
b.title = title;
|
|
79
|
+
b.onclick = onclick;
|
|
80
|
+
return b;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// the view's own size: nearly the window
|
|
84
|
+
function size() {
|
|
85
|
+
const m = Math.min(48, innerWidth * 0.04);
|
|
86
|
+
Object.assign(box.style, { left: `${m}px`, top: `${m}px`, width: `${innerWidth - 2 * m}px`, height: `${innerHeight - 2 * m}px` });
|
|
87
|
+
}
|
|
88
|
+
// where the scene's picture is in the view (inside its border; it may run past the sides)
|
|
89
|
+
function middle() {
|
|
90
|
+
const top = bar.offsetHeight, w = box.clientWidth, h = box.clientHeight - top;
|
|
91
|
+
const a = Math.min(Math.max(w / h, NARROWEST), WIDEST), fh = Math.min(w, h * a) / a, pw = fh * STAGE;
|
|
92
|
+
return { left: (w - pw) / 2, top: top + (h - fh) / 2, width: pw, height: fh };
|
|
93
|
+
}
|
|
94
|
+
// the picture in its place, the sign of waiting in its middle
|
|
95
|
+
function fitStill() {
|
|
96
|
+
const m = middle();
|
|
97
|
+
Object.assign(still.style, { left: `${m.left}px`, top: `${m.top}px`, width: `${m.width}px`, height: `${m.height}px` });
|
|
98
|
+
Object.assign(wait.style, { left: `${m.left + m.width / 2}px`, top: `${m.top + m.height / 2}px` });
|
|
99
|
+
}
|
|
100
|
+
// the view laid over a picture (r, on the screen): scaled to its height, cut to its middle — or itself
|
|
101
|
+
function over(r) {
|
|
102
|
+
if (!r) {
|
|
103
|
+
box.style.transform = '';
|
|
104
|
+
box.style.clipPath = '';
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// (the transform counts from the box's outer edge, its border included)
|
|
108
|
+
const m = middle(), b = box.getBoundingClientRect(), s = r.height / m.height;
|
|
109
|
+
const left = box.clientLeft + m.left, top = box.clientTop + m.top;
|
|
110
|
+
const x = r.left + (r.width - m.width * s) / 2 - b.left - left * s, y = r.top - b.top - top * s;
|
|
111
|
+
box.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
|
|
112
|
+
box.style.clipPath = picture(s);
|
|
113
|
+
}
|
|
114
|
+
// the view cut to the part of the scene's picture that shows (the picture may be wider than the
|
|
115
|
+
// view), its corners as a picture's at scale s (the clip counts from the box's outer edge too)
|
|
116
|
+
function picture(s) {
|
|
117
|
+
const m = middle(), top = box.clientTop + m.top;
|
|
118
|
+
const side = box.clientLeft + Math.max(0, m.left), bottom = box.offsetHeight - top - m.height;
|
|
119
|
+
return `inset(${top}px ${side}px ${bottom}px ${side}px round ${ROUND / s}px)`;
|
|
120
|
+
}
|
|
121
|
+
// the view in its place, unscaled: all of it once the scene has drawn itself (then no cut: its
|
|
122
|
+
// shadow shows), until then only its picture
|
|
123
|
+
function settle() {
|
|
124
|
+
box.style.transform = 'translate(0, 0) scale(1)';
|
|
125
|
+
box.style.clipPath = ready ? 'inset(0 0 0 0 round 12px)' : picture(1);
|
|
126
|
+
clearTimeout(landing);
|
|
127
|
+
if (ready) landing = setTimeout(() => { box.classList.remove('moving'); box.style.clipPath = ''; }, MOVE);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function show(i) {
|
|
131
|
+
at = i;
|
|
132
|
+
const it = items[at];
|
|
133
|
+
name.textContent = it.title || 'сцена';
|
|
134
|
+
page.href = it.href;
|
|
135
|
+
prev.disabled = at === 0;
|
|
136
|
+
next.disabled = at === items.length - 1;
|
|
137
|
+
ready = false;
|
|
138
|
+
// still not there once the view has grown: the sign of waiting
|
|
139
|
+
clearTimeout(waiting);
|
|
140
|
+
box.classList.remove('waiting');
|
|
141
|
+
waiting = setTimeout(() => { if (!ready) box.classList.add('waiting'); }, MOVE);
|
|
142
|
+
still.classList.remove('gone');
|
|
143
|
+
still.hidden = !it.picture;
|
|
144
|
+
still.src = it.picture || '';
|
|
145
|
+
// the scene in a frame of its own, in place of the one before
|
|
146
|
+
const f = document.createElement('iframe');
|
|
147
|
+
f.className = 'viewer-frame';
|
|
148
|
+
f.title = 'сцена';
|
|
149
|
+
const scene = it.href.match(/\/scenes\/(\d+)$/)?.[1];
|
|
150
|
+
f.src = scene && it.key ? `${FRAME.pathname}?${new URLSearchParams({ scene, key: it.key })}` : `${it.href}?frame`;
|
|
151
|
+
f.onload = () => f.contentWindow.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
|
152
|
+
if (frame) frame.replaceWith(f); else bar.after(f);
|
|
153
|
+
frame = f;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function go(i) {
|
|
157
|
+
if (i < 0 || i >= items.length || i === at) return;
|
|
158
|
+
show(i);
|
|
159
|
+
box.classList.add('moving');
|
|
160
|
+
settle(); // (cut to the next scene's picture until it has drawn itself)
|
|
161
|
+
history.replaceState(history.state, '', items[at].href);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function open(list, i, el) {
|
|
165
|
+
if (!box) build();
|
|
166
|
+
clearTimeout(closing);
|
|
167
|
+
items = list;
|
|
168
|
+
// the address first: the step of history it leaves holds no frame
|
|
169
|
+
history.pushState({ viewer: true }, '', items[i].href);
|
|
170
|
+
pushed = true;
|
|
171
|
+
box.classList.remove('moving', 'closing');
|
|
172
|
+
show(i);
|
|
173
|
+
await still.decode().catch(() => {}); // (the picture ready to be seen from the first moment)
|
|
174
|
+
box.classList.add('open');
|
|
175
|
+
size();
|
|
176
|
+
fitStill();
|
|
177
|
+
over(el.getBoundingClientRect());
|
|
178
|
+
veil.classList.add('open');
|
|
179
|
+
box.getBoundingClientRect(); // laid out where it starts, before it moves
|
|
180
|
+
box.classList.add('moving');
|
|
181
|
+
settle();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function close() {
|
|
185
|
+
if (!box || !box.classList.contains('open')) return;
|
|
186
|
+
if (pushed) { pushed = false; history.back(); return; } // (popstate closes it)
|
|
187
|
+
clearTimeout(landing);
|
|
188
|
+
// back into the picture of the scene it shows now, if that is on the page
|
|
189
|
+
const el = items[at]?.from, back = el?.isConnected ? el.getBoundingClientRect() : null;
|
|
190
|
+
if (back) {
|
|
191
|
+
box.style.clipPath = 'inset(0 0 0 0 round 12px)';
|
|
192
|
+
box.getBoundingClientRect();
|
|
193
|
+
box.classList.add('moving', 'closing');
|
|
194
|
+
over(back);
|
|
195
|
+
}
|
|
196
|
+
veil.classList.remove('open');
|
|
197
|
+
closing = setTimeout(() => { box.classList.remove('open', 'moving', 'closing'); over(null); frame?.remove(); frame = null; clearTimeout(waiting); box.classList.remove('waiting'); }, back ? MOVE : 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// links marked data-view open in the viewer; each grows from (and returns to) its scene's picture
|
|
201
|
+
document.addEventListener('click', e => {
|
|
202
|
+
const a = e.target.closest('a[data-view]');
|
|
203
|
+
if (!a || e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
const group = a.closest('[data-views]') || document;
|
|
206
|
+
const items = new Map(); // (a scene's picture and its name lead to the same place)
|
|
207
|
+
for (const l of group.querySelectorAll('a[data-view]')) {
|
|
208
|
+
const href = l.getAttribute('href'), img = l.querySelector('img');
|
|
209
|
+
const known = items.get(href);
|
|
210
|
+
if (!known) items.set(href, { href, title: l.dataset.view, key: l.dataset.key || null, from: img || l, picture: img?.src || null });
|
|
211
|
+
else if (!known.picture && img) Object.assign(known, { from: img, picture: img.src });
|
|
212
|
+
}
|
|
213
|
+
const list = [...items.values()], i = list.findIndex(it => it.href === a.getAttribute('href'));
|
|
214
|
+
open(list, i, list[i].from);
|
|
215
|
+
});
|
|
216
|
+
})();
|
package/engine/volume.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// How loud the site is for whoever listens here: one setting for all of it, every scene — the
|
|
2
|
+
// music and the voices, each 0..1, kept in this browser (and followed by its other tabs). The 🔊 in
|
|
3
|
+
// the crumbs (any [data-volume] button) opens two sliders; the players ask get() and listen on().
|
|
4
|
+
const MooodVolume = (() => {
|
|
5
|
+
const DEFAULT = { music: 0.4, voice: 1 };
|
|
6
|
+
const NAMES = { music: 'музыка', voice: 'голоса' };
|
|
7
|
+
const key = k => `moood:volume:${k}`;
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
|
|
10
|
+
function get(k) {
|
|
11
|
+
try { const v = parseFloat(localStorage.getItem(key(k))); if (v >= 0 && v <= 1) return v; } catch {}
|
|
12
|
+
return DEFAULT[k];
|
|
13
|
+
}
|
|
14
|
+
function set(k, v) {
|
|
15
|
+
try { localStorage.setItem(key(k), String(v)); } catch {}
|
|
16
|
+
tell(k);
|
|
17
|
+
}
|
|
18
|
+
function tell(k) { for (const f of listeners) f(k, get(k)); }
|
|
19
|
+
// f(kind, value): kind 'music' or 'voice' changed
|
|
20
|
+
function on(f) { listeners.add(f); }
|
|
21
|
+
addEventListener('storage', e => { for (const k in DEFAULT) if (e.key === key(k)) tell(k); });
|
|
22
|
+
|
|
23
|
+
// ── the sliders ──────────────────────────────────────────────────────────
|
|
24
|
+
let panel = null;
|
|
25
|
+
function build() {
|
|
26
|
+
panel = document.createElement('div');
|
|
27
|
+
panel.id = 'volume';
|
|
28
|
+
panel.hidden = true;
|
|
29
|
+
for (const k in DEFAULT) {
|
|
30
|
+
const row = document.createElement('label');
|
|
31
|
+
const name = document.createElement('span');
|
|
32
|
+
name.textContent = NAMES[k];
|
|
33
|
+
const r = document.createElement('input');
|
|
34
|
+
r.type = 'range'; r.min = 0; r.max = 1; r.step = 0.01; r.value = get(k); r.dataset.kind = k;
|
|
35
|
+
r.oninput = () => set(k, Number(r.value));
|
|
36
|
+
row.append(name, r);
|
|
37
|
+
panel.append(row);
|
|
38
|
+
}
|
|
39
|
+
document.body.append(panel);
|
|
40
|
+
}
|
|
41
|
+
function open(button) {
|
|
42
|
+
if (!panel) build();
|
|
43
|
+
for (const r of panel.querySelectorAll('input')) r.value = get(r.dataset.kind);
|
|
44
|
+
const at = button.getBoundingClientRect();
|
|
45
|
+
panel.style.left = `${Math.max(8, Math.min(at.left, innerWidth - 228))}px`;
|
|
46
|
+
panel.style.top = `${at.bottom + 8}px`;
|
|
47
|
+
panel.hidden = false;
|
|
48
|
+
}
|
|
49
|
+
document.addEventListener('click', e => {
|
|
50
|
+
const b = e.target.closest?.('[data-volume]');
|
|
51
|
+
if (b) { panel && !panel.hidden ? (panel.hidden = true) : open(b); return; }
|
|
52
|
+
if (panel && !panel.hidden && !panel.contains(e.target)) panel.hidden = true;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { get, set, on };
|
|
56
|
+
})();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Widgets — the scene's own bits of interface over the picture: a sign over someone's head,
|
|
2
|
+
// a clock, a letter, a scoreboard, a panel with buttons (spec.widgets). Each is the artist's
|
|
3
|
+
// HTML + CSS + JS, living right in the page: its markup and styles in a shadow root (so its CSS
|
|
4
|
+
// and the page's don't mix), its scripts run as the page's own, given `root` (that shadow root:
|
|
5
|
+
// root.getElementById, root.querySelector) and `widget` (its element). A widget is laid out in px
|
|
6
|
+
// for a 1080-px-tall screen and scaled with the screen, so it looks the same everywhere. It
|
|
7
|
+
// stands at a point of the frame (interface: a corner, the middle), or follows an element of the scene (hovering `lift` metres
|
|
8
|
+
// over its anchor) as the viewer walks. Errors its scripts throw land in window.__mooodErrors.
|
|
9
|
+
const MooodWidgets = (() => {
|
|
10
|
+
const REF = 1080;
|
|
11
|
+
const ANCHORS = { center: [0.5, 0.5], top: [0.5, 0], bottom: [0.5, 1], left: [0, 0.5], right: [1, 0.5],
|
|
12
|
+
'top-left': [0, 0], 'top-right': [1, 0], 'bottom-left': [0, 1], 'bottom-right': [1, 1] };
|
|
13
|
+
const report = (key, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`widget ${key}: ${err && err.message || err}`);
|
|
14
|
+
|
|
15
|
+
// builds a widget's element: markup into its shadow root, then its scripts, in order
|
|
16
|
+
function make(w, key) {
|
|
17
|
+
const el = document.createElement('div');
|
|
18
|
+
el.className = 'widget';
|
|
19
|
+
el.dataset.id = key;
|
|
20
|
+
const root = el.attachShadow({ mode: 'open' });
|
|
21
|
+
root.innerHTML = `<style>:host{display:block;overflow:hidden}</style>${w.html}`;
|
|
22
|
+
// markup set as HTML doesn't run its scripts: run them ourselves (after the element is in the page)
|
|
23
|
+
const scripts = [...root.querySelectorAll('script')];
|
|
24
|
+
for (const s of scripts) s.remove();
|
|
25
|
+
el.start = () => {
|
|
26
|
+
for (const s of scripts) {
|
|
27
|
+
if (s.src) { const n = document.createElement('script'); n.src = s.src; n.onerror = () => report(key, `can't load ${s.src}`); document.head.append(n); continue; }
|
|
28
|
+
try { new Function('root', 'widget', s.textContent)(root, el); } catch (err) { report(key, err); }
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return el;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function create({ canvas, stage }) {
|
|
35
|
+
let spec = null, items = [], timers = [], raf = 0, gen = 0;
|
|
36
|
+
const box = document.createElement('div');
|
|
37
|
+
box.id = 'widgets';
|
|
38
|
+
canvas.after(box);
|
|
39
|
+
|
|
40
|
+
function show(next) {
|
|
41
|
+
spec = next;
|
|
42
|
+
for (const t of timers) clearTimeout(t);
|
|
43
|
+
timers = [];
|
|
44
|
+
const list = (Array.isArray(spec?.widgets) ? spec.widgets : []).filter(w => w && typeof w.html === 'string');
|
|
45
|
+
// keep a widget (and its state) when only its placement changed
|
|
46
|
+
const old = new Map(items.map(it => [it.src, it]));
|
|
47
|
+
const fresh = [];
|
|
48
|
+
items = list.map((w, i) => {
|
|
49
|
+
const key = w.id || `widget${i}`, src = `${key}\n${w.html}`;
|
|
50
|
+
let it = old.get(src);
|
|
51
|
+
if (it) old.delete(src);
|
|
52
|
+
else { it = { src, el: make(w, key) }; fresh.push(it); }
|
|
53
|
+
it.w = w;
|
|
54
|
+
const el = it.el;
|
|
55
|
+
el.style.width = `${Math.max(1, w.width || 320)}px`;
|
|
56
|
+
el.style.height = `${Math.max(1, w.height || 120)}px`;
|
|
57
|
+
el.style.pointerEvents = w.interactive ? 'auto' : 'none';
|
|
58
|
+
el.classList.toggle('on', !(w.at > 0));
|
|
59
|
+
if (w.at > 0) timers.push(setTimeout(() => el.classList.add('on'), w.at * 1000));
|
|
60
|
+
if (w.for > 0) timers.push(setTimeout(() => el.classList.remove('on'), (Math.max(0, w.at || 0) + w.for) * 1000));
|
|
61
|
+
box.append(el); // in spec order: later ones on top
|
|
62
|
+
return it;
|
|
63
|
+
});
|
|
64
|
+
for (const it of old.values()) it.el.remove();
|
|
65
|
+
for (const it of fresh) it.el.start();
|
|
66
|
+
cancelAnimationFrame(raf);
|
|
67
|
+
const g = ++gen;
|
|
68
|
+
const loop = () => { if (g !== gen) return; place(); raf = requestAnimationFrame(loop); };
|
|
69
|
+
if (items.length) loop();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function place() {
|
|
73
|
+
// in the frame (engine.js): bars around it on screens too narrow or wide for it
|
|
74
|
+
const f = stage.frame(), k = f.h / REF;
|
|
75
|
+
for (const { w, el } of items) {
|
|
76
|
+
const [ax, ay] = ANCHORS[w.anchor] || (w.follow ? ANCHORS.bottom : ANCHORS.center);
|
|
77
|
+
const at = w.follow ? stage.screenOf(w.follow, w.lift ?? 0) : [f.x + (w.x ?? 0.5) * f.w, f.y + (w.y ?? 0.5) * f.h];
|
|
78
|
+
el.style.visibility = at ? '' : 'hidden';
|
|
79
|
+
if (!at) continue;
|
|
80
|
+
const fw = Math.max(1, w.width || 320), fh = Math.max(1, w.height || 120);
|
|
81
|
+
el.style.transform = `translate(${at[0] - ax * fw * k}px, ${at[1] - ay * fh * k}px) scale(${k})`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { show };
|
|
86
|
+
}
|
|
87
|
+
return { create };
|
|
88
|
+
})();
|
package/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// A moood scene rendered here, with moood's own engine (engine/: the site's web/scene, its version in
|
|
2
|
+
// engine/engine.json) in a Chromium already installed (playwright-core: nothing is downloaded) — no
|
|
3
|
+
// network, no site. Time is a virtual clock, stepped from the scene's start: a frame at a moment is
|
|
4
|
+
// the same every time, with the same state.
|
|
5
|
+
// render({spec, modules, device, at, state, bare, chromium, date}) → [{at, png, errors}]
|
|
6
|
+
// spec — the scene (readScene(folder) from a world's repository, or any spec);
|
|
7
|
+
// modules — the modules it lists, themselves: {name: {code, data}} (readModules(world, spec));
|
|
8
|
+
// device — the frame: 16:9 (1600×900), 4:3, 21:9 (the same height), tile (640×360), big (1920×1080);
|
|
9
|
+
// at — the moments, seconds from the scene's start (default [0]): a frame each;
|
|
10
|
+
// state — values over the spec's starting state ({"dawn": 1, "girl": "gone"});
|
|
11
|
+
// bare — without the title and the captions over it;
|
|
12
|
+
// chromium — the browser's executable (else MOOOD_CHROMIUM, else Playwright's own);
|
|
13
|
+
// date — what the clock says at the start (default 2026-01-01T12:00:00Z).
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { chromium as browserType } from 'playwright-core';
|
|
17
|
+
|
|
18
|
+
export { readModules, readScene } from './scene.js';
|
|
19
|
+
|
|
20
|
+
const PAGE = pathToFileURL(fileURLToPath(new URL('./engine/render.html', import.meta.url))).href;
|
|
21
|
+
export const ENGINE = JSON.parse(readFileSync(new URL('./engine/engine.json', import.meta.url), 'utf8')).engine;
|
|
22
|
+
export const DEVICES = { '16:9': [1600, 900], '4:3': [1200, 900], '21:9': [2100, 900], tile: [640, 360], big: [1920, 1080] };
|
|
23
|
+
const STEP = 100; // ms: timers fire as they come due, not all at the end of one jump
|
|
24
|
+
|
|
25
|
+
export async function render({ spec, modules = {}, device = '16:9', at = [0], state = {}, bare = false, chromium, date } = {}) {
|
|
26
|
+
const size = DEVICES[device];
|
|
27
|
+
if (!size) throw new Error(`device: one of ${Object.keys(DEVICES).join(', ')}`);
|
|
28
|
+
if (!spec || typeof spec !== 'object') throw new Error('spec: a scene expected');
|
|
29
|
+
const shown = { ...spec, state: { ...(spec.state && typeof spec.state === 'object' ? spec.state : {}), ...state } };
|
|
30
|
+
const browser = await browserType.launch({
|
|
31
|
+
executablePath: chromium || process.env.MOOOD_CHROMIUM || undefined,
|
|
32
|
+
// WebGL on SwiftShader; 2D canvases on the CPU's Skia (SwiftShader takes minutes for many blended fills)
|
|
33
|
+
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-accelerated-2d-canvas',
|
|
34
|
+
'--allow-file-access-from-files'],
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const page = await (await browser.newContext({ viewport: { width: size[0], height: size[1] } })).newPage();
|
|
38
|
+
const errors = [];
|
|
39
|
+
page.on('pageerror', e => errors.push(String(e.message)));
|
|
40
|
+
// the clock stands still: only the frames' moments move it (install alone lets it run as time does)
|
|
41
|
+
const start = new Date(date || '2026-01-01T12:00:00Z').getTime();
|
|
42
|
+
await page.clock.install({ time: start });
|
|
43
|
+
await page.clock.pauseAt(start + 1);
|
|
44
|
+
await page.addInitScript(([s, m]) => {
|
|
45
|
+
window.__mooodSpec = s; window.__mooodModules = m;
|
|
46
|
+
// chance, the same every time (the engine's grain, rain, sparks): the same moment, the same frame
|
|
47
|
+
let seed = 1;
|
|
48
|
+
Math.random = () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
49
|
+
}, [shown, modules]);
|
|
50
|
+
await page.goto(bare ? `${PAGE}?bare` : PAGE);
|
|
51
|
+
// what loads by itself (fonts, the scene's modules) has loaded before the clock moves at all
|
|
52
|
+
await page.evaluate(() => document.fonts.ready);
|
|
53
|
+
await page.waitForTimeout(1000);
|
|
54
|
+
const frames = [];
|
|
55
|
+
let now = 0;
|
|
56
|
+
for (const t of [...at].map(Number).sort((a, b) => a - b)) {
|
|
57
|
+
while (now < t * 1000) {
|
|
58
|
+
const step = Math.min(STEP, t * 1000 - now);
|
|
59
|
+
await page.clock.runFor(step);
|
|
60
|
+
now += step;
|
|
61
|
+
}
|
|
62
|
+
await page.clock.runFor(0);
|
|
63
|
+
const said = await page.evaluate(() => window.__mooodErrors || []);
|
|
64
|
+
frames.push({ at: t, png: await page.screenshot({ type: 'png' }), errors: [...new Set([...errors, ...said])].slice(0, 100) });
|
|
65
|
+
}
|
|
66
|
+
return frames;
|
|
67
|
+
} finally {
|
|
68
|
+
await browser.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@moood/render",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@moood/render",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A moood scene rendered locally — the site's own engine, in a browser already installed — at a moment, with a state: a PNG and the errors its code threw.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"bin": { "moood-render": "cli.js" },
|
|
9
|
+
"files": ["index.js", "cli.js", "scene.js", "engine/", "README.md"],
|
|
10
|
+
"engines": { "node": ">=20" },
|
|
11
|
+
"dependencies": { "playwright-core": "^1.56.0" },
|
|
12
|
+
"scripts": { "build": "node build.js" }
|
|
13
|
+
}
|
package/scene.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A scene as it lies in its world's repository, put together as moood does it (the scenes service's
|
|
2
|
+
// layout.py): scenes/<folder>/scene.json and its code beside it — elements/<id>.js (objects[].code),
|
|
3
|
+
// elements/<id>.script.js (objects[].script), script.js, lib.js, notes.md, widgets/<id>.html — and
|
|
4
|
+
// the modules it lists (modules/<name>.js, .data.json, in the world's repository).
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const TOP = { script: 'script.js', lib: 'lib.js', notes: 'notes.md' };
|
|
9
|
+
const IN = { objects: { code: 'elements/{id}.js', script: 'elements/{id}.script.js' }, widgets: { html: 'widgets/{id}.html' } };
|
|
10
|
+
|
|
11
|
+
const walk = dir => readdirSync(dir).flatMap(f => (statSync(join(dir, f)).isDirectory() ? walk(join(dir, f)) : [join(dir, f)]));
|
|
12
|
+
|
|
13
|
+
// the spec a scene's folder makes (an error says what is wrong with it)
|
|
14
|
+
export function readScene(folder) {
|
|
15
|
+
const files = Object.fromEntries(walk(folder).map(p => [relative(folder, p).split(sep).join('/'), readFileSync(p, 'utf8')]));
|
|
16
|
+
if (!('scene.json' in files)) throw new Error(`${folder}: no scene.json`);
|
|
17
|
+
const spec = JSON.parse(files['scene.json']);
|
|
18
|
+
const used = new Set(['scene.json']);
|
|
19
|
+
for (const [key, name] of Object.entries(TOP)) if (name in files) { spec[key] = files[name].replace(/\n+$/, ''); used.add(name); }
|
|
20
|
+
for (const [key, fields] of Object.entries(IN)) {
|
|
21
|
+
for (const item of spec[key] || []) {
|
|
22
|
+
if (!item || typeof item !== 'object' || !item.id) continue;
|
|
23
|
+
for (const [field, path] of Object.entries(fields)) {
|
|
24
|
+
const name = path.replace('{id}', item.id);
|
|
25
|
+
if (name in files) { item[field] = files[name].replace(/\n+$/, ''); used.add(name); }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const extra = Object.keys(files).filter(f => !used.has(f));
|
|
30
|
+
if (extra.length) throw new Error(`files no element or widget of scene.json names: ${extra.join(', ')}`);
|
|
31
|
+
return spec;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// the modules a scene lists, from its world's repository: {name: {code, data}}
|
|
35
|
+
export function readModules(world, spec) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const name of [...new Set((spec.modules || []).filter(n => typeof n === 'string'))].sort()) {
|
|
38
|
+
const code = join(world, 'modules', `${name}.js`), data = join(world, 'modules', `${name}.data.json`);
|
|
39
|
+
if (!existsSync(code)) throw new Error(`the scene lists modules/${name}.js, and there is none`);
|
|
40
|
+
out[name] = { code: readFileSync(code, 'utf8'), data: existsSync(data) ? JSON.parse(readFileSync(data, 'utf8')) : null };
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|