spine-rigc 0.22.2 โ†’ 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -352,6 +352,16 @@ directories makes the result a frame set like any other โ€” the world box every
352
352
  frame is a picture of. `--animation <name>` narrows it to one, `--fps` and
353
353
  `--max` change the rate and the frame size.
354
354
 
355
+ > ๐Ÿšจ **`--skin <name>` if your rig has more than one.** With no `--skin` no skin
356
+ > is set at all, so every slot resolves through the **default** skin alone โ€” and
357
+ > a slot whose art lives only in a named skin draws *nothing*. That is not a
358
+ > quirk of `render`: `check` compares `render`'s frames, so a multi-skin rig
359
+ > checked with no skin compares blank against blank and reports a perfect
360
+ > `0.0000` about art nobody drew. `render --skin` records the name in
361
+ > `frames.json`, `check --skin` poses the candidate under it, and a candidate
362
+ > scored against frames rendered under a *different* skin is refused by name
363
+ > rather than measured.
364
+
355
365
  **`preview`** writes a single self-contained `.html`: your skeleton, your atlas
356
366
  and every page's PNG bytes are embedded in it as data URIs, and it plays them in
357
367
  the **official [Spine Web Player](https://esotericsoftware.com/spine-player)**.
@@ -493,6 +503,7 @@ commands take it and what its default is.
493
503
  | `build โ€ฆ --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` โ€” losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
494
504
  | `build โ€ฆ --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
495
505
  | `validate <dir>` | re-gates artifacts already on disk |
506
+ | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. `--stage x,y,w,h` supplies the one value a skeleton does not hold, and `--images <dir>` writes the spec's own images directory โ€” the opposite direction from `build --images`, which overrides it โ€” so the rebuild carries no flag at all |
496
507
  | `explain --rig โ€ฆ --motion โ€ฆ` | the compiled rig as a table โ€” every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
497
508
  | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
498
509
  | `preview --candidate <dir>` | one self-contained `.html` that plays it |
@@ -521,6 +532,71 @@ relative to the `cuts.json` file itself, so the table lives with the project tha
521
532
  the art. Its shape is under
522
533
  [Usage](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md#usage).
523
534
 
535
+ ### Starting from a skeleton you already have
536
+
537
+ `rigc ingest` reads a Spine 4.3 `skeleton.json` and writes the two spec files that
538
+ rebuild it. It is the only command that runs against `build`'s direction, and the
539
+ only one whose contract is an equality rather than a rulebook:
540
+
541
+ ```bash
542
+ rigc ingest hero.json --out specs/ --stage 0,0,1024,768 --images parts/
543
+ rigc build --rig specs/rig.json --motion specs/motion.json --out build/
544
+ rigc diff build/skeleton.json hero.json
545
+ ```
546
+
547
+ `--images parts/` is what makes the second line carry no flag: it writes the spec's
548
+ own `images` directory, spelled from `--out`, so the specs are self-contained from
549
+ there on. Leave it off and the `image` names resolve against `specs/` itself, which
550
+ holds no art โ€” every rebuild then has to repeat `build --images parts/`.
551
+
552
+ **`build(ingest(x))` is `x`.** Over the eleven rigs this repository builds โ€” the seven
553
+ gallery examples, the three generated probes and a coverage probe written for the
554
+ purpose โ€” the rebuilt `skeleton.json` is byte for byte the file the decompiler read,
555
+ and `bun run selftest` holds it there on every run. The atlas is held to a weaker
556
+ claim on purpose, and the weakening is measured rather than assumed: it comes back
557
+ equal as a **multiset of region blocks**, because the order the pages are collected in
558
+ is in no field of the skeleton.
559
+
560
+ **And over twelve skeletons nobody here wrote.** The same run ingests every editor
561
+ export in the fetched example corpus, rebuilds it through the pack beside it and
562
+ `diff`s the result against the source: **12 of 12, no blockers, 1.000 on every measure
563
+ the report carries.** Byte identity is not the claim there and the reason is the input
564
+ rather than the round trip โ€” an editor header carries `hash` and `audio`, which the rig
565
+ spec has no field for โ€” so the contract is the structural one `diff` measures.
566
+ [INGEST.md ยง2.3](docs/INGEST.md) has the three kinds of difference that remain, with
567
+ what each is worth.
568
+
569
+ **What it reads is skeleton JSON and nothing else** โ€” no `.spine` project, no binary
570
+ `.skel`, no atlas, no art. So it never invents, and the things it cannot get out of
571
+ the file are **findings** with codes rather than plausible values: a construct the
572
+ spec format cannot hold (`linkedmesh`, `point`, a `sequence` block, an unknown field
573
+ on a bone, slot or constraint) is a blocker, the command exits non-zero, and both
574
+ specs are still written โ€” a spec plus a list of what is missing from it beats no spec.
575
+ One thing it drops on purpose and says so: a path attachment's `lengths`, which is
576
+ `PathConstraint`'s own measurement and which rigc re-measures.
577
+
578
+ โš ๏ธ **Two values are not in a skeleton at all.**
579
+
580
+ - **The stage.** `skeleton.width`/`height`: rigc always writes one, and a skeleton that
581
+ carries none is refused by name unless `--stage x,y,w,h` supplies it. It is not
582
+ derivable โ€” posing the rig gives the *animated* extent, which is a different number
583
+ from the setup box. โš ๏ธ This said *"an editor export carries none"* until #594 measured
584
+ it: **all twelve exports in the example corpus carry a stage** and none of them needs
585
+ the flag. It is still the value that costs least to get wrong, because `diff` reports
586
+ the box and gates nothing on it.
587
+ - **An animation's duration.** The format has no such field. The largest key time is
588
+ the only derivable answer and it is what a runtime plays to; it is wrong for an
589
+ animation that holds its last pose past its last key, so it is recorded as a finding
590
+ on every animation rather than chosen quietly.
591
+
592
+ Both specs carry a `note` that `ingest` writes itself, saying the file is decompiled
593
+ and naming the skeleton it came from โ€” because a decompiled spec is indistinguishable
594
+ from an authored one by inspection, every gate here calls it green (it *is* green),
595
+ and no gate can catch a missing note.
596
+
597
+ [docs/INGEST.md](docs/INGEST.md) is the whole page on working from a file you were
598
+ handed; [docs/AUTHORING.md](docs/AUTHORING.md) ยง0.3 is the loop.
599
+
524
600
  ### The editor round trip โ€” for a licence holder, never in CI
525
601
 
526
602
  `tools/editor_roundtrip.ts` drives the loop the output's whole premise rests on:
@@ -534,7 +610,10 @@ bun tools/editor_roundtrip.ts --build build/ --editor /Applications/Spine.app/Co
534
610
 
535
611
  It prints the import and export exit codes, the validator's verdict on the
536
612
  export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
537
- animation, and a field-by-field list of what the editor rewrote. On its first
613
+ animation **for each skin the build declares** โ€” one render-and-check block per
614
+ skin, with a per-skin roll-up under them, because a rig's contested art lives in
615
+ its named skins and a single un-skinned check draws none of it โ€” and a
616
+ field-by-field list of what the editor rewrote. On its first
538
617
  run it found three emitter defects โ€” [#368](https://github.com/firejune/rigc/issues/368),
539
618
  [#369](https://github.com/firejune/rigc/issues/369),
540
619
  [#370](https://github.com/firejune/rigc/issues/370) โ€” and then showed that a
package/cli.ts CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  type BoneDistReport,
58
58
  } from './src/bonedist.ts';
59
59
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
60
- import { compile, CompileError, type CompileOptions } from './src/compile.ts';
60
+ import { compile, CompileError, relativeImagesPath, type CompileOptions } from './src/compile.ts';
61
61
  import {
62
62
  skeletonDataFromText,
63
63
  surveyDeformKeys,
@@ -67,6 +67,7 @@ import {
67
67
  type DeformSpan,
68
68
  } from './src/deformmeasure.ts';
69
69
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
70
+ import { ingest, type IngestFindingKind, type IngestStage } from './src/ingest.ts';
70
71
  import { copyAtlasImages } from './src/emit.ts';
71
72
  import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
72
73
  import { parseJsonWithPosition } from './src/json-position.ts';
@@ -1415,8 +1416,8 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
1415
1416
  */
1416
1417
  function readCheckFlags(
1417
1418
  flags: Record<string, string>,
1418
- ): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom'> {
1419
- const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom'> = {};
1419
+ ): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> {
1420
+ const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> = {};
1420
1421
  if (flags.framing !== undefined) {
1421
1422
  if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
1422
1423
  throw new UsageError('--framing takes per-shot (the default) or shared');
@@ -1437,6 +1438,10 @@ function readCheckFlags(
1437
1438
  out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
1438
1439
  }
1439
1440
  if (flags.as !== undefined) out.as = flags.as;
1441
+ // The name is not checked against the candidate here: `check` owns that
1442
+ // refusal, because it is the side that has the skeleton open and can list the
1443
+ // skins it declares. `render` checks its own for the same reason.
1444
+ if (flags.skin !== undefined) out.skin = flags.skin;
1440
1445
  if (flags['texture-from'] !== undefined) {
1441
1446
  const path = resolve(flags['texture-from']);
1442
1447
  if (!existsSync(path)) {
@@ -1528,6 +1533,29 @@ function readAnimationFlag(flags: Record<string, string>, available: string[]):
1528
1533
  return name;
1529
1534
  }
1530
1535
 
1536
+ /**
1537
+ * `--skin`, checked against what the skeleton actually declares.
1538
+ *
1539
+ * โญ Absent is not `default`: it is "set no skin at all", which is what every
1540
+ * render did before issue #571 and what `spine-core` starts a skeleton in. The
1541
+ * distinction is the whole of the default path's byte-identity โ€” see
1542
+ * `FramesSidecar.skin`.
1543
+ *
1544
+ * The miss is refused by name with the declared names beside it, the way
1545
+ * `--animation` is: a skin name is the one place a typo draws a whole rig's
1546
+ * worth of the wrong art and reports a number about it.
1547
+ */
1548
+ function readSkinFlag(flags: Record<string, string>, declared: string[]): string | undefined {
1549
+ const name = flags.skin;
1550
+ if (name === undefined) return undefined;
1551
+ if (!declared.includes(name)) {
1552
+ throw new UsageError(
1553
+ `no skin ${JSON.stringify(name)} in this skeleton; it declares [${declared.join(', ') || 'none'}]`,
1554
+ );
1555
+ }
1556
+ return name;
1557
+ }
1558
+
1531
1559
  function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
1532
1560
  const raw = flags[key];
1533
1561
  if (raw === undefined) return fallback;
@@ -1555,11 +1583,20 @@ function cmdRender(flags: Record<string, string>): void {
1555
1583
  console.log(` .. atlas ${atlasPath}`);
1556
1584
  const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
1557
1585
  const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
1558
-
1559
- const viewport = framingViewport(data, maxSide);
1586
+ const skin = readSkinFlag(flags, data.skins.map((s) => s.name));
1587
+ // One object, so the framing and the frames cannot be posed under two
1588
+ // different skins โ€” which would frame one shot with another shot's box.
1589
+ // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
1590
+ // `src/render.ts` has one of its own. The inferred shape is the render one.
1591
+ const pose = skin === undefined ? undefined : { skin };
1592
+ if (skin !== undefined) console.log(` .. skin ${skin}`);
1593
+
1594
+ const viewport = framingViewport(data, maxSide, pose);
1560
1595
  if (!viewport) {
1561
1596
  throw new UsageError(
1562
- `${skeletonPath} posed no drawable attachment in any animation or in its setup pose โ€” there is nothing to draw`,
1597
+ `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
1598
+ skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
1599
+ } โ€” there is nothing to draw`,
1563
1600
  );
1564
1601
  }
1565
1602
 
@@ -1567,7 +1604,7 @@ function cmdRender(flags: Record<string, string>): void {
1567
1604
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
1568
1605
  // the same sampler rather than a second path through it.
1569
1606
  const sampled: Map<string, Frame[]> =
1570
- only === undefined ? sampleAll(data, fps) : new Map([[only, sampleAnimation(data, only, fps)]]);
1607
+ only === undefined ? sampleAll(data, fps, pose) : new Map([[only, sampleAnimation(data, only, fps, pose)]]);
1571
1608
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
1572
1609
 
1573
1610
  mkdirSync(outRoot, { recursive: true });
@@ -1609,6 +1646,10 @@ function cmdRender(flags: Record<string, string>): void {
1609
1646
  // render something else into the same grid later.
1610
1647
  const sidecar: FramesSidecar = {
1611
1648
  spec: FRAMES_SPEC,
1649
+ // Written only when a skin was asked for: absent says "no skin was set",
1650
+ // which is both what this run did and what every frame set written before
1651
+ // #571 did. See `FramesSidecar.skin`.
1652
+ ...(skin === undefined ? {} : { skin }),
1612
1653
  background: BACKGROUND,
1613
1654
  viewport: {
1614
1655
  x: viewport.minX,
@@ -2395,7 +2436,15 @@ function cmdExplain(flags: Record<string, string>): void {
2395
2436
  // last one in the repository and issue #307 was about exactly that.
2396
2437
  const motion = parseMotionSpec(readJsonFile(opts.motionPath), opts.motionPath);
2397
2438
 
2398
- console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
2439
+ // A rig may state that it has no stage at all (issue #578), and the two must
2440
+ // not print alike: `undefined x undefined` is what a template does with an
2441
+ // absence, and it reads like a defect in the tool rather than a claim in the
2442
+ // spec.
2443
+ const stage =
2444
+ result.skeleton.skeleton.width === undefined || result.skeleton.skeleton.height === undefined
2445
+ ? 'none declared'
2446
+ : `${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height}`;
2447
+ console.log(`\nstage ${stage} (spine ${result.skeleton.skeleton.spine})`);
2399
2448
 
2400
2449
  // The crop note describes where the numbers CAME from, and without a manifest
2401
2450
  // they came from the rig spec's own literals โ€” there is no crop to be relative
@@ -2679,6 +2728,135 @@ function cmdExplain(flags: Record<string, string>): void {
2679
2728
  console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
2680
2729
  }
2681
2730
 
2731
+ /**
2732
+ * ingest โ€” a skeleton back into the two specs that rebuild it.
2733
+ *
2734
+ * The only command that runs against `build`'s direction, and the contract is an
2735
+ * equality rather than a rulebook: `build(ingest(A))` is `A`. Everything it
2736
+ * cannot carry is a **finding** printed here with its code, because those lines
2737
+ * are what tells an author what the rebuilt rig will not have โ€” they are this
2738
+ * command's whole UI, exactly as the validator's messages are `build`'s.
2739
+ *
2740
+ * โš ๏ธ It writes both specs even when a blocker was found, and then exits
2741
+ * non-zero: a blocker means the rebuild will not be the file that was read, and
2742
+ * the useful thing at that point is the spec plus the list of what is missing
2743
+ * from it. `build`'s "nothing written" rule is not this rule โ€” that one is about
2744
+ * a **gated artifact** on disk, and these two files are inputs to the gate
2745
+ * rather than output of it.
2746
+ */
2747
+ function cmdIngest(flags: Record<string, string>, positional: string[]): void {
2748
+ const [source] = positional;
2749
+ if (!source) throw new UsageError('ingest takes one path: <skeleton.json>');
2750
+ const skeletonPath = resolve(source);
2751
+ if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
2752
+ // The same sentence `resolveArtifacts` refuses a `.spine` with, for the same
2753
+ // reason (docs/INGEST.md ยง5): this reads Spine 4.3 skeleton JSON and nothing
2754
+ // else โ€” not a project file, not a binary `.skel`, not the atlas.
2755
+ if (!skeletonPath.endsWith('.json')) {
2756
+ throw new UsageError(
2757
+ `${skeletonPath} is not a .json skeleton โ€” ingest reads Spine 4.3 skeleton JSON and nothing else: not a ` +
2758
+ '.spine project, not a binary .skel, not an atlas. Re-export as JSON',
2759
+ );
2760
+ }
2761
+ if (flags.out === undefined) throw new UsageError('ingest needs --out <dir> โ€” the directory to write rig.json and motion.json into');
2762
+ const art = flags.art ?? 'loose';
2763
+ if (art !== 'loose' && art !== 'none') {
2764
+ throw new UsageError(
2765
+ `--art is ${JSON.stringify(art)}; it is "loose" (name an image per attachment, for \`build --images <dir>\`) ` +
2766
+ 'or "none" (state width/height only, for `build --atlas-in <pack>`). A skeleton encodes neither, which is ' +
2767
+ 'why this is a flag',
2768
+ );
2769
+ }
2770
+ let stage: IngestStage | undefined;
2771
+ if (flags.stage !== undefined) {
2772
+ const parts = flags.stage.split(',').map(Number);
2773
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
2774
+ throw new UsageError(`--stage is ${JSON.stringify(flags.stage)}; give four numbers, x,y,width,height`);
2775
+ }
2776
+ stage = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
2777
+ }
2778
+ const outDir = resolve(flags.out);
2779
+ /**
2780
+ * The rig spec's own `images`, spelled from `--out` the way `build` spells
2781
+ * `skeleton.images` from its own output directory โ€” the SAME function, so the
2782
+ * two conventions cannot drift (issue #595). Without it the field is left out,
2783
+ * an `image` name resolves against the spec's own directory, and every rebuild
2784
+ * of the spec has to carry `build --images <dir>`.
2785
+ */
2786
+ let specImages: string | undefined;
2787
+ if (flags.images !== undefined) {
2788
+ // Refused rather than ignored, for the reason `chainfit` refuses `--atlas`:
2789
+ // a flag that silently does nothing is worse than one that says why it
2790
+ // cannot. Measured โ€” an `--art none` spec rebuilt with `--images` naming a
2791
+ // directory that does not exist is byte-identical to one rebuilt without
2792
+ // it, because no attachment carries an `image` for that directory to be the
2793
+ // base of.
2794
+ if (art === 'none') {
2795
+ throw new UsageError(
2796
+ '--images <dir> and --art none contradict: `none` writes width/height and no `image` at all, so the rig ' +
2797
+ "spec's `images` directory would be the base of nothing and no rebuild would read it. Use --art loose to " +
2798
+ 'name an image per attachment, or drop --images โ€” an `--art none` spec rebuilds with `build --atlas-in ' +
2799
+ '<pack.atlas>`',
2800
+ );
2801
+ }
2802
+ // Not checked for existence, deliberately: `ingest` reads the skeleton and
2803
+ // nothing else, so the parts may well be extracted AFTER the specs are
2804
+ // written, and refusing a directory this command never opens would refuse a
2805
+ // legitimate order of work. `build` is where a missing PNG is named.
2806
+ specImages = relativeImagesPath(outDir, resolve(flags.images));
2807
+ }
2808
+ console.log(`rigc ingest ${skeletonPath}`);
2809
+ console.log(` .. out ${outDir}`);
2810
+ console.log(` .. art ${art}`);
2811
+ if (specImages !== undefined) console.log(` .. images ${specImages} (the rig spec's own, from ${outDir})`);
2812
+
2813
+ const result = ingest(readJsonFile(skeletonPath), {
2814
+ name: flags.name ?? basename(skeletonPath, '.json'),
2815
+ art,
2816
+ images: specImages,
2817
+ stage,
2818
+ source: basename(skeletonPath),
2819
+ version: readVersion(),
2820
+ });
2821
+
2822
+ mkdirSync(outDir, { recursive: true });
2823
+ // Indent 2, which is what `compile` writes the skeleton with. One emitter
2824
+ // convention, so a spec and the skeleton it came from read the same way.
2825
+ writeFileSync(join(outDir, 'rig.json'), `${JSON.stringify(result.rig, null, 2)}\n`);
2826
+ writeFileSync(join(outDir, 'motion.json'), `${JSON.stringify(result.motion, null, 2)}\n`);
2827
+ writeFileSync(join(outDir, 'findings.json'), `${JSON.stringify(result.findings, null, 2)}\n`);
2828
+
2829
+ // Grouped by kind rather than printed in discovery order: a blocker is what
2830
+ // decides the exit code, and a reader scanning for one should not have to
2831
+ // read past a hundred DURATION lines to find it.
2832
+ const GUTTER: Record<IngestFindingKind, string> = { blocker: 'BLOCK', judgement: 'JUDGE', lossy: 'LOSS ' };
2833
+ for (const kind of ['blocker', 'judgement', 'lossy'] as const) {
2834
+ for (const finding of result.findings.filter((f) => f.kind === kind)) {
2835
+ console.log(` ${GUTTER[kind]} ${finding.code}: ${finding.where} โ€” ${finding.detail}`);
2836
+ }
2837
+ }
2838
+ console.log(`rigc: wrote ${join(outDir, 'rig.json')}`);
2839
+ console.log(`rigc: wrote ${join(outDir, 'motion.json')}`);
2840
+ console.log(`rigc: wrote ${join(outDir, 'findings.json')}`);
2841
+ // The hint is the command the caller will actually run, so it drops `--images`
2842
+ // exactly when the spec now carries the directory itself โ€” a hint that asks for
2843
+ // a flag the spec made unnecessary is the defect issue #595 is about, printed.
2844
+ const artFlag = art === 'none' ? ' --atlas-in <pack.atlas>' : specImages === undefined ? ' --images <dir>' : '';
2845
+ console.log(
2846
+ `rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')}` +
2847
+ `${artFlag} --out <dir>`,
2848
+ );
2849
+
2850
+ const blockers = result.findings.filter((f) => f.kind === 'blocker');
2851
+ if (blockers.length > 0) {
2852
+ console.error(
2853
+ `rigc: ${blockers.length} blocker(s) โ€” both specs were written, and a build from them will NOT be the ` +
2854
+ `skeleton that was read (${[...new Set(blockers.map((f) => f.code))].join(', ')})`,
2855
+ );
2856
+ process.exit(1);
2857
+ }
2858
+ }
2859
+
2682
2860
  // ---------------------------------------------------------------------------
