spine-rigc 1.1.0 → 1.2.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/docs/AUTHORING.md CHANGED
@@ -123,6 +123,8 @@ bun cli.ts check \
123
123
  # …and if nobody gave you frames, LOOK at it instead — neither needs a reference:
124
124
  bun cli.ts render --candidate path/to/spine # PNG frames + a contact sheet grid
125
125
  bun cli.ts preview --candidate path/to/spine # one .html that plays it in Spine's own player
126
+ # ↳ a green build ends by naming this command for its own --out; repeat
127
+ # --candidate for a pane per candidate on one page
126
128
 
127
129
  # …and where you have several green candidates and no instrument that separates
128
130
  # them, ask a human — the one loop step this toolchain cannot run for you:
@@ -141,6 +143,27 @@ the animation is the one in the frames, and there is no assertion that could —
141
143
  §9. The two run in that order because `check` needs artifacts on disk and `build`
142
144
  only writes them when the gate is green.
143
145
 
146
+ 🎞️ **When the source is a foreign player — a Live2D model, a Unity scene, a video —
147
+ make the reference frames first, from the source.** rigc reads none of those
148
+ formats and will not ([FACE.md](FACE.md#11-non-goals--stated-so-nobody-proposes-them-as-gaps)
149
+ §11, the paragraph that opens *No Live2D file is read or written*); what it reads
150
+ is the pictures they produce. Render the source at the rate you will check at into
151
+ one directory of `f0000.png`, `f0001.png`… named after the candidate animation it
152
+ shows (or pair the two with `--as`): `check` accepts a set with no `frames.json`
153
+ and takes its rate from `--fps` (§9). What that set cannot carry is its
154
+ **background**, which the sidecar would have recorded — so render it onto an
155
+ **opaque** background of the colour `check`'s no-`frames.json` note names
156
+ (`232, 232, 232, 255` in this release). The content box is found against that
157
+ colour and alpha is not read: measured on the 24 frames of `gallery/look`'s `turn`,
158
+ the same drawn pixels read MAE mean **2.25** over that grey, **5.19** over white and
159
+ **20.37** over a transparent background, where the fit took the whole 234×256
160
+ frame for the figure — all three at exit 0 with the same notes. A port with no
161
+ reference frames is **unmeasured, not finished**: green from `build` says the file
162
+ is valid and nothing about whether it is the source's picture. And the parts are
163
+ the source's own texture cut along its drawables, **never a screenshot of it** — a
164
+ screenshot is the composed result, so a part cut from it carries every part under
165
+ it, which is what the first of the three questions under *LOOK* below finds.
166
+
144
167
  🚨 **Read `check`'s per-frame column before its MAE.** The table's headline figures
145
168
  are the MAE and the slot drift, and a reader who came for those will skip the
146
169
  `per-frame` line printed under them — but that line is the only thing in this
@@ -159,7 +182,7 @@ What the flags mean:
159
182
  | --- | --- |
160
183
  | `--rig` | the rig spec — skeleton structure |
161
184
  | `--motion` | the motion spec — time |
162
- | `--out` | directory for `skeleton.json` + `skeleton.atlas`; atlas page paths and `skeleton.images` are written relative to it |
185
+ | `--out` | directory for `skeleton.json` + `skeleton.atlas`; atlas page paths and `skeleton.images` are written relative to it. On `check` it is a directory of **pictures** instead: one per frame the table lists (every compared frame under `--all-frames`), reference · candidate · difference · overlay at the comparison grid's native size, as `<dir>/<set>/f####.png` beside a `frames.json` that says what they are pictures of. Each `<dir>/<set>/` is cleared first; a file at `<dir>`, or a directory that is or holds `--frames`, is refused by name — **§9.2.1** |
163
186
  | `--copy-images` | `build` only: also copies every page **the emitted atlas names** into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (§3.1 says why it is spelled `../<out>/` and not `./`). Under `--atlas-in` those pages are the pack's, not one per part (**§0.2**). Without it, page paths point at the source art |
164
187
  | `--pack` | `build` only: arrange every part onto **shared** atlas page(s), written into `--out` as real PNGs, instead of one page per part. Lossless — nothing is resampled, trimmed or rotated. The default is one page per part — **§0.1** |
165
188
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
@@ -170,8 +193,8 @@ What the flags mean:
170
193
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
171
194
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
172
195
  | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
173
- | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
174
- | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview`, and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote`, so is a name that only *some* candidates have |
196
+ | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
197
+ | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
175
198
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
176
199
  | `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
177
200
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
@@ -749,7 +772,8 @@ bun cli.ts diff candidate.json reference.json [--as <candidate>=<reference>]
749
772
  bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin …]
750
773
  bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
751
774
  bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
752
- bun cli.ts preview --candidate path/to/spine [--animation …] [--out preview.html]
775
+ bun cli.ts render --candidate path/to/spine --slot <name,…> | --hide <name,…> # part of the rig, same grid
776
+ bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
753
777
  bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
754
778
  bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
755
779
  bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
@@ -824,12 +848,39 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
824
848
  gate can know you did not mean them. `render` writes
825
849
  `render/<animation>/f0000.png…` with a `contact.png` grid of **every** frame
826
850
  beside them (open that one first — spacing is a comparison across frames) and a
827
- `frames.json` sidecar naming the world box they are pictures of. `preview`
851
+ `frames.json` sidecar naming the world box they are pictures of. `contact.png`
852
+ is for spacing across frames; a defect is read on **one frame at full size**,
853
+ because a tile is too small to say which part a pixel belongs to. When a frame
854
+ looks wrong, render it again with `--hide <slot>` — the **largest attachment**
855
+ first, since it covers the most and is the likeliest to hide what is under it —
856
+ and compare the two frames: `--hide` and `--slot <name,…>` draw a subset of the
857
+ slots on the **same grid** as the whole rig (the viewport is still fitted to
858
+ every slot), so the frames overlay pixel for pixel and the difference is the
859
+ part. Three questions to ask of that full-size frame, each answered that way.
860
+ **Is any picture drawn twice?** Hide the largest attachment and look for a second
861
+ copy of what it covered, since a ghost under an opaque part is invisible until the
862
+ part is gone. **Is there a straight edge where the art has none?** A part cut as a
863
+ rectangle carries a border its drawing never had, and `--slot <that slot>` shows it
864
+ alone. **Does a part cover a feature the art shows?** An eye slot drawn over the
865
+ eye is found by hiding the slot and watching the feature come back. A name the skeleton does not declare is refused with every slot it does,
866
+ in draw order; a slot whose art lives only under another skin is refused naming
867
+ that skin (pass `--skin`); the two flags together are refused. `frames.json`
868
+ records the subset as `slots` or `hidden`, and **`check` refuses such a set as
869
+ a reference** (§9). `preview`
828
870
  writes one self-contained `.html`: your skeleton, atlas and page PNGs are
829
871
  embedded in it as data URIs and played by the official **Spine Web Player**, so
830
872
  double-clicking it is also the interop proof — what plays there was played by
831
873
  Esoteric Software's runtime, not by rigc's. The player is loaded from a CDN
832
- rather than copied into the file, so the first open needs a network.
874
+ rather than copied into the file, so the first open needs a network. Its
875
+ header carries the gate's line for the candidate — the `N assertions: …`
876
+ summary `rigc validate <dir>` prints for the same files, and its first `FAIL`
877
+ line when there is one — with the rigc version, measured when the page is
878
+ written; a refused candidate is still previewed, because looking at a red
879
+ build is what the page is for. Note that it is the **bare-directory** reading:
880
+ with no rig spec and no second compile beside the files, `A09` and `A18` report
881
+ SKIP there, so its counts are not the ones `build` printed. It takes
882
+ neither `--slot` nor `--hide` — the player draws what the skeleton draws — and
883
+ refuses both by name rather than playing the whole rig as if it had obeyed.
833
884
  - 📐 **`pose` is the only command here that reads an INPUT rather than a result.**
834
885
  Everything else takes a spec or a build and tells you something about it; `pose`
835
886
  takes a picture the user already has — a key pose — and reports where each loose
@@ -6018,7 +6069,8 @@ rather than the rig (**§9.2**'s atlas floor — and note that it is *not* `--at
6018
6069
  which re-seats your geometry on that atlas's packing), `--skin <name>` to pose
6019
6070
  your candidate under one of its skins (below), `--all-frames` to list
6020
6071
  every frame instead of the worst by MAE, `--json <out>` for the whole per-frame,
6021
- per-slot report.
6072
+ per-slot report, `--out <dir>` for the picture each listed frame's figures came from
6073
+ (**§9.2.1**).
6022
6074
 
6023
6075
  🚨 **What `check` certifies is the DEFAULT skin, unless you pass `--skin`.** With no `--skin` no skin is set at all, which is `spine-core`'s own
6024
6076
  initial state: every slot resolves through `SkeletonData.defaultSkin` alone, and a
@@ -6056,6 +6108,21 @@ remember:
6056
6108
  A skin name the candidate does not declare is refused with the ones it does —
6057
6109
  `the candidate declares no skin "path"; it declares [default, patch, torn]`.
6058
6110
 
6111
+ 🚫 **A frame set of PART of a rig is not a reference.** `render --slot`/`--hide`
6112
+ (§0) records the subset in `frames.json`, and `check` refuses any set that carries
6113
+ `slots` or `hidden`, before posing anything:
6114
+
6115
+ ```
6116
+ rigc check error: --frames frames/ records a slot subset (hidden: head) in frames.json; a partial render is not a reference set — render the reference without --slot/--hide
6117
+ ```
6118
+
6119
+ It is the skin clause one step stronger: two skins are two pictures of one rig,
6120
+ while a reference with a part left out is not a picture of the whole rig at all,
6121
+ and scoring a whole candidate against it would print a real figure about art the
6122
+ reference never drew. There is no flag that makes it comparable. A set with
6123
+ neither key — every set rendered without the flags, and every set written before
6124
+ they existed — is compared exactly as before.
6125
+
6059
6126
  📌 **Deform measurement needs no such flag, because the timeline carries the
6060
6127
  name.** `A39` and the `DEFORM` block pose each key with the skin that key is keyed
6061
6128
  on — a deform timeline's address is a `skin / slot / attachment` triple — so a
@@ -7001,6 +7068,66 @@ slots column is a parenthesis rather than a fraction, the deformation it carries
7001
7068
  already being scored inside the chain that owns the slot, and the row is telling you
7002
7069
  about your bone tree rather than about a hole in your figure.
7003
7070
 
7071
+ #### 9.2.1 Reading the pictures — `--out`
7072
+
7073
+ ```bash
7074
+ bun cli.ts check --candidate path/to/spine --frames path/to/reference/frames --out check-pictures
7075
+ ```
7076
+
7077
+ The table says **how much** a frame differs; `--out <dir>` writes **where**. For
7078
+ every frame the table lists — the frames worth reading, or every compared frame
7079
+ under `--all-frames` — it writes `<dir>/<set>/f####.png`, and beside them a
7080
+ `frames.json` recording what they are pictures of: the candidate, the frames
7081
+ directory, both skins, the framing scope, the grid, and per set the box it was
7082
+ framed in and which frames were written. Each picture is four panes, left to right:
7083
+
7084
+ | Pane | What it is | The figure in its label |
7085
+ | --- | --- | --- |
7086
+ | `REFERENCE` | the frame as read from `--frames` | `REF D` — the table's `ref Δ`: pixels the reference moved since its own previous frame (`-` when that frame was not compared) |
7087
+ | `CANDIDATE` | your candidate exactly as `check` rendered it onto that grid — same framing, same skin, same texture | `DPX` — the table's `Δpx`, the same count on your side |
7088
+ | `DIFFERENCE` | per pixel, the largest of the three channel differences between the two, on a **fixed** grey ramp: 0 is black, 255 is white. Drawn over exactly the pixels the MAE averages over — what your geometry covers or the reference drew — and transparent everywhere else | `MAE` — the frame's MAE, as the table prints it |
7089
+ | `OVERLAY` | the two at 50 % each, so a displaced edge reads as a double line and a part drawn twice reads as a ghost | `UNION` — the table's `union px` |
7090
+
7091
+ Under the panes, one row per slot of that frame: its drift at the table's
7092
+ precision and how it was matched, or why it has none (`not drawn`, `no attributable
7093
+ drift`). The frame's worst slot — the one its table row names — is marked `>`. The
7094
+ text is the tree's 5x7 bitmap font, so it prints in capitals, and a character the
7095
+ font does not carry draws as a solid block rather than disappearing.
7096
+
7097
+ - **The ramp is fixed, and deliberately not stretched to the frame's own maximum.**
7098
+ Two runs' pictures are then comparable by eye, and a quiet frame looks quiet. The
7099
+ cost is the other side of the same choice: a frame whose MAE is a texture floor
7100
+ of a few points reads as a **near-black silhouette** of the union, because 7 of
7101
+ 255 is dark. That silhouette is not nothing — it is where the two sides drew —
7102
+ and the grey you have to find is the difference you have to fix.
7103
+ - **Every pane is at the grid's native size, and there is no upscaling option.** An
7104
+ upscaled difference is a difference the instrument invented: a resampling filter
7105
+ spreads ink into pixels the comparison never measured. Open the file and zoom in
7106
+ your viewer instead, where the magnification is yours and visibly not the data.
7107
+ - **Nothing in a picture is a new number.** Every figure in it is one the report
7108
+ already holds. The rasters are the ones the figures were computed on, kept at the
7109
+ moment they were compared rather than rendered again.
7110
+
7111
+ What a picture cannot tell you, and does not try to:
7112
+
7113
+ - **Which side is right.** The difference pane is symmetric. A pixel lit there is
7114
+ a pixel the two disagree about; whether yours or the reference's is the one to
7115
+ move is yours to read off the other panes.
7116
+ - **Anything about a frame it does not show.** The set is the table's listing, so
7117
+ a frame outside it was not pictured — not pictured clean. `--all-frames` widens
7118
+ the set to every compared frame when the listing is not enough.
7119
+
7120
+ `<dir>/<set>/` is cleared and rewritten, as `render` clears its own, and nothing
7121
+ else under `<dir>` is touched. Two things are refused before anything is compared:
7122
+ a **file** at `<dir>` (`check: --out <path> is a file; it names a directory`), and a
7123
+ directory that **is `--frames` or holds it** (`check: --out <path> is --frames …` /
7124
+ `… holds --frames …`), because clearing a set directory there deletes the frames the
7125
+ pictures are of. And a `check --out` directory is not a frame set: pointed at by
7126
+ `--frames`, it is refused by the field that says so —
7127
+ `` frames.json carries "comparison": it was written by `rigc check --out` `` and the
7128
+ rest of the sentence names the frames it was made against. The reference pane inside each
7129
+ picture is a frame; the file is four panes and a table.
7130
+
7004
7131
  ### 9.3 What it still cannot see
7005
7132
 
7006
7133
  - **Anything a frame does not contain.** Bone `length`, the setup `inherit` mode,
package/docs/MOTION.md CHANGED
@@ -1007,6 +1007,7 @@ rigc build --rig semaphore.rig.json --motion semaphore.motion.json --images part
1007
1007
  .. pages=3 regions=3 bones=4 slots=3 animations=1 version=4.3.13 regionAttachments=3 meshAttachments=0 physicsConstraints=0 rig=semaphore profile=spine
1008
1008
  rigc: wrote …/semaphore/spine/skeleton.json
1009
1009
  rigc: wrote …/semaphore/spine/skeleton.atlas
1010
+ rigc: look at it: rigc preview --candidate …/semaphore/spine
1010
1011
  ```
1011
1012
 
1012
1013
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: rigc
3
- description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output through a spine-core round-trip before writing it. Use for any request to make a Spine rig or Spine animation from PNG parts, to run or read rigc build, validate, render, preview, check or vote, or to write or fix a *.rig.json or *.motion.json spec; it says which shipped guide to open for the need at hand. Not for Live2D conversion, cutting an illustration into parts, or real-time face tracking.
3
+ description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output through a spine-core round-trip before writing it. Use for any request to make a Spine rig or Spine animation from PNG parts, or where the source is a Live2D, Unity or video model whose pictures you can render, to run or read rigc build, validate, render, preview, check or vote, or to write or fix a *.rig.json or *.motion.json spec; it says which shipped guide to open for the need at hand. Not for Live2D conversion, cutting an illustration into parts, or real-time face tracking.
4
4
  license: MIT
5
5
  compatibility: Requires Bun 1.2 or later. The tool is the npm package spine-rigc (bunx spine-rigc, or bun add -d spine-rigc); the command it installs is rigc.
6
6
  ---
@@ -55,15 +55,38 @@ skill the package ships there, and `rigc skills --help` says what it refuses.
55
55
  §4.11.2.
56
56
  4. `rigc render --candidate <out>` or `rigc preview --candidate <out>` — look at
57
57
  it. A rig with its head off its torso passes the gate; looking is what catches it.
58
- 5. `rigc check --candidate <out> --frames <dir>` when you have reference pictures;
58
+ Open one frame at full size, not only `contact.png`: the sheet is for spacing
59
+ across frames, and a defect is read on a frame. Ask it three things — is any
60
+ picture drawn twice, is there a straight edge where the art has none, does a part
61
+ cover a feature the art shows — and answer each with `render --hide <slot>` (or
62
+ `--slot <slot,…>`), which draws the frame again without that part on the same
63
+ grid, so the two frames say which part a pixel is. AUTHORING §0 holds the three.
64
+ 5. `rigc check --candidate <out> --frames <dir>` when you have reference pictures
65
+ (`--out <dir>` writes the picture each of its numbers came from — open the worst);
59
66
  `rigc vote --candidate <a> --candidate <b>` when several candidates are green and
60
67
  only a person can choose between them.
61
68
  6. `rigc validate <out>` re-gates artifacts already on disk, and
62
69
  `rigc <command> --help` is each command's own flag table.
70
+ 7. Every finished unit ends with `rigc preview --candidate <out>`, and the report
71
+ names the `.html` it wrote. The hand-off to a person is part of the work.
63
72
 
64
73
  The loop in full, with `pose` before it and `chainfit` after the first build:
65
74
  AUTHORING §0.
66
75
 
76
+ ## When the source is not loose PNGs
77
+
78
+ A Live2D model, a Unity scene or a video is a player, not a set of parts. Make the
79
+ reference frames first: render the source at the rate you will check at, into one
80
+ directory of `f0000.png`, `f0001.png`…, and `rigc check --frames <that dir> --fps <rate>`
81
+ reads them with no `frames.json`. A port with no reference frames is unmeasured,
82
+ not finished. The parts are the source's own texture cut along its drawables, never
83
+ a screenshot: a screenshot is the composed result, and a part cut from it carries
84
+ every part under it. rigc reads none of those formats — FACE §11, the paragraph
85
+ that opens *No Live2D file is read or written*
86
+ ([FACE.md](https://github.com/firejune/rigc/blob/main/docs/FACE.md#11-non-goals--stated-so-nobody-proposes-them-as-gaps)) —
87
+ only the pictures they produce. The rule, the background those frames need and
88
+ what a wrong one costs: AUTHORING §0, *When the source is a foreign player*.
89
+
67
90
  ## Which guide, for which need
68
91
 
69
92
  Read [AUTHORING.md](https://github.com/firejune/rigc/blob/main/docs/AUTHORING.md) first, whatever the need: the two
package/src/ballot.ts CHANGED
@@ -63,6 +63,9 @@ import {
63
63
  dataUri,
64
64
  embeddedJson,
65
65
  escapeHtml,
66
+ PANE_STAGE_CSS,
67
+ paneGridCss,
68
+ paneSection,
66
69
  PLAYER_LINE,
67
70
  PLAYER_SCRIPT_URL,
68
71
  PLAYER_STYLE_URL,
@@ -592,14 +595,10 @@ export function buildBallot(input: BallotInput): { html: string; manifest: Ballo
592
595
  },
593
596
  };
594
597
 
595
- const panes = manifest.candidates
596
- .map(
597
- (c) => `<section class="pane">
598
- <h2>${c.label}</h2>
599
- <div class="stage" id="rigc-player-${c.label}"></div>
600
- </section>`,
601
- )
602
- .join('\n');
598
+ // The pane markup and its grid are `preview.ts`'s since issue #837, which
599
+ // borrowed them for a page of panes that asks nothing; the bytes here are
600
+ // the ones this page always wrote.
601
+ const panes = manifest.candidates.map((c) => paneSection(`rigc-player-${c.label}`, `<h2>${c.label}</h2>`)).join('\n');
603
602
 
604
603
  const choices = [...labels, TIE]
605
604
  .map(
@@ -649,11 +648,9 @@ export function buildBallot(input: BallotInput): { html: string; manifest: Ballo
649
648
  button { font: inherit; padding: 4px 12px; border: 1px solid rgba(0, 0, 0, 0.35); border-radius: 4px; background: rgba(255, 255, 255, 0.6); cursor: pointer; }
650
649
  button:hover { background: rgba(255, 255, 255, 0.95); }
651
650
  button[aria-pressed="true"] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
652
- #panes { display: grid; grid-template-columns: repeat(${manifest.candidates.length}, minmax(0, 1fr)); gap: 1px; background: rgba(0, 0, 0, 0.15); }
653
- @media (max-width: 720px) { #panes { grid-template-columns: minmax(0, 1fr); } }
654
- .pane { background: ${backgroundHex()}; display: flex; flex-direction: column; min-width: 0; }
651
+ ${paneGridCss(manifest.candidates.length)}
655
652
  .pane h2 { margin: 0; padding: 6px 14px; font-size: 15px; letter-spacing: 0.12em; }
656
- .stage { height: 52vh; min-height: 260px; }
653
+ ${PANE_STAGE_CSS}
657
654
  #vote { border-top: 1px solid rgba(0, 0, 0, 0.15); display: flex; flex-direction: column; gap: 10px; }
658
655
  .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
659
656
  .row > label, .caption { opacity: 0.65; }
package/src/check.ts CHANGED
@@ -214,6 +214,17 @@ function readSidecar(root: string): FramesSidecar | null {
214
214
  const raw = readFrameFile(root, join(root, FRAMES_SIDECAR)).toString('utf8');
215
215
  const parsed: unknown = JSON.parse(raw);
216
216
  if (typeof parsed !== 'object' || parsed === null) return null;
217
+ // Before the spec, because it is the more specific answer: a `check --out`
218
+ // directory carries this build's own spec, and what is wrong with it is not
219
+ // its version but what it is a picture OF — see `COMPARISON_FIELD`.
220
+ if (Object.prototype.hasOwnProperty.call(parsed, COMPARISON_FIELD)) {
221
+ throw new CheckError(
222
+ `${join(root, FRAMES_SIDECAR)} carries ${JSON.stringify(COMPARISON_FIELD)}: it was written by \`rigc check ` +
223
+ '--out`, and a check\'s pictures are a comparison, not a reference frame set — each one holds a reference ' +
224
+ 'pane, but the file is four panes and a table. Point --frames at the frames that comparison was made ' +
225
+ `against, which its ${JSON.stringify(COMPARISON_FIELD)}.frames names.`,
226
+ );
227
+ }
217
228
  const sidecar = parsed as FramesSidecar;
