spine-rigc 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.ts CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  PAD,
78
78
  FRAMES_SIDECAR,
79
79
  FRAMES_SPEC,
80
+ type Footprint,
80
81
  type Frame,
81
82
  type FramesSidecar,
82
83
  type FrameSet,
@@ -93,6 +94,7 @@ import {
93
94
  fitIsSettled,
94
95
  fitSeparation,
95
96
  frameContentBox,
97
+ unionBoxes,
96
98
  isContent,
97
99
  BACKGROUND_TOLERANCE,
98
100
  CYCLE_PIXELS,
@@ -100,10 +102,12 @@ import {
100
102
  type ContentBox,
101
103
  type FramingFit,
102
104
  } from './framing.ts';
103
- import { componentsOf, isAttributable, matchSlots, type SlotTrack } from './slots.ts';
105
+ import { componentsOf, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
106
+ import { chainsOf, type BoneChain } from './chains.ts';
104
107
  import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
105
108
 
106
109
  export { componentsOf, matchSlots, searchRadius, type Component, type MatchMethod, type SlotTrack } from './slots.ts';
110
+ export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
107
111
  export type { BoxPair, ContentBox, FramingFit } from './framing.ts';
108
112
 
109
113
  // ---------------------------------------------------------------------------
@@ -259,6 +263,25 @@ export interface FrameCheck {
259
263
  file: string;
260
264
  /** Mean absolute RGB difference over the union alpha, 0..255. */
261
265
  mae: number;
266
+ /**
267
+ * The same total difference over the REFERENCE's own drawn pixels alone.
268
+ *
269
+ * ⭐ The figure to optimise against, and the reason is the denominator. `mae`
270
+ * divides by the pixels either side drew, and the candidate owns half of that:
271
+ * drawing something large and mostly transparent adds many cheap pixels to the
272
+ * union and the *mean falls*, so an optimiser can buy a better score by growing
273
+ * (issue #119 — a muzzle flare walked its own scale to 13x doing exactly this).
274
+ * This denominator is the reference's, which nothing the candidate does can
275
+ * move, so the only way down is to draw the reference's picture.
276
+ *
277
+ * ⚠️ Not bounded by 255, and deliberately: a candidate that draws far more than
278
+ * the reference has more absolute error than the reference has pixels to carry
279
+ * it, and the figure says so instead of saturating.
280
+ *
281
+ * `mae` is still the right figure for comparing two builds of the same rig,
282
+ * where the union is near enough the same on both sides.
283
+ */
284
+ maeReference: number;
262
285
  /**
263
286
  * The same difference averaged over the WHOLE frame, background included.
264
287
  *
@@ -285,6 +308,60 @@ export interface FrameCheck {
285
308
  change: FrameChange | null;
286
309
  }
287
310
 
311
+ /**
312
+ * One bone chain's slice of a set — the row an author reads before deciding what
313
+ * to re-key.
314
+ *
315
+ * The chains come from the CANDIDATE's bone tree (`src/chains.ts` owns the rule
316
+ * and the reasoning); the reference stays pixels, so this is a decomposition of
317
+ * your own figure and never a reading of the answer.
318
+ */
319
+ export interface ChainCheck {
320
+ /** The chain, named as `src/chains.ts` names it. */
321
+ chain: string;
322
+ /** How many slots it owns. */
323
+ slots: number;
324
+ /** How many of those drew anything in at least one compared frame. */
325
+ drewSlots: number;
326
+ /** The worst attributable slot drift anywhere in it, in frame pixels. */
327
+ worstDrift: number;
328
+ /** Which slot that was, and in which frame — `null`/`-1` when none was attributable. */
329
+ worstDriftSlot: string | null;
330
+ worstDriftFrame: number;
331
+ /** The mean of every attributable slot drift in it, over `driftSamples` of them. */
332
+ meanDrift: number;
333
+ driftSamples: number;
334
+ /** How many frames contributed at least one of those samples. */
335
+ driftFrames: number;
336
+ /**
337
+ * The absolute RGB difference attributed to this chain, summed over the
338
+ * REFERENCE's own drawn pixels — never over the union.
339
+ *
340
+ * ⭐ The denominator lesson from issue #119, applied to a share. A reference
341
+ * pixel goes to the chain whose ink is nearest to it, so the chains partition
342
+ * the reference's drawn pixels and the shares add up to the whole. What the
343
+ * candidate controls is only *which* chain a pixel lands in, and growing a
344
+ * chain's ink pulls MORE of the reference's pixels — and their error — into it.
345
+ * There is no move here that makes a chain look better by drawing more, which is
346
+ * exactly what the union MAE could not say.
347
+ */
348
+ error: number;
349
+ /** How many reference-drawn pixels it took, summed over frames. */
350
+ referencePixels: number;
351
+ /**
352
+ * `error` per pixel it took — the MAE *inside* this chain, 0..255.
353
+ *
354
+ * Printed beside the share because the share alone confounds being wrong with
355
+ * being big: spineboy's head, goggles, eye and mouth are one chain covering a
356
+ * lot of the figure, so it can carry a third of the error at a per-pixel figure
357
+ * below the run's own mean. The share says where the error IS; this says whether
358
+ * the chain is actually worse than the rest of the figure.
359
+ */
360
+ mae: number;
361
+ /** `error` over the set's own total, 0..1 — see `AnimationCheck.chainDenominator`. */
362
+ maeShare: number;
363
+ }
364
+
288
365
  export interface AnimationCheck {
289
366
  dir: string;
290
367
  /** The animation the frames show, per the sidecar. */
@@ -296,6 +373,18 @@ export interface AnimationCheck {
296
373
  candidateFrames: number;
297
374
  compared: number;
298
375
  meanMae: number;
376
+ /** Mean of the per-frame reference-denominator MAE — see `FrameCheck.maeReference`. */
377
+ meanMaeReference: number;
378
+ /**
379
+ * How much this set draws, against how much the reference draws: the mean over
380
+ * its frames of `candidatePixels / referencePixels`.
381
+ *
382
+ * 1 means the two shots put ink on the same amount of the frame. Above 1 the
383
+ * candidate is drawing more than the reference does, which is the move that
384
+ * makes the union MAE cheaper — see `OVERDRAW_RATIO`, which is where the
385
+ * threshold and the corpus it came from are written down.
386
+ */
387
+ drawnRatio: number;
299
388
  /** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
300
389
  meanMaeFrame: number;
301
390
  worstMae: number;
@@ -311,7 +400,38 @@ export interface AnimationCheck {
311
400
  changeDisagreements: number;
312
401
  /** The widest of those disagreements, and `-1` when there is none. */
313
402
  worstChangeFrame: number;
403
+ /**
404
+ * This set, broken down by the candidate's own bone chains — see `ChainCheck`.
405
+ *
406
+ * Chains that own no slot at all are left out: they have nothing to attribute.
407
+ * They are still in `CheckReport.chains`, so the roster stays a complete account
408
+ * of where every bone went.
409
+ */
410
+ chains: ChainCheck[];
411
+ /**
412
+ * The set's whole difference over the reference's own drawn pixels — the
413
+ * denominator every `ChainCheck.maeShare` divides by.
414
+ *
415
+ * The same numerator `meanMaeReference` averages, kept as a total because a
416
+ * share needs the total and a mean has already divided it away.
417
+ */
418
+ chainDenominator: number;
419
+ /** The part of it no chain could take, because the candidate drew nothing at all. */
420
+ unattributedError: number;
314
421
  frames: FrameCheck[];
422
+ /**
423
+ * The box THIS set's candidate frames were rendered into.
424
+ *
425
+ * Under the default per-shot scope every set carries its own, and they are
426
+ * different boxes; under `--framing shared` they are all the same one. Either
427
+ * way it is here rather than only at the top of the report, because it is
428
+ * upstream of every number in this row.
429
+ */
430
+ viewport: Framing;
431
+ /** How this set's box was chosen — see `FramingSource`. */
432
+ framing: FramingHow;
433
+ /** Where this set's drawn pixels ended up against the reference's. */
434
+ framingFit: FramingReport | null;
315
435
  notes: string[];
316
436
  }
317
437
 
@@ -339,6 +459,44 @@ export interface Framing {
339
459
  */
340
460
  export type FramingSource = 'derived' | 'declared' | 'pinned';
341
461
 
462
+ /** The same three, named for the report line rather than for the code path. */
463
+ export type FramingHow = 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
464
+
465
+ /**
466
+ * Whether the framing is decided per frame set, or once across every set.
467
+ *
468
+ * ## What `per-shot` actually scopes, and why only that
469
+ *
470
+ * A framing is decided over the frames it is measured on, so pointing `check` at a
471
+ * skeleton root used to decide ONE for every set under it — and one badly-framed
472
+ * shot was then paid for by all the others. Measured on the spineboy rung, 8 shots
473
+ * and 147 frames: `idle` read **41.59** MAE at the root against the **18.77** it
474
+ * reads on its own frames, with not one key different (issue #100).
475
+ *
476
+ * `per-shot` moves exactly one decision into the set: **whether the box
477
+ * `frames.json` records is this set's box too.** That decision is a measurement
478
+ * with no floor — either the set's own drawn pixels land in the declared box or
479
+ * they do not — and over the union one shot that does not can put the pooled
480
+ * correction over `COINCIDENT_PIXELS` and take every other shot down with it. Per
481
+ * set, the ones that qualify read exactly what pinning by hand reads.
482
+ *
483
+ * ⚠️ It does NOT fit a separate chain per set, and that is a measured decision
484
+ * rather than a simplification. Per-set FITTING is worse: `fitFraming` registers
485
+ * extent, extent is not alignment, and one shot's frames do not constrain that
486
+ * enough — spineboy's `hit` reads 92.36 fitted on its own against 60.59 in the
487
+ * shared fit, and its two-frame `shoot@30fps` set reads 101.94 against 42.98. So a
488
+ * set that cannot take the declared box is measured in the shared framing, where
489
+ * every frame in the run constrains the answer.
490
+ *
491
+ * `shared` is the old behaviour, and it answers one question well: *does a single
492
+ * box serve every set?* The report prints that fit either way — see
493
+ * `CheckReport.sharedFraming`.
494
+ *
495
+ * ⚠️ The two are different measurements and their absolute numbers are not
496
+ * comparable across builds. The report says which one it did.
497
+ */
498
+ export type FramingScope = 'per-shot' | 'shared';
499
+
342
500
  /** What the framing pass concluded, and how sure it is of it. */
343
501
  export interface FramingReport {
344
502
  /** The residual fit measured at the viewport that was used. */
@@ -401,12 +559,35 @@ export interface CheckReport {
401
559
  candidate: { skeleton: string; atlas: string };
402
560
  framesDir: string;
403
561
  framesRoot: string;
404
- /** How the candidate's own world box was chosen. */
405
- framing: 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
562
+ /** One framing per set, or one across every set — see `FramingScope`. */
563
+ framingScope: FramingScope;
564
+ /**
565
+ * How the candidate's own world box was chosen, when ONE box covers the run.
566
+ *
567
+ * `null` under a per-shot scope with more than one set compared: there is no
568
+ * single answer then, and each `AnimationCheck` carries its own. A run that
569
+ * compared exactly one set fills these in whatever the scope, because for one
570
+ * set the two scopes are the same measurement.
571
+ */
572
+ framing: FramingHow | null;
406
573
  /** The box the CANDIDATE was rendered into, at the reference's pixel size. */
407
- viewport: Framing;
574
+ viewport: Framing | null;
408
575
  /** Where the candidate's drawn pixels ended up against the reference's. */
409
576
  framingFit: FramingReport | null;
577
+ /**
578
+ * The framing ONE shared box gives across every set compared.
579
+ *
580
+ * Under `per-shot` it is both reported and used: every set that cannot take the
581
+ * frames' own declared box is measured in it. It is also the figure that says
582
+ * *why* a whole-root run is a different measurement — a set that reads well in
583
+ * the declared box and badly here is a set the old whole-root run was measuring
584
+ * through somebody else's silhouette, which is what `idle` reading 41.59 against
585
+ * 18.77 was (issue #100).
586
+ *
587
+ * `null` when the scope is already shared — `framingFit` is that number then —
588
+ * and when only one set was compared, where the two scopes are the same thing.
589
+ */
590
+ sharedFraming: FramingReport | null;
410
591
  /**
411
592
  * The box the REFERENCE was rendered into, when the sidecar records one.
412
593
  *
@@ -416,6 +597,14 @@ export interface CheckReport {
416
597
  */
417
598
  referenceViewport: Framing | null;
418
599
  background: RGBA;
600
+ /**
601
+ * The candidate's bone tree, cut into chains — the roster the report prints.
602
+ *
603
+ * Printed rather than assumed, because a decomposition an author has to guess at
604
+ * is one they will read wrong: the table says `front-thigh` and the roster says
605
+ * which bones and which slots that name covers. `src/chains.ts` owns the rule.
606
+ */
607
+ chains: BoneChain[];
419
608
  animations: AnimationCheck[];
420
609
  notes: string[];
421
610
  }
@@ -443,6 +632,15 @@ export interface CheckOptions {
443
632
  viewport?: { x: number; y: number; width: number; height: number };
444
633
  /** Play this candidate animation against the frames, when the names differ. */
445
634
  as?: string;
635
+ /**
636
+ * Fit one framing per frame set, or one across every set compared.
637
+ *
638
+ * Defaults to `per-shot`. See `FramingScope` for what the choice costs and why
639
+ * this is the default; it has no effect when only one set is compared, and none
640
+ * when `viewport` pins the box (a pin is a claim about the candidate's own
641
+ * coordinates, and those do not change between shots).
642
+ */
643
+ framing?: FramingScope;
446
644
  }
447
645
 
448
646
  // ---------------------------------------------------------------------------
@@ -572,34 +770,56 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
572
770
  const referenceBoxes =
573
771
  pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
574
772
 
575
- let viewport: Viewport;
576
- let framingFit: FramingReport | null = null;
773
+ const scope: FramingScope = options.framing ?? 'per-shot';
774
+ const slices = sliceBySet(prepared, referenceBoxes);
775
+ /** One per prepared set, in `prepared` order. */
776
+ const framings: SetFraming[] = [];
777
+ let topViewport: Viewport | null = null;
778
+ let topHow: FramingHow | null = null;
779
+ let topFit: FramingReport | null = null;
780
+ let sharedFraming: FramingReport | null = null;
781
+
782
+ const reportFor = (fit: FramingFit, at: Viewport, over: Omit<FramingReport, 'units' | 'fit'>): FramingReport => ({
783
+ ...over,
784
+ fit,
785
+ units: extentsOf(fit, at.scale, referenceViewport),
786
+ });
787
+
577
788
  if (options.viewport) {
789
+ // A pin is a claim about the CANDIDATE's own coordinates, and those do not
790
+ // change between shots — so one box covers the run whatever the scope. The
791
+ // per-set fits below are free: every frame is measured in that one box once,
792
+ // and splitting the result per set costs nothing.
578
793
  const v = options.viewport;
579
- viewport = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
794
+ const pinned = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
580
795
  notes.push(
581
796
  `the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
582
797
  "from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
583
798
  'framing line below is still measured, so it says what the pin cost.',
584
799
  );
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
- };
800
+ const pinnedShape = { passes: 1, settled: false, source: 'pinned' as const, cycled: false, applied: false };
801
+ const perSet = prepared.map((p, i) =>
802
+ pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
803
+ );
804
+ for (const boxes of perSet) {
805
+ const fit = boxes.length === 0 ? null : fitFraming(boxes);
806
+ framings.push({
807
+ viewport: pinned,
808
+ how: 'viewport-flag',
809
+ fit: fit === null ? null : reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS }),
810
+ notes: [],
811
+ });
598
812
  }
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');
813
+ const all = perSet.flat();
814
+ topViewport = pinned;
815
+ topHow = 'viewport-flag';
816
+ if (all.length > 0) {
817
+ const fit = fitFraming(all);
818
+ topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS });
602
819
  }
820
+ } else if (referenceBoxes.every((b) => b === null)) {
821
+ throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
822
+ } else if (scope === 'shared' || prepared.length === 1) {
603
823
  const framed = frameCandidate(
604
824
  prepared,
605
825
  posable.pages,
@@ -610,14 +830,115 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
610
830
  pixelHeight,
611
831
  referenceViewport,
612
832
  );
613
- viewport = framed.viewport;
614
- framingFit = { ...framed.report, units: extentsOf(framed.report.fit, viewport.scale, referenceViewport) };
833
+ const fit = { ...framed.report, units: extentsOf(framed.report.fit, framed.viewport.scale, referenceViewport) };
834
+ const how = HOW_BY_SOURCE[framed.report.source];
835
+ for (let i = 0; i < prepared.length; i++) framings.push({ viewport: framed.viewport, how, fit, notes: [] });
836
+ topViewport = framed.viewport;
837
+ topHow = how;
838
+ topFit = fit;
615
839
  notes.push(...framingNotes(framed.report));
840
+ if (prepared.length > 1) {
841
+ notes.push(
842
+ `one framing was fitted across all ${prepared.length} frame set(s) (--framing shared). Its absolute numbers ` +
843
+ 'are not comparable with a per-shot run, and one badly-fitted set moves every other set in it.',
844
+ );
845
+ }
846
+ } else {
847
+ // Per shot: the DECLARED BOX is decided per set, and every set that does not
848
+ // qualify for it is measured in the one shared framing.
849
+ //
850
+ // ## Why the split falls exactly there, and not "fit each set on its own"
851
+ //
852
+ // The obvious reading of issue #100 is that each set should get its own fitted
853
+ // framing. It was written that way and measured, and it is worse — on the
854
+ // spineboy rung, per-set fitting reads `hit` **92.36** against the shared
855
+ // fit's 60.59 and `shoot@30fps` **101.94** against 42.98 (a two-frame set,
856
+ // framed 24 % off). The reason is `fitFraming`'s own: it registers **extent**,
857
+ // and extent is not alignment, so on a shot whose silhouette genuinely differs
858
+ // the chain has a local minimum of the correction that is not a minimum of the
859
+ // difference. More frames constrain that; one shot's worth does not.
860
+ //
861
+ // What actually produced the good column in that run is the other half — the
862
+ // box `frames.json` records, which is not an estimate of anything and has no
863
+ // floor. Over the union it was refused, because ONE badly-fitted shot put the
864
+ // pooled correction over `COINCIDENT_PIXELS` and the whole root fell back to a
865
+ // fit. Per set, the four sets that ARE in the frames' coordinates take it and
866
+ // read exactly what pinning by hand reads: `idle` **18.77** against 41.59,
867
+ // `walk` 32.00 against 45.33.
868
+ //
869
+ // So a set is framed by the frames' own box when its OWN pixels land there,
870
+ // and by the shared fit otherwise. Every set is then at least as well framed
871
+ // as a whole-root run framed it, and four of spineboy's sixteen much better.
872
+ const shared = frameCandidate(
873
+ prepared,
874
+ posable.pages,
875
+ referenceBoxes,
876
+ background,
877
+ level,
878
+ pixelWidth,
879
+ pixelHeight,
880
+ referenceViewport,
881
+ );
882
+ const sharedShape: SetFraming = {
883
+ viewport: shared.viewport,
884
+ how: HOW_BY_SOURCE[shared.report.source],
885
+ fit: { ...shared.report, units: extentsOf(shared.report.fit, shared.viewport.scale, referenceViewport) },
886
+ notes: [],
887
+ };
888
+ sharedFraming = sharedShape.fit;
889
+ let own = 0;
890
+ for (let i = 0; i < prepared.length; i++) {
891
+ const p = prepared[i];
892
+ const declared =
893
+ p.pairs.length === 0
894
+ ? null
895
+ : frameByDeclaredBox(
896
+ [p],
897
+ posable.pages,
898
+ slices[i],
899
+ background,
900
+ level,
901
+ pixelWidth,
902
+ pixelHeight,
903
+ referenceViewport,
904
+ );
905
+ if (!declared) {
906
+ framings.push({ ...sharedShape, notes: framingNotes(shared.report) });
907
+ continue;
908
+ }
909
+ own++;
910
+ framings.push({
911
+ viewport: declared.viewport,
912
+ how: HOW_BY_SOURCE[declared.report.source],
913
+ fit: { ...declared.report, units: extentsOf(declared.report.fit, declared.viewport.scale, referenceViewport) },
914
+ notes: framingNotes(declared.report),
915
+ });
916
+ }
917
+ notes.push(
918
+ `the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
919
+ `${FRAMES_SIDECAR}'s own box because their own pixels land there, and the rest in the one shared framing on ` +
920
+ 'the "shared box" line. A set framed by the frames\' own box cannot be moved by any other set. --framing ' +
921
+ 'shared measures every set in the shared framing instead, which is a different measurement and not ' +
922
+ 'comparable with this one.',
923
+ );
616
924
  }
617
925
 
926
+ // The candidate's own decomposition, derived once and used by every set — see
927
+ // `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
928
+ // side of the honesty rule: the reference is still nothing but pixels.
929
+ const chains = chainsOf(
930
+ posable.data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
931
+ posable.data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
932
+ );
933
+ const chainOfSlot = new Map<string, number>();
934
+ chains.forEach((chain, index) => {
935
+ for (const slot of chain.slots) chainOfSlot.set(slot, index);
936
+ });
937
+
618
938
  const animations: AnimationCheck[] = [];
619
- for (const p of prepared) {
620
- animations.push(checkOneSet(located.root, p, posable, viewport, background));
939
+ for (let i = 0; i < prepared.length; i++) {
940
+ const f = framings[i];
941
+ animations.push(checkOneSet(located.root, prepared[i], posable, f, background, chains, chainOfSlot));
621
942
  }
622
943
 
623
944
  return {
@@ -627,28 +948,64 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
627
948
  },
628
949
  framesDir: resolve(options.framesDir),
629
950
  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,
951
+ framingScope: scope,
952
+ framing: topHow,
953
+ viewport: topViewport === null ? null : framingOfViewport(topViewport),
954
+ framingFit: topFit,
955
+ sharedFraming,
645
956
  referenceViewport,
646
957
  background,
958
+ chains,
647
959
  animations,
648
960
  notes,
649
961
  };
650
962
  }
651
963
 
964
+ /** The framing one prepared set was measured in. */
965
+ interface SetFraming {
966
+ viewport: Viewport;
967
+ how: FramingHow;
968
+ fit: FramingReport | null;
969
+ notes: string[];
970
+ }
971
+
972
+ /** `FramingSource` said in the report's own words. */
973
+ const HOW_BY_SOURCE: Record<FramingSource, FramingHow> = {
974
+ derived: 'candidate-pixels',
975
+ declared: 'frames-viewport',
976
+ pinned: 'viewport-flag',
977
+ };
978
+
979
+ /**
980
+ * `referenceBoxes` cut into one array per prepared set, in `prepared` order.
981
+ *
982
+ * The array is built by `prepared.flatMap((p) => p.pairs)`, so this is the inverse
983
+ * of that flatten and nothing else. It exists because a per-shot framing measures
984
+ * one set at a time and `frameCandidate` indexes its boxes the flat way.
985
+ */
986
+ function sliceBySet(prepared: PreparedSet[], referenceBoxes: Array<ContentBox | null>): Array<Array<ContentBox | null>> {
987
+ const out: Array<Array<ContentBox | null>> = [];
988
+ let at = 0;
989
+ for (const p of prepared) {
990
+ out.push(referenceBoxes.slice(at, at + p.pairs.length));
991
+ at += p.pairs.length;
992
+ }
993
+ return out;
994
+ }
995
+
996
+ /** A rendering viewport as the report states it. */
997
+ function framingOfViewport(v: Viewport): Framing {
998
+ return {
999
+ x: v.minX,
1000
+ y: v.minY,
1001
+ width: v.maxX - v.minX,
1002
+ height: v.maxY - v.minY,
1003
+ scale: v.scale,
1004
+ pixelWidth: v.width,
1005
+ pixelHeight: v.height,
1006
+ };
1007
+ }
1008
+
652
1009
  function readPlateFrom(root: string, file: string): Plate {
653
1010
  readFrameFile(root, file); // the guard; readPlate does the decoding
654
1011
  return readPlate(file);
@@ -793,6 +1150,12 @@ interface FramingPass {
793
1150
  distance: number;
794
1151
  }
795
1152
 
1153
+ /** A framing for one run of sets: the box, and what it still leaves over. */
1154
+ interface FramedSets {
1155
+ viewport: Viewport;
1156
+ report: Omit<FramingReport, 'units'>;
1157
+ }
1158
+
796
1159
  /** A chain of passes and why it stopped. */
797
1160
  interface FramingChain {
798
1161
  passes: FramingPass[];
@@ -898,7 +1261,7 @@ function frameCandidate(
898
1261
  pixelWidth: number,
899
1262
  pixelHeight: number,
900
1263
  referenceViewport: Framing | null,
901
- ): { viewport: Viewport; report: Omit<FramingReport, 'units'> } {
1264
+ ): FramedSets {
902
1265
  const declared = frameByDeclaredBox(
903
1266
  prepared,
904
1267
  pages,
@@ -909,34 +1272,14 @@ function frameCandidate(
909
1272
  pixelHeight,
910
1273
  referenceViewport,
911
1274
  );
1275
+ // The declared-box probe measures every frame in `frames.json`'s own box
1276
+ // whether or not it ends up being used, and that measurement is the only one
1277
+ // taken in a box every set shares. Handing it back is what lets a per-shot run
1278
+ // report `sharedFit` without a second render — see `CheckReport.sharedFit`.
912
1279
  if (declared) return declared;
913
1280
 
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
1281
  const chain = runFramingChain(
939
- seed,
1282
+ seedFromGeometry(prepared, pages, referenceBoxes, pixelWidth, pixelHeight),
940
1283
  prepared,
941
1284
  pages,
942
1285
  referenceBoxes,
@@ -964,6 +1307,88 @@ function frameCandidate(
964
1307
  };
965
1308
  }
966
1309
 
1310
+ /**
1311
+ * The starting viewport, from the candidate's own posed geometry laid onto the
1312
+ * reference's own drawn extent.
1313
+ *
1314
+ * ## Why the reference's extent and not the frame
1315
+ *
1316
+ * The seed used to scale the candidate's trimmed quads to **fill the frame**, and
1317
+ * that is an assumption about the reference: that the shot its frames show was
1318
+ * framed around itself. Over a whole skeleton root it holds well enough, because
1319
+ * the sidecar's one box was chosen to hold every set. Over one SHORT set it can be
1320
+ * badly wrong — rung 3's `light` covers about half of the box its frames were
1321
+ * rendered in, so filling the frame starts it near 2x too large, and the chain
1322
+ * walks that back by only a few per cent a pass: `--frames <root>/light` on a
1323
+ * candidate in its own coordinates read **MAE 141** with a framing 65 % off, after
1324
+ * spending its whole pass budget (issue #100).
1325
+ *
1326
+ * The reference's own content box is already measured, on the same frames, with
1327
+ * the same predicate — it is what the fit is trying to reach. Starting there costs
1328
+ * nothing and starts the chain where it used to end up: the same shot now settles
1329
+ * on the first or second pass.
1330
+ *
1331
+ * The scale matches the two boxes by **area** rather than by either side, because
1332
+ * a candidate whose silhouette differs has two different side ratios and picking
1333
+ * one of them would seed the chain with that difference as a scale error.
1334
+ *
1335
+ * ⚠️ Falls back to filling the frame when there is no reference box to aim at —
1336
+ * every frame unreadable, or a set with nothing on disk.
1337
+ */
1338
+ function seedFromGeometry(
1339
+ prepared: PreparedSet[],
1340
+ pages: Map<string, Plate>,
1341
+ referenceBoxes: Array<ContentBox | null>,
1342
+ pixelWidth: number,
1343
+ pixelHeight: number,
1344
+ ): Viewport {
1345
+ const quads = trimmedUnionBounds(
1346
+ prepared.map((p) => p.pairs.map((pair) => pair.frame)),
1347
+ pages,
1348
+ );
1349
+ if (!Number.isFinite(quads.minX)) {
1350
+ throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
1351
+ }
1352
+ const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
1353
+ const world = {
1354
+ minX: quads.minX - pad,
1355
+ minY: quads.minY - pad,
1356
+ maxX: quads.maxX + pad,
1357
+ maxY: quads.maxY + pad,
1358
+ };
1359
+ const worldWidth = world.maxX - world.minX;
1360
+ const worldHeight = world.maxY - world.minY;
1361
+
1362
+ let reference: ContentBox | null = null;
1363
+ for (const box of referenceBoxes) reference = unionBoxes(reference, box);
1364
+ if (reference !== null && boxWidth(reference) > 0 && boxHeight(reference) > 0) {
1365
+ // The reference's box is the trimmed content, so pad it the same way the
1366
+ // candidate's is before the two are matched — otherwise the pad is a scale
1367
+ // error the chain then has to undo.
1368
+ const refWidth = boxWidth(reference) * (1 + 2 * PAD);
1369
+ const refHeight = boxHeight(reference) * (1 + 2 * PAD);
1370
+ const scale = Math.sqrt((refWidth * refHeight) / (worldWidth * worldHeight));
1371
+ const left = reference.left - boxWidth(reference) * PAD;
1372
+ const top = reference.top - boxHeight(reference) * PAD;
1373
+ // `projector` is px = (wx - minX)·k and py = (maxY - wy)·k, so putting the
1374
+ // candidate's padded box on the reference's is one subtraction per axis.
1375
+ const minX = world.minX - left / scale;
1376
+ const maxY = world.maxY + top / scale;
1377
+ return viewportOfSize(minX, maxY - pixelHeight / scale, pixelWidth / scale, pixelHeight / scale, scale, pixelWidth, pixelHeight);
1378
+ }
1379
+
1380
+ const maxSide = Math.max(pixelWidth, pixelHeight);
1381
+ return viewportOfSize(
1382
+ world.minX,
1383
+ world.minY,
1384
+ worldWidth,
1385
+ worldHeight,
1386
+ maxSide / Math.max(worldWidth, worldHeight),
1387
+ pixelWidth,
1388
+ pixelHeight,
1389
+ );
1390
+ }
1391
+
967
1392
  /**
968
1393
  * The box `frames.json` records, used as the candidate's own — when, and only
969
1394
  * when, the candidate's pixels are measured to land in it.
@@ -1193,10 +1618,14 @@ function checkOneSet(
1193
1618
  root: string,
1194
1619
  prepared: PreparedSet,
1195
1620
  posable: ReturnType<typeof posableFromText>,
1196
- viewport: Viewport,
1621
+ framing: SetFraming,
1197
1622
  background: RGBA,
1623
+ chains: BoneChain[],
1624
+ /** Slot name → its index in `chains`. */
1625
+ chainOfSlot: Map<string, number>,
1198
1626
  ): AnimationCheck {
1199
1627
  const { set } = prepared;
1628
+ const viewport = framing.viewport;
1200
1629
  const blank: AnimationCheck = {
1201
1630
  dir: set.dir,
1202
1631
  animation: set.animation,
@@ -1206,6 +1635,8 @@ function checkOneSet(
1206
1635
  candidateFrames: prepared.candidateFrames,
1207
1636
  compared: 0,
1208
1637
  meanMae: 0,
1638
+ meanMaeReference: 0,
1639
+ drawnRatio: 1,
1209
1640
  meanMaeFrame: 0,
1210
1641
  worstMae: 0,
1211
1642
  worstMaeFrame: -1,
@@ -1216,13 +1647,21 @@ function checkOneSet(
1216
1647
  changePairs: 0,
1217
1648
  changeDisagreements: 0,
1218
1649
  worstChangeFrame: -1,
1650
+ chains: [],
1651
+ chainDenominator: 0,
1652
+ unattributedError: 0,
1219
1653
  frames: [],
1220
- notes: prepared.missing ? [prepared.missing] : prepared.notes,
1654
+ viewport: framingOfViewport(viewport),
1655
+ framing: framing.how,
1656
+ framingFit: framing.fit,
1657
+ notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
1221
1658
  };
1222
1659
  if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
1223
1660
 
1224
1661
  const frames: FrameCheck[] = [];
1225
1662
  let maeSum = 0;
1663
+ let maeReferenceSum = 0;
1664
+ let drawnRatioSum = 0;
1226
1665
  let maeFrameSum = 0;
1227
1666
  let worstMae = 0;
1228
1667
  let worstMaeFrame = -1;
@@ -1238,15 +1677,34 @@ function checkOneSet(
1238
1677
  // ITSELF a frame earlier. Both are already rendered or read for this frame, so
1239
1678
  // holding one frame of each costs one extra plate and no extra work.
1240
1679
  let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
1680
+ const tally: ChainTally = {
1681
+ error: new Array<number>(chains.length).fill(0),
1682
+ pixels: new Array<number>(chains.length).fill(0),
1683
+ unattributed: 0,
1684
+ total: 0,
1685
+ };
1241
1686
 
1242
1687
  for (const { index, file, frame } of prepared.pairs) {
1243
1688
  const reference = readPlateFrom(root, file);
1244
1689
  const rendered = renderFrame(frame, posable.pages, viewport, background);
1245
- const check = checkOneFrame(index, file, frame, posable.pages, viewport, background, reference, rendered);
1690
+ const check = checkOneFrame(
1691
+ index,
1692
+ file,
1693
+ frame,
1694
+ posable.pages,
1695
+ viewport,
1696
+ background,
1697
+ reference,
1698
+ rendered,
1699
+ chainOfSlot,
1700
+ tally,
1701
+ );
1246
1702
  check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
1247
1703
  previous = { index, candidate: rendered, reference };
1248
1704
  frames.push(check);
1249
1705
  maeSum += check.mae;
1706
+ maeReferenceSum += check.maeReference;
1707
+ drawnRatioSum += check.referencePixels === 0 ? 1 : check.candidatePixels / check.referencePixels;
1250
1708
  maeFrameSum += check.maeFrame;
1251
1709
  if (check.attributed === 0) framesWithoutDrift++;
1252
1710
  if (check.change) {
@@ -1274,7 +1732,12 @@ function checkOneSet(
1274
1732
  return {
1275
1733
  ...blank,
1276
1734
  compared: frames.length,
1735
+ chains: chainChecks(chains, frames, tally),
1736
+ chainDenominator: tally.total,
1737
+ unattributedError: tally.unattributed,
1277
1738
  meanMae: maeSum / frames.length,
1739
+ meanMaeReference: maeReferenceSum / frames.length,
1740
+ drawnRatio: drawnRatioSum / frames.length,
1278
1741
  meanMaeFrame: maeFrameSum / frames.length,
1279
1742
  worstMae,
1280
1743
  worstMaeFrame,
@@ -1286,7 +1749,7 @@ function checkOneSet(
1286
1749
  changeDisagreements,
1287
1750
  worstChangeFrame,
1288
1751
  frames,
1289
- notes: prepared.notes,
1752
+ notes: [...framing.notes, ...prepared.notes],
1290
1753
  };
1291
1754
  }
1292
1755
 
@@ -1393,6 +1856,176 @@ function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number
1393
1856
  return { pixels, mae: sum / 3 / count };
1394
1857
  }
1395
1858
 
1859
+ /**
1860
+ * Roll a set's frames up into one row per chain — the dashboard's rows.
1861
+ *
1862
+ * A chain that owns no slot is left out: it has nothing to attribute, and a row of
1863
+ * dashes in every set is noise in a table read sixteen times. The roster at the
1864
+ * foot of the report still lists it, so the account of where every bone went stays
1865
+ * complete.
1866
+ */
1867
+ function chainChecks(chains: BoneChain[], frames: FrameCheck[], tally: ChainTally): ChainCheck[] {
1868
+ const out: ChainCheck[] = [];
1869
+ chains.forEach((chain, index) => {
1870
+ if (chain.slots.length === 0) return;
1871
+ const own = new Set(chain.slots);
1872
+ const drew = new Set<string>();
1873
+ let worstDrift = 0;
1874
+ let worstDriftSlot: string | null = null;
1875
+ let worstDriftFrame = -1;
1876
+ let driftSum = 0;
1877
+ let driftSamples = 0;
1878
+ let driftFrames = 0;
1879
+ for (const frame of frames) {
1880
+ let sampled = false;
1881
+ for (const track of frame.slots) {
1882
+ if (!own.has(track.slot)) continue;
1883
+ if (track.candidate !== null) drew.add(track.slot);
1884
+ if (!isAttributable(track)) continue;
1885
+ const drift = track.drift as number;
1886
+ driftSum += drift;
1887
+ driftSamples++;
1888
+ sampled = true;
1889
+ if (drift > worstDrift) {
1890
+ worstDrift = drift;
1891
+ worstDriftSlot = track.slot;
1892
+ worstDriftFrame = frame.index;
1893
+ }
1894
+ }
1895
+ if (sampled) driftFrames++;
1896
+ }
1897
+ out.push({
1898
+ chain: chain.name,
1899
+ slots: chain.slots.length,
1900
+ drewSlots: drew.size,
1901
+ worstDrift,
1902
+ worstDriftSlot,
1903
+ worstDriftFrame,
1904
+ meanDrift: driftSamples === 0 ? 0 : driftSum / driftSamples,
1905
+ driftSamples,
1906
+ driftFrames,
1907
+ error: tally.error[index],
1908
+ referencePixels: tally.pixels[index],
1909
+ mae: tally.pixels[index] === 0 ? 0 : tally.error[index] / tally.pixels[index],
1910
+ maeShare: tally.total === 0 ? 0 : tally.error[index] / tally.total,
1911
+ });
1912
+ });
1913
+ return out;
1914
+ }
1915
+
1916
+ /**
1917
+ * A set's error, being split between the candidate's chains as its frames are read.
1918
+ *
1919
+ * Carried across frames rather than parked on each `FrameCheck` because a share is
1920
+ * a fact about the SET — and because a per-frame array of it would land in every
1921
+ * `--json` report and every `bench.json` for a number nobody reads per frame.
1922
+ */
1923
+ interface ChainTally {
1924
+ /** Absolute difference over reference-drawn pixels attributed to each chain. */
1925
+ error: number[];
1926
+ /** How many such pixels each chain took. */
1927
+ pixels: number[];
1928
+ /** The same, over reference pixels no chain could take — the candidate drew nothing. */
1929
+ unattributed: number;
1930
+ /** Every reference-drawn pixel's difference, chain or not: the share's denominator. */
1931
+ total: number;
1932
+ }
1933
+
1934
+ /** How much a diagonal step costs the chamfer pass below. */
1935
+ const DIAGONAL_STEP = Math.SQRT2;
1936
+
1937
+ /**
1938
+ * Give every pixel of the frame the chain whose ink is nearest to it.
1939
+ *
1940
+ * Two chamfer passes over the owner mask — forward then backward, propagating
1941
+ * (distance, label) together. It is an approximate Euclidean transform and that is
1942
+ * enough: what it decides is which of a handful of well-separated regions a pixel
1943
+ * belongs to, not a distance anybody reads.
1944
+ *
1945
+ * ⚠️ Nearest **ink the candidate drew**, so a chain that draws nothing seeds
1946
+ * nothing and is handed no pixels at all — its share reads 0 % while its slots are
1947
+ * missing entirely. That is why the table prints `drewSlots` beside the share: 0 %
1948
+ * on `0/3 slots` is the loudest row here, not the quietest one.
1949
+ *
1950
+ * The distance comes back with the label because the caller bounds it — see
1951
+ * `chainRadii`.
1952
+ */
1953
+ function nearestOwner(owner: Int32Array, width: number, height: number): { label: Int32Array; dist: Float32Array } {
1954
+ const label = Int32Array.from(owner);
1955
+ const dist = new Float32Array(width * height);
1956
+ for (let i = 0; i < label.length; i++) dist[i] = label[i] >= 0 ? 0 : Infinity;
1957
+ const relax = (at: number, from: number, step: number): void => {
1958
+ const reach = dist[from] + step;
1959
+ if (reach >= dist[at]) return;
1960
+ dist[at] = reach;
1961
+ label[at] = label[from];
1962
+ };
1963
+ for (let y = 0; y < height; y++) {
1964
+ for (let x = 0; x < width; x++) {
1965
+ const at = y * width + x;
1966
+ if (x > 0) relax(at, at - 1, 1);
1967
+ if (y > 0) {
1968
+ relax(at, at - width, 1);
1969
+ if (x > 0) relax(at, at - width - 1, DIAGONAL_STEP);
1970
+ if (x + 1 < width) relax(at, at - width + 1, DIAGONAL_STEP);
1971
+ }
1972
+ }
1973
+ }
1974
+ for (let y = height - 1; y >= 0; y--) {
1975
+ for (let x = width - 1; x >= 0; x--) {
1976
+ const at = y * width + x;
1977
+ if (x + 1 < width) relax(at, at + 1, 1);
1978
+ if (y + 1 < height) {
1979
+ relax(at, at + width, 1);
1980
+ if (x + 1 < width) relax(at, at + width + 1, DIAGONAL_STEP);
1981
+ if (x > 0) relax(at, at + width - 1, DIAGONAL_STEP);
1982
+ }
1983
+ }
1984
+ }
1985
+ return { label, dist };
1986
+ }
1987
+
1988
+ /**
1989
+ * How far each chain's attribution may reach, in frame pixels.
1990
+ *
1991
+ * The same judgement `src/slots.ts` makes about a slot — *past about its own long
1992
+ * side a part no longer overlaps where it was, and something out there is another
1993
+ * object rather than this one moved* — applied to the chain's own drawn box. Past
1994
+ * it, reference ink is left **unattributed** instead of being handed to whichever
1995
+ * chain happens to be nearest.
1996
+ *
1997
+ * ⚠️ This is the bound that keeps the dashboard honest about its own limits, and
1998
+ * it is a bound rather than a fix. Nothing candidate-side can know which part of
1999
+ * the REFERENCE a pixel belonged to; nearest-ink is a good guess while the figure
2000
+ * is roughly in place and a bad one once a part has left. So a part displaced past
2001
+ * its own size stops being blamed on its neighbour and starts showing up in the
2002
+ * `(unattributed)` row, next to the `reference component(s) no slot reaches` count
2003
+ * that says the same thing a different way.
2004
+ */
2005
+ function chainRadii(
2006
+ footprints: Map<string, Footprint>,
2007
+ chainOfSlot: Map<string, number>,
2008
+ chains: number,
2009
+ ): Float64Array {
2010
+ const minX = new Float64Array(chains).fill(Infinity);
2011
+ const minY = new Float64Array(chains).fill(Infinity);
2012
+ const maxX = new Float64Array(chains).fill(-Infinity);
2013
+ const maxY = new Float64Array(chains).fill(-Infinity);
2014
+ for (const [slot, foot] of footprints) {
2015
+ const chain = chainOfSlot.get(slot);
2016
+ if (chain === undefined || foot.pixels === 0) continue;
2017
+ if (foot.minX < minX[chain]) minX[chain] = foot.minX;
2018
+ if (foot.minY < minY[chain]) minY[chain] = foot.minY;
2019
+ if (foot.maxX > maxX[chain]) maxX[chain] = foot.maxX;
2020
+ if (foot.maxY > maxY[chain]) maxY[chain] = foot.maxY;
2021
+ }
2022
+ const out = new Float64Array(chains);
2023
+ for (let i = 0; i < chains; i++) {
2024
+ out[i] = maxX[i] < minX[i] ? -1 : searchRadius(maxX[i] - minX[i], maxY[i] - minY[i]);
2025
+ }
2026
+ return out;
2027
+ }
2028
+
1396
2029
  function checkOneFrame(
1397
2030
  index: number,
1398
2031
  file: string,
@@ -1403,8 +2036,16 @@ function checkOneFrame(
1403
2036
  reference: Plate,
1404
2037
  /** The candidate's own frame, rendered by the caller — it needs it too. */
1405
2038
  rendered: Plate,
2039
+ /** Slot name → chain index, for the per-chain split. */
2040
+ chainOfSlot: Map<string, number>,
2041
+ /** Accumulated across the set by the caller — see `ChainTally`. */
2042
+ tally: ChainTally,
1406
2043
  ): FrameCheck {
1407
- const { coverage, footprints } = frameGeometry(frame, pages, viewport);
2044
+ const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
2045
+ // Only worth the transform when something was drawn to be nearest TO.
2046
+ const nearest =
2047
+ owner !== null && owner.some((at) => at >= 0) ? nearestOwner(owner, viewport.width, viewport.height) : null;
2048
+ const radii = chainRadii(footprints, chainOfSlot, tally.error.length);
1408
2049
 
1409
2050
  let union = 0;
1410
2051
  let candidatePixels = 0;
@@ -1421,6 +2062,20 @@ function checkOneFrame(
1421
2062
  const b = reference.get(x, y);
1422
2063
  const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
1423
2064
  sumAll += delta;
2065
+ if (inReference) {
2066
+ // The share's denominator is the reference's own drawn pixels, and the
2067
+ // split is over exactly those — issue #119's lesson, as a partition.
2068
+ tally.total += delta;
2069
+ const at = y * viewport.width + x;
2070
+ const found = nearest === null ? -1 : nearest.label[at];
2071
+ const chain = found >= 0 && nearest !== null && nearest.dist[at] <= radii[found] ? found : -1;
2072
+ if (chain >= 0) {
2073
+ tally.error[chain] += delta;
2074
+ tally.pixels[chain]++;
2075
+ } else {
2076
+ tally.unattributed += delta;
2077
+ }
2078
+ }
1424
2079
  if (!inCandidate && !inReference) continue;
1425
2080
  union++;
1426
2081
  sum += delta;
@@ -1454,6 +2109,10 @@ function checkOneFrame(
1454
2109
  index,
1455
2110
  file,
1456
2111
  mae: union === 0 ? 0 : sum / union,
2112
+ // The same numerator over a denominator the candidate does not control — see
2113
+ // `FrameCheck.maeReference`. Both figures are already in hand here, which is
2114
+ // why the second one costs nothing to publish.
2115
+ maeReference: referencePixels === 0 ? 0 : sum / referencePixels,
1457
2116
  maeFrame: sumAll / (viewport.width * viewport.height),
1458
2117
  unionPixels: union,
1459
2118
  candidatePixels,
@@ -1475,6 +2134,58 @@ function checkOneFrame(
1475
2134
  // the report
1476
2135
  // ---------------------------------------------------------------------------
1477
2136
 
2137
+ /**
2138
+ * How much more than the reference a set may draw before `check` calls it
2139
+ * overdraw, as a ratio of drawn pixels.
2140
+ *
2141
+ * ## Why this direction needs its own warning
2142
+ *
2143
+ * `mae` divides by the pixels **either side drew** — the union — and the
2144
+ * candidate owns half of that denominator. A large, mostly transparent sprite
2145
+ * adds many cheap pixels to it and the *mean falls*, so anything optimising
2146
+ * against `mae` can buy a better score by drawing more, which is the opposite of
2147
+ * fidelity. Issue #119: spineboy-2's muzzle flare walked its own scale to 13x
2148
+ * doing exactly this, and cost every set in that run its framing. Reproduced
2149
+ * here, that candidate's `shoot` reads union MAE **39.65 against the honest
2150
+ * build's 47.20** — the metric calls the flare an improvement — while the same
2151
+ * difference over the reference's own pixels reads **73.06 against 52.54**.
2152
+ *
2153
+ * ⭐ Asymmetric on purpose. A candidate that draws LESS than the reference is
2154
+ * being punished by the MAE, not rewarded, and needs no warning to find out.
2155
+ *
2156
+ * ## Where 1.5 comes from
2157
+ *
2158
+ * Measured over the corpus rather than picked. Across the twelve committed
2159
+ * candidates in `bench/runs/` — 64 compared sets, 1 to 121 frames each — the
2160
+ * ratio spans **0.852 … 1.069** on 62 of them, and the two above that are both
2161
+ * the same shot on the same character: spineboy-1's `shoot@30fps` at 1.154 and
2162
+ * spineboy-2's at **1.274**, two-frame stills sets where the muzzle flare lands
2163
+ * a frame off. Rung 8's ball reads 1.041, rung 3's candidate 0.993.
2164
+ *
2165
+ * The 13x flare reads **1.850** on `shoot` and **3.199** on `shoot@30fps`, and
2166
+ * 0.94–1.01 on the fourteen sets that do not draw it — so the warning names the
2167
+ * shot the overdraw is in rather than colouring the whole run.
2168
+ *
2169
+ * 1.5 is the geometric middle of the gap between the widest honest reading and
2170
+ * the weakest defective one (1.274 · 1.850 ≈ 1.535²): half again as much ink as
2171
+ * the reference put down, which no honest candidate in the corpus approaches and
2172
+ * which the case this was built for clears on both its sets.
2173
+ *
2174
+ * ## What was measured and rejected: the content boxes
2175
+ *
2176
+ * Issue #119 suggests the two content boxes, and `check` has both. Measured, that
2177
+ * test is defeated by the framing it is measured through. `fitFraming` absorbs a
2178
+ * uniform scale on purpose, so a candidate that draws everything too big reads a
2179
+ * box growth of **−3.4 %** while its union MAE falls 137.6 → 36.0 — the fit
2180
+ * simply shrinks it back. It also fires where nothing is overdrawn: the
2181
+ * time-reversed fixture, whose ink is right and whose *timing* is wrong, reads
2182
+ * **+14.1 %** because sampling a reversed shot lands on different poses. Counting
2183
+ * ink is blind to both — that same reversed fixture draws **1.30–1.39x**, under
2184
+ * the bar, and a bloated one draws what it drew whatever the framing does with it
2185
+ * afterwards. C08 and C09 hold both ends of that.
2186
+ */
2187
+ export const OVERDRAW_RATIO = 1.5;
2188
+
1478
2189
  /** How many worst frames a set prints when the whole set is too long to list. */
1479
2190
  export const WORST_FRAMES = 8;
1480
2191
  /** Sets no longer than this print every frame. */
@@ -1487,17 +2198,15 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1487
2198
  lines.push(` candidate ${report.candidate.skeleton}`);
1488
2199
  lines.push(` atlas ${report.candidate.atlas}`);
1489
2200
  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
2201
  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})`,
2202
+ ` scope ${
2203
+ report.framingScope === 'per-shot'
2204
+ ? "the framing decided per frame set (--framing shared measures every set in one shared framing)"
2205
+ : "one framing across every frame set (--framing per-shot lets a set take frames.json's own box instead)"
2206
+ }`,
1500
2207
  );
2208
+ const v = report.viewport;
2209
+ if (v !== null) lines.push(...framedToLines(v, report.framing));
1501
2210
  const r = report.referenceViewport;
1502
2211
  if (r) {
1503
2212
  lines.push(
@@ -1506,7 +2215,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1506
2215
  );
1507
2216
  lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
1508
2217
  }
1509
- for (const line of framingLines(report)) lines.push(line);
2218
+ for (const line of framingLines(report.framingFit)) lines.push(line);
2219
+ if (report.sharedFraming) {
2220
+ const shared = report.sharedFraming;
2221
+ const f = shared.fit;
2222
+ const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
2223
+ lines.push(
2224
+ ` shared box one box for all ${report.animations.length} set(s) leaves x${f.scale.toFixed(6)} offset ` +
2225
+ `${signed(f.dx)}, ${signed(f.dy)} px rms ${f.rms.toFixed(2)} px over ${f.frames * 4} edge(s) ` +
2226
+ `(${shared.source}; used for every set that could not take the frames' own box)`,
2227
+ );
2228
+ lines.push(
2229
+ " ⤷ how far one shared framing is from serving every set. A set below that took the frames' own " +
2230
+ 'box instead is measured with no such correction at all; --framing shared measures every set here.',
2231
+ );
2232
+ }
1510
2233
  for (const note of report.notes) lines.push(` ⚠️ ${note}`);
1511
2234
  lines.push('');
1512
2235
 
@@ -1521,6 +2244,13 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1521
2244
  lines.push(
1522
2245
  ` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
1523
2246
  );
2247
+ // Only when this set has a framing of its own: under a shared scope, or a pin,
2248
+ // the header already printed the one box every set was measured in, and
2249
+ // repeating it per set would read as though they differed.
2250
+ if (report.framingScope === 'per-shot' && report.viewport === null) {
2251
+ for (const line of framedToLines(anim.viewport, anim.framing, ' ')) lines.push(line);
2252
+ for (const line of framingLines(anim.framingFit, ' ')) lines.push(line);
2253
+ }
1524
2254
  for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
1525
2255
  if (anim.compared === 0) {
1526
2256
  lines.push('');
@@ -1530,6 +2260,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1530
2260
  ` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} at f${String(anim.worstMaeFrame).padStart(4, '0')}` +
1531
2261
  ` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
1532
2262
  );
2263
+ lines.push(
2264
+ ` ⤷ over the REFERENCE's own drawn pixels, mean ${f2(anim.meanMaeReference)} — the union figure ` +
2265
+ 'compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.',
2266
+ );
2267
+ if (anim.drawnRatio > OVERDRAW_RATIO) {
2268
+ const mine = Math.round(anim.frames.reduce((sum, f) => sum + f.candidatePixels, 0) / anim.frames.length);
2269
+ const theirs = Math.round(anim.frames.reduce((sum, f) => sum + f.referencePixels, 0) / anim.frames.length);
2270
+ lines.push(
2271
+ ` ⚠️ overdraw: this shot draws ${mine.toLocaleString('en-US')} px a frame where the reference ` +
2272
+ `draws ${theirs.toLocaleString('en-US')} — ${anim.drawnRatio.toFixed(2)}x as much ink, past the ` +
2273
+ `${OVERDRAW_RATIO}x no committed candidate reaches. Most of that excess lands in the MAE's own ` +
2274
+ 'denominator and makes the figure above cheaper without moving a pixel closer, so read the one under it. ' +
2275
+ 'Something here is drawn that should not be, or is far too big.',
2276
+ );
2277
+ }
1533
2278
  const blind =
1534
2279
  anim.framesWithoutDrift === 0
1535
2280
  ? ''
@@ -1541,6 +2286,7 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1541
2286
  `f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
1542
2287
  );
1543
2288
  lines.push(changeSummary(anim));
2289
+ for (const line of chainTable(anim)) lines.push(line);
1544
2290
  lines.push('');
1545
2291
 
1546
2292
  const listed = framesToList(anim, opts?.allFrames === true);
@@ -1574,9 +2320,13 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1574
2320
  lines.push('');
1575
2321
  }
1576
2322
 
2323
+ for (const line of chainFoot(report)) lines.push(line);
2324
+
1577
2325
  lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
1578
2326
  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');
2327
+ lines.push(' there is one in `diff`. The figure under it divides the same difference by the pixels the');
2328
+ lines.push(' REFERENCE drew — a denominator you cannot grow, which is what makes it the one to author');
2329
+ lines.push(' against; it is not bounded by 255. Read the framing line first: it is upstream of every number');
1580
2330
  lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
1581
2331
  lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
1582
2332
  lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
@@ -1589,6 +2339,164 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
1589
2339
  return lines;
1590
2340
  }
1591
2341
 
2342
+ /** One drift, as the table says it: distance, slot, frame. */
2343
+ function driftPhrase(drift: number, slot: string | null, frame: number): string {
2344
+ if (slot === null) return 'no slot attributable';
2345
+ return `${drift.toFixed(1)} px ${JSON.stringify(slot)} f${String(frame).padStart(4, '0')}`;
2346
+ }
2347
+
2348
+ /** One set, broken down by chain — see `ChainCheck`. */
2349
+ function chainTable(anim: AnimationCheck): string[] {
2350
+ if (anim.chains.length === 0) return [];
2351
+ const out: string[] = [];
2352
+ out.push(
2353
+ ` chains ${anim.chains.length} from the candidate's own bone tree — the roster is at the foot of the report`,
2354
+ );
2355
+ out.push(
2356
+ ` ${'chain'.padEnd(20)} ${'slots'.padStart(6)} ${'worst slot drift'.padEnd(33)} ` +
2357
+ `${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
2358
+ );
2359
+ // Derivation order rather than worst-first, so the same row is in the same place
2360
+ // in every set's table and a run can be read down a column.
2361
+ for (const chain of anim.chains) {
2362
+ const mean = chain.driftSamples === 0 ? '—' : `${chain.meanDrift.toFixed(1)} px`;
2363
+ out.push(
2364
+ ` ${chain.chain.padEnd(20)} ${`${chain.drewSlots}/${chain.slots}`.padStart(6)} ` +
2365
+ `${driftPhrase(chain.worstDrift, chain.worstDriftSlot, chain.worstDriftFrame).padEnd(33)} ` +
2366
+ `${mean.padStart(8)} ${f2(chain.mae).padStart(9)} ${`${(chain.maeShare * 100).toFixed(1)}%`.padStart(6)}`,
2367
+ );
2368
+ }
2369
+ if (anim.unattributedError > 0 && anim.chainDenominator > 0) {
2370
+ const share = (anim.unattributedError / anim.chainDenominator) * 100;
2371
+ out.push(
2372
+ ` ${'(unattributed)'.padEnd(20)} ${'—'.padStart(6)} ${'—'.padEnd(33)} ${'—'.padStart(8)} ` +
2373
+ `${'—'.padStart(9)} ${`${share.toFixed(1)}%`.padStart(6)}`,
2374
+ );
2375
+ }
2376
+ out.push(
2377
+ " ⤷ share is of this set's own difference over the REFERENCE's drawn pixels, split by nearest ink; " +
2378
+ '`MAE in it` is that same error per pixel. The rule, the denominator and what a 0 % row means are under ' +
2379
+ '"chains" at the foot of the report.',
2380
+ );
2381
+ return out;
2382
+ }
2383
+
2384
+ /** One line per chain across every set, plus the roster the names refer to. */
2385
+ function chainFoot(report: CheckReport): string[] {
2386
+ if (report.chains.length === 0) return [];
2387
+ const out: string[] = [];
2388
+ out.push(' ── chains ──');
2389
+ out.push(
2390
+ " Cut from the CANDIDATE's own bone tree at every branch point: a chain runs from a root or a fork down to the",
2391
+ );
2392
+ out.push(
2393
+ ' next fork, a single-bone chain that is itself a fork folds into its parent, and each is named after the first',
2394
+ );
2395
+ out.push(
2396
+ ' bone in it that carries a slot. The reference is still nothing but pixels — this is your figure decomposed,',
2397
+ );
2398
+ out.push(' not the reference’s, which is what keeps it inside the ladder’s honesty rule.');
2399
+ out.push('');
2400
+ out.push(" MAE share divides the difference over the REFERENCE's own drawn pixels — the denominator from the MAE");
2401
+ out.push(' line above, which nothing you draw can grow — and splits it by giving each of those pixels to the chain');
2402
+ out.push(' whose ink is NEAREST it. So the shares are a partition and add to the whole, and no chain can look');
2403
+ out.push(' better by drawing more: growing its ink only pulls more of the reference’s pixels, and their error,');
2404
+ out.push(' into it. `MAE in it` is the same error per pixel it took, and it is the column that separates a chain');
2405
+ out.push(' that is WRONG from one that is merely large — a head and its features cover a lot of a figure and can');
2406
+ out.push(' carry a third of the error at a below-average figure per pixel.');
2407
+ out.push('');
2408
+ out.push(' ⚠️ Two things the split cannot do, both of which show rather than hide. Reference ink further from your');
2409
+ out.push(' ink than the part’s own size is left `(unattributed)` instead of blamed on a neighbour, so a part that');
2410
+ out.push(' has left its place stops being charged to whatever is next to it. And a chain that draws NOTHING seeds');
2411
+ out.push(' nothing and reads 0 % — which is why the slots column is beside the share: 0 % on 0 slots drawn is the');
2412
+ out.push(' loudest row here, not the quietest.');
2413
+ out.push(` ${'chain'.padEnd(20)} ${'bones'.padEnd(57)} slots`);
2414
+ for (const chain of report.chains) {
2415
+ out.push(
2416
+ ` ${chain.name.padEnd(20)} ${chain.bones.join(', ').padEnd(57)} ` +
2417
+ `${chain.slots.length === 0 ? '(draws nothing)' : chain.slots.join(', ')}`,
2418
+ );
2419
+ }
2420
+ const rows = chainRollup(report);
2421
+ if (rows.length === 0) return out;
2422
+ out.push('');
2423
+ out.push(
2424
+ ` ${'chain'.padEnd(20)} ${'worst slot drift across every set'.padEnd(56)} ` +
2425
+ `${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
2426
+ );
2427
+ for (const row of rows) {
2428
+ const where = row.set === null ? '' : ` in ${row.set}/f${String(row.frame).padStart(4, '0')}`;
2429
+ const worst =
2430
+ row.slot === null ? 'no slot attributable in any set' : `${row.drift.toFixed(1)} px ${JSON.stringify(row.slot)}${where}`;
2431
+ const mean = row.samples === 0 ? '—' : `${row.mean.toFixed(1)} px`;
2432
+ out.push(
2433
+ ` ${row.chain.padEnd(20)} ${worst.padEnd(56)} ${mean.padStart(8)} ` +
2434
+ `${(row.pixels === 0 ? 0 : row.error / row.pixels).toFixed(2).padStart(9)} ` +
2435
+ `${`${(row.share * 100).toFixed(1)}%`.padStart(6)}`,
2436
+ );
2437
+ }
2438
+ out.push('');
2439
+ return out;
2440
+ }
2441
+
2442
+ interface ChainRollup {
2443
+ chain: string;
2444
+ drift: number;
2445
+ slot: string | null;
2446
+ set: string | null;
2447
+ frame: number;
2448
+ mean: number;
2449
+ samples: number;
2450
+ error: number;
2451
+ pixels: number;
2452
+ share: number;
2453
+ }
2454
+
2455
+ /**
2456
+ * Each chain's worst reading anywhere in the run, worst share first.
2457
+ *
2458
+ * Worst-first here and derivation order in the per-set tables, deliberately: this
2459
+ * is the line a run’s README quotes, so it is ranked by what to fix, while a table
2460
+ * printed once per set is ranked so the sets line up.
2461
+ */
2462
+ function chainRollup(report: CheckReport): ChainRollup[] {
2463
+ const rows = new Map<string, ChainRollup>();
2464
+ let denominator = 0;
2465
+ for (const anim of report.animations) {
2466
+ if (anim.compared === 0) continue;
2467
+ denominator += anim.chainDenominator;
2468
+ for (const chain of anim.chains) {
2469
+ const row = rows.get(chain.chain) ?? {
2470
+ chain: chain.chain,
2471
+ drift: 0,
2472
+ slot: null,
2473
+ set: null,
2474
+ frame: -1,
2475
+ mean: 0,
2476
+ samples: 0,
2477
+ error: 0,
2478
+ pixels: 0,
2479
+ share: 0,
2480
+ };
2481
+ if (chain.worstDriftSlot !== null && chain.worstDrift > row.drift) {
2482
+ row.drift = chain.worstDrift;
2483
+ row.slot = chain.worstDriftSlot;
2484
+ row.set = anim.dir;
2485
+ row.frame = chain.worstDriftFrame;
2486
+ }
2487
+ row.mean = row.mean * row.samples + chain.meanDrift * chain.driftSamples;
2488
+ row.samples += chain.driftSamples;
2489
+ row.mean = row.samples === 0 ? 0 : row.mean / row.samples;
2490
+ row.error += chain.error;
2491
+ row.pixels += chain.referencePixels;
2492
+ rows.set(chain.chain, row);
2493
+ }
2494
+ }
2495
+ const out = [...rows.values()];
2496
+ for (const row of out) row.share = denominator === 0 ? 0 : row.error / denominator;
2497
+ return out.sort((a, b) => b.share - a.share);
2498
+ }
2499
+
1592
2500
  /**
1593
2501
  * The frames worth printing: the worst by MAE, plus every change disagreement.
1594
2502
  *
@@ -1659,8 +2567,21 @@ function convergence(framing: FramingReport): string {
1659
2567
  }
1660
2568
 
1661
2569
  /** The framing, as the line an author reads before anything else. */
1662
- function framingLines(report: CheckReport): string[] {
1663
- const framing = report.framingFit;
2570
+ /** The `framed to` line: the box that was rendered into, and how it was chosen. */
2571
+ function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[] {
2572
+ const said =
2573
+ how === 'candidate-pixels'
2574
+ ? "fitted to the candidate's own drawn pixels"
2575
+ : how === 'frames-viewport'
2576
+ ? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
2577
+ : '--viewport';
2578
+ return [
2579
+ `${indent} framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
2580
+ `world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${said})`,
2581
+ ];
2582
+ }
2583
+
2584
+ function framingLines(framing: FramingReport | null, indent = ''): string[] {
1664
2585
  if (!framing) return [];
1665
2586
  const { fit } = framing;
1666
2587
  const c = fit.candidate;
@@ -1670,8 +2591,8 @@ function framingLines(report: CheckReport): string[] {
1670
2591
  `${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
1671
2592
  const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
1672
2593
  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 ` +
2594
+ `${indent} content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
2595
+ `${indent} ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
1675
2596
  `rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
1676
2597
  `union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
1677
2598
  `aspect ${percent(fit.aspectError)}` +
@@ -1683,7 +2604,7 @@ function framingLines(report: CheckReport): string[] {
1683
2604
  if (spread > 1) {
1684
2605
  const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
1685
2606
  out.push(
1686
- ` ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
2607
+ `${indent} ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
1687
2608
  `${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
1688
2609
  `${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
1689
2610
  'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
@@ -1692,7 +2613,7 @@ function framingLines(report: CheckReport): string[] {
1692
2613
  }
1693
2614
  if (fit.rms > 1) {
1694
2615
  out.push(
1695
- ` ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
2616
+ `${indent} ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
1696
2617
  'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
1697
2618
  'misframed.',
1698
2619
  );
@@ -1700,12 +2621,12 @@ function framingLines(report: CheckReport): string[] {
1700
2621
  const units = framing.units;
1701
2622
  if (units) {
1702
2623
  out.push(
1703
- ` in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
2624
+ `${indent} in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
1704
2625
  `reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
1705
2626
  `x${units.ratio.toFixed(4)}`,
1706
2627
  );
1707
2628
  out.push(
1708
- ' ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ' +
2629
+ `${indent} ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ` +
1709
2630
  'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
1710
2631
  'only if you measured the shot in the frames’ own units.',
1711
2632
  );