2683
2861
  // usage / per-command help
2684
2862
  // ---------------------------------------------------------------------------
@@ -2767,11 +2945,25 @@ const FLAG_MEANINGS: Record<string, string> = {
2767
2945
  `printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
2768
2946
  'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
2769
2947
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
2948
+ skin:
2949
+ 'pose under this skin, by the name the skeleton declares. Without it NO skin is set โ€” every slot resolves ' +
2950
+ 'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
2951
+ 'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
2952
+ '`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
2770
2953
  max: 'longest side of a rendered frame, in pixels (default 256)',
2771
2954
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
2772
2955
  ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
2773
2956
  ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
2774
2957
  again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
2958
+ name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
2959
+ art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
2960
+ "attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
2961
+ '--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
2962
+ 'resolve (default: loose)',
2963
+ stage:
2964
+ "the setup bounding box โ€” `skeleton.x,y,width,height`. An editor export carries none and rigc refuses a " +
2965
+ 'compile without one; posing the rig gives the ANIMATED extent, which is a different number, so this is the ' +
2966
+ "caller's value and is never derived. Without it the missing stage is reported as a blocker",
2775
2967
  help: "show this command's flags and exit",
2776
2968
  };
2777
2969
 
@@ -2812,10 +3004,14 @@ const FLAG_VALUES: Record<string, string> = {
2812
3004
  'anchor-residual': '<0..1>',
2813
3005
  'inward-lever': '<px>',
2814
3006
  animation: '<name>',
3007
+ skin: '<name>',
2815
3008
  max: '<px>',
2816
3009
  record: '<result.json>',
2817
3010
  ballot: '<ballot.html>',
2818
3011
  ledger: '<votes.jsonl>',
3012
+ name: '<n>',
3013
+ art: 'loose|none',
3014
+ stage: '<x,y,w,h>',
2819
3015
  };
