@moood/render 0.1.4 → 0.1.5
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 +65 -10
- package/cli.js +287 -37
- package/compose.js +71 -0
- package/engine/directions.js +3 -3
- package/engine/engine.js +1600 -150
- package/engine/engine.json +1 -1
- package/engine/gl.js +1092 -216
- package/engine/kit.js +50 -6
- package/engine/render.html +7 -3
- package/engine/scenestate.js +127 -15
- package/engine/select.js +4 -3
- package/engine/speech.js +23 -19
- package/index.js +105 -33
- package/package.json +4 -2
- package/scene.js +28 -12
- package/video.js +66 -0
package/index.js
CHANGED
|
@@ -1,89 +1,161 @@
|
|
|
1
1
|
// A moood scene rendered here, with moood's own engine (engine/: the site's web/scene, its version in
|
|
2
2
|
// engine/engine.json) in a Chromium already installed (playwright-core: nothing is downloaded) — no
|
|
3
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
|
-
//
|
|
4
|
+
// the same every time, with the same state. Only the frames taken are drawn: up to each moment the
|
|
5
|
+
// scene goes on unseen (its scripts, its state, the memory its modules keep — all as when it plays;
|
|
6
|
+
// the engine's stage.draw(false)), so a late moment costs little more than an early one.
|
|
7
|
+
// render({spec, modules, device, at, state, set, views, bare, debug, look, crop, chromium, date, engine, each})
|
|
8
|
+
// → [{at, view, png, errors, warnings, tells, look}]
|
|
6
9
|
// spec — the scene (readScene(folder) from a world's repository, or any spec);
|
|
7
10
|
// modules — the modules it lists, themselves: {name: {code, data}} (readModules(world, spec));
|
|
8
11
|
// device — the frame: 16:9 (1600×900), 4:3, 21:9 (the same height), tile (640×360), big (1920×1080);
|
|
9
12
|
// at — the moments, seconds from the scene's start (default [0]): a frame each;
|
|
10
13
|
// state — values over the spec's starting state ({"dawn": 1, "girl": "gone"});
|
|
11
14
|
// set — values set as the scene goes, as a game sets them: [[second, key, value, over?, ease?], …]
|
|
12
|
-
// (a film's beat at its second: [[4, "beat", "cover"]]);
|
|
15
|
+
// (a film's beat at its second: [[4, "beat", "cover"]]); a cut: [[4, "cut", {values}]] (scene.cut);
|
|
16
|
+
// views — other cameras on each moment: [{camera_target: "keeper", camera_shot: "close"}, {camera_x: 2},
|
|
17
|
+
// …] — a frame each (`view` 1, 2 …) of the very moment, the camera there at once; the film (its
|
|
18
|
+
// state, its clock, its own camera) goes on untouched;
|
|
13
19
|
// bare — without the title and the captions over it;
|
|
20
|
+
// debug — what is where, drawn over the frame (the scene's ?debug): ids, boxes, points, notes;
|
|
21
|
+
// true, or the ids of some elements ('keeper,boy'): only theirs;
|
|
22
|
+
// look — what is where, as numbers, with each frame (`look`): {t, camera, frame, elements, state}
|
|
23
|
+
// (the engine's stage.inspect(): each element's box in the frame's pixels, whether it is seen,
|
|
24
|
+
// its points, its notes, what its last painting cost);
|
|
25
|
+
// crop — an element's id: each frame only around it (its box, a margin round it);
|
|
26
|
+
// jpeg — frames as JPEG (`jpg` instead of `png`), quality 90: smaller, for a video;
|
|
14
27
|
// chromium — the browser's executable (else MOOOD_CHROMIUM, else Playwright's own);
|
|
15
|
-
// date — what the clock says at the start (default 2026-01-01T12:00:00Z)
|
|
28
|
+
// date — what the clock says at the start (default 2026-01-01T12:00:00Z);
|
|
29
|
+
// engine — another engine to render with: a folder like web/scene (another checkout's, an older
|
|
30
|
+
// package's engine/) — to compare engines;
|
|
31
|
+
// each — called with each frame as it is taken (async ok); then frames are not kept.
|
|
16
32
|
// Each frame: its errors, and what the scene's scripts told so far (scene.tell): [{at, text, about}].
|
|
17
33
|
// renderMotion({motion, at, views, kit, cell, chromium}) → {png, errors}
|
|
18
34
|
// a person's motion made by a model (moood's make_motion: list_motions(key, body=true) — its `body` is
|
|
19
35
|
// the motion, its `clip` the people module's entry) as frames on its body (engine/motion.html): a row
|
|
20
36
|
// per view (front, side, back, or {yaw, pitch, dist}), a column per moment (at, seconds; default eight
|
|
21
37
|
// across it); kit — the module's clip, its skeleton drawn over the body; cell — [w, h] of a frame.
|
|
22
|
-
|
|
38
|
+
// browser(chromium) — the Chromium it renders in, launched (compose.js lays frames out in it too).
|
|
39
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
40
|
+
import { join, resolve } from 'node:path';
|
|
23
41
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
42
|
import { chromium as browserType } from 'playwright-core';
|
|
25
43
|
|
|
26
44
|
export { readModules, readScene } from './scene.js';
|
|
27
45
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
export const ENGINE = JSON.parse(readFileSync(new URL('./engine/engine.json', import.meta.url), 'utf8')).engine;
|
|
46
|
+
const OWN = fileURLToPath(new URL('./engine/', import.meta.url));
|
|
47
|
+
export const ENGINE = JSON.parse(readFileSync(join(OWN, 'engine.json'), 'utf8')).engine;
|
|
31
48
|
export const DEVICES = { '16:9': [1600, 900], '4:3': [1200, 900], '21:9': [2100, 900], tile: [640, 360], big: [1920, 1080] };
|
|
32
49
|
const STEP = 100; // ms: timers fire as they come due, not all at the end of one jump
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
const FRAME = 17; // ms: one frame drawn
|
|
51
|
+
// ms before a frame taken that the scene is painted as when drawn (unseen still): what keeps a memory
|
|
52
|
+
// of its own and depends on how big it is painted (a module's detail drawn only up close) comes to
|
|
53
|
+
// the frame exactly as when the film plays; before that, coarsely
|
|
54
|
+
const SETTLE = 2000;
|
|
55
|
+
const PATIENCE = 10 * 60_000; // ms: how long anything on the page is waited for
|
|
36
56
|
|
|
37
|
-
export
|
|
57
|
+
export const browser = chromium => browserType.launch({
|
|
58
|
+
executablePath: chromium || process.env.MOOOD_CHROMIUM || undefined,
|
|
59
|
+
// WebGL on SwiftShader; 2D canvases on the CPU's Skia (SwiftShader takes minutes for many blended fills)
|
|
60
|
+
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-accelerated-2d-canvas',
|
|
61
|
+
'--allow-file-access-from-files'],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export async function render({ spec, modules = {}, device = '16:9', at = [0], state = {}, set = [], views = [], bare = false, debug = false,
|
|
65
|
+
look = false, crop = null, jpeg = false, chromium, date, engine, each } = {}) {
|
|
38
66
|
const size = DEVICES[device];
|
|
39
67
|
if (!size) throw new Error(`device: one of ${Object.keys(DEVICES).join(', ')}`);
|
|
40
68
|
if (!spec || typeof spec !== 'object') throw new Error('spec: a scene expected');
|
|
69
|
+
const dir = engine ? resolve(engine) : OWN;
|
|
70
|
+
if (!existsSync(join(dir, 'render.html'))) throw new Error(`engine: ${dir} is not an engine (no render.html in it: a folder like moood/web/scene)`);
|
|
41
71
|
const shown = { ...spec, state: { ...(spec.state && typeof spec.state === 'object' ? spec.state : {}), ...state } };
|
|
42
|
-
const
|
|
43
|
-
executablePath: chromium || process.env.MOOOD_CHROMIUM || undefined, args: ARGS,
|
|
44
|
-
});
|
|
72
|
+
const b = await browser(chromium);
|
|
45
73
|
try {
|
|
46
|
-
const page = await (await
|
|
74
|
+
const page = await (await b.newContext({ viewport: { width: size[0], height: size[1] } })).newPage();
|
|
75
|
+
// a machine under load draws slowly: what waits on the page waits long (Playwright's own: 30 s)
|
|
76
|
+
page.setDefaultTimeout(PATIENCE);
|
|
47
77
|
const errors = [];
|
|
48
78
|
page.on('pageerror', e => errors.push(String(e.message)));
|
|
49
|
-
// the clock
|
|
79
|
+
// the real clock, before the page's own is stopped: what painting costs is timed by it
|
|
80
|
+
await page.addInitScript(() => { const now = performance.now.bind(performance); window.__mooodClock = () => now(); });
|
|
81
|
+
// the clock stands still: only the frames' moments move it (install alone lets it run as time
|
|
82
|
+
// does — from a minute before, so that however slowly a loaded machine gets to stopping it, it
|
|
83
|
+
// is stopped ahead of where it is, not behind: "Cannot fast-forward to the past")
|
|
50
84
|
const start = new Date(date || '2026-01-01T12:00:00Z').getTime();
|
|
51
|
-
await page.clock.install({ time: start });
|
|
52
|
-
await page.clock.pauseAt(start
|
|
85
|
+
await page.clock.install({ time: start - 60_000 });
|
|
86
|
+
await page.clock.pauseAt(start);
|
|
53
87
|
await page.addInitScript(([s, m]) => {
|
|
54
88
|
window.__mooodSpec = s; window.__mooodModules = m;
|
|
55
89
|
// chance, the same every time (the engine's grain, rain, sparks): the same moment, the same frame
|
|
56
90
|
let seed = 1;
|
|
57
91
|
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; };
|
|
58
92
|
}, [shown, modules]);
|
|
59
|
-
|
|
93
|
+
const query = [bare && 'bare', debug && (debug === true ? 'debug' : `debug=${encodeURIComponent(debug)}`)].filter(Boolean).join('&');
|
|
94
|
+
await page.goto(`${pathToFileURL(join(dir, 'render.html')).href}${query ? `?${query}` : ''}`);
|
|
60
95
|
// what loads by itself (fonts, the scene's modules) has loaded before the clock moves at all
|
|
61
96
|
await page.evaluate(() => document.fonts.ready);
|
|
62
97
|
await page.waitForTimeout(1000);
|
|
98
|
+
// values put in its starting state that nothing in the scene uses (a mistyped key): said
|
|
99
|
+
const extra = Object.keys(state).filter(k => !(spec.state && typeof spec.state === 'object' && k in spec.state));
|
|
100
|
+
if (extra.length) await page.evaluate(k => typeof MooodState !== 'undefined' && MooodState.check?.(k, '--state'), extra);
|
|
101
|
+
// an engine that can go on unseen (older ones draw every frame up to the moment)
|
|
102
|
+
const [unseeing, inspecting, stills] = await page.evaluate(() => ['draw', 'inspect', 'still'].map(f => typeof window.__mooodStage?.[f] === 'function'));
|
|
103
|
+
if ((look || crop) && !inspecting) throw new Error('this engine does not tell what is where (look, crop): it is older than them');
|
|
63
104
|
const frames = [], sets = [...set].map(([s, ...rest]) => [Number(s) * 1000, ...rest]).sort((a, b) => a[0] - b[0]);
|
|
64
105
|
let now = 0;
|
|
65
106
|
// the values due by now, set (the clock stops at each one's second: it is set on time)
|
|
66
107
|
const due = async () => {
|
|
67
108
|
while (sets.length && sets[0][0] <= now) {
|
|
68
109
|
const [, key, value, over, ease] = sets.shift();
|
|
69
|
-
await page.evaluate(([k, v, o, e]) => MooodState.set(k, v, o, e), [key, value, over, ease]);
|
|
110
|
+
await page.evaluate(([k, v, o, e]) => (k === 'cut' ? MooodState.cut(v, { who: '--set' }) : MooodState.set(k, v, o, e, { who: '--set' })), [key, value, over, ease]);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const run = async ms => { await page.clock.runFor(ms); now += ms; await due(); };
|
|
114
|
+
// on to `ms`, the clock stopping at each value's second (what is due by then is set)
|
|
115
|
+
const until = async ms => { while (now < ms) await run(Math.min(STEP, ms - now, sets.length ? sets[0][0] - now : STEP)); };
|
|
116
|
+
const draw = how => unseeing && page.evaluate(v => window.__mooodStage.draw(v), how);
|
|
117
|
+
// (keys set from outside that nothing in the scene has read — a mistyped one — said with the last
|
|
118
|
+
// moment's frame: by then whatever reads a key late has read it)
|
|
119
|
+
const last = Math.max(...at.map(Number));
|
|
120
|
+
const shot = async (t, view) => {
|
|
121
|
+
const [said, warned, tells, seen] = await page.evaluate(([l, end]) => [window.__mooodErrors || [], [...(window.__mooodWarnings || []), ...(end ? MooodState.unknown?.() || [] : [])], window.__mooodTells || [],
|
|
122
|
+
l ? { ...window.__mooodStage.inspect(), state: MooodState.all() } : null], [look || !!crop, t === last]);
|
|
123
|
+
let clip;
|
|
124
|
+
if (crop) {
|
|
125
|
+
const el = seen.elements[crop];
|
|
126
|
+
if (!el) throw new Error(`crop: there is no element "${crop}" (its id): ${Object.keys(seen.elements).join(', ')}`);
|
|
127
|
+
if (el.seen && el.box) {
|
|
128
|
+
const [x, y, w, h] = el.box, m = 0.1 * Math.max(w, h), x0 = Math.max(0, x - m), y0 = Math.max(0, y - m);
|
|
129
|
+
clip = { x: x0, y: y0, width: Math.min(size[0], x + w + m) - x0, height: Math.min(size[1], y + h + m) - y0 };
|
|
130
|
+
if (!(clip.width >= 8 && clip.height >= 8)) clip = undefined;
|
|
131
|
+
}
|
|
132
|
+
if (!clip) errors.push(`crop: "${crop}" is not in the frame at ${t}s — the whole frame instead`);
|
|
70
133
|
}
|
|
134
|
+
const image = await page.screenshot(jpeg ? { type: 'jpeg', quality: 90, clip } : { type: 'png', clip });
|
|
135
|
+
const frame = { at: t, view, [jpeg ? 'jpg' : 'png']: image, errors: [...new Set([...errors, ...said])].slice(0, 100), warnings: [...new Set(warned)].slice(0, 100), tells, ...(look ? { look: seen } : {}) };
|
|
136
|
+
if (each) await each(frame); else frames.push(frame);
|
|
71
137
|
};
|
|
72
138
|
await due();
|
|
73
139
|
for (const t of [...at].map(Number).sort((a, b) => a - b)) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
await due();
|
|
140
|
+
// unseen up to a frame before the moment (its last seconds painted as when drawn); that frame drawn
|
|
141
|
+
if (now < t * 1000 - FRAME) {
|
|
142
|
+
if (now < t * 1000 - SETTLE) { await draw(false); await until(t * 1000 - SETTLE); }
|
|
143
|
+
await draw('painted'); await until(t * 1000 - FRAME); await draw(true);
|
|
79
144
|
}
|
|
145
|
+
// (the scene's first frame is drawn at the first display refresh: a moment before it is that frame)
|
|
146
|
+
await until(Math.max(t * 1000, FRAME));
|
|
80
147
|
await page.clock.runFor(0);
|
|
81
|
-
|
|
82
|
-
|
|
148
|
+
await shot(t);
|
|
149
|
+
if (!views.length) continue;
|
|
150
|
+
if (!stills) throw new Error('views: this engine cannot draw a moment from another camera (stage.still): it is older than that');
|
|
151
|
+
for (const [i, view] of views.entries()) {
|
|
152
|
+
await page.evaluate(v => window.__mooodStage.still(v), view);
|
|
153
|
+
await shot(t, i + 1);
|
|
154
|
+
}
|
|
83
155
|
}
|
|
84
156
|
return frames;
|
|
85
157
|
} finally {
|
|
86
|
-
await
|
|
158
|
+
await b.close();
|
|
87
159
|
}
|
|
88
160
|
}
|
|
89
161
|
|
|
@@ -91,17 +163,17 @@ export async function renderMotion({ motion, at, views = ['front', 'side'], kit
|
|
|
91
163
|
if (!motion || !Array.isArray(motion.names)) throw new Error('motion: the body of a made motion expected (list_motions(key, body=true).body)');
|
|
92
164
|
const length = (motion.n - 1) / motion.fps;
|
|
93
165
|
const moments = at && at.length ? at.map(s => Math.min(Math.max(0, Number(s)), length)) : Array.from({ length: 8 }, (_, i) => +(length * i / 7).toFixed(2));
|
|
94
|
-
const
|
|
166
|
+
const b = await browser(chromium);
|
|
95
167
|
try {
|
|
96
|
-
const page = await (await
|
|
168
|
+
const page = await (await b.newContext({ viewport: { width: cell[0] * moments.length, height: cell[1] * views.length } })).newPage();
|
|
97
169
|
const errors = [];
|
|
98
170
|
page.on('pageerror', e => errors.push(String(e.message)));
|
|
99
171
|
await page.addInitScript(([m, s]) => { window.__mooodMotion = m; window.__mooodMotionShow = s; }, [motion, { at: moments, views, kit, cell }]);
|
|
100
|
-
await page.goto(
|
|
172
|
+
await page.goto(pathToFileURL(join(OWN, 'motion.html')).href);
|
|
101
173
|
await page.waitForFunction(() => document.title === 'ready', null, { timeout: 120_000 });
|
|
102
174
|
errors.push(...await page.evaluate(() => window.__mooodRenderErrors || []));
|
|
103
175
|
return { png: await page.screenshot({ type: 'png' }), errors: [...new Set(errors)] };
|
|
104
176
|
} finally {
|
|
105
|
-
await
|
|
177
|
+
await b.close();
|
|
106
178
|
}
|
|
107
179
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moood/render",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A moood scene rendered locally — the site's own engine, in a browser already installed — at
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "A moood scene rendered locally — the site's own engine, in a browser already installed — at any moment of it, with a state: frames, a sheet, a video, what is where as numbers, two versions compared; and the errors its code threw. And a person's motion made by a model, as frames on its body.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"index.js",
|
|
13
13
|
"cli.js",
|
|
14
14
|
"scene.js",
|
|
15
|
+
"compose.js",
|
|
16
|
+
"video.js",
|
|
15
17
|
"engine/",
|
|
16
18
|
"README.md"
|
|
17
19
|
],
|
package/scene.js
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
// A scene as it lies in its world's repository, put together as moood does it (the scenes service's
|
|
2
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).
|
|
3
|
+
// elements/<id>.script.js (objects[].script), script.js, lib.js, relief.js, notes.md, widgets/<id>.html — and
|
|
4
|
+
// the modules it lists (modules/<name>.js, .data.json, in the world's repository). As it is on disk,
|
|
5
|
+
// or as a commit of the repository has it (`ref`: a branch, a tag, a commit — the world's main, say).
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
5
7
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
8
|
import { join, relative, sep } from 'node:path';
|
|
7
9
|
|
|
8
|
-
const TOP = { script: 'script.js', lib: 'lib.js', notes: 'notes.md' };
|
|
10
|
+
const TOP = { script: 'script.js', lib: 'lib.js', relief: 'relief.js', notes: 'notes.md' };
|
|
9
11
|
const IN = { objects: { code: 'elements/{id}.js', script: 'elements/{id}.script.js' }, widgets: { html: 'widgets/{id}.html' } };
|
|
10
12
|
|
|
11
13
|
const walk = dir => readdirSync(dir).flatMap(f => (statSync(join(dir, f)).isDirectory() ? walk(join(dir, f)) : [join(dir, f)]));
|
|
14
|
+
const git = (world, ...args) => execFileSync('git', ['-C', world, ...args], { encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
15
|
+
// a file at a commit (null: there is none) — its path from the world's folder, which may lie deeper in
|
|
16
|
+
// its repository (./: from there)
|
|
17
|
+
const shown = (world, ref, path) => { try { return git(world, 'show', `${ref}:./${path}`); } catch { return null; } };
|
|
12
18
|
|
|
13
|
-
// the spec a scene's folder makes (an error says what is wrong with it)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
// the spec a scene's folder makes (an error says what is wrong with it); `ref` — as that commit of the
|
|
20
|
+
// world's repository (`world`) has it
|
|
21
|
+
export function readScene(folder, { world, ref } = {}) {
|
|
22
|
+
let files;
|
|
23
|
+
if (ref) {
|
|
24
|
+
const at = relative(world, folder).split(sep).join('/');
|
|
25
|
+
try { git(world, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`); } catch { throw new Error(`${ref}: no such branch or commit in ${world}`); }
|
|
26
|
+
// (-z: the names as they are — any letters — not quoted; from the world's folder)
|
|
27
|
+
const names = git(world, 'ls-tree', '-r', '-z', '--name-only', ref, '--', `${at}/`).split('\0').filter(Boolean);
|
|
28
|
+
if (!names.length) throw new Error(`${ref}: there is no ${at} in it`);
|
|
29
|
+
files = Object.fromEntries(names.map(p => [p.slice(at.length + 1), shown(world, ref, p)]));
|
|
30
|
+
} else files = Object.fromEntries(walk(folder).map(p => [relative(folder, p).split(sep).join('/'), readFileSync(p, 'utf8')]));
|
|
31
|
+
if (!('scene.json' in files)) throw new Error(`${folder}${ref ? ` (${ref})` : ''}: no scene.json`);
|
|
17
32
|
const spec = JSON.parse(files['scene.json']);
|
|
18
33
|
const used = new Set(['scene.json']);
|
|
19
34
|
for (const [key, name] of Object.entries(TOP)) if (name in files) { spec[key] = files[name].replace(/\n+$/, ''); used.add(name); }
|
|
@@ -31,13 +46,14 @@ export function readScene(folder) {
|
|
|
31
46
|
return spec;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
// the modules a scene lists, from its world's repository: {name: {code, data}}
|
|
35
|
-
export function readModules(world, spec) {
|
|
49
|
+
// the modules a scene lists, from its world's repository: {name: {code, data}} (`ref`: at that commit)
|
|
50
|
+
export function readModules(world, spec, { ref } = {}) {
|
|
51
|
+
const read = path => (ref ? shown(world, ref, path) : existsSync(join(world, path)) ? readFileSync(join(world, path), 'utf8') : null);
|
|
36
52
|
const out = {};
|
|
37
53
|
for (const name of [...new Set((spec.modules || []).filter(n => typeof n === 'string'))].sort()) {
|
|
38
|
-
const code =
|
|
39
|
-
if (
|
|
40
|
-
out[name] = { code
|
|
54
|
+
const code = read(`modules/${name}.js`), data = read(`modules/${name}.data.json`);
|
|
55
|
+
if (code == null) throw new Error(`the scene lists modules/${name}.js, and there is none${ref ? ` at ${ref}` : ''}`);
|
|
56
|
+
out[name] = { code, data: data == null ? null : JSON.parse(data) };
|
|
41
57
|
}
|
|
42
58
|
return out;
|
|
43
59
|
}
|
package/video.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Frames into a video — by an ffmpeg already on this machine (nothing is downloaded): MOOOD_FFMPEG,
|
|
2
|
+
// else the one on the PATH, else the one Playwright installs beside its browsers (that one writes
|
|
3
|
+
// .webm only). The frames come in as JPEGs.
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { dirname, extname, join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
// what each kind of file is written with: the encoder it needs, and how
|
|
10
|
+
const KINDS = {
|
|
11
|
+
'.mp4': ['libx264', ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-preset', 'veryfast', '-movflags', '+faststart']],
|
|
12
|
+
'.webm': ['libvpx', ['-c:v', 'libvpx', '-b:v', '4M', '-crf', '8', '-auto-alt-ref', '0']],
|
|
13
|
+
'.gif': ['gif', ['-vf', 'split[a][b];[a]palettegen[p];[b][p]paletteuse']],
|
|
14
|
+
};
|
|
15
|
+
export const VIDEOS = Object.keys(KINDS);
|
|
16
|
+
|
|
17
|
+
// the ffmpegs here: the one asked for, the PATH's, Playwright's (beside its browsers: where it keeps
|
|
18
|
+
// them on Linux, macOS or Windows, or up from the browser given)
|
|
19
|
+
function ffmpegs(chromium) {
|
|
20
|
+
const found = [process.env.MOOOD_FFMPEG, 'ffmpeg'];
|
|
21
|
+
const homes = [process.env.PLAYWRIGHT_BROWSERS_PATH, join(homedir(), '.cache', 'ms-playwright'), join(homedir(), 'Library', 'Caches', 'ms-playwright'),
|
|
22
|
+
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'ms-playwright')];
|
|
23
|
+
for (let d = chromium && dirname(chromium); d && dirname(d) !== d; d = dirname(d)) homes.push(d);
|
|
24
|
+
for (const home of homes.filter(h => h && existsSync(h))) {
|
|
25
|
+
for (const f of readdirSync(home).filter(f => f.startsWith('ffmpeg-')).sort().reverse()) {
|
|
26
|
+
for (const exe of ['ffmpeg-linux', 'ffmpeg-mac', 'ffmpeg-win64.exe']) if (existsSync(join(home, f, exe))) found.push(join(home, f, exe));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(found.filter(Boolean))];
|
|
30
|
+
}
|
|
31
|
+
const encoders = exe => { const r = spawnSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8' }); return r.status === 0 ? r.stdout : null; };
|
|
32
|
+
|
|
33
|
+
// a video being written to `out` at `fps` frames a second: write(jpeg) each frame, then done()
|
|
34
|
+
export function video(out, fps, { chromium } = {}) {
|
|
35
|
+
const kind = KINDS[extname(out).toLowerCase()];
|
|
36
|
+
if (!kind) throw new Error(`--video: ${out} — a .mp4, .webm or .gif`);
|
|
37
|
+
const [need, how] = kind, tried = [];
|
|
38
|
+
let exe = null;
|
|
39
|
+
for (const f of ffmpegs(chromium || process.env.MOOOD_CHROMIUM)) {
|
|
40
|
+
const list = encoders(f);
|
|
41
|
+
if (list == null) continue;
|
|
42
|
+
tried.push(f);
|
|
43
|
+
if (new RegExp(`^\\s*V\\S*\\s+${need}\\s`, 'm').test(list)) { exe = f; break; }
|
|
44
|
+
}
|
|
45
|
+
if (!exe) {
|
|
46
|
+
throw new Error(tried.length
|
|
47
|
+
? `--video: ${extname(out)} needs an ffmpeg with ${need}, and ${tried.join(', ')} ${tried.length > 1 ? 'have' : 'has'} none — write .webm, or install ffmpeg, or point MOOOD_FFMPEG at one`
|
|
48
|
+
: '--video: no ffmpeg here — install it (or point MOOOD_FFMPEG at one); Playwright\'s own (npx playwright install ffmpeg) writes .webm');
|
|
49
|
+
}
|
|
50
|
+
const p = spawn(exe, ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(fps), '-i', '-', ...how, out], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
51
|
+
let said = '';
|
|
52
|
+
p.stderr.on('data', d => { said += d; });
|
|
53
|
+
// (ffmpeg gone — it could not start, or it stopped: what it said is the error, the frames' pipe
|
|
54
|
+
// closing with it is not)
|
|
55
|
+
const ended = new Promise((ok, no) => {
|
|
56
|
+
p.on('error', e => no(new Error(`--video: ffmpeg (${exe}) could not run: ${e.message}`)));
|
|
57
|
+
p.on('close', code => (code === 0 ? ok() : no(new Error(`--video: ffmpeg (${exe}) failed: ${said.trim() || `exit ${code}`}`))));
|
|
58
|
+
});
|
|
59
|
+
ended.catch(() => {});
|
|
60
|
+
p.stdin.on('error', () => {});
|
|
61
|
+
return {
|
|
62
|
+
ffmpeg: exe,
|
|
63
|
+
write: jpeg => new Promise((ok, no) => p.stdin.write(jpeg, e => (e ? ended.then(() => no(e), no) : ok()))),
|
|
64
|
+
done: async () => { p.stdin.end(); await ended; },
|
|
65
|
+
};
|
|
66
|
+
}
|