flowfig 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -23
- package/dist/atlas.js +6 -2
- package/dist/auto.js +104 -9
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +9 -4
- package/dist/check.d.ts +4 -3
- package/dist/check.js +143 -8
- package/dist/cli.js +180 -70
- package/dist/diff.d.ts +2 -0
- package/dist/diff.js +79 -24
- package/dist/element-looks.js +4 -0
- package/dist/element-world.js +6 -0
- package/dist/element.d.ts +27 -0
- package/dist/element.js +67 -0
- package/dist/explain.d.ts +45 -0
- package/dist/explain.js +161 -0
- package/dist/frames.d.ts +34 -0
- package/dist/frames.js +117 -0
- package/dist/index.js +328 -152
- package/dist/load.js +9 -4
- package/dist/looks.d.ts +15 -0
- package/dist/looks.js +32 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.js +14 -6
- package/dist/model.d.ts +105 -3
- package/dist/model.js +221 -21
- package/dist/scene.d.ts +2 -0
- package/dist/source.d.ts +4 -2
- package/dist/source.js +15 -9
- package/dist/svg.d.ts +12 -0
- package/dist/svg.js +245 -81
- package/dist/verify.d.ts +6 -0
- package/dist/verify.js +104 -56
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/world-art.d.ts +59 -0
- package/dist/world-art.js +854 -0
- package/dist/world.d.ts +233 -0
- package/dist/world.js +1577 -0
- package/package.json +9 -3
package/dist/check.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
|
|
1
|
+
import { DARK, LIGHT, LOOKS, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, scenes, str, toBeat, MAX_LEVEL, } from './model.js';
|
|
2
2
|
import { layoutRail } from './rail.js';
|
|
3
3
|
import { crosses, samples } from './geometry.js';
|
|
4
4
|
import { textWidth } from './text.js';
|
|
5
|
+
import { cast } from './explain.js';
|
|
5
6
|
import { owners, parseSource } from './source.js';
|
|
6
7
|
const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
|
|
7
8
|
const warn = (rule, ids, message) => ({ rule, severity: 'warning', ids, message });
|
|
@@ -42,6 +43,8 @@ function plainText(fig) {
|
|
|
42
43
|
for (const n of nodes(fig.layout)) {
|
|
43
44
|
check(`box "${n.id}"`, 'label', n.label, { source: n.source, command: true });
|
|
44
45
|
check(`box "${n.id}"`, 'sub', n.sub);
|
|
46
|
+
check(`box "${n.id}"`, 'check', n.check);
|
|
47
|
+
check(`box "${n.id}"`, 'done', n.done);
|
|
45
48
|
}
|
|
46
49
|
const groupLabels = (g) => {
|
|
47
50
|
if (g.label != null)
|
|
@@ -51,11 +54,16 @@ function plainText(fig) {
|
|
|
51
54
|
groupLabels(c);
|
|
52
55
|
};
|
|
53
56
|
groupLabels(fig.layout);
|
|
54
|
-
fig.edges.forEach((e) =>
|
|
57
|
+
fig.edges.forEach((e) => {
|
|
58
|
+
check(`edge "${edgeId(e)}"`, 'label', e.label, { source: e.source, command: true });
|
|
59
|
+
if (e.carries && typeof e.carries === 'object')
|
|
60
|
+
check(`edge "${edgeId(e)}"`, 'carries.thing', e.carries.thing);
|
|
61
|
+
});
|
|
55
62
|
for (const s of fig.steps ?? []) {
|
|
56
63
|
const name = str(s.label);
|
|
57
64
|
check(`step "${name}"`, 'label', s.label);
|
|
58
65
|
check(`step "${name}"`, 'caption', s.caption, { words: true });
|
|
66
|
+
check(`step "${name}"`, 'outcome', s.outcome, { words: true });
|
|
59
67
|
s.flow.map(toBeat).forEach((b, i) => {
|
|
60
68
|
check(`step "${name}"`, `say ${i + 1}`, b.say, { words: true });
|
|
61
69
|
b.hops.forEach((h) => check(`step "${name}"`, `data ${i + 1}`, h.data, { code: false }));
|
|
@@ -65,6 +73,94 @@ function plainText(fig) {
|
|
|
65
73
|
}
|
|
66
74
|
/** Faults in the spec: dangling ids, duplicate ids, empty steps. The renderers skip them silently. */
|
|
67
75
|
export function checkSpec(fig) {
|
|
76
|
+
const own = specRules(fig);
|
|
77
|
+
if (!nodes(fig.layout).some((n) => n.inside))
|
|
78
|
+
return own;
|
|
79
|
+
return [...own, ...insideRules(fig)];
|
|
80
|
+
}
|
|
81
|
+
function insideRules(fig) {
|
|
82
|
+
const out = [];
|
|
83
|
+
const form = fig.timeline
|
|
84
|
+
? 'timeline; a timeline does not'
|
|
85
|
+
: fig.lanes
|
|
86
|
+
? 'lanes; lanes do not'
|
|
87
|
+
: fig.rail === 'only'
|
|
88
|
+
? 'rail: "only"; a rail-only figure does not'
|
|
89
|
+
: null;
|
|
90
|
+
if (form) {
|
|
91
|
+
for (const n of nodes(fig.layout))
|
|
92
|
+
if (n.inside)
|
|
93
|
+
out.push(warn('parts-ignored', [n.id], `the figure sets ${form} open boxes, so the renderers ignore inside on "${n.id}"`));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
let total = 0;
|
|
97
|
+
const limits = (g, level) => {
|
|
98
|
+
for (const n of nodes(g)) {
|
|
99
|
+
if (!n.inside)
|
|
100
|
+
continue;
|
|
101
|
+
if (level === MAX_LEVEL) {
|
|
102
|
+
out.push(err('part-limit', [n.id], `box "${n.id}" is at level ${MAX_LEVEL}, so its parts are at level ${MAX_LEVEL + 1}; the limit is ${MAX_LEVEL} levels`));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const count = nodes(n.inside.layout).length;
|
|
106
|
+
total += count;
|
|
107
|
+
if (!count)
|
|
108
|
+
out.push(err('part-limit', [n.id], `box "${n.id}": inside has no parts`));
|
|
109
|
+
else if (count > 16)
|
|
110
|
+
out.push(err('part-limit', [n.id], `box "${n.id}" has ${count} parts; the limit is 16`));
|
|
111
|
+
limits(n.inside.layout, level + 1);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
limits(fig.layout, 1);
|
|
115
|
+
if (total > 60)
|
|
116
|
+
out.push(err('part-limit', [], `the figure has ${total} parts; the limit is 60`));
|
|
117
|
+
const all = scenes(fig);
|
|
118
|
+
const seen = { boxes: new Map(), groups: new Map(), edges: new Map() };
|
|
119
|
+
const partsOf = (s) => {
|
|
120
|
+
const ports = new Set(s.ports.map((p) => p.id));
|
|
121
|
+
return nodes(s.props.layout).filter((n) => !ports.has(n.id));
|
|
122
|
+
};
|
|
123
|
+
for (const s of all) {
|
|
124
|
+
const add = (m, ids) => ids.forEach((id) => m.set(id, (m.get(id) ?? new Map()).set(s.path.join('>'), s.line || 'the root')));
|
|
125
|
+
add(seen.boxes, partsOf(s).map((n) => n.id));
|
|
126
|
+
add(seen.groups, groupIds(s.props.layout));
|
|
127
|
+
add(seen.edges, s.props.edges.map(edgeId));
|
|
128
|
+
}
|
|
129
|
+
for (const [kind, m] of Object.entries(seen))
|
|
130
|
+
for (const [id, lines] of m)
|
|
131
|
+
if (lines.size > 1)
|
|
132
|
+
out.push(err('duplicate-id', [id], `two ${kind} have the id "${id}": ${[...lines.values()].join(' and ')}`));
|
|
133
|
+
const byKey = new Map(all.map((s) => [s.path.join('>'), s]));
|
|
134
|
+
for (const s of all.slice(1)) {
|
|
135
|
+
const parent = byKey.get(s.path.slice(0, -1).join('>'));
|
|
136
|
+
const owner = s.path.at(-1);
|
|
137
|
+
const found = specRules(s.props).filter((f) => f.rule !== 'bad-source');
|
|
138
|
+
const parts = new Set(partsOf(s).map((n) => n.id));
|
|
139
|
+
for (const n of partsOf(s))
|
|
140
|
+
if (n.source == null)
|
|
141
|
+
found.push(warn('part-no-source', [n.id], `part "${n.id}" has no source`));
|
|
142
|
+
const outerIn = parent.props.edges.filter((e) => e.to === owner);
|
|
143
|
+
const outerOut = parent.props.edges.filter((e) => e.from === owner);
|
|
144
|
+
for (const e of s.props.edges) {
|
|
145
|
+
const id = edgeId(e);
|
|
146
|
+
if (!parts.has(e.from) && !parts.has(e.to))
|
|
147
|
+
found.push(err('part-edge-mismatch', [id], `edge "${id}" has no part at either end`));
|
|
148
|
+
else if (!parts.has(e.from) && !outerIn.some((o) => o.from === e.from))
|
|
149
|
+
found.push(err('part-edge-mismatch', [id, e.from], `edge "${id}" comes in from "${e.from}", but no edge goes from "${e.from}" to "${owner}"`));
|
|
150
|
+
else if (!parts.has(e.to) && !outerOut.some((o) => o.to === e.to))
|
|
151
|
+
found.push(err('part-edge-mismatch', [id, e.to], `edge "${id}" goes out to "${e.to}", but no edge goes from "${owner}" to "${e.to}"`));
|
|
152
|
+
}
|
|
153
|
+
for (const o of outerIn)
|
|
154
|
+
if (!s.props.edges.some((e) => e.from === o.from))
|
|
155
|
+
found.push(warn('part-edge-mismatch', [edgeId(o)], `edge "${edgeId(o)}" comes in, but no edge inside "${owner}" starts at "${o.from}"; the open view cannot show where the flow comes in`));
|
|
156
|
+
for (const o of outerOut)
|
|
157
|
+
if (!s.props.edges.some((e) => e.to === o.to))
|
|
158
|
+
found.push(warn('part-edge-mismatch', [edgeId(o)], `edge "${edgeId(o)}" goes out, but no edge inside "${owner}" ends at "${o.to}"; the open view cannot show where the flow goes out`));
|
|
159
|
+
out.push(...found.map((f) => ({ ...f, scene: s.line })));
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function specRules(fig) {
|
|
68
164
|
const out = [];
|
|
69
165
|
const boxIds = nodes(fig.layout).map((n) => n.id);
|
|
70
166
|
const groups = groupIds(fig.layout);
|
|
@@ -76,13 +172,21 @@ export function checkSpec(fig) {
|
|
|
76
172
|
const known = new Set([...boxIds, ...groups]);
|
|
77
173
|
const knownEdges = new Set(edges);
|
|
78
174
|
const empty = new Set();
|
|
175
|
+
const firstBox = new Map();
|
|
79
176
|
const walk = (g) => {
|
|
80
|
-
for (const c of g.children)
|
|
177
|
+
for (const c of g.children) {
|
|
178
|
+
if (c.look != null && !LOOKS.includes(c.look)) {
|
|
179
|
+
const who = isGroup(c) ? `group "${c.id ?? str(c.label)}"` : `box "${c.id}"`;
|
|
180
|
+
out.push(warn('unknown-look', isGroup(c) ? (c.id == null ? [] : [c.id]) : [c.id], `${who} has look "${c.look}"; use one of: ${LOOKS.join(', ')}`));
|
|
181
|
+
}
|
|
81
182
|
if (isGroup(c)) {
|
|
82
183
|
if (c.id != null && !nodes(c).length)
|
|
83
184
|
empty.add(c.id);
|
|
185
|
+
if (c.id != null && nodes(c).length)
|
|
186
|
+
firstBox.set(c.id, nodes(c)[0].id);
|
|
84
187
|
walk(c);
|
|
85
188
|
}
|
|
189
|
+
}
|
|
86
190
|
};
|
|
87
191
|
walk(fig.layout);
|
|
88
192
|
fig.edges.forEach((e, i) => {
|
|
@@ -92,6 +196,30 @@ export function checkSpec(fig) {
|
|
|
92
196
|
else if (empty.has(end))
|
|
93
197
|
out.push(err('unknown-id', [edges[i], end], `edge "${edges[i]}" ends at group "${end}", which has no boxes`));
|
|
94
198
|
});
|
|
199
|
+
const actorOf = new Map(cast(fig).flatMap((a) => a.boxes.map((b) => [b, a.name])));
|
|
200
|
+
fig.edges.forEach((e, i) => {
|
|
201
|
+
const c = e.carries;
|
|
202
|
+
if (!c || typeof c !== 'object')
|
|
203
|
+
return;
|
|
204
|
+
const id = edges[i];
|
|
205
|
+
const thing = str(c.thing).trim();
|
|
206
|
+
if (c.count != null && !(Number.isInteger(c.count) && c.count >= 1 && c.count <= 4))
|
|
207
|
+
out.push(warn('bad-carries', [id], `edge "${id}" has carries.count ${c.count}; use 1 to 4`));
|
|
208
|
+
if (!thing)
|
|
209
|
+
out.push(warn('bad-carries', [id], `edge "${id}" has an empty carries.thing; name the thing in plain words`));
|
|
210
|
+
if (c.form != null && !['box', 'barrel', 'notes'].includes(c.form))
|
|
211
|
+
out.push(warn('bad-carries', [id], `edge "${id}" has carries.form "${c.form}"; use box, barrel or notes`));
|
|
212
|
+
if (c.from != null && !known.has(c.from)) {
|
|
213
|
+
out.push(err('unknown-id', [id, c.from], `edge "${id}" has carries.from "${c.from}", which names no box and no group`));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const start = c.from == null ? e.from : (firstBox.get(c.from) ?? c.from);
|
|
217
|
+
const [a, b] = [actorOf.get(start), actorOf.get(e.to)];
|
|
218
|
+
if (a == null || b == null)
|
|
219
|
+
out.push(warn('thing-no-road', [id], `edge "${id}" carries "${thing}", but box "${a == null ? start : e.to}" has no actor; put it in a lane or a labeled group`));
|
|
220
|
+
else if (a === b)
|
|
221
|
+
out.push(warn('thing-no-road', [id], `edge "${id}" carries "${thing}", but both ends are at actor "${a}"; set carries.from to the box where the thing starts`));
|
|
222
|
+
});
|
|
95
223
|
(fig.steps ?? []).forEach((s, si) => {
|
|
96
224
|
const where = `step ${si + 1} ("${str(s.label)}")`;
|
|
97
225
|
if (!s.flow.length)
|
|
@@ -209,7 +337,7 @@ const overlap = (a, b, pad) => a.x + pad < b.x + b.w - pad && b.x + pad < a.x +
|
|
|
209
337
|
const px = (n) => Math.round(n * 10) / 10;
|
|
210
338
|
// Measured corner touches go 3.5 px deep; real crossings go 24 px or more.
|
|
211
339
|
const TOUCH = 6;
|
|
212
|
-
export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
340
|
+
export function checkScene(scene, { width = 830, minText = 10, auto } = {}) {
|
|
213
341
|
const out = [];
|
|
214
342
|
for (const b of scene.boxes)
|
|
215
343
|
for (const t of b.texts) {
|
|
@@ -223,7 +351,7 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
223
351
|
const pts = path(e);
|
|
224
352
|
for (const b of scene.boxes)
|
|
225
353
|
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, TOUCH)))
|
|
226
|
-
out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}": move a box, or set "auto": true on the root layout`));
|
|
354
|
+
out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}": move a box${auto ? '' : ', or set "auto": true on the root layout'}`));
|
|
227
355
|
}
|
|
228
356
|
for (const e of scene.edges) {
|
|
229
357
|
const pts = path(e);
|
|
@@ -231,7 +359,7 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
231
359
|
const ratio = drawn / Math.max(1, Math.hypot(pts.at(-1).x - pts[0].x, pts.at(-1).y - pts[0].y));
|
|
232
360
|
// The README figures have a good edge at ratio 1.77 and 425 px.
|
|
233
361
|
if (ratio >= 1.6 && drawn >= 600)
|
|
234
|
-
out.push(warn('long-edge', [e.id], `edge "${e.id}" is ${px(ratio)} times the straight distance between its ends: put its ends in one row or column, route it with around, or set "auto": true on the root layout`));
|
|
362
|
+
out.push(warn('long-edge', [e.id], `edge "${e.id}" is ${px(ratio)} times the straight distance between its ends: put its ends in one row or column${auto ? ', or route it with around' : ', route it with around, or set "auto": true on the root layout'}`));
|
|
235
363
|
}
|
|
236
364
|
const pills = scene.edges.filter((e) => e.pts && e.label);
|
|
237
365
|
const under = new Set();
|
|
@@ -376,12 +504,19 @@ export function planFor(fig, opts) {
|
|
|
376
504
|
const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
|
|
377
505
|
return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
|
|
378
506
|
}
|
|
379
|
-
export function checkRendered(fig, opts, scene) {
|
|
507
|
+
export function checkRendered(fig, opts, scene, levels) {
|
|
380
508
|
const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
|
|
381
509
|
rule: 'lane-end-block',
|
|
382
510
|
severity: 'warning',
|
|
383
511
|
ids: [id],
|
|
384
512
|
message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
|
|
385
513
|
}));
|
|
386
|
-
return [
|
|
514
|
+
return [
|
|
515
|
+
...checkSpec(fig),
|
|
516
|
+
...(levels
|
|
517
|
+
? levels.flatMap((l, i) => checkScene({ ...l.scene, width: Math.max(...levels.map((x) => x.scene.width)) }, { ...opts, auto: opts.auto ?? l.props.layout.auto }).map((f) => (i ? { ...f, scene: l.line } : f)))
|
|
518
|
+
: checkScene(scene, { ...opts, auto: opts.auto ?? fig.layout.auto })),
|
|
519
|
+
...lost,
|
|
520
|
+
...checkTheme({ ...fig.theme, ...opts.theme }),
|
|
521
|
+
];
|
|
387
522
|
}
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os';
|
|
|
13
13
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { captureFrames, findBrowser, launch } from './browser.js';
|
|
16
|
+
import { keyFrames, sheetGrid, sheetSvg } from './frames.js';
|
|
16
17
|
import { delays, encodeGif } from './gif.js';
|
|
17
18
|
import { GUIDE, TOPICS } from './guide.js';
|
|
18
19
|
import { runDraw } from './draw.js';
|
|
@@ -20,14 +21,15 @@ import { runInit } from './init.js';
|
|
|
20
21
|
import { serve } from './mcp.js';
|
|
21
22
|
import { runAtlas } from './atlas.js';
|
|
22
23
|
import { runFromMermaid } from './mermaid.js';
|
|
23
|
-
import { openedLine, openSvg, runOpen } from './open.js';
|
|
24
|
+
import { openedLine, openFile, openSvg, runOpen } from './open.js';
|
|
24
25
|
import { pageHtml } from './page.js';
|
|
25
26
|
import { decodePng } from './png.js';
|
|
26
27
|
import { diff, formatDiff, mergeFigures } from './diff.js';
|
|
27
28
|
import { loadSpec, reportLines, sortFindings, specOf, summaryLines, svgWithSpec, verifyLines } from './load.js';
|
|
28
29
|
import { check, render, toSvg } from './svg.js';
|
|
29
30
|
import { checkRendered } from './check.js';
|
|
30
|
-
import { coverageLine, links, parseSource, unsureLines, verifyReport } from './verify.js';
|
|
31
|
+
import { coverageLine, levelLines, links, parseSource, unsureLines, verifyReport } from './verify.js';
|
|
32
|
+
import { VERSION } from './version.js';
|
|
31
33
|
import { trace, traceLines } from './trace.js';
|
|
32
34
|
import { coverageLines, coverageReport } from './coverage.js';
|
|
33
35
|
const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
|
|
@@ -45,11 +47,72 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] rende
|
|
|
45
47
|
flowfig open <figure.svg> [--html <path>] show the figure in the default browser
|
|
46
48
|
flowfig from-mermaid <file.mmd|file.md|-> [--out <dir>] convert Mermaid diagrams to specs
|
|
47
49
|
flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
|
|
48
|
-
|
|
50
|
+
flowfig frames <figure.svg> [out.png] [--step <n>] [--dark] [--fps <n>] [--every <ms>] [--scale <n>] [--open] write one PNG sheet of the key frames
|
|
51
|
+
flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only), --scene <id> (render one level), --script (add the script that opens a box on a click)
|
|
52
|
+
flowfig --version, -v print the version`;
|
|
49
53
|
function usage(message) {
|
|
50
54
|
console.error(message);
|
|
51
55
|
process.exit(2);
|
|
52
56
|
}
|
|
57
|
+
async function withBrowser(name, work) {
|
|
58
|
+
const found = findBrowser({ platform: process.platform, env: process.env, exists: existsSync });
|
|
59
|
+
if (!found.path)
|
|
60
|
+
usage([`${name} needs Chrome, Edge, Chromium or Brave. Checked:`, ...found.checked, 'Set CHROME_PATH to the browser program.'].join('\n'));
|
|
61
|
+
const temp = mkdtempSync(join(tmpdir(), `flowfig-${name}-`));
|
|
62
|
+
let launching;
|
|
63
|
+
let cleaning;
|
|
64
|
+
const cleanup = () => (cleaning ??= (async () => {
|
|
65
|
+
await (await launching?.catch(() => undefined))?.close();
|
|
66
|
+
try {
|
|
67
|
+
// Windows can hold a file lock briefly after the exit, so retry.
|
|
68
|
+
rmSync(temp, { recursive: true, force: true, maxRetries: 10 });
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
console.error(`warning: could not remove ${temp}: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
})());
|
|
74
|
+
let stopped = 0;
|
|
75
|
+
const yieldLoop = () => new Promise((done) => setImmediate(done));
|
|
76
|
+
// A write comes right after this check, so a signal must run its handler first.
|
|
77
|
+
const run = {
|
|
78
|
+
written: [],
|
|
79
|
+
proceed: async () => {
|
|
80
|
+
await yieldLoop();
|
|
81
|
+
if (stopped)
|
|
82
|
+
throw new Error('stopped');
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
// Keep the handlers: a second signal must not end the process mid-cleanup.
|
|
86
|
+
const onSignal = (signal) => {
|
|
87
|
+
if (stopped)
|
|
88
|
+
return;
|
|
89
|
+
stopped = signal === 'SIGINT' ? 130 : 143;
|
|
90
|
+
void (run.onStop?.() ?? Promise.resolve())
|
|
91
|
+
.then(() => run.written.forEach((f) => rmSync(f, { force: true })))
|
|
92
|
+
.then(cleanup)
|
|
93
|
+
.finally(() => process.exit(stopped));
|
|
94
|
+
};
|
|
95
|
+
process.on('SIGINT', onSignal);
|
|
96
|
+
process.on('SIGTERM', onSignal);
|
|
97
|
+
let code = 0;
|
|
98
|
+
try {
|
|
99
|
+
launching = launch(found.path, join(temp, 'profile'));
|
|
100
|
+
await work((await launching).cdp, run);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (!stopped)
|
|
104
|
+
console.error(`${name}: ${e.message}`);
|
|
105
|
+
code = 1;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await cleanup();
|
|
109
|
+
}
|
|
110
|
+
// A late signal runs its handler in this await, before the exit.
|
|
111
|
+
await yieldLoop();
|
|
112
|
+
if (stopped)
|
|
113
|
+
run.written.forEach((f) => rmSync(f, { force: true }));
|
|
114
|
+
process.exit(stopped || code);
|
|
115
|
+
}
|
|
53
116
|
const args = process.argv.slice(2);
|
|
54
117
|
const flag = (name) => {
|
|
55
118
|
const i = args.indexOf(name);
|
|
@@ -73,6 +136,10 @@ if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
|
|
|
73
136
|
usage(USAGE);
|
|
74
137
|
process.exit(0);
|
|
75
138
|
}
|
|
139
|
+
if (['--version', '-v'].includes(args[0])) {
|
|
140
|
+
console.log(VERSION);
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
76
143
|
if (args[0] === 'docs') {
|
|
77
144
|
const topic = args[1];
|
|
78
145
|
if (topic && !Object.hasOwn(TOPICS, topic))
|
|
@@ -154,10 +221,12 @@ if (args[0] === 'verify') {
|
|
|
154
221
|
else {
|
|
155
222
|
const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
156
223
|
for (const f of findings)
|
|
157
|
-
console.log(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.figure}: ${f.message}`);
|
|
224
|
+
console.log(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.figure}: ${f.scene ? `[${f.scene}] ` : ''}${f.message}`);
|
|
158
225
|
console.log(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
|
|
159
226
|
for (const c of coverage) {
|
|
160
227
|
console.log(coverageLine(c.figure, c));
|
|
228
|
+
for (const l of levelLines(c))
|
|
229
|
+
console.log(l);
|
|
161
230
|
for (const l of unsureLines(c))
|
|
162
231
|
console.log(l);
|
|
163
232
|
}
|
|
@@ -266,56 +335,20 @@ if (args[0] === 'gif') {
|
|
|
266
335
|
usage(`${path}: no width and height on the <svg> element`);
|
|
267
336
|
if (!existsSync(dirname(resolve(out))))
|
|
268
337
|
usage(`${out}: the folder does not exist`);
|
|
269
|
-
const found = findBrowser({ platform: process.platform, env: process.env, exists: existsSync });
|
|
270
|
-
if (!found.path)
|
|
271
|
-
usage(['gif needs Chrome, Edge, Chromium or Brave. Checked:', ...found.checked, 'Set CHROME_PATH to the browser program.'].join('\n'));
|
|
272
338
|
const line = (file, frames, bytes) => `${file} — ${frames} frame${frames === 1 ? '' : 's'}, ${(frames / fps).toFixed(1)} s, ${(bytes / 1048576).toFixed(1)} MB`;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// Windows can hold a file lock briefly after the exit, so retry.
|
|
280
|
-
rmSync(temp, { recursive: true, force: true, maxRetries: 10 });
|
|
281
|
-
}
|
|
282
|
-
catch (e) {
|
|
283
|
-
console.error(`warning: could not remove ${temp}: ${e.message}`);
|
|
284
|
-
}
|
|
285
|
-
})());
|
|
286
|
-
const written = [];
|
|
287
|
-
let ffmpeg, ffmpegDone = Promise.resolve(), stopped = 0;
|
|
288
|
-
const yieldLoop = () => new Promise((done) => setImmediate(done));
|
|
289
|
-
// Keep the handlers: a second signal must not end the process mid-cleanup.
|
|
290
|
-
const onSignal = (signal) => {
|
|
291
|
-
if (stopped)
|
|
292
|
-
return;
|
|
293
|
-
stopped = signal === 'SIGINT' ? 130 : 143;
|
|
294
|
-
ffmpeg?.kill('SIGKILL');
|
|
295
|
-
void ffmpegDone
|
|
296
|
-
.then(() => written.forEach((f) => rmSync(f, { force: true })))
|
|
297
|
-
.then(cleanup)
|
|
298
|
-
.finally(() => process.exit(stopped));
|
|
299
|
-
};
|
|
300
|
-
process.on('SIGINT', onSignal);
|
|
301
|
-
process.on('SIGTERM', onSignal);
|
|
302
|
-
// A write comes right after this check, so a signal must run its handler first.
|
|
303
|
-
const proceed = async () => {
|
|
304
|
-
await yieldLoop();
|
|
305
|
-
if (stopped)
|
|
306
|
-
throw new Error('stopped');
|
|
307
|
-
};
|
|
308
|
-
let code = 0;
|
|
309
|
-
try {
|
|
310
|
-
launching = launch(found.path, join(temp, 'profile'));
|
|
311
|
-
const browser = await launching;
|
|
339
|
+
await withBrowser('gif', async (cdp, run) => {
|
|
340
|
+
let ffmpeg, ffmpegDone = Promise.resolve();
|
|
341
|
+
run.onStop = () => {
|
|
342
|
+
ffmpeg?.kill('SIGKILL');
|
|
343
|
+
return ffmpegDone;
|
|
344
|
+
};
|
|
312
345
|
const viewport = { width: Math.ceil(Number(size[1])), height: Math.ceil(Number(size[2])) };
|
|
313
|
-
const { pngs } = await captureFrames(
|
|
346
|
+
const { pngs } = await captureFrames(cdp, pageHtml(svg, basename(path)), { ...viewport, scale, fps, dark });
|
|
314
347
|
const wait = delays(pngs.length, fps);
|
|
315
348
|
// The GIF keeps the PNG bytes: a decoded 2400 x 1600 frame is 15 MB.
|
|
316
349
|
const gif = await encodeGif(pngs.map((png, i) => ({ load: () => decodePng(png), delay: wait[i], same: i > 0 && png.equals(pngs[i - 1]) })));
|
|
317
|
-
await proceed();
|
|
318
|
-
written.push(out);
|
|
350
|
+
await run.proceed();
|
|
351
|
+
run.written.push(out);
|
|
319
352
|
writeFileSync(out, gif);
|
|
320
353
|
console.log(line(out, pngs.length, gif.length));
|
|
321
354
|
if (gif.length > 10485760)
|
|
@@ -326,8 +359,8 @@ if (args[0] === 'gif') {
|
|
|
326
359
|
if (spawnSync(ffmpegBin, ['-version']).error)
|
|
327
360
|
console.error('gif: no ffmpeg on the PATH, so no MP4. The GIF is written.');
|
|
328
361
|
else {
|
|
329
|
-
await proceed();
|
|
330
|
-
written.push(mp4Path);
|
|
362
|
+
await run.proceed();
|
|
363
|
+
run.written.push(mp4Path);
|
|
331
364
|
// yuv420p needs an even width and height, so the pad filter adds a pixel.
|
|
332
365
|
ffmpeg = spawn(ffmpegBin, [
|
|
333
366
|
'-y',
|
|
@@ -358,7 +391,7 @@ if (args[0] === 'gif') {
|
|
|
358
391
|
ffmpeg.once('error', () => done(-1));
|
|
359
392
|
ffmpeg.once('close', done);
|
|
360
393
|
}));
|
|
361
|
-
await proceed();
|
|
394
|
+
await run.proceed();
|
|
362
395
|
if (status !== 0) {
|
|
363
396
|
rmSync(mp4Path, { force: true });
|
|
364
397
|
throw new Error(`ffmpeg failed: ${stderr.trim()}`);
|
|
@@ -366,26 +399,93 @@ if (args[0] === 'gif') {
|
|
|
366
399
|
console.log(line(mp4Path, pngs.length, statSync(mp4Path).size));
|
|
367
400
|
}
|
|
368
401
|
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (args[0] === 'frames') {
|
|
405
|
+
args.shift();
|
|
406
|
+
const dark = flag('--dark'), opening = flag('--open');
|
|
407
|
+
const fps = value('--fps') ?? 20, scale = value('--scale') ?? 1, step = value('--step'), every = value('--every');
|
|
408
|
+
if (!Number.isInteger(fps) || fps > 50)
|
|
409
|
+
usage('--fps needs a whole number from 1 to 50');
|
|
410
|
+
const unknown = args.find((a) => a.startsWith('-'));
|
|
411
|
+
if (unknown)
|
|
412
|
+
usage(`unknown flag ${unknown}`);
|
|
413
|
+
const [path, outArg] = args;
|
|
414
|
+
if (!path)
|
|
415
|
+
usage('usage: flowfig frames <figure.svg> [out.png] [--step <n>] [--dark] [--fps <n>] [--every <ms>] [--scale <n>] [--open]');
|
|
416
|
+
if (!path.endsWith('.svg'))
|
|
417
|
+
usage(`${path}: expected a .svg path`);
|
|
418
|
+
const props = (() => {
|
|
419
|
+
try {
|
|
420
|
+
return loadSpec(path);
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
return usage(e.message);
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
const count = props.steps?.length ?? 0;
|
|
427
|
+
if (!count)
|
|
428
|
+
usage(`${path}: frames needs a figure with steps; this figure has 0`);
|
|
429
|
+
if (step !== undefined && (!Number.isInteger(step) || step > count))
|
|
430
|
+
usage(`--step needs a whole number from 1 to ${count}`);
|
|
431
|
+
const fig = step === undefined ? props : { ...props, steps: [props.steps[step - 1]] };
|
|
432
|
+
const svg = step === undefined ? readFileSync(path, 'utf8') : toSvg(fig);
|
|
433
|
+
const { tiles, frames } = keyFrames(fig, { fps, every, step });
|
|
434
|
+
if (tiles.length > 64)
|
|
435
|
+
usage(`${path}: ${tiles.length} tiles; the sheet holds 64 or fewer. Use --step <n> or a larger --every <ms>.`);
|
|
436
|
+
const out = outArg ?? path.replace(/\.svg$/, step === undefined ? '-frames.png' : `-step${step}-frames.png`);
|
|
437
|
+
const size = /<svg\b[^>]*?\swidth="([\d.]+)"[^>]*?\sheight="([\d.]+)"/.exec(svg);
|
|
438
|
+
if (!size)
|
|
439
|
+
usage(`${path}: no width and height on the <svg> element`);
|
|
440
|
+
if (!existsSync(dirname(resolve(out))))
|
|
441
|
+
usage(`${out}: the folder does not exist`);
|
|
442
|
+
const [w, h] = [Number(size[1]), Number(size[2])];
|
|
443
|
+
const grid = sheetGrid(tiles.length, w, h, scale);
|
|
444
|
+
const loopMs = Math.round((frames * 1000) / fps);
|
|
445
|
+
await withBrowser('frames', async (cdp, run) => {
|
|
446
|
+
// A fractional device scale draws paused opacity animations two ways on alternate frames.
|
|
447
|
+
const shot = await captureFrames(cdp, pageHtml(svg, basename(path), '', '', '<style>svg { width: 100% }</style>'), {
|
|
448
|
+
width: grid.tile,
|
|
449
|
+
height: grid.tileH,
|
|
450
|
+
scale: 1,
|
|
451
|
+
fps,
|
|
452
|
+
dark,
|
|
453
|
+
frames: tiles.map((t) => t.frame),
|
|
454
|
+
});
|
|
455
|
+
if (Math.abs(Math.round((shot.loopMs * fps) / 1000) - frames) > 1)
|
|
456
|
+
throw new Error(`${path}: the SVG loop is ${Math.round(shot.loopMs)} ms but its spec gives ${loopMs} ms. Render the figure again.`);
|
|
457
|
+
const header = `${basename(path)} · ${tiles.length} tiles of ${frames} frames at ${fps} fps · loop ${loopMs} ms`;
|
|
458
|
+
const sheet = await captureFrames(cdp, pageHtml(sheetSvg(tiles, shot.pngs, grid, { header, fps, dark, scale }), basename(out)), {
|
|
459
|
+
width: grid.width,
|
|
460
|
+
height: grid.height,
|
|
461
|
+
scale: 1,
|
|
462
|
+
fps,
|
|
463
|
+
dark,
|
|
464
|
+
});
|
|
465
|
+
const png = sheet.pngs[0];
|
|
466
|
+
await run.proceed();
|
|
467
|
+
run.written.push(out);
|
|
468
|
+
writeFileSync(out, png);
|
|
469
|
+
const rows = tiles.map((t) => [
|
|
470
|
+
`#${t.frame}`,
|
|
471
|
+
`${Math.round((t.frame * 1000) / fps)} ms`,
|
|
472
|
+
`step ${t.step}${t.beat ? ` beat ${t.beat}` : ''}`,
|
|
473
|
+
t.kinds.join('+'),
|
|
474
|
+
]);
|
|
475
|
+
const widths = [0, 1, 2, 3].map((c) => Math.max(...rows.map((r) => r[c].length)));
|
|
476
|
+
tiles.forEach((t, i) => console.log([...rows[i].map((v, c) => v.padEnd(widths[c])), t.text].join(' ') + (t.say ? ` "${t.say}"` : '')));
|
|
477
|
+
console.log(`${out} — ${tiles.length} tiles of ${frames} frames at ${fps} fps, ${grid.cols} x ${grid.rows} grid, ${grid.width} x ${grid.height} px, ${(png.length / 1048576).toFixed(1)} MB`);
|
|
478
|
+
if (grid.warning)
|
|
479
|
+
console.error(grid.warning);
|
|
480
|
+
if (opening)
|
|
481
|
+
await openFile(out);
|
|
482
|
+
});
|
|
383
483
|
}
|
|
384
484
|
const command = args[0] === 'check' ? args.shift() : 'render';
|
|
385
485
|
const json = flag('--json'), strict = flag('--strict'), skip = flag('--no-check'), skipVerify = flag('--no-verify'), open = flag('--open');
|
|
386
486
|
if (command === 'check' && open)
|
|
387
487
|
usage('--open works with a render, not with check');
|
|
388
|
-
const opts = { width: value('--width'), minText: value('--min-text') };
|
|
488
|
+
const opts = { width: value('--width'), minText: value('--min-text'), scene: option('--scene'), script: flag('--script') };
|
|
389
489
|
const unknown = args.find((a) => a.startsWith('-') && a !== '-');
|
|
390
490
|
if (unknown)
|
|
391
491
|
usage(`unknown flag ${unknown}`);
|
|
@@ -393,8 +493,18 @@ const [input, out] = args;
|
|
|
393
493
|
if (!input)
|
|
394
494
|
usage(USAGE);
|
|
395
495
|
const props = await load(input);
|
|
396
|
-
const
|
|
397
|
-
|
|
496
|
+
const drawn = () => {
|
|
497
|
+
try {
|
|
498
|
+
return render(props, opts);
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
if (!e.message.endsWith('has no level to open'))
|
|
502
|
+
throw e;
|
|
503
|
+
return usage(e.message);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
const rendered = skip ? undefined : drawn();
|
|
507
|
+
const findings = rendered ? sortFindings(checkRendered(props, opts, rendered.scene, rendered.levels), strict) : [];
|
|
398
508
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
399
509
|
const report = (print, tty, extra = []) => {
|
|
400
510
|
const paint = (s) => tty && !process.env.NO_COLOR ? s.replace(/^(error {3}|warning )/, (w) => `\x1b[${w[0] === 'e' ? 31 : 33}m${w}\x1b[0m`) : s;
|
|
@@ -412,7 +522,7 @@ const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/,
|
|
|
412
522
|
report(console.error, process.stderr.isTTY, errors ? [] : [...summaryLines(props), ...(skipVerify ? [] : verifyLines(props, dest))]);
|
|
413
523
|
if (errors)
|
|
414
524
|
process.exit(1);
|
|
415
|
-
const withSpec = svgWithSpec(props, opts, rendered
|
|
525
|
+
const withSpec = svgWithSpec(props, opts, (rendered ?? drawn()).svg);
|
|
416
526
|
writeFileSync(dest, withSpec);
|
|
417
527
|
console.log(`${dest} — ${(withSpec.length / 1024).toFixed(1)} kB`);
|
|
418
528
|
if (open) {
|
package/dist/diff.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type Change = {
|
|
|
4
4
|
op: 'added' | 'removed' | 'changed';
|
|
5
5
|
id: string;
|
|
6
6
|
detail?: string;
|
|
7
|
+
/** The path line of the level, for a change inside a box. */
|
|
8
|
+
path?: string;
|
|
7
9
|
};
|
|
8
10
|
export declare function diff(before: FlowProps, after: FlowProps): Change[];
|
|
9
11
|
export declare function formatDiff(changes: Change[], as: 'text' | 'md'): string;
|