@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/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A scene of [moood](https://moood.me) rendered on your machine — with moood's own engine, the version
|
|
4
4
|
the site runs (`moood-render --version`), in a Chromium you have already (nothing is downloaded, no
|
|
5
|
-
network): a
|
|
5
|
+
network): frames at any second of it, a sheet of them, a video of a stretch, what is where as numbers,
|
|
6
|
+
two versions side by side — and the errors the scene's code threw and what the engine found wrong
|
|
7
|
+
with it (a painting cut off by its box in the frame, an unknown camera target …).
|
|
6
8
|
|
|
7
9
|
npm i -g @moood/render
|
|
8
10
|
moood-render <world>/scenes/<folder> --at 0,2.5 --device 16:9 --out frame.png
|
|
@@ -10,28 +12,81 @@ network): a PNG and the errors the scene's code threw.
|
|
|
10
12
|
- **A scene** is a folder of a world's repository (`scene.json` and its code beside it — see
|
|
11
13
|
https://github.com/moood-me/docs), or a spec as a `.json` file. The modules it lists are read from
|
|
12
14
|
the world's `modules/` (`--world <the world's repository>`; by default two folders up).
|
|
13
|
-
- **`--at`** — moments, in seconds from the scene's start: a frame each (`frame-2.5s.png`)
|
|
14
|
-
|
|
15
|
-
same every run.
|
|
16
|
-
|
|
15
|
+
- **`--at`** — moments, in seconds from the scene's start: a frame each (`frame-2.5s.png`); `0:90:10`
|
|
16
|
+
— every 10 s from 0 to 90. Time is a virtual clock stepped from the start; chance (`Math.random`)
|
|
17
|
+
is the same every run. **Only the frames taken are drawn**: up to each moment the scene goes on
|
|
18
|
+
unseen — its scripts, its state, the memory its modules keep (springs, steps: their code runs every
|
|
19
|
+
frame as for something out of sight) — so the 80th second costs seconds, not minutes, and looks as
|
|
20
|
+
it does when the film plays through. The same moment and state give the same picture —
|
|
21
|
+
pixel-close, not bit-equal (the grain, the GPU emulation's rounding).
|
|
17
22
|
- **`--state '{"dawn": 1}'`** — values over the scene's starting state.
|
|
18
23
|
- **`--set '[[4, "beat", "cover"], [6, "dawn", 1, 3]]'`** — values set as the scene goes, as a game sets
|
|
19
24
|
them: `[second, key, value, over?, ease?]` (the clock stops at each one's second). A film's beat at
|
|
20
|
-
its second; a branch begun mid-beat.
|
|
25
|
+
its second; a branch begun mid-beat. `[4, "cut", {"camera_target": "keeper"}]` — a cut (`scene.cut`).
|
|
26
|
+
- **`--states '[{"pose": "bow"}, {"pose": "kneel", "facing": 90}]'`** — the scene played from its start
|
|
27
|
+
once for each of these (over `--state`): a frame each at each moment (`frame-s1-2s.png` …); with
|
|
28
|
+
`--sheet`, one picture of them all — a row for each, its moments across, labelled with its values — a gallery of a figure's poses (a
|
|
29
|
+
world's stand scene that shows a person in the pose its state names), a value's steps side by side.
|
|
30
|
+
- **`--views '[{"camera_target": "keeper", "camera_shot": "close"}, {"camera_x": 2}]'`** — other cameras on
|
|
31
|
+
each moment: a frame each (`frame-12s-v1.png` …) of that very moment, the camera there at once; the
|
|
32
|
+
film itself (its state, its clock, its own camera) goes on untouched.
|
|
33
|
+
- **`--sheet sheet.png`** — the frames in one picture, each labelled with its second (and view).
|
|
34
|
+
- **`--video walk.mp4 --from 40 --to 47 --fps 12`** — that stretch as a video (`.mp4`, `.webm`, `.gif`):
|
|
35
|
+
rhythm, weight, follow-through, which a sheet only hints at. Written by an ffmpeg already here —
|
|
36
|
+
`MOOOD_FFMPEG`, else the one on the PATH, else the one Playwright keeps beside its browsers (that
|
|
37
|
+
one writes `.webm` only). `--device tile` (640×360) for a quick look.
|
|
38
|
+
- **`--debug`** — what is where, drawn over the frame (the scene's `?debug` on the site): each element
|
|
39
|
+
seen — its box and id, its anchor (a ring), its `e.at` point (a cross), its named points (`#name`), its parts
|
|
40
|
+
(dashed), what its last painting cost,
|
|
41
|
+
and what its code wrote with `e.debug(value, x?, y?)` (a module's poses and their weights, a target
|
|
42
|
+
as a point); the scene's second, and its errors and warnings at the top. On the site, `?debug=keeper,boy` — only those.
|
|
43
|
+
- **`--json`** — the same as numbers, beside each frame (`frame-8s.json`): `t`, `camera`, `frame`,
|
|
44
|
+
`state` (every value at that moment), and per element (by id) `kind`, `seen`, `box` `[x, y, w, h]` (in
|
|
45
|
+
the frame's pixels — where its painting may reach), `anchor`, `at`, `points` (those its code names:
|
|
46
|
+
`e.at(x, y, name)`), `parts` (`e.part`: each one's box and distance), `distance` (m), `fade` (as it comes
|
|
47
|
+
within 60 cm of the eye), `notes` (`e.debug`), `paint` `{ms, repaint: frame | state | once}`.
|
|
48
|
+
- **`--crop <id>`** — each frame only around that element (its box, a margin round it).
|
|
49
|
+
- **`--vs <ref | folder | spec.json>`** — the same moments of another version of the scene: a branch or
|
|
50
|
+
commit of the world's repository (`--vs main`: before your branch), or another checkout's folder.
|
|
51
|
+
Each frame is A | B | where they differ (red, brighter the more), and how much: `frame-3s.png —
|
|
52
|
+
differs: 0.42% of pixels, by up to 37`. Chance is seeded the same in both, so fire and water flicker
|
|
53
|
+
alike and the difference is what changed. **`--vs-engine <dir>`** — rendered by another engine (a folder
|
|
54
|
+
like `moood/web/scene` of another checkout); with both, another scene by another engine. An engine
|
|
55
|
+
older than this one draws every frame up to the moment (slowly).
|
|
56
|
+
- **A world** (its folder, or its `scenes/`) instead of a scene: each of its scenes in turn, the files
|
|
57
|
+
named by their folders (`frame-kukhnya-2s.png`, `sheet-kukhnya.png`). With `--vs main` — before
|
|
58
|
+
merging a change to a module or the engine (`--vs-engine`): every scene of the world before and
|
|
59
|
+
after, and at the end the scenes listed by how much they changed, the most changed first; a scene
|
|
60
|
+
new on the branch is said so.
|
|
21
61
|
- **What the scene told** (`scene.tell` — a film's `beat:<name>` as a beat ends) is printed with its
|
|
22
62
|
second: `told at 4.50s: beat:cover`.
|
|
23
63
|
- **`--device`** — `16:9` (1600×900), `4:3`, `21:9` (the same height), `tile` (640×360), `big` (1920×1080).
|
|
24
64
|
- **`--bare`** — without the title and captions over it.
|
|
25
65
|
- **The browser**: `--chromium <path>`, or `MOOOD_CHROMIUM`, or Playwright's own (`PLAYWRIGHT_BROWSERS_PATH`).
|
|
26
66
|
|
|
67
|
+
**What is wrong** is said on stderr, once each, with the second it was first seen: errors the scene's
|
|
68
|
+
code threw (exit 1); and, as `warning: …` (they leave the exit code 0), what the engine found (a
|
|
69
|
+
painting cut off by its box, an unknown `camera_target`); a key
|
|
70
|
+
set from outside — `--set`, `--state`, or the game on the site — that the scene does not know (not
|
|
71
|
+
in its `state`, and nothing in it — its scripts, its painting — has read it by the last moment taken:
|
|
72
|
+
a mistyped key does nothing, silently); and what
|
|
73
|
+
its modules said they do not know — a module calls `e.warn(text)` (an element's; `scene.warn` in a
|
|
74
|
+
script), and the text is said once: `Pete: no pose "wawe" — the poses: stand, wave, crouch, point`.
|
|
75
|
+
|
|
76
|
+
Asked for wrongly (an unknown option, a moment that is not a number, `--set` given one row, a world
|
|
77
|
+
instead of a scene …), it says what is wrong and how it is asked for, and exits 2. The paths it
|
|
78
|
+
writes are printed. Exits 1 if the scene's code threw or the engine found something wrong.
|
|
79
|
+
|
|
27
80
|
From code:
|
|
28
81
|
|
|
29
82
|
import { render, readScene, readModules } from '@moood/render';
|
|
30
|
-
const spec = readScene('world/scenes/kukhnya');
|
|
31
|
-
const [frame] = await render({ spec, modules: readModules('world', spec), at: [
|
|
32
|
-
// frame.png (a Buffer), frame.errors, frame.tells ([{at, text, about}] so far)
|
|
83
|
+
const spec = readScene('world/scenes/kukhnya'); // readScene(folder, {world, ref: 'main'}) — at a commit
|
|
84
|
+
const [frame] = await render({ spec, modules: readModules('world', spec), at: [80], look: true });
|
|
85
|
+
// frame.png (a Buffer), frame.errors, frame.tells ([{at, text, about}] so far), frame.look (--json)
|
|
33
86
|
|
|
34
|
-
|
|
87
|
+
`render` also takes `debug`, `crop`, `engine` (another engine's folder) and `each` (called with each
|
|
88
|
+
frame as it is taken, instead of keeping them); `compose.js` lays frames out (`sheet`, `compare`),
|
|
89
|
+
`video.js` writes them as a video.
|
|
35
90
|
|
|
36
91
|
## A person's motion
|
|
37
92
|
|
package/cli.js
CHANGED
|
@@ -1,50 +1,300 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// moood-render <scene folder | spec.json
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { statSync, writeFileSync, readFileSync } from 'node:fs';
|
|
10
|
-
import { dirname, resolve } from 'node:path';
|
|
2
|
+
// moood-render <scene folder | spec.json | world> — a scene (or each of a world's) rendered here (moood-render --help says how).
|
|
3
|
+
// Frames (or a sheet of them, a video, two versions compared) written where --out says, their paths
|
|
4
|
+
// on stdout; what the scene's scripts told (scene.tell) on stdout; the errors its code threw on stderr
|
|
5
|
+
// (exit 1), and what the engine found wrong with it (warning: …) — exit 0 for those alone. Asked for
|
|
6
|
+
// wrongly: what is wrong, on stderr (exit 2).
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
11
9
|
import { parseArgs } from 'node:util';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { DEVICES, ENGINE, readModules, readScene, render, renderMotion } from './index.js';
|
|
12
|
+
import { compare, sheet } from './compose.js';
|
|
13
|
+
import { VIDEOS, video } from './video.js';
|
|
14
|
+
|
|
15
|
+
const HELP = `moood-render <world>/scenes/<folder> | <spec.json> | <world> [options]
|
|
16
|
+
moood-render motion <motion.json> [--at 0,1.5,3] [--views front,side] [--kit] [--out motion.png]
|
|
17
|
+
a person's motion made by a model (list_motions(key, body=true) saved as a file) as frames on its
|
|
18
|
+
body: a row per view (front, side, back), a column per moment (default eight across it); --kit —
|
|
19
|
+
the people module's skeleton over it
|
|
20
|
+
|
|
21
|
+
a world (or its scenes/): each of its scenes in turn, the files named by their folders
|
|
22
|
+
(frame-kukhnya-2s.png) — with --vs main, every scene before and after, listed at the end, the most changed first
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
--at 0,2.5,10 the moments, in seconds from the scene's start: a frame each;
|
|
25
|
+
a range from:to:step — 0:20:2 — is every 2 s from 0 to 20
|
|
26
|
+
--out frame.png where (several frames: frame-2.5s.png …)
|
|
27
|
+
--device 16:9 ${Object.keys(DEVICES).join(', ')}
|
|
28
|
+
--state '{"dawn": 1}' values over the scene's starting state
|
|
29
|
+
--set '[[4, "beat", "cover"]]' values set as the scene goes: [second, key, value, over?, ease?]
|
|
30
|
+
--views '[{"camera_target": "keeper"}]' other cameras on each moment (the film untouched)
|
|
31
|
+
--states '[{"pose": "bow"}, {"pose": "kneel"}]' the scene played from its start once for each of
|
|
32
|
+
these (over --state): a frame each at each moment (frame-s1-2s.png …) — with
|
|
33
|
+
--sheet, a grid of variants (a figure's poses, a value's steps) in one picture
|
|
34
|
+
--bare without the title and captions
|
|
35
|
+
--debug what is where, over the frame: ids, boxes, anchors, points, the code's notes (e.debug)
|
|
36
|
+
--json what is where, as numbers, beside each frame (frame-2.5s.json): each element's box
|
|
37
|
+
in the frame's pixels, whether it is seen, its points, notes, paint cost; the state
|
|
38
|
+
--crop <id> each frame only around that element
|
|
39
|
+
--sheet <out.png> the frames in one picture, labelled with their seconds
|
|
40
|
+
--video <out.mp4> --from 2 --to 8 [--fps 12] that stretch as a video (.mp4, .webm, .gif)
|
|
41
|
+
--vs <ref | folder | spec.json> the same moments of another version of the scene — a branch or
|
|
42
|
+
commit of the world's repository (main), another checkout's folder: side by side
|
|
43
|
+
with where they differ (frame-2.5s.png: A | B | difference)
|
|
44
|
+
--vs-engine <dir> … rendered by another engine: a folder like moood/web/scene
|
|
45
|
+
--world <dir> the world's repository (default: two folders up from the scene's)
|
|
46
|
+
--chromium <path> the browser (else MOOOD_CHROMIUM, else Playwright's own)
|
|
47
|
+
--version the engine's version`;
|
|
48
|
+
|
|
49
|
+
const OPTIONS = {
|
|
50
|
+
world: { type: 'string' }, device: { type: 'string', default: '16:9' }, at: { type: 'string' },
|
|
51
|
+
state: { type: 'string' }, states: { type: 'string' }, set: { type: 'string' }, views: { type: 'string' }, bare: { type: 'boolean' }, debug: { type: 'boolean' },
|
|
52
|
+
json: { type: 'boolean' }, crop: { type: 'string' }, sheet: { type: 'string' }, video: { type: 'string' },
|
|
53
|
+
from: { type: 'string' }, to: { type: 'string' }, fps: { type: 'string' }, vs: { type: 'string' }, 'vs-engine': { type: 'string' },
|
|
54
|
+
out: { type: 'string', default: 'frame.png' }, chromium: { type: 'string' }, version: { type: 'boolean' }, help: { type: 'boolean' },
|
|
55
|
+
kit: { type: 'boolean' },
|
|
56
|
+
};
|
|
57
|
+
// asked for wrongly: said plainly, with the way it is asked for
|
|
58
|
+
function wrong(msg) { console.error(`moood-render: ${msg}\n(moood-render --help — all it takes)`); process.exit(2); }
|
|
59
|
+
// the option meant, for one mistyped
|
|
60
|
+
const edits = (a, b) => { let row = [...Array(b.length + 1).keys()]; for (const x of a) { const next = [row[0] + 1]; [...b].forEach((y, j) => next.push(Math.min(row[j + 1] + 1, next[j] + 1, row[j] + (x === y ? 0 : 1)))); row = next; } return row[b.length]; };
|
|
61
|
+
const near = name => Object.keys(OPTIONS).map(k => [k, k.startsWith(name) ? 0 : edits(name, k)]).filter(([, d]) => d <= 2).sort((a, b) => a[1] - b[1])[0]?.[0];
|
|
62
|
+
|
|
63
|
+
let o, rest;
|
|
64
|
+
try { ({ values: o, positionals: rest } = parseArgs({ allowPositionals: true, options: OPTIONS })); } catch (e) {
|
|
65
|
+
const opt = /'(-{1,2}[^' ]+)/.exec(e.message)?.[1], m = opt && near(opt.replace(/^-+/, ''));
|
|
66
|
+
wrong(e.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' ? `no option ${opt}${m ? ` — --${m}?` : ''}` : e.message.split('\n')[0]);
|
|
67
|
+
}
|
|
68
|
+
if (o.help) { console.log(HELP); process.exit(0); }
|
|
69
|
+
if (o.version) { console.log(`moood engine ${ENGINE}`); process.exit(0); }
|
|
70
|
+
if (!rest.length) wrong('which scene? moood-render <world>/scenes/<folder>');
|
|
71
|
+
// a person's motion (moood-render motion <motion.json>)
|
|
72
|
+
if (rest[0] === 'motion') {
|
|
73
|
+
if (rest.length !== 2) wrong('which motion? moood-render motion <motion.json> (list_motions(key, body=true) saved as a file)');
|
|
74
|
+
let made;
|
|
75
|
+
try { made = JSON.parse(readFileSync(rest[1], 'utf8')); } catch (e) { wrong(`${rest[1]}: not a motion (JSON): ${e.message}`); }
|
|
76
|
+
const views = o.views ? o.views.split(',') : ['front', 'side'], at = o.at ? o.at.split(',').map(Number) : null;
|
|
77
|
+
const { png, errors } = await renderMotion({ motion: made.body || made, kit: o.kit ? made.clip : null, chromium: o.chromium, at, views });
|
|
29
78
|
const out = o.out === 'frame.png' ? 'motion.png' : o.out;
|
|
30
79
|
writeFileSync(out, png);
|
|
31
80
|
console.log(out);
|
|
32
81
|
for (const e of errors) console.error(e);
|
|
33
82
|
process.exit(errors.length ? 1 : 0);
|
|
34
83
|
}
|
|
84
|
+
if (o.kit) wrong('--kit is for a motion (moood-render motion <motion.json> --kit)');
|
|
85
|
+
if (rest.length > 1) wrong(`one scene at a time — and ${rest.slice(1).map(a => `"${a}"`).join(', ')}? (a list of moments: --at 0,2,4, no spaces)`);
|
|
86
|
+
const what = resolve(rest[0]);
|
|
87
|
+
|
|
88
|
+
// ── what is asked ──────────────────────────────────────────────────────
|
|
89
|
+
const json = (name, text, check, example) => {
|
|
90
|
+
if (text == null) return undefined;
|
|
91
|
+
let v;
|
|
92
|
+
try { v = JSON.parse(text); } catch { wrong(`--${name}: not JSON — ${example} (keys and texts in double quotes; the whole in single quotes)`); }
|
|
93
|
+
const bad = check(v);
|
|
94
|
+
if (bad) wrong(`--${name}: ${bad} — ${example}`);
|
|
95
|
+
return v;
|
|
96
|
+
};
|
|
97
|
+
const plain = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
98
|
+
const state = json('state', o.state, v => !plain(v) && 'an object of values', `'{"dawn": 1, "girl": "gone"}'`);
|
|
99
|
+
const set = json('set', o.set, v => {
|
|
100
|
+
if (!Array.isArray(v)) return 'a list of rows';
|
|
101
|
+
if (v.length && !Array.isArray(v[0])) return 'a list of rows, and this is one row: wrap it in [ ]';
|
|
102
|
+
const i = v.findIndex(r => !Array.isArray(r) || r.length < 3 || typeof r[0] !== 'number' || r[0] < 0 || typeof r[1] !== 'string' || (r[1] === 'cut' && !plain(r[2])));
|
|
103
|
+
return i >= 0 && `row ${i + 1} (${JSON.stringify(v[i])}) is not [second, "key", value, over?, ease?]${v[i]?.[1] === 'cut' ? ' — a cut: [second, "cut", {values}]' : ''}`;
|
|
104
|
+
}, `'[[4, "beat", "cover"], [6, "dawn", 1, 3], [9, "cut", {"camera_target": "keeper"}]]'`);
|
|
105
|
+
const states = json('states', o.states, v => (plain(v) ? 'a list of states, and this is one: wrap it in [ ]' : !Array.isArray(v) || !v.length || !v.every(plain) ? 'a list of objects of values' : null),
|
|
106
|
+
`'[{"pose": "bow"}, {"pose": "kneel", "facing": 90}]'`);
|
|
107
|
+
const views = json('views', o.views, v => (plain(v) ? 'a list of cameras, and this is one: wrap it in [ ]' : !Array.isArray(v) || !v.every(plain) ? 'a list of objects of values' : null),
|
|
108
|
+
`'[{"camera_target": "keeper", "camera_shot": "close"}, {"camera_x": 2}]'`);
|
|
109
|
+
if (!DEVICES[o.device]) wrong(`--device ${o.device}: one of ${Object.keys(DEVICES).join(', ')}`);
|
|
35
110
|
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
111
|
+
const seconds = (name, text) => {
|
|
112
|
+
const v = Number(text);
|
|
113
|
+
if (text == null || text.trim() === '' || !Number.isFinite(v) || v < 0) wrong(`--${name} ${text ?? ''}: seconds from the scene's start (a number, 0 or more)`);
|
|
114
|
+
return v;
|
|
115
|
+
};
|
|
116
|
+
// --at: moments and ranges of them
|
|
117
|
+
function moments(text) {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const part of text.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
120
|
+
const r = part.split(':');
|
|
121
|
+
if (r.length === 1) { out.push(seconds('at', part)); continue; }
|
|
122
|
+
if (r.length !== 3) wrong(`--at ${part}: a range is from:to:step (0:20:2 — every 2 s from 0 to 20)`);
|
|
123
|
+
const [a, b, s] = r.map(x => seconds('at', x));
|
|
124
|
+
if (!(s > 0) || b < a) wrong(`--at ${part}: from:to:step — from up to to, a step above 0`);
|
|
125
|
+
for (let i = 0; a + i * s <= b + 1e-9; i++) out.push(Math.round((a + i * s) * 1000) / 1000);
|
|
126
|
+
}
|
|
127
|
+
if (!out.length) wrong('--at: which moments? 0,2.5,10 or 0:20:2');
|
|
128
|
+
return [...new Set(out)].sort((x, y) => x - y);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let at, fps;
|
|
132
|
+
if (o.video) {
|
|
133
|
+
if (o.at) wrong('--video takes its stretch as --from, --to (and --fps), not --at');
|
|
134
|
+
if (!VIDEOS.includes(extname(o.video).toLowerCase())) wrong(`--video ${o.video}: a ${VIDEOS.join(', ')}`);
|
|
135
|
+
if (o.from == null || o.to == null) wrong('--video: which stretch? --from 2 --to 8 (seconds)');
|
|
136
|
+
const a = seconds('from', o.from), b = seconds('to', o.to);
|
|
137
|
+
if (b <= a) wrong(`--video: --to (${b}) comes after --from (${a})`);
|
|
138
|
+
fps = o.fps == null ? 12 : Number(o.fps);
|
|
139
|
+
if (!(Number.isInteger(fps) && fps >= 1 && fps <= 60)) wrong(`--fps ${o.fps}: frames a second, a whole number 1…60`);
|
|
140
|
+
if (o.crop) wrong('--crop with --video: a video\'s frames are all one size — leave --crop out');
|
|
141
|
+
if (o.sheet || o.vs || o['vs-engine'] || views || states) wrong('--video: one at a time — not with --sheet, --vs, --vs-engine, --views or --states');
|
|
142
|
+
at = Array.from({ length: Math.floor((b - a) * fps + 1e-6) + 1 }, (_, i) => Math.round((a + i / fps) * 1000) / 1000);
|
|
143
|
+
} else {
|
|
144
|
+
for (const k of ['from', 'to', 'fps']) if (o[k] != null) wrong(`--${k} is for --video (--video out.mp4 --from 2 --to 8); frames: --at 2:8:0.5`);
|
|
145
|
+
at = moments(o.at ?? '0');
|
|
146
|
+
if (at.length > 300) wrong(`--at: ${at.length} frames — a video is better for that many (--video out.mp4 --from ${at[0]} --to ${at.at(-1)})`);
|
|
147
|
+
}
|
|
148
|
+
if (o.sheet && extname(o.sheet).toLowerCase() !== '.png') wrong(`--sheet ${o.sheet}: a .png`);
|
|
149
|
+
if (extname(o.out).toLowerCase() !== '.png') wrong(`--out ${o.out}: a .png (a video: --video out.mp4)`);
|
|
150
|
+
if ((o.vs || o['vs-engine']) && o.sheet) wrong('--vs with --sheet: one at a time');
|
|
151
|
+
|
|
152
|
+
// ── the scene (or every scene of a world) ──────────────────────────────
|
|
153
|
+
// what a path given as a scene is, said plainly when it is not one
|
|
154
|
+
function specAt(path) {
|
|
155
|
+
if (!existsSync(path)) wrong(`no such file or folder: ${path}`);
|
|
156
|
+
if (!statSync(path).isDirectory()) {
|
|
157
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch (e) { wrong(`${path}: not a spec (JSON): ${e.message}`); }
|
|
158
|
+
}
|
|
159
|
+
if (!existsSync(join(path, 'scene.json'))) wrong(`${path}: no scene.json — a scene is a world's scenes/<folder>`);
|
|
160
|
+
try { return readScene(path); } catch (e) { wrong(e.message); }
|
|
161
|
+
}
|
|
162
|
+
// the scenes asked for: one, or — a world, or its scenes/ — each of them
|
|
163
|
+
const scenesOf = path => {
|
|
164
|
+
if (!existsSync(path) || !statSync(path).isDirectory() || existsSync(join(path, 'scene.json'))) return null;
|
|
165
|
+
const dir = existsSync(join(path, 'scenes')) ? join(path, 'scenes') : path;
|
|
166
|
+
const inside = readdirSync(dir).filter(f => existsSync(join(dir, f, 'scene.json'))).sort();
|
|
167
|
+
return inside.length ? { world: dir === path ? join(path, '..') : path, list: inside.map(f => join(dir, f)) } : null;
|
|
168
|
+
};
|
|
169
|
+
const all = scenesOf(what);
|
|
170
|
+
if (all && (o.video || o.crop)) wrong(`${what} is a world: --${o.video ? 'video' : 'crop'} is for one scene (<world>/scenes/<folder>)`);
|
|
171
|
+
const world = resolve(o.world || (all ? all.world : existsSync(what) && statSync(what).isDirectory() ? join(what, '..', '..') : dirname(what)));
|
|
172
|
+
if (o['vs-engine'] && !existsSync(join(resolve(o['vs-engine']), 'render.html'))) wrong(`--vs-engine ${o['vs-engine']}: not an engine (no render.html in it) — a folder like moood/web/scene`);
|
|
173
|
+
// (a world may lie anywhere in its repository)
|
|
174
|
+
const inGit = dir => spawnSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' }).stdout?.trim() === 'true';
|
|
175
|
+
const modulesOf = (s, ref) => { try { return readModules(world, s, { ref }); } catch (e) { wrong(e.message); } };
|
|
176
|
+
|
|
177
|
+
// the other version of a scene (--vs: a ref of the world's repository, or another scene's folder or spec)
|
|
178
|
+
function otherOf(what, spec) {
|
|
179
|
+
if (!o.vs && !o['vs-engine']) return null;
|
|
180
|
+
let ospec = spec, omods = null, name = o.vs || 'this scene';
|
|
181
|
+
if (o.vs) {
|
|
182
|
+
const path = resolve(o.vs);
|
|
183
|
+
if (existsSync(path)) {
|
|
184
|
+
if (all) wrong(`--vs ${o.vs}: for a world, another version is a branch or commit of it (--vs main)`);
|
|
185
|
+
ospec = specAt(path); omods = modulesOf(ospec); name = o.vs;
|
|
186
|
+
} else {
|
|
187
|
+
if (!existsSync(what) || !statSync(what).isDirectory()) wrong(`--vs ${o.vs}: no such file or folder (a ref is for a scene's folder in a world's repository)`);
|
|
188
|
+
if (!inGit(world)) wrong(`--vs ${o.vs}: no such file or folder, and ${world} is not a git repository to find it in as a branch or commit`);
|
|
189
|
+
try { ospec = readScene(what, { world, ref: o.vs }); } catch (e) {
|
|
190
|
+
if (!all || !/: there is no /.test(e.message)) wrong(`--vs ${e.message}`);
|
|
191
|
+
return { missing: `not in ${o.vs}` }; // a scene new on this branch
|
|
192
|
+
}
|
|
193
|
+
omods = modulesOf(ospec, o.vs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { spec: ospec, modules: omods, engine: o['vs-engine'], name: `${name}${o['vs-engine'] ? ` · engine ${o['vs-engine']}` : ''}` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── rendering ──────────────────────────────────────────────────────────
|
|
200
|
+
const asked = { device: o.device, state: state || {}, set: set || [], views: views || [], bare: o.bare, debug: o.debug, chromium: o.chromium };
|
|
201
|
+
const several = at.length > 1 || !!views?.length;
|
|
202
|
+
// a frame's file: --out, with the scene's folder (a world's scenes), the variant (--states) and the moment (several)
|
|
203
|
+
const nameOf = (f, ext, scene) => o.out.replace(/\.png$/i, `${scene ? `-${scene}` : ''}${f.variant ? `-s${f.variant}` : ''}${several ? `-${f.at}s${f.view ? `-v${f.view}` : ''}` : ''}`) + ext;
|
|
204
|
+
// the scene rendered — once, or once for each of --states (each frame knows which: `variant`, from 1)
|
|
205
|
+
async function take(args) {
|
|
206
|
+
if (!states) return render(args);
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const [i, s] of states.entries()) for (const f of await render({ ...args, state: { ...args.state, ...s } })) out.push({ ...f, variant: i + 1 });
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
const say = v => JSON.stringify(v).replace(/"/g, '').replace(/[{}]/g, '').replace(/,/g, ', ');
|
|
212
|
+
const sheetOf = scene => (scene ? o.sheet.replace(/\.png$/i, `-${scene}.png`) : o.sheet);
|
|
213
|
+
// what is where (--json): an element, a value of the state a line each
|
|
214
|
+
const lookJSON = look => `{\n${Object.entries(look).map(([k, v]) => (plain(v) && (k === 'elements' || k === 'state')
|
|
215
|
+
? ` "${k}": {\n${Object.entries(v).map(([id, x]) => ` ${JSON.stringify(id)}: ${JSON.stringify(x)}`).join(',\n')}\n }`
|
|
216
|
+
: ` "${k}": ${JSON.stringify(v)}`)).join(',\n')}\n}\n`;
|
|
217
|
+
const label = f => `${f.variant ? `${say(states[f.variant - 1])} · ` : ''}${f.at} s${f.view ? ` · view ${f.view}` : ''}`;
|
|
42
218
|
let failed = false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
219
|
+
// each error once, with the moment it was first seen (and the scene, of a world's) — and each warning
|
|
220
|
+
// (what the scene does wrong without failing: a name it does not know, a mistyped key), which leaves
|
|
221
|
+
// the exit code as it is
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
const once = (who, e, line) => { if (!seen.has(who + e)) { seen.add(who + e); console.error(line); } };
|
|
224
|
+
const said = (f, who = '') => {
|
|
225
|
+
for (const e of f.errors) { failed = true; once(who, e, `${f.at}s${who}: ${e}`); }
|
|
226
|
+
for (const w of f.warnings || []) once(who, w, `${f.at}s${who}: warning: ${w}`);
|
|
227
|
+
};
|
|
228
|
+
const tells = (frames, who = '') => { for (const t of frames.at(-1)?.tells || []) console.log(`${who}told at ${t.at.toFixed(2)}s: ${t.text}${t.about ? ` (${t.about})` : ''}`); };
|
|
229
|
+
const started = Date.now();
|
|
230
|
+
const took = () => `${((Date.now() - started) / 1000).toFixed(1)} s`;
|
|
231
|
+
|
|
232
|
+
// one scene; `scene` — its folder's name, when it is one of a world's
|
|
233
|
+
async function one(what, spec, scene) {
|
|
234
|
+
const who = scene ? ` ${scene}` : '';
|
|
235
|
+
if (o.video) {
|
|
236
|
+
const v = video(o.video, fps, { chromium: o.chromium });
|
|
237
|
+
let last = null, n = 0;
|
|
238
|
+
// (what went wrong first is said: a render that failed, else ffmpeg)
|
|
239
|
+
let failure = null;
|
|
240
|
+
try {
|
|
241
|
+
await render({ spec, modules: modulesOf(spec), at, ...asked, look: o.json, jpeg: true, each: async f => {
|
|
242
|
+
await v.write(f.jpg); n++; last = f;
|
|
243
|
+
if (o.json) writeFileSync(nameOf(f, '.json'), lookJSON(f.look));
|
|
244
|
+
} });
|
|
245
|
+
} catch (e) { failure = e; }
|
|
246
|
+
try { await v.done(); } catch (e) { failure ??= e; }
|
|
247
|
+
if (failure) throw failure;
|
|
248
|
+
if (last) said(last);
|
|
249
|
+
console.log(`${o.video} — ${n} frames, ${at[0]}…${at.at(-1)} s at ${fps} a second (${took()}, by ${basename(v.ffmpeg)})`);
|
|
250
|
+
if (last) tells([last]);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const look = o.json, other = otherOf(what, spec), frames = await take({ spec, modules: modulesOf(spec), at, ...asked, look, crop: o.crop });
|
|
254
|
+
let most = null;
|
|
255
|
+
if (other?.missing) {
|
|
256
|
+
console.log(`${scene}: ${other.missing} — this branch's only`);
|
|
257
|
+
frames.forEach(f => said(f, who));
|
|
258
|
+
} else if (other) {
|
|
259
|
+
const theirs = await take({ spec: other.spec, modules: other.modules ?? modulesOf(other.spec), engine: other.engine, at, ...asked, crop: o.crop });
|
|
260
|
+
const done = await compare(frames.map((f, i) => ({ a: f.png, b: theirs[i].png, label: `${scene ? `${scene} · ` : ''}${label(f)}` })), { names: [scene || basename(what), other.name], chromium: o.chromium });
|
|
261
|
+
frames.forEach((f, i) => {
|
|
262
|
+
const out = nameOf(f, '.png', scene);
|
|
263
|
+
writeFileSync(out, done[i].png);
|
|
264
|
+
console.log(`${out} — differs: ${(done[i].share * 100).toFixed(2)}% of pixels, by up to ${done[i].max}`);
|
|
265
|
+
said(f, who); said(theirs[i], `${who} (${other.name})`);
|
|
266
|
+
});
|
|
267
|
+
most = Math.max(...done.map(d => d.share));
|
|
268
|
+
} else if (o.sheet) {
|
|
269
|
+
writeFileSync(sheetOf(scene), await sheet(frames.map(f => ({ png: f.png, label: `${scene ? `${scene} · ` : ''}${label(f)}` })), { chromium: o.chromium,
|
|
270
|
+
// variants (--states) a row each, their moments across
|
|
271
|
+
cols: states && frames.length / states.length <= 8 ? frames.length / states.length : undefined }));
|
|
272
|
+
console.log(`${sheetOf(scene)} — ${frames.length} frames (${took()})`);
|
|
273
|
+
frames.forEach(f => said(f, who));
|
|
274
|
+
} else {
|
|
275
|
+
for (const f of frames) { writeFileSync(nameOf(f, '.png', scene), f.png); console.log(nameOf(f, '.png', scene)); said(f, who); }
|
|
276
|
+
}
|
|
277
|
+
if (look) for (const f of frames) { writeFileSync(nameOf(f, '.json', scene), lookJSON(f.look)); console.log(nameOf(f, '.json', scene)); }
|
|
278
|
+
tells(frames, scene ? `${scene}: ` : '');
|
|
279
|
+
return most;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
if (!all) await one(what, specAt(what));
|
|
284
|
+
else {
|
|
285
|
+
// every scene of the world, one after another; with --vs, which differ most, at the end
|
|
286
|
+
const most = [];
|
|
287
|
+
for (const path of all.list) {
|
|
288
|
+
const scene = basename(path);
|
|
289
|
+
try { most.push([scene, await one(path, readScene(path), scene)]); } catch (e) { failed = true; console.error(`${scene}: ${e.message}`); }
|
|
290
|
+
}
|
|
291
|
+
if (o.vs || o['vs-engine']) {
|
|
292
|
+
console.log(`\n${all.list.length} scenes (${took()}), by how much they differ:`);
|
|
293
|
+
for (const [scene, share] of most.filter(([, s]) => s != null).sort((a, b) => b[1] - a[1])) console.log(` ${(share * 100).toFixed(2).padStart(6)}% ${scene}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.error(`moood-render: ${e.message}`);
|
|
298
|
+
process.exit(e.message.startsWith('crop:') ? 2 : 1);
|
|
48
299
|
}
|
|
49
|
-
for (const t of frames.at(-1)?.tells || []) console.log(`told at ${t.at.toFixed(2)}s: ${t.text}${t.about ? ` (${t.about})` : ''}`);
|
|
50
300
|
process.exit(failed ? 1 : 0);
|
package/compose.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Frames laid out by the browser's canvas (nothing else to install): a sheet of them, labelled; and
|
|
2
|
+
// two renders of the same moment side by side with where they differ.
|
|
3
|
+
// sheet([{png, label}], {cols, chromium}) → a PNG: the frames in rows (`cols` a row, else as many as
|
|
4
|
+
// suits their number), each with its label above it;
|
|
5
|
+
// compare([{a, b, label}], {names: [A's, B's], tolerance, chromium}) → [{png, share, max}] — each
|
|
6
|
+
// pair as A | B | the difference (A dimmed, in red where a pixel's channel differs by more than
|
|
7
|
+
// `tolerance` of 255, brighter the more), how much of the frame differs (0…1) and by how much at most.
|
|
8
|
+
import { browser } from './index.js';
|
|
9
|
+
|
|
10
|
+
async function inPage(chromium, fn, arg) {
|
|
11
|
+
const b = await browser(chromium);
|
|
12
|
+
try { return await (await b.newPage()).evaluate(fn, arg); } finally { await b.close(); }
|
|
13
|
+
}
|
|
14
|
+
const url = png => `data:image/png;base64,${png.toString('base64')}`;
|
|
15
|
+
const buffer = data => Buffer.from(data.slice(data.indexOf(',') + 1), 'base64');
|
|
16
|
+
|
|
17
|
+
export async function sheet(frames, { cols: asked, chromium } = {}) {
|
|
18
|
+
const data = await inPage(chromium, async ({ list, asked }) => {
|
|
19
|
+
const ims = await Promise.all(list.map(async f => { const i = new Image(); i.src = f.src; await i.decode(); return { i, label: f.label }; }));
|
|
20
|
+
const n = ims.length, cols = asked || (n <= 4 ? n : n <= 9 ? 3 : n <= 16 ? 4 : 5), rows = Math.ceil(n / cols), LABEL = 26, GAP = 6;
|
|
21
|
+
const w = Math.min(Math.max(...ims.map(f => f.i.width)), Math.floor(2400 / cols)), k = w / Math.max(...ims.map(f => f.i.width));
|
|
22
|
+
const h = Math.ceil(Math.max(...ims.map(f => f.i.height)) * k);
|
|
23
|
+
const c = document.createElement('canvas'), g = c.getContext('2d');
|
|
24
|
+
c.width = cols * w + (cols + 1) * GAP; c.height = rows * (h + LABEL) + (rows + 1) * GAP;
|
|
25
|
+
g.fillStyle = '#111'; g.fillRect(0, 0, c.width, c.height);
|
|
26
|
+
g.font = '15px sans-serif'; g.textBaseline = 'middle';
|
|
27
|
+
ims.forEach(({ i, label }, j) => {
|
|
28
|
+
const x = GAP + (j % cols) * (w + GAP), y = GAP + Math.floor(j / cols) * (h + LABEL + GAP);
|
|
29
|
+
g.fillStyle = '#ddd'; g.fillText(label, x + 4, y + LABEL / 2);
|
|
30
|
+
g.drawImage(i, x, y + LABEL, i.width * k, i.height * k);
|
|
31
|
+
});
|
|
32
|
+
return c.toDataURL('image/png');
|
|
33
|
+
}, { list: frames.map(f => ({ src: url(f.png), label: f.label })), asked });
|
|
34
|
+
return buffer(data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function compare(pairs, { names = ['A', 'B'], tolerance = 8, chromium } = {}) {
|
|
38
|
+
const out = await inPage(chromium, async ({ list, names, tol }) => {
|
|
39
|
+
const load = async src => { const i = new Image(); i.src = src; await i.decode(); return i; };
|
|
40
|
+
const pixels = (i, w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d'); g.drawImage(i, 0, 0); return g.getImageData(0, 0, w, h); };
|
|
41
|
+
const res = [];
|
|
42
|
+
for (const { a, b, label } of list) {
|
|
43
|
+
const [ia, ib] = await Promise.all([load(a), load(b)]), w = Math.max(ia.width, ib.width), h = Math.max(ia.height, ib.height);
|
|
44
|
+
const A = pixels(ia, w, h), B = pixels(ib, w, h), D = new ImageData(w, h);
|
|
45
|
+
let n = 0, max = 0;
|
|
46
|
+
for (let p = 0; p < A.data.length; p += 4) {
|
|
47
|
+
const d = Math.max(Math.abs(A.data[p] - B.data[p]), Math.abs(A.data[p + 1] - B.data[p + 1]), Math.abs(A.data[p + 2] - B.data[p + 2]));
|
|
48
|
+
const grey = (A.data[p] + A.data[p + 1] + A.data[p + 2]) / 3 * 0.3;
|
|
49
|
+
max = Math.max(max, d);
|
|
50
|
+
if (d > tol) { n++; D.data[p] = Math.min(255, 120 + d * 2); D.data[p + 1] = grey * 0.5; D.data[p + 2] = grey * 0.5; }
|
|
51
|
+
else { D.data[p] = D.data[p + 1] = D.data[p + 2] = grey; }
|
|
52
|
+
D.data[p + 3] = 255;
|
|
53
|
+
}
|
|
54
|
+
const share = n / (w * h), pw = Math.min(w, 800), k = pw / w, ph = Math.round(h * k), LABEL = 28, GAP = 6;
|
|
55
|
+
const c = document.createElement('canvas'), g = c.getContext('2d'), dc = document.createElement('canvas');
|
|
56
|
+
dc.width = w; dc.height = h; dc.getContext('2d').putImageData(D, 0, 0);
|
|
57
|
+
c.width = 3 * pw + 4 * GAP; c.height = ph + LABEL + 2 * GAP;
|
|
58
|
+
g.fillStyle = '#111'; g.fillRect(0, 0, c.width, c.height);
|
|
59
|
+
g.font = '15px sans-serif'; g.textBaseline = 'middle'; g.fillStyle = '#ddd';
|
|
60
|
+
const said = `${label} · differs: ${(share * 100).toFixed(2)}% of pixels, by up to ${max}`;
|
|
61
|
+
[[ia, names[0]], [ib, names[1]], [dc, said]].forEach(([im, text], j) => {
|
|
62
|
+
const x = GAP + j * (pw + GAP);
|
|
63
|
+
g.fillText(text, x + 4, GAP + LABEL / 2, pw - 8);
|
|
64
|
+
g.drawImage(im, x, GAP + LABEL, im.width * k, im.height * k);
|
|
65
|
+
});
|
|
66
|
+
res.push({ data: c.toDataURL('image/png'), share, max });
|
|
67
|
+
}
|
|
68
|
+
return res;
|
|
69
|
+
}, { list: pairs.map(p => ({ a: url(p.a), b: url(p.b), label: p.label })), names, tol: tolerance });
|
|
70
|
+
return out.map(r => ({ png: buffer(r.data), share: r.share, max: r.max }));
|
|
71
|
+
}
|
package/engine/directions.js
CHANGED
|
@@ -21,9 +21,9 @@ const MooodDirections = (() => {
|
|
|
21
21
|
back.type = go.type = 'button';
|
|
22
22
|
back.textContent = '◀'; go.textContent = '▶';
|
|
23
23
|
back.title = 'обратно'; go.title = 'превратить';
|
|
24
|
-
slider.oninput = () => MooodState.set(d.key, Number(slider.value));
|
|
25
|
-
go.onclick = () => MooodState.set(d.key, 1, SECONDS * (1 - (MooodState.get(d.key) || 0)));
|
|
26
|
-
back.onclick = () => MooodState.set(d.key, 0, SECONDS * (MooodState.get(d.key) || 0));
|
|
24
|
+
slider.oninput = () => MooodState.set(d.key, Number(slider.value), 0, undefined, { who: 'a direction' });
|
|
25
|
+
go.onclick = () => MooodState.set(d.key, 1, SECONDS * (1 - (MooodState.get(d.key) || 0)), undefined, { who: 'a direction' });
|
|
26
|
+
back.onclick = () => MooodState.set(d.key, 0, SECONDS * (MooodState.get(d.key) || 0), undefined, { who: 'a direction' });
|
|
27
27
|
row.append(name, back, slider, go);
|
|
28
28
|
panel.append(row);
|
|
29
29
|
return { d, row, slider, back, go };
|