spine-rigc 0.2.1 → 0.3.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
@@ -133,7 +133,11 @@ per animation and per frame:
133
133
  still.
134
134
  - **The framing** — where the candidate's drawn pixels sit against the reference's,
135
135
  as a scale, an offset and a residual. It is printed first because it is upstream
136
- of everything else: get it wrong and the error arrives disguised as motion.
136
+ of everything else: get it wrong and the error arrives disguised as motion. On a
137
+ skeleton root it is decided **per animation directory**: a set whose own pixels
138
+ land in the box `frames.json` records is measured there, exactly, and the rest
139
+ share one fitted framing. `--framing shared` measures every set in the shared one
140
+ — the whole-root behaviour before issue #100, and worth 15–25 MAE on a character.
137
141
  - **Per-frame change** — how many pixels each side moved since **its own** previous
138
142
  frame, compared against each other. It is the only measure here that looks at the
139
143
  relation between two frames rather than at one, and it is what catches a held pose
@@ -146,6 +150,13 @@ per animation and per frame:
146
150
  template-matched against its own rendered pixels instead, with a confidence; and
147
151
  where nothing inside the distance that slot could plausibly have moved matches
148
152
  it, the answer is **no match** rather than a number about some other part.
153
+ - **Per-chain attribution** — the same two, rolled up onto the unit an author
154
+ repairs. `check` cuts the **candidate's own** bone tree into chains at its branch
155
+ points and prints, per chain per set, the worst slot drift with its slot and
156
+ frame, the mean, the error per pixel inside it, and its share of the set's error
157
+ over the reference's own drawn pixels — plus one rollup line per chain across
158
+ every set. A figure with a dozen joints otherwise collapses to one number a shot,
159
+ and *"motion ✗"* over sixteen shots does not say which limb to re-key.
149
160
 
150
161
  🔒 **It never reads the reference skeleton.** It opens the candidate and PNG
151
162
  frames, and nothing else: every reference-side read goes through one guard that
@@ -195,6 +206,46 @@ closed**; B3's validator half is (the packed-atlas clauses live behind `--profil
195
206
  emitter half — no packer, no atlas importer — is not. Ordered gap list in Part 4 of that document;
196
207
  live status, and B1's proof, in [docs/LADDER.md](docs/LADDER.md).
197
208
 
