spine-rigc 0.22.1 โ†’ 0.23.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 |
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,55 @@ 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
543
+ rigc build --rig specs/rig.json --motion specs/motion.json --images parts/ --out build/
544
+ rigc diff build/skeleton.json hero.json
545
+ ```
546
+
547
+ **`build(ingest(x))` is `x`.** Over the eleven rigs this repository builds โ€” the seven
548
+ gallery examples, the three generated probes and a coverage probe written for the
549
+ purpose โ€” the rebuilt `skeleton.json` is byte for byte the file the decompiler read,
550
+ and `bun run selftest` holds it there on every run. The atlas is held to a weaker
551
+ claim on purpose, and the weakening is measured rather than assumed: it comes back
552
+ equal as a **multiset of region blocks**, because the order the pages are collected in
553
+ is in no field of the skeleton.
554
+
555
+ **What it reads is skeleton JSON and nothing else** โ€” no `.spine` project, no binary
556
+ `.skel`, no atlas, no art. So it never invents, and the things it cannot get out of
557
+ the file are **findings** with codes rather than plausible values: a construct the
558
+ spec format cannot hold (`linkedmesh`, `point`, a `sequence` block, an unknown field
559
+ on a bone, slot or constraint) is a blocker, the command exits non-zero, and both
560
+ specs are still written โ€” a spec plus a list of what is missing from it beats no spec.
561
+ One thing it drops on purpose and says so: a path attachment's `lengths`, which is
562
+ `PathConstraint`'s own measurement and which rigc re-measures.
563
+
564
+ โš ๏ธ **Two values are not in a skeleton at all.**
565
+
566
+ - **The stage.** `skeleton.width`/`height`: rigc always writes one and an editor
567
+ export carries none, so without `--stage x,y,w,h` the missing box is refused by
568
+ name. It is not derivable โ€” posing the rig gives the *animated* extent, which is a
569
+ different number from the setup box โ€” and it is the value that costs nothing to get
570
+ wrong, because no measure `diff` reports reads the skeleton header at all.
571
+ - **An animation's duration.** The format has no such field. The largest key time is
572
+ the only derivable answer and it is what a runtime plays to; it is wrong for an
573
+ animation that holds its last pose past its last key, so it is recorded as a finding
574
+ on every animation rather than chosen quietly.
575
+
576
+ Both specs carry a `note` that `ingest` writes itself, saying the file is decompiled
577
+ and naming the skeleton it came from โ€” because a decompiled spec is indistinguishable
578
+ from an authored one by inspection, every gate here calls it green (it *is* green),
579
+ and no gate can catch a missing note.
580
+
581
+ [docs/INGEST.md](docs/INGEST.md) is the whole page on working from a file you were
582
+ handed; [docs/AUTHORING.md](docs/AUTHORING.md) ยง0.3 is the loop.
583
+
524
584
  ### The editor round trip โ€” for a licence holder, never in CI
525
585
 
526
586
  `tools/editor_roundtrip.ts` drives the loop the output's whole premise rests on:
@@ -534,7 +594,10 @@ bun tools/editor_roundtrip.ts --build build/ --editor /Applications/Spine.app/Co
534
594
 
535
595
  It prints the import and export exit codes, the validator's verdict on the
536
596
  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
597
+ animation **for each skin the build declares** โ€” one render-and-check block per
598
+ skin, with a per-skin roll-up under them, because a rig's contested art lives in
599
+ its named skins and a single un-skinned check draws none of it โ€” and a
600
+ field-by-field list of what the editor rewrote. On its first
538
601
  run it found three emitter defects โ€” [#368](https://github.com/firejune/rigc/issues/368),
539
602
  [#369](https://github.com/firejune/rigc/issues/369),
540
603
  [#370](https://github.com/firejune/rigc/issues/370) โ€” and then showed that a
package/cli.ts CHANGED
@@ -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,100 @@ 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
+ console.log(`rigc ingest ${skeletonPath}`);
2780
+ console.log(` .. out ${outDir}`);
2781
+ console.log(` .. art ${art}`);
2782
+
2783
+ const result = ingest(readJsonFile(skeletonPath), {
2784
+ name: flags.name ?? basename(skeletonPath, '.json'),
2785
+ art,
2786
+ stage,
2787
+ source: basename(skeletonPath),
2788
+ version: readVersion(),
2789
+ });
2790
+
2791
+ mkdirSync(outDir, { recursive: true });
2792
+ // Indent 2, which is what `compile` writes the skeleton with. One emitter
2793
+ // convention, so a spec and the skeleton it came from read the same way.
2794
+ writeFileSync(join(outDir, 'rig.json'), `${JSON.stringify(result.rig, null, 2)}\n`);
2795
+ writeFileSync(join(outDir, 'motion.json'), `${JSON.stringify(result.motion, null, 2)}\n`);
2796
+ writeFileSync(join(outDir, 'findings.json'), `${JSON.stringify(result.findings, null, 2)}\n`);
2797
+
2798
+ // Grouped by kind rather than printed in discovery order: a blocker is what
2799
+ // decides the exit code, and a reader scanning for one should not have to
2800
+ // read past a hundred DURATION lines to find it.
2801
+ const GUTTER: Record<IngestFindingKind, string> = { blocker: 'BLOCK', judgement: 'JUDGE', lossy: 'LOSS ' };
2802
+ for (const kind of ['blocker', 'judgement', 'lossy'] as const) {
2803
+ for (const finding of result.findings.filter((f) => f.kind === kind)) {
2804
+ console.log(` ${GUTTER[kind]} ${finding.code}: ${finding.where} โ€” ${finding.detail}`);
2805
+ }
2806
+ }
2807
+ console.log(`rigc: wrote ${join(outDir, 'rig.json')}`);
2808
+ console.log(`rigc: wrote ${join(outDir, 'motion.json')}`);
2809
+ console.log(`rigc: wrote ${join(outDir, 'findings.json')}`);
2810
+ console.log(
2811
+ `rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')} ` +
2812
+ `${art === 'loose' ? '--images <dir>' : '--atlas-in <pack.atlas>'} --out <dir>`,
2813
+ );
2814
+
2815
+ const blockers = result.findings.filter((f) => f.kind === 'blocker');
2816
+ if (blockers.length > 0) {
2817
+ console.error(
2818
+ `rigc: ${blockers.length} blocker(s) โ€” both specs were written, and a build from them will NOT be the ` +
2819
+ `skeleton that was read (${[...new Set(blockers.map((f) => f.code))].join(', ')})`,
2820
+ );
2821
+ process.exit(1);
2822
+ }
2823
+ }
2824
+
2682
2825
  // ---------------------------------------------------------------------------
