spine-rigc 0.3.0 → 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/README.md +27 -3
- package/cli.ts +19 -2
- package/docs/AUTHORING.md +416 -47
- package/docs/SPEC_COVERAGE.md +5 -4
- package/package.json +1 -1
- package/src/check.ts +560 -24
- package/src/framing.ts +280 -0
- package/src/render.ts +21 -0
- package/src/slots.ts +102 -4
package/src/check.ts
CHANGED
|
@@ -77,6 +77,9 @@ import {
|
|
|
77
77
|
PAD,
|
|
78
78
|
FRAMES_SIDECAR,
|
|
79
79
|
FRAMES_SPEC,
|
|
80
|
+
SHEET_COLUMNS,
|
|
81
|
+
SHEET_FILE,
|
|
82
|
+
SHEET_GAP,
|
|
80
83
|
type Footprint,
|
|
81
84
|
type Frame,
|
|
82
85
|
type FramesSidecar,
|
|
@@ -94,21 +97,38 @@ import {
|
|
|
94
97
|
fitIsSettled,
|
|
95
98
|
fitSeparation,
|
|
96
99
|
frameContentBox,
|
|
100
|
+
offsetIsWorthApplying,
|
|
101
|
+
OffsetScan,
|
|
102
|
+
shiftViewport,
|
|
97
103
|
unionBoxes,
|
|
98
104
|
isContent,
|
|
99
105
|
BACKGROUND_TOLERANCE,
|
|
100
106
|
CYCLE_PIXELS,
|
|
107
|
+
REFINE_MIN_GAIN,
|
|
108
|
+
REFINE_MIN_GAIN_MAE,
|
|
109
|
+
REFINE_RADIUS,
|
|
101
110
|
type BoxPair,
|
|
102
111
|
type ContentBox,
|
|
103
112
|
type FramingFit,
|
|
113
|
+
type OffsetGain,
|
|
104
114
|
} from './framing.ts';
|
|
105
|
-
import {
|
|
115
|
+
import { componentField, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
|
|
106
116
|
import { chainsOf, type BoneChain } from './chains.ts';
|
|
107
117
|
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
108
|
-
|
|
109
|
-
|
|
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';
|
|
110
130
|
export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
|
|
111
|
-
export type { BoxPair, ContentBox, FramingFit } from './framing.ts';
|
|
131
|
+
export type { BoxPair, ContentBox, FramingFit, OffsetGain } from './framing.ts';
|
|
112
132
|
|
|
113
133
|
// ---------------------------------------------------------------------------
|
|
114
134
|
// the reference side — frames only, and mechanically so
|
|
@@ -362,6 +382,57 @@ export interface ChainCheck {
|
|
|
362
382
|
maeShare: number;
|
|
363
383
|
}
|
|
364
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
|
+
|
|
365
436
|
export interface AnimationCheck {
|
|
366
437
|
dir: string;
|
|
367
438
|
/** The animation the frames show, per the sidecar. */
|
|
@@ -432,6 +503,12 @@ export interface AnimationCheck {
|
|
|
432
503
|
framing: FramingHow;
|
|
433
504
|
/** Where this set's drawn pixels ended up against the reference's. */
|
|
434
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;
|
|
435
512
|
notes: string[];
|
|
436
513
|
}
|
|
437
514
|
|
|
@@ -499,7 +576,16 @@ export type FramingScope = 'per-shot' | 'shared';
|
|
|
499
576
|
|
|
500
577
|
/** What the framing pass concluded, and how sure it is of it. */
|
|
501
578
|
export interface FramingReport {
|
|
502
|
-
/**
|
|
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
|
+
*/
|
|
503
589
|
fit: FramingFit;
|
|
504
590
|
/** How many render/measure/correct passes ran. */
|
|
505
591
|
passes: number;
|
|
@@ -547,6 +633,70 @@ export interface FramingReport {
|
|
|
547
633
|
* what it does and does not mean attached.
|
|
548
634
|
*/
|
|
549
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;
|
|
550
700
|
}
|
|
551
701
|
|
|
552
702
|
/** A width and a height in world units. */
|
|
@@ -798,6 +948,9 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
798
948
|
'framing line below is still measured, so it says what the pin cost.',
|
|
799
949
|
);
|
|
800
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');
|
|
801
954
|
const perSet = prepared.map((p, i) =>
|
|
802
955
|
pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
|
|
803
956
|
);
|
|
@@ -806,7 +959,10 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
806
959
|
framings.push({
|
|
807
960
|
viewport: pinned,
|
|
808
961
|
how: 'viewport-flag',
|
|
809
|
-
fit:
|
|
962
|
+
fit:
|
|
963
|
+
fit === null
|
|
964
|
+
? null
|
|
965
|
+
: reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement }),
|
|
810
966
|
notes: [],
|
|
811
967
|
});
|
|
812
968
|
}
|
|
@@ -815,7 +971,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
815
971
|
topHow = 'viewport-flag';
|
|
816
972
|
if (all.length > 0) {
|
|
817
973
|
const fit = fitFraming(all);
|
|
818
|
-
topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS });
|
|
974
|
+
topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
|
|
819
975
|
}
|
|
820
976
|
} else if (referenceBoxes.every((b) => b === null)) {
|
|
821
977
|
throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
|
|
@@ -830,10 +986,24 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
830
986
|
pixelHeight,
|
|
831
987
|
referenceViewport,
|
|
832
988
|
);
|
|
833
|
-
|
|
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
|
+
};
|
|
834
1004
|
const how = HOW_BY_SOURCE[framed.report.source];
|
|
835
|
-
for (let i = 0; i < prepared.length; i++) framings.push({ viewport
|
|
836
|
-
topViewport =
|
|
1005
|
+
for (let i = 0; i < prepared.length; i++) framings.push({ viewport, how, fit, notes: [] });
|
|
1006
|
+
topViewport = viewport;
|
|
837
1007
|
topHow = how;
|
|
838
1008
|
topFit = fit;
|
|
839
1009
|
notes.push(...framingNotes(framed.report));
|
|
@@ -902,17 +1072,24 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
902
1072
|
pixelHeight,
|
|
903
1073
|
referenceViewport,
|
|
904
1074
|
);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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));
|
|
916
1093
|
}
|
|
917
1094
|
notes.push(
|
|
918
1095
|
`the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
|
|
@@ -1303,6 +1480,8 @@ function frameCandidate(
|
|
|
1303
1480
|
cycled: chain.cycled,
|
|
1304
1481
|
agrees: chosen.distance <= COINCIDENT_PIXELS,
|
|
1305
1482
|
applied: true,
|
|
1483
|
+
// Filled in by the refined pass, which runs once the box is decided.
|
|
1484
|
+
refinement: null,
|
|
1306
1485
|
},
|
|
1307
1486
|
};
|
|
1308
1487
|
}
|
|
@@ -1481,10 +1660,91 @@ function frameByDeclaredBox(
|
|
|
1481
1660
|
cycled: false,
|
|
1482
1661
|
agrees: true,
|
|
1483
1662
|
applied: true,
|
|
1663
|
+
refinement: null,
|
|
1484
1664
|
},
|
|
1485
1665
|
};
|
|
1486
1666
|
}
|
|
1487
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
|
+
|
|
1488
1748
|
/**
|
|
1489
1749
|
* What the framing pass concluded, in the words that tell the three cases apart.
|
|
1490
1750
|
*
|
|
@@ -1539,6 +1799,16 @@ interface PreparedSet {
|
|
|
1539
1799
|
candidateFrames: number;
|
|
1540
1800
|
referenceFrames: number;
|
|
1541
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[];
|
|
1542
1812
|
notes: string[];
|
|
1543
1813
|
/** Set when nothing could be compared at all, saying why. */
|
|
1544
1814
|
missing: string | null;
|
|
@@ -1562,6 +1832,7 @@ function prepareSet(
|
|
|
1562
1832
|
candidateFrames: 0,
|
|
1563
1833
|
referenceFrames: disk.length,
|
|
1564
1834
|
pairs: [],
|
|
1835
|
+
frames: [],
|
|
1565
1836
|
notes: [],
|
|
1566
1837
|
missing:
|
|
1567
1838
|
`the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
|
|
@@ -1609,6 +1880,7 @@ function prepareSet(
|
|
|
1609
1880
|
candidateFrames: candidateFrames.length,
|
|
1610
1881
|
referenceFrames: disk.length,
|
|
1611
1882
|
pairs,
|
|
1883
|
+
frames: candidateFrames,
|
|
1612
1884
|
notes,
|
|
1613
1885
|
missing: null,
|
|
1614
1886
|
};
|
|
@@ -1654,6 +1926,7 @@ function checkOneSet(
|
|
|
1654
1926
|
viewport: framingOfViewport(viewport),
|
|
1655
1927
|
framing: framing.how,
|
|
1656
1928
|
framingFit: framing.fit,
|
|
1929
|
+
sheet: null,
|
|
1657
1930
|
notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
|
|
1658
1931
|
};
|
|
1659
1932
|
if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
|
|
@@ -1729,9 +2002,15 @@ function checkOneSet(
|
|
|
1729
2002
|
}
|
|
1730
2003
|
}
|
|
1731
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
|
+
|
|
1732
2010
|
return {
|
|
1733
2011
|
...blank,
|
|
1734
2012
|
compared: frames.length,
|
|
2013
|
+
sheet: sheet.sheet,
|
|
1735
2014
|
chains: chainChecks(chains, frames, tally),
|
|
1736
2015
|
chainDenominator: tally.total,
|
|
1737
2016
|
unattributedError: tally.unattributed,
|
|
@@ -1749,7 +2028,7 @@ function checkOneSet(
|
|
|
1749
2028
|
changeDisagreements,
|
|
1750
2029
|
worstChangeFrame,
|
|
1751
2030
|
frames,
|
|
1752
|
-
notes: [...framing.notes, ...prepared.notes],
|
|
2031
|
+
notes: [...framing.notes, ...prepared.notes, ...sheet.notes],
|
|
1753
2032
|
};
|
|
1754
2033
|
}
|
|
1755
2034
|
|
|
@@ -2082,8 +2361,9 @@ function checkOneFrame(
|
|
|
2082
2361
|
}
|
|
2083
2362
|
}
|
|
2084
2363
|
|
|
2085
|
-
const
|
|
2086
|
-
const
|
|
2364
|
+
const field = componentField(reference, background);
|
|
2365
|
+
const components = field.components;
|
|
2366
|
+
const { tracks, matchedComponents } = matchSlots(footprints, field, {
|
|
2087
2367
|
frame,
|
|
2088
2368
|
pages,
|
|
2089
2369
|
viewport,
|
|
@@ -2130,6 +2410,205 @@ function checkOneFrame(
|
|
|
2130
2410
|
};
|
|
2131
2411
|
}
|
|
2132
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
|
+
|
|
2133
2612
|
// ---------------------------------------------------------------------------
|
|
2134
2613
|
// the report
|
|
2135
2614
|
// ---------------------------------------------------------------------------
|
|
@@ -2286,6 +2765,7 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
2286
2765
|
`f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
|
|
2287
2766
|
);
|
|
2288
2767
|
lines.push(changeSummary(anim));
|
|
2768
|
+
for (const line of sheetLines(anim.sheet)) lines.push(line);
|
|
2289
2769
|
for (const line of chainTable(anim)) lines.push(line);
|
|
2290
2770
|
lines.push('');
|
|
2291
2771
|
|
|
@@ -2332,6 +2812,10 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
2332
2812
|
lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
|
|
2333
2813
|
lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
|
|
2334
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.');
|
|
2335
2819
|
lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
|
|
2336
2820
|
lines.push(' not against each other. They are the only columns that can see a held pose that is');
|
|
2337
2821
|
lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
|
|
@@ -2581,6 +3065,57 @@ function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[
|
|
|
2581
3065
|
];
|
|
2582
3066
|
}
|
|
2583
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
|
+
|
|
2584
3119
|
function framingLines(framing: FramingReport | null, indent = ''): string[] {
|
|
2585
3120
|
if (!framing) return [];
|
|
2586
3121
|
const { fit } = framing;
|
|
@@ -2600,6 +3135,7 @@ function framingLines(framing: FramingReport | null, indent = ''): string[] {
|
|
|
2600
3135
|
? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
|
|
2601
3136
|
: ' (measured, NOT applied — --viewport pinned)'),
|
|
2602
3137
|
];
|
|
3138
|
+
out.push(...refinementLines(framing.refinement, indent));
|
|
2603
3139
|
const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
|
|
2604
3140
|
if (spread > 1) {
|
|
2605
3141
|
const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
|