spine-rigc 0.2.1 → 0.4.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
@@ -77,6 +77,10 @@ import {
77
77
  PAD,
78
78
  FRAMES_SIDECAR,
79
79
  FRAMES_SPEC,
80
+ SHEET_COLUMNS,
81
+ SHEET_FILE,
82
+ SHEET_GAP,
83
+ type Footprint,
80
84
  type Frame,
81
85
  type FramesSidecar,
82
86
  type FrameSet,
@@ -93,18 +97,38 @@ import {
93
97
  fitIsSettled,
94
98
  fitSeparation,
95
99
  frameContentBox,
100
+ offsetIsWorthApplying,
101
+ OffsetScan,
102
+ shiftViewport,
103
+ unionBoxes,
96
104
  isContent,
97
105
  BACKGROUND_TOLERANCE,
98
106
  CYCLE_PIXELS,
107
+ REFINE_MIN_GAIN,
108
+ REFINE_MIN_GAIN_MAE,
109
+ REFINE_RADIUS,
99
110
  type BoxPair,
100
111
  type ContentBox,
101
112
  type FramingFit,
113
+ type OffsetGain,
102
114
  } from './framing.ts';
103
- import { componentsOf, isAttributable, matchSlots, type SlotTrack } from './slots.ts';
115
+ import { componentField, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
116
+ import { chainsOf, type BoneChain } from './chains.ts';
104
117
  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';
118
+ import { GLYPH_H, textWidth } from '../tools/font5x7.ts';
119
+
120
+ export {
121
+ componentField,
122
+ componentsOf,
123
+ matchSlots,
124
+ searchRadius,
125
+ type Component,
126
+ type ComponentField,
127
+ type MatchMethod,
128
+ type SlotTrack,
129
+ } from './slots.ts';
130
+ export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
131
+ export type { BoxPair, ContentBox, FramingFit, OffsetGain } from './framing.ts';
108
132
 
109
133
  // ---------------------------------------------------------------------------
110
134
  // the reference side — frames only, and mechanically so
@@ -259,6 +283,25 @@ export interface FrameCheck {
259
283
  file: string;
260
284
  /** Mean absolute RGB difference over the union alpha, 0..255. */
261
285
  mae: number;
286
+ /**
287
+ * The same total difference over the REFERENCE's own drawn pixels alone.
288
+ *
289
+ * ⭐ The figure to optimise against, and the reason is the denominator. `mae`
290
+ * divides by the pixels either side drew, and the candidate owns half of that:
291
+ * drawing something large and mostly transparent adds many cheap pixels to the
292
+ * union and the *mean falls*, so an optimiser can buy a better score by growing
293
+ * (issue #119 — a muzzle flare walked its own scale to 13x doing exactly this).
294
+ * This denominator is the reference's, which nothing the candidate does can
295
+ * move, so the only way down is to draw the reference's picture.
296
+ *
297
+ * ⚠️ Not bounded by 255, and deliberately: a candidate that draws far more than
298
+ * the reference has more absolute error than the reference has pixels to carry
299
+ * it, and the figure says so instead of saturating.
300
+ *
301
+ * `mae` is still the right figure for comparing two builds of the same rig,
302
+ * where the union is near enough the same on both sides.
303
+ */
304
+ maeReference: number;
262
305
  /**
263
306
  * The same difference averaged over the WHOLE frame, background included.
264
307
  *
@@ -285,6 +328,111 @@ export interface FrameCheck {
285
328
  change: FrameChange | null;
286
329
  }
287
330
 
331
+ /**
332
+ * One bone chain's slice of a set — the row an author reads before deciding what
333
+ * to re-key.
334
+ *
335
+ * The chains come from the CANDIDATE's bone tree (`src/chains.ts` owns the rule
336
+ * and the reasoning); the reference stays pixels, so this is a decomposition of
337
+ * your own figure and never a reading of the answer.
338
+ */
339
+ export interface ChainCheck {
340
+ /** The chain, named as `src/chains.ts` names it. */
341
+ chain: string;
342
+ /** How many slots it owns. */
343
+ slots: number;
344
+ /** How many of those drew anything in at least one compared frame. */
345
+ drewSlots: number;
346
+ /** The worst attributable slot drift anywhere in it, in frame pixels. */
347
+ worstDrift: number;
348
+ /** Which slot that was, and in which frame — `null`/`-1` when none was attributable. */
349
+ worstDriftSlot: string | null;
350
+ worstDriftFrame: number;
351
+ /** The mean of every attributable slot drift in it, over `driftSamples` of them. */
352
+ meanDrift: number;
353
+ driftSamples: number;
354
+ /** How many frames contributed at least one of those samples. */
355
+ driftFrames: number;
356
+ /**
357
+ * The absolute RGB difference attributed to this chain, summed over the
358
+ * REFERENCE's own drawn pixels — never over the union.
359
+ *
360
+ * ⭐ The denominator lesson from issue #119, applied to a share. A reference
361
+ * pixel goes to the chain whose ink is nearest to it, so the chains partition
362
+ * the reference's drawn pixels and the shares add up to the whole. What the
363
+ * candidate controls is only *which* chain a pixel lands in, and growing a
364
+ * chain's ink pulls MORE of the reference's pixels — and their error — into it.
365
+ * There is no move here that makes a chain look better by drawing more, which is
366
+ * exactly what the union MAE could not say.
367
+ */
368
+ error: number;
369
+ /** How many reference-drawn pixels it took, summed over frames. */
370
+ referencePixels: number;
371
+ /**
372
+ * `error` per pixel it took — the MAE *inside* this chain, 0..255.
373
+ *
374
+ * Printed beside the share because the share alone confounds being wrong with
375
+ * being big: spineboy's head, goggles, eye and mouth are one chain covering a
376
+ * lot of the figure, so it can carry a third of the error at a per-pixel figure
377
+ * below the run's own mean. The share says where the error IS; this says whether
378
+ * the chain is actually worse than the rest of the figure.
379
+ */
380
+ mae: number;
381
+ /** `error` over the set's own total, 0..1 — see `AnimationCheck.chainDenominator`. */
382
+ maeShare: number;
383
+ }
384
+
385
+ /**
386
+ * The whole shot against the contact sheet beside it — the frames `check` has no
387
+ * file for.
388
+ *
389
+ * ## Why a sheet is a frame set and not a picture of one
390
+ *
391
+ * A long shot does not commit 311 near-duplicate PNGs. It commits a couple of
392
+ * stills and folds every sampled frame into one `contact.png`, and `check` used to
393
+ * read the two stills, say `2 compared`, and mean it — an honest number with a hole
394
+ * behind it: nothing whatever was measured about the other 309 frames, so a clean
395
+ * table said nothing about the shot (issue #36). A sheet is the same thing a frame
396
+ * is, at a smaller scale and with a frame number burned into the corner, and
397
+ * reading it reads no reference skeleton.
398
+ *
399
+ * So the candidate is sampled at the set's own rate, rendered into the same world
400
+ * box the set was framed in at the sheet's own scale, and each frame is compared
401
+ * against its own tile. The prototype this replaces was written in-run by the
402
+ * second rung-2 attempt, which found with it what the two-still table could not:
403
+ * flat MAE 4.85–4.95 over all 1,244 frames of four shots, no spikes — evidence
404
+ * that trajectories, ring rates and attachment swaps land where and when they
405
+ * should.
406
+ *
407
+ * ## What it deliberately does not measure: the per-frame change
408
+ *
409
+ * The tiles are adjacent, so `FrameChange` looks reachable here, and it is not:
410
+ * `CHANGE_EXCESS` is two dozen pixels at frame scale, and a quarter-scale tile has
411
+ * a sixteenth of the pixels to move. The thresholds would have to be re-derived
412
+ * against sheets before that column could mean anything, and a figure printed at
413
+ * the wrong scale is worse than one not printed. MAE only, and the report says so.
414
+ */
415
+ export interface SheetCheck {
416
+ /** The sheet itself, so a worst-tile line is openable. */
417
+ file: string;
418
+ /** The grid, measured off the sheet — see `sheetGeometry`. */
419
+ columns: number;
420
+ tileWidth: number;
421
+ tileHeight: number;
422
+ /** Frame pixels per tile pixel: how much smaller a tile is than a frame. */
423
+ tileScale: number;
424
+ /** How many tiles the sheet holds, and how many the candidate could be compared on. */
425
+ tiles: number;
426
+ compared: number;
427
+ /** Mean and worst over the tiles, in the same two denominators the frames use. */
428
+ meanMae: number;
429
+ meanMaeReference: number;
430
+ worstMae: number;
431
+ worstTile: number;
432
+ /** The worst tiles by MAE, worst first — at most `WORST_FRAMES` of them. */
433
+ worst: Array<{ index: number; mae: number }>;
434
+ }
435
+
288
436
  export interface AnimationCheck {
289
437
  dir: string;
290
438
  /** The animation the frames show, per the sidecar. */
@@ -296,6 +444,18 @@ export interface AnimationCheck {
296
444
  candidateFrames: number;
297
445
  compared: number;
298
446
  meanMae: number;
447
+ /** Mean of the per-frame reference-denominator MAE — see `FrameCheck.maeReference`. */
448
+ meanMaeReference: number;
449
+ /**
450
+ * How much this set draws, against how much the reference draws: the mean over
451
+ * its frames of `candidatePixels / referencePixels`.
452
+ *
453
+ * 1 means the two shots put ink on the same amount of the frame. Above 1 the
454
+ * candidate is drawing more than the reference does, which is the move that
455
+ * makes the union MAE cheaper — see `OVERDRAW_RATIO`, which is where the
456
+ * threshold and the corpus it came from are written down.
457
+ */
458
+ drawnRatio: number;
299
459
  /** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
300
460
  meanMaeFrame: number;
301
461
  worstMae: number;
@@ -311,7 +471,44 @@ export interface AnimationCheck {
311
471
  changeDisagreements: number;
312
472
  /** The widest of those disagreements, and `-1` when there is none. */
313
473
  worstChangeFrame: number;
474
+ /**
475
+ * This set, broken down by the candidate's own bone chains — see `ChainCheck`.
476
+ *
477
+ * Chains that own no slot at all are left out: they have nothing to attribute.
478
+ * They are still in `CheckReport.chains`, so the roster stays a complete account
479
+ * of where every bone went.
480
+ */
481
+ chains: ChainCheck[];
482
+ /**
483
+ * The set's whole difference over the reference's own drawn pixels — the
484
+ * denominator every `ChainCheck.maeShare` divides by.
485
+ *
486
+ * The same numerator `meanMaeReference` averages, kept as a total because a
487
+ * share needs the total and a mean has already divided it away.
488
+ */
489
+ chainDenominator: number;
490
+ /** The part of it no chain could take, because the candidate drew nothing at all. */
491
+ unattributedError: number;
314
492
  frames: FrameCheck[];
493
+ /**
494
+ * The box THIS set's candidate frames were rendered into.
495
+ *
496
+ * Under the default per-shot scope every set carries its own, and they are
497
+ * different boxes; under `--framing shared` they are all the same one. Either
498
+ * way it is here rather than only at the top of the report, because it is
499
+ * upstream of every number in this row.
500
+ */
501
+ viewport: Framing;
502
+ /** How this set's box was chosen — see `FramingSource`. */
503
+ framing: FramingHow;
504
+ /** Where this set's drawn pixels ended up against the reference's. */
505
+ framingFit: FramingReport | null;
506
+ /**
507
+ * The whole shot against the contact sheet, when the set ships one and does not
508
+ * ship every frame — see `SheetCheck`. `null` when there is nothing to add: no
509
+ * sheet, or every sampled frame already on disk as a frame of its own.
510
+ */
511
+ sheet: SheetCheck | null;
315
512
  notes: string[];
316
513
  }
317
514
 
@@ -339,9 +536,56 @@ export interface Framing {
339
536
  */
340
537
  export type FramingSource = 'derived' | 'declared' | 'pinned';
341
538
 
539
+ /** The same three, named for the report line rather than for the code path. */
540
+ export type FramingHow = 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
541
+
542
+ /**
543
+ * Whether the framing is decided per frame set, or once across every set.
544
+ *
545
+ * ## What `per-shot` actually scopes, and why only that
546
+ *
547
+ * A framing is decided over the frames it is measured on, so pointing `check` at a
548
+ * skeleton root used to decide ONE for every set under it — and one badly-framed
549
+ * shot was then paid for by all the others. Measured on the spineboy rung, 8 shots
550
+ * and 147 frames: `idle` read **41.59** MAE at the root against the **18.77** it
551
+ * reads on its own frames, with not one key different (issue #100).
552
+ *
553
+ * `per-shot` moves exactly one decision into the set: **whether the box
554
+ * `frames.json` records is this set's box too.** That decision is a measurement
555
+ * with no floor — either the set's own drawn pixels land in the declared box or
556
+ * they do not — and over the union one shot that does not can put the pooled
557
+ * correction over `COINCIDENT_PIXELS` and take every other shot down with it. Per
558
+ * set, the ones that qualify read exactly what pinning by hand reads.
559
+ *
560
+ * ⚠️ It does NOT fit a separate chain per set, and that is a measured decision
561
+ * rather than a simplification. Per-set FITTING is worse: `fitFraming` registers
562
+ * extent, extent is not alignment, and one shot's frames do not constrain that
563
+ * enough — spineboy's `hit` reads 92.36 fitted on its own against 60.59 in the
564
+ * shared fit, and its two-frame `shoot@30fps` set reads 101.94 against 42.98. So a
565
+ * set that cannot take the declared box is measured in the shared framing, where
566
+ * every frame in the run constrains the answer.
567
+ *
568
+ * `shared` is the old behaviour, and it answers one question well: *does a single
569
+ * box serve every set?* The report prints that fit either way — see
570
+ * `CheckReport.sharedFraming`.
571
+ *
572
+ * ⚠️ The two are different measurements and their absolute numbers are not
573
+ * comparable across builds. The report says which one it did.
574
+ */
575
+ export type FramingScope = 'per-shot' | 'shared';
576
+
342
577
  /** What the framing pass concluded, and how sure it is of it. */
343
578
  export interface FramingReport {
344
- /** The residual fit measured at the viewport that was used. */
579
+ /**
580
+ * The residual fit measured at the box the framing chain chose.
581
+ *
582
+ * ⚠️ **Before** the MAE-refined pass below it, when that pass moved the box —
583
+ * and deliberately, because the two answer different questions and both are
584
+ * worth printing: this says how far the two *extents* were from registering,
585
+ * `refinement` says how far the *pictures* were from each other after that. Its
586
+ * `settled` / `agrees` / `cycled` words describe the chain that reached the box,
587
+ * which is a fact about the chain and does not change afterwards.
588
+ */
345
589
  fit: FramingFit;
346
590
  /** How many render/measure/correct passes ran. */
347
591
  passes: number;
@@ -389,6 +633,70 @@ export interface FramingReport {
389
633
  * what it does and does not mean attached.
390
634
  */
391
635
  units: { candidate: Extent; reference: Extent; ratio: number } | null;
636
+ /**
637
+ * What the MAE-refined final pass found, and whether it moved the box.
638
+ *
639
+ * `null` when nothing could be searched (no frame with reference ink). See
640
+ * `FramingRefinement`.
641
+ */
642
+ refinement: FramingRefinement | null;
643
+ }
644
+
645
+ /**
646
+ * The final framing pass, which asks a different question from every pass above
647
+ * it: not *do the two extents register?* but *is a constant pixel of this set's
648
+ * MAE a framing offset?*
649
+ *
650
+ * ## Why it exists, and why it is the last thing that happens
651
+ *
652
+ * Issue #146 measured the answer on the spineboy candidates: a **constant**
653
+ * translation of one or two pixels is worth 12 % of `death`'s headline
654
+ * reference-denominator MAE and up to 30 % of a fitted set's, while what is left
655
+ * after it is taken out is per-frame and small. That is `fitFraming`'s documented
656
+ * "extent is not alignment" floor arriving as a tenth of the number an author is
657
+ * reading as motion. `OffsetScan` searches whole-pixel offsets against the MAE
658
+ * itself, so it cannot walk off the answer the way the extent refinement measured
659
+ * and rejected in `frameByDeclaredBox` did — the figure it minimises is the figure
660
+ * the report prints.
661
+ *
662
+ * ## ⭐ Where it is allowed to move the box, and where it only reports
663
+ *
664
+ * **A fitted box is an estimate and gets corrected. An exact box does not.**
665
+ *
666
+ * - `derived` — the box came from a fit of extents, so a constant pixel in it is
667
+ * the estimator's own floor. The offset is applied.
668
+ * - `declared` — `frames.json`'s box is not an estimate of where the frames were
669
+ * drawn, it is where they were drawn (`frameByDeclaredBox`). A constant pixel
670
+ * *there* is the candidate's own figure sitting a pixel off inside the right
671
+ * box, which is a finding an author can act on and the framing must not absorb.
672
+ * Searched, reported, never applied — and measured: over the committed corpus
673
+ * every declared-box set's best offset is the exact identity, so this branch
674
+ * has cost nothing so far and would only ever fire on a real offset.
675
+ * - `pinned` — `--viewport` is the author's claim about their own coordinates and
676
+ * nothing here overrides it, exactly as the fit above is measured and not
677
+ * applied.
678
+ */
679
+ export interface FramingRefinement {
680
+ /** The best whole-pixel offset found, in frame pixels. `0, 0` means none was. */
681
+ dx: number;
682
+ dy: number;
683
+ /** Was it applied to the box the set was measured in? */
684
+ applied: boolean;
685
+ /** The set's mean reference-denominator MAE at the box the framing chose... */
686
+ before: number;
687
+ /** ...and at `dx, dy`, which is the same figure with the constant taken out. */
688
+ after: number;
689
+ /** How far the search looked, and how many frames it pooled. */
690
+ radius: number;
691
+ frames: number;
692
+ /**
693
+ * Why the offset was not applied — `null` when it was.
694
+ *
695
+ * `identity` is the answer this pass gives on a set whose framing is already
696
+ * where the picture is, and it is a measurement rather than a default: the
697
+ * search ran over the whole window and the identity won it.
698
+ */
699
+ declined: 'identity' | 'below-threshold' | 'box-is-exact' | 'pinned' | null;
392
700
  }
393
701
 
394
702
  /** A width and a height in world units. */
@@ -401,12 +709,35 @@ export interface CheckReport {
401
709
  candidate: { skeleton: string; atlas: string };
402
710
  framesDir: string;
403
711
  framesRoot: string;
404
- /** How the candidate's own world box was chosen. */
405
- framing: 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
712
+ /** One framing per set, or one across every set — see `FramingScope`. */
713
+ framingScope: FramingScope;
714
+ /**
715
+ * How the candidate's own world box was chosen, when ONE box covers the run.
716
+ *
717
+ * `null` under a per-shot scope with more than one set compared: there is no
718
+ * single answer then, and each `AnimationCheck` carries its own. A run that
719
+ * compared exactly one set fills these in whatever the scope, because for one
720
+ * set the two scopes are the same measurement.
721
+ */
722
+ framing: FramingHow | null;
406
723
  /** The box the CANDIDATE was rendered into, at the reference's pixel size. */
407
- viewport: Framing;
724
+ viewport: Framing | null;
408
725
  /** Where the candidate's drawn pixels ended up against the reference's. */
409
726
  framingFit: FramingReport | null;
727
+ /**
728
+ * The framing ONE shared box gives across every set compared.
729
+ *
730
+ * Under `per-shot` it is both reported and used: every set that cannot take the
731
+ * frames' own declared box is measured in it. It is also the figure that says
732
+ * *why* a whole-root run is a different measurement — a set that reads well in
733
+ * the declared box and badly here is a set the old whole-root run was measuring
734
+ * through somebody else's silhouette, which is what `idle` reading 41.59 against
735
+ * 18.77 was (issue #100).
736
+ *
737
+ * `null` when the scope is already shared — `framingFit` is that number then —
738
+ * and when only one set was compared, where the two scopes are the same thing.
739
+ */
740
+ sharedFraming: FramingReport | null;
410
741
  /**
411
742
  * The box the REFERENCE was rendered into, when the sidecar records one.
412
743
  *
@@ -416,6 +747,14 @@ export interface CheckReport {
416
747
  */
417
748
  referenceViewport: Framing | null;
418
749
  background: RGBA;
750
+ /**
751
+ * The candidate's bone tree, cut into chains — the roster the report prints.
752
+ *
753
+ * Printed rather than assumed, because a decomposition an author has to guess at
754
+ * is one they will read wrong: the table says `front-thigh` and the roster says
755
+ * which bones and which slots that name covers. `src/chains.ts` owns the rule.
756
+ */
757
+ chains: BoneChain[];
419
758
  animations: AnimationCheck[];
420
759
  notes: string[];
421
760
  }
@@ -443,6 +782,15 @@ export interface CheckOptions {
443
782
  viewport?: { x: number; y: number; width: number; height: number };
444
783
  /** Play this candidate animation against the frames, when the names differ. */
445
784
  as?: string;
785
+ /**
786
+ * Fit one framing per frame set, or one across every set compared.
787
+ *
788
+ * Defaults to `per-shot`. See `FramingScope` for what the choice costs and why
789
+ * this is the default; it has no effect when only one set is compared, and none
790
+ * when `viewport` pins the box (a pin is a claim about the candidate's own
791
+ * coordinates, and those do not change between shots).
792
+ */
793
+ framing?: FramingScope;
446
794
  }
447
795
 
448
796
  // ---------------------------------------------------------------------------
@@ -572,34 +920,62 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
572
920
  const referenceBoxes =
573
921
  pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
574
922
 
575
- let viewport: Viewport;
576
- let framingFit: FramingReport | null = null;
923
+ const scope: FramingScope = options.framing ?? 'per-shot';
924
+ const slices = sliceBySet(prepared, referenceBoxes);
925
+ /** One per prepared set, in `prepared` order. */
926
+ const framings: SetFraming[] = [];
927
+ let topViewport: Viewport | null = null;
928
+ let topHow: FramingHow | null = null;
929
+ let topFit: FramingReport | null = null;
930
+ let sharedFraming: FramingReport | null = null;
931
+
932
+ const reportFor = (fit: FramingFit, at: Viewport, over: Omit<FramingReport, 'units' | 'fit'>): FramingReport => ({
933
+ ...over,
934
+ fit,
935
+ units: extentsOf(fit, at.scale, referenceViewport),
936
+ });
937
+
577
938
  if (options.viewport) {
939
+ // A pin is a claim about the CANDIDATE's own coordinates, and those do not
940
+ // change between shots — so one box covers the run whatever the scope. The
941
+ // per-set fits below are free: every frame is measured in that one box once,
942
+ // and splitting the result per set costs nothing.
578
943
  const v = options.viewport;
579
- viewport = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
944
+ const pinned = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
580
945
  notes.push(
581
946
  `the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
582
947
  "from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
583
948
  'framing line below is still measured, so it says what the pin cost.',
584
949
  );
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
- };
950
+ const pinnedShape = { passes: 1, settled: false, source: 'pinned' as const, cycled: false, applied: false };
951
+ // A pin is one claim for the whole run, so the refined pass is measured over
952
+ // the run as a whole — and never applied, for the same reason the fit is not.
953
+ const refinement = refinementOf(scanOffsets(located.root, prepared, posable.pages, pinned, background), 'pinned');
954
+ const perSet = prepared.map((p, i) =>
955
+ pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
956
+ );
957
+ for (const boxes of perSet) {
958
+ const fit = boxes.length === 0 ? null : fitFraming(boxes);
959
+ framings.push({
960
+ viewport: pinned,
961
+ how: 'viewport-flag',
962
+ fit:
963
+ fit === null
964
+ ? null
965
+ : reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement }),
966
+ notes: [],
967
+ });
598
968
  }
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');
969
+ const all = perSet.flat();
970
+ topViewport = pinned;
971
+ topHow = 'viewport-flag';
972
+ if (all.length > 0) {
973
+ const fit = fitFraming(all);
974
+ topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
602
975
  }
976
+ } else if (referenceBoxes.every((b) => b === null)) {
977
+ throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
978
+ } else if (scope === 'shared' || prepared.length === 1) {
603
979
  const framed = frameCandidate(
604
980
  prepared,
605
981
  posable.pages,
@@ -610,14 +986,136 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
610
986
  pixelHeight,
611
987
  referenceViewport,
612
988
  );
613
- viewport = framed.viewport;
614
- framingFit = { ...framed.report, units: extentsOf(framed.report.fit, viewport.scale, referenceViewport) };
989
+ // One box for the run, so one refined pass over every frame in it: a single
990
+ // shared framing that each set nudged its own way would not be a shared one.
991
+ const refinement = refinementOf(
992
+ scanOffsets(located.root, prepared, posable.pages, framed.viewport, background),
993
+ framed.report.source,
994
+ );
995
+ const viewport =
996
+ refinement !== null && refinement.applied
997
+ ? shiftViewport(framed.viewport, refinement.dx, refinement.dy, pixelWidth, pixelHeight)
998
+ : framed.viewport;
999
+ const fit = {
1000
+ ...framed.report,
1001
+ units: extentsOf(framed.report.fit, framed.viewport.scale, referenceViewport),
1002
+ refinement,
1003
+ };
1004
+ const how = HOW_BY_SOURCE[framed.report.source];
1005
+ for (let i = 0; i < prepared.length; i++) framings.push({ viewport, how, fit, notes: [] });
1006
+ topViewport = viewport;
1007
+ topHow = how;
1008
+ topFit = fit;
615
1009
  notes.push(...framingNotes(framed.report));
1010
+ if (prepared.length > 1) {
1011
+ notes.push(
1012
+ `one framing was fitted across all ${prepared.length} frame set(s) (--framing shared). Its absolute numbers ` +
1013
+ 'are not comparable with a per-shot run, and one badly-fitted set moves every other set in it.',
1014
+ );
1015
+ }
1016
+ } else {
1017
+ // Per shot: the DECLARED BOX is decided per set, and every set that does not
1018
+ // qualify for it is measured in the one shared framing.
1019
+ //
1020
+ // ## Why the split falls exactly there, and not "fit each set on its own"
1021
+ //
1022
+ // The obvious reading of issue #100 is that each set should get its own fitted
1023
+ // framing. It was written that way and measured, and it is worse — on the
1024
+ // spineboy rung, per-set fitting reads `hit` **92.36** against the shared
1025
+ // fit's 60.59 and `shoot@30fps` **101.94** against 42.98 (a two-frame set,
1026
+ // framed 24 % off). The reason is `fitFraming`'s own: it registers **extent**,
1027
+ // and extent is not alignment, so on a shot whose silhouette genuinely differs
1028
+ // the chain has a local minimum of the correction that is not a minimum of the
1029
+ // difference. More frames constrain that; one shot's worth does not.
1030
+ //
1031
+ // What actually produced the good column in that run is the other half — the
1032
+ // box `frames.json` records, which is not an estimate of anything and has no
1033
+ // floor. Over the union it was refused, because ONE badly-fitted shot put the
1034
+ // pooled correction over `COINCIDENT_PIXELS` and the whole root fell back to a
1035
+ // fit. Per set, the four sets that ARE in the frames' coordinates take it and
1036
+ // read exactly what pinning by hand reads: `idle` **18.77** against 41.59,
1037
+ // `walk` 32.00 against 45.33.
1038
+ //
1039
+ // So a set is framed by the frames' own box when its OWN pixels land there,
1040
+ // and by the shared fit otherwise. Every set is then at least as well framed
1041
+ // as a whole-root run framed it, and four of spineboy's sixteen much better.
1042
+ const shared = frameCandidate(
1043
+ prepared,
1044
+ posable.pages,
1045
+ referenceBoxes,
1046
+ background,
1047
+ level,
1048
+ pixelWidth,
1049
+ pixelHeight,
1050
+ referenceViewport,
1051
+ );
1052
+ const sharedShape: SetFraming = {
1053
+ viewport: shared.viewport,
1054
+ how: HOW_BY_SOURCE[shared.report.source],
1055
+ fit: { ...shared.report, units: extentsOf(shared.report.fit, shared.viewport.scale, referenceViewport) },
1056
+ notes: [],
1057
+ };
1058
+ sharedFraming = sharedShape.fit;
1059
+ let own = 0;
1060
+ for (let i = 0; i < prepared.length; i++) {
1061
+ const p = prepared[i];
1062
+ const declared =
1063
+ p.pairs.length === 0
1064
+ ? null
1065
+ : frameByDeclaredBox(
1066
+ [p],
1067
+ posable.pages,
1068
+ slices[i],
1069
+ background,
1070
+ level,
1071
+ pixelWidth,
1072
+ pixelHeight,
1073
+ referenceViewport,
1074
+ );
1075
+ const chosen: SetFraming = declared
1076
+ ? {
1077
+ viewport: declared.viewport,
1078
+ how: HOW_BY_SOURCE[declared.report.source],
1079
+ fit: {
1080
+ ...declared.report,
1081
+ units: extentsOf(declared.report.fit, declared.viewport.scale, referenceViewport),
1082
+ },
1083
+ notes: framingNotes(declared.report),
1084
+ }
1085
+ : { ...sharedShape, notes: framingNotes(shared.report) };
1086
+ if (declared) own++;
1087
+ // Per set, because the constant this pass removes is per set: issue #146
1088
+ // measured spineboy's `death` wanting (−1, +1) and its `jump` (0, −1) in the
1089
+ // same run and the same shared box. That is not the per-set *fitting* this
1090
+ // scope rejects — the offset is measured against the MAE itself, where one
1091
+ // shot's frames constrain the answer completely.
1092
+ framings.push(refined(located.root, [p], posable.pages, chosen, background, pixelWidth, pixelHeight));
1093
+ }
1094
+ notes.push(
1095
+ `the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
1096
+ `${FRAMES_SIDECAR}'s own box because their own pixels land there, and the rest in the one shared framing on ` +
1097
+ 'the "shared box" line. A set framed by the frames\' own box cannot be moved by any other set. --framing ' +
1098
+ 'shared measures every set in the shared framing instead, which is a different measurement and not ' +
1099
+ 'comparable with this one.',
1100
+ );
616
1101
  }
617
1102
 
1103
+ // The candidate's own decomposition, derived once and used by every set — see
1104
+ // `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
1105
+ // side of the honesty rule: the reference is still nothing but pixels.
1106
+ const chains = chainsOf(
1107
+ posable.data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
1108
+ posable.data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
1109
+ );
1110
+ const chainOfSlot = new Map<string, number>();
1111
+ chains.forEach((chain, index) => {
1112
+ for (const slot of chain.slots) chainOfSlot.set(slot, index);
1113
+ });
1114
+
618
1115
  const animations: AnimationCheck[] = [];
619
- for (const p of prepared) {
620
- animations.push(checkOneSet(located.root, p, posable, viewport, background));
1116
+ for (let i = 0; i < prepared.length; i++) {
1117
+ const f = framings[i];
1118
+ animations.push(checkOneSet(located.root, prepared[i], posable, f, background, chains, chainOfSlot));
621
1119
  }
622
1120
 
623
1121
  return {
@@ -627,28 +1125,64 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
627
1125
  },
628
1126
  framesDir: resolve(options.framesDir),
629
1127
  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,
1128
+ framingScope: scope,
1129
+ framing: topHow,
1130
+ viewport: topViewport === null ? null : framingOfViewport(topViewport),
1131
+ framingFit: topFit,
1132
+ sharedFraming,
645
1133
  referenceViewport,
646
1134
  background,
1135
+ chains,
647
1136
  animations,
648
1137
  notes,
649
1138
  };
650
1139
  }
651
1140
 
1141
+ /** The framing one prepared set was measured in. */
1142
+ interface SetFraming {
1143
+ viewport: Viewport;
1144
+ how: FramingHow;
1145
+ fit: FramingReport | null;
1146
+ notes: string[];
1147
+ }
1148
+
1149
+ /** `FramingSource` said in the report's own words. */
1150
+ const HOW_BY_SOURCE: Record<FramingSource, FramingHow> = {
1151
+ derived: 'candidate-pixels',
1152
+ declared: 'frames-viewport',
1153
+ pinned: 'viewport-flag',
1154
+ };
1155
+
1156
+ /**
1157
+ * `referenceBoxes` cut into one array per prepared set, in `prepared` order.
1158
+ *
1159
+ * The array is built by `prepared.flatMap((p) => p.pairs)`, so this is the inverse
1160
+ * of that flatten and nothing else. It exists because a per-shot framing measures
1161
+ * one set at a time and `frameCandidate` indexes its boxes the flat way.
1162
+ */
1163
+ function sliceBySet(prepared: PreparedSet[], referenceBoxes: Array<ContentBox | null>): Array<Array<ContentBox | null>> {
1164
+ const out: Array<Array<ContentBox | null>> = [];
1165
+ let at = 0;
1166
+ for (const p of prepared) {
1167
+ out.push(referenceBoxes.slice(at, at + p.pairs.length));
1168
+ at += p.pairs.length;
1169
+ }
1170
+ return out;
1171
+ }
1172
+
1173
+ /** A rendering viewport as the report states it. */
1174
+ function framingOfViewport(v: Viewport): Framing {
1175
+ return {
1176
+ x: v.minX,
1177
+ y: v.minY,
1178
+ width: v.maxX - v.minX,
1179
+ height: v.maxY - v.minY,
1180
+ scale: v.scale,
1181
+ pixelWidth: v.width,
1182
+ pixelHeight: v.height,
1183
+ };
1184
+ }
1185
+
652
1186
  function readPlateFrom(root: string, file: string): Plate {
653
1187
  readFrameFile(root, file); // the guard; readPlate does the decoding
654
1188
  return readPlate(file);
@@ -793,6 +1327,12 @@ interface FramingPass {
793
1327
  distance: number;
794
1328
  }
795
1329
 
1330
+ /** A framing for one run of sets: the box, and what it still leaves over. */
1331
+ interface FramedSets {
1332
+ viewport: Viewport;
1333
+ report: Omit<FramingReport, 'units'>;
1334
+ }
1335
+
796
1336
  /** A chain of passes and why it stopped. */
797
1337
  interface FramingChain {
798
1338
  passes: FramingPass[];
@@ -898,7 +1438,7 @@ function frameCandidate(
898
1438
  pixelWidth: number,
899
1439
  pixelHeight: number,
900
1440
  referenceViewport: Framing | null,
901
- ): { viewport: Viewport; report: Omit<FramingReport, 'units'> } {
1441
+ ): FramedSets {
902
1442
  const declared = frameByDeclaredBox(
903
1443
  prepared,
904
1444
  pages,
@@ -909,34 +1449,14 @@ function frameCandidate(
909
1449
  pixelHeight,
910
1450
  referenceViewport,
911
1451
  );
1452
+ // The declared-box probe measures every frame in `frames.json`'s own box
1453
+ // whether or not it ends up being used, and that measurement is the only one
1454
+ // taken in a box every set shares. Handing it back is what lets a per-shot run
1455
+ // report `sharedFit` without a second render — see `CheckReport.sharedFit`.
912
1456
  if (declared) return declared;
913
1457
 
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
1458
  const chain = runFramingChain(
939
- seed,
1459
+ seedFromGeometry(prepared, pages, referenceBoxes, pixelWidth, pixelHeight),
940
1460
  prepared,
941
1461
  pages,
942
1462
  referenceBoxes,
@@ -960,10 +1480,94 @@ function frameCandidate(
960
1480
  cycled: chain.cycled,
961
1481
  agrees: chosen.distance <= COINCIDENT_PIXELS,
962
1482
  applied: true,
1483
+ // Filled in by the refined pass, which runs once the box is decided.
1484
+ refinement: null,
963
1485
  },
964
1486
  };
965
1487
  }
966
1488
 
1489
+ /**
1490
+ * The starting viewport, from the candidate's own posed geometry laid onto the
1491
+ * reference's own drawn extent.
1492
+ *
1493
+ * ## Why the reference's extent and not the frame
1494
+ *
1495
+ * The seed used to scale the candidate's trimmed quads to **fill the frame**, and
1496
+ * that is an assumption about the reference: that the shot its frames show was
1497
+ * framed around itself. Over a whole skeleton root it holds well enough, because
1498
+ * the sidecar's one box was chosen to hold every set. Over one SHORT set it can be
1499
+ * badly wrong — rung 3's `light` covers about half of the box its frames were
1500
+ * rendered in, so filling the frame starts it near 2x too large, and the chain
1501
+ * walks that back by only a few per cent a pass: `--frames <root>/light` on a
1502
+ * candidate in its own coordinates read **MAE 141** with a framing 65 % off, after
1503
+ * spending its whole pass budget (issue #100).
1504
+ *
1505
+ * The reference's own content box is already measured, on the same frames, with
1506
+ * the same predicate — it is what the fit is trying to reach. Starting there costs
1507
+ * nothing and starts the chain where it used to end up: the same shot now settles
1508
+ * on the first or second pass.
1509
+ *
1510
+ * The scale matches the two boxes by **area** rather than by either side, because
1511
+ * a candidate whose silhouette differs has two different side ratios and picking
1512
+ * one of them would seed the chain with that difference as a scale error.
1513
+ *
1514
+ * ⚠️ Falls back to filling the frame when there is no reference box to aim at —
1515
+ * every frame unreadable, or a set with nothing on disk.
1516
+ */
1517
+ function seedFromGeometry(
1518
+ prepared: PreparedSet[],
1519
+ pages: Map<string, Plate>,
1520
+ referenceBoxes: Array<ContentBox | null>,
1521
+ pixelWidth: number,
1522
+ pixelHeight: number,
1523
+ ): Viewport {
1524
+ const quads = trimmedUnionBounds(
1525
+ prepared.map((p) => p.pairs.map((pair) => pair.frame)),
1526
+ pages,
1527
+ );
1528
+ if (!Number.isFinite(quads.minX)) {
1529
+ throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
1530
+ }
1531
+ const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
1532
+ const world = {
1533
+ minX: quads.minX - pad,
1534
+ minY: quads.minY - pad,
1535
+ maxX: quads.maxX + pad,
1536
+ maxY: quads.maxY + pad,
1537
+ };
1538
+ const worldWidth = world.maxX - world.minX;
1539
+ const worldHeight = world.maxY - world.minY;
1540
+
1541
+ let reference: ContentBox | null = null;
1542
+ for (const box of referenceBoxes) reference = unionBoxes(reference, box);
1543
+ if (reference !== null && boxWidth(reference) > 0 && boxHeight(reference) > 0) {
1544
+ // The reference's box is the trimmed content, so pad it the same way the
1545
+ // candidate's is before the two are matched — otherwise the pad is a scale
1546
+ // error the chain then has to undo.
1547
+ const refWidth = boxWidth(reference) * (1 + 2 * PAD);
1548
+ const refHeight = boxHeight(reference) * (1 + 2 * PAD);
1549
+ const scale = Math.sqrt((refWidth * refHeight) / (worldWidth * worldHeight));
1550
+ const left = reference.left - boxWidth(reference) * PAD;
1551
+ const top = reference.top - boxHeight(reference) * PAD;
1552
+ // `projector` is px = (wx - minX)·k and py = (maxY - wy)·k, so putting the
1553
+ // candidate's padded box on the reference's is one subtraction per axis.
1554
+ const minX = world.minX - left / scale;
1555
+ const maxY = world.maxY + top / scale;
1556
+ return viewportOfSize(minX, maxY - pixelHeight / scale, pixelWidth / scale, pixelHeight / scale, scale, pixelWidth, pixelHeight);
1557
+ }
1558
+
1559
+ const maxSide = Math.max(pixelWidth, pixelHeight);
1560
+ return viewportOfSize(
1561
+ world.minX,
1562
+ world.minY,
1563
+ worldWidth,
1564
+ worldHeight,
1565
+ maxSide / Math.max(worldWidth, worldHeight),
1566
+ pixelWidth,
1567
+ pixelHeight,
1568
+ );
1569
+ }
1570
+
967
1571
  /**
968
1572
  * The box `frames.json` records, used as the candidate's own — when, and only
969
1573
  * when, the candidate's pixels are measured to land in it.
@@ -1056,10 +1660,91 @@ function frameByDeclaredBox(
1056
1660
  cycled: false,
1057
1661
  agrees: true,
1058
1662
  applied: true,
1663
+ refinement: null,
1059
1664
  },
1060
1665
  };
1061
1666
  }
1062
1667
 
1668
+ // ---------------------------------------------------------------------------
1669
+ // the MAE-refined final pass — see `FramingRefinement`
1670
+ // ---------------------------------------------------------------------------
1671
+
1672
+ /**
1673
+ * The reference-denominator MAE of these sets at every whole-pixel offset in a
1674
+ * ±`REFINE_RADIUS` window, in the box they were framed in.
1675
+ *
1676
+ * One render per frame, whatever the window's size: `OffsetScan` owns why that is
1677
+ * exact for whole pixels. The reference plates are read again here and again by
1678
+ * `checkOneSet`; decoding a PNG twice is cheaper than holding a set's frames in
1679
+ * memory, which on the ladder's largest set is a quarter of a gigabyte.
1680
+ */
1681
+ function scanOffsets(
1682
+ root: string,
1683
+ prepared: PreparedSet[],
1684
+ pages: Map<string, Plate>,
1685
+ viewport: Viewport,
1686
+ background: RGBA,
1687
+ ): OffsetGain | null {
1688
+ const scan = new OffsetScan(REFINE_RADIUS);
1689
+ for (const p of prepared) {
1690
+ for (const pair of p.pairs) {
1691
+ scan.add(
1692
+ renderFrame(pair.frame, pages, viewport, background),
1693
+ frameGeometry(pair.frame, pages, viewport).coverage,
1694
+ readPlateFrom(root, pair.file),
1695
+ background,
1696
+ );
1697
+ }
1698
+ }
1699
+ return scan.best();
1700
+ }
1701
+
1702
+ /**
1703
+ * What to do with the offset a scan found, given how the box was chosen.
1704
+ *
1705
+ * The whole judgement of `FramingRefinement` in one function, so that "a fitted
1706
+ * box is corrected and an exact one is only reported" is a single readable rule
1707
+ * rather than a condition spread over three call sites.
1708
+ */
1709
+ function refinementOf(gain: OffsetGain | null, source: FramingSource): FramingRefinement | null {
1710
+ if (gain === null) return null;
1711
+ const shape = {
1712
+ dx: gain.dx,
1713
+ dy: gain.dy,
1714
+ before: gain.identity,
1715
+ after: gain.best,
1716
+ radius: gain.radius,
1717
+ frames: gain.frames,
1718
+ };
1719
+ if (gain.dx === 0 && gain.dy === 0) return { ...shape, applied: false, declined: 'identity' };
1720
+ if (!offsetIsWorthApplying(gain)) return { ...shape, applied: false, declined: 'below-threshold' };
1721
+ if (source === 'pinned') return { ...shape, applied: false, declined: 'pinned' };
1722
+ if (source === 'declared') return { ...shape, applied: false, declined: 'box-is-exact' };
1723
+ return { ...shape, applied: true, declined: null };
1724
+ }
1725
+
1726
+ /** One set's framing with the refined pass run over it, and applied if it may be. */
1727
+ function refined(
1728
+ root: string,
1729
+ prepared: PreparedSet[],
1730
+ pages: Map<string, Plate>,
1731
+ framing: SetFraming,
1732
+ background: RGBA,
1733
+ pixelWidth: number,
1734
+ pixelHeight: number,
1735
+ ): SetFraming {
1736
+ if (framing.fit === null) return framing;
1737
+ const refinement = refinementOf(
1738
+ scanOffsets(root, prepared, pages, framing.viewport, background),
1739
+ framing.fit.source,
1740
+ );
1741
+ const viewport =
1742
+ refinement !== null && refinement.applied
1743
+ ? shiftViewport(framing.viewport, refinement.dx, refinement.dy, pixelWidth, pixelHeight)
1744
+ : framing.viewport;
1745
+ return { ...framing, viewport, fit: { ...framing.fit, refinement } };
1746
+ }
1747
+
1063
1748
  /**
1064
1749
  * What the framing pass concluded, in the words that tell the three cases apart.
1065
1750
  *
@@ -1114,6 +1799,16 @@ interface PreparedSet {
1114
1799
  candidateFrames: number;
1115
1800
  referenceFrames: number;
1116
1801
  pairs: FramePair[];
1802
+ /**
1803
+ * Every frame the candidate sampled, in index order — not only the ones a file
1804
+ * on disk pairs with.
1805
+ *
1806
+ * The contact sheet holds a tile for every SAMPLED frame, so a set that commits
1807
+ * two stills out of 311 needs all 311 poses to be compared against it
1808
+ * (`SheetCheck`). Kept as poses rather than plates: a `Frame` is geometry, and
1809
+ * holding 311 rendered plates of a busy shot is a quarter of a gigabyte.
1810
+ */
1811
+ frames: Frame[];
1117
1812
  notes: string[];
1118
1813
  /** Set when nothing could be compared at all, saying why. */
1119
1814
  missing: string | null;
@@ -1137,6 +1832,7 @@ function prepareSet(
1137
1832
  candidateFrames: 0,
1138
1833
  referenceFrames: disk.length,
1139
1834
  pairs: [],
1835
+ frames: [],
1140
1836
  notes: [],
1141
1837
  missing:
1142
1838
  `the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
@@ -1184,6 +1880,7 @@ function prepareSet(
1184
1880
  candidateFrames: candidateFrames.length,
1185
1881
  referenceFrames: disk.length,
1186
1882
  pairs,
1883
+ frames: candidateFrames,
1187
1884
  notes,
1188
1885
  missing: null,
1189
1886
  };
@@ -1193,10 +1890,14 @@ function checkOneSet(
1193
1890
  root: string,
1194
1891
  prepared: PreparedSet,
1195
1892
  posable: ReturnType<typeof posableFromText>,
1196
- viewport: Viewport,
1893
+ framing: SetFraming,
1197
1894
  background: RGBA,
1895
+ chains: BoneChain[],
1896
+ /** Slot name → its index in `chains`. */
1897
+ chainOfSlot: Map<string, number>,
1198
1898
  ): AnimationCheck {
1199
1899
  const { set } = prepared;
1900
+ const viewport = framing.viewport;
1200
1901
  const blank: AnimationCheck = {
1201
1902
  dir: set.dir,
1202
1903
  animation: set.animation,
@@ -1206,6 +1907,8 @@ function checkOneSet(
1206
1907
  candidateFrames: prepared.candidateFrames,
1207
1908
  compared: 0,
1208
1909
  meanMae: 0,
1910
+ meanMaeReference: 0,
1911
+ drawnRatio: 1,
1209
1912
  meanMaeFrame: 0,
1210
1913
  worstMae: 0,
1211
1914
  worstMaeFrame: -1,
@@ -1216,13 +1919,22 @@ function checkOneSet(
1216
1919
  changePairs: 0,
1217
1920
  changeDisagreements: 0,
1218
1921
  worstChangeFrame: -1,
1922
+ chains: [],
1923
+ chainDenominator: 0,
1924
+ unattributedError: 0,
1219
1925
  frames: [],
1220
- notes: prepared.missing ? [prepared.missing] : prepared.notes,
1926
+ viewport: framingOfViewport(viewport),
1927
+ framing: framing.how,
1928
+ framingFit: framing.fit,
1929
+ sheet: null,
1930
+ notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
1221
1931
  };
1222
1932
  if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
1223
1933
 
1224
1934
  const frames: FrameCheck[] = [];
1225
1935
  let maeSum = 0;
1936
+ let maeReferenceSum = 0;
1937
+ let drawnRatioSum = 0;
1226
1938
  let maeFrameSum = 0;
1227
1939
  let worstMae = 0;
1228
1940
  let worstMaeFrame = -1;
@@ -1238,15 +1950,34 @@ function checkOneSet(
1238
1950
  // ITSELF a frame earlier. Both are already rendered or read for this frame, so
1239
1951
  // holding one frame of each costs one extra plate and no extra work.
1240
1952
  let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
1953
+ const tally: ChainTally = {
1954
+ error: new Array<number>(chains.length).fill(0),
1955
+ pixels: new Array<number>(chains.length).fill(0),
1956
+ unattributed: 0,
1957
+ total: 0,
1958
+ };
1241
1959
 
1242
1960
  for (const { index, file, frame } of prepared.pairs) {
1243
1961
  const reference = readPlateFrom(root, file);
1244
1962
  const rendered = renderFrame(frame, posable.pages, viewport, background);
1245
- const check = checkOneFrame(index, file, frame, posable.pages, viewport, background, reference, rendered);
1963
+ const check = checkOneFrame(
1964
+ index,
1965
+ file,
1966
+ frame,
1967
+ posable.pages,
1968
+ viewport,
1969
+ background,
1970
+ reference,
1971
+ rendered,
1972
+ chainOfSlot,
1973
+ tally,
1974
+ );
1246
1975
  check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
1247
1976
  previous = { index, candidate: rendered, reference };
1248
1977
  frames.push(check);
1249
1978
  maeSum += check.mae;
1979
+ maeReferenceSum += check.maeReference;
1980
+ drawnRatioSum += check.referencePixels === 0 ? 1 : check.candidatePixels / check.referencePixels;
1250
1981
  maeFrameSum += check.maeFrame;
1251
1982
  if (check.attributed === 0) framesWithoutDrift++;
1252
1983
  if (check.change) {
@@ -1271,10 +2002,21 @@ function checkOneSet(
1271
2002
  }
1272
2003
  }
1273
2004
 
2005
+ // The frames the set does not commit as files, against the sheet that holds
2006
+ // them — see `SheetCheck`. After the frame loop, because it is measured in the
2007
+ // box that loop was measured in.
2008
+ const sheet = checkAgainstSheet(root, prepared, posable, viewport, background);
2009
+
1274
2010
  return {
1275
2011
  ...blank,
1276
2012
  compared: frames.length,
2013
+ sheet: sheet.sheet,
2014
+ chains: chainChecks(chains, frames, tally),
2015
+ chainDenominator: tally.total,
2016
+ unattributedError: tally.unattributed,
1277
2017
  meanMae: maeSum / frames.length,
2018
+ meanMaeReference: maeReferenceSum / frames.length,
2019
+ drawnRatio: drawnRatioSum / frames.length,
1278
2020
  meanMaeFrame: maeFrameSum / frames.length,
1279
2021
  worstMae,
1280
2022
  worstMaeFrame,
@@ -1286,7 +2028,7 @@ function checkOneSet(
1286
2028
  changeDisagreements,
1287
2029
  worstChangeFrame,
1288
2030
  frames,
1289
- notes: prepared.notes,
2031
+ notes: [...framing.notes, ...prepared.notes, ...sheet.notes],
1290
2032
  };
1291
2033
  }
1292
2034
 
@@ -1393,6 +2135,176 @@ function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number
1393
2135
  return { pixels, mae: sum / 3 / count };
1394
2136
  }
1395
2137
 
2138
+ /**
2139
+ * Roll a set's frames up into one row per chain — the dashboard's rows.
2140
+ *
2141
+ * A chain that owns no slot is left out: it has nothing to attribute, and a row of
2142
+ * dashes in every set is noise in a table read sixteen times. The roster at the
2143
+ * foot of the report still lists it, so the account of where every bone went stays
2144
+ * complete.
2145
+ */
2146
+ function chainChecks(chains: BoneChain[], frames: FrameCheck[], tally: ChainTally): ChainCheck[] {
2147
+ const out: ChainCheck[] = [];
2148
+ chains.forEach((chain, index) => {
2149
+ if (chain.slots.length === 0) return;
2150
+ const own = new Set(chain.slots);
2151
+ const drew = new Set<string>();
2152
+ let worstDrift = 0;
2153
+ let worstDriftSlot: string | null = null;
2154
+ let worstDriftFrame = -1;
2155
+ let driftSum = 0;
2156
+ let driftSamples = 0;
2157
+ let driftFrames = 0;
2158
+ for (const frame of frames) {
2159
+ let sampled = false;
2160
+ for (const track of frame.slots) {
2161
+ if (!own.has(track.slot)) continue;
2162
+ if (track.candidate !== null) drew.add(track.slot);
2163
+ if (!isAttributable(track)) continue;
2164
+ const drift = track.drift as number;
2165
+ driftSum += drift;
2166
+ driftSamples++;
2167
+ sampled = true;
2168
+ if (drift > worstDrift) {
2169
+ worstDrift = drift;
2170
+ worstDriftSlot = track.slot;
2171
+ worstDriftFrame = frame.index;
2172
+ }
2173
+ }
2174
+ if (sampled) driftFrames++;
2175
+ }
2176
+ out.push({
2177
+ chain: chain.name,
2178
+ slots: chain.slots.length,
2179
+ drewSlots: drew.size,
2180
+ worstDrift,
2181
+ worstDriftSlot,
2182
+ worstDriftFrame,
2183
+ meanDrift: driftSamples === 0 ? 0 : driftSum / driftSamples,
2184
+ driftSamples,
2185
+ driftFrames,
2186
+ error: tally.error[index],
2187
+ referencePixels: tally.pixels[index],
2188
+ mae: tally.pixels[index] === 0 ? 0 : tally.error[index] / tally.pixels[index],
2189
+ maeShare: tally.total === 0 ? 0 : tally.error[index] / tally.total,
2190
+ });
2191
+ });
2192
+ return out;
2193
+ }
2194
+
2195
+ /**
2196
+ * A set's error, being split between the candidate's chains as its frames are read.
2197
+ *
2198
+ * Carried across frames rather than parked on each `FrameCheck` because a share is
2199
+ * a fact about the SET — and because a per-frame array of it would land in every
2200
+ * `--json` report and every `bench.json` for a number nobody reads per frame.
2201
+ */
2202
+ interface ChainTally {
2203
+ /** Absolute difference over reference-drawn pixels attributed to each chain. */
2204
+ error: number[];
2205
+ /** How many such pixels each chain took. */
2206
+ pixels: number[];
2207
+ /** The same, over reference pixels no chain could take — the candidate drew nothing. */
2208
+ unattributed: number;
2209
+ /** Every reference-drawn pixel's difference, chain or not: the share's denominator. */
2210
+ total: number;
2211
+ }
2212
+
2213
+ /** How much a diagonal step costs the chamfer pass below. */
2214
+ const DIAGONAL_STEP = Math.SQRT2;
2215
+
2216
+ /**
2217
+ * Give every pixel of the frame the chain whose ink is nearest to it.
2218
+ *
2219
+ * Two chamfer passes over the owner mask — forward then backward, propagating
2220
+ * (distance, label) together. It is an approximate Euclidean transform and that is
2221
+ * enough: what it decides is which of a handful of well-separated regions a pixel
2222
+ * belongs to, not a distance anybody reads.
2223
+ *
2224
+ * ⚠️ Nearest **ink the candidate drew**, so a chain that draws nothing seeds
2225
+ * nothing and is handed no pixels at all — its share reads 0 % while its slots are
2226
+ * missing entirely. That is why the table prints `drewSlots` beside the share: 0 %
2227
+ * on `0/3 slots` is the loudest row here, not the quietest one.
2228
+ *
2229
+ * The distance comes back with the label because the caller bounds it — see
2230
+ * `chainRadii`.
2231
+ */
2232
+ function nearestOwner(owner: Int32Array, width: number, height: number): { label: Int32Array; dist: Float32Array } {
2233
+ const label = Int32Array.from(owner);
2234
+ const dist = new Float32Array(width * height);
2235
+ for (let i = 0; i < label.length; i++) dist[i] = label[i] >= 0 ? 0 : Infinity;
2236
+ const relax = (at: number, from: number, step: number): void => {
2237
+ const reach = dist[from] + step;
2238
+ if (reach >= dist[at]) return;
2239
+ dist[at] = reach;
2240
+ label[at] = label[from];
2241
+ };
2242
+ for (let y = 0; y < height; y++) {
2243
+ for (let x = 0; x < width; x++) {
2244
+ const at = y * width + x;
2245
+ if (x > 0) relax(at, at - 1, 1);
2246
+ if (y > 0) {
2247
+ relax(at, at - width, 1);
2248
+ if (x > 0) relax(at, at - width - 1, DIAGONAL_STEP);
2249
+ if (x + 1 < width) relax(at, at - width + 1, DIAGONAL_STEP);
2250
+ }
2251
+ }
2252
+ }
2253
+ for (let y = height - 1; y >= 0; y--) {
2254
+ for (let x = width - 1; x >= 0; x--) {
2255
+ const at = y * width + x;
2256
+ if (x + 1 < width) relax(at, at + 1, 1);
2257
+ if (y + 1 < height) {
2258
+ relax(at, at + width, 1);
2259
+ if (x + 1 < width) relax(at, at + width + 1, DIAGONAL_STEP);
2260
+ if (x > 0) relax(at, at + width - 1, DIAGONAL_STEP);
2261
+ }
2262
+ }
2263
+ }
2264
+ return { label, dist };
2265
+ }
2266
+
2267
+ /**
2268
+ * How far each chain's attribution may reach, in frame pixels.
2269
+ *
2270
+ * The same judgement `src/slots.ts` makes about a slot — *past about its own long
2271
+ * side a part no longer overlaps where it was, and something out there is another
2272
+ * object rather than this one moved* — applied to the chain's own drawn box. Past
2273
+ * it, reference ink is left **unattributed** instead of being handed to whichever
2274
+ * chain happens to be nearest.
2275
+ *
2276
+ * ⚠️ This is the bound that keeps the dashboard honest about its own limits, and
2277
+ * it is a bound rather than a fix. Nothing candidate-side can know which part of
2278
+ * the REFERENCE a pixel belonged to; nearest-ink is a good guess while the figure
2279
+ * is roughly in place and a bad one once a part has left. So a part displaced past
2280
+ * its own size stops being blamed on its neighbour and starts showing up in the
2281
+ * `(unattributed)` row, next to the `reference component(s) no slot reaches` count
2282
+ * that says the same thing a different way.
2283
+ */
2284
+ function chainRadii(
2285
+ footprints: Map<string, Footprint>,
2286
+ chainOfSlot: Map<string, number>,
2287
+ chains: number,
2288
+ ): Float64Array {
2289
+ const minX = new Float64Array(chains).fill(Infinity);
2290
+ const minY = new Float64Array(chains).fill(Infinity);
2291
+ const maxX = new Float64Array(chains).fill(-Infinity);
2292
+ const maxY = new Float64Array(chains).fill(-Infinity);
2293
+ for (const [slot, foot] of footprints) {
2294
+ const chain = chainOfSlot.get(slot);
2295
+ if (chain === undefined || foot.pixels === 0) continue;
2296
+ if (foot.minX < minX[chain]) minX[chain] = foot.minX;
2297
+ if (foot.minY < minY[chain]) minY[chain] = foot.minY;
2298
+ if (foot.maxX > maxX[chain]) maxX[chain] = foot.maxX;
2299
+ if (foot.maxY > maxY[chain]) maxY[chain] = foot.maxY;
2300
+ }
2301
+ const out = new Float64Array(chains);
2302
+ for (let i = 0; i < chains; i++) {
2303
+ out[i] = maxX[i] < minX[i] ? -1 : searchRadius(maxX[i] - minX[i], maxY[i] - minY[i]);
2304
+ }
2305
+ return out;
2306
+ }
2307
+
1396
2308
  function checkOneFrame(
1397
2309
  index: number,
1398
2310
  file: string,
@@ -1403,8 +2315,16 @@ function checkOneFrame(
1403
2315
  reference: Plate,
1404
2316
  /** The candidate's own frame, rendered by the caller — it needs it too. */
1405
2317
  rendered: Plate,
2318
+ /** Slot name → chain index, for the per-chain split. */
2319
+ chainOfSlot: Map<string, number>,
2320
+ /** Accumulated across the set by the caller — see `ChainTally`. */
2321
+ tally: ChainTally,
1406
2322
  ): FrameCheck {
1407
- const { coverage, footprints } = frameGeometry(frame, pages, viewport);
2323
+ const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
2324
+ // Only worth the transform when something was drawn to be nearest TO.
2325
+ const nearest =
2326
+ owner !== null && owner.some((at) => at >= 0) ? nearestOwner(owner, viewport.width, viewport.height) : null;
2327
+ const radii = chainRadii(footprints, chainOfSlot, tally.error.length);
1408
2328
 
1409
2329
  let union = 0;
1410
2330
  let candidatePixels = 0;
@@ -1421,14 +2341,29 @@ function checkOneFrame(
1421
2341
  const b = reference.get(x, y);
1422
2342
  const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
1423
2343
  sumAll += delta;
2344
+ if (inReference) {
2345
+ // The share's denominator is the reference's own drawn pixels, and the
2346
+ // split is over exactly those — issue #119's lesson, as a partition.
2347
+ tally.total += delta;
2348
+ const at = y * viewport.width + x;
2349
+ const found = nearest === null ? -1 : nearest.label[at];
2350
+ const chain = found >= 0 && nearest !== null && nearest.dist[at] <= radii[found] ? found : -1;
2351
+ if (chain >= 0) {
2352
+ tally.error[chain] += delta;
2353
+ tally.pixels[chain]++;
2354
+ } else {
2355
+ tally.unattributed += delta;
2356
+ }
2357
+ }
1424
2358
  if (!inCandidate && !inReference) continue;
1425
2359
  union++;
1426
2360
  sum += delta;
1427
2361
  }
1428
2362
  }
1429
2363
 
1430
- const components = componentsOf(reference, background);
1431
- const { tracks, matchedComponents } = matchSlots(footprints, components, {
2364
+ const field = componentField(reference, background);
2365
+ const components = field.components;
2366
+ const { tracks, matchedComponents } = matchSlots(footprints, field, {
1432
2367
  frame,
1433
2368
  pages,
1434
2369
  viewport,
@@ -1454,6 +2389,10 @@ function checkOneFrame(
1454
2389
  index,
1455
2390
  file,
1456
2391
  mae: union === 0 ? 0 : sum / union,
2392
+ // The same numerator over a denominator the candidate does not control — see
2393
+ // `FrameCheck.maeReference`. Both figures are already in hand here, which is
2394
+ // why the second one costs nothing to publish.
2395
+ maeReference: referencePixels === 0 ? 0 : sum / referencePixels,
1457
2396
  maeFrame: sumAll / (viewport.width * viewport.height),
1458
2397
  unionPixels: union,
1459
2398
  candidatePixels,
@@ -1471,10 +2410,261 @@ function checkOneFrame(
1471
2410
  };
1472
2411
  }
1473
2412
 
2413
+ // ---------------------------------------------------------------------------
2414
+ // the contact sheet — see `SheetCheck`
2415
+ // ---------------------------------------------------------------------------
2416
+
2417
+ /** A sheet's grid, in the terms the tiles are cut out with. */
2418
+ interface SheetGeometry {
2419
+ columns: number;
2420
+ rows: number;
2421
+ tileWidth: number;
2422
+ tileHeight: number;
2423
+ /** Tile pixels per frame pixel. */
2424
+ tileScale: number;
2425
+ }
2426
+
2427
+ /**
2428
+ * A sheet's grid, **measured off the sheet** rather than taken on trust.
2429
+ *
2430
+ * `frames.json` records the frame count and the world box; it does not record the
2431
+ * tile size, because `--tile` is a per-run choice and the sheet's own dimensions
2432
+ * state the answer exactly. With `n` tiles in `c` columns the sheet is
2433
+ * `c·(w+1)+1` by `ceil(n/c)·(h+1)+1`, so a column count either divides both
2434
+ * dimensions exactly or is wrong — and the surviving candidate has to agree with
2435
+ * the frames' own aspect ratio as well, since both tile sides came from one scale.
2436
+ *
2437
+ * `SHEET_COLUMNS` is tried first because it is the contract
2438
+ * `bench/render_reference.ts` writes; the search behind it is what keeps a sheet
2439
+ * rendered by something else readable, and what makes a mismatch a **named
2440
+ * refusal** rather than a silent misread of somebody's grid.
2441
+ */
2442
+ export function sheetGeometry(
2443
+ sheet: Plate,
2444
+ tiles: number,
2445
+ pixelWidth: number,
2446
+ pixelHeight: number,
2447
+ ): SheetGeometry | null {
2448
+ if (tiles <= 0 || pixelWidth <= 0 || pixelHeight <= 0) return null;
2449
+ const candidates = [SHEET_COLUMNS, ...Array.from({ length: Math.min(tiles, 64) }, (_, i) => i + 1)];
2450
+ const slack = 1 / Math.max(pixelWidth, pixelHeight);
2451
+ let best: { geometry: SheetGeometry; error: number } | null = null;
2452
+ for (const columns of candidates) {
2453
+ if (columns > tiles) continue;
2454
+ const across = sheet.width - SHEET_GAP;
2455
+ const rows = Math.ceil(tiles / columns);
2456
+ const down = sheet.height - SHEET_GAP;
2457
+ if (across % columns !== 0 || down % rows !== 0) continue;
2458
+ const tileWidth = across / columns - SHEET_GAP;
2459
+ const tileHeight = down / rows - SHEET_GAP;
2460
+ if (tileWidth < 1 || tileHeight < 1) continue;
2461
+ // Both tile sides are one scale, rounded — so the two ratios agree to within
2462
+ // the rounding, and a grid that does not is a different grid.
2463
+ const error = Math.abs(tileWidth / pixelWidth - tileHeight / pixelHeight);
2464
+ if (error > slack) continue;
2465
+ const geometry = { columns, rows, tileWidth, tileHeight, tileScale: tileWidth / pixelWidth };
2466
+ if (best === null || error < best.error) best = { geometry, error };
2467
+ if (columns === SHEET_COLUMNS) break;
2468
+ }
2469
+ return best === null ? null : best.geometry;
2470
+ }
2471
+
2472
+ /**
2473
+ * The label burned into a tile's corner, as a box to leave out of the comparison.
2474
+ *
2475
+ * `bench/render_reference.ts` paints the frame's index at `(2, 2)` in the tile at
2476
+ * scale 1, and the candidate does not draw it. Left in, it would add the same
2477
+ * constant to every tile and a bigger one to four-digit frames than to one-digit
2478
+ * ones — a difference that is a fact about the labeller. The box is derived from
2479
+ * the font's own metrics, with a pixel of margin, rather than measured once and
2480
+ * written down.
2481
+ */
2482
+ function labelBox(index: number): { width: number; height: number } {
2483
+ return { width: 2 + textWidth(String(index), 1) + 1, height: 2 + GLYPH_H + 1 };
2484
+ }
2485
+
2486
+ /**
2487
+ * One frame set against its contact sheet, tile by tile.
2488
+ *
2489
+ * The candidate is rendered into the SAME world box the set was framed in, at the
2490
+ * sheet's own scale — so a set framed by `frames.json`'s own box is compared here
2491
+ * with no correction at all, and a set framed by a fit carries that fit (which,
2492
+ * for a stills-plus-sheet set, was measured on the stills). The report says which.
2493
+ */
2494
+ function checkAgainstSheet(
2495
+ root: string,
2496
+ prepared: PreparedSet,
2497
+ posable: ReturnType<typeof posableFromText>,
2498
+ viewport: Viewport,
2499
+ background: RGBA,
2500
+ ): { sheet: SheetCheck | null; notes: string[] } {
2501
+ const { set } = prepared;
2502
+ const file = join(root, set.dir, SHEET_FILE);
2503
+ if (!existsSync(file)) return { sheet: null, notes: [] };
2504
+ const onDisk = framesOnDisk(root, set.dir).length;
2505
+ // Every sampled frame already has a file of its own: the sheet is the same
2506
+ // pictures again, smaller, and measuring them twice would just report the
2507
+ // resampling.
2508
+ if (onDisk >= set.sampled) return { sheet: null, notes: [] };
2509
+ if (prepared.frames.length === 0) return { sheet: null, notes: [] };
2510
+
2511
+ const plate = readPlateFrom(root, file);
2512
+ const geometry = sheetGeometry(plate, set.sampled, viewport.width, viewport.height);
2513
+ if (geometry === null) {
2514
+ return {
2515
+ sheet: null,
2516
+ notes: [
2517
+ `${file} is ${plate.width}x${plate.height} px, which is not a grid of ${set.sampled} tile(s) at the ` +
2518
+ `${viewport.width}x${viewport.height} aspect of these frames — so the whole shot was NOT compared, only ` +
2519
+ `the ${onDisk} still(s) on disk. Re-render the set with bench/render_reference.ts if the sheet is stale.`,
2520
+ ],
2521
+ };
2522
+ }
2523
+
2524
+ const { columns, tileWidth, tileHeight, tileScale } = geometry;
2525
+ const tileViewport = viewportOfSize(
2526
+ viewport.minX,
2527
+ viewport.minY,
2528
+ viewport.maxX - viewport.minX,
2529
+ viewport.maxY - viewport.minY,
2530
+ viewport.scale * tileScale,
2531
+ tileWidth,
2532
+ tileHeight,
2533
+ );
2534
+ const compared = Math.min(set.sampled, prepared.frames.length);
2535
+ let maeSum = 0;
2536
+ let maeReferenceSum = 0;
2537
+ let worstMae = 0;
2538
+ let worstTile = -1;
2539
+ const per: Array<{ index: number; mae: number }> = [];
2540
+ for (let index = 0; index < compared; index++) {
2541
+ const frame = prepared.frames[index];
2542
+ const rendered = renderFrame(frame, posable.pages, tileViewport, background);
2543
+ const ox = SHEET_GAP + (index % columns) * (tileWidth + SHEET_GAP);
2544
+ const oy = SHEET_GAP + Math.floor(index / columns) * (tileHeight + SHEET_GAP);
2545
+ const label = labelBox(index);
2546
+ let union = 0;
2547
+ let referencePixels = 0;
2548
+ let sum = 0;
2549
+ for (let y = 0; y < tileHeight; y++) {
2550
+ for (let x = 0; x < tileWidth; x++) {
2551
+ if (y < label.height && x < label.width) continue;
2552
+ const sx = ox + x;
2553
+ const sy = oy + y;
2554
+ if (sx >= plate.width || sy >= plate.height) continue;
2555
+ const inReference = isContent(plate, sx, sy, background);
2556
+ const inCandidate = isContent(rendered, x, y, background);
2557
+ if (inReference) referencePixels++;
2558
+ if (!inReference && !inCandidate) continue;
2559
+ const a = rendered.get(x, y);
2560
+ const b = plate.get(sx, sy);
2561
+ union++;
2562
+ sum += (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
2563
+ }
2564
+ }
2565
+ const mae = union === 0 ? 0 : sum / union;
2566
+ maeSum += mae;
2567
+ maeReferenceSum += referencePixels === 0 ? 0 : sum / referencePixels;
2568
+ per.push({ index, mae });
2569
+ if (mae > worstMae) {
2570
+ worstMae = mae;
2571
+ worstTile = index;
2572
+ }
2573
+ }
2574
+ per.sort((a, b) => b.mae - a.mae);
2575
+ return {
2576
+ sheet: {
2577
+ file,
2578
+ columns,
2579
+ tileWidth,
2580
+ tileHeight,
2581
+ tileScale,
2582
+ tiles: set.sampled,
2583
+ compared,
2584
+ meanMae: compared === 0 ? 0 : maeSum / compared,
2585
+ meanMaeReference: compared === 0 ? 0 : maeReferenceSum / compared,
2586
+ worstMae,
2587
+ worstTile,
2588
+ worst: per.slice(0, WORST_FRAMES),
2589
+ },
2590
+ notes: [],
2591
+ };
2592
+ }
2593
+
2594
+ /** The sheet block, as the lines an author reads after the frame table. */
2595
+ function sheetLines(sheet: SheetCheck | null): string[] {
2596
+ if (sheet === null) return [];
2597
+ const worst = sheet.worst
2598
+ .map((tile) => `f${String(tile.index).padStart(4, '0')}=${tile.mae.toFixed(1)}`)
2599
+ .join(' ');
2600
+ return [
2601
+ ` sheet ${sheet.compared} of ${sheet.tiles} tile(s) of ${basename(sheet.file)} at ` +
2602
+ `${sheet.tileWidth}x${sheet.tileHeight}px in ${sheet.columns} column(s) MAE mean ${f2(sheet.meanMae)} ` +
2603
+ `worst ${f2(sheet.worstMae)} at f${String(sheet.worstTile).padStart(4, '0')} ` +
2604
+ `(over the reference's own pixels, mean ${f2(sheet.meanMaeReference)})`,
2605
+ ' ⤷ the frames this set does not commit as files. The candidate is sampled at the set\'s own ' +
2606
+ "rate and rendered into the same box the frames above were, at the sheet's scale. Read the " +
2607
+ 'series, not the mean: flat is framing or art, a spike is timing at that moment.',
2608
+ ` ⤷ worst ${sheet.worst.length}: ${worst}`,
2609
+ ];
2610
+ }
2611
+
1474
2612
  // ---------------------------------------------------------------------------
1475
2613
  // the report
1476
2614
  // ---------------------------------------------------------------------------
1477
2615
 
2616
+ /**
2617
+ * How much more than the reference a set may draw before `check` calls it
2618
+ * overdraw, as a ratio of drawn pixels.
2619
+ *
2620
+ * ## Why this direction needs its own warning
2621
+ *
2622
+ * `mae` divides by the pixels **either side drew** — the union — and the
2623
+ * candidate owns half of that denominator. A large, mostly transparent sprite
2624
+ * adds many cheap pixels to it and the *mean falls*, so anything optimising
2625
+ * against `mae` can buy a better score by drawing more, which is the opposite of
2626
+ * fidelity. Issue #119: spineboy-2's muzzle flare walked its own scale to 13x
2627
+ * doing exactly this, and cost every set in that run its framing. Reproduced
2628
+ * here, that candidate's `shoot` reads union MAE **39.65 against the honest
2629
+ * build's 47.20** — the metric calls the flare an improvement — while the same
2630
+ * difference over the reference's own pixels reads **73.06 against 52.54**.
2631
+ *
2632
+ * ⭐ Asymmetric on purpose. A candidate that draws LESS than the reference is
2633
+ * being punished by the MAE, not rewarded, and needs no warning to find out.
2634
+ *
2635
+ * ## Where 1.5 comes from
2636
+ *
2637
+ * Measured over the corpus rather than picked. Across the twelve committed
2638
+ * candidates in `bench/runs/` — 64 compared sets, 1 to 121 frames each — the
2639
+ * ratio spans **0.852 … 1.069** on 62 of them, and the two above that are both
2640
+ * the same shot on the same character: spineboy-1's `shoot@30fps` at 1.154 and
2641
+ * spineboy-2's at **1.274**, two-frame stills sets where the muzzle flare lands
2642
+ * a frame off. Rung 8's ball reads 1.041, rung 3's candidate 0.993.
2643
+ *
2644
+ * The 13x flare reads **1.850** on `shoot` and **3.199** on `shoot@30fps`, and
2645
+ * 0.94–1.01 on the fourteen sets that do not draw it — so the warning names the
2646
+ * shot the overdraw is in rather than colouring the whole run.
2647
+ *
2648
+ * 1.5 is the geometric middle of the gap between the widest honest reading and
2649
+ * the weakest defective one (1.274 · 1.850 ≈ 1.535²): half again as much ink as
2650
+ * the reference put down, which no honest candidate in the corpus approaches and
2651
+ * which the case this was built for clears on both its sets.
2652
+ *
2653
+ * ## What was measured and rejected: the content boxes
2654
+ *
2655
+ * Issue #119 suggests the two content boxes, and `check` has both. Measured, that
2656
+ * test is defeated by the framing it is measured through. `fitFraming` absorbs a
2657
+ * uniform scale on purpose, so a candidate that draws everything too big reads a
2658
+ * box growth of **−3.4 %** while its union MAE falls 137.6 → 36.0 — the fit
2659
+ * simply shrinks it back. It also fires where nothing is overdrawn: the
2660
+ * time-reversed fixture, whose ink is right and whose *timing* is wrong, reads
2661
+ * **+14.1 %** because sampling a reversed shot lands on different poses. Counting
2662
+ * ink is blind to both — that same reversed fixture draws **1.30–1.39x**, under
2663
+ * the bar, and a bloated one draws what it drew whatever the framing does with it
2664
+ * afterwards. C08 and C09 hold both ends of that.
2665
+ */
2666
+ export const OVERDRAW_RATIO = 1.5;
2667
+
1478
2668
  /** How many worst frames a set prints when the whole set is too long to list. */
1479
2669
  export const WORST_FRAMES = 8;
1480
2670
  /** Sets no longer than this print every frame. */
@@ -1487,17 +2677,15 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1487
2677
  lines.push(` candidate ${report.candidate.skeleton}`);
1488
2678
  lines.push(` atlas ${report.candidate.atlas}`);
1489
2679
  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
2680
  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})`,
2681
+ ` scope ${
2682
+ report.framingScope === 'per-shot'
2683
+ ? "the framing decided per frame set (--framing shared measures every set in one shared framing)"
2684
+ : "one framing across every frame set (--framing per-shot lets a set take frames.json's own box instead)"
2685
+ }`,
1500
2686
  );
2687
+ const v = report.viewport;
2688
+ if (v !== null) lines.push(...framedToLines(v, report.framing));
1501
2689
  const r = report.referenceViewport;
1502
2690
  if (r) {
1503
2691
  lines.push(
@@ -1506,7 +2694,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1506
2694
  );
1507
2695
  lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
1508
2696
  }
1509
- for (const line of framingLines(report)) lines.push(line);
2697
+ for (const line of framingLines(report.framingFit)) lines.push(line);
2698
+ if (report.sharedFraming) {
2699
+ const shared = report.sharedFraming;
2700
+ const f = shared.fit;
2701
+ const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
2702
+ lines.push(
2703
+ ` shared box one box for all ${report.animations.length} set(s) leaves x${f.scale.toFixed(6)} offset ` +
2704
+ `${signed(f.dx)}, ${signed(f.dy)} px rms ${f.rms.toFixed(2)} px over ${f.frames * 4} edge(s) ` +
2705
+ `(${shared.source}; used for every set that could not take the frames' own box)`,
2706
+ );
2707
+ lines.push(
2708
+ " ⤷ how far one shared framing is from serving every set. A set below that took the frames' own " +
2709
+ 'box instead is measured with no such correction at all; --framing shared measures every set here.',
2710
+ );
2711
+ }
1510
2712
  for (const note of report.notes) lines.push(` ⚠️ ${note}`);
1511
2713
  lines.push('');
1512
2714
 
@@ -1521,6 +2723,13 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1521
2723
  lines.push(
1522
2724
  ` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
1523
2725
  );
2726
+ // Only when this set has a framing of its own: under a shared scope, or a pin,
2727
+ // the header already printed the one box every set was measured in, and
2728
+ // repeating it per set would read as though they differed.
2729
+ if (report.framingScope === 'per-shot' && report.viewport === null) {
2730
+ for (const line of framedToLines(anim.viewport, anim.framing, ' ')) lines.push(line);
2731
+ for (const line of framingLines(anim.framingFit, ' ')) lines.push(line);
2732
+ }
1524
2733
  for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
1525
2734
  if (anim.compared === 0) {
1526
2735
  lines.push('');
@@ -1530,6 +2739,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1530
2739
  ` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} at f${String(anim.worstMaeFrame).padStart(4, '0')}` +
1531
2740
  ` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
1532
2741
  );
2742
+ lines.push(
2743
+ ` ⤷ over the REFERENCE's own drawn pixels, mean ${f2(anim.meanMaeReference)} — the union figure ` +
2744
+ 'compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.',
2745
+ );
2746
+ if (anim.drawnRatio > OVERDRAW_RATIO) {
2747
+ const mine = Math.round(anim.frames.reduce((sum, f) => sum + f.candidatePixels, 0) / anim.frames.length);
2748
+ const theirs = Math.round(anim.frames.reduce((sum, f) => sum + f.referencePixels, 0) / anim.frames.length);
2749
+ lines.push(
2750
+ ` ⚠️ overdraw: this shot draws ${mine.toLocaleString('en-US')} px a frame where the reference ` +
2751
+ `draws ${theirs.toLocaleString('en-US')} — ${anim.drawnRatio.toFixed(2)}x as much ink, past the ` +
2752
+ `${OVERDRAW_RATIO}x no committed candidate reaches. Most of that excess lands in the MAE's own ` +
2753
+ 'denominator and makes the figure above cheaper without moving a pixel closer, so read the one under it. ' +
2754
+ 'Something here is drawn that should not be, or is far too big.',
2755
+ );
2756
+ }
1533
2757
  const blind =
1534
2758
  anim.framesWithoutDrift === 0
1535
2759
  ? ''
@@ -1541,6 +2765,8 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1541
2765
  `f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
1542
2766
  );
1543
2767
  lines.push(changeSummary(anim));
2768
+ for (const line of sheetLines(anim.sheet)) lines.push(line);
2769
+ for (const line of chainTable(anim)) lines.push(line);
1544
2770
  lines.push('');
1545
2771
 
1546
2772
  const listed = framesToList(anim, opts?.allFrames === true);
@@ -1574,14 +2800,22 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1574
2800
  lines.push('');
1575
2801
  }
1576
2802
 
2803
+ for (const line of chainFoot(report)) lines.push(line);
2804
+
1577
2805
  lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
1578
2806
  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');
2807
+ lines.push(' there is one in `diff`. The figure under it divides the same difference by the pixels the');
2808
+ lines.push(' REFERENCE drew — a denominator you cannot grow, which is what makes it the one to author');
2809
+ lines.push(' against; it is not bounded by 255. Read the framing line first: it is upstream of every number');
1580
2810
  lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
1581
2811
  lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
1582
2812
  lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
1583
2813
  lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
1584
2814
  lines.push(' than its best rival, and a slot that matched nothing at all is left out of the count.');
2815
+ lines.push(' A `sheet` line is the frames a set does not commit as files: the candidate sampled at the');
2816
+ lines.push(" set's own rate against the tiles of its contact.png, in the same box the frame table used.");
2817
+ lines.push(' Read it as a series — flat is framing or art, a spike is timing at that moment — and note');
2818
+ lines.push(' that it is MAE only: the change columns below are pixel counts at frame scale.');
1585
2819
  lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
1586
2820
  lines.push(' not against each other. They are the only columns that can see a held pose that is');
1587
2821
  lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
@@ -1589,6 +2823,164 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1589
2823
  return lines;
1590
2824
  }
1591
2825
 
2826
+ /** One drift, as the table says it: distance, slot, frame. */
2827
+ function driftPhrase(drift: number, slot: string | null, frame: number): string {
2828
+ if (slot === null) return 'no slot attributable';
2829
+ return `${drift.toFixed(1)} px ${JSON.stringify(slot)} f${String(frame).padStart(4, '0')}`;
2830
+ }
2831
+
2832
+ /** One set, broken down by chain — see `ChainCheck`. */
2833
+ function chainTable(anim: AnimationCheck): string[] {
2834
+ if (anim.chains.length === 0) return [];
2835
+ const out: string[] = [];
2836
+ out.push(
2837
+ ` chains ${anim.chains.length} from the candidate's own bone tree — the roster is at the foot of the report`,
2838
+ );
2839
+ out.push(
2840
+ ` ${'chain'.padEnd(20)} ${'slots'.padStart(6)} ${'worst slot drift'.padEnd(33)} ` +
2841
+ `${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
2842
+ );
2843
+ // Derivation order rather than worst-first, so the same row is in the same place
2844
+ // in every set's table and a run can be read down a column.
2845
+ for (const chain of anim.chains) {
2846
+ const mean = chain.driftSamples === 0 ? '—' : `${chain.meanDrift.toFixed(1)} px`;
2847
+ out.push(
2848
+ ` ${chain.chain.padEnd(20)} ${`${chain.drewSlots}/${chain.slots}`.padStart(6)} ` +
2849
+ `${driftPhrase(chain.worstDrift, chain.worstDriftSlot, chain.worstDriftFrame).padEnd(33)} ` +
2850
+ `${mean.padStart(8)} ${f2(chain.mae).padStart(9)} ${`${(chain.maeShare * 100).toFixed(1)}%`.padStart(6)}`,
2851
+ );
2852
+ }
2853
+ if (anim.unattributedError > 0 && anim.chainDenominator > 0) {
2854
+ const share = (anim.unattributedError / anim.chainDenominator) * 100;
2855
+ out.push(
2856
+ ` ${'(unattributed)'.padEnd(20)} ${'—'.padStart(6)} ${'—'.padEnd(33)} ${'—'.padStart(8)} ` +
2857
+ `${'—'.padStart(9)} ${`${share.toFixed(1)}%`.padStart(6)}`,
2858
+ );
2859
+ }
2860
+ out.push(
2861
+ " ⤷ share is of this set's own difference over the REFERENCE's drawn pixels, split by nearest ink; " +
2862
+ '`MAE in it` is that same error per pixel. The rule, the denominator and what a 0 % row means are under ' +
2863
+ '"chains" at the foot of the report.',
2864
+ );
2865
+ return out;
2866
+ }
2867
+
2868
+ /** One line per chain across every set, plus the roster the names refer to. */
2869
+ function chainFoot(report: CheckReport): string[] {
2870
+ if (report.chains.length === 0) return [];
2871
+ const out: string[] = [];
2872
+ out.push(' ── chains ──');
2873
+ out.push(
2874
+ " Cut from the CANDIDATE's own bone tree at every branch point: a chain runs from a root or a fork down to the",
2875
+ );
2876
+ out.push(
2877
+ ' next fork, a single-bone chain that is itself a fork folds into its parent, and each is named after the first',
2878
+ );
2879
+ out.push(
2880
+ ' bone in it that carries a slot. The reference is still nothing but pixels — this is your figure decomposed,',
2881
+ );
2882
+ out.push(' not the reference’s, which is what keeps it inside the ladder’s honesty rule.');
2883
+ out.push('');
2884
+ out.push(" MAE share divides the difference over the REFERENCE's own drawn pixels — the denominator from the MAE");
2885
+ out.push(' line above, which nothing you draw can grow — and splits it by giving each of those pixels to the chain');
2886
+ out.push(' whose ink is NEAREST it. So the shares are a partition and add to the whole, and no chain can look');
2887
+ out.push(' better by drawing more: growing its ink only pulls more of the reference’s pixels, and their error,');
2888
+ out.push(' into it. `MAE in it` is the same error per pixel it took, and it is the column that separates a chain');
2889
+ out.push(' that is WRONG from one that is merely large — a head and its features cover a lot of a figure and can');
2890
+ out.push(' carry a third of the error at a below-average figure per pixel.');
2891
+ out.push('');
2892
+ out.push(' ⚠️ Two things the split cannot do, both of which show rather than hide. Reference ink further from your');
2893
+ out.push(' ink than the part’s own size is left `(unattributed)` instead of blamed on a neighbour, so a part that');
2894
+ out.push(' has left its place stops being charged to whatever is next to it. And a chain that draws NOTHING seeds');
2895
+ out.push(' nothing and reads 0 % — which is why the slots column is beside the share: 0 % on 0 slots drawn is the');
2896
+ out.push(' loudest row here, not the quietest.');
2897
+ out.push(` ${'chain'.padEnd(20)} ${'bones'.padEnd(57)} slots`);
2898
+ for (const chain of report.chains) {
2899
+ out.push(
2900
+ ` ${chain.name.padEnd(20)} ${chain.bones.join(', ').padEnd(57)} ` +
2901
+ `${chain.slots.length === 0 ? '(draws nothing)' : chain.slots.join(', ')}`,
2902
+ );
2903
+ }
2904
+ const rows = chainRollup(report);
2905
+ if (rows.length === 0) return out;
2906
+ out.push('');
2907
+ out.push(
2908
+ ` ${'chain'.padEnd(20)} ${'worst slot drift across every set'.padEnd(56)} ` +
2909
+ `${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
2910
+ );
2911
+ for (const row of rows) {
2912
+ const where = row.set === null ? '' : ` in ${row.set}/f${String(row.frame).padStart(4, '0')}`;
2913
+ const worst =
2914
+ row.slot === null ? 'no slot attributable in any set' : `${row.drift.toFixed(1)} px ${JSON.stringify(row.slot)}${where}`;
2915
+ const mean = row.samples === 0 ? '—' : `${row.mean.toFixed(1)} px`;
2916
+ out.push(
2917
+ ` ${row.chain.padEnd(20)} ${worst.padEnd(56)} ${mean.padStart(8)} ` +
2918
+ `${(row.pixels === 0 ? 0 : row.error / row.pixels).toFixed(2).padStart(9)} ` +
2919
+ `${`${(row.share * 100).toFixed(1)}%`.padStart(6)}`,
2920
+ );
2921
+ }
2922
+ out.push('');
2923
+ return out;
2924
+ }
2925
+
2926
+ interface ChainRollup {
2927
+ chain: string;
2928
+ drift: number;
2929
+ slot: string | null;
2930
+ set: string | null;
2931
+ frame: number;
2932
+ mean: number;
2933
+ samples: number;
2934
+ error: number;
2935
+ pixels: number;
2936
+ share: number;
2937
+ }
2938
+
2939
+ /**
2940
+ * Each chain's worst reading anywhere in the run, worst share first.
2941
+ *
2942
+ * Worst-first here and derivation order in the per-set tables, deliberately: this
2943
+ * is the line a run’s README quotes, so it is ranked by what to fix, while a table
2944
+ * printed once per set is ranked so the sets line up.
2945
+ */
2946
+ function chainRollup(report: CheckReport): ChainRollup[] {
2947
+ const rows = new Map<string, ChainRollup>();
2948
+ let denominator = 0;
2949
+ for (const anim of report.animations) {
2950
+ if (anim.compared === 0) continue;
2951
+ denominator += anim.chainDenominator;
2952
+ for (const chain of anim.chains) {
2953
+ const row = rows.get(chain.chain) ?? {
2954
+ chain: chain.chain,
2955
+ drift: 0,
2956
+ slot: null,
2957
+ set: null,
2958
+ frame: -1,
2959
+ mean: 0,
2960
+ samples: 0,
2961
+ error: 0,
2962
+ pixels: 0,
2963
+ share: 0,
2964
+ };
2965
+ if (chain.worstDriftSlot !== null && chain.worstDrift > row.drift) {
2966
+ row.drift = chain.worstDrift;
2967
+ row.slot = chain.worstDriftSlot;
2968
+ row.set = anim.dir;
2969
+ row.frame = chain.worstDriftFrame;
2970
+ }
2971
+ row.mean = row.mean * row.samples + chain.meanDrift * chain.driftSamples;
2972
+ row.samples += chain.driftSamples;
2973
+ row.mean = row.samples === 0 ? 0 : row.mean / row.samples;
2974
+ row.error += chain.error;
2975
+ row.pixels += chain.referencePixels;
2976
+ rows.set(chain.chain, row);
2977
+ }
2978
+ }
2979
+ const out = [...rows.values()];
2980
+ for (const row of out) row.share = denominator === 0 ? 0 : row.error / denominator;
2981
+ return out.sort((a, b) => b.share - a.share);
2982
+ }
2983
+
1592
2984
  /**
1593
2985
  * The frames worth printing: the worst by MAE, plus every change disagreement.
1594
2986
  *
@@ -1659,8 +3051,72 @@ function convergence(framing: FramingReport): string {
1659
3051
  }
1660
3052
 
1661
3053
  /** The framing, as the line an author reads before anything else. */
1662
- function framingLines(report: CheckReport): string[] {
1663
- const framing = report.framingFit;
3054
+ /** The `framed to` line: the box that was rendered into, and how it was chosen. */
3055
+ function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[] {
3056
+ const said =
3057
+ how === 'candidate-pixels'
3058
+ ? "fitted to the candidate's own drawn pixels"
3059
+ : how === 'frames-viewport'
3060
+ ? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
3061
+ : '--viewport';
3062
+ return [
3063
+ `${indent} framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
3064
+ `world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${said})`,
3065
+ ];
3066
+ }
3067
+
3068
+ /**
3069
+ * What the MAE-refined pass found, in the words that separate its four answers.
3070
+ *
3071
+ * All four are printed, the identity included, because "the framing is already
3072
+ * where the picture is" is a measurement this pass makes and not a default it
3073
+ * falls back to — the same reason an assertion with nothing to measure reports
3074
+ * SKIP here rather than a pass. The two that decline a real offset are the loud
3075
+ * ones: they say the constant pixel is in the candidate rather than in the box.
3076
+ */
3077
+ function refinementLines(refinement: FramingRefinement | null, indent = ''): string[] {
3078
+ if (refinement === null) return [];
3079
+ const { dx, dy, before, after, radius, frames } = refinement;
3080
+ const at = `${dx >= 0 ? '+' : ''}${dx}, ${dy >= 0 ? '+' : ''}${dy} px`;
3081
+ const worth =
3082
+ `${f2(before)} → ${f2(after)} over the reference's own pixels` +
3083
+ (before > 0 ? ` (${(((before - after) / before) * 100).toFixed(1)}% of the figure)` : '');
3084
+ const searched = `±${radius} px over ${frames} frame(s)`;
3085
+ if (refinement.declined === 'identity') {
3086
+ return [
3087
+ `${indent} ⤷ MAE-refined pass: searched ${searched} and the identity won, so no part of this set's ` +
3088
+ 'figure is a constant offset.',
3089
+ ];
3090
+ }
3091
+ if (refinement.declined === 'below-threshold') {
3092
+ return [
3093
+ `${indent} ⤷ MAE-refined pass: the best offset in ${searched} was ${at}, worth ${worth} — under the ` +
3094
+ `${(REFINE_MIN_GAIN * 100).toFixed(0)}% / ${REFINE_MIN_GAIN_MAE.toFixed(2)} MAE this pass moves a box for, so ` +
3095
+ 'the box was left alone.',
3096
+ ];
3097
+ }
3098
+ if (refinement.declined === 'box-is-exact') {
3099
+ return [
3100
+ `${indent} ⚠️ a constant ${at} would take this set ${worth} — and it was NOT applied, because this ` +
3101
+ `box is ${FRAMES_SIDECAR}'s own and is not an estimate of anything. A constant pixel inside the box the ` +
3102
+ 'frames were drawn at is your own figure sitting a pixel off, which is a thing to fix rather than to frame ' +
3103
+ 'away. Read it beside the drift below.',
3104
+ ];
3105
+ }
3106
+ if (refinement.declined === 'pinned') {
3107
+ return [
3108
+ `${indent} ⤷ a constant ${at} would take this set ${worth} — measured, NOT applied, because ` +
3109
+ '--viewport pinned the box.',
3110
+ ];
3111
+ }
3112
+ return [
3113
+ `${indent} ⭐ MAE-refined by ${at}: ${worth}. The fit above registers the two extents, and the best ` +
3114
+ 'fit of two extents is not the best alignment of two pictures — this pass takes that difference out, so the ' +
3115
+ 'figures below are what is left after it rather than a constant offset read as motion.',
3116
+ ];
3117
+ }
3118
+
3119
+ function framingLines(framing: FramingReport | null, indent = ''): string[] {
1664
3120
  if (!framing) return [];
1665
3121
  const { fit } = framing;
1666
3122
  const c = fit.candidate;
@@ -1670,8 +3126,8 @@ function framingLines(report: CheckReport): string[] {
1670
3126
  `${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
1671
3127
  const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
1672
3128
  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 ` +
3129
+ `${indent} content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
3130
+ `${indent} ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
1675
3131
  `rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
1676
3132
  `union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
1677
3133
  `aspect ${percent(fit.aspectError)}` +
@@ -1679,11 +3135,12 @@ function framingLines(report: CheckReport): string[] {
1679
3135
  ? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
1680
3136
  : ' (measured, NOT applied — --viewport pinned)'),
1681
3137
  ];
3138
+ out.push(...refinementLines(framing.refinement, indent));
1682
3139
  const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
1683
3140
  if (spread > 1) {
1684
3141
  const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
1685
3142
  out.push(
1686
- ` ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
3143
+ `${indent} ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
1687
3144
  `${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
1688
3145
  `${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
1689
3146
  'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
@@ -1692,7 +3149,7 @@ function framingLines(report: CheckReport): string[] {
1692
3149
  }
1693
3150
  if (fit.rms > 1) {
1694
3151
  out.push(
1695
- ` ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
3152
+ `${indent} ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
1696
3153
  'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
1697
3154
  'misframed.',
1698
3155
  );
@@ -1700,12 +3157,12 @@ function framingLines(report: CheckReport): string[] {
1700
3157
  const units = framing.units;
1701
3158
  if (units) {
1702
3159
  out.push(
1703
- ` in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
3160
+ `${indent} in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
1704
3161
  `reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
1705
3162
  `x${units.ratio.toFixed(4)}`,
1706
3163
  );
1707
3164
  out.push(
1708
- ' ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ' +
3165
+ `${indent} ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ` +
1709
3166
  'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
1710
3167
  'only if you measured the shot in the frames’ own units.',
1711
3168
  );