2683
2826
  // usage / per-command help
2684
2827
  // ---------------------------------------------------------------------------
@@ -2767,11 +2910,24 @@ const FLAG_MEANINGS: Record<string, string> = {
2767
2910
  `printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
2768
2911
  'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
2769
2912
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
2913
+ skin:
2914
+ 'pose under this skin, by the name the skeleton declares. Without it NO skin is set โ€” every slot resolves ' +
2915
+ 'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
2916
+ 'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
2917
+ '`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
2770
2918
  max: 'longest side of a rendered frame, in pixels (default 256)',
2771
2919
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
2772
2920
  ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
2773
2921
  ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
2774
2922
  again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
2923
+ name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
2924
+ art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
2925
+ 'attachment for `build --images <dir>` to measure, `none` states width/height only for `build --atlas-in ' +
2926
+ '<pack>` to resolve (default: loose)',
2927
+ stage:
2928
+ "the setup bounding box โ€” `skeleton.x,y,width,height`. An editor export carries none and rigc refuses a " +
2929
+ 'compile without one; posing the rig gives the ANIMATED extent, which is a different number, so this is the ' +
2930
+ "caller's value and is never derived. Without it the missing stage is reported as a blocker",
2775
2931
  help: "show this command's flags and exit",
2776
2932
  };
2777
2933
 
@@ -2812,10 +2968,14 @@ const FLAG_VALUES: Record<string, string> = {
2812
2968
  'anchor-residual': '<0..1>',
2813
2969
  'inward-lever': '<px>',
2814
2970
  animation: '<name>',
2971
+ skin: '<name>',
2815
2972
  max: '<px>',
2816
2973
  record: '<result.json>',
2817
2974
  ballot: '<ballot.html>',
2818
2975
  ledger: '<votes.jsonl>',
2976
+ name: '<n>',
2977
+ art: 'loose|none',
2978
+ stage: '<x,y,w,h>',
2819
2979
  };
2820
2980
 
