spine-rigc 0.5.0 → 0.7.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/NOTICE.md +21 -0
- package/README.md +409 -229
- package/bin/rigc.cjs +40 -0
- package/cli.ts +916 -56
- package/docs/AUTHORING.md +345 -27
- package/docs/PROMPTING.md +106 -0
- package/docs/SPEC_COVERAGE.md +16 -14
- package/package.json +4 -2
- package/src/ballot.ts +869 -0
- package/src/compile.ts +796 -137
- package/src/emit.ts +88 -0
- package/src/json-position.ts +253 -0
- package/src/png.ts +72 -7
- package/src/preview.ts +243 -0
- package/src/render.ts +58 -0
- package/src/types.ts +171 -0
- package/src/validate.ts +287 -14
- package/tools/plate.ts +164 -21
package/cli.ts
CHANGED
|
@@ -31,13 +31,50 @@
|
|
|
31
31
|
* Its paths resolve against the cuts.json file itself, so the table travels
|
|
32
32
|
* with the project that owns the art rather than with this repository.
|
|
33
33
|
*/
|
|
34
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
-
import { dirname, join, resolve } from 'node:path';
|
|
34
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
36
|
+
import {
|
|
37
|
+
BallotError,
|
|
38
|
+
buildBallot,
|
|
39
|
+
ledgerLineText,
|
|
40
|
+
MAX_CANDIDATES,
|
|
41
|
+
MIN_CANDIDATES,
|
|
42
|
+
parseLedger,
|
|
43
|
+
readBallotManifest,
|
|
44
|
+
resultFilename,
|
|
45
|
+
TIE,
|
|
46
|
+
verifyResult,
|
|
47
|
+
VOTE_RULES,
|
|
48
|
+
type BallotCandidateInput,
|
|
49
|
+
type BallotInput,
|
|
50
|
+
} from './src/ballot.ts';
|
|
36
51
|
import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
|
|
37
52
|
import { compile, CompileError, type CompileOptions } from './src/compile.ts';
|
|
38
53
|
import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
|
|
54
|
+
import { copyAtlasImages } from './src/emit.ts';
|
|
55
|
+
import { parseJsonWithPosition } from './src/json-position.ts';
|
|
39
56
|
import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
|
|
40
|
-
import {
|
|
57
|
+
import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
|
|
58
|
+
import {
|
|
59
|
+
atlasPageNames,
|
|
60
|
+
BACKGROUND,
|
|
61
|
+
contactSheet,
|
|
62
|
+
FRAMES_SIDECAR,
|
|
63
|
+
FRAMES_SPEC,
|
|
64
|
+
framingViewport,
|
|
65
|
+
loadPosable,
|
|
66
|
+
PROTOCOL_FPS,
|
|
67
|
+
renderFrame,
|
|
68
|
+
sampleAll,
|
|
69
|
+
sampleAnimation,
|
|
70
|
+
SETUP_POSE_DIR,
|
|
71
|
+
SHEET_FILE,
|
|
72
|
+
SHEET_TILE,
|
|
73
|
+
type Frame,
|
|
74
|
+
type FramesSidecar,
|
|
75
|
+
type FrameSet,
|
|
76
|
+
} from './src/render.ts';
|
|
77
|
+
import { CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
|
|
41
78
|
import type { CompileResult, MotionSpec } from './src/types.ts';
|
|
42
79
|
|
|
43
80
|
/**
|
|
@@ -61,6 +98,40 @@ export type CutTable = Record<string, CutEntry>;
|
|
|
61
98
|
|
|
62
99
|
class UsageError extends Error {}
|
|
63
100
|
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// package metadata — the installed version and repository, for `--version`
|
|
103
|
+
// and for naming a remedy `bench` can only give from a repo checkout.
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
interface PackageMeta {
|
|
107
|
+
version?: string;
|
|
108
|
+
repository?: string | { url?: string };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let packageMeta: PackageMeta | null | undefined;
|
|
112
|
+
|
|
113
|
+
/** `package.json` sits next to this file both in the repo and once installed. */
|
|
114
|
+
function readPackageMeta(): PackageMeta | null {
|
|
115
|
+
if (packageMeta === undefined) {
|
|
116
|
+
try {
|
|
117
|
+
packageMeta = JSON.parse(readFileSync(join(import.meta.dir, 'package.json'), 'utf8')) as PackageMeta;
|
|
118
|
+
} catch {
|
|
119
|
+
packageMeta = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return packageMeta;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readVersion(): string {
|
|
126
|
+
return readPackageMeta()?.version ?? 'unknown';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function repositoryUrl(): string {
|
|
130
|
+
const repo = readPackageMeta()?.repository;
|
|
131
|
+
const url = typeof repo === 'string' ? repo : repo?.url;
|
|
132
|
+
return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
|
|
133
|
+
}
|
|
134
|
+
|
|
64
135
|
// ---------------------------------------------------------------------------
|
|
65
136
|
// argument parsing
|
|
66
137
|
// ---------------------------------------------------------------------------
|
|
@@ -72,31 +143,78 @@ class UsageError extends Error {}
|
|
|
72
143
|
* flag": inferring it would turn `--out --json report.json` — a real typo, a
|
|
73
144
|
* missing value — into a silently accepted switch plus a stray positional.
|
|
74
145
|
*/
|
|
75
|
-
const BOOLEAN_FLAGS = new Set(['all-frames']);
|
|
146
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again']);
|
|
76
147
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
148
|
+
/**
|
|
149
|
+
* The flags a command is allowed to spell more than once.
|
|
150
|
+
*
|
|
151
|
+
* Only `vote --candidate` is, because a ballot is *by definition* several
|
|
152
|
+
* candidates. Everywhere else a repeat is a mistake and is refused: `check
|
|
153
|
+
* --candidate a --candidate b` used to take `b` silently, which is a report
|
|
154
|
+
* about a rig the caller did not think they were asking about.
|
|
155
|
+
*/
|
|
156
|
+
const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
|
|
157
|
+
vote: new Set(['candidate']),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `--flag value` pairs plus the leftover positionals, in order.
|
|
162
|
+
*
|
|
163
|
+
* `lists` carries every occurrence of every flag and `flags` carries the last
|
|
164
|
+
* one, so a command that wants a repeated flag reads `lists` and the ones that
|
|
165
|
+
* do not are untouched by the addition.
|
|
166
|
+
*/
|
|
167
|
+
function parseArgs(
|
|
168
|
+
argv: string[],
|
|
169
|
+
repeatable: ReadonlySet<string> = new Set(),
|
|
170
|
+
): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
|
|
79
171
|
const flags: Record<string, string> = {};
|
|
172
|
+
const lists: Record<string, string[]> = {};
|
|
80
173
|
const positional: string[] = [];
|
|
174
|
+
const take = (name: string, value: string): void => {
|
|
175
|
+
if (flags[name] !== undefined && !repeatable.has(name)) {
|
|
176
|
+
throw new UsageError(
|
|
177
|
+
`--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
|
|
178
|
+
'this command takes it once',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
flags[name] = value;
|
|
182
|
+
(lists[name] ??= []).push(value);
|
|
183
|
+
};
|
|
81
184
|
for (let i = 0; i < argv.length; i++) {
|
|
82
185
|
const arg = argv[i];
|
|
83
186
|
if (arg.startsWith('--')) {
|
|
84
187
|
const eq = arg.indexOf('=');
|
|
85
188
|
if (eq !== -1) {
|
|
86
|
-
|
|
189
|
+
take(arg.slice(2, eq), arg.slice(eq + 1));
|
|
87
190
|
} else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
|
|
88
|
-
|
|
191
|
+
take(arg.slice(2), 'true');
|
|
89
192
|
} else {
|
|
90
193
|
const next = argv[i + 1];
|
|
91
194
|
if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
|
|
92
|
-
|
|
195
|
+
take(arg.slice(2), next);
|
|
93
196
|
i++;
|
|
94
197
|
}
|
|
95
198
|
} else {
|
|
96
199
|
positional.push(arg);
|
|
97
200
|
}
|
|
98
201
|
}
|
|
99
|
-
return { flags, positional };
|
|
202
|
+
return { flags, lists, positional };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read and parse a JSON file the caller named on the command line — a cuts
|
|
207
|
+
* table, a candidate or reference skeleton to `diff`. A parse failure names the
|
|
208
|
+
* file and, best-effort, where inside it the syntax broke (see
|
|
209
|
+
* `parseJsonWithPosition`); left as a raw `JSON.parse`, it would surface as an
|
|
210
|
+
* unhandled `SyntaxError` with a stack trace instead of a usage error.
|
|
211
|
+
*/
|
|
212
|
+
function readJsonFile(path: string): unknown {
|
|
213
|
+
try {
|
|
214
|
+
return parseJsonWithPosition(readFileSync(path, 'utf8'));
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
|
|
217
|
+
}
|
|
100
218
|
}
|
|
101
219
|
|
|
102
220
|
/**
|
|
@@ -107,7 +225,7 @@ function parseArgs(argv: string[]): { flags: Record<string, string>; positional:
|
|
|
107
225
|
function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
|
|
108
226
|
const abs = resolve(cutsPath);
|
|
109
227
|
if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
|
|
110
|
-
const parsed
|
|
228
|
+
const parsed = readJsonFile(abs);
|
|
111
229
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
112
230
|
throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
|
|
113
231
|
}
|
|
@@ -168,14 +286,17 @@ function resolveCut(flags: Record<string, string>): { label: string; opts: Compi
|
|
|
168
286
|
// ---------------------------------------------------------------------------
|
|
169
287
|
|
|
170
288
|
/**
|
|
171
|
-
* Read `--profile`, defaulting to
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
289
|
+
* Read `--profile`, defaulting to `spine` — see `CLI_DEFAULT_PROFILE`.
|
|
290
|
+
*
|
|
291
|
+
* An unknown name is a usage error rather than a silent fallback, and that
|
|
292
|
+
* matters in both directions: a typo used to re-apply the strictest rulebook to
|
|
293
|
+
* data the caller was trying to exempt, and it would now drop the policy layer
|
|
294
|
+
* from a caller who typed `--profile spine-htlm` and believes they asked for it.
|
|
295
|
+
* Neither is something to discover from a green.
|
|
175
296
|
*/
|
|
176
297
|
function readProfile(flags: Record<string, string>): ValidateProfile {
|
|
177
298
|
const raw = flags.profile;
|
|
178
|
-
if (raw === undefined) return
|
|
299
|
+
if (raw === undefined) return CLI_DEFAULT_PROFILE;
|
|
179
300
|
const found = VALIDATE_PROFILES.find((p) => p === raw);
|
|
180
301
|
if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
|
|
181
302
|
return found;
|
|
@@ -206,6 +327,12 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
206
327
|
const { label, opts } = resolveCut(flags);
|
|
207
328
|
const profile = readProfile(flags);
|
|
208
329
|
console.log(`rigc build ${label}`);
|
|
330
|
+
// Named explicitly and on their own lines rather than folded into the header
|
|
331
|
+
// above: with two input files, a header that names only one of them (the rig,
|
|
332
|
+
// historically) reads as though it were the one at fault whenever the error
|
|
333
|
+
// that follows actually comes from the other.
|
|
334
|
+
console.log(` .. rig ${opts.rigPath}`);
|
|
335
|
+
console.log(` .. motion ${opts.motionPath}`);
|
|
209
336
|
const result = compile(opts);
|
|
210
337
|
|
|
211
338
|
console.log(` .. ${result.images.length} part page(s):`);
|
|
@@ -241,8 +368,25 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
241
368
|
}
|
|
242
369
|
|
|
243
370
|
mkdirSync(opts.outDir, { recursive: true });
|
|
371
|
+
|
|
372
|
+
// `--copy-images`: `--out` is otherwise NOT self-contained — a page's default
|
|
373
|
+
// path is relative to the source art (often `../parts/foo.png`), which is
|
|
374
|
+
// correct for a build sitting beside the project it came from and breaks the
|
|
375
|
+
// moment the directory is zipped, committed or moved on its own (issue #217).
|
|
376
|
+
// Opt-in only: the default stays exactly what it has always been.
|
|
377
|
+
let atlasText = result.atlasText;
|
|
378
|
+
if (flags['copy-images'] !== undefined) {
|
|
379
|
+
const copied = copyAtlasImages(result.images, opts.outDir);
|
|
380
|
+
atlasText = copied.atlasText;
|
|
381
|
+
console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
|
|
382
|
+
for (const p of copied.pages) {
|
|
383
|
+
const note = p.to === basename(p.from) ? '' : ` (renamed from ${basename(p.from)} — basename collision)`;
|
|
384
|
+
console.log(` .. ${p.region.padEnd(24)} <- ${p.to}${note}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
244
388
|
writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
|
|
245
|
-
writeFileSync(join(opts.outDir, 'skeleton.atlas'),
|
|
389
|
+
writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
|
|
246
390
|
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
|
|
247
391
|
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
|
|
248
392
|
}
|
|
@@ -325,10 +469,7 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
|
|
|
325
469
|
for (const path of [candidatePath, referencePath]) {
|
|
326
470
|
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
327
471
|
}
|
|
328
|
-
const report = diffSkeletons(
|
|
329
|
-
JSON.parse(readFileSync(candidatePath, 'utf8')),
|
|
330
|
-
JSON.parse(readFileSync(referencePath, 'utf8')),
|
|
331
|
-
);
|
|
472
|
+
const report = diffSkeletons(readJsonFile(candidatePath), readJsonFile(referencePath));
|
|
332
473
|
console.log('rigc diff');
|
|
333
474
|
for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
|
|
334
475
|
if (flags.json !== undefined) {
|
|
@@ -411,14 +552,452 @@ function writeJson(target: string, body: unknown): void {
|
|
|
411
552
|
console.log(`rigc: wrote ${out}`);
|
|
412
553
|
}
|
|
413
554
|
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// seeing the result — render and preview
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
//
|
|
559
|
+
// ⭐ Why two commands exist for one question. `validate` says the artifact is
|
|
560
|
+
// valid, `check` says how close it is to reference frames — and a first user has
|
|
561
|
+
// neither a reference nor any way to look at what they built. A rig whose head
|
|
562
|
+
// sits visibly off its torso passes the gate, loads in `spine-core` and steps
|
|
563
|
+
// cleanly, because the offsets are the ones the spec asked for. The only remedy
|
|
564
|
+
// is looking (issue #216).
|
|
565
|
+
//
|
|
566
|
+
// `render` looks with OUR rasteriser: PNGs on disk, no browser, no network, and
|
|
567
|
+
// the same frame geometry `check` compares against — so its output is a frame set
|
|
568
|
+
// like any other, sidecar included. `preview` looks with ESOTERIC'S, in one HTML
|
|
569
|
+
// file, which is the stronger statement of the two: a rig that plays there has
|
|
570
|
+
// been played by the reference implementation rather than by ours (issue #151).
|
|
571
|
+
//
|
|
572
|
+
// Both take a COMPILED artifact rather than a rig and motion spec. That is what
|
|
573
|
+
// `check`, `bench` and `validate` all take, it is what `build --out` leaves
|
|
574
|
+
// behind, and it keeps `--out` meaning one thing per command instead of naming
|
|
575
|
+
// the build directory on the way in and the pictures on the way out.
|
|
576
|
+
|
|
577
|
+
/** Both commands' shared front door: which artifact, and what is in it. */
|
|
578
|
+
function resolveViewable(flags: Record<string, string>): {
|
|
579
|
+
skeletonPath: string;
|
|
580
|
+
atlasPath: string;
|
|
581
|
+
atlasDir: string;
|
|
582
|
+
} {
|
|
583
|
+
if (flags.candidate === undefined) {
|
|
584
|
+
throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
|
|
585
|
+
}
|
|
586
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
|
|
587
|
+
for (const path of [skeletonPath, atlasPath]) {
|
|
588
|
+
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
589
|
+
}
|
|
590
|
+
return { skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** `--animation`, checked against what the skeleton actually carries. */
|
|
594
|
+
function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
|
|
595
|
+
const name = flags.animation;
|
|
596
|
+
if (name === undefined) return undefined;
|
|
597
|
+
if (!available.includes(name)) {
|
|
598
|
+
throw new UsageError(
|
|
599
|
+
`no animation ${JSON.stringify(name)} in this skeleton; it has [${available.join(', ') || 'none'}]`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
return name;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
|
|
606
|
+
const raw = flags[key];
|
|
607
|
+
if (raw === undefined) return fallback;
|
|
608
|
+
const value = Number(raw);
|
|
609
|
+
if (!Number.isFinite(value) || value < least) throw new UsageError(`--${key} must be a number of at least ${least}`);
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* render — the frame series, drawn by the same rasteriser `check` measures with.
|
|
615
|
+
*
|
|
616
|
+
* The framing is measured across EVERY animation at `FRAMING_FPS` and not across
|
|
617
|
+
* the one being written, which is `src/render.ts`'s own invariant: the viewport is
|
|
618
|
+
* a property of the shot, so two animations of one rig — and the same animation at
|
|
619
|
+
* two rates — land on one pixel grid and stay comparable.
|
|
620
|
+
*/
|
|
621
|
+
function cmdRender(flags: Record<string, string>): void {
|
|
622
|
+
const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
|
|
623
|
+
const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
|
|
624
|
+
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
625
|
+
const outRoot = resolve(flags.out ?? 'render');
|
|
626
|
+
|
|
627
|
+
console.log('rigc render');
|
|
628
|
+
console.log(` .. skeleton ${skeletonPath}`);
|
|
629
|
+
console.log(` .. atlas ${atlasPath}`);
|
|
630
|
+
const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
|
|
631
|
+
const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
|
|
632
|
+
|
|
633
|
+
const viewport = framingViewport(data, maxSide);
|
|
634
|
+
if (!viewport) {
|
|
635
|
+
throw new UsageError(
|
|
636
|
+
`${skeletonPath} posed no drawable attachment in any animation or in its setup pose — there is nothing to draw`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// `sampleAll` covers the skeleton with no animation at all, which files its one
|
|
641
|
+
// setup-pose frame under the reserved name. Narrowing to one animation reuses
|
|
642
|
+
// the same sampler rather than a second path through it.
|
|
643
|
+
const sampled: Map<string, Frame[]> =
|
|
644
|
+
only === undefined ? sampleAll(data, fps) : new Map([[only, sampleAnimation(data, only, fps)]]);
|
|
645
|
+
console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
|
|
646
|
+
|
|
647
|
+
mkdirSync(outRoot, { recursive: true });
|
|
648
|
+
const sets: FrameSet[] = [];
|
|
649
|
+
for (const [name, frames] of sampled) {
|
|
650
|
+
// Same naming as a reference render: the protocol rate says nothing, any
|
|
651
|
+
// other rate says itself, so two rates of one animation sit side by side.
|
|
652
|
+
const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
|
|
653
|
+
const dir = join(outRoot, dirName);
|
|
654
|
+
// Cleared rather than written over: a shorter animation would otherwise leave
|
|
655
|
+
// the tail of a longer previous run on disk, and stale frames in a frame set
|
|
656
|
+
// are indistinguishable from real ones.
|
|
657
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true });
|
|
658
|
+
mkdirSync(dir, { recursive: true });
|
|
659
|
+
for (let i = 0; i < frames.length; i++) {
|
|
660
|
+
renderFrame(frames[i], pages, viewport, BACKGROUND).writePng(join(dir, `f${String(i).padStart(4, '0')}.png`));
|
|
661
|
+
}
|
|
662
|
+
// One frame has nothing to compare itself against, so it gets no sheet — it
|
|
663
|
+
// would be the same picture with a border and a "0" on it.
|
|
664
|
+
const sheet = frames.length > 1;
|
|
665
|
+
if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
|
|
666
|
+
const duration = frames[frames.length - 1].time;
|
|
667
|
+
sets.push({
|
|
668
|
+
dir: dirName,
|
|
669
|
+
animation: name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name,
|
|
670
|
+
fps,
|
|
671
|
+
sampled: frames.length,
|
|
672
|
+
written: frames.length,
|
|
673
|
+
stride: 1,
|
|
674
|
+
duration,
|
|
675
|
+
});
|
|
676
|
+
const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
|
|
677
|
+
console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${sheet ? ` + ${SHEET_FILE}` : ''} -> ${dir}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The sidecar is what makes this a frame SET rather than a pile of pictures:
|
|
681
|
+
// the world box every frame is a picture of, so a distance measured in pixels
|
|
682
|
+
// converts back to the units the rig is authored in — and so `rigc check` can
|
|
683
|
+
// render something else into the same grid later.
|
|
684
|
+
const sidecar: FramesSidecar = {
|
|
685
|
+
spec: FRAMES_SPEC,
|
|
686
|
+
background: BACKGROUND,
|
|
687
|
+
viewport: {
|
|
688
|
+
x: viewport.minX,
|
|
689
|
+
y: viewport.minY,
|
|
690
|
+
width: viewport.maxX - viewport.minX,
|
|
691
|
+
height: viewport.maxY - viewport.minY,
|
|
692
|
+
scale: viewport.scale,
|
|
693
|
+
pixelWidth: viewport.width,
|
|
694
|
+
pixelHeight: viewport.height,
|
|
695
|
+
},
|
|
696
|
+
sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
|
|
697
|
+
};
|
|
698
|
+
writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
699
|
+
console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** The animation names an emitted skeleton carries, in the order it lists them. */
|
|
703
|
+
function skeletonAnimationNames(skeletonText: string, path: string): string[] {
|
|
704
|
+
let parsed: unknown;
|
|
705
|
+
try {
|
|
706
|
+
parsed = parseJsonWithPosition(skeletonText);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
|
|
709
|
+
}
|
|
710
|
+
if (typeof parsed !== 'object' || parsed === null) throw new UsageError(`${path} is not a skeleton object`);
|
|
711
|
+
const animations = (parsed as { animations?: unknown }).animations;
|
|
712
|
+
if (animations === undefined) return [];
|
|
713
|
+
if (typeof animations !== 'object' || animations === null || Array.isArray(animations)) {
|
|
714
|
+
throw new UsageError(`${path} has an "animations" field that is not an object`);
|
|
715
|
+
}
|
|
716
|
+
return Object.keys(animations);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* preview — the artifact playing in Esoteric's own web player, as one file.
|
|
721
|
+
*
|
|
722
|
+
* ⚠️ Nothing is rasterised here and nothing is decoded. The pages go into the
|
|
723
|
+
* page as the bytes they are on disk, so a preview works for any PNG a BROWSER
|
|
724
|
+
* can draw rather than for the ones our own decoder reads — which is the right
|
|
725
|
+
* direction for the command whose whole job is "just show me".
|
|
726
|
+
*/
|
|
727
|
+
function cmdPreview(flags: Record<string, string>): void {
|
|
728
|
+
const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
|
|
729
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
730
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
731
|
+
const animations = skeletonAnimationNames(skeletonText, skeletonPath);
|
|
732
|
+
const chosen = readAnimationFlag(flags, animations);
|
|
733
|
+
|
|
734
|
+
// A directory for --out is taken as "put the default name in here", because
|
|
735
|
+
// `--out render/` is what the sibling command means by the same flag and a
|
|
736
|
+
// preview written OVER a directory is not a recoverable mistake.
|
|
737
|
+
const target = resolve(flags.out ?? 'preview.html');
|
|
738
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
|
|
739
|
+
|
|
740
|
+
console.log('rigc preview');
|
|
741
|
+
console.log(` .. skeleton ${skeletonPath}`);
|
|
742
|
+
console.log(` .. atlas ${atlasPath}`);
|
|
743
|
+
|
|
744
|
+
const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
|
|
745
|
+
const path = join(atlasDir, name);
|
|
746
|
+
if (!existsSync(path)) {
|
|
747
|
+
throw new UsageError(
|
|
748
|
+
`the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
|
|
749
|
+
'a page a preview cannot embed is a page the player could not have loaded either',
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
return { name, bytes: readFileSync(path) };
|
|
753
|
+
});
|
|
754
|
+
for (const page of pages) {
|
|
755
|
+
console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const html = buildPreview({
|
|
759
|
+
skeletonText,
|
|
760
|
+
atlasText,
|
|
761
|
+
pages,
|
|
762
|
+
animation: chosen ?? animations[0] ?? null,
|
|
763
|
+
animations,
|
|
764
|
+
label: skeletonPath,
|
|
765
|
+
version: readVersion(),
|
|
766
|
+
});
|
|
767
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
768
|
+
writeFileSync(out, html);
|
|
769
|
+
console.log(
|
|
770
|
+
` .. embedded ${pages.length} page(s) + the skeleton and atlas as data URIs; ` +
|
|
771
|
+
`the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
|
|
772
|
+
);
|
|
773
|
+
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// choosing between results — vote
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
//
|
|
780
|
+
// ⭐ `preview` shows one candidate; this shows two to four of them side by side
|
|
781
|
+
// and takes an answer back. The rest of this toolchain is instruments, and it
|
|
782
|
+
// should be — the vote opens only where the instruments have already run out.
|
|
783
|
+
// See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
|
|
784
|
+
// why the labels are A and B, and why the record is hashes.
|
|
785
|
+
//
|
|
786
|
+
// Two modes on one command, because they share exactly one thing and it is the
|
|
787
|
+
// contract between them: the ballot manifest. Splitting them would document
|
|
788
|
+
// that format twice and let the halves drift.
|
|
789
|
+
//
|
|
790
|
+
// rigc vote --candidate <a> --candidate <b> [--animation <n>] [--out ballot.html]
|
|
791
|
+
// rigc vote --record <result.json> [--ballot ballot.html] [--ledger votes.jsonl] [--again]
|
|
792
|
+
|
|
793
|
+
const DEFAULT_BALLOT = 'ballot.html';
|
|
794
|
+
const DEFAULT_LEDGER = 'votes.jsonl';
|
|
795
|
+
|
|
796
|
+
/** Load one candidate off disk in the shape a ballot needs. */
|
|
797
|
+
function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
|
|
798
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
|
|
799
|
+
for (const path of [skeletonPath, atlasPath]) {
|
|
800
|
+
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
801
|
+
}
|
|
802
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
803
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
804
|
+
const atlasDir = dirname(atlasPath);
|
|
805
|
+
const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
|
|
806
|
+
const path = join(atlasDir, name);
|
|
807
|
+
if (!existsSync(path)) {
|
|
808
|
+
throw new UsageError(
|
|
809
|
+
`the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
|
|
810
|
+
'a page a ballot cannot embed is a page the player could not have loaded either',
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return { name, bytes: readFileSync(path) };
|
|
814
|
+
});
|
|
815
|
+
return {
|
|
816
|
+
candidate: { source: skeletonPath, skeletonText, atlasText, pages },
|
|
817
|
+
animations: skeletonAnimationNames(skeletonText, skeletonPath),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The one animation every candidate plays.
|
|
823
|
+
*
|
|
824
|
+
* ⚠️ Refused rather than resolved per candidate. Two panes running two
|
|
825
|
+
* different animations look like a comparison and are not one, and a voter has
|
|
826
|
+
* no way to see that it happened — the labels are `A` and `B`, which is the
|
|
827
|
+
* whole point, so nothing on the screen would say so.
|
|
828
|
+
*/
|
|
829
|
+
function commonAnimation(
|
|
830
|
+
flags: Record<string, string>,
|
|
831
|
+
loaded: { animations: string[] }[],
|
|
832
|
+
): string | null {
|
|
833
|
+
const asked = flags.animation;
|
|
834
|
+
if (asked === undefined) {
|
|
835
|
+
const first = loaded[0].animations[0];
|
|
836
|
+
if (first === undefined) {
|
|
837
|
+
const withAny = loaded.findIndex((l) => l.animations.length > 0);
|
|
838
|
+
if (withAny !== -1) {
|
|
839
|
+
throw new UsageError(
|
|
840
|
+
`candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
|
|
841
|
+
'a ballot plays one animation in every pane, so there is nothing to compare here',
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
const missing = loaded.findIndex((l) => !l.animations.includes(first));
|
|
847
|
+
if (missing !== -1) {
|
|
848
|
+
throw new UsageError(
|
|
849
|
+
`the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
|
|
850
|
+
`have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
return first;
|
|
854
|
+
}
|
|
855
|
+
const missing = loaded.findIndex((l) => !l.animations.includes(asked));
|
|
856
|
+
if (missing !== -1) {
|
|
857
|
+
throw new UsageError(
|
|
858
|
+
`no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
|
|
859
|
+
`[${loaded[missing].animations.join(', ') || 'none'}]`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
return asked;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** vote (ballot mode) — write the page a human opens. */
|
|
866
|
+
function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
|
|
867
|
+
if (candidates.length < MIN_CANDIDATES) {
|
|
868
|
+
throw new UsageError(
|
|
869
|
+
`a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
|
|
870
|
+
'was given — one candidate on its own is `rigc preview`',
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
if (candidates.length > MAX_CANDIDATES) {
|
|
874
|
+
throw new UsageError(
|
|
875
|
+
`${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
|
|
876
|
+
'on one screen, and a comparison that needs scrolling is not a comparison',
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
// `--atlas` names ONE atlas and there are several skeletons here, so there is
|
|
880
|
+
// no unambiguous thing it could mean. Each candidate's atlas has to sit beside
|
|
881
|
+
// its skeleton, which is what `build --out` leaves behind.
|
|
882
|
+
if (flags.atlas !== undefined) {
|
|
883
|
+
throw new UsageError(
|
|
884
|
+
'--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const loaded = candidates.map((target) => loadBallotCandidate(target));
|
|
889
|
+
const animation = commonAnimation(flags, loaded);
|
|
890
|
+
|
|
891
|
+
const target = resolve(flags.out ?? DEFAULT_BALLOT);
|
|
892
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
|
|
893
|
+
|
|
894
|
+
const input: BallotInput = {
|
|
895
|
+
candidates: loaded.map((l) => l.candidate),
|
|
896
|
+
animation,
|
|
897
|
+
version: readVersion(),
|
|
898
|
+
};
|
|
899
|
+
const { html, manifest } = buildBallot(input);
|
|
900
|
+
|
|
901
|
+
console.log('rigc vote');
|
|
902
|
+
console.log(` .. ballot ${manifest.ballot}`);
|
|
903
|
+
console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
|
|
904
|
+
for (let i = 0; i < manifest.candidates.length; i++) {
|
|
905
|
+
const entry = manifest.candidates[i];
|
|
906
|
+
const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
|
|
907
|
+
console.log(
|
|
908
|
+
` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
|
|
909
|
+
`${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
913
|
+
writeFileSync(out, html);
|
|
914
|
+
console.log(
|
|
915
|
+
` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
|
|
916
|
+
'in its manifest, never on the screen',
|
|
917
|
+
);
|
|
918
|
+
console.log(
|
|
919
|
+
` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
|
|
920
|
+
`unpkg (@${PLAYER_LINE}), so the first open needs a network`,
|
|
921
|
+
);
|
|
922
|
+
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
923
|
+
console.log(
|
|
924
|
+
`rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** vote (record mode) — check one saved vote and append it to the ledger. */
|
|
929
|
+
function cmdVoteRecord(flags: Record<string, string>): void {
|
|
930
|
+
for (const key of ['candidate', 'out'] as const) {
|
|
931
|
+
if (flags[key] !== undefined) {
|
|
932
|
+
throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const resultPath = resolve(flags.record);
|
|
936
|
+
const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
|
|
937
|
+
const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
|
|
938
|
+
for (const [what, path] of [
|
|
939
|
+
['result', resultPath],
|
|
940
|
+
['ballot', ballotPath],
|
|
941
|
+
] as const) {
|
|
942
|
+
if (!existsSync(path)) {
|
|
943
|
+
throw new UsageError(
|
|
944
|
+
`no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
console.log('rigc vote --record');
|
|
950
|
+
console.log(` .. result ${resultPath}`);
|
|
951
|
+
console.log(` .. ballot ${ballotPath}`);
|
|
952
|
+
console.log(` .. ledger ${ledgerPath}`);
|
|
953
|
+
|
|
954
|
+
const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
|
|
955
|
+
const result = readJsonFile(resultPath);
|
|
956
|
+
const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
|
|
957
|
+
const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
|
|
958
|
+
const again = flags.again !== undefined;
|
|
959
|
+
|
|
960
|
+
const { refusals, line } = verifyResult(manifest, result, { attempts, again });
|
|
961
|
+
if (line === null) {
|
|
962
|
+
for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
|
|
963
|
+
console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
|
|
967
|
+
|
|
968
|
+
line.seq = existing.length + 1;
|
|
969
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
970
|
+
appendFileSync(ledgerPath, ledgerLineText(line));
|
|
971
|
+
console.log(
|
|
972
|
+
` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
|
|
973
|
+
`reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
|
|
974
|
+
);
|
|
975
|
+
console.log(
|
|
976
|
+
` .. coverage ${line.coverage.length} candidate(s): ` +
|
|
977
|
+
line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
|
|
978
|
+
);
|
|
979
|
+
console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function cmdVote(flags: Record<string, string>, candidates: string[]): void {
|
|
983
|
+
if (flags.record !== undefined) cmdVoteRecord(flags);
|
|
984
|
+
else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
|
|
985
|
+
else {
|
|
986
|
+
throw new UsageError(
|
|
987
|
+
'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
|
|
988
|
+
'record one that came back',
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
414
993
|
/**
|
|
415
994
|
* bench — run one rung of the benchmark ladder against a candidate rig.
|
|
416
995
|
*
|
|
417
996
|
* Two questions, asked in this order and never merged:
|
|
418
997
|
*
|
|
419
998
|
* 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
|
|
420
|
-
* and it is the only part with a pass/fail.
|
|
421
|
-
*
|
|
999
|
+
* and it is the only part with a pass/fail. The profile is pinned here, not
|
|
1000
|
+
* inherited from the CLI default: the thing being reproduced is an editor
|
|
422
1001
|
* export, and holding it to this project's renderer policy would fail rungs
|
|
423
1002
|
* for reasons the rung is not about.
|
|
424
1003
|
* 2. How close is it, structurally, to the reference? That is `diff`, and it
|
|
@@ -438,13 +1017,22 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
438
1017
|
if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
|
|
439
1018
|
if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
|
|
440
1019
|
|
|
441
|
-
// bench judges a reproduction of editor output, so `spine` is
|
|
442
|
-
|
|
1020
|
+
// bench judges a reproduction of editor output, so `spine` is PINNED here
|
|
1021
|
+
// rather than inherited. It reads the same as the CLI default today (#221) and
|
|
1022
|
+
// is kept as its own statement anyway: the ladder's stage-1 gate is defined by
|
|
1023
|
+
// `docs/GATE.md` as `validate --profile spine`, and a bench run must go on
|
|
1024
|
+
// meaning that whatever a later release decides the default should be.
|
|
1025
|
+
const profile: ValidateProfile = flags.profile === undefined ? 'spine' : readProfile(flags);
|
|
443
1026
|
const exportDir = resolve(import.meta.dir, 'examples', rung.example, 'export');
|
|
444
1027
|
if (!existsSync(exportDir)) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
1028
|
+
// `bun run fetch-examples` runs `scripts/fetch-examples.sh`, and `scripts/`
|
|
1029
|
+
// is not in package.json's `files` — an npm install has no such script to
|
|
1030
|
+
// run. Its presence is what tells the two contexts apart, so the remedy
|
|
1031
|
+
// named here is one that actually exists in whichever context this is.
|
|
1032
|
+
const remedy = existsSync(resolve(import.meta.dir, 'scripts', 'fetch-examples.sh'))
|
|
1033
|
+
? 'run `bun run fetch-examples` first'
|
|
1034
|
+
: `bench needs a checkout of ${repositoryUrl()} — its \`fetch-examples\` script is not part of the installed package`;
|
|
1035
|
+
throw new UsageError(`no example corpus at ${exportDir} — ${remedy} (examples/ is gitignored, not shipped)`);
|
|
448
1036
|
}
|
|
449
1037
|
|
|
450
1038
|
const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
|
|
@@ -615,10 +1203,14 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
615
1203
|
|
|
616
1204
|
function cmdExplain(flags: Record<string, string>): void {
|
|
617
1205
|
const { label, opts } = resolveCut(flags);
|
|
1206
|
+
console.log(`rigc explain ${label}`);
|
|
1207
|
+
// See the identical pair of lines in `cmdBuild` for why both paths are named
|
|
1208
|
+
// here rather than only the one the header's `label` happens to carry.
|
|
1209
|
+
console.log(` .. rig ${opts.rigPath}`);
|
|
1210
|
+
console.log(` .. motion ${opts.motionPath}`);
|
|
618
1211
|
const result = compile(opts);
|
|
619
|
-
const motion =
|
|
1212
|
+
const motion = readJsonFile(opts.motionPath) as MotionSpec;
|
|
620
1213
|
|
|
621
|
-
console.log(`rigc explain ${label}`);
|
|
622
1214
|
console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
|
|
623
1215
|
|
|
624
1216
|
// The crop note describes where the numbers CAME from, and without a manifest
|
|
@@ -684,6 +1276,46 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
684
1276
|
}
|
|
685
1277
|
}
|
|
686
1278
|
}
|
|
1279
|
+
// The two constraint groups: one unnamed timeline per constraint, so the
|
|
1280
|
+
// name printed is the constraint's and there is no timeline name to print
|
|
1281
|
+
// beside it. Every field a key carries is shown, because each one is
|
|
1282
|
+
// optional in the file and the ABSENT ones are what a reader has to see —
|
|
1283
|
+
// an omitted `softness` is 0, not "unchanged".
|
|
1284
|
+
for (const group of ['ik', 'transform'] as const) {
|
|
1285
|
+
for (const [name, keys] of Object.entries(anim[group] ?? {})) {
|
|
1286
|
+
console.log(` ${group}.${name} ${keys.length} key(s) <- one timeline per constraint`);
|
|
1287
|
+
for (const key of keys) {
|
|
1288
|
+
const fields = Object.entries(key)
|
|
1289
|
+
.filter(([k]) => k !== 'time' && k !== 'curve')
|
|
1290
|
+
.map(([k, v]) => `${k}=${String(v)}`)
|
|
1291
|
+
.join(' ');
|
|
1292
|
+
const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
|
|
1293
|
+
console.log(` t=${String(key.time).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
// Deform timelines are keyed on a skin/slot/attachment triple, and the run
|
|
1298
|
+
// is printed as its span rather than its numbers: `offset` plus a length is
|
|
1299
|
+
// what tells a reader whether the key lands where they meant, and a hundred
|
|
1300
|
+
// vertex offsets on one line tells them nothing.
|
|
1301
|
+
for (const [skinName, slotMap] of Object.entries(anim.attachments ?? {})) {
|
|
1302
|
+
for (const [slotName, attMap] of Object.entries(slotMap)) {
|
|
1303
|
+
for (const [attName, timelines] of Object.entries(attMap)) {
|
|
1304
|
+
for (const [timelineName, keys] of Object.entries(timelines)) {
|
|
1305
|
+
console.log(` ${skinName}/${slotName}/${attName}.${timelineName} ${keys.length} key(s)`);
|
|
1306
|
+
for (const key of keys) {
|
|
1307
|
+
const run = Array.isArray(key.vertices) ? (key.vertices as number[]) : null;
|
|
1308
|
+
const offset = typeof key.offset === 'number' ? key.offset : 0;
|
|
1309
|
+
const span = run
|
|
1310
|
+
? `deform[${offset}..${offset + run.length}] ${run.length / 2} pair(s)`
|
|
1311
|
+
: 'back to the setup pose';
|
|
1312
|
+
const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
|
|
1313
|
+
console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
687
1319
|
// The draw-order timeline names no target, so it hangs off the animation
|
|
688
1320
|
// rather than off a slot — and a timeline `explain` did not print would be a
|
|
689
1321
|
// timeline nobody could check without reading the emitted JSON.
|
|
@@ -725,35 +1357,238 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
725
1357
|
console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
|
|
726
1358
|
}
|
|
727
1359
|
|
|
1360
|
+
// ---------------------------------------------------------------------------
|
|
1361
|
+
// usage / per-command help
|
|
1362
|
+
// ---------------------------------------------------------------------------
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* One meaning per flag name, shared by every command that takes it — the
|
|
1366
|
+
* single place this project states what a flag means. AUTHORING.md §0 quotes
|
|
1367
|
+
* this table for `build`'s `--rig`/`--motion`/`--out`/`--images`/`--manifest`/
|
|
1368
|
+
* `--profile`; if the two ever disagree, this is the one the code runs.
|
|
1369
|
+
*/
|
|
1370
|
+
const FLAG_MEANINGS: Record<string, string> = {
|
|
1371
|
+
rig: 'the rig spec — skeleton structure',
|
|
1372
|
+
motion: 'the motion spec — time',
|
|
1373
|
+
out: 'directory for skeleton.json + skeleton.atlas; atlas page paths are written relative to it',
|
|
1374
|
+
images: "override the rig spec's own images directory (relative to your working directory)",
|
|
1375
|
+
manifest: 'a cut manifest, for a rig with measured art behind it; a foreign skeleton has none',
|
|
1376
|
+
'copy-images':
|
|
1377
|
+
'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
|
|
1378
|
+
'self-contained enough to zip or commit on its own (default: page paths still point at the source art)',
|
|
1379
|
+
cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
|
|
1380
|
+
cuts: 'the cuts.json --cut names',
|
|
1381
|
+
profile:
|
|
1382
|
+
'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
|
|
1383
|
+
"correctly; spine-html = also this project's renderer/archetype policy",
|
|
1384
|
+
atlas: "the candidate's atlas, when it is not beside the skeleton",
|
|
1385
|
+
candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
|
|
1386
|
+
frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
|
|
1387
|
+
fps: 'frame rate, only for a frame set with no frames.json sidecar',
|
|
1388
|
+
viewport: "pin the candidate's world box, y up, instead of fitting it",
|
|
1389
|
+
framing: 'fit each frame set on its own (default) or once across all of them',
|
|
1390
|
+
as: 'the candidate animation to play, when it is named differently from the frame set',
|
|
1391
|
+
'all-frames': 'print every frame, not just the worst by MAE',
|
|
1392
|
+
json: 'also write the whole report to this path',
|
|
1393
|
+
animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
|
|
1394
|
+
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
1395
|
+
record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
|
|
1396
|
+
ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
|
|
1397
|
+
ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
|
|
1398
|
+
again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
|
|
1399
|
+
help: "show this command's flags and exit",
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
/** The `<value>` a flag takes, for its column in a command's flag table. Absent for a boolean switch. */
|
|
1403
|
+
const FLAG_VALUES: Record<string, string> = {
|
|
1404
|
+
rig: '<path>',
|
|
1405
|
+
motion: '<path>',
|
|
1406
|
+
out: '<dir>',
|
|
1407
|
+
images: '<dir>',
|
|
1408
|
+
manifest: '<path>',
|
|
1409
|
+
cut: '<name>',
|
|
1410
|
+
cuts: '<path>',
|
|
1411
|
+
profile: 'spine|spine-html',
|
|
1412
|
+
atlas: '<path>',
|
|
1413
|
+
candidate: '<dir|skeleton.json>',
|
|
1414
|
+
frames: '<dir>',
|
|
1415
|
+
fps: '<n>',
|
|
1416
|
+
viewport: '<x,y,w,h>',
|
|
1417
|
+
framing: 'per-shot|shared',
|
|
1418
|
+
as: '<name>',
|
|
1419
|
+
json: '<out>',
|
|
1420
|
+
animation: '<name>',
|
|
1421
|
+
max: '<px>',
|
|
1422
|
+
record: '<result.json>',
|
|
1423
|
+
ballot: '<ballot.html>',
|
|
1424
|
+
ledger: '<votes.jsonl>',
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
interface CommandDoc {
|
|
1428
|
+
name: string;
|
|
1429
|
+
/** One or more invocation forms, each already spelling the command name. */
|
|
1430
|
+
usage: string[];
|
|
1431
|
+
/** Flag names (into FLAG_MEANINGS/FLAG_VALUES), in display order. `--help` is appended automatically. */
|
|
1432
|
+
flags: string[];
|
|
1433
|
+
/**
|
|
1434
|
+
* Per-command wording for a flag whose value or meaning genuinely differs here.
|
|
1435
|
+
*
|
|
1436
|
+
* ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
|
|
1437
|
+
* and this is the named exception to it, not a second table. Three flags earn it:
|
|
1438
|
+
* `--out` is a directory of artifacts to `build`, a directory of pictures to
|
|
1439
|
+
* `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
|
|
1440
|
+
* was RECORDED at to `check`, which reads it off a sidecar, and the rate to
|
|
1441
|
+
* SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
|
|
1442
|
+
* everywhere except `vote`, which is the one command that takes several and is
|
|
1443
|
+
* the reason there is a ballot at all. Writing any of them as one sentence
|
|
1444
|
+
* covering every command would leave every command's own help less true.
|
|
1445
|
+
*/
|
|
1446
|
+
overrides?: Record<string, { value?: string; meaning?: string }>;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const COMMANDS: CommandDoc[] = [
|
|
1450
|
+
{
|
|
1451
|
+
name: 'build',
|
|
1452
|
+
usage: [
|
|
1453
|
+
'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
|
|
1454
|
+
'rigc build --cut <name> --cuts <cuts.json>',
|
|
1455
|
+
],
|
|
1456
|
+
flags: ['rig', 'motion', 'out', 'manifest', 'images', 'copy-images', 'cut', 'cuts', 'profile'],
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
name: 'explain',
|
|
1460
|
+
usage: ['rigc explain (same arguments as build, minus --profile — it never gates)'],
|
|
1461
|
+
flags: ['rig', 'motion', 'out', 'manifest', 'images', 'cut', 'cuts'],
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
name: 'validate',
|
|
1465
|
+
usage: [
|
|
1466
|
+
'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
|
|
1467
|
+
'rigc validate --cut <name> --cuts <cuts.json> (also re-derives declared durations)',
|
|
1468
|
+
],
|
|
1469
|
+
flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
name: 'diff',
|
|
1473
|
+
usage: ['rigc diff <candidate.json> <reference.json> [--json <out>]'],
|
|
1474
|
+
flags: ['json'],
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
name: 'check',
|
|
1478
|
+
usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
|
|
1479
|
+
flags: ['candidate', 'frames', 'atlas', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
name: 'bench',
|
|
1483
|
+
usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
|
|
1484
|
+
flags: ['candidate', 'atlas', 'frames', 'profile', 'all-frames', 'json'],
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
name: 'render',
|
|
1488
|
+
usage: [
|
|
1489
|
+
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--fps 12] [--max 256] [--out render/]',
|
|
1490
|
+
],
|
|
1491
|
+
flags: ['candidate', 'atlas', 'animation', 'fps', 'max', 'out'],
|
|
1492
|
+
overrides: {
|
|
1493
|
+
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
1494
|
+
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
|
1495
|
+
},
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
name: 'preview',
|
|
1499
|
+
usage: ['rigc preview --candidate <dir | skeleton.json> [--animation <name>] [--out preview.html]'],
|
|
1500
|
+
flags: ['candidate', 'atlas', 'animation', 'out'],
|
|
1501
|
+
overrides: {
|
|
1502
|
+
out: {
|
|
1503
|
+
value: '<file>',
|
|
1504
|
+
meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
name: 'vote',
|
|
1510
|
+
usage: [
|
|
1511
|
+
`rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
|
|
1512
|
+
`rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
|
|
1513
|
+
],
|
|
1514
|
+
flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
|
|
1515
|
+
overrides: {
|
|
1516
|
+
candidate: {
|
|
1517
|
+
value: '<dir|skeleton.json>',
|
|
1518
|
+
meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
|
|
1519
|
+
},
|
|
1520
|
+
animation: {
|
|
1521
|
+
meaning:
|
|
1522
|
+
'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
|
|
1523
|
+
'it is refused — two panes playing two animations is not a comparison',
|
|
1524
|
+
},
|
|
1525
|
+
out: {
|
|
1526
|
+
value: '<file>',
|
|
1527
|
+
meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
|
|
1528
|
+
},
|
|
1529
|
+
},
|
|
1530
|
+
},
|
|
1531
|
+
];
|
|
1532
|
+
|
|
1533
|
+
const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
|
|
1534
|
+
|
|
1535
|
+
/** `rigc <command> --help`: that command's own usage line(s) and flag table. */
|
|
1536
|
+
function commandHelp(name: string): string {
|
|
1537
|
+
const doc = COMMANDS.find((c) => c.name === name);
|
|
1538
|
+
if (!doc) throw new Error(`internal: no help text for command "${name}"`);
|
|
1539
|
+
const keys = [...doc.flags, 'help'];
|
|
1540
|
+
const value = (key: string): string | undefined => doc.overrides?.[key]?.value ?? FLAG_VALUES[key];
|
|
1541
|
+
const meaning = (key: string): string => doc.overrides?.[key]?.meaning ?? FLAG_MEANINGS[key];
|
|
1542
|
+
const labels = keys.map((key) => `--${key}${value(key) ? ` ${value(key)}` : ''}`);
|
|
1543
|
+
const width = Math.max(...labels.map((l) => l.length)) + 2;
|
|
1544
|
+
return ['usage:', ...doc.usage.map((u) => ` ${u}`), '', 'flags:', ...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`)].join(
|
|
1545
|
+
'\n',
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
728
1549
|
const USAGE = [
|
|
1550
|
+
'rigc — the rig compiler',
|
|
1551
|
+
'',
|
|
1552
|
+
'(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)',
|
|
1553
|
+
'',
|
|
729
1554
|
'usage:',
|
|
730
|
-
|
|
731
|
-
' bun cli.ts build --cut <name> --cuts <cuts.json>',
|
|
732
|
-
' bun cli.ts explain (same arguments as build)',
|
|
733
|
-
' bun cli.ts validate <dir | skeleton.json> [--atlas <path>]',
|
|
734
|
-
' bun cli.ts diff <candidate.json> <reference.json> [--json <out>]',
|
|
735
|
-
' bun cli.ts check --candidate <dir | skeleton.json> --frames <dir>',
|
|
736
|
-
` bun cli.ts bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>]`,
|
|
1555
|
+
...COMMANDS.flatMap((c) => c.usage.map((u) => ` ${u}`)),
|
|
737
1556
|
'',
|
|
738
|
-
'
|
|
1557
|
+
' rigc <command> --help that command\'s own flag table',
|
|
1558
|
+
' rigc --version print the installed version (-v works too)',
|
|
1559
|
+
'',
|
|
1560
|
+
'build, validate and bench take --profile spine|spine-html:',
|
|
739
1561
|
' spine is this valid Spine 4.3 that any runtime plays correctly?',
|
|
740
|
-
'
|
|
1562
|
+
' THE DEFAULT — 20 rules, and the question the output answers when',
|
|
1563
|
+
' you import it into the Spine editor.',
|
|
1564
|
+
' spine-html the above, plus this project\'s renderer and archetype policy:',
|
|
1565
|
+
' all 36 rules, opt-in. Those extra 14 fire on real, correct,',
|
|
1566
|
+
' editor-produced Spine data, so they are somebody\'s policy rather',
|
|
1567
|
+
' than anybody\'s validity.',
|
|
1568
|
+
'',
|
|
1569
|
+
'Every report names the profile that judged it and lists, on PROF lines, the',
|
|
1570
|
+
'rules that profile left out.',
|
|
741
1571
|
'',
|
|
742
1572
|
'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
|
|
743
1573
|
'there by its own drawn pixels, and compares. It reads the frames and never the',
|
|
744
1574
|
'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
|
|
745
|
-
'see a wrong animation and this can
|
|
746
|
-
'
|
|
747
|
-
'
|
|
748
|
-
' --
|
|
749
|
-
'
|
|
750
|
-
'
|
|
751
|
-
'
|
|
752
|
-
'
|
|
753
|
-
'
|
|
754
|
-
'
|
|
755
|
-
'
|
|
756
|
-
' --
|
|
1575
|
+
'see a wrong animation and this can. See `rigc check --help` for its flags.',
|
|
1576
|
+
'',
|
|
1577
|
+
'render and preview are how you LOOK at a build, and they need no reference at all:',
|
|
1578
|
+
' rigc render --candidate <the dir build --out wrote> PNG frames + a contact sheet',
|
|
1579
|
+
' rigc preview --candidate <the same dir> one .html file that plays it',
|
|
1580
|
+
'A rig with its head off its torso passes the gate and steps cleanly — the offsets',
|
|
1581
|
+
'are the ones you asked for — so looking is the only thing that catches it. render',
|
|
1582
|
+
'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
|
|
1583
|
+
'it in the official Spine Web Player, which is also the interop proof.',
|
|
1584
|
+
'',
|
|
1585
|
+
'vote is the same page with two to four builds in it and an answer coming back:',
|
|
1586
|
+
' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
|
|
1587
|
+
' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
|
|
1588
|
+
'Reach for it where the instruments have run out — a choice with no reference behind',
|
|
1589
|
+
'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
|
|
1590
|
+
'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
|
|
1591
|
+
'refused by name instead of appended.',
|
|
757
1592
|
'',
|
|
758
1593
|
'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
|
|
759
1594
|
' "manifest": "..." (optional) } }, with every path',
|
|
@@ -762,22 +1597,47 @@ const USAGE = [
|
|
|
762
1597
|
|
|
763
1598
|
const [command, ...rest] = process.argv.slice(2);
|
|
764
1599
|
try {
|
|
765
|
-
|
|
1600
|
+
if (command === undefined) {
|
|
1601
|
+
console.error(USAGE);
|
|
1602
|
+
process.exit(2);
|
|
1603
|
+
}
|
|
1604
|
+
if (command === '--version' || command === '-v') {
|
|
1605
|
+
console.log(readVersion());
|
|
1606
|
+
process.exit(0);
|
|
1607
|
+
}
|
|
1608
|
+
if (command === '--help' || command === '-h') {
|
|
1609
|
+
console.log(USAGE);
|
|
1610
|
+
process.exit(0);
|
|
1611
|
+
}
|
|
1612
|
+
if (!KNOWN_COMMANDS.includes(command)) {
|
|
1613
|
+
throw new UsageError(`unknown command: ${command}`);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const { flags, lists, positional } = parseArgs(rest, REPEATABLE_FLAGS[command]);
|
|
1617
|
+
if (flags.help !== undefined) {
|
|
1618
|
+
console.log(commandHelp(command));
|
|
1619
|
+
process.exit(0);
|
|
1620
|
+
}
|
|
766
1621
|
if (command === 'build') cmdBuild(flags);
|
|
767
1622
|
else if (command === 'validate') cmdValidate(flags, positional);
|
|
768
1623
|
else if (command === 'explain') cmdExplain(flags);
|
|
769
1624
|
else if (command === 'diff') cmdDiff(flags, positional);
|
|
770
1625
|
else if (command === 'check') cmdCheck(flags);
|
|
771
1626
|
else if (command === 'bench') cmdBench(flags, positional);
|
|
772
|
-
else
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
}
|
|
1627
|
+
else if (command === 'render') cmdRender(flags);
|
|
1628
|
+
else if (command === 'preview') cmdPreview(flags);
|
|
1629
|
+
else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
|
|
776
1630
|
} catch (err) {
|
|
777
1631
|
if (err instanceof UsageError) {
|
|
778
1632
|
console.error(`rigc: ${err.message}\n\n${USAGE}`);
|
|
779
1633
|
process.exit(2);
|
|
780
1634
|
}
|
|
1635
|
+
// A ballot refuses on its arguments, like a usage error, but its messages are
|
|
1636
|
+
// long enough that reprinting the whole usage under them buries the reason.
|
|
1637
|
+
if (err instanceof BallotError) {
|
|
1638
|
+
console.error(`rigc vote: ${err.message}`);
|
|
1639
|
+
process.exit(2);
|
|
1640
|
+
}
|
|
781
1641
|
if (err instanceof CompileError) {
|
|
782
1642
|
console.error(`rigc compile error: ${err.message}`);
|
|
783
1643
|
process.exit(1);
|