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/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) => check(`edge "${edgeId(e)}"`, 'label', e.label, { source: e.source, command: true }));
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 [...checkSpec(fig), ...checkScene(scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
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
- flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)`;
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
- const temp = mkdtempSync(join(tmpdir(), 'flowfig-gif-'));
274
- let launching;
275
- let cleaning;
276
- const cleanup = () => (cleaning ??= (async () => {
277
- await (await launching?.catch(() => undefined))?.close();
278
- try {
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(browser.cdp, pageHtml(svg, basename(path)), { ...viewport, scale, fps, dark });
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
- catch (e) {
371
- if (!stopped)
372
- console.error(`gif: ${e.message}`);
373
- code = 1;
374
- }
375
- finally {
376
- await cleanup();
377
- }
378
- // A late signal runs its handler in this await, before the exit.
379
- await yieldLoop();
380
- if (stopped)
381
- written.forEach((f) => rmSync(f, { force: true }));
382
- process.exit(stopped || code);
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 rendered = skip ? undefined : render(props, opts);
397
- const findings = rendered ? sortFindings(checkRendered(props, opts, rendered.scene), strict) : [];
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?.svg);
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;