spine-rigc 0.4.0 → 0.6.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/cli.ts CHANGED
@@ -31,13 +31,35 @@
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 { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
+ import { basename, dirname, join, resolve } from 'node:path';
36
36
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
37
37
  import { compile, CompileError, type CompileOptions } from './src/compile.ts';
38
38
  import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
39
+ import { copyAtlasImages } from './src/emit.ts';
40
+ import { parseJsonWithPosition } from './src/json-position.ts';
39
41
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
40
- import { DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
42
+ import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
43
+ import {
44
+ atlasPageNames,
45
+ BACKGROUND,
46
+ contactSheet,
47
+ FRAMES_SIDECAR,
48
+ FRAMES_SPEC,
49
+ framingViewport,
50
+ loadPosable,
51
+ PROTOCOL_FPS,
52
+ renderFrame,
53
+ sampleAll,
54
+ sampleAnimation,
55
+ SETUP_POSE_DIR,
56
+ SHEET_FILE,
57
+ SHEET_TILE,
58
+ type Frame,
59
+ type FramesSidecar,
60
+ type FrameSet,
61
+ } from './src/render.ts';
62
+ import { CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
41
63
  import type { CompileResult, MotionSpec } from './src/types.ts';
42
64
 
43
65
  /**
@@ -61,6 +83,40 @@ export type CutTable = Record<string, CutEntry>;
61
83
 
62
84
  class UsageError extends Error {}
63
85
 
86
+ // ---------------------------------------------------------------------------
87
+ // package metadata — the installed version and repository, for `--version`
88
+ // and for naming a remedy `bench` can only give from a repo checkout.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ interface PackageMeta {
92
+ version?: string;
93
+ repository?: string | { url?: string };
94
+ }
95
+
96
+ let packageMeta: PackageMeta | null | undefined;
97
+
98
+ /** `package.json` sits next to this file both in the repo and once installed. */
99
+ function readPackageMeta(): PackageMeta | null {
100
+ if (packageMeta === undefined) {
101
+ try {
102
+ packageMeta = JSON.parse(readFileSync(join(import.meta.dir, 'package.json'), 'utf8')) as PackageMeta;
103
+ } catch {
104
+ packageMeta = null;
105
+ }
106
+ }
107
+ return packageMeta;
108
+ }
109
+
110
+ function readVersion(): string {
111
+ return readPackageMeta()?.version ?? 'unknown';
112
+ }
113
+
114
+ function repositoryUrl(): string {
115
+ const repo = readPackageMeta()?.repository;
116
+ const url = typeof repo === 'string' ? repo : repo?.url;
117
+ return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
118
+ }
119
+
64
120
  // ---------------------------------------------------------------------------
65
121
  // argument parsing
66
122
  // ---------------------------------------------------------------------------
@@ -72,7 +128,7 @@ class UsageError extends Error {}
72
128
  * flag": inferring it would turn `--out --json report.json` — a real typo, a
73
129
  * missing value — into a silently accepted switch plus a stray positional.
74
130
  */
75
- const BOOLEAN_FLAGS = new Set(['all-frames']);
131
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images']);
76
132
 
77
133
  /** `--flag value` pairs plus the leftover positionals, in order. */
78
134
  function parseArgs(argv: string[]): { flags: Record<string, string>; positional: string[] } {
@@ -99,6 +155,21 @@ function parseArgs(argv: string[]): { flags: Record<string, string>; positional:
99
155
  return { flags, positional };
100
156
  }
101
157
 
158
+ /**
159
+ * Read and parse a JSON file the caller named on the command line — a cuts
160
+ * table, a candidate or reference skeleton to `diff`. A parse failure names the
161
+ * file and, best-effort, where inside it the syntax broke (see
162
+ * `parseJsonWithPosition`); left as a raw `JSON.parse`, it would surface as an
163
+ * unhandled `SyntaxError` with a stack trace instead of a usage error.
164
+ */
165
+ function readJsonFile(path: string): unknown {
166
+ try {
167
+ return parseJsonWithPosition(readFileSync(path, 'utf8'));
168
+ } catch (err) {
169
+ throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
170
+ }
171
+ }
172
+
102
173
  /**
103
174
  * Read a cuts.json and resolve its three paths against the file's own
104
175
  * directory. Anchoring on the table rather than on the process cwd is what lets
@@ -107,7 +178,7 @@ function parseArgs(argv: string[]): { flags: Record<string, string>; positional:
107
178
  function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
108
179
  const abs = resolve(cutsPath);
109
180
  if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
110
- const parsed: unknown = JSON.parse(readFileSync(abs, 'utf8'));
181
+ const parsed = readJsonFile(abs);
111
182
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
112
183
  throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
113
184
  }
@@ -168,14 +239,17 @@ function resolveCut(flags: Record<string, string>): { label: string; opts: Compi
168
239
  // ---------------------------------------------------------------------------
169
240
 
170
241
  /**
171
- * Read `--profile`, defaulting to the profile every caller had before the flag
172
- * existed. An unknown name is a usage error rather than a silent fallback: the
173
- * fallback would be `spine-html`, so a typo would quietly re-apply the strictest
174
- * rulebook to data the caller was trying to exempt.
242
+ * Read `--profile`, defaulting to `spine` — see `CLI_DEFAULT_PROFILE`.
243
+ *
244
+ * An unknown name is a usage error rather than a silent fallback, and that
245
+ * matters in both directions: a typo used to re-apply the strictest rulebook to
246
+ * data the caller was trying to exempt, and it would now drop the policy layer
247
+ * from a caller who typed `--profile spine-htlm` and believes they asked for it.
248
+ * Neither is something to discover from a green.
175
249
  */
176
250
  function readProfile(flags: Record<string, string>): ValidateProfile {
177
251
  const raw = flags.profile;
178
- if (raw === undefined) return DEFAULT_PROFILE;
252
+ if (raw === undefined) return CLI_DEFAULT_PROFILE;
179
253
  const found = VALIDATE_PROFILES.find((p) => p === raw);
180
254
  if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
181
255
  return found;
@@ -206,6 +280,12 @@ function cmdBuild(flags: Record<string, string>): void {
206
280
  const { label, opts } = resolveCut(flags);
207
281
  const profile = readProfile(flags);
208
282
  console.log(`rigc build ${label}`);
283
+ // Named explicitly and on their own lines rather than folded into the header
284
+ // above: with two input files, a header that names only one of them (the rig,
285
+ // historically) reads as though it were the one at fault whenever the error
286
+ // that follows actually comes from the other.
287
+ console.log(` .. rig ${opts.rigPath}`);
288
+ console.log(` .. motion ${opts.motionPath}`);
209
289
  const result = compile(opts);
210
290
 
211
291
  console.log(` .. ${result.images.length} part page(s):`);
@@ -241,8 +321,25 @@ function cmdBuild(flags: Record<string, string>): void {
241
321
  }
242
322
 
243
323
  mkdirSync(opts.outDir, { recursive: true });
324
+
325
+ // `--copy-images`: `--out` is otherwise NOT self-contained — a page's default
326
+ // path is relative to the source art (often `../parts/foo.png`), which is
327
+ // correct for a build sitting beside the project it came from and breaks the
328
+ // moment the directory is zipped, committed or moved on its own (issue #217).
329
+ // Opt-in only: the default stays exactly what it has always been.
330
+ let atlasText = result.atlasText;
331
+ if (flags['copy-images'] !== undefined) {
332
+ const copied = copyAtlasImages(result.images, opts.outDir);
333
+ atlasText = copied.atlasText;
334
+ console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
335
+ for (const p of copied.pages) {
336
+ const note = p.to === basename(p.from) ? '' : ` (renamed from ${basename(p.from)} — basename collision)`;
337
+ console.log(` .. ${p.region.padEnd(24)} <- ${p.to}${note}`);
338
+ }
339
+ }
340
+
244
341
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
245
- writeFileSync(join(opts.outDir, 'skeleton.atlas'), result.atlasText);
342
+ writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
246
343
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
247
344
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
248
345
  }
@@ -325,10 +422,7 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
325
422
  for (const path of [candidatePath, referencePath]) {
326
423
  if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
327
424
  }
328
- const report = diffSkeletons(
329
- JSON.parse(readFileSync(candidatePath, 'utf8')),
330
- JSON.parse(readFileSync(referencePath, 'utf8')),
331
- );
425
+ const report = diffSkeletons(readJsonFile(candidatePath), readJsonFile(referencePath));
332
426
  console.log('rigc diff');
333
427
  for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
334
428
  if (flags.json !== undefined) {
@@ -411,14 +505,235 @@ function writeJson(target: string, body: unknown): void {
411
505
  console.log(`rigc: wrote ${out}`);
412
506
  }
413
507
 
508
+ // ---------------------------------------------------------------------------
509
+ // seeing the result — render and preview
510
+ // ---------------------------------------------------------------------------
511
+ //
512
+ // ⭐ Why two commands exist for one question. `validate` says the artifact is
513
+ // valid, `check` says how close it is to reference frames — and a first user has
514
+ // neither a reference nor any way to look at what they built. A rig whose head
515
+ // sits visibly off its torso passes the gate, loads in `spine-core` and steps
516
+ // cleanly, because the offsets are the ones the spec asked for. The only remedy
517
+ // is looking (issue #216).
518
+ //
519
+ // `render` looks with OUR rasteriser: PNGs on disk, no browser, no network, and
520
+ // the same frame geometry `check` compares against — so its output is a frame set
521
+ // like any other, sidecar included. `preview` looks with ESOTERIC'S, in one HTML
522
+ // file, which is the stronger statement of the two: a rig that plays there has
523
+ // been played by the reference implementation rather than by ours (issue #151).
524
+ //
525
+ // Both take a COMPILED artifact rather than a rig and motion spec. That is what
526
+ // `check`, `bench` and `validate` all take, it is what `build --out` leaves
527
+ // behind, and it keeps `--out` meaning one thing per command instead of naming
528
+ // the build directory on the way in and the pictures on the way out.
529
+
530
+ /** Both commands' shared front door: which artifact, and what is in it. */
531
+ function resolveViewable(flags: Record<string, string>): {
532
+ skeletonPath: string;
533
+ atlasPath: string;
534
+ atlasDir: string;
535
+ } {
536
+ if (flags.candidate === undefined) {
537
+ throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
538
+ }
539
+ const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
540
+ for (const path of [skeletonPath, atlasPath]) {
541
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
542
+ }
543
+ return { skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
544
+ }
545
+
546
+ /** `--animation`, checked against what the skeleton actually carries. */
547
+ function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
548
+ const name = flags.animation;
549
+ if (name === undefined) return undefined;
550
+ if (!available.includes(name)) {
551
+ throw new UsageError(
552
+ `no animation ${JSON.stringify(name)} in this skeleton; it has [${available.join(', ') || 'none'}]`,
553
+ );
554
+ }
555
+ return name;
556
+ }
557
+
558
+ function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
559
+ const raw = flags[key];
560
+ if (raw === undefined) return fallback;
561
+ const value = Number(raw);
562
+ if (!Number.isFinite(value) || value < least) throw new UsageError(`--${key} must be a number of at least ${least}`);
563
+ return value;
564
+ }
565
+
566
+ /**
567
+ * render — the frame series, drawn by the same rasteriser `check` measures with.
568
+ *
569
+ * The framing is measured across EVERY animation at `FRAMING_FPS` and not across
570
+ * the one being written, which is `src/render.ts`'s own invariant: the viewport is
571
+ * a property of the shot, so two animations of one rig — and the same animation at
572
+ * two rates — land on one pixel grid and stay comparable.
573
+ */
574
+ function cmdRender(flags: Record<string, string>): void {
575
+ const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
576
+ const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
577
+ const maxSide = readPositiveNumber(flags, 'max', 256, 16);
578
+ const outRoot = resolve(flags.out ?? 'render');
579
+
580
+ console.log('rigc render');
581
+ console.log(` .. skeleton ${skeletonPath}`);
582
+ console.log(` .. atlas ${atlasPath}`);
583
+ const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
584
+ const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
585
+
586
+ const viewport = framingViewport(data, maxSide);
587
+ if (!viewport) {
588
+ throw new UsageError(
589
+ `${skeletonPath} posed no drawable attachment in any animation or in its setup pose — there is nothing to draw`,
590
+ );
591
+ }
592
+
593
+ // `sampleAll` covers the skeleton with no animation at all, which files its one
594
+ // setup-pose frame under the reserved name. Narrowing to one animation reuses
595
+ // the same sampler rather than a second path through it.
596
+ const sampled: Map<string, Frame[]> =
597
+ only === undefined ? sampleAll(data, fps) : new Map([[only, sampleAnimation(data, only, fps)]]);
598
+ console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
599
+
600
+ mkdirSync(outRoot, { recursive: true });
601
+ const sets: FrameSet[] = [];
602
+ for (const [name, frames] of sampled) {
603
+ // Same naming as a reference render: the protocol rate says nothing, any
604
+ // other rate says itself, so two rates of one animation sit side by side.
605
+ const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
606
+ const dir = join(outRoot, dirName);
607
+ // Cleared rather than written over: a shorter animation would otherwise leave
608
+ // the tail of a longer previous run on disk, and stale frames in a frame set
609
+ // are indistinguishable from real ones.
610
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
611
+ mkdirSync(dir, { recursive: true });
612
+ for (let i = 0; i < frames.length; i++) {
613
+ renderFrame(frames[i], pages, viewport, BACKGROUND).writePng(join(dir, `f${String(i).padStart(4, '0')}.png`));
614
+ }
615
+ // One frame has nothing to compare itself against, so it gets no sheet — it
616
+ // would be the same picture with a border and a "0" on it.
617
+ const sheet = frames.length > 1;
618
+ if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
619
+ const duration = frames[frames.length - 1].time;
620
+ sets.push({
621
+ dir: dirName,
622
+ animation: name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name,
623
+ fps,
624
+ sampled: frames.length,
625
+ written: frames.length,
626
+ stride: 1,
627
+ duration,
628
+ });
629
+ const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
630
+ console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${sheet ? ` + ${SHEET_FILE}` : ''} -> ${dir}`);
631
+ }
632
+
633
+ // The sidecar is what makes this a frame SET rather than a pile of pictures:
634
+ // the world box every frame is a picture of, so a distance measured in pixels
635
+ // converts back to the units the rig is authored in — and so `rigc check` can
636
+ // render something else into the same grid later.
637
+ const sidecar: FramesSidecar = {
638
+ spec: FRAMES_SPEC,
639
+ background: BACKGROUND,
640
+ viewport: {
641
+ x: viewport.minX,
642
+ y: viewport.minY,
643
+ width: viewport.maxX - viewport.minX,
644
+ height: viewport.maxY - viewport.minY,
645
+ scale: viewport.scale,
646
+ pixelWidth: viewport.width,
647
+ pixelHeight: viewport.height,
648
+ },
649
+ sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
650
+ };
651
+ writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
652
+ console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
653
+ }
654
+
655
+ /** The animation names an emitted skeleton carries, in the order it lists them. */
656
+ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
657
+ let parsed: unknown;
658
+ try {
659
+ parsed = parseJsonWithPosition(skeletonText);
660
+ } catch (err) {
661
+ throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
662
+ }
663
+ if (typeof parsed !== 'object' || parsed === null) throw new UsageError(`${path} is not a skeleton object`);
664
+ const animations = (parsed as { animations?: unknown }).animations;
665
+ if (animations === undefined) return [];
666
+ if (typeof animations !== 'object' || animations === null || Array.isArray(animations)) {
667
+ throw new UsageError(`${path} has an "animations" field that is not an object`);
668
+ }
669
+ return Object.keys(animations);
670
+ }
671
+
672
+ /**
673
+ * preview — the artifact playing in Esoteric's own web player, as one file.
674
+ *
675
+ * ⚠️ Nothing is rasterised here and nothing is decoded. The pages go into the
676
+ * page as the bytes they are on disk, so a preview works for any PNG a BROWSER
677
+ * can draw rather than for the ones our own decoder reads — which is the right
678
+ * direction for the command whose whole job is "just show me".
679
+ */
680
+ function cmdPreview(flags: Record<string, string>): void {
681
+ const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
682
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
683
+ const atlasText = readFileSync(atlasPath, 'utf8');
684
+ const animations = skeletonAnimationNames(skeletonText, skeletonPath);
685
+ const chosen = readAnimationFlag(flags, animations);
686
+
687
+ // A directory for --out is taken as "put the default name in here", because
688
+ // `--out render/` is what the sibling command means by the same flag and a
689
+ // preview written OVER a directory is not a recoverable mistake.
690
+ const target = resolve(flags.out ?? 'preview.html');
691
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
692
+
693
+ console.log('rigc preview');
694
+ console.log(` .. skeleton ${skeletonPath}`);
695
+ console.log(` .. atlas ${atlasPath}`);
696
+
697
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
698
+ const path = join(atlasDir, name);
699
+ if (!existsSync(path)) {
700
+ throw new UsageError(
701
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
702
+ 'a page a preview cannot embed is a page the player could not have loaded either',
703
+ );
704
+ }
705
+ return { name, bytes: readFileSync(path) };
706
+ });
707
+ for (const page of pages) {
708
+ console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
709
+ }
710
+
711
+ const html = buildPreview({
712
+ skeletonText,
713
+ atlasText,
714
+ pages,
715
+ animation: chosen ?? animations[0] ?? null,
716
+ animations,
717
+ label: skeletonPath,
718
+ version: readVersion(),
719
+ });
720
+ mkdirSync(dirname(out), { recursive: true });
721
+ writeFileSync(out, html);
722
+ console.log(
723
+ ` .. embedded ${pages.length} page(s) + the skeleton and atlas as data URIs; ` +
724
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
725
+ );
726
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
727
+ }
728
+
414
729
  /**
415
730
  * bench — run one rung of the benchmark ladder against a candidate rig.
416
731
  *
417
732
  * Two questions, asked in this order and never merged:
418
733
  *
419
734
  * 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
420
- * and it is the only part with a pass/fail. `spine-html` is not the default
421
- * here (it is everywhere else): the thing being reproduced is an editor
735
+ * and it is the only part with a pass/fail. The profile is pinned here, not
736
+ * inherited from the CLI default: the thing being reproduced is an editor
422
737
  * export, and holding it to this project's renderer policy would fail rungs
423
738
  * for reasons the rung is not about.
424
739
  * 2. How close is it, structurally, to the reference? That is `diff`, and it
@@ -438,13 +753,22 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
438
753
  if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
439
754
  if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
440
755
 
441
- // bench judges a reproduction of editor output, so `spine` is the default.
442
- const profile = flags.profile === undefined ? 'spine' : readProfile(flags);
756
+ // bench judges a reproduction of editor output, so `spine` is PINNED here
757
+ // rather than inherited. It reads the same as the CLI default today (#221) and
758
+ // is kept as its own statement anyway: the ladder's stage-1 gate is defined by
759
+ // `docs/GATE.md` as `validate --profile spine`, and a bench run must go on
760
+ // meaning that whatever a later release decides the default should be.
761
+ const profile: ValidateProfile = flags.profile === undefined ? 'spine' : readProfile(flags);
443
762
  const exportDir = resolve(import.meta.dir, 'examples', rung.example, 'export');
444
763
  if (!existsSync(exportDir)) {
445
- throw new UsageError(
446
- `no example corpus at ${exportDir} — run \`bun run fetch-examples\` first (examples/ is gitignored, not shipped)`,
447
- );
764
+ // `bun run fetch-examples` runs `scripts/fetch-examples.sh`, and `scripts/`
765
+ // is not in package.json's `files` — an npm install has no such script to
766
+ // run. Its presence is what tells the two contexts apart, so the remedy
767
+ // named here is one that actually exists in whichever context this is.
768
+ const remedy = existsSync(resolve(import.meta.dir, 'scripts', 'fetch-examples.sh'))
769
+ ? 'run `bun run fetch-examples` first'
770
+ : `bench needs a checkout of ${repositoryUrl()} — its \`fetch-examples\` script is not part of the installed package`;
771
+ throw new UsageError(`no example corpus at ${exportDir} — ${remedy} (examples/ is gitignored, not shipped)`);
448
772
  }
449
773
 
450
774
  const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
@@ -615,10 +939,14 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
615
939
 
616
940
  function cmdExplain(flags: Record<string, string>): void {
617
941
  const { label, opts } = resolveCut(flags);
942
+ console.log(`rigc explain ${label}`);
943
+ // See the identical pair of lines in `cmdBuild` for why both paths are named
944
+ // here rather than only the one the header's `label` happens to carry.
945
+ console.log(` .. rig ${opts.rigPath}`);
946
+ console.log(` .. motion ${opts.motionPath}`);
618
947
  const result = compile(opts);
619
- const motion = JSON.parse(readFileSync(opts.motionPath, 'utf8')) as MotionSpec;
948
+ const motion = readJsonFile(opts.motionPath) as MotionSpec;
620
949
 
621
- console.log(`rigc explain ${label}`);
622
950
  console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
623
951
 
624
952
  // The crop note describes where the numbers CAME from, and without a manifest
@@ -725,35 +1053,198 @@ function cmdExplain(flags: Record<string, string>): void {
725
1053
  console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
726
1054
  }
727
1055
 
1056
+ // ---------------------------------------------------------------------------
1057
+ // usage / per-command help
1058
+ // ---------------------------------------------------------------------------
1059
+
1060
+ /**
1061
+ * One meaning per flag name, shared by every command that takes it — the
1062
+ * single place this project states what a flag means. AUTHORING.md §0 quotes
1063
+ * this table for `build`'s `--rig`/`--motion`/`--out`/`--images`/`--manifest`/
1064
+ * `--profile`; if the two ever disagree, this is the one the code runs.
1065
+ */
1066
+ const FLAG_MEANINGS: Record<string, string> = {
1067
+ rig: 'the rig spec — skeleton structure',
1068
+ motion: 'the motion spec — time',
1069
+ out: 'directory for skeleton.json + skeleton.atlas; atlas page paths are written relative to it',
1070
+ images: "override the rig spec's own images directory (relative to your working directory)",
1071
+ manifest: 'a cut manifest, for a rig with measured art behind it; a foreign skeleton has none',
1072
+ 'copy-images':
1073
+ 'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
1074
+ 'self-contained enough to zip or commit on its own (default: page paths still point at the source art)',
1075
+ cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
1076
+ cuts: 'the cuts.json --cut names',
1077
+ profile:
1078
+ 'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
1079
+ "correctly; spine-html = also this project's renderer/archetype policy",
1080
+ atlas: "the candidate's atlas, when it is not beside the skeleton",
1081
+ candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
1082
+ frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
1083
+ fps: 'frame rate, only for a frame set with no frames.json sidecar',
1084
+ viewport: "pin the candidate's world box, y up, instead of fitting it",
1085
+ framing: 'fit each frame set on its own (default) or once across all of them',
1086
+ as: 'the candidate animation to play, when it is named differently from the frame set',
1087
+ 'all-frames': 'print every frame, not just the worst by MAE',
1088
+ json: 'also write the whole report to this path',
1089
+ animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1090
+ max: 'longest side of a rendered frame, in pixels (default 256)',
1091
+ help: "show this command's flags and exit",
1092
+ };
1093
+
1094
+ /** The `<value>` a flag takes, for its column in a command's flag table. Absent for a boolean switch. */
1095
+ const FLAG_VALUES: Record<string, string> = {
1096
+ rig: '<path>',
1097
+ motion: '<path>',
1098
+ out: '<dir>',
1099
+ images: '<dir>',
1100
+ manifest: '<path>',
1101
+ cut: '<name>',
1102
+ cuts: '<path>',
1103
+ profile: 'spine|spine-html',
1104
+ atlas: '<path>',
1105
+ candidate: '<dir|skeleton.json>',
1106
+ frames: '<dir>',
1107
+ fps: '<n>',
1108
+ viewport: '<x,y,w,h>',
1109
+ framing: 'per-shot|shared',
1110
+ as: '<name>',
1111
+ json: '<out>',
1112
+ animation: '<name>',
1113
+ max: '<px>',
1114
+ };
1115
+
1116
+ interface CommandDoc {
1117
+ name: string;
1118
+ /** One or more invocation forms, each already spelling the command name. */
1119
+ usage: string[];
1120
+ /** Flag names (into FLAG_MEANINGS/FLAG_VALUES), in display order. `--help` is appended automatically. */
1121
+ flags: string[];
1122
+ /**
1123
+ * Per-command wording for a flag whose value or meaning genuinely differs here.
1124
+ *
1125
+ * ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
1126
+ * and this is the named exception to it, not a second table. Two flags earn it:
1127
+ * `--out` is a directory of artifacts to `build`, a directory of pictures to
1128
+ * `render` and one file to `preview`; `--fps` is the rate a frame set was
1129
+ * RECORDED at to `check`, which reads it off a sidecar, and the rate to SAMPLE
1130
+ * at to `render`, which is choosing it. Writing either as one sentence covering
1131
+ * every command would leave every command's own help less true than it is now.
1132
+ */
1133
+ overrides?: Record<string, { value?: string; meaning?: string }>;
1134
+ }
1135
+
1136
+ const COMMANDS: CommandDoc[] = [
1137
+ {
1138
+ name: 'build',
1139
+ usage: [
1140
+ 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
1141
+ 'rigc build --cut <name> --cuts <cuts.json>',
1142
+ ],
1143
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'copy-images', 'cut', 'cuts', 'profile'],
1144
+ },
1145
+ {
1146
+ name: 'explain',
1147
+ usage: ['rigc explain (same arguments as build, minus --profile — it never gates)'],
1148
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'cut', 'cuts'],
1149
+ },
1150
+ {
1151
+ name: 'validate',
1152
+ usage: [
1153
+ 'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
1154
+ 'rigc validate --cut <name> --cuts <cuts.json> (also re-derives declared durations)',
1155
+ ],
1156
+ flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
1157
+ },
1158
+ {
1159
+ name: 'diff',
1160
+ usage: ['rigc diff <candidate.json> <reference.json> [--json <out>]'],
1161
+ flags: ['json'],
1162
+ },
1163
+ {
1164
+ name: 'check',
1165
+ usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
1166
+ flags: ['candidate', 'frames', 'atlas', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
1167
+ },
1168
+ {
1169
+ name: 'bench',
1170
+ usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
1171
+ flags: ['candidate', 'atlas', 'frames', 'profile', 'all-frames', 'json'],
1172
+ },
1173
+ {
1174
+ name: 'render',
1175
+ usage: [
1176
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--fps 12] [--max 256] [--out render/]',
1177
+ ],
1178
+ flags: ['candidate', 'atlas', 'animation', 'fps', 'max', 'out'],
1179
+ overrides: {
1180
+ out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1181
+ fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
1182
+ },
1183
+ },
1184
+ {
1185
+ name: 'preview',
1186
+ usage: ['rigc preview --candidate <dir | skeleton.json> [--animation <name>] [--out preview.html]'],
1187
+ flags: ['candidate', 'atlas', 'animation', 'out'],
1188
+ overrides: {
1189
+ out: {
1190
+ value: '<file>',
1191
+ meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
1192
+ },
1193
+ },
1194
+ },
1195
+ ];
1196
+
1197
+ const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
1198
+
1199
+ /** `rigc <command> --help`: that command's own usage line(s) and flag table. */
1200
+ function commandHelp(name: string): string {
1201
+ const doc = COMMANDS.find((c) => c.name === name);
1202
+ if (!doc) throw new Error(`internal: no help text for command "${name}"`);
1203
+ const keys = [...doc.flags, 'help'];
1204
+ const value = (key: string): string | undefined => doc.overrides?.[key]?.value ?? FLAG_VALUES[key];
1205
+ const meaning = (key: string): string => doc.overrides?.[key]?.meaning ?? FLAG_MEANINGS[key];
1206
+ const labels = keys.map((key) => `--${key}${value(key) ? ` ${value(key)}` : ''}`);
1207
+ const width = Math.max(...labels.map((l) => l.length)) + 2;
1208
+ return ['usage:', ...doc.usage.map((u) => ` ${u}`), '', 'flags:', ...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`)].join(
1209
+ '\n',
1210
+ );
1211
+ }
1212
+
728
1213
  const USAGE = [
1214
+ 'rigc — the rig compiler',
1215
+ '',
1216
+ '(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)',
1217
+ '',
729
1218
  'usage:',
730
- ' bun cli.ts build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>]',
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>]`,
1219
+ ...COMMANDS.flatMap((c) => c.usage.map((u) => ` ${u}`)),
737
1220
  '',
738
- 'build and validate take --profile spine|spine-html (default spine-html):',
1221
+ ' rigc <command> --help that command\'s own flag table',
1222
+ ' rigc --version print the installed version (-v works too)',
1223
+ '',
1224
+ 'build, validate and bench take --profile spine|spine-html:',
739
1225
  ' spine is this valid Spine 4.3 that any runtime plays correctly?',
740
- ' spine-html the above, plus this project\'s renderer and archetype policy.',
1226
+ ' THE DEFAULT — 20 rules, and the question the output answers when',
1227
+ ' you import it into the Spine editor.',
1228
+ ' spine-html the above, plus this project\'s renderer and archetype policy:',
1229
+ ' all 34 rules, opt-in. Those extra 14 fire on real, correct,',
1230
+ ' editor-produced Spine data, so they are somebody\'s policy rather',
1231
+ ' than anybody\'s validity.',
1232
+ '',
1233
+ 'Every report names the profile that judged it and lists, on PROF lines, the',
1234
+ 'rules that profile left out.',
741
1235
  '',
742
1236
  'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
743
1237
  'there by its own drawn pixels, and compares. It reads the frames and never the',
744
1238
  'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
745
- 'see a wrong animation and this can:',
746
- ' --frames <dir> a rendered frame set (a skeleton root, or one animation dir)',
747
- ' --atlas <path> the candidate\'s atlas, when it is not beside the skeleton',
748
- ' --fps <n> only for a frame set with no frames.json sidecar',
749
- ' --viewport x,y,w,h pin the candidate\'s world box, y up, instead of fitting it',
750
- ' --framing per-shot|shared decide the framing per frame set (default), or once',
751
- ' across all of them. Per set, a set whose own pixels land in',
752
- ' frames.json\'s box is measured there; on a multi-shot root that',
753
- ' is worth 15-25 MAE against one shared fit for every set',
754
- ' --as <name> the candidate animation to play, when it is named differently',
755
- ' --all-frames print every frame, not just the worst by MAE',
756
- ' --json <out> the whole per-frame, per-slot report',
1239
+ 'see a wrong animation and this can. See `rigc check --help` for its flags.',
1240
+ '',
1241
+ 'render and preview are how you LOOK at a build, and they need no reference at all:',
1242
+ ' rigc render --candidate <the dir build --out wrote> PNG frames + a contact sheet',
1243
+ ' rigc preview --candidate <the same dir> one .html file that plays it',
1244
+ 'A rig with its head off its torso passes the gate and steps cleanly — the offsets',
1245
+ 'are the ones you asked for — so looking is the only thing that catches it. render',
1246
+ 'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
1247
+ 'it in the official Spine Web Player, which is also the interop proof.',
757
1248
  '',
758
1249
  'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
759
1250
  ' "manifest": "..." (optional) } }, with every path',
@@ -762,17 +1253,35 @@ const USAGE = [
762
1253
 
763
1254
  const [command, ...rest] = process.argv.slice(2);
764
1255
  try {
1256
+ if (command === undefined) {
1257
+ console.error(USAGE);
1258
+ process.exit(2);
1259
+ }
1260
+ if (command === '--version' || command === '-v') {
1261
+ console.log(readVersion());
1262
+ process.exit(0);
1263
+ }
1264
+ if (command === '--help' || command === '-h') {
1265
+ console.log(USAGE);
1266
+ process.exit(0);
1267
+ }
1268
+ if (!KNOWN_COMMANDS.includes(command)) {
1269
+ throw new UsageError(`unknown command: ${command}`);
1270
+ }
1271
+
765
1272
  const { flags, positional } = parseArgs(rest);
1273
+ if (flags.help !== undefined) {
1274
+ console.log(commandHelp(command));
1275
+ process.exit(0);
1276
+ }
766
1277
  if (command === 'build') cmdBuild(flags);
767
1278
  else if (command === 'validate') cmdValidate(flags, positional);
768
1279
  else if (command === 'explain') cmdExplain(flags);
769
1280
  else if (command === 'diff') cmdDiff(flags, positional);
770
1281
  else if (command === 'check') cmdCheck(flags);
771
1282
  else if (command === 'bench') cmdBench(flags, positional);
772
- else {
773
- console.error(USAGE);
774
- process.exit(2);
775
- }
1283
+ else if (command === 'render') cmdRender(flags);
1284
+ else if (command === 'preview') cmdPreview(flags);
776
1285
  } catch (err) {
777
1286
  if (err instanceof UsageError) {
778
1287
  console.error(`rigc: ${err.message}\n\n${USAGE}`);