209
+ ## Run viewer — watching a run instead of reading it
210
+
211
+ `check.txt` says a candidate's worst frame is f0012 at 56 MAE. The viewer shows
212
+ you f0012.
213
+
214
+ ```bash
215
+ bun run viewer # http://localhost:5173
216
+ ```
217
+
218
+ Pick a run, a candidate and an animation. The left pane plays the candidate's
219
+ emitted `skeleton.json` — rendered by **[spine-html](https://github.com/firejune/spine-html)**,
220
+ plain DOM, one CSS matrix per slot — and the right pane shows the reference
221
+ frames for the same animation from `bench/reference/`, indexed by the scrubber's
222
+ time at the frame set's own fps. Both panes use the world box the run was
223
+ measured in (`bench.json`'s `check.viewport`, per frame set where the run framed
224
+ them separately), so the two pictures are comparable exactly as far as the
225
+ check's numbers say they are — and the pane label names which box that was.
226
+ Under them: `bench.json`'s section means and the framing plus per-animation
227
+ summary from `check.txt`.
228
+
229
+ It is also the smallest end-to-end proof the two modules have. rigc emits Spine
230
+ data; spine-html consumes Spine data; neither is checking its own work when the
231
+ skeleton one wrote comes up animating in the other.
232
+
233
+ Every run under `bench/runs/` is listed, including the ones that predate a
234
+ convention — those are greyed out with the reason (a missing atlas page usually
235
+ means `bun run fetch-examples` has not run) rather than dropped, because the
236
+ ladder's history is part of what the viewer is for.
237
+
238
+ 🚫 **There is no build, and that is deliberate.** The viewer reads the working
239
+ tree: the runs, the reference frames, and `examples/` — which is Esoteric
240
+ Software's art, fetched rather than redistributed and non-commercial even then
241
+ (see [NOTICE.md](NOTICE.md)). A bundle would copy those pixels into a
242
+ distributable artifact. So there is one mode, `vite dev` on localhost, the dev
243
+ server serves nothing outside `bench/` and `examples/`, and `vite build` fails
244
+ on purpose. `viewer/` is not in `package.json`'s `files`, so it never ships
245
+ either; it is also outside the root `tsconfig.json` (it needs the DOM lib, which
246
+ the rest of the repository must not have) and is type-checked on its own with
247
+ `bunx tsc -p viewer --noEmit`. `bun run lint` covers it like everything else.
248
+
198
249
  ## What exists today
199
250
 
200
251
  **Inputs — three files, one domain each.** Only the middle one is required.
@@ -249,7 +300,7 @@ model (what is pinned, what may move, how authority falls off), and the
249
300
  ### The validator
250
301
 
251
302
  [`src/validate.ts`](src/validate.ts) parses the emitted artifacts with `spine-core`
252
- and then runs 32 named assertions over the loaded skeleton. Each one exists because
303
+ and then runs 34 named assertions over the loaded skeleton. Each one exists because
253
304
  the failure it catches is **silent**: the file loads, animates, and lies.
254
305
 
255
306
  Assertions whose data is absent are reported as **SKIP**, never folded into the pass
@@ -257,7 +308,7 @@ count — an assertion with nothing to check has not checked anything.
257
308
 
258
309
  #### Profiles — "wrong" versus "not how we do it here"
259
310
 
260
- Not all 32 rules are about Spine. Some are about **spine-html**, the renderer this
311
+ Not all 34 rules are about Spine. Some are about **spine-html**, the renderer this
261
312
  compiler was built to feed, and about one project's frame budget; they fire on real,
262
313
  correct, editor-produced Spine data, because the official example projects carry
263
314
  clipping attachments, unweighted meshes, 116-triangle meshes and packed atlases —
@@ -270,8 +321,8 @@ So `validate` and `build` take a `--profile`:
270
321
 
271
322
  | Profile | Runs | For |
272
323
  | --- | --- | --- |
273
- | `spine-html` | all 32 | **the default.** Is this a rig this project can ship? |
274
- | `spine` | the 18 validity rules | Is this valid Spine 4.3 that any runtime plays correctly? |
324
+ | `spine-html` | all 34 | **the default.** Is this a rig this project can ship? |
325
+ | `spine` | the 20 validity rules | Is this valid Spine 4.3 that any runtime plays correctly? |
275
326
 
276
327
  The **Profile** column below says which is which — `both` = validity, `renderer` and
277
328
  `archetype` = `spine-html` only, and **`both ◑`** = a mixed assertion whose validity
@@ -315,6 +366,8 @@ the renderer policy*.
315
366
  | `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | the stroke plus any inward keys stays within the cut's measured contact depth (skipped when the manifest declares none) |
316
367
  | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | the stroke stays within the cut's measured containment ceiling, and nothing in the axis subtree scales — a scale key changes the contour the ceiling was measured on (skipped when the manifest declares none) |
317
368
  | `A31_DRAW_ORDER_OFFSETS_RESOLVE` | both | every draw-order key resolves to a real permutation: known slots, one entry per slot, each landing inside the slots array, offsets in ascending slot order. The **only assertion that runs before `A00`** — descending offsets make `readDrawOrder`'s forward-only cursor spin rather than return, so the round trip is refused by name instead of attempted |
369
+ | `A32_EVENT_KEYS_RESOLVE` | both | every event key fires an event the skeleton declares, no key sits earlier in time than the one before it, and `volume`/`balance` appear only on an event with an `audio` path. Only the first of those is loud in the parser; the other two load clean and drop the firing or the value in silence. SKIPs when no animation carries an event timeline |
370
+ | `A33_VERTEX_ATTACHMENT_GEOMETRY` | both | every bounding box and clipping polygon states a `vertexCount` that agrees with its vertex array, its weighted run decodes to that many vertices with bone indices in range, and a clipping `end` names a slot that exists. All three load clean: a missing count reads as zero and empties the polygon, and a missing end slot makes the clip run to the bottom of the draw order. SKIPs when the skeleton carries neither type |
318
371
 
319
372
  ## Install
320
373
 
@@ -347,6 +400,174 @@ frames rendered from them, which are fetched rather than redistributed (see
347
400
  [NOTICE.md](NOTICE.md)). They need a clone and `bun run fetch-examples`, and say
348
401
  so by name when the corpus is absent.
349
402
 
403
+ ## First rig in ten minutes
404
+
405
+ A whole rig, end to end, in a scratch directory: three tiny plates, two JSON
406
+ files, one `build`, one `validate`. No clone, no art pipeline, nothing fetched.
407
+
408
+ 🚫 **Every value below is invented for this section** — a doll that exists
409
+ nowhere else in this repository. That is [AUTHORING.md](docs/AUTHORING.md) §3's
410
+ rule applied here: no example value in these documents is copied out of a
411
+ reference export, so nothing you read in a quickstart is an answer to anything
412
+ [the ladder](docs/LADDER.md) measures.
413
+
414
+ **1. Install the command.**
415
+
416
+ ```bash
417
+ bun add -g spine-rigc # installs `rigc`
418
+ ```
419
+
420
+ Or skip the install and prefix every command below with `bunx `, e.g.
421
+ `bunx spine-rigc build …`.
422
+
423
+ **2. Make a directory and three plates.** rigc measures PNGs rather than trusting
424
+ a number you typed (R5), so the art has to exist. These three are solid colours a
425
+ few dozen pixels across — a hull, a mast and a lamp:
426
+
427
+ ```bash
428
+ mkdir -p buoy/images && cd buoy
429
+ bun -e '
430
+ const parts = {
431
+ "images/hull.png": "iVBORw0KGgoAAAANSUhEUgAAADgAAAAMCAYAAAA3bX6lAAAAKElEQVR42mOI8bL6P5wxw6gHRz046sFRD456cNSDox4c9eCoBwcrBgDSZ+mdl2OiDgAAAABJRU5ErkJggg==",
432
+ "images/mast.png": "iVBORw0KGgoAAAANSUhEUgAAAAgAAAA0CAYAAAC3t3ldAAAAH0lEQVR42mO4dunIf3yYYVTBqIJRBaMKRhWMKhgcCgBGJo4s9YnopgAAAABJRU5ErkJggg==",
433
+ "images/lamp.png": "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAHElEQVR42mP4v8HhPzUww6hBowaNGjRq0HAzCADvdrVmFPbc+QAAAABJRU5ErkJggg=="
434
+ };
435
+ for (const [p, b] of Object.entries(parts)) await Bun.write(p, Buffer.from(b, "base64"));
436
+ '
437
+ ```
438
+
439
+ **3. The rig spec — `buoy.rig.json`.** Structure only: bones, the slots array in
440
+ draw order, and one skin mapping each slot to a plate.
441
+
442
+ ```json
443
+ {
444
+ "spec": "rigc-rig/1",
445
+ "name": "buoy",
446
+ "images": "images",
447
+ "skeleton": { "width": 200, "height": 200 },
448
+ "bones": [
449
+ { "name": "root" },
450
+ { "name": "hull", "parent": "root", "x": 0, "y": 0 },
451
+ { "name": "mast", "parent": "hull", "x": 0, "y": 4 },
452
+ { "name": "lamp", "parent": "mast", "x": 0, "y": 52 }
453
+ ],
454
+ "slots": [
455
+ { "name": "mast", "bone": "mast", "attachment": "mast" },
456
+ { "name": "hull", "bone": "hull", "attachment": "hull" },
457
+ { "name": "lamp", "bone": "lamp", "attachment": "lamp" }
458
+ ],
459
+ "skins": {
460
+ "default": {
461
+ "mast": { "mast": { "image": "mast.png", "y": 26 } },
462
+ "hull": { "hull": { "image": "hull.png" } },
463
+ "lamp": { "lamp": { "image": "lamp.png" } }
464
+ }
465
+ }
466
+ }
467
+ ```
468
+
469
+ Three things in there are worth naming, because each is a rule rather than a
470
+ style: the **slots array is the setup draw order** (R4) — index 0 is furthest
471
+ back, so the mast is behind the hull; the attachment carries an **`image`
472
+ instead of a `width`/`height`** (R5), which is what makes the size in the
473
+ skeleton and the size in the atlas incapable of drifting apart; and the mast's
474
+ `"y": 26` offsets the plate *within* its slot so the bone sits at the mast's foot
475
+ rather than its middle.
476
+
477
+ **4. The motion spec — `buoy.motion.json`.** Time only, aimed at the rig by name:
478
+
479
+ ```json
480
+ {
481
+ "spec": "rigc-motion/1",
482
+ "archetype": "buoy",
483
+ "cut": "buoy",
484
+ "easings": { "swing": [0.42, 0, 0.58, 1] },
485
+ "animations": {
486
+ "bob": {
487
+ "duration": 2,
488
+ "loop": true,
489
+ "tracks": [
490
+ {
491
+ "bone": "hull",
492
+ "property": "translatey",
493
+ "keys": [
494
+ { "t": 0, "v": [0], "ease": "swing" },
495
+ { "t": 0.5, "v": [5], "ease": "swing" },
496
+ { "t": 1.5, "v": [-5], "ease": "swing" },
497
+ { "t": 2, "v": [0] }
498
+ ]
499
+ },
500
+ {
501
+ "bone": "mast",
502
+ "property": "rotate",
503
+ "keys": [
504
+ { "t": 0, "v": [-6], "ease": "swing" },
505
+ { "t": 1, "v": [6], "ease": "swing" },
506
+ { "t": 2, "v": [-6] }
507
+ ]
508
+ }
509
+ ]
510
+ }
511
+ }
512
+ }
513
+ ```
514
+
515
+ `archetype` must equal the rig's `name`. `duration` is declared and then checked
516
+ against what actually compiled (R7). The **last key of each track carries no
517
+ easing** — there is nothing after it to ease towards, and saying otherwise is a
518
+ compile error.
519
+
520
+ **5. Build, then re-gate what it wrote.**
521
+
522
+ ```bash
523
+ rigc build --rig buoy.rig.json --motion buoy.motion.json --images images --out spine
524
+ rigc validate spine
525
+ ```
526
+
527
+ `build` prints every assertion by name, then the shape of what it emitted, then
528
+ the two files:
529
+
530
+ ```
531
+ .. pages=3 regions=3 bones=4 slots=3 animations=1 version=4.3.13 regionAttachments=3 meshAttachments=0 physicsConstraints=0 rig=buoy profile=spine-html
532
+ rigc: wrote …/buoy/spine/skeleton.json
533
+ rigc: wrote …/buoy/spine/skeleton.atlas
534
+ ```
535
+
536
+ and `validate` re-reads those artifacts from disk and ends `rigc: green`. That is
537
+ a rig. `spine/skeleton.json` is Spine 4.3 skeleton data — it loads in a Spine
538
+ runtime and it imports into the Spine editor.
539
+
540
+ **Try breaking it**, because the validator's messages are the interface here and
541
+ they are worth meeting once on purpose. Rename `images/hull.png` to
542
+ `images/raft.png`, point the spec's `image` at the new name, and build again:
543
+
544
+ ```
545
+ FAIL A08_REGION_NAMES_MATCH_ATTACHMENTS: attachment "hull" resolves to region "raft"; v0 requires them identical
546
+ rigc: 1 assertion(s) failed — nothing written
547
+ ```
548
+
549
+ Nothing was written. A red run leaves no half-built artifact on disk to mistake
550
+ for a result, and there is no flag that changes that.
551
+
552
+ **Where to go next.**
553
+
554
+ - 📘 **[docs/AUTHORING.md](docs/AUTHORING.md)** is the real guide — both files
555
+ field by field, the emission rules, every named failure mapped to the file that
556
+ has to change, and §8–§9 for reproducing a shot you were given as pictures. It
557
+ ships inside the npm package too, at
558
+ `node_modules/spine-rigc/docs/AUTHORING.md`.
559
+ - `rigc explain --rig buoy.rig.json --motion buoy.motion.json --out spine` prints
560
+ the compiled rig as a table — every bone with its resolved parent, the slots in
561
+ draw order, every timeline key by key — and writes nothing. It is what to reach
562
+ for when a rig compiles and still looks wrong.
563
+ - 🚨 **A green gate does not mean the animation is right**, and no assertion
564
+ could. If you have reference pictures of the shot,
565
+ `rigc check --candidate spine --frames <dir>` is the half of the loop that can
566
+ see a wrong animation — AUTHORING.md §9.
567
+ - [docs/LADDER.md](docs/LADDER.md) is the benchmark: the same job, from a brief
568
+ and rendered frames, scored. [docs/PILOT.md](docs/PILOT.md) is how to run an
569
+ agent through it and score what comes back.
570
+
350
571
  ## Usage
351
572
 
352
573
  📘 **Writing a spec? Read [docs/AUTHORING.md](docs/AUTHORING.md) first.** It is the
@@ -508,7 +729,7 @@ fixtures/ public.ts — the three synthetic cuts the selftest breaks
508
729
  src/
509
730
  compile.ts rig + motion spec (+ manifest) -> skeleton JSON + atlas text (pure data assembly)
510
731
  rig.ts the rig spec — `spec: "rigc-rig/1"`, the skeleton as data
511
- validate.ts spine-core round trip + the 32 assertions
732
+ validate.ts spine-core round trip + the 34 assertions
512
733
  diff.ts structural comparison of two skeletons, one ratio per measure
513
734
  render.ts the rasteriser (regions + meshes), shared by the reference renderer and check
514
735
  check.ts a candidate against rendered frames — pixels and per-slot drift,
@@ -530,6 +751,10 @@ bench/ count_features.ts — what the example corpus actually uses
530
751
  runs/ — one directory per attempt, and the run protocol
531
752
  transcriptions/ — rung specs transcribed from a reference export,
532
753
  which measure expressiveness and NOT authoring (see LADDER.md)
754
+ viewer/ the run viewer — dev server only, no build (see above)
755
+ vite.config.ts /api/inventory and /repo/<path>, and the build refusal
756
+ inventory.ts what is under bench/runs, resolved to URLs
757
+ main.ts the two panes, the transport, the report
533
758
  docs/ AUTHORING.md (how to author a rig), LADDER.md (live rung status),
534
759
  SPEC_COVERAGE.md (format survey),
535
760
  feature_matrix.{csv,json}
package/cli.ts CHANGED
@@ -358,8 +358,14 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
358
358
  *
359
359
  * There is no pass mark, for the same reason `diff` has none.
360
360
  */
361
- function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps' | 'viewport' | 'as'> {
362
- const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as'> = {};
361
+ function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing'> {
362
+ const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing'> = {};
363
+ if (flags.framing !== undefined) {
364
+ if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
365
+ throw new UsageError('--framing takes per-shot (the default) or shared');
366
+ }
367
+ out.framing = flags.framing;
368
+ }
363
369
  if (flags.fps !== undefined) {
364
370
  const fps = Number(flags.fps);
365
371
  if (!Number.isFinite(fps) || fps <= 0) throw new UsageError('--fps must be a positive number');
@@ -503,6 +509,13 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
503
509
  // summary that reported those numbers without saying how the two shots were
504
510
  // put on each other is how issue #34 stayed invisible for two ladder runs.
505
511
  const framing = check.framingFit;
512
+ if (!framing && check.sharedFraming) {
513
+ const f = check.sharedFraming.fit;
514
+ console.log(
515
+ ` framing one per set (${check.animations.length}); one shared box leaves ` +
516
+ `x${f.scale.toFixed(6)}, rms ${f.rms.toFixed(2)}px — see the check table above for each set's own`,
517
+ );
518
+ }
506
519
  if (framing) {
507
520
  const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
508
521
  const how = !framing.applied
@@ -528,9 +541,19 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
528
541
  anim.changeDisagreements === 0
529
542
  ? ''
530
543
  : `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
544
+ // Which of the candidate's own bone chains the error is in — one name, so a
545
+ // loop between builds reads a unit to fix rather than a verdict on the shot.
546
+ // The full table is in `check`'s own report; this is its headline.
547
+ const worstChain = [...anim.chains].sort((a, b) => b.maeShare - a.maeShare)[0];
548
+ const chain =
549
+ worstChain === undefined ? '' : `, ${worstChain.chain} carries ${(worstChain.maeShare * 100).toFixed(0)}%`;
550
+ // `ref=` is the same difference over the reference's own drawn pixels. It is
551
+ // carried here and not only in `check`'s own table because this is the line a
552
+ // loop reads between builds, and `mean=` has a denominator the candidate can
553
+ // grow — see `FrameCheck.maeReference`.
531
554
  console.log(
532
- ` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
533
- `over ${anim.compared} frame(s) ${drift}${change}`,
555
+ ` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
556
+ `ref=${anim.meanMaeReference.toFixed(2)} over ${anim.compared} frame(s) ${drift}${change}${chain}`,
534
557
  );
535
558
  }
536
559
  } else {
@@ -541,10 +564,17 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
541
564
  console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
542
565
 
543
566
  if (flags.json !== undefined) {
567
+ // No `gates` field, deliberately. The rung's gate string names its features
568
+ // and its per-skeleton counts, which `bench/runs/README.md` forbids a run
569
+ // from reading — and this report is one of the six files the run protocol
570
+ // requires committing, so a copy of it here would sit inside every future
571
+ // run's directory, which is exactly where the next author looks for process
572
+ // notes. `rung` identifies the rung and carries nothing (issue #137). The
573
+ // console block above still prints the gate string: that is for the person
574
+ // reading the run, not a file the protocol commits.
544
575
  writeJson(flags.json, {
545
576
  rung: rung.id,
546
577
  example: rung.example,
547
- gates: rung.gates,
548
578
  profile,
549
579
  candidate: { skeleton: skeletonPath, atlas: atlasPath },
550
580
  validate: report,
@@ -700,6 +730,10 @@ const USAGE = [
700
730
  ' --atlas <path> the candidate\'s atlas, when it is not beside the skeleton',
701
731
  ' --fps <n> only for a frame set with no frames.json sidecar',
702
732
  ' --viewport x,y,w,h pin the candidate\'s world box, y up, instead of fitting it',
733
+ ' --framing per-shot|shared decide the framing per frame set (default), or once',
734
+ ' across all of them. Per set, a set whose own pixels land in',
735
+ ' frames.json\'s box is measured there; on a multi-shot root that',
736
+ ' is worth 15-25 MAE against one shared fit for every set',
703
737
  ' --as <name> the candidate animation to play, when it is named differently',
704
738
  ' --all-frames print every frame, not just the worst by MAE',
705
739
  ' --json <out> the whole per-frame, per-slot report',