spine-rigc 0.2.1

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 ADDED
@@ -0,0 +1,1714 @@
1
+ /**
2
+ * rigc check — measure a candidate against reference **frames**, never against
3
+ * the reference file.
4
+ *
5
+ * ## The hole this closes
6
+ *
7
+ * The gate is a *validity* gate. It parses the skeleton, steps every animation,
8
+ * and refuses anything degenerate — and it has no opinion whatever about whether
9
+ * the animation is the one in the frames. Three honest ladder runs produced zero
10
+ * validator FAILs between them, and one of those runs shipped a build in which
11
+ * **every easing in the file was reversed** and came back green. Both authors
12
+ * closed the loop the same way, with a script they wrote themselves: pose the
13
+ * candidate with `spine-core`, and compare it against what they had measured off
14
+ * the pictures. This is that script, promoted, so the loop is
15
+ *
16
+ * build → validate → check against frames → fix
17
+ *
18
+ * and the last step is a command rather than something each author reinvents.
19
+ *
20
+ * ## 🔒 The invariant: this never reads the answer
21
+ *
22
+ * `check` opens exactly two things — **the candidate** (its skeleton, its atlas
23
+ * and the atlas pages) and **PNG frames** under `--frames`, plus the
24
+ * `frames.json` sidecar beside them. It has no code path that names
25
+ * `examples/`, an `export/` directory, a rung or a reference skeleton, and every
26
+ * read on the reference side goes through `readFrameFile`, which refuses a path
27
+ * that escapes `--frames` or that is neither a `.png` nor the sidecar.
28
+ *
29
+ * That is not fastidiousness. `docs/LADDER.md`'s honesty rule is the only thing
30
+ * that makes a rung's number mean anything, and a fidelity tool that quietly
31
+ * loaded the reference JSON would convert every future run from authoring into
32
+ * transcription without anybody noticing — the exact failure that is hardest to
33
+ * detect after the fact.
34
+ *
35
+ * ## What it measures
36
+ *
37
+ * Per animation, per frame:
38
+ *
39
+ * - **The framing** — where the candidate's drawn pixels sit against the
40
+ * reference's drawn pixels, as a scale ratio and a residual. It is reported
41
+ * first because it is upstream of everything else: get it wrong and every
42
+ * number below carries the error, disguised as motion (issue #34).
43
+ * - **MAE over the union alpha** — the mean absolute RGB difference between the
44
+ * candidate composited over the frames' background and the reference frame,
45
+ * averaged over the pixels either side covers. Over the union rather than the
46
+ * whole frame because most of a frame is background on both sides, and
47
+ * averaging that in makes every number small and every difference between
48
+ * numbers smaller.
49
+ * - **Per-frame change** — how much each side moved since **its own** previous
50
+ * frame, and whether those two agree. The only measure here that looks at a
51
+ * relation between two frames rather than at one, and the only one that can see
52
+ * a held pose that is not held or a one-frame event that never fired — see
53
+ * `FrameChange`.
54
+ * - **Per-slot tracking** — where each of the candidate's own slots landed
55
+ * against the reference frame. This is the part an author acts on: MAE says
56
+ * *how wrong*, a slot's drift says *which part, which way, how far*.
57
+ *
58
+ * ⚠️ Both of the last two are bounded by what a picture can attribute, and
59
+ * `src/slots.ts` owns that judgement: a slot the reference merged into a
60
+ * neighbour is template-matched against its own pixels rather than guessed at,
61
+ * and a slot nothing in its search radius matches comes back as **no match**
62
+ * rather than as a number. A drift printed beside the wrong part is worse than a
63
+ * blank, because it is actionable and wrong.
64
+ */
65
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
66
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
67
+ import {
68
+ BACKGROUND,
69
+ frameGeometry,
70
+ PROTOCOL_FPS,
71
+ posableFromText,
72
+ renderFrame,
73
+ sampleAnimation,
74
+ sampleSetupPose,
75
+ trimmedUnionBounds,
76
+ viewportOfSize,
77
+ PAD,
78
+ FRAMES_SIDECAR,
79
+ FRAMES_SPEC,
80
+ type Frame,
81
+ type FramesSidecar,
82
+ type FrameSet,
83
+ type Viewport,
84
+ } from './render.ts';
85
+ import {
86
+ applyFit,
87
+ boxHeight,
88
+ boxWidth,
89
+ contentBoxOfPlate,
90
+ ContrastHistogram,
91
+ fitDistance,
92
+ fitFraming,
93
+ fitIsSettled,
94
+ fitSeparation,
95
+ frameContentBox,
96
+ isContent,
97
+ BACKGROUND_TOLERANCE,
98
+ CYCLE_PIXELS,
99
+ type BoxPair,
100
+ type ContentBox,
101
+ type FramingFit,
102
+ } from './framing.ts';
103
+ import { componentsOf, isAttributable, matchSlots, type SlotTrack } from './slots.ts';
104
+ import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
105
+
106
+ export { componentsOf, matchSlots, searchRadius, type Component, type MatchMethod, type SlotTrack } from './slots.ts';
107
+ export type { BoxPair, ContentBox, FramingFit } from './framing.ts';
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // the reference side — frames only, and mechanically so
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * The honesty invariant, as a function: this path is a frame under `--frames`.
115
+ *
116
+ * Every reference-side read in this module goes through it, which is what makes
117
+ * "`check` reads only PNG frames" a property of the code rather than a claim in
118
+ * a comment — a path that climbs out of the frames directory, or that is neither
119
+ * a PNG nor the sidecar, throws with both paths named. It is exported so the
120
+ * selftest can make it fire: an invariant nobody has seen refuse anything is not
121
+ * an invariant.
122
+ */
123
+ export function assertFrameReadable(framesRoot: string, path: string): void {
124
+ const abs = resolve(path);
125
+ const inside = relative(resolve(framesRoot), abs);
126
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
127
+ throw new CheckError(`${abs} is outside --frames ${resolve(framesRoot)}; check reads frames and nothing else`);
128
+ }
129
+ const name = basename(abs);
130
+ if (!name.endsWith('.png') && name !== FRAMES_SIDECAR) {
131
+ throw new CheckError(
132
+ `${abs} is neither a .png frame nor ${FRAMES_SIDECAR}; check never reads a reference skeleton — see src/check.ts`,
133
+ );
134
+ }
135
+ }
136
+
137
+ function readFrameFile(framesRoot: string, path: string): Buffer {
138
+ assertFrameReadable(framesRoot, path);
139
+ return readFileSync(resolve(path));
140
+ }
141
+
142
+ export class CheckError extends Error {}
143
+
144
+ /** Where a frames directory's sidecar is, and which of its sets `--frames` selected. */
145
+ interface Located {
146
+ /** The skeleton root — where `frames.json` sits. */
147
+ root: string;
148
+ sidecar: FramesSidecar | null;
149
+ /** Set directories to compare; empty means "every set in the sidecar". */
150
+ only: string[];
151
+ }
152
+
153
+ /**
154
+ * Resolve `--frames <dir>`: either a skeleton root holding the sidecar, or one
155
+ * animation directory inside one.
156
+ */
157
+ export function locateFrames(framesDir: string): Located {
158
+ const dir = resolve(framesDir);
159
+ if (!existsSync(dir)) throw new CheckError(`no frames directory at ${dir}`);
160
+ if (existsSync(join(dir, FRAMES_SIDECAR))) {
161
+ return { root: dir, sidecar: readSidecar(dir), only: [] };
162
+ }
163
+ const parent = dirname(dir);
164
+ if (existsSync(join(parent, FRAMES_SIDECAR))) {
165
+ const sidecar = readSidecar(parent);
166
+ const name = basename(dir);
167
+ if (sidecar && sidecar.sets.some((s) => s.dir === name)) {
168
+ return { root: parent, sidecar, only: [name] };
169
+ }
170
+ }
171
+ return { root: dir, sidecar: null, only: [] };
172
+ }
173
+
174
+ function readSidecar(root: string): FramesSidecar | null {
175
+ const raw = readFrameFile(root, join(root, FRAMES_SIDECAR)).toString('utf8');
176
+ const parsed: unknown = JSON.parse(raw);
177
+ if (typeof parsed !== 'object' || parsed === null) return null;
178
+ const sidecar = parsed as FramesSidecar;
179
+ if (sidecar.spec !== FRAMES_SPEC) {
180
+ throw new CheckError(
181
+ `${join(root, FRAMES_SIDECAR)} declares spec ${JSON.stringify(sidecar.spec)}; this build reads ${FRAMES_SPEC}`,
182
+ );
183
+ }
184
+ return sidecar;
185
+ }
186
+
187
+ /** The `f0000.png` frames in a directory, by index, in index order. */
188
+ function framesOnDisk(root: string, dir: string): Array<{ index: number; file: string }> {
189
+ const abs = join(root, dir);
190
+ if (!existsSync(abs)) throw new CheckError(`no frame directory at ${abs}`);
191
+ const out: Array<{ index: number; file: string }> = [];
192
+ for (const name of readdirSync(abs)) {
193
+ const m = /^f(\d+)\.png$/.exec(name);
194
+ if (!m) continue;
195
+ out.push({ index: Number(m[1]), file: join(abs, name) });
196
+ }
197
+ out.sort((a, b) => a.index - b.index);
198
+ return out;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // the measures
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * How much a frame moved since the frame before it — on **each side separately**.
207
+ *
208
+ * ## Why this is not the MAE again
209
+ *
210
+ * Every other measure here compares the candidate against the reference *at one
211
+ * moment*. This one compares each side against **itself** a frame earlier, and
212
+ * then compares those two numbers. What that catches is a class of defect the
213
+ * aggregate MAE is structurally blind to, because it is small in every single
214
+ * frame and wrong in the relationship between them:
215
+ *
216
+ * - **A held pose that is not held.** Rung 6's reference is pixel-identical across
217
+ * f64–f67. A greedy key reduction had sloped a line through that plateau —
218
+ * legal under its own per-key tolerance, invisible to `validate`, invisible to
219
+ * `diff`, and worth so little MAE per frame that nothing flagged it. Re-rendering
220
+ * the candidate and diffing **its own** f67 against **its own** f68 showed 91 px
221
+ * moving where the reference moves 3.
222
+ * - **A one-frame event that never fires.** The same run's tracker reveal landed a
223
+ * fraction of a millisecond past the animation's last sample, so it never
224
+ * happened. `diff` read `animations.deform` and `draw_order` as matching, the
225
+ * gate was green, and only looking at the last two frames found it.
226
+ *
227
+ * Both were found by that run building its own render-diff outside the tool
228
+ * (`bench/runs/2026-08-23-rung6-1/LOOP.md` §10, issue #53). `check` has both frame
229
+ * sequences in hand already, so it is the tool's job and not the author's.
230
+ *
231
+ * ⚠️ Only between **adjacent** frames. A set that commits stills rather than every
232
+ * frame — rung 2's contact sheets ship `f0000` and `f0310` — has no frame-to-frame
233
+ * delta to report, and the difference between two frames 310 apart is not one. That
234
+ * is `null` here rather than a number about the wrong thing.
235
+ */
236
+ export interface FrameChange {
237
+ /** The frame this one is measured against; always `index - 1`. */
238
+ previous: number;
239
+ /** Pixels the candidate moved since its own previous frame. */
240
+ candidate: number;
241
+ /** Pixels the reference moved since its own previous frame. */
242
+ reference: number;
243
+ /** The same two as a mean absolute RGB difference over the whole frame, 0..255. */
244
+ candidateMae: number;
245
+ referenceMae: number;
246
+ /**
247
+ * How the two compare — see `CHANGE_RATIO`.
248
+ *
249
+ * `moves` means the candidate changed materially more than the reference did
250
+ * here, `holds` materially less. Both are diagnoses the per-frame MAE cannot
251
+ * give: a frame that is merely off by a constant offset agrees on this measure.
252
+ */
253
+ verdict: 'agrees' | 'moves' | 'holds';
254
+ }
255
+
256
+ export interface FrameCheck {
257
+ index: number;
258
+ /** The reference PNG, so a worst-frame line is directly openable. */
259
+ file: string;
260
+ /** Mean absolute RGB difference over the union alpha, 0..255. */
261
+ mae: number;
262
+ /**
263
+ * The same difference averaged over the WHOLE frame, background included.
264
+ *
265
+ * Reported beside `mae` and never instead of it. Most of a frame is background
266
+ * on both sides, so this number is small for every candidate and the gap
267
+ * between a good one and a bad one is smaller still — but it is the number an
268
+ * ad-hoc re-render check naturally computes, so a run comparing itself against
269
+ * an older log needs it to be able to.
270
+ */
271
+ maeFrame: number;
272
+ unionPixels: number;
273
+ candidatePixels: number;
274
+ referencePixels: number;
275
+ components: number;
276
+ /** Components no slot reached — something in the shot the candidate has not drawn. */
277
+ unmatchedComponents: number;
278
+ worstSlot: string | null;
279
+ worstDrift: number | null;
280
+ /** How many slots got an attributable drift, out of how many drew anything. */
281
+ attributed: number;
282
+ drawn: number;
283
+ slots: SlotTrack[];
284
+ /** This frame against the one before it, on each side — `null` unless adjacent. */
285
+ change: FrameChange | null;
286
+ }
287
+
288
+ export interface AnimationCheck {
289
+ dir: string;
290
+ /** The animation the frames show, per the sidecar. */
291
+ animation: string | null;
292
+ /** The candidate animation played against it. */
293
+ candidateAnimation: string | null;
294
+ fps: number;
295
+ referenceFrames: number;
296
+ candidateFrames: number;
297
+ compared: number;
298
+ meanMae: number;
299
+ /** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
300
+ meanMaeFrame: number;
301
+ worstMae: number;
302
+ worstMaeFrame: number;
303
+ worstDrift: number;
304
+ worstDriftFrame: number;
305
+ worstDriftSlot: string | null;
306
+ /** Frames in which no slot at all could be attributed — the drift's denominator. */
307
+ framesWithoutDrift: number;
308
+ /** Adjacent frame pairs a frame-to-frame change could be measured across. */
309
+ changePairs: number;
310
+ /** How many of those the candidate's own change disagrees with the reference's. */
311
+ changeDisagreements: number;
312
+ /** The widest of those disagreements, and `-1` when there is none. */
313
+ worstChangeFrame: number;
314
+ frames: FrameCheck[];
315
+ notes: string[];
316
+ }
317
+
318
+ /** A world box and the pixel grid it was drawn into. */
319
+ export interface Framing {
320
+ x: number;
321
+ y: number;
322
+ width: number;
323
+ height: number;
324
+ /** Frame pixels per world unit. */
325
+ scale: number;
326
+ pixelWidth: number;
327
+ pixelHeight: number;
328
+ }
329
+
330
+ /**
331
+ * How the world box the candidate was rendered into was arrived at.
332
+ *
333
+ * - `derived` — fitted to the candidate's own drawn pixels, from a start taken
334
+ * from its posed geometry. The default, and the only option without a sidecar.
335
+ * - `declared` — the box `frames.json` records, reached from that same box and
336
+ * kept because the candidate's own pixels land on the reference's in it. See
337
+ * `frameByDeclaredBox` for why a measured coincidence licenses it.
338
+ * - `pinned` — `--viewport`, which is a claim by the author and is not checked.
339
+ */
340
+ export type FramingSource = 'derived' | 'declared' | 'pinned';
341
+
342
+ /** What the framing pass concluded, and how sure it is of it. */
343
+ export interface FramingReport {
344
+ /** The residual fit measured at the viewport that was used. */
345
+ fit: FramingFit;
346
+ /** How many render/measure/correct passes ran. */
347
+ passes: number;
348
+ /** Did the correction converge to the identity, or was it still moving? */
349
+ settled: boolean;
350
+ /** How the box was chosen — see `FramingSource`. */
351
+ source: FramingSource;
352
+ /**
353
+ * Did the correction fall into a repeating orbit instead of converging?
354
+ *
355
+ * When it did, `settled` is false and **more passes cannot help**: the loop is
356
+ * re-measuring states it has already been in. That is a fact about the fit
357
+ * having no fixed point on this shot, not about the pass budget, and the two
358
+ * used to print the same warning.
359
+ */
360
+ cycled: boolean;
361
+ /**
362
+ * Do the two content boxes agree, at the viewport that was used?
363
+ *
364
+ * `fitDistance` under `COINCIDENT_PIXELS`. This is what separates "the loop
365
+ * fell short of its own target and the two shots are nevertheless in the same
366
+ * place" from "the loop fell short because these are different shapes" — the
367
+ * first is the tool's floor and the second is a finding.
368
+ */
369
+ agrees: boolean;
370
+ /**
371
+ * Whether the fit was APPLIED or only measured.
372
+ *
373
+ * `--viewport` pins the box, so the fit is reported and not used — which is the
374
+ * most useful thing about pinning it: it separates "my keys are wrong" from
375
+ * "my framing is wrong", and those are two different repairs.
376
+ */
377
+ applied: boolean;
378
+ /**
379
+ * The same two content boxes in **world units**, when the sidecar records the
380
+ * reference's scale.
381
+ *
382
+ * ⭐ This is the one place a difference of pure scale can show up at all. The
383
+ * framing deliberately absorbs it — a candidate is authored in its own
384
+ * coordinates, so "twice as big in world units" is a choice of units and not an
385
+ * error, and a tool that reported it as one would be reporting the thing it was
386
+ * built to be blind to. But an author who measured the shot off the frames IS
387
+ * working in the frames' units, and for them these two numbers are directly
388
+ * comparable and a 2 % disagreement is a real finding. So it is printed, with
389
+ * what it does and does not mean attached.
390
+ */
391
+ units: { candidate: Extent; reference: Extent; ratio: number } | null;
392
+ }
393
+
394
+ /** A width and a height in world units. */
395
+ export interface Extent {
396
+ width: number;
397
+ height: number;
398
+ }
399
+
400
+ export interface CheckReport {
401
+ candidate: { skeleton: string; atlas: string };
402
+ framesDir: string;
403
+ framesRoot: string;
404
+ /** How the candidate's own world box was chosen. */
405
+ framing: 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
406
+ /** The box the CANDIDATE was rendered into, at the reference's pixel size. */
407
+ viewport: Framing;
408
+ /** Where the candidate's drawn pixels ended up against the reference's. */
409
+ framingFit: FramingReport | null;
410
+ /**
411
+ * The box the REFERENCE was rendered into, when the sidecar records one.
412
+ *
413
+ * Informational: the two skeletons do not share a coordinate system, so these
414
+ * numbers are not comparable term by term. The pixel dimensions are — they are
415
+ * the same grid — and a difference between them is the diagnostic.
416
+ */
417
+ referenceViewport: Framing | null;
418
+ background: RGBA;
419
+ animations: AnimationCheck[];
420
+ notes: string[];
421
+ }
422
+
423
+ export interface CheckOptions {
424
+ skeletonText: string;
425
+ atlasText: string;
426
+ /** Where the atlas's page paths resolve from. */
427
+ atlasDir: string;
428
+ framesDir: string;
429
+ /** Labels for the report; the texts above are what is actually read. */
430
+ labels?: { skeleton: string; atlas: string };
431
+ /** Only used when there is no sidecar to take the rate from. */
432
+ fps?: number;
433
+ /**
434
+ * Pin the candidate's world box `x,y,width,height` instead of deriving it.
435
+ *
436
+ * Two uses, and the second is the one an authoring loop wants. It is the escape
437
+ * hatch when the derivation cannot work — a candidate deliberately missing a
438
+ * part has a different content box by construction, and pinning lets the rest
439
+ * of the shot still be measured. And it is the way to hold the framing FIXED
440
+ * across builds: the framing line is still reported, so a pinned run separates
441
+ * "my keys moved" from "my framing moved" without either hiding the other.
442
+ */
443
+ viewport?: { x: number; y: number; width: number; height: number };
444
+ /** Play this candidate animation against the frames, when the names differ. */
445
+ as?: string;
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /**
451
+ * Compare a candidate against a set of reference frames.
452
+ *
453
+ * ## 🧭 Why the candidate is framed by its own pixels
454
+ *
455
+ * The obvious move — render the candidate into the world box the sidecar
456
+ * records — is wrong, and wrong in a way that reads as a catastrophic failure
457
+ * rather than as a mistake: on rung 3's honest candidate it reports MAE 146/255,
458
+ * because that candidate put its origin on the pendulum's pivot and the
459
+ * reference put its own somewhere else entirely. **A candidate is authored in
460
+ * its own coordinate system, and under the ladder's honesty rule it could not be
461
+ * authored in any other** — the reference's origin is in the file the author is
462
+ * not allowed to open.
463
+ *
464
+ * So the candidate is framed by its own content. What changed (issue #34) is what
465
+ * "content" means. It used to be the union of the **posed quad corners**, and a
466
+ * region attachment's quad extends past its own artwork wherever the art is
467
+ * transparent, so an outermost corner routinely sat where no pixel was. Combined
468
+ * with a mapping that read only `minX`, `maxY` and the long side, that let one
469
+ * corner of one quad in one frame set the scale for a whole run: rung 5's first
470
+ * correct build reported **MAE 39.00 instead of 4.35** on a box 0.93 % narrow, and
471
+ * rung 4 watched a rotation the pixels cannot see move the reported MAE from 27.6
472
+ * to 84.9 by swinging one corner in and out of the box.
473
+ *
474
+ * Now both sides are measured the same way, **on drawn pixels**:
475
+ *
476
+ * 1. render the candidate at the frames' own rate and grid, and take the content
477
+ * box of what it actually draws (`src/framing.ts`);
478
+ * 2. take the reference's content box off the PNGs with the same predicate;
479
+ * 3. fit the similarity transform — uniform scale plus translation, least squares
480
+ * over **both** width and height — that carries one onto the other, and render
481
+ * through it. No single corner can set the scale, and an invisible margin
482
+ * cannot move it at all.
483
+ *
484
+ * The pass repeats until the correction is the identity, because the correction
485
+ * changes the pixels it was measured on.
486
+ *
487
+ * ⚠️ What this is still not blind to: a candidate that is missing a part, or has
488
+ * an extra one, genuinely has a different content box, and one uniform scale
489
+ * cannot make two different shapes agree. That is no longer silently spent on the
490
+ * framing — it is reported as the fit's **residual** and its aspect error, which
491
+ * is the number to read before reading a drift. `--viewport` pins the box outright
492
+ * when even that is not enough.
493
+ */
494
+ export function checkAgainstFrames(options: CheckOptions): CheckReport {
495
+ const located = locateFrames(options.framesDir);
496
+ const notes: string[] = [];
497
+
498
+ const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
499
+ let background: RGBA;
500
+ let sets: FrameSet[];
501
+ let pixelWidth: number;
502
+ let pixelHeight: number;
503
+ let referenceViewport: Framing | null = null;
504
+
505
+ if (located.sidecar) {
506
+ const s = located.sidecar;
507
+ background = s.background;
508
+ pixelWidth = s.viewport.pixelWidth;
509
+ pixelHeight = s.viewport.pixelHeight;
510
+ referenceViewport = { ...s.viewport };
511
+ sets = located.only.length > 0 ? s.sets.filter((set) => located.only.includes(set.dir)) : s.sets;
512
+ if (options.fps !== undefined && sets.some((set) => set.fps !== options.fps)) {
513
+ throw new CheckError(
514
+ `--fps ${options.fps} disagrees with ${FRAMES_SIDECAR}, which records ` +
515
+ `${[...new Set(sets.map((set) => set.fps))].join(', ')} fps. The frames' own rate is the one they were ` +
516
+ 'rendered at; drop --fps, or point --frames at the set you meant',
517
+ );
518
+ }
519
+ } else {
520
+ // No sidecar: the pixel grid comes from the frames themselves, the rate from
521
+ // --fps, and the background from this build's default — and the report says
522
+ // so rather than letting a default look like a measurement.
523
+ const dir = basename(located.root);
524
+ const parent = dirname(located.root);
525
+ const disk = framesOnDisk(parent, dir);
526
+ if (disk.length === 0) throw new CheckError(`no f####.png frames in ${located.root}`);
527
+ const first = readPlateFrom(located.root, disk[0].file);
528
+ pixelWidth = first.width;
529
+ pixelHeight = first.height;
530
+ background = BACKGROUND;
531
+ const fps = options.fps ?? PROTOCOL_FPS;
532
+ const animation = dir.replace(/@\d+(\.\d+)?fps$/, '');
533
+ sets = [
534
+ {
535
+ dir,
536
+ animation: posable.data.animations.length === 0 ? null : animation,
537
+ fps,
538
+ sampled: disk[disk.length - 1].index + 1,
539
+ written: disk.length,
540
+ stride: 1,
541
+ duration: disk[disk.length - 1].index / fps,
542
+ },
543
+ ];
544
+ notes.push(
545
+ `no ${FRAMES_SIDECAR} at ${located.root} or beside it — this frame set predates the sidecar. The rate is ` +
546
+ `--fps ${fps}${options.fps === undefined ? ' (the protocol default, not a measurement of these frames)' : ''} ` +
547
+ `and the background is this build's default (${BACKGROUND.join(', ')}). Re-render the set with ` +
548
+ 'bench/render_reference.ts and both become facts about the frames.',
549
+ );
550
+ // The set root is the animation directory itself here, so reads resolve
551
+ // against its parent the way a sidecar layout does.
552
+ located.root = parent;
553
+ located.only = [dir];
554
+ }
555
+
556
+ if (sets.length === 0) throw new CheckError(`no frame set to compare in ${options.framesDir}`);
557
+
558
+ // Pose every set once. Its frames are wanted twice — to frame the candidate and
559
+ // to compare it — and posing twice is both slower and a chance for the framing
560
+ // and the comparison to disagree about what they measured.
561
+ const prepared = sets.map((set) => prepareSet(located.root, set, posable, options.as));
562
+ const pairs = prepared.flatMap((p) => p.pairs);
563
+ if (pairs.length === 0) {
564
+ notes.push('no reference frame has a candidate frame at the same index — nothing below was measured');
565
+ }
566
+
567
+ const maxSide = Math.max(pixelWidth, pixelHeight);
568
+ // One edge level for both sides, read off the reference frames — see
569
+ // `EDGE_FRACTION`. A handful of frames is enough: the level is a property of the
570
+ // palette, not of a pose, and reading every frame twice to learn it is waste.
571
+ const level = pairs.length === 0 ? BACKGROUND_TOLERANCE : edgeLevelOf(located.root, pairs, background);
572
+ const referenceBoxes =
573
+ pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
574
+
575
+ let viewport: Viewport;
576
+ let framingFit: FramingReport | null = null;
577
+ if (options.viewport) {
578
+ const v = options.viewport;
579
+ viewport = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
580
+ notes.push(
581
+ `the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
582
+ "from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
583
+ 'framing line below is still measured, so it says what the pin cost.',
584
+ );
585
+ const boxes = pairUpBoxes(prepared, posable.pages, viewport, background, level, referenceBoxes);
586
+ if (boxes.length > 0) {
587
+ const fit = fitFraming(boxes);
588
+ framingFit = {
589
+ fit,
590
+ passes: 1,
591
+ settled: false,
592
+ source: 'pinned',
593
+ cycled: false,
594
+ agrees: fitDistance(fit) <= COINCIDENT_PIXELS,
595
+ applied: false,
596
+ units: extentsOf(fit, viewport.scale, referenceViewport),
597
+ };
598
+ }
599
+ } else {
600
+ if (referenceBoxes.every((b) => b === null)) {
601
+ throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
602
+ }
603
+ const framed = frameCandidate(
604
+ prepared,
605
+ posable.pages,
606
+ referenceBoxes,
607
+ background,
608
+ level,
609
+ pixelWidth,
610
+ pixelHeight,
611
+ referenceViewport,
612
+ );
613
+ viewport = framed.viewport;
614
+ framingFit = { ...framed.report, units: extentsOf(framed.report.fit, viewport.scale, referenceViewport) };
615
+ notes.push(...framingNotes(framed.report));
616
+ }
617
+
618
+ const animations: AnimationCheck[] = [];
619
+ for (const p of prepared) {
620
+ animations.push(checkOneSet(located.root, p, posable, viewport, background));
621
+ }
622
+
623
+ return {
624
+ candidate: {
625
+ skeleton: options.labels?.skeleton ?? '(in memory)',
626
+ atlas: options.labels?.atlas ?? '(in memory)',
627
+ },
628
+ framesDir: resolve(options.framesDir),
629
+ framesRoot: located.root,
630
+ framing: options.viewport
631
+ ? 'viewport-flag'
632
+ : framingFit?.source === 'declared'
633
+ ? 'frames-viewport'
634
+ : 'candidate-pixels',
635
+ viewport: {
636
+ x: viewport.minX,
637
+ y: viewport.minY,
638
+ width: viewport.maxX - viewport.minX,
639
+ height: viewport.maxY - viewport.minY,
640
+ scale: viewport.scale,
641
+ pixelWidth: viewport.width,
642
+ pixelHeight: viewport.height,
643
+ },
644
+ framingFit,
645
+ referenceViewport,
646
+ background,
647
+ animations,
648
+ notes,
649
+ };
650
+ }
651
+
652
+ function readPlateFrom(root: string, file: string): Plate {
653
+ readFrameFile(root, file); // the guard; readPlate does the decoding
654
+ return readPlate(file);
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // framing
659
+ // ---------------------------------------------------------------------------
660
+
661
+ /**
662
+ * How many render → measure → correct passes the framing is allowed.
663
+ *
664
+ * A faithful candidate settles on the first look. What the old ceiling of 4 read
665
+ * as "a jitter floor by the third or fourth pass" is, measured properly, an
666
+ * **orbit**: on rung 6 the correction repeats with period 4, so passes 4–7 come
667
+ * back as 8–11 and again as 12–15, to within 0.02 px. Stopping at 4 stopped
668
+ * mid-orbit, on whichever phase pass 4 happened to be — and that phase was the
669
+ * worst of the four, framed 0.063 % off the scale the frames were rendered at and
670
+ * worth **5 MAE points** on that shot (8.73 against a pinned 3.50, issue #52).
671
+ *
672
+ * One more pass reaches the orbit's closest phase and takes the same shot to 5.62;
673
+ * 6, 8, 12, 16 and 24 passes all return that same viewport, because `chosen`
674
+ * keeps the closest pass and the orbit has no better one. So the ceiling is raised
675
+ * to two full periods — enough to see every phase of an orbit this size — and
676
+ * `fitSeparation` stops the loop the moment it recognises one, which costs the
677
+ * cycling case one pass rather than four. A shot that is genuinely still
678
+ * converging is unaffected: it settles and breaks out first.
679
+ *
680
+ * ⚠️ This is not a way to reach the right framing. Nothing in an extent fit can
681
+ * be: at the box `frames.json` records — the one the frames were actually drawn
682
+ * at — rung 6's edge residual is 0.41 px rms, **worse** than the 0.23 px the orbit
683
+ * reaches, because the candidate's silhouette genuinely differs and the best fit
684
+ * of two extents is not the best alignment of two pictures (`fitFraming`, "the
685
+ * floor, stated plainly"). That is what `frameByDeclaredBox` is for.
686
+ */
687
+ const FRAMING_PASSES = 8;
688
+
689
+ /**
690
+ * How far the candidate's drawn pixels may sit from the reference's and still be
691
+ * called the same place, in pixels.
692
+ *
693
+ * One pixel, and the margin on either side of it is enormous rather than fine.
694
+ * What this threshold has to separate is a candidate authored in the frames' own
695
+ * world coordinates from one authored in its own, and those differ by an origin
696
+ * or a unit — tens to hundreds of pixels — not by a fraction of one. Rung 6's
697
+ * candidate reads 0.45 px in the declared box; rung 3's mechanical transcription
698
+ * reads 0.07; a rig scaled by 2 % reads about 5 before the scale is taken out and
699
+ * about 0.07 after. Nothing measured so far lands between 1 and 5.
700
+ */
701
+ export const COINCIDENT_PIXELS = 1;
702
+
703
+ /**
704
+ * The two content boxes in world units, each divided by its own render scale.
705
+ *
706
+ * `null` without a sidecar: the reference's scale is the only thing that makes
707
+ * its pixels into units, and a frame set that predates `frames.json` does not
708
+ * record one. Inventing a default there would print a number that looks measured.
709
+ */
710
+ function extentsOf(
711
+ fit: FramingFit,
712
+ candidateScale: number,
713
+ referenceViewport: Framing | null,
714
+ ): { candidate: Extent; reference: Extent; ratio: number } | null {
715
+ if (!referenceViewport || candidateScale <= 0 || referenceViewport.scale <= 0) return null;
716
+ const candidate = {
717
+ width: boxWidth(fit.candidate) / candidateScale,
718
+ height: boxHeight(fit.candidate) / candidateScale,
719
+ };
720
+ const reference = {
721
+ width: boxWidth(fit.reference) / referenceViewport.scale,
722
+ height: boxHeight(fit.reference) / referenceViewport.scale,
723
+ };
724
+ const area = reference.width * reference.height;
725
+ // Area rather than either side: one ratio for a shot whose two axes can differ.
726
+ const ratio = area > 0 ? Math.sqrt((candidate.width * candidate.height) / area) : 1;
727
+ return { candidate, reference, ratio };
728
+ }
729
+
730
+ /** How many reference frames the edge level is estimated from. */
731
+ const LEVEL_SAMPLES = 8;
732
+
733
+ /** The edge threshold both sides are measured with — see `EDGE_FRACTION`. */
734
+ function edgeLevelOf(root: string, pairs: FramePair[], background: RGBA): number {
735
+ const histogram = new ContrastHistogram();
736
+ const step = Math.max(1, Math.ceil(pairs.length / LEVEL_SAMPLES));
737
+ for (let i = 0; i < pairs.length; i += step) histogram.add(readPlateFrom(root, pairs[i].file), background);
738
+ return histogram.level();
739
+ }
740
+
741
+ /** Each reference frame's own content box, and a check that they are one grid. */
742
+ function referenceContentBoxes(
743
+ root: string,
744
+ pairs: FramePair[],
745
+ background: RGBA,
746
+ level: number,
747
+ pixelWidth: number,
748
+ pixelHeight: number,
749
+ ): Array<ContentBox | null> {
750
+ return pairs.map((pair) => {
751
+ const plate = readPlateFrom(root, pair.file);
752
+ if (plate.width !== pixelWidth || plate.height !== pixelHeight) {
753
+ throw new CheckError(
754
+ `${pair.file} is ${plate.width}x${plate.height} but the viewport says ${pixelWidth}x${pixelHeight}; ` +
755
+ 'the frames and the sidecar disagree about their own size',
756
+ );
757
+ }
758
+ return contentBoxOfPlate(plate, background, level);
759
+ });
760
+ }
761
+
762
+ /**
763
+ * The two content boxes of every frame that has both, in one array.
764
+ *
765
+ * Per frame rather than unioned, because that is what the fit is made from — see
766
+ * `fitFraming`. `referenceBoxes` is indexed the same way `prepared.flatMap(pairs)`
767
+ * is, which is the order it was built in.
768
+ */
769
+ function pairUpBoxes(
770
+ prepared: PreparedSet[],
771
+ pages: Map<string, Plate>,
772
+ viewport: Viewport,
773
+ background: RGBA,
774
+ level: number,
775
+ referenceBoxes: Array<ContentBox | null>,
776
+ ): BoxPair[] {
777
+ const out: BoxPair[] = [];
778
+ let at = 0;
779
+ for (const p of prepared) {
780
+ for (const pair of p.pairs) {
781
+ const reference = referenceBoxes[at++];
782
+ const candidate = frameContentBox(pair.frame, pages, viewport, background, level);
783
+ if (candidate && reference) out.push({ candidate, reference });
784
+ }
785
+ }
786
+ return out;
787
+ }
788
+
789
+ /** One measured pass of the framing loop. */
790
+ interface FramingPass {
791
+ viewport: Viewport;
792
+ fit: FramingFit;
793
+ distance: number;
794
+ }
795
+
796
+ /** A chain of passes and why it stopped. */
797
+ interface FramingChain {
798
+ passes: FramingPass[];
799
+ settled: boolean;
800
+ cycled: boolean;
801
+ }
802
+
803
+ /**
804
+ * Render → measure → correct, from one starting viewport, until it stops.
805
+ *
806
+ * ## Why it iterates
807
+ *
808
+ * The correction is measured on a render, and applying it changes the render it
809
+ * was measured on. One pass leaves the candidate close; a second measures what is
810
+ * left. It stops as soon as the correction is the identity to within
811
+ * `SETTLED_PIXELS`, which for a faithful candidate is the first look.
812
+ *
813
+ * ## And why it also watches for an orbit
814
+ *
815
+ * When the fit has no fixed point on a shot — which happens whenever the
816
+ * candidate's silhouette genuinely differs, because the fit registers extent and
817
+ * extent is not alignment — the sequence does not wander and does not converge. It
818
+ * **cycles**, and every further pass re-measures a state it has already been in at
819
+ * the cost of a full re-render of every frame. `fitSeparation` recognises that in
820
+ * one comparison per pass, so a cycling shot stops one pass after its orbit closes
821
+ * instead of burning the whole budget, and the report can say which of the two
822
+ * happened. Rung 6 cycles with period 4 (issue #52).
823
+ */
824
+ function runFramingChain(
825
+ seed: Viewport,
826
+ prepared: PreparedSet[],
827
+ pages: Map<string, Plate>,
828
+ referenceBoxes: Array<ContentBox | null>,
829
+ background: RGBA,
830
+ level: number,
831
+ pixelWidth: number,
832
+ pixelHeight: number,
833
+ cap: number,
834
+ ): FramingChain {
835
+ let viewport = seed;
836
+ const passes: FramingPass[] = [];
837
+ for (let pass = 1; pass <= cap; pass++) {
838
+ const boxes = pairUpBoxes(prepared, pages, viewport, background, level, referenceBoxes);
839
+ // A viewport the candidate draws nothing into ends the chain rather than
840
+ // failing it, and both ways of reaching one are real. The declared box gets
841
+ // there on its first pass whenever the candidate is authored somewhere else
842
+ // entirely — rung 1's `drop` candidate is nowhere near the frames' own world
843
+ // box — which is the declared path being refused, not an error. And a chain
844
+ // that does not converge can walk its own box off its content later on, where
845
+ // the answer is the closest pass already measured. The caller decides what an
846
+ // empty chain means; only the fitted path treats it as a failure.
847
+ if (boxes.length === 0) return { passes, settled: false, cycled: false };
848
+ const fit = fitFraming(boxes);
849
+ const cycled = passes.some((seen) => fitSeparation(fit, seen.fit) < CYCLE_PIXELS);
850
+ passes.push({ viewport, fit, distance: fitDistance(fit) });
851
+ if (fitIsSettled(fit)) return { passes, settled: true, cycled: false };
852
+ if (cycled) return { passes, settled: false, cycled: true };
853
+ viewport = applyFit(viewport, fit, pixelWidth, pixelHeight);
854
+ }
855
+ return { passes, settled: false, cycled: false };
856
+ }
857
+
858
+ /**
859
+ * The pass whose correction is closest to the identity.
860
+ *
861
+ * ⚠️ The **closest** pass rather than the last, and the viewport handed back is
862
+ * therefore one that was actually MEASURED, with the fit beside it being what it
863
+ * still leaves over — never a correction applied on the way out and never looked
864
+ * at. Near the answer the correction can jitter or orbit instead of converging, so
865
+ * taking the last pass would hand back whichever phase the loop happened to stop
866
+ * on, and applying one more unverified correction is a coin flip. A report that
867
+ * describes a viewport nobody rendered is worse than a slightly worse viewport.
868
+ */
869
+ function closestPass(chain: FramingChain): FramingPass {
870
+ let best = chain.passes[0];
871
+ for (const pass of chain.passes) if (pass.distance < best.distance) best = pass;
872
+ return best;
873
+ }
874
+
875
+ /**
876
+ * Put the candidate's drawn pixels on the reference's drawn pixels.
877
+ *
878
+ * Two ways in, and the second is tried first because when it applies it is exact
879
+ * rather than estimated — see `frameByDeclaredBox`. The fitted path is the general
880
+ * one and the only one available without a sidecar.
881
+ *
882
+ * ## Why the fitted start is trimmed
883
+ *
884
+ * The starting box is the union of the posed quads **trimmed to their opaque
885
+ * texels**, not the quads themselves. It is only a starting point — the framing is
886
+ * fitted on rendered pixels either way — but the fit's landing point depends on
887
+ * where it starts, so a start that moved with an invisible margin would leave the
888
+ * margin able to move the answer after all, by a fraction of a pixel instead of by
889
+ * two. With the trim, art padded on both sides is byte-identical work: same start,
890
+ * same passes, same numbers. `selftest` C03 asserts exactly that.
891
+ */
892
+ function frameCandidate(
893
+ prepared: PreparedSet[],
894
+ pages: Map<string, Plate>,
895
+ referenceBoxes: Array<ContentBox | null>,
896
+ background: RGBA,
897
+ level: number,
898
+ pixelWidth: number,
899
+ pixelHeight: number,
900
+ referenceViewport: Framing | null,
901
+ ): { viewport: Viewport; report: Omit<FramingReport, 'units'> } {
902
+ const declared = frameByDeclaredBox(
903
+ prepared,
904
+ pages,
905
+ referenceBoxes,
906
+ background,
907
+ level,
908
+ pixelWidth,
909
+ pixelHeight,
910
+ referenceViewport,
911
+ );
912
+ if (declared) return declared;
913
+
914
+ const quads = trimmedUnionBounds(
915
+ prepared.map((p) => p.pairs.map((pair) => pair.frame)),
916
+ pages,
917
+ );
918
+ if (!Number.isFinite(quads.minX)) {
919
+ throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
920
+ }
921
+ const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
922
+ const world = {
923
+ minX: quads.minX - pad,
924
+ minY: quads.minY - pad,
925
+ maxX: quads.maxX + pad,
926
+ maxY: quads.maxY + pad,
927
+ };
928
+ const maxSide = Math.max(pixelWidth, pixelHeight);
929
+ const seed = viewportOfSize(
930
+ world.minX,
931
+ world.minY,
932
+ world.maxX - world.minX,
933
+ world.maxY - world.minY,
934
+ maxSide / Math.max(world.maxX - world.minX, world.maxY - world.minY),
935
+ pixelWidth,
936
+ pixelHeight,
937
+ );
938
+ const chain = runFramingChain(
939
+ seed,
940
+ prepared,
941
+ pages,
942
+ referenceBoxes,
943
+ background,
944
+ level,
945
+ pixelWidth,
946
+ pixelHeight,
947
+ FRAMING_PASSES,
948
+ );
949
+ if (chain.passes.length === 0) {
950
+ throw new CheckError('the candidate drew no pixel in any frame that was compared');
951
+ }
952
+ const chosen = closestPass(chain);
953
+ return {
954
+ viewport: chosen.viewport,
955
+ report: {
956
+ fit: chosen.fit,
957
+ passes: chain.passes.length,
958
+ settled: chain.settled,
959
+ source: 'derived',
960
+ cycled: chain.cycled,
961
+ agrees: chosen.distance <= COINCIDENT_PIXELS,
962
+ applied: true,
963
+ },
964
+ };
965
+ }
966
+
967
+ /**
968
+ * The box `frames.json` records, used as the candidate's own — when, and only
969
+ * when, the candidate's pixels are measured to land in it.
970
+ *
971
+ * ## 🔒 This is not reading the answer
972
+ *
973
+ * The thing the honesty rule protects is the reference **skeleton** — its bones,
974
+ * its keys, its curves — and none of that is here. `frames.json` is a sidecar
975
+ * `check` already reads, whose `viewport` it already prints, and which
976
+ * [`docs/AUTHORING.md`](../docs/AUTHORING.md) §9 already tells an author to hand
977
+ * back through `--viewport`. What changes is only that the tool now *checks* the
978
+ * condition the guide asks the author to assert, instead of requiring them to
979
+ * notice it and type it.
980
+ *
981
+ * ## Why a declared box beats a fitted one
982
+ *
983
+ * A fit is an estimate with a floor. `fitFraming` registers two shots by their
984
+ * **extent**, so when the candidate's silhouette genuinely differs the best fit of
985
+ * the extents is about a third of a pixel away from the best alignment of the
986
+ * pictures — and on a small high-contrast shot a third of a pixel is several MAE.
987
+ * Rung 6 measures that floor as a 5-point tax: 8.73 fitted against 3.50 in the box
988
+ * the frames were actually drawn at, with every content box, residual and rms
989
+ * under the method's own noise (issue #52).
990
+ *
991
+ * The declared box has no such floor. It is not an estimate of where the frames
992
+ * were drawn; it is where they were drawn. So the only question is whether it
993
+ * applies to *this* candidate, and that is one measurement: render the candidate
994
+ * into the declared box, fit, and keep the box when the correction it asks for is
995
+ * under `COINCIDENT_PIXELS`. Nothing is corrected and nothing is iterated —
996
+ * either the candidate is in the frames' coordinates or it is not.
997
+ *
998
+ * ⚠️ **Correcting the declared box makes it worse, measured.** The obvious extra
999
+ * step — accept it, then run the usual passes from there to polish — was written
1000
+ * and measured, and it walks off the answer: rung 5's candidate, whose author
1001
+ * matched the reference's world box by hand, reads **4.35** at the declared box and
1002
+ * **6.24** after three refining passes, and 4.35 is the figure `fitFraming` records
1003
+ * as this shot's correct framing. Rung 6 reads 3.50 at the box and drifts the same
1004
+ * way. The refinement is the extent fit, and the extent fit is exactly what the
1005
+ * declared box is here to avoid.
1006
+ *
1007
+ * A candidate authored in its own coordinates — the ordinary case, and the one the
1008
+ * ladder's honesty rule guarantees — misses by a wide margin and is framed by the
1009
+ * fitted path exactly as before. Rung 3's candidate put its origin on the pendulum's
1010
+ * pivot and the reference put its own elsewhere; in the declared box that candidate
1011
+ * reports MAE 146/255, and its fit says so long before the pixels are ever compared.
1012
+ * Rung 1's `drop` candidate draws nothing at all in the declared box.
1013
+ *
1014
+ * ⚠️ So does a rig in the frames' coordinates at **different units**, which is a
1015
+ * choice the framing must stay blind to (`selftest` C04). It is refused here and
1016
+ * framed by the fitted path, where the blindness lives — and it therefore pays the
1017
+ * fit's floor where a same-units candidate does not. Recovering the unit from the
1018
+ * fit and scaling the declared box by it was measured too: on a rig scaled by 2 %
1019
+ * it recovers 1.0196 against a true 1.02 and lands two thirds of the way back, which
1020
+ * is more machinery for a case no candidate in the corpus has and still not exact.
1021
+ */
1022
+ function frameByDeclaredBox(
1023
+ prepared: PreparedSet[],
1024
+ pages: Map<string, Plate>,
1025
+ referenceBoxes: Array<ContentBox | null>,
1026
+ background: RGBA,
1027
+ level: number,
1028
+ pixelWidth: number,
1029
+ pixelHeight: number,
1030
+ referenceViewport: Framing | null,
1031
+ ): { viewport: Viewport; report: Omit<FramingReport, 'units'> } | null {
1032
+ if (!referenceViewport || referenceViewport.scale <= 0) return null;
1033
+ const viewport = viewportOfSize(
1034
+ referenceViewport.x,
1035
+ referenceViewport.y,
1036
+ referenceViewport.width,
1037
+ referenceViewport.height,
1038
+ referenceViewport.scale,
1039
+ pixelWidth,
1040
+ pixelHeight,
1041
+ );
1042
+ const boxes = pairUpBoxes(prepared, pages, viewport, background, level, referenceBoxes);
1043
+ // Nothing drawn in the declared box is the loudest possible "not these
1044
+ // coordinates", not a failure: rung 1's `drop` candidate is nowhere near it.
1045
+ if (boxes.length === 0) return null;
1046
+ const fit = fitFraming(boxes);
1047
+ const distance = fitDistance(fit);
1048
+ if (distance > COINCIDENT_PIXELS) return null;
1049
+ return {
1050
+ viewport,
1051
+ report: {
1052
+ fit,
1053
+ passes: 1,
1054
+ settled: fitIsSettled(fit),
1055
+ source: 'declared',
1056
+ cycled: false,
1057
+ agrees: true,
1058
+ applied: true,
1059
+ },
1060
+ };
1061
+ }
1062
+
1063
+ /**
1064
+ * What the framing pass concluded, in the words that tell the three cases apart.
1065
+ *
1066
+ * The distinction the report used to be missing (issue #52): "did not settle, and
1067
+ * the two shots are nevertheless in the same place" is the tool reaching its own
1068
+ * floor, "did not settle, and they are not" is a finding about the candidate, and
1069
+ * "the correction is cycling" says more passes cannot change either answer.
1070
+ */
1071
+ function framingNotes(report: Omit<FramingReport, 'units'>): string[] {
1072
+ if (report.source === 'declared') {
1073
+ return [
1074
+ `the candidate's world box was taken from ${FRAMES_SIDECAR} rather than fitted, because rendering it into ` +
1075
+ `that box put its own drawn pixels on the reference's to within ${report.fit.rms.toFixed(2)} px rms — so ` +
1076
+ "the candidate is authored in the frames' own coordinates, measured rather than assumed, and the box they " +
1077
+ 'were rendered at is exact where a fit of it is an estimate. The framing line below is still measured; what ' +
1078
+ `it leaves over is the extent fit's own floor${report.settled ? '' : ', which is why it does not read as the identity'}. ` +
1079
+ 'Pass --viewport to override.',
1080
+ ];
1081
+ }
1082
+ if (report.settled) return [];
1083
+ const how = report.cycled
1084
+ ? `the framing correction fell into a repeating orbit after ${report.passes} pass(es) rather than settling, so ` +
1085
+ 'more passes cannot help: the fit has no fixed point on this shot'
1086
+ : `the framing did not settle in ${report.passes} pass(es)`;
1087
+ return [
1088
+ report.agrees
1089
+ ? `${how}. The two content boxes nevertheless agree to within ${report.fit.rms.toFixed(2)} px rms, so this is ` +
1090
+ "the fit's own floor and not a shape mismatch — the fit registers extent, and on a silhouette that differs " +
1091
+ 'anywhere the best fit of the extents is not the best alignment of the pictures. Pass --viewport to pin the ' +
1092
+ 'box when you know your own coordinates.'
1093
+ : `${how}, and the two content boxes do not agree either — the correction below is what is left over after ` +
1094
+ 'the closest pass. A residual much larger than a pixel means the two shots are different shapes, which is ' +
1095
+ 'a finding about the candidate rather than about the loop.',
1096
+ ];
1097
+ }
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // posing the candidate against one frame set
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ /** One reference frame and the candidate frame that shares its index. */
1104
+ interface FramePair {
1105
+ index: number;
1106
+ file: string;
1107
+ frame: Frame;
1108
+ }
1109
+
1110
+ /** One frame set, posed and paired up with the frames on disk. */
1111
+ interface PreparedSet {
1112
+ set: FrameSet;
1113
+ candidateAnimation: string | null;
1114
+ candidateFrames: number;
1115
+ referenceFrames: number;
1116
+ pairs: FramePair[];
1117
+ notes: string[];
1118
+ /** Set when nothing could be compared at all, saying why. */
1119
+ missing: string | null;
1120
+ }
1121
+
1122
+ function prepareSet(
1123
+ root: string,
1124
+ set: FrameSet,
1125
+ posable: ReturnType<typeof posableFromText>,
1126
+ as: string | undefined,
1127
+ ): PreparedSet {
1128
+ const notes: string[] = [];
1129
+ const wanted = as ?? set.animation;
1130
+ const have = posable.data.animations.map((a) => a.name);
1131
+ const disk = framesOnDisk(root, set.dir);
1132
+
1133
+ if (wanted !== null && !have.includes(wanted)) {
1134
+ return {
1135
+ set,
1136
+ candidateAnimation: null,
1137
+ candidateFrames: 0,
1138
+ referenceFrames: disk.length,
1139
+ pairs: [],
1140
+ notes: [],
1141
+ missing:
1142
+ `the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
1143
+ 'Nothing was compared for this set; name the candidate animation with --as <name> if it is called ' +
1144
+ 'something else.',
1145
+ };
1146
+ }
1147
+
1148
+ let candidateFrames: Frame[];
1149
+ let candidateAnimation: string | null;
1150
+ if (wanted === null) {
1151
+ if (have.length > 0) {
1152
+ notes.push(
1153
+ `these frames are a setup pose (the skeleton that made them has no animation), but the candidate has ` +
1154
+ `[${have.join(', ')}] — the setup pose is what was compared`,
1155
+ );
1156
+ }
1157
+ candidateFrames = sampleSetupPose(posable.data);
1158
+ candidateAnimation = null;
1159
+ } else {
1160
+ candidateFrames = sampleAnimation(posable.data, wanted, set.fps);
1161
+ candidateAnimation = wanted;
1162
+ }
1163
+
1164
+ if (candidateFrames.length !== set.sampled) {
1165
+ notes.push(
1166
+ `the candidate samples to ${candidateFrames.length} frame(s) at ${set.fps} fps where the reference sampled ` +
1167
+ `${set.sampled} — the two animations do not last the same time, and only the frames both have were compared`,
1168
+ );
1169
+ }
1170
+
1171
+ const byIndex = new Map<number, Frame>();
1172
+ for (const frame of candidateFrames) byIndex.set(frame.index, frame);
1173
+ const pairs: FramePair[] = [];
1174
+ for (const { index, file } of disk) {
1175
+ const frame = byIndex.get(index);
1176
+ if (frame) pairs.push({ index, file, frame });
1177
+ }
1178
+ if (pairs.length === 0 && disk.length > 0) {
1179
+ notes.push(`none of the ${disk.length} reference frame(s) has a candidate frame at the same index`);
1180
+ }
1181
+ return {
1182
+ set,
1183
+ candidateAnimation,
1184
+ candidateFrames: candidateFrames.length,
1185
+ referenceFrames: disk.length,
1186
+ pairs,
1187
+ notes,
1188
+ missing: null,
1189
+ };
1190
+ }
1191
+
1192
+ function checkOneSet(
1193
+ root: string,
1194
+ prepared: PreparedSet,
1195
+ posable: ReturnType<typeof posableFromText>,
1196
+ viewport: Viewport,
1197
+ background: RGBA,
1198
+ ): AnimationCheck {
1199
+ const { set } = prepared;
1200
+ const blank: AnimationCheck = {
1201
+ dir: set.dir,
1202
+ animation: set.animation,
1203
+ candidateAnimation: prepared.candidateAnimation,
1204
+ fps: set.fps,
1205
+ referenceFrames: prepared.referenceFrames,
1206
+ candidateFrames: prepared.candidateFrames,
1207
+ compared: 0,
1208
+ meanMae: 0,
1209
+ meanMaeFrame: 0,
1210
+ worstMae: 0,
1211
+ worstMaeFrame: -1,
1212
+ worstDrift: 0,
1213
+ worstDriftFrame: -1,
1214
+ worstDriftSlot: null,
1215
+ framesWithoutDrift: 0,
1216
+ changePairs: 0,
1217
+ changeDisagreements: 0,
1218
+ worstChangeFrame: -1,
1219
+ frames: [],
1220
+ notes: prepared.missing ? [prepared.missing] : prepared.notes,
1221
+ };
1222
+ if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
1223
+
1224
+ const frames: FrameCheck[] = [];
1225
+ let maeSum = 0;
1226
+ let maeFrameSum = 0;
1227
+ let worstMae = 0;
1228
+ let worstMaeFrame = -1;
1229
+ let worstDrift = 0;
1230
+ let worstDriftFrame = -1;
1231
+ let worstDriftSlot: string | null = null;
1232
+ let framesWithoutDrift = 0;
1233
+ let changePairs = 0;
1234
+ let changeDisagreements = 0;
1235
+ let worstChangeFrame = -1;
1236
+ let worstChangeGap = 0;
1237
+ // The previous frame's two plates, kept so each side can be compared against
1238
+ // ITSELF a frame earlier. Both are already rendered or read for this frame, so
1239
+ // holding one frame of each costs one extra plate and no extra work.
1240
+ let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
1241
+
1242
+ for (const { index, file, frame } of prepared.pairs) {
1243
+ const reference = readPlateFrom(root, file);
1244
+ const rendered = renderFrame(frame, posable.pages, viewport, background);
1245
+ const check = checkOneFrame(index, file, frame, posable.pages, viewport, background, reference, rendered);
1246
+ check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
1247
+ previous = { index, candidate: rendered, reference };
1248
+ frames.push(check);
1249
+ maeSum += check.mae;
1250
+ maeFrameSum += check.maeFrame;
1251
+ if (check.attributed === 0) framesWithoutDrift++;
1252
+ if (check.change) {
1253
+ changePairs++;
1254
+ if (check.change.verdict !== 'agrees') {
1255
+ changeDisagreements++;
1256
+ const gap = Math.abs(check.change.candidate - check.change.reference);
1257
+ if (gap > worstChangeGap) {
1258
+ worstChangeGap = gap;
1259
+ worstChangeFrame = index;
1260
+ }
1261
+ }
1262
+ }
1263
+ if (check.mae > worstMae) {
1264
+ worstMae = check.mae;
1265
+ worstMaeFrame = index;
1266
+ }
1267
+ if (check.worstDrift !== null && check.worstDrift > worstDrift) {
1268
+ worstDrift = check.worstDrift;
1269
+ worstDriftFrame = index;
1270
+ worstDriftSlot = check.worstSlot;
1271
+ }
1272
+ }
1273
+
1274
+ return {
1275
+ ...blank,
1276
+ compared: frames.length,
1277
+ meanMae: maeSum / frames.length,
1278
+ meanMaeFrame: maeFrameSum / frames.length,
1279
+ worstMae,
1280
+ worstMaeFrame,
1281
+ worstDrift,
1282
+ worstDriftFrame,
1283
+ worstDriftSlot,
1284
+ framesWithoutDrift,
1285
+ changePairs,
1286
+ changeDisagreements,
1287
+ worstChangeFrame,
1288
+ frames,
1289
+ notes: prepared.notes,
1290
+ };
1291
+ }
1292
+
1293
+ /**
1294
+ * How far a channel must move for a pixel to count as having **changed**.
1295
+ *
1296
+ * The same threshold `isContent` uses to decide there is anything there at all,
1297
+ * and for the same reason: below it the difference is the rasteriser's own last
1298
+ * bit, and a measure that counts those reports every frame as moving.
1299
+ */
1300
+ export const CHANGE_TOLERANCE = BACKGROUND_TOLERANCE;
1301
+
1302
+ /**
1303
+ * How many times more one side has to move than the other to be a disagreement,
1304
+ * when **both** of them moved.
1305
+ *
1306
+ * Four, with `CHANGE_EXCESS` beside it, because a ratio alone means nothing on
1307
+ * small counts. Together the two read: *four times as much, and at least two dozen
1308
+ * pixels more.*
1309
+ */
1310
+ export const CHANGE_RATIO = 4;
1311
+
1312
+ /**
1313
+ * ...and how many pixels more, when both sides moved.
1314
+ *
1315
+ * Measured rather than picked. Across the corpus's two mechanically faithful
1316
+ * transcriptions — the same skeleton on both sides, where the true answer is
1317
+ * "identical" — the largest excess between two adjacent frames that clears
1318
+ * `CHANGE_RATIO` at all is **12 px**, on one pair out of 152. Twenty-four is double
1319
+ * that, and the case it has to keep is rung 6's broken plateau at 91 against 3.
1320
+ */
1321
+ export const CHANGE_EXCESS = 24;
1322
+
1323
+ /**
1324
+ * Did this side move materially more than that one?
1325
+ *
1326
+ * ⭐ **Stillness is categorical and gets no floor**, which is the reason this is a
1327
+ * predicate and not a threshold. A held pose is held *exactly* — rung 6's reference
1328
+ * is pixel-identical across f64-f67 — and a one-frame event is as small as the
1329
+ * thing it reveals, which on that same shot is **three pixels**. A floor big enough
1330
+ * to be safe about a moving frame would be big enough to hide both, so the two
1331
+ * regimes are separated instead: against a still side, moving at all is the
1332
+ * finding; against a moving side, `CHANGE_RATIO` and `CHANGE_EXCESS` apply.
1333
+ * Measured: neither faithful transcription has a single pair where one side is
1334
+ * still and the other is not.
1335
+ */
1336
+ function disagrees(mine: number, theirs: number): boolean {
1337
+ if (mine === 0) return false;
1338
+ if (theirs === 0) return true;
1339
+ return mine > theirs * CHANGE_RATIO && mine - theirs > CHANGE_EXCESS;
1340
+ }
1341
+
1342
+ /**
1343
+ * One frame against the frame before it, on each side, and what that says.
1344
+ *
1345
+ * ⚠️ Over the **whole frame**, and not over either side's content mask the way the
1346
+ * MAE is. The omission is deliberate: a change is a change wherever it happens, and
1347
+ * masking it would hide precisely the case where one side draws something the other
1348
+ * does not — which is half of what this measure exists for. A one-frame reveal
1349
+ * appears on background pixels by definition.
1350
+ */
1351
+ function frameChange(
1352
+ previous: { index: number; candidate: Plate; reference: Plate },
1353
+ candidate: Plate,
1354
+ reference: Plate,
1355
+ ): FrameChange {
1356
+ const mine = plateDelta(previous.candidate, candidate);
1357
+ const theirs = plateDelta(previous.reference, reference);
1358
+ return {
1359
+ previous: previous.index,
1360
+ candidate: mine.pixels,
1361
+ reference: theirs.pixels,
1362
+ candidateMae: mine.mae,
1363
+ referenceMae: theirs.mae,
1364
+ verdict: disagrees(mine.pixels, theirs.pixels)
1365
+ ? 'moves'
1366
+ : disagrees(theirs.pixels, mine.pixels)
1367
+ ? 'holds'
1368
+ : 'agrees',
1369
+ };
1370
+ }
1371
+
1372
+ /**
1373
+ * Changed pixels and mean absolute RGB difference between two plates of one size.
1374
+ *
1375
+ * Straight over `Plate.data` rather than through `Plate.get`, because this runs
1376
+ * twice per compared frame over the whole grid and `get` allocates a four-element
1377
+ * array per pixel. On the ladder's largest set that difference is most of what this
1378
+ * measure costs.
1379
+ */
1380
+ function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number } {
1381
+ const a = before.data;
1382
+ const b = after.data;
1383
+ const count = after.width * after.height;
1384
+ let pixels = 0;
1385
+ let sum = 0;
1386
+ for (let i = 0; i < count * 4; i += 4) {
1387
+ const dr = Math.abs(a[i] - b[i]);
1388
+ const dg = Math.abs(a[i + 1] - b[i + 1]);
1389
+ const db = Math.abs(a[i + 2] - b[i + 2]);
1390
+ sum += dr + dg + db;
1391
+ if (dr > CHANGE_TOLERANCE || dg > CHANGE_TOLERANCE || db > CHANGE_TOLERANCE) pixels++;
1392
+ }
1393
+ return { pixels, mae: sum / 3 / count };
1394
+ }
1395
+
1396
+ function checkOneFrame(
1397
+ index: number,
1398
+ file: string,
1399
+ frame: Frame,
1400
+ pages: Map<string, Plate>,
1401
+ viewport: Viewport,
1402
+ background: RGBA,
1403
+ reference: Plate,
1404
+ /** The candidate's own frame, rendered by the caller — it needs it too. */
1405
+ rendered: Plate,
1406
+ ): FrameCheck {
1407
+ const { coverage, footprints } = frameGeometry(frame, pages, viewport);
1408
+
1409
+ let union = 0;
1410
+ let candidatePixels = 0;
1411
+ let referencePixels = 0;
1412
+ let sum = 0;
1413
+ let sumAll = 0;
1414
+ for (let y = 0; y < viewport.height; y++) {
1415
+ for (let x = 0; x < viewport.width; x++) {
1416
+ const inCandidate = coverage[y * viewport.width + x] === 1;
1417
+ const inReference = isContent(reference, x, y, background);
1418
+ if (inCandidate) candidatePixels++;
1419
+ if (inReference) referencePixels++;
1420
+ const a = rendered.get(x, y);
1421
+ const b = reference.get(x, y);
1422
+ const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
1423
+ sumAll += delta;
1424
+ if (!inCandidate && !inReference) continue;
1425
+ union++;
1426
+ sum += delta;
1427
+ }
1428
+ }
1429
+
1430
+ const components = componentsOf(reference, background);
1431
+ const { tracks, matchedComponents } = matchSlots(footprints, components, {
1432
+ frame,
1433
+ pages,
1434
+ viewport,
1435
+ background,
1436
+ reference,
1437
+ });
1438
+
1439
+ let worstDrift: number | null = null;
1440
+ let worstSlot: string | null = null;
1441
+ let attributed = 0;
1442
+ let drawn = 0;
1443
+ for (const track of tracks) {
1444
+ if (track.candidate !== null) drawn++;
1445
+ if (!isAttributable(track)) continue;
1446
+ attributed++;
1447
+ if (worstDrift === null || (track.drift as number) > worstDrift) {
1448
+ worstDrift = track.drift;
1449
+ worstSlot = track.slot;
1450
+ }
1451
+ }
1452
+
1453
+ return {
1454
+ index,
1455
+ file,
1456
+ mae: union === 0 ? 0 : sum / union,
1457
+ maeFrame: sumAll / (viewport.width * viewport.height),
1458
+ unionPixels: union,
1459
+ candidatePixels,
1460
+ referencePixels,
1461
+ components: components.length,
1462
+ unmatchedComponents: components.length - matchedComponents,
1463
+ worstSlot,
1464
+ worstDrift,
1465
+ attributed,
1466
+ drawn,
1467
+ slots: tracks,
1468
+ // Filled in by the caller, which is the only place that has the frame before
1469
+ // this one — see `frameChange`.
1470
+ change: null,
1471
+ };
1472
+ }
1473
+
1474
+ // ---------------------------------------------------------------------------
1475
+ // the report
1476
+ // ---------------------------------------------------------------------------
1477
+
1478
+ /** How many worst frames a set prints when the whole set is too long to list. */
1479
+ export const WORST_FRAMES = 8;
1480
+ /** Sets no longer than this print every frame. */
1481
+ const LIST_EVERY = 24;
1482
+
1483
+ const f2 = (n: number): string => n.toFixed(2);
1484
+
1485
+ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }): string[] {
1486
+ const lines: string[] = [];
1487
+ lines.push(` candidate ${report.candidate.skeleton}`);
1488
+ lines.push(` atlas ${report.candidate.atlas}`);
1489
+ lines.push(` frames ${report.framesDir}`);
1490
+ const v = report.viewport;
1491
+ const how =
1492
+ report.framing === 'candidate-pixels'
1493
+ ? "fitted to the candidate's own drawn pixels"
1494
+ : report.framing === 'frames-viewport'
1495
+ ? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
1496
+ : '--viewport';
1497
+ lines.push(
1498
+ ` framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
1499
+ `world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${how})`,
1500
+ );
1501
+ const r = report.referenceViewport;
1502
+ if (r) {
1503
+ lines.push(
1504
+ ` reference ${r.pixelWidth}x${r.pixelHeight}px ${r.scale.toFixed(6)} px/unit ` +
1505
+ `world x[${r.x.toFixed(1)} .. ${(r.x + r.width).toFixed(1)}] y[${r.y.toFixed(1)} .. ${(r.y + r.height).toFixed(1)}] (${FRAMES_SIDECAR})`,
1506
+ );
1507
+ lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
1508
+ }
1509
+ for (const line of framingLines(report)) lines.push(line);
1510
+ for (const note of report.notes) lines.push(` ⚠️ ${note}`);
1511
+ lines.push('');
1512
+
1513
+ for (const anim of report.animations) {
1514
+ const played =
1515
+ anim.candidateAnimation !== null
1516
+ ? `candidate animation ${JSON.stringify(anim.candidateAnimation)}`
1517
+ : anim.animation === null
1518
+ ? 'setup pose'
1519
+ : 'nothing in the candidate to play against it';
1520
+ lines.push(` ── ${anim.dir} — ${played}, ${anim.fps} fps ──`);
1521
+ lines.push(
1522
+ ` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
1523
+ );
1524
+ for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
1525
+ if (anim.compared === 0) {
1526
+ lines.push('');
1527
+ continue;
1528
+ }
1529
+ lines.push(
1530
+ ` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} at f${String(anim.worstMaeFrame).padStart(4, '0')}` +
1531
+ ` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
1532
+ );
1533
+ const blind =
1534
+ anim.framesWithoutDrift === 0
1535
+ ? ''
1536
+ : ` (${anim.framesWithoutDrift} of ${anim.compared} frame(s) attributed no slot at all)`;
1537
+ lines.push(
1538
+ anim.worstDriftFrame < 0
1539
+ ? ` slot drift no slot could be attributed in any of the ${anim.compared} frame(s) — read the MAE instead`
1540
+ : ` slot drift worst ${anim.worstDrift.toFixed(1)} px ${JSON.stringify(anim.worstDriftSlot)} at ` +
1541
+ `f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
1542
+ );
1543
+ lines.push(changeSummary(anim));
1544
+ lines.push('');
1545
+
1546
+ const listed = framesToList(anim, opts?.allFrames === true);
1547
+ const heading =
1548
+ listed.length === anim.frames.length
1549
+ ? 'every frame'
1550
+ : `the ${listed.length} frames worth reading — worst by MAE, plus every frame whose own change disagrees`;
1551
+ lines.push(` ${heading}, in index order`);
1552
+ lines.push(' frame MAE union px Δpx ref Δ worst slot drift how slots note');
1553
+ for (const frame of listed) {
1554
+ const worst = frame.slots.find((s) => s.slot === frame.worstSlot) ?? null;
1555
+ const drift = frame.worstDrift === null ? ' —' : `${frame.worstDrift.toFixed(1).padStart(5)}`;
1556
+ const how =
1557
+ worst === null
1558
+ ? '— '
1559
+ : worst.method === 'template'
1560
+ ? `tmpl ${(worst.confidence ?? 0).toFixed(2)}`
1561
+ : 'component ';
1562
+ const note = [changeNote(frame.change), frame.unmatchedComponents > 0 ? `${frame.unmatchedComponents} reference component(s) no slot reaches` : '']
1563
+ .filter(Boolean)
1564
+ .join('; ');
1565
+ const change = frame.change
1566
+ ? `${String(frame.change.candidate).padStart(7)}${String(frame.change.reference).padStart(7)}`
1567
+ : `${'—'.padStart(7)}${'—'.padStart(7)}`;
1568
+ lines.push(
1569
+ ` f${String(frame.index).padStart(4, '0')} ${f2(frame.mae).padStart(8)} ${String(frame.unionPixels).padStart(9)} ${change} ` +
1570
+ `${(frame.worstSlot ?? '—').padEnd(20)} ${drift} ${how.padEnd(9)} ${String(frame.attributed)}/${String(frame.drawn)}` +
1571
+ `${note ? ` ${note}` : ''}`,
1572
+ );
1573
+ }
1574
+ lines.push('');
1575
+ }
1576
+
1577
+ lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
1578
+ lines.push(' read against 255 and not against a threshold: there is no pass mark here any more than');
1579
+ lines.push(' there is one in `diff`. Read the framing line first: it is upstream of every number');
1580
+ lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
1581
+ lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
1582
+ lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
1583
+ lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
1584
+ lines.push(' than its best rival, and a slot that matched nothing at all is left out of the count.');
1585
+ lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
1586
+ lines.push(' not against each other. They are the only columns that can see a held pose that is');
1587
+ lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
1588
+ lines.push(' wrong only in the relation between two, which is where the MAE cannot look.');
1589
+ return lines;
1590
+ }
1591
+
1592
+ /**
1593
+ * The frames worth printing: the worst by MAE, plus every change disagreement.
1594
+ *
1595
+ * The union matters rather than being tidy. The defects `FrameChange` exists to
1596
+ * catch are **cheap in MAE by construction** — a plateau sloped through by a
1597
+ * fraction of a pixel, a three-pixel reveal that did not fire — so a listing
1598
+ * ranked by MAE is exactly the listing that leaves them out. Rung 6's f65–f68 sit
1599
+ * near the bottom of that ranking.
1600
+ */
1601
+ function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
1602
+ if (allFrames || anim.frames.length <= LIST_EVERY) return anim.frames;
1603
+ const chosen = new Set(
1604
+ [...anim.frames]
1605
+ .sort((a, b) => b.mae - a.mae)
1606
+ .slice(0, WORST_FRAMES)
1607
+ .map((f) => f.index),
1608
+ );
1609
+ for (const frame of anim.frames) if (frame.change && frame.change.verdict !== 'agrees') chosen.add(frame.index);
1610
+ return anim.frames.filter((f) => chosen.has(f.index));
1611
+ }
1612
+
1613
+ /** The per-frame change measure, as the animation's own summary line. */
1614
+ function changeSummary(anim: AnimationCheck): string {
1615
+ if (anim.changePairs === 0) {
1616
+ return (
1617
+ ' per-frame no two compared frames are adjacent, so nothing was measured about how much this shot ' +
1618
+ 'changes from frame to frame'
1619
+ );
1620
+ }
1621
+ if (anim.changeDisagreements === 0) {
1622
+ return ` per-frame all ${anim.changePairs} adjacent pair(s) change by as much as the reference's own frames do`;
1623
+ }
1624
+ const worst = anim.frames.find((f) => f.index === anim.worstChangeFrame);
1625
+ const at =
1626
+ worst && worst.change
1627
+ ? `; worst f${String(worst.index).padStart(4, '0')}, yours moved ${worst.change.candidate} px where the ` +
1628
+ `reference moved ${worst.change.reference}`
1629
+ : '';
1630
+ return (
1631
+ ` per-frame ${anim.changeDisagreements} of ${anim.changePairs} adjacent pair(s) change by a different ` +
1632
+ `amount than the reference does${at}`
1633
+ );
1634
+ }
1635
+
1636
+ /** What one frame's change disagreement says, in the words that name the defect. */
1637
+ function changeNote(change: FrameChange | null): string {
1638
+ if (!change || change.verdict === 'agrees') return '';
1639
+ if (change.verdict === 'moves') {
1640
+ return change.reference === 0
1641
+ ? 'the reference holds still here and yours does not'
1642
+ : `yours moves ${(change.candidate / Math.max(1, change.reference)).toFixed(0)}x the reference`;
1643
+ }
1644
+ return change.candidate === 0
1645
+ ? 'the reference moves here and yours holds still'
1646
+ : `yours moves ${(change.reference / Math.max(1, change.candidate)).toFixed(0)}x less than the reference`;
1647
+ }
1648
+
1649
+ /**
1650
+ * What the fit did, in one word.
1651
+ *
1652
+ * The declared box is never "unsettled" — it was not being iterated towards, it
1653
+ * was measured and kept, and `coincident` says the measurement that kept it.
1654
+ */
1655
+ function convergence(framing: FramingReport): string {
1656
+ if (framing.settled) return 'settled';
1657
+ if (framing.source === 'declared') return 'coincident';
1658
+ return framing.cycled ? 'cycling' : 'unsettled';
1659
+ }
1660
+
1661
+ /** The framing, as the line an author reads before anything else. */
1662
+ function framingLines(report: CheckReport): string[] {
1663
+ const framing = report.framingFit;
1664
+ if (!framing) return [];
1665
+ const { fit } = framing;
1666
+ const c = fit.candidate;
1667
+ const r = fit.reference;
1668
+ const percent = (n: number): string => `${n >= 0 ? '+' : ''}${(n * 100).toFixed(2)}%`;
1669
+ const box = (b: ContentBox): string =>
1670
+ `${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
1671
+ const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
1672
+ const out = [
1673
+ ` content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
1674
+ ` ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
1675
+ `rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
1676
+ `union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
1677
+ `aspect ${percent(fit.aspectError)}` +
1678
+ (framing.applied
1679
+ ? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
1680
+ : ' (measured, NOT applied — --viewport pinned)'),
1681
+ ];
1682
+ const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
1683
+ if (spread > 1) {
1684
+ const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
1685
+ out.push(
1686
+ ` ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
1687
+ `${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
1688
+ `${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
1689
+ 'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
1690
+ 'reading a drift.',
1691
+ );
1692
+ }
1693
+ if (fit.rms > 1) {
1694
+ out.push(
1695
+ ` ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
1696
+ 'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
1697
+ 'misframed.',
1698
+ );
1699
+ }
1700
+ const units = framing.units;
1701
+ if (units) {
1702
+ out.push(
1703
+ ` in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
1704
+ `reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
1705
+ `x${units.ratio.toFixed(4)}`,
1706
+ );
1707
+ out.push(
1708
+ ' ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ' +
1709
+ 'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
1710
+ 'only if you measured the shot in the frames’ own units.',
1711
+ );
1712
+ }
1713
+ return out;
1714
+ }