2821
2981
  interface CommandDoc {
@@ -2829,7 +2989,8 @@ interface CommandDoc {
2829
2989
  *
2830
2990
  * โš ๏ธ The default above it โ€” one meaning per flag name, everywhere โ€” is the rule
2831
2991
  * 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
2992
+ * `--out` is a directory of artifacts to `build`, a directory of specs to
2993
+ * `ingest`, a directory of pictures to
2833
2994
  * `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
2834
2995
  * was RECORDED at to `check`, which reads it off a sidecar, and the rate to
2835
2996
  * SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
@@ -2878,6 +3039,17 @@ const COMMANDS: CommandDoc[] = [
2878
3039
  ],
2879
3040
  flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
2880
3041
  },
3042
+ {
3043
+ name: 'ingest',
3044
+ usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--stage x,y,w,h]'],
3045
+ flags: ['out', 'name', 'art', 'stage'],
3046
+ overrides: {
3047
+ out: {
3048
+ value: '<dir>',
3049
+ meaning: 'directory to write rig.json, motion.json and findings.json into โ€” the two specs that rebuild this skeleton',
3050
+ },
3051
+ },
3052
+ },
2881
3053
  {
2882
3054
  name: 'diff',
2883
3055
  usage: ['rigc diff <candidate.json> <reference.json> [--json <out>]'],
@@ -2886,7 +3058,16 @@ const COMMANDS: CommandDoc[] = [
2886
3058
  {
2887
3059
  name: 'check',
2888
3060
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
2889
- flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
3061
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json'],
3062
+ overrides: {
3063
+ skin: {
3064
+ meaning:
3065
+ 'pose the CANDIDATE under this skin, by the name it declares. Without it no skin is set and the ' +
3066
+ 'default skin alone is compared, which for a multi-skin rig is a comparison that can see none of the ' +
3067
+ 'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
3068
+ 'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
3069
+ },
3070
+ },
2890
3071
  },
2891
3072
  {
2892
3073
  name: 'bench',
@@ -2914,9 +3095,9 @@ const COMMANDS: CommandDoc[] = [
2914
3095
  {
2915
3096
  name: 'render',
2916
3097
  usage: [
2917
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--fps 12] [--max 256] [--out render/]',
3098
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
2918
3099
  ],
2919
- flags: ['candidate', 'atlas', 'animation', 'fps', 'max', 'out'],
3100
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'fps', 'max', 'out'],
2920
3101
  overrides: {
2921
3102
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
2922
3103
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -3070,6 +3251,18 @@ const USAGE = [
3070
3251
  'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
3071
3252
  'it in the official Spine Web Player, which is also the interop proof.',
3072
3253
  '',
3254
+ 'ingest runs build backwards: it reads a Spine 4.3 skeleton.json and writes the rig',
3255
+ 'spec and motion spec that rebuild it, so an existing skeleton becomes a starting',
3256
+ 'point instead of something to retype:',
3257
+ ' rigc ingest hero.json --out specs/ --stage 0,0,1024,768 rig.json + motion.json',
3258
+ 'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
3259
+ 'It reads the skeleton and nothing else โ€” no .spine project, no binary .skel, no',
3260
+ 'atlas โ€” so two things are the caller\'s and are refused rather than guessed: the',
3261
+ 'setup stage (--stage; an export carries none) and how the spec reaches the art',
3262
+ '(--art). Everything the spec format cannot hold is printed as a named finding and',
3263
+ 'exits non-zero, with both files still written, because a spec plus a list of what',
3264
+ 'is missing from it beats no spec at all.',
3265
+ '',
3073
3266
  'pose runs the other way round from everything above: it reads a picture you already',
3074
3267
  'have โ€” one key pose โ€” and reports where each loose part PNG sits in it (x, y, rotation,',
3075
3268
  'scale) so an agent can state those poses in a spec by construction:',
@@ -3130,6 +3323,7 @@ try {
3130
3323
  process.exit(0);
3131
3324
  }
3132
3325
  if (command === 'build') cmdBuild(flags);
3326
+ else if (command === 'ingest') cmdIngest(flags, positional);
3133
3327
  else if (command === 'validate') cmdValidate(flags, positional);
3134
3328
  else if (command === 'explain') cmdExplain(flags);
3135
3329
  else if (command === 'diff') cmdDiff(flags, positional);