218
229
  if (sidecar.spec !== FRAMES_SPEC) {
219
230
  throw new CheckError(
@@ -1006,6 +1017,12 @@ export interface CheckOptions {
1006
1017
  * this adds a second render per compared frame beside them.
1007
1018
  */
1008
1019
  textureFrom?: { atlasText: string; atlasDir: string; label: string };
1020
+ /**
1021
+ * Keep the rasters behind the frames the report will list, for `--out` — see
1022
+ * `CheckPlates`. Nothing about the report changes when this is set: it is filled
1023
+ * in beside the comparison, from the plates the comparison was computed on.
1024
+ */
1025
+ plates?: CheckPlates;
1009
1026
  }
1010
1027
 
1011
1028
  // ---------------------------------------------------------------------------
@@ -1056,6 +1073,22 @@ export interface CheckOptions {
1056
1073
  */
1057
1074
  export function checkAgainstFrames(options: CheckOptions): CheckReport {
1058
1075
  const located = locateFrames(options.framesDir);
1076
+ // A frame set `render --slot`/`--hide` wrote is a picture of PART of a rig
1077
+ // (issue #835), and it is refused before anything is posed. The clause is the
1078
+ // skin mismatch's below, one step stronger: there two skins are two pictures
1079
+ // of one rig, here the reference is not a picture of a whole rig at all, so a
1080
+ // whole candidate compared against it would print a real figure about art the
1081
+ // reference leaves out. No flag makes that comparable — a warning would still
1082
+ // print the figure — so there is no remedy here but the reference's own.
1083
+ const subsetKey = located.sidecar?.slots !== undefined ? 'slots' : located.sidecar?.hidden !== undefined ? 'hidden' : null;
1084
+ if (subsetKey !== null) {
1085
+ const recorded = located.sidecar?.[subsetKey];
1086
+ throw new CheckError(
1087
+ `--frames ${options.framesDir} records a slot subset (${subsetKey}: ${
1088
+ Array.isArray(recorded) ? recorded.join(', ') : JSON.stringify(recorded)
1089
+ }) in ${FRAMES_SIDECAR}; a partial render is not a reference set — render the reference without --slot/--hide`,
1090
+ );
1091
+ }
1059
1092
  const notes: string[] = [];
1060
1093
 
1061
1094
  const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
@@ -1404,7 +1437,18 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1404
1437
  for (let i = 0; i < prepared.length; i++) {
1405
1438
  const f = framings[i];
1406
1439
  animations.push(
1407
- checkOneSet(located.root, prepared[i], posable, f, background, chains, chainOfSlot, substitution, unmatched),
1440
+ checkOneSet(
1441
+ located.root,
1442
+ prepared[i],
1443
+ posable,
1444
+ f,
1445
+ background,
1446
+ chains,
1447
+ chainOfSlot,
1448
+ substitution,
1449
+ unmatched,
1450
+ options.plates ?? null,
1451
+ ),
1408
1452
  );
1409
1453
  }
1410
1454
 
@@ -2367,6 +2411,8 @@ function checkOneSet(
2367
2411
  substitution: TextureSubstitution | null,
2368
2412
  /** Region names it could not reach, unioned across every set by the caller. */
2369
2413
  unmatched: Set<string>,
2414
+ /** Where to keep the rasters of the frames the report will list — `null` keeps none. */
2415
+ plates: CheckPlates | null,
2370
2416
  ): AnimationCheck {
2371
2417
  const { set } = prepared;
2372
2418
  const viewport = framing.viewport;
@@ -2437,6 +2483,7 @@ function checkOneSet(
2437
2483
  // because `substituteTexture` prefixes every name it writes.
2438
2484
  const floorPages = substitution === null ? null : new Map([...posable.pages, ...substitution.pages]);
2439
2485
  const floorSum = { floor: 0, aboveFloor: 0, floorReference: 0, aboveFloorReference: 0 };
2486
+ plates?.begin(set.dir, prepared.pairs.length);
2440
2487
 
2441
2488
  for (const { index, file, frame } of prepared.pairs) {
2442
2489
  const reference = readPlateFrom(root, file);
@@ -2447,7 +2494,7 @@ function checkOneSet(
2447
2494
  for (const name of swapped.unmatched) unmatched.add(name);
2448
2495
  floorPlate = renderFrame(swapped.frame, floorPages, viewport, background);
2449
2496
  }
2450
- const check = checkOneFrame(
2497
+ const { check, coverage } = checkOneFrame(
2451
2498
  index,
2452
2499
  file,
2453
2500
  frame,
@@ -2462,6 +2509,9 @@ function checkOneSet(
2462
2509
  );
2463
2510
  check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
2464
2511
  previous = { index, candidate: rendered, reference };
2512
+ // After the change is known, because whether a frame will be listed depends
2513
+ // on it — see `CheckPlates.offer`.
2514
+ plates?.offer(set.dir, check, { reference, candidate: rendered, coverage });
2465
2515
  frames.push(check);
2466
2516
  maeSum += check.mae;
2467
2517
  maeReferenceSum += check.maeReference;
@@ -2827,7 +2877,7 @@ function checkOneFrame(
2827
2877
  * see `TextureFloor`. `null` is the ordinary case and costs nothing.
2828
2878
  */
2829
2879
  floorPlate: Plate | null,
2830
- ): FrameCheck {
2880
+ ): { check: FrameCheck; coverage: Uint8Array } {
2831
2881
  const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
2832
2882
  // Only worth the transform when something was drawn to be nearest TO.
2833
2883
  const nearest =
@@ -2911,7 +2961,7 @@ function checkOneFrame(
2911
2961
  }
2912
2962
  }
2913
2963
 
2914
- return {
2964
+ const check: FrameCheck = {
2915
2965
  index,
2916
2966
  file,
2917
2967
  mae: union === 0 ? 0 : sum / union,
@@ -2943,6 +2993,9 @@ function checkOneFrame(
2943
2993
  aboveFloorReference: referencePixels === 0 ? 0 : aboveReferenceSum / referencePixels,
2944
2994
  },
2945
2995
  };
2996
+ // The coverage goes back beside the figures because the union it defines is the
2997
+ // one a `--out` difference pane is drawn over — see `CheckPlates`.
2998
+ return { check, coverage };
2946
2999
  }
2947
3000
 
2948
3001
  // ---------------------------------------------------------------------------
@@ -3653,7 +3706,7 @@ function chainRollup(report: CheckReport): ChainRollup[] {
3653
3706
  * ranked by MAE is exactly the listing that leaves them out. Rung 6's f65–f68 sit
3654
3707
  * near the bottom of that ranking.
3655
3708
  */
3656
- function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
3709
+ export function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
3657
3710
  if (allFrames || anim.frames.length <= LIST_EVERY) return anim.frames;
3658
3711
  const chosen = new Set(
3659
3712
  [...anim.frames]
@@ -3665,6 +3718,129 @@ function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
3665
3718
  return anim.frames.filter((f) => chosen.has(f.index));
3666
3719
  }
3667
3720
 
3721
+ /**
3722
+ * The field of a `frames.json` that says the directory is `check --out`'s
3723
+ * pictures and not a frame set — see `src/checkpics.ts`. `check --frames`
3724
+ * refuses a sidecar carrying it, by this name.
3725
+ */
3726
+ export const COMPARISON_FIELD = 'comparison';
3727
+
3728
+ /** The three rasters one frame's figures were computed on. */
3729
+ export interface ComparedPlates {
3730
+ /** The reference frame, as read from `--frames`. */
3731
+ reference: Plate;
3732
+ /** The candidate, rendered onto the reference's grid over the frames' background. */
3733
+ candidate: Plate;
3734
+ /** Which pixels the candidate's geometry covers, 1 or 0 — half of the union alpha. */
3735
+ coverage: Uint8Array;
3736
+ }
3737
+
3738
+ /**
3739
+ * The rasters behind the frames a report will list, kept at the moment they were
3740
+ * compared — what `check --out` draws its pictures from.
3741
+ *
3742
+ * ## Why they have to be kept, and why not all of them
3743
+ *
3744
+ * `checkOneSet` holds a frame's two plates for exactly one iteration (and the
3745
+ * previous frame's for the change measure); the difference is never a raster at
3746
+ * all, only a running sum. And which frames are *worth reading* is not known
3747
+ * until the set is finished, because it is the worst by MAE over all of them —
3748
+ * `framesToList` decides it at print time. So the choice is between re-rendering
3749
+ * the listed frames afterwards and keeping them now, and re-rendering is rejected:
3750
+ * a second render that agreed with the first would be a claim about the picture,
3751
+ * and this is meant to be the record of it.
3752
+ *
3753
+ * Keeping every frame is the other simple answer and it does not scale: one
3754
+ * frame at 256x116 is 261 KiB of plates and coverage, and a 300-frame set at
3755
+ * 512x512 would hold 675 MiB. So a set longer than the listing threshold keeps a
3756
+ * running top `WORST_FRAMES` by MAE — ties to the earlier index, which is the
3757
+ * order `framesToList`'s stable sort gives them — plus every frame whose change
3758
+ * disagrees, and drops the rest as it goes. `--all-frames` keeps everything,
3759
+ * because then everything is listed.
3760
+ *
3761
+ * 🔒 `framesToList` stays the one derivation of the listing. This only has to
3762
+ * keep a superset of it, and `writeCheckPictures` refuses by name a listed frame
3763
+ * it finds nothing kept for, so the two cannot disagree in silence.
3764
+ */
3765
+ export class CheckPlates {
3766
+ /** Whether every compared frame is kept — `--all-frames`. */
3767
+ readonly every: boolean;
3768
+ private readonly kept = new Map<string, Map<number, ComparedPlates>>();
3769
+ /** Per set: whether the whole set will be listed, so everything is kept. */
3770
+ private readonly whole = new Map<string, boolean>();
3771
+ /** Per set: the running worst by MAE, worst first, at most `WORST_FRAMES`. */
3772
+ private readonly ranked = new Map<string, Array<{ index: number; mae: number }>>();
3773
+ /** Per set: frames kept because their change disagrees, whatever their MAE. */
3774
+ private readonly disagreeing = new Map<string, Set<number>>();
3775
+ private held = 0;
3776
+ /** The most bytes of raster this held at any one time — the cost `--out` adds. */
3777
+ peakBytes = 0;
3778
+
3779
+ constructor(opts: { allFrames: boolean }) {
3780
+ this.every = opts.allFrames;
3781
+ }
3782
+
3783
+ /** A set is about to be compared, over this many frame pairs. */
3784
+ begin(dir: string, compared: number): void {
3785
+ this.kept.set(dir, new Map());
3786
+ this.whole.set(dir, this.every || compared <= LIST_EVERY);
3787
+ this.ranked.set(dir, []);
3788
+ this.disagreeing.set(dir, new Set());
3789
+ }
3790
+
3791
+ /** One frame has been compared: keep its plates if it can be listed. */
3792
+ offer(dir: string, check: FrameCheck, plates: ComparedPlates): void {
3793
+ const kept = this.kept.get(dir);
3794
+ const ranked = this.ranked.get(dir);
3795
+ const disagreeing = this.disagreeing.get(dir);
3796
+ if (kept === undefined || ranked === undefined || disagreeing === undefined) {
3797
+ throw new Error(`CheckPlates: set ${JSON.stringify(dir)} was offered a frame before it began`);
3798
+ }
3799
+ if (this.whole.get(dir) === true) {
3800
+ this.keep(kept, check.index, plates);
3801
+ return;
3802
+ }
3803
+ const disagrees = check.change !== null && check.change.verdict !== 'agrees';
3804
+ if (disagrees) disagreeing.add(check.index);
3805
+ // Frames arrive in index order, so a later frame that only ties the last
3806
+ // ranked one loses to it — exactly as the stable sort would place them.
3807
+ const enters = ranked.length < WORST_FRAMES || check.mae > ranked[ranked.length - 1].mae;
3808
+ if (enters) {
3809
+ let at = ranked.findIndex((r) => check.mae > r.mae);
3810
+ if (at < 0) at = ranked.length;
3811
+ ranked.splice(at, 0, { index: check.index, mae: check.mae });
3812
+ if (ranked.length > WORST_FRAMES) {
3813
+ const out = ranked.pop() as { index: number; mae: number };
3814
+ if (!disagreeing.has(out.index)) this.drop(kept, out.index);
3815
+ }
3816
+ }
3817
+ if (enters || disagrees) this.keep(kept, check.index, plates);
3818
+ }
3819
+
3820
+ /** The plates kept for one frame of one set, if any. */
3821
+ of(dir: string, index: number): ComparedPlates | undefined {
3822
+ return this.kept.get(dir)?.get(index);
3823
+ }
3824
+
3825
+ private keep(kept: Map<number, ComparedPlates>, index: number, plates: ComparedPlates): void {
3826
+ if (kept.has(index)) return;
3827
+ kept.set(index, plates);
3828
+ this.held += bytesOf(plates);
3829
+ if (this.held > this.peakBytes) this.peakBytes = this.held;
3830
+ }
3831
+
3832
+ private drop(kept: Map<number, ComparedPlates>, index: number): void {
3833
+ const plates = kept.get(index);
3834
+ if (plates === undefined) return;
3835
+ kept.delete(index);
3836
+ this.held -= bytesOf(plates);
3837
+ }
3838
+ }
3839
+
3840
+ function bytesOf(plates: ComparedPlates): number {
3841
+ return plates.reference.data.length + plates.candidate.data.length + plates.coverage.length;
3842
+ }
3843
+
3668
3844
  /** The per-frame change measure, as the animation's own summary line. */
3669
3845
  function changeSummary(anim: AnimationCheck): string {
3670
3846
  if (anim.changePairs === 0) {