spine-rigc 1.0.2 → 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/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) {
@@ -0,0 +1,293 @@
1
+ /**
2
+ * rigc check --out — the picture each of `check`'s numbers came from.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `check` renders the candidate onto the reference frames' own grid, subtracts,
7
+ * and prints the result as numbers. A number says *how much*; only a picture says
8
+ * *where*. An agent porting a sample drew the same face composite twice at a
9
+ * 20 px offset — a ghost hairline — with a green gate and a contact sheet of
10
+ * ~100 px tiles in hand, and did not see it (issue #834). The comparison that
11
+ * would have shown it was already computed and thrown away.
12
+ *
13
+ * So for every frame the report lists under *the frames worth reading* (every
14
+ * compared frame under `--all-frames`), this composes one image, left to right:
15
+ *
16
+ * 1. **reference** — the frame as read from `--frames`;
17
+ * 2. **candidate** — the candidate exactly as `check` rendered it onto that grid;
18
+ * 3. **difference** — per pixel, the largest of the three channel differences
19
+ * `|candidate − reference|`, 0..255 on a fixed grey ramp, over the pixels the
20
+ * MAE averages over (the union alpha) and transparent everywhere else;
21
+ * 4. **overlay** — the two at 50 % each, so a displaced edge reads as a double line.
22
+ *
23
+ * Each pane is at the grid's **native size**. An upscaled difference is a
24
+ * difference the instrument invented: a resampling filter moves ink into pixels
25
+ * the comparison never measured. The picture is only ever the comparison.
26
+ *
27
+ * ## 🔒 Nothing here is a new number
28
+ *
29
+ * Every figure burned into a picture is read off the `CheckReport` the table was
30
+ * printed from, at the precision the table prints it — the difference pane's MAE,
31
+ * the per-side change counts, the union — and the per-slot rows are the frame's
32
+ * own `slots`. The rasters are the ones the figures were computed on, kept by
33
+ * `CheckPlates` at the moment they were compared, not re-rendered afterwards: a
34
+ * second render that happened to agree would be a claim, and this is a record.
35
+ *
36
+ * ⚠️ Two things a picture cannot say, and it says neither: which side is right,
37
+ * and anything about a frame it does not show.
38
+ */
39
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+ import {
42
+ COMPARISON_FIELD,
43
+ framesToList,
44
+ type AnimationCheck,
45
+ type CheckPlates,
46
+ type CheckReport,
47
+ type ComparedPlates,
48
+ type Framing,
49
+ type FrameCheck,
50
+ } from './check.ts';
51
+ import { FRAMES_SIDECAR, FRAMES_SPEC } from './render.ts';
52
+ import { isContent } from './framing.ts';
53
+ import { isAttributable } from './slots.ts';
54
+ import { encodePng, Plate, type RGBA } from '../tools/plate.ts';
55
+ import { GLYPH_H, textWidth } from '../tools/font5x7.ts';
56
+
57
+ /** The four panes, left to right, by the names their labels carry. */
58
+ export const PANES = ['reference', 'candidate', 'difference', 'overlay'] as const;
59
+ export type Pane = (typeof PANES)[number];
60
+
61
+ /** Transparent pixels between two panes, and between the panes and the slot rows. */
62
+ export const PANE_GAP = 4;
63
+ /** Pixels of strip around a line of text, above and below. */
64
+ const TEXT_PAD = 2;
65
+ /** One line of 5x7 text with its padding: the label strip, and each slot row. */
66
+ export const TEXT_ROW = GLYPH_H + 2 * TEXT_PAD;
67
+ /** The strips' own colour, and the text's — opaque, so the labels read on any viewer background. */
68
+ const STRIP: RGBA = [0, 0, 0, 255];
69
+ const INK: RGBA = [255, 255, 255, 255];
70
+
71
+ const f2 = (n: number): string => n.toFixed(2);
72
+ const frameName = (index: number): string => `f${String(index).padStart(4, '0')}`;
73
+
74
+ /** Where one pane sits in a picture, in picture pixels. */
75
+ export interface PaneRect {
76
+ pane: Pane;
77
+ x: number;
78
+ y: number;
79
+ width: number;
80
+ height: number;
81
+ }
82
+
83
+ /**
84
+ * The geometry of one picture — exported so a reader can find a pane without
85
+ * knowing how the picture was drawn, which is what the selftest does.
86
+ *
87
+ * A column is as wide as its pane, or as its label when the label is wider: the
88
+ * PANE stays at native size either way, and a label clipped to fit a small grid
89
+ * would drop the one figure it exists to carry.
90
+ */
91
+ export function pictureLayout(
92
+ gridWidth: number,
93
+ gridHeight: number,
94
+ labels: readonly string[],
95
+ rows: readonly string[],
96
+ ): { width: number; height: number; panes: PaneRect[]; rowsTop: number } {
97
+ const columns = labels.map((label) => Math.max(gridWidth, textWidth(label, 1) + 2 * TEXT_PAD));
98
+ const panes: PaneRect[] = [];
99
+ let x = 0;
100
+ PANES.forEach((pane, i) => {
101
+ panes.push({ pane, x, y: TEXT_ROW, width: gridWidth, height: gridHeight });
102
+ x += columns[i] + (i < PANES.length - 1 ? PANE_GAP : 0);
103
+ });
104
+ const widestRow = rows.reduce((w, row) => Math.max(w, textWidth(row, 1) + 2 * TEXT_PAD), 0);
105
+ const rowsTop = TEXT_ROW + gridHeight + PANE_GAP;
106
+ return { width: Math.max(x, widestRow), height: rowsTop + rows.length * TEXT_ROW, panes, rowsTop };
107
+ }
108
+
109
+ /** The label above each pane: the frame, the pane, and the pane's own figure from the table. */
110
+ export function paneLabels(frame: FrameCheck): string[] {
111
+ const at = frameName(frame.index);
112
+ const moved = (n: number | undefined): string => (n === undefined ? '-' : String(n));
113
+ return [
114
+ // The table's `ref Δ` and `Δpx` columns: how far each side moved since its own previous frame.
115
+ `${at} reference ref d ${moved(frame.change?.reference)}`,
116
+ `${at} candidate dpx ${moved(frame.change?.candidate)}`,
117
+ `${at} difference mae ${f2(frame.mae)}`,
118
+ `${at} overlay union ${frame.unionPixels}`,
119
+ ];
120
+ }
121
+
122
+ /**
123
+ * One row per slot in the frame's own `slots`, in the order the report holds them.
124
+ *
125
+ * The drift is printed at the table's precision, and a slot with no attributable
126
+ * drift says which of the two reasons it is rather than printing a number — the
127
+ * rule `src/slots.ts` owns for every figure beside a part's name. The frame's
128
+ * worst slot, the one the table's row names, is marked `>`.
129
+ */
130
+ export function slotRows(frame: FrameCheck): string[] {
131
+ return frame.slots.map((track) => {
132
+ const mark = track.slot === frame.worstSlot ? '>' : ' ';
133
+ if (!isAttributable(track)) {
134
+ return `${mark} ${track.slot} - ${track.candidate === null ? 'not drawn' : 'no attributable drift'}`;
135
+ }
136
+ const how = track.method === 'template' ? `tmpl ${(track.confidence ?? 0).toFixed(2)}` : 'component';
137
+ return `${mark} ${track.slot} ${(track.drift as number).toFixed(1)} px ${how}`;
138
+ });
139
+ }
140
+
141
+ /** Compose one frame's picture from the rasters its figures were computed on. */
142
+ export function composePicture(frame: FrameCheck, plates: ComparedPlates, background: RGBA): Plate {
143
+ const { reference, candidate, coverage } = plates;
144
+ const w = reference.width;
145
+ const h = reference.height;
146
+ const labels = paneLabels(frame);
147
+ const rows = slotRows(frame);
148
+ const layout = pictureLayout(w, h, labels, rows);
149
+ const out = new Plate(layout.width, layout.height);
150
+
151
+ layout.panes.forEach((rect, i) => {
152
+ out.rect(rect.x, 0, Math.max(rect.width, textWidth(labels[i], 1) + 2 * TEXT_PAD), TEXT_ROW, STRIP);
153
+ out.text(labels[i], rect.x + TEXT_PAD, TEXT_PAD, 1, INK);
154
+ });
155
+ const [ref, cand, diff, over] = layout.panes;
156
+ for (let y = 0; y < h; y++) {
157
+ for (let x = 0; x < w; x++) {
158
+ const a = candidate.get(x, y);
159
+ const b = reference.get(x, y);
160
+ out.set(ref.x + x, ref.y + y, b);
161
+ out.set(cand.x + x, cand.y + y, a);
162
+ // The union `checkOneFrame` averages over: what the candidate's geometry
163
+ // covers, or what the reference drew — the same predicate, not a lookalike.
164
+ if (coverage[y * w + x] === 1 || isContent(reference, x, y, background)) {
165
+ const v = Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
166
+ out.set(diff.x + x, diff.y + y, [v, v, v, 255]);
167
+ }
168
+ out.set(over.x + x, over.y + y, [
169
+ Math.round((a[0] + b[0]) / 2),
170
+ Math.round((a[1] + b[1]) / 2),
171
+ Math.round((a[2] + b[2]) / 2),
172
+ Math.round((a[3] + b[3]) / 2),
173
+ ]);
174
+ }
175
+ }
176
+ rows.forEach((row, i) => {
177
+ const top = layout.rowsTop + i * TEXT_ROW;
178
+ out.rect(0, top, layout.width, TEXT_ROW, STRIP);
179
+ out.text(row, TEXT_PAD, top + TEXT_PAD, 1, INK);
180
+ });
181
+ return out;
182
+ }
183
+
184
+ /** What `--out` wrote for one frame set. */
185
+ export interface PicturesWritten {
186
+ /** The set's directory, as the report names it. */
187
+ dir: string;
188
+ /** Absolute path of the directory the pictures went into. */
189
+ path: string;
190
+ /** The frame indices written, in index order. */
191
+ frames: number[];
192
+ /** Whether that is every compared frame rather than the frames worth reading. */
193
+ every: boolean;
194
+ }
195
+
196
+ /**
197
+ * Write the pictures for a report into `outDir`, and `frames.json` beside them.
198
+ *
199
+ * `<outDir>/<set>/` is cleared and rewritten for every set the report holds, as
200
+ * `render` clears its own: a set that lists fewer frames this run would otherwise
201
+ * keep last run's pictures beside this run's, and a stale picture of a comparison
202
+ * is indistinguishable from a current one. Nothing else under `outDir` is touched.
203
+ *
204
+ * ⚠️ Throws, naming the frame, when `plates` does not hold a frame the report
205
+ * lists. That is not a reachable input: it is `CheckPlates`'s retention and
206
+ * `framesToList` disagreeing about which frames are worth reading, and a picture
207
+ * set with a hole in it would be the silent version of that disagreement.
208
+ */
209
+ export function writeCheckPictures(
210
+ outDir: string,
211
+ report: CheckReport,
212
+ plates: CheckPlates,
213
+ opts: { allFrames: boolean },
214
+ ): PicturesWritten[] {
215
+ mkdirSync(outDir, { recursive: true });
216
+ const written: PicturesWritten[] = [];
217
+ for (const anim of report.animations) {
218
+ const dir = join(outDir, anim.dir);
219
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
220
+ const listed = anim.compared === 0 ? [] : framesToList(anim, opts.allFrames);
221
+ if (listed.length > 0) mkdirSync(dir, { recursive: true });
222
+ for (const frame of listed) {
223
+ const held = plates.of(anim.dir, frame.index);
224
+ if (held === undefined) {
225
+ throw new Error(
226
+ `check --out: set ${JSON.stringify(anim.dir)} lists ${frameName(frame.index)} as worth reading and no ` +
227
+ 'raster was kept for it — CheckPlates and framesToList disagree about the listing (a defect in rigc)',
228
+ );
229
+ }
230
+ const picture = composePicture(frame, held, report.background);
231
+ writeFileSync(join(dir, `${frameName(frame.index)}.png`), encodePng(picture.width, picture.height, picture.data));
232
+ }
233
+ written.push({
234
+ dir: anim.dir,
235
+ path: dir,
236
+ frames: listed.map((f) => f.index),
237
+ every: listed.length === anim.frames.length,
238
+ });
239
+ }
240
+ writeFileSync(join(outDir, FRAMES_SIDECAR), `${JSON.stringify(picturesSidecar(report, written, opts.allFrames), null, 2)}\n`);
241
+ return written;
242
+ }
243
+
244
+ /**
245
+ * What the pictures are OF — the file a reader opens first, under the name a
246
+ * frame set's sidecar has, so "what is this directory" is answered in the place
247
+ * it is always answered.
248
+ *
249
+ * 🔒 The field named by `COMPARISON_FIELD` is what makes it not a frame set, and
250
+ * `check --frames` refuses a sidecar that carries it, by that name: the reference
251
+ * pane inside each picture is a frame, but the file is a comparison, and scoring
252
+ * a candidate against a picture of a comparison is a number about the wrong
253
+ * thing. There is deliberately no top-level `viewport`: under the per-shot scope
254
+ * every set was measured in its own box, so the box is recorded per set, and one
255
+ * box at the top would be a claim the run did not make.
256
+ */
257
+ function picturesSidecar(report: CheckReport, written: PicturesWritten[], allFrames: boolean): Record<string, unknown> {
258
+ return {
259
+ spec: FRAMES_SPEC,
260
+ [COMPARISON_FIELD]: {
261
+ candidate: report.candidate,
262
+ frames: report.framesDir,
263
+ skin: report.skin,
264
+ referenceSkin: report.referenceSkin,
265
+ framingScope: report.framingScope,
266
+ grid: gridOf(report),
267
+ panes: [...PANES],
268
+ listing: allFrames ? 'every compared frame (--all-frames)' : 'the frames worth reading',
269
+ textureFrom: report.textureFrom === null ? null : report.textureFrom.atlas,
270
+ },
271
+ background: report.background,
272
+ sets: report.animations.map((anim: AnimationCheck, i) => {
273
+ const viewport: Framing = anim.viewport;
274
+ return {
275
+ dir: anim.dir,
276
+ animation: anim.animation,
277
+ candidateAnimation: anim.candidateAnimation,
278
+ fps: anim.fps,
279
+ compared: anim.compared,
280
+ written: written[i].frames.length,
281
+ frames: written[i].frames,
282
+ framing: anim.framing,
283
+ viewport,
284
+ };
285
+ }),
286
+ };
287
+ }
288
+
289
+ /** The comparison grid — the reference frames' own pixel size, which every set shares. */
290
+ function gridOf(report: CheckReport): { pixelWidth: number; pixelHeight: number } | null {
291
+ const v = report.referenceViewport ?? report.animations[0]?.viewport ?? null;
292
+ return v === null ? null : { pixelWidth: v.pixelWidth, pixelHeight: v.pixelHeight };
293
+ }