2820
3016
 
2821
3017
  interface CommandDoc {
@@ -2828,14 +3024,25 @@ interface CommandDoc {
2828
3024
  * Per-command wording for a flag whose value or meaning genuinely differs here.
2829
3025
  *
2830
3026
  * โš ๏ธ The default above it โ€” one meaning per flag name, everywhere โ€” is the rule
2831
- * and this is the named exception to it, not a second table. Three flags earn it:
2832
- * `--out` is a directory of artifacts to `build`, a directory of pictures to
3027
+ * and this is the named exception to it, not a second table. What earns an entry
3028
+ * is the criterion rather than a headcount: the flag is **shared with another
3029
+ * command**, and it means something different in this one. Examples, and not an
3030
+ * inventory: `--out` is a directory of artifacts to `build`, a directory of specs
3031
+ * to `ingest`, a directory of pictures to
2833
3032
  * `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
2834
3033
  * was RECORDED at to `check`, which reads it off a sidecar, and the rate to
2835
3034
  * SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
2836
3035
  * everywhere except `vote`, which is the one command that takes several and is
2837
3036
  * the reason there is a ballot at all. Writing any of them as one sentence
2838
3037
  * covering every command would leave every command's own help less true.
3038
+ *
3039
+ * โ›” The other side of the criterion, which is the one that keeps this from
3040
+ * becoming the second table it says it is not: a flag no other command takes has
3041
+ * nothing to differ FROM, so its wording belongs in `FLAG_MEANINGS` /
3042
+ * `FLAG_VALUES` above and an entry here for it buys only a second place to look.
3043
+ * Both halves are read off `--help` by `CLI71` in `selftest.ts`, which is why
3044
+ * this sentence no longer counts anything: it said *"three"* where #605 counted
3045
+ * nine, and nothing had ever compared the two.
2839
3046
  */
2840
3047
  overrides?: Record<string, { value?: string; meaning?: string }>;
2841
3048
  }
@@ -2878,6 +3085,25 @@ const COMMANDS: CommandDoc[] = [
2878
3085
  ],
2879
3086
  flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
2880
3087
  },
3088
+ {
3089
+ name: 'ingest',
3090
+ usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--images <dir>] [--stage x,y,w,h]'],
3091
+ flags: ['out', 'name', 'art', 'images', 'stage'],
3092
+ overrides: {
3093
+ out: {
3094
+ value: '<dir>',
3095
+ meaning: 'directory to write rig.json, motion.json and findings.json into โ€” the two specs that rebuild this skeleton',
3096
+ },
3097
+ images: {
3098
+ value: '<dir>',
3099
+ meaning:
3100
+ "WRITE the rig spec's own images directory, spelled relative to --out, so the rebuild is a plain `build " +
3101
+ '--rig โ€ฆ --motion โ€ฆ --out โ€ฆ` with no flag. โš ๏ธ The opposite direction from `build --images`, which ' +
3102
+ 'OVERRIDES that field: this one fills it in. Without it the field is left out and every `image` resolves ' +
3103
+ 'against --out itself. Refused together with --art none, which writes no `image` for it to be the base of',
3104
+ },
3105
+ },
3106
+ },
2881
3107
  {
2882
3108
  name: 'diff',
2883
3109
  usage: ['rigc diff <candidate.json> <reference.json> [--json <out>]'],
@@ -2886,7 +3112,16 @@ const COMMANDS: CommandDoc[] = [
2886
3112
  {
2887
3113
  name: 'check',
2888
3114
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
2889
- flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
3115
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json'],
3116
+ overrides: {
3117
+ skin: {
3118
+ meaning:
3119
+ 'pose the CANDIDATE under this skin, by the name it declares. Without it no skin is set and the ' +
3120
+ 'default skin alone is compared, which for a multi-skin rig is a comparison that can see none of the ' +
3121
+ 'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
3122
+ 'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
3123
+ },
3124
+ },
2890
3125
  },
2891
3126
  {
2892
3127
  name: 'bench',
@@ -2914,9 +3149,9 @@ const COMMANDS: CommandDoc[] = [
2914
3149
  {
2915
3150
  name: 'render',
2916
3151
  usage: [
2917
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--fps 12] [--max 256] [--out render/]',
3152
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
2918
3153
  ],
2919
- flags: ['candidate', 'atlas', 'animation', 'fps', 'max', 'out'],
3154
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'fps', 'max', 'out'],
2920
3155
  overrides: {
2921
3156
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
2922
3157
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -3070,6 +3305,20 @@ const USAGE = [
3070
3305
  'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
3071
3306
  'it in the official Spine Web Player, which is also the interop proof.',
3072
3307
  '',
3308
+ 'ingest runs build backwards: it reads a Spine 4.3 skeleton.json and writes the rig',
3309
+ 'spec and motion spec that rebuild it, so an existing skeleton becomes a starting',
3310
+ 'point instead of something to retype:',
3311
+ ' rigc ingest hero.json --out specs/ --stage 0,0,1024,768 rig.json + motion.json',
3312
+ 'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
3313
+ 'It reads the skeleton and nothing else โ€” no .spine project, no binary .skel, no',
3314
+ 'atlas โ€” so two things are the caller\'s and are refused rather than guessed: the',
3315
+ 'setup stage (--stage; an export carries none) and how the spec reaches the art',
3316
+ '(--art). --images <dir> is the third and the only optional one: it WRITES the rig',
3317
+ 'spec\'s own images directory, relative to --out, so the rebuild needs no flag.',
3318
+ 'Everything the spec format cannot hold is printed as a named finding and',
3319
+ 'exits non-zero, with both files still written, because a spec plus a list of what',
3320
+ 'is missing from it beats no spec at all.',
3321
+ '',
3073
3322
  'pose runs the other way round from everything above: it reads a picture you already',
3074
3323
  'have โ€” one key pose โ€” and reports where each loose part PNG sits in it (x, y, rotation,',
3075
3324
  'scale) so an agent can state those poses in a spec by construction:',
@@ -3130,6 +3379,7 @@ try {
3130
3379
  process.exit(0);
3131
3380
  }
3132
3381
  if (command === 'build') cmdBuild(flags);
3382
+ else if (command === 'ingest') cmdIngest(flags, positional);
3133
3383
  else if (command === 'validate') cmdValidate(flags, positional);
3134
3384
  else if (command === 'explain') cmdExplain(flags);
3135
3385
  else if (command === 'diff') cmdDiff(flags, positional);