@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 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 PNG and the errors the scene's code threw.
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`). Time is a
14
- virtual clock stepped from the start, a tenth of a second at a time; chance (`Math.random`) is the
15
- same every run. The same moment and state give the same picture — pixel-close, not bit-equal (the
16
- GPU emulation's rounding): compare frames with a small tolerance.
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: [3] });
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
- Exits 1 if the scene's code threw.
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> [--world <its world's repository>] [--device 16:9] [--at 0,1.5,3]
3
- // [--state '{"dawn":1}'] [--set '[[4,"beat","cover"]]'] [--bare] [--out frame.png] [--chromium <path>]
4
- // A frame for each moment (--out frame.png → frame.png, or frame-1.5s.png … for several); the
5
- // errors the scene's code threw, on stderr; what its scripts told (scene.tell), on stdout. The world defaults to the folder two levels up
6
- // (<world>/scenes/<folder>): where its modules are.
7
- // moood-render motion <motion.json> [--at 0,1.5,3] [--views front,side] [--kit] [--out motion.png]
8
- // A person's motion made by a model (list_motions(key, body=true) saved as a file) as frames on its body.
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 { ENGINE, readModules, readScene, render, renderMotion } from './index.js';
13
-
14
- const { values: o, positionals: [what, file] } = parseArgs({
15
- allowPositionals: true,
16
- options: { world: { type: 'string' }, device: { type: 'string', default: '16:9' }, at: { type: 'string', default: '0' },
17
- state: { type: 'string' }, set: { type: 'string' }, bare: { type: 'boolean' }, out: { type: 'string', default: 'frame.png' },
18
- chromium: { type: 'string' }, version: { type: 'boolean' }, views: { type: 'string', default: 'front,side' },
19
- kit: { type: 'boolean' } },
20
- });
21
- if (o.version) { console.log(`moood engine ${ENGINE}`); process.exit(0); }
22
- if (!what) { console.error('moood-render <scene folder | spec.json> [--world …] [--device 16:9] [--at 0,1.5] [--state {…}] [--set [[s,k,v],…]] [--bare] [--out frame.png]'); process.exit(2); }
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
- if (what === 'motion') {
25
- if (!file) { console.error('moood-render motion <motion.json> [--at 0,1.5] [--views front,side] [--kit] [--out motion.png]'); process.exit(2); }
26
- const made = JSON.parse(readFileSync(file, 'utf8'));
27
- const { png, errors } = await renderMotion({ motion: made.body || made, kit: o.kit ? made.clip : null, chromium: o.chromium,
28
- at: process.argv.includes('--at') ? o.at.split(',').map(Number) : null, views: o.views.split(',') });
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 folder = statSync(what).isDirectory();
37
- const spec = folder ? readScene(what) : JSON.parse(readFileSync(what, 'utf8'));
38
- const world = o.world || (folder ? resolve(what, '..', '..') : dirname(resolve(what)));
39
- const at = o.at.split(',').map(Number);
40
- const frames = await render({ spec, modules: readModules(world, spec), device: o.device, at, state: o.state ? JSON.parse(o.state) : {},
41
- set: o.set ? JSON.parse(o.set) : [], bare: o.bare, chromium: o.chromium });
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
- for (const f of frames) {
44
- const out = frames.length > 1 ? o.out.replace(/(\.png)?$/, `-${f.at}s.png`) : o.out;
45
- writeFileSync(out, f.png);
46
- console.log(out);
47
- for (const e of f.errors) { console.error(`${f.at}s: ${e}`); failed = true; }
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
+ }
@@ -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 };