rig-c 0.0.0-stage → 2.20.4

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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
package/src/framing.ts ADDED
@@ -0,0 +1,819 @@
1
+ /**
2
+ * Framing — putting the candidate's pixels onto the reference's pixels.
3
+ *
4
+ * ## Why this is its own module
5
+ *
6
+ * Everything `check` reports sits downstream of one decision: which world box the
7
+ * candidate is rendered into. Get it wrong and every pixel below it is shifted or
8
+ * rescaled, and the error arrives disguised as MAE — as motion, which is the one
9
+ * thing `check` exists to measure. Two independent honest authoring runs measured
10
+ * exactly that (issue #34): rung 4's `wave-by-hand` read 49.45 framed and 22.96
11
+ * with the box pinned, on identical keys, and rung 5's first correct build read
12
+ * 39.00 instead of 4.35 because its content box came out 0.93 % narrow.
13
+ *
14
+ * ## The rule: both sides are measured the same way, on pixels
15
+ *
16
+ * The old procedure framed the candidate by the union of its **posed quad
17
+ * corners**. A region attachment's quad extends past its own artwork wherever the
18
+ * art is transparent, so a corner can sit where no pixel is — and since the
19
+ * mapping used `minX`, `maxY` and the long side only, one such corner in one frame
20
+ * of one animation set the scale for the whole comparison.
21
+ *
22
+ * So the box is taken from **drawn pixels** instead, on both sides, with the same
23
+ * predicate: a pixel is content when it differs from the frames' background by
24
+ * more than `BACKGROUND_TOLERANCE` on any channel. The candidate's box is the
25
+ * union over the frames it will be compared on, the reference's is the union over
26
+ * the frames on disk, and a similarity transform (uniform scale + translation)
27
+ * carries one onto the other.
28
+ *
29
+ * ⭐ The property that buys: **when the candidate is right, the framing is exact.**
30
+ * A faithful candidate renders to the same pixels as the reference, so its content
31
+ * box IS the reference's content box, the fit is the identity, and every
32
+ * measurement error in this file cancels. What is left is a procedure whose noise
33
+ * shrinks as the candidate improves, which is the only shape of noise an authoring
34
+ * loop can work against.
35
+ *
36
+ * ## And one pass after that, on the MAE itself
37
+ *
38
+ * The extent fit has a floor it cannot see past, because the best fit of two
39
+ * extents is not the best alignment of two pictures. On a hard shot that floor is
40
+ * a **constant** pixel worth a tenth of the headline figure (issue #146), which a
41
+ * loop reads as motion. So a fitted box gets one final pass — `OffsetScan` — that
42
+ * searches whole-pixel translations in a ±`REFINE_RADIUS` window for the lowest
43
+ * *reference-denominator MAE* and moves the box when the gain clears
44
+ * `REFINE_MIN_GAIN`. It optimises the reported figure directly rather than a proxy
45
+ * for it, which is what separates it from the extent refinement measured and
46
+ * rejected in `frameByDeclaredBox`: that one walked off the answer because the
47
+ * answer was not what it was minimising.
48
+ */
49
+ import { Plate, type RGBA } from '../tools/plate.ts';
50
+ import {
51
+ fill,
52
+ pageFor,
53
+ projector,
54
+ rasterisePiece,
55
+ viewportOfSize,
56
+ type Frame,
57
+ type Viewport,
58
+ } from './render_shared.ts';
59
+
60
+ /** How far a channel must move for a pixel to count as "not background". */
61
+ export const BACKGROUND_TOLERANCE = 8;
62
+
63
+ /**
64
+ * How far a pixel is from the background, as the largest channel difference.
65
+ *
66
+ * Used two ways, and they have to be the same function or the two uses disagree
67
+ * about where an edge is: as a threshold (`isContent`) and as a weight (the
68
+ * sub-pixel edge estimate below).
69
+ */
70
+ export function backgroundDistance(plate: Plate, x: number, y: number, background: RGBA): number {
71
+ const [r, g, b] = plate.get(x, y);
72
+ return Math.max(
73
+ Math.abs(r - background[0]),
74
+ Math.abs(g - background[1]),
75
+ Math.abs(b - background[2]),
76
+ );
77
+ }
78
+
79
+ export function isContent(plate: Plate, x: number, y: number, background: RGBA): boolean {
80
+ return backgroundDistance(plate, x, y, background) > BACKGROUND_TOLERANCE;
81
+ }
82
+
83
+ /**
84
+ * Where a shape stops, as opposed to where it is faintly visible.
85
+ *
86
+ * ## Why the content box does not use `BACKGROUND_TOLERANCE`
87
+ *
88
+ * `BACKGROUND_TOLERANCE` answers "is there anything here at all", at about 3 % of
89
+ * a channel, and that is the right question for the union alpha and for
90
+ * connected components. It is the wrong question for an *edge*, because the two
91
+ * sides of this comparison do not render edges the same way: the reference frames
92
+ * are drawn from the example's packed atlas, which for several rungs ships at
93
+ * `scale: 0.5`, while the candidate is compiled from the loose full-size PNGs.
94
+ * Same geometry, softer ramp — and at a 3 % threshold the softer ramp reaches
95
+ * further out.
96
+ *
97
+ * Measured on rung 3's mechanical transcription, where the two sides are the same
98
+ * skeleton and the true answer is "identical": at tolerance 8 the left edges
99
+ * disagreed by **0.36 px**, which is enough to double the reported MAE.
100
+ *
101
+ * A blurred step crosses **half** its own contrast at the position of the
102
+ * original step, whatever the blur — so the box is taken at half of a robust
103
+ * estimate of a solid pixel's contrast, and the same disagreement drops to
104
+ * **0.01 px**. The level is derived from the reference frames and used on both
105
+ * sides, so it is one number rather than two that can drift apart.
106
+ */
107
+ export const EDGE_FRACTION = 0.5;
108
+ /** Which quantile of the content's contrast counts as "a solid pixel". */
109
+ export const CONTENT_QUANTILE = 0.75;
110
+
111
+ /** Pooled contrast of everything that is not background, as a 0..255 histogram. */
112
+ export class ContrastHistogram {
113
+ private readonly bins = new Uint32Array(256);
114
+ private counted = 0;
115
+
116
+ add(plate: Plate, background: RGBA): void {
117
+ for (let y = 0; y < plate.height; y++) {
118
+ for (let x = 0; x < plate.width; x++) {
119
+ const d = backgroundDistance(plate, x, y, background);
120
+ if (d <= BACKGROUND_TOLERANCE) continue;
121
+ this.bins[Math.min(255, Math.round(d))]++;
122
+ this.counted++;
123
+ }
124
+ }
125
+ }
126
+
127
+ /** The half-maximum level, or the bare tolerance when nothing was counted. */
128
+ level(): number {
129
+ if (this.counted === 0) return BACKGROUND_TOLERANCE;
130
+ const target = this.counted * CONTENT_QUANTILE;
131
+ let seen = 0;
132
+ for (let d = 0; d < 256; d++) {
133
+ seen += this.bins[d];
134
+ if (seen >= target) return Math.max(BACKGROUND_TOLERANCE, d * EDGE_FRACTION);
135
+ }
136
+ return BACKGROUND_TOLERANCE;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * A content box in frame pixels, with **real** edges rather than pixel indices.
142
+ *
143
+ * The convention is the one a rasteriser uses: pixel `i` covers `[i, i+1)`, so a
144
+ * box `{ left: 3, right: 7 }` is four whole pixels wide and `{ left: 3.5 }` says
145
+ * the edge runs down the middle of pixel 3.
146
+ */
147
+ export interface ContentBox {
148
+ left: number;
149
+ top: number;
150
+ right: number;
151
+ bottom: number;
152
+ }
153
+
154
+ export const boxWidth = (b: ContentBox): number => b.right - b.left;
155
+ export const boxHeight = (b: ContentBox): number => b.bottom - b.top;
156
+
157
+ export function unionBoxes(a: ContentBox | null, b: ContentBox | null): ContentBox | null {
158
+ if (!a) return b;
159
+ if (!b) return a;
160
+ return {
161
+ left: Math.min(a.left, b.left),
162
+ top: Math.min(a.top, b.top),
163
+ right: Math.max(a.right, b.right),
164
+ bottom: Math.max(a.bottom, b.bottom),
165
+ };
166
+ }
167
+
168
+ /** An integer scan window, half-open on the far edges. */
169
+ export interface ScanWindow {
170
+ minX: number;
171
+ minY: number;
172
+ maxX: number;
173
+ maxY: number;
174
+ }
175
+
176
+ /**
177
+ * Where a rendered plate's content stops, to a fraction of a pixel.
178
+ *
179
+ * ## The sub-pixel part, and why it is worth the paragraph
180
+ *
181
+ * A box read off integer pixel indices is quantised to ±0.5 px per edge, and rung
182
+ * 5 measured this shot's MAE moving 1.35 for a **0.065 px** viewport offset. Half
183
+ * a pixel of framing noise would therefore be louder than most of what an author
184
+ * is trying to hear.
185
+ *
186
+ * So each edge is refined by the mass in its outermost row or column. Anti-aliased
187
+ * coverage scales `backgroundDistance` roughly linearly, so if the first column
188
+ * holding content carries half the mass of the column behind it, the true edge
189
+ * runs about half a pixel in. That model is crude for a wedge — a shape that
190
+ * genuinely narrows towards its edge reads as partial coverage — but it is applied
191
+ * **identically to both sides**, so on similar silhouettes the bias is common-mode
192
+ * and on an exact candidate it cancels outright.
193
+ *
194
+ * `level` is the edge threshold — see `EDGE_FRACTION` for why it is not the bare
195
+ * background tolerance. `within` bounds the scan; it must contain every pixel that
196
+ * could be content, and the caller has that for free from the rasteriser's own
197
+ * destination bounds.
198
+ */
199
+ export function contentBoxOfPlate(
200
+ plate: Plate,
201
+ background: RGBA,
202
+ level: number,
203
+ within?: ScanWindow,
204
+ ): ContentBox | null {
205
+ const x0 = Math.max(0, within?.minX ?? 0);
206
+ const y0 = Math.max(0, within?.minY ?? 0);
207
+ const x1 = Math.min(plate.width, within?.maxX ?? plate.width);
208
+ const y1 = Math.min(plate.height, within?.maxY ?? plate.height);
209
+ if (x1 <= x0 || y1 <= y0) return null;
210
+
211
+ const columns = new Float64Array(plate.width);
212
+ const rows = new Float64Array(plate.height);
213
+ let minX = Infinity;
214
+ let minY = Infinity;
215
+ let maxX = -Infinity;
216
+ let maxY = -Infinity;
217
+ for (let y = y0; y < y1; y++) {
218
+ for (let x = x0; x < x1; x++) {
219
+ const d = backgroundDistance(plate, x, y, background);
220
+ if (d <= level) continue;
221
+ columns[x] += d;
222
+ rows[y] += d;
223
+ if (x < minX) minX = x;
224
+ if (x > maxX) maxX = x;
225
+ if (y < minY) minY = y;
226
+ if (y > maxY) maxY = y;
227
+ }
228
+ }
229
+ if (!Number.isFinite(minX)) return null;
230
+ return {
231
+ left: minX + inset(columns, minX, 1),
232
+ right: maxX + 1 - inset(columns, maxX, -1),
233
+ top: minY + inset(rows, minY, 1),
234
+ bottom: maxY + 1 - inset(rows, maxY, -1),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * How far inside its own pixel an edge sits, from the mass either side of it.
240
+ *
241
+ * `towards` points into the shape. A full outer line (as much mass as the line
242
+ * behind it) insets nothing; an outer line with none of it insets a whole pixel,
243
+ * which is the limit rather than a case that happens — a line with no mass is not
244
+ * the edge.
245
+ */
246
+ function inset(mass: Float64Array, edge: number, towards: 1 | -1): number {
247
+ const inner = mass[edge + towards];
248
+ if (!inner || inner <= 0) return 0;
249
+ return Math.max(0, Math.min(1, 1 - mass[edge] / inner));
250
+ }
251
+
252
+ /** Every pixel a frame's pieces could touch, as an integer scan window. */
253
+ function pieceWindow(frame: Frame, viewport: Viewport): ScanWindow | null {
254
+ const project = projector(viewport);
255
+ let minX = Infinity;
256
+ let minY = Infinity;
257
+ let maxX = -Infinity;
258
+ let maxY = -Infinity;
259
+ for (const piece of frame.pieces) {
260
+ for (let i = 0; i < piece.world.length; i += 2) {
261
+ const [px, py] = project(piece.world[i], piece.world[i + 1]);
262
+ if (px < minX) minX = px;
263
+ if (px > maxX) maxX = px;
264
+ if (py < minY) minY = py;
265
+ if (py > maxY) maxY = py;
266
+ }
267
+ }
268
+ if (!Number.isFinite(minX)) return null;
269
+ return {
270
+ minX: Math.floor(minX) - 1,
271
+ minY: Math.floor(minY) - 1,
272
+ maxX: Math.ceil(maxX) + 2,
273
+ maxY: Math.ceil(maxY) + 2,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * The content box of one candidate frame, drawn into `viewport`.
279
+ *
280
+ * Composited rather than measured piece by piece: two nearly-transparent parts
281
+ * overlapping at an extreme edge are content together and neither alone, and the
282
+ * reference side is a composite, so this one has to be too.
283
+ */
284
+ export function frameContentBox(
285
+ frame: Frame,
286
+ pages: Map<string, Plate>,
287
+ viewport: Viewport,
288
+ background: RGBA,
289
+ level: number,
290
+ ): ContentBox | null {
291
+ const window = pieceWindow(frame, viewport);
292
+ if (!window) return null;
293
+ const plate = new Plate(viewport.width, viewport.height);
294
+ fill(plate, background);
295
+ const project = projector(viewport);
296
+ for (const piece of frame.pieces) {
297
+ rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, r, g, b, a) => {
298
+ plate.blend(px, py, [r, g, b, a]);
299
+ });
300
+ }
301
+ return contentBoxOfPlate(plate, background, level, window);
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // the fit
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /** One frame's two content boxes: what the candidate drew, and what is on disk. */
309
+ export interface BoxPair {
310
+ candidate: ContentBox;
311
+ reference: ContentBox;
312
+ }
313
+
314
+ /** What framing the candidate cost, and what it could not absorb. */
315
+ export interface FramingFit {
316
+ /** The candidate's content box, unioned over every frame compared. */
317
+ candidate: ContentBox;
318
+ /** The reference frames' content box, over the same frames. */
319
+ reference: ContentBox;
320
+ /** How many frames the fit was made from. */
321
+ frames: number;
322
+ /**
323
+ * Uniform scale carrying candidate pixels onto reference pixels.
324
+ *
325
+ * `> 1` means the candidate's content is SMALLER than the reference's and was
326
+ * scaled up to meet it; `1` means the two agree.
327
+ */
328
+ scale: number;
329
+ /** Translation in frame pixels, applied after the scale. */
330
+ dx: number;
331
+ dy: number;
332
+ /** RMS of what the fit left over, across every edge of every frame, in pixels. */
333
+ rms: number;
334
+ /** What one uniform scale could not absorb on the union box: `scale·w − W`. */
335
+ residualWidth: number;
336
+ residualHeight: number;
337
+ /** Candidate aspect ÷ reference aspect − 1, on the union box. */
338
+ aspectError: number;
339
+ }
340
+
341
+ /**
342
+ * The similarity transform mapping the candidate's content onto the reference's,
343
+ * by least squares over **every edge of every frame**.
344
+ *
345
+ * ## Why every frame, and not the union box
346
+ *
347
+ * The first version of this fitted one union box to the other, and that is still
348
+ * an extreme-value statistic — it just moved the fragility from an invisible quad
349
+ * corner to a visible pixel. Rung 5 demonstrates it: that candidate's runner has
350
+ * rigid limbs where the reference's fly off the body, so on a handful of frames it
351
+ * reaches about 1.5 px further left than anything in the reference does. Framing on
352
+ * the union let those few frames rescale all 322 of them, and the reported MAE went
353
+ * **4.35 → 19.7** against a viewport that is known to be right.
354
+ *
355
+ * Fitting over every frame gives each frame's four edges one vote out of `4N`, so
356
+ * a pose that overreaches on three frames moves the framing by about `3/N` of what
357
+ * it used to. What that overreach *should* do — and now does — is show up in the
358
+ * residual, where it reads as "your shot covers more ground than the reference's".
359
+ *
360
+ * ## The derivation
361
+ *
362
+ * With `T(p) = s·p + t` the optimal `t` always centres the two clouds, so with `x`
363
+ * the candidate's edge positions and `X` the reference's,
364
+ *
365
+ * s = [Σ(x−x̄)(X−X̄) + Σ(y−ȳ)(Y−Ȳ)] / [Σ(x−x̄)² + Σ(y−ȳ)²]
366
+ *
367
+ * x and y pooled into one sum because the scale is uniform, with their own means
368
+ * because the translation is not. The single-box case is this with `N = 1`.
369
+ *
370
+ * ## What it deliberately does NOT do: throw outliers away
371
+ *
372
+ * Four robust variants were written and measured against the five ladder
373
+ * candidates and the selftest fixture — sigma trimming, Tukey IRLS, a median
374
+ * estimator, and dropping whichever of the four edge kinds disagrees with the
375
+ * other three. None of them beat plain least squares across the set, and two were
376
+ * clearly worse on the one shot where a correct viewport is known (rung 5, whose
377
+ * author matched the reference's world box by hand): 8.60 trimmed and 13.4 IRLS
378
+ * against 12.5 plain, where the correct framing scores 4.35. The simplest
379
+ * estimator that no single frame can move is the one that ships.
380
+ *
381
+ * ## The floor, stated plainly
382
+ *
383
+ * This registers two shots by their **extent**, and when the candidate's
384
+ * silhouette genuinely differs the best fit of the extents is not the best
385
+ * alignment of the pictures. Measured on rung 5: at the viewport known to be
386
+ * right, the candidate's top edge sits 0.2 px inside the reference's, and the fit
387
+ * spends 0.1 % of scale and a quarter-pixel of offset absorbing it — which costs
388
+ * more than leaving it. That shot moves 1.35 MAE per 0.065 px of viewport, so a
389
+ * framing good to a third of a pixel is worth several MAE there and nothing at all
390
+ * on rung 4. The residual line says when this is happening (`union residual`
391
+ * larger than a pixel, or `rms` above one) and `--viewport` pins the box when an
392
+ * author knows better.
393
+ *
394
+ * ⚠️ What least squares cannot do is make two different shapes agree. If the
395
+ * candidate covers a different extent the fit splits the difference, and the
396
+ * leftover is reported as `residualWidth`/`residualHeight` and `rms` — computed
397
+ * over **every** edge, trimmed ones included — rather than being silently spent.
398
+ * That residual is the number that says "something is a different size, or is in
399
+ * one shot and not the other", which used to arrive disguised as MAE.
400
+ */
401
+ export function fitFraming(pairs: BoxPair[]): FramingFit {
402
+ if (pairs.length === 0) throw new Error('fitFraming needs at least one frame to fit');
403
+ const xs: Array<[number, number]> = [];
404
+ const ys: Array<[number, number]> = [];
405
+ let candidate: ContentBox | null = null;
406
+ let reference: ContentBox | null = null;
407
+ for (const pair of pairs) {
408
+ xs.push([pair.candidate.left, pair.reference.left], [pair.candidate.right, pair.reference.right]);
409
+ ys.push([pair.candidate.top, pair.reference.top], [pair.candidate.bottom, pair.reference.bottom]);
410
+ candidate = unionBoxes(candidate, pair.candidate);
411
+ reference = unionBoxes(reference, pair.reference);
412
+ }
413
+ const mean = (v: Array<[number, number]>, i: 0 | 1): number => v.reduce((a, p) => a + p[i], 0) / v.length;
414
+ const xBar = mean(xs, 0);
415
+ const XBar = mean(xs, 1);
416
+ const yBar = mean(ys, 0);
417
+ const YBar = mean(ys, 1);
418
+ let numerator = 0;
419
+ let denominator = 0;
420
+ for (const [x, X] of xs) {
421
+ numerator += (x - xBar) * (X - XBar);
422
+ denominator += (x - xBar) ** 2;
423
+ }
424
+ for (const [y, Y] of ys) {
425
+ numerator += (y - yBar) * (Y - YBar);
426
+ denominator += (y - yBar) ** 2;
427
+ }
428
+ const scale = denominator > 0 ? numerator / denominator : 1;
429
+ const dx = XBar - scale * xBar;
430
+ const dy = YBar - scale * yBar;
431
+ let squared = 0;
432
+ for (const [x, X] of xs) squared += (scale * x + dx - X) ** 2;
433
+ for (const [y, Y] of ys) squared += (scale * y + dy - Y) ** 2;
434
+
435
+ const c = candidate as ContentBox;
436
+ const r = reference as ContentBox;
437
+ const candidateAspect = boxHeight(c) > 0 ? boxWidth(c) / boxHeight(c) : 0;
438
+ const referenceAspect = boxHeight(r) > 0 ? boxWidth(r) / boxHeight(r) : 0;
439
+ return {
440
+ candidate: c,
441
+ reference: r,
442
+ frames: pairs.length,
443
+ scale,
444
+ dx,
445
+ dy,
446
+ rms: Math.sqrt(squared / (xs.length + ys.length)),
447
+ residualWidth: scale * boxWidth(c) - boxWidth(r),
448
+ residualHeight: scale * boxHeight(c) - boxHeight(r),
449
+ aspectError: referenceAspect > 0 ? candidateAspect / referenceAspect - 1 : 0,
450
+ };
451
+ }
452
+
453
+ /**
454
+ * How far from the identity a fit may be and still be called settled, in pixels.
455
+ *
456
+ * A tenth of a pixel, because that is roughly the floor of the method: a content
457
+ * box read off a rendered grid is quantised, and the sub-pixel edge estimate
458
+ * recovers a fraction of that rather than all of it. Chasing below this is
459
+ * chasing the measurement, and the loop starts jittering instead of converging.
460
+ */
461
+ export const SETTLED_PIXELS = 0.1;
462
+
463
+ /** Is this fit close enough to the identity that another pass would only add noise? */
464
+ export function fitIsSettled(fit: FramingFit): boolean {
465
+ return fitDistance(fit) < SETTLED_PIXELS;
466
+ }
467
+
468
+ /**
469
+ * How far applying this fit would move the content, in pixels: the worst of its
470
+ * four box corners.
471
+ *
472
+ * Not `|scale − 1|` and not `|t|` — either alone is misleading, because the
473
+ * translation is chosen to centre the boxes and therefore *cancels* part of the
474
+ * scale. What an author cares about, and what the loop should stop on, is how far
475
+ * anything actually moves.
476
+ */
477
+ export function fitDistance(fit: FramingFit): number {
478
+ return cornerSpread(fit.candidate, (x, y) => [fit.scale * x + fit.dx - x, fit.scale * y + fit.dy - y]);
479
+ }
480
+
481
+ /**
482
+ * How far apart two passes' corrections are, in pixels — the same corner measure
483
+ * as `fitDistance`, applied to the difference between the two transforms.
484
+ *
485
+ * This is what tells a framing loop that it is **cycling** rather than
486
+ * converging. The loop's passes are not independent samples: each one is measured
487
+ * on the render the previous one produced, so when the fit has no fixed point the
488
+ * sequence falls into a repeating orbit instead of wandering. Rung 6 does exactly
489
+ * that with period 4 — passes 4–7 repeat as 8–11 and again as 12–15, agreeing to
490
+ * within 0.02 px — and a loop that cannot tell that apart from slow convergence
491
+ * spends every remaining pass re-measuring states it has already seen.
492
+ *
493
+ * The boxes come from `a`, because the two fits are measured against the same
494
+ * reference frames and it is the candidate's own extent that the correction moves.
495
+ */
496
+ export function fitSeparation(a: FramingFit, b: FramingFit): number {
497
+ return cornerSpread(a.candidate, (x, y) => [
498
+ a.scale * x + a.dx - (b.scale * x + b.dx),
499
+ a.scale * y + a.dy - (b.scale * y + b.dy),
500
+ ]);
501
+ }
502
+
503
+ /** The worst displacement a transform applies over a box's four corners. */
504
+ function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [number, number]): number {
505
+ const { left, top, right, bottom } = box;
506
+ let worst = 0;
507
+ for (const [x, y] of [
508
+ [left, top],
509
+ [right, top],
510
+ [left, bottom],
511
+ [right, bottom],
512
+ ]) {
513
+ const [dx, dy] = displace(x, y);
514
+ worst = Math.max(worst, Math.hypot(dx, dy));
515
+ }
516
+ return worst;
517
+ }
518
+
519
+ /**
520
+ * How far two passes' corrections may differ and still be called the same state.
521
+ *
522
+ * Half of `SETTLED_PIXELS`, and the gap it has to live in was measured rather than
523
+ * picked: on rung 6 two passes one period apart differ by **0.018 px** while two
524
+ * adjacent passes of the same orbit differ by **0.110 px**. A detector at 0.05 px
525
+ * separates those by a factor of three either way. Loose enough to fire late, and
526
+ * a late cycle report costs a pass; tight enough that it never fires on a loop
527
+ * that is still moving, and a false one would stop a converging fit short.
528
+ */
529
+ export const CYCLE_PIXELS = SETTLED_PIXELS / 2;
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // the MAE-refined final pass
533
+ // ---------------------------------------------------------------------------
534
+
535
+ /**
536
+ * How far the MAE-refined pass may move a fitted box, in frame pixels.
537
+ *
538
+ * Two, because what it is there to recover is *one* pixel. The extent fit lands
539
+ * within a fraction of a pixel of its own optimum and its optimum is the wrong
540
+ * one by about that much (`fitFraming`, "the floor, stated plainly"), so the
541
+ * distance between "where the extents agree" and "where the pictures agree" is a
542
+ * pixel or two and never more — measured across the committed corpus, every
543
+ * offset the pass applies is `(0, ±1)`, `(±1, 0)`, `(±1, ±1)`, `(−1, 2)` or
544
+ * `(−2, ≤1)`, and none of the 86 sets wants a corner of the window. A wider
545
+ * window would start being able to absorb a real displacement, which is the one
546
+ * thing the framing must not do.
547
+ */
548
+ export const REFINE_RADIUS = 2;
549
+
550
+ /**
551
+ * How much of the figure a shift must buy before it is allowed to move the box,
552
+ * as a fraction of the set's own reference-denominator MAE...
553
+ *
554
+ * ...and `REFINE_MIN_GAIN_MAE` beside it, absolutely, because a ratio alone means
555
+ * nothing on a shot whose MAE is already 3.
556
+ *
557
+ * ## What the corpus says, and what the threshold is therefore for
558
+ *
559
+ * Measured over the 86 compared sets of the committed runs: the best offset in
560
+ * ±2 px is the **exact identity** on 52 of them — every set framed by
561
+ * `frames.json`'s own box among them, which is the `idle`-class control issue #146
562
+ * asked for — and on the other 34 the gain runs **0.9 % … 30.9 %** (0.40 … 14.34
563
+ * MAE), clustering at 3 % and above with two lone readings at 1.0 % and 0.9 %.
564
+ *
565
+ * ⚠️ So this is **not** a threshold separating two measured populations, and it
566
+ * must not be quoted as one: it is a floor under a continuum. What makes a low
567
+ * floor the right shape here is that the pass minimises the reported figure
568
+ * *itself*, so the cost of applying a marginal offset is bounded by the threshold
569
+ * — a hundredth of the figure — while the cost of refusing one is a constant pixel
570
+ * left inside a number an author reads as motion. Erring towards applying is the
571
+ * cheap direction, and the report prints what was applied and what it was worth
572
+ * either way.
573
+ */
574
+ export const REFINE_MIN_GAIN = 0.01;
575
+ /** ...and how much of the figure that is, in MAE points, whatever the ratio says. */
576
+ export const REFINE_MIN_GAIN_MAE = 0.1;
577
+
578
+ /** What the best whole-pixel offset in a window is worth, over a set's frames. */
579
+ export interface OffsetGain {
580
+ dx: number;
581
+ dy: number;
582
+ /** Mean reference-denominator MAE at the identity — the figure as it stands. */
583
+ identity: number;
584
+ /** ...and at `dx, dy`, which is the same figure with one constant taken out. */
585
+ best: number;
586
+ /** How far the search looked, and how many frames it pooled. */
587
+ radius: number;
588
+ frames: number;
589
+ }
590
+
591
+ /**
592
+ * The set's reference-denominator MAE at every whole-pixel offset in a window,
593
+ * accumulated one frame at a time.
594
+ *
595
+ * ## What this is for: the constant pixel a settled fit still leaves
596
+ *
597
+ * `fitFraming` registers two shots by their **extent**, and a shot whose
598
+ * silhouette genuinely differs has its best extent fit about a third of a pixel
599
+ * from its best alignment. Measured on the spineboy candidates (issue #146), that
600
+ * floor is not a rounding detail: a **constant** translation of one or two pixels
601
+ * is worth 12 % of `death`'s headline MAE and up to 30 % of a fitted set's,
602
+ * while the genuinely per-frame remainder is a tenth of it. A loop reading those
603
+ * numbers as motion is reading a framing offset.
604
+ *
605
+ * ## Why the objective is the reference denominator
606
+ *
607
+ * Because it is the figure the report tells an author to optimise against
608
+ * (`FrameCheck.maeReference`), and because it is the one the candidate cannot
609
+ * grow: minimising the union MAE would let a shift that drags more cheap pixels
610
+ * into the denominator win, which is issue #119 arriving by another door.
611
+ *
612
+ * ## Why whole pixels, and why a plate shift rather than a re-render
613
+ *
614
+ * The projector is `px = (wx − minX)·k`, so moving the box by exactly `dx/k`
615
+ * moves every sample point by exactly one pixel and samples the same texels —
616
+ * the render at the shifted box **is** the render shifted, but for content
617
+ * outside the old frame. That makes a 25-offset search cost one render per frame
618
+ * instead of 25, and it is why the window is whole pixels: a sub-pixel offset
619
+ * changes the resampling, so nothing could be searched without re-rendering it.
620
+ * The offset that wins is then applied to the viewport and everything the report
621
+ * prints is measured on a real render at a real box.
622
+ */
623
+ export class OffsetScan {
624
+ readonly radius: number;
625
+ private readonly span: number;
626
+ /** Σ over frames of the per-frame reference-denominator MAE, per offset. */
627
+ private readonly sums: Float64Array;
628
+ private counted = 0;
629
+
630
+ constructor(radius: number = REFINE_RADIUS) {
631
+ this.radius = Math.max(0, Math.round(radius));
632
+ this.span = this.radius * 2 + 1;
633
+ this.sums = new Float64Array(this.span * this.span);
634
+ }
635
+
636
+ /**
637
+ * One frame: the candidate as it was rendered, its own coverage mask, and the
638
+ * reference as it is on disk.
639
+ *
640
+ * ⭐ The figure accumulated here is `FrameCheck.maeReference` **exactly** —
641
+ * the difference summed over the pixels either side covers, over the count of
642
+ * the ones the *reference* covers — because the line the report prints and the
643
+ * line this pass minimises have to be the same line. Taking the numerator over
644
+ * the reference's pixels alone would be a near neighbour of it and a different
645
+ * number, and a "54.31 → 48.47" that did not match the MAE line under it would
646
+ * be two measurements wearing one name.
647
+ *
648
+ * A frame the reference drew nothing in counts as zero rather than being
649
+ * skipped, which is again what `checkOneFrame` does with it: an empty
650
+ * denominator is not a measurement, and dropping the frame instead would divide
651
+ * this pass's mean by a different frame count than the report's.
652
+ */
653
+ add(candidate: Plate, coverage: Uint8Array, reference: Plate, background: RGBA): void {
654
+ const { width, height } = reference;
655
+ this.counted++;
656
+ // The reference's own drawn pixels, by the predicate `checkOneFrame` uses.
657
+ const drawn = new Uint8Array(width * height);
658
+ const drawnAt: number[] = [];
659
+ for (let y = 0; y < height; y++) {
660
+ for (let x = 0; x < width; x++) {
661
+ if (!isContent(reference, x, y, background)) continue;
662
+ const at = y * width + x;
663
+ drawn[at] = 1;
664
+ drawnAt.push(at);
665
+ }
666
+ }
667
+ if (drawnAt.length === 0) return;
668
+ // ...and the candidate's, which move with the offset. Held as a list because
669
+ // the second sum below walks them in candidate coordinates.
670
+ const inkAt: number[] = [];
671
+ for (let at = 0; at < coverage.length; at++) if (coverage[at] === 1) inkAt.push(at);
672
+
673
+ const a = candidate.data;
674
+ const b = reference.data;
675
+ const bg = background;
676
+ /** |candidate at `from` − reference at `to`|, mean over RGB, either off-grid. */
677
+ const delta = (from: number, to: number): number => {
678
+ const j = to * 4;
679
+ const i = from * 4;
680
+ const ar = from < 0 ? bg[0] : a[i];
681
+ const ag = from < 0 ? bg[1] : a[i + 1];
682
+ const ab = from < 0 ? bg[2] : a[i + 2];
683
+ const br = to < 0 ? bg[0] : b[j];
684
+ const bgc = to < 0 ? bg[1] : b[j + 1];
685
+ const bb = to < 0 ? bg[2] : b[j + 2];
686
+ return (Math.abs(ar - br) + Math.abs(ag - bgc) + Math.abs(ab - bb)) / 3;
687
+ };
688
+ for (let dy = -this.radius; dy <= this.radius; dy++) {
689
+ for (let dx = -this.radius; dx <= this.radius; dx++) {
690
+ let sum = 0;
691
+ // Every reference-drawn pixel, against whatever the shifted candidate puts
692
+ // there — background where the shift pulls it off its own grid, which is
693
+ // what the render at the shifted box would draw.
694
+ for (let i = 0; i < drawnAt.length; i++) {
695
+ const at = drawnAt[i];
696
+ const x = at % width;
697
+ const y = (at - x) / width;
698
+ const sx = x - dx;
699
+ const sy = y - dy;
700
+ sum += delta(sx < 0 || sy < 0 || sx >= width || sy >= height ? -1 : sy * width + sx, at);
701
+ }
702
+ // ...and every pixel the candidate covers that the reference does not,
703
+ // which is the other half of the union. A pixel the shift carries out of
704
+ // the frame is clipped there, so it leaves the sum entirely.
705
+ for (let i = 0; i < inkAt.length; i++) {
706
+ const at = inkAt[i];
707
+ const x = at % width;
708
+ const y = (at - x) / width;
709
+ const tx = x + dx;
710
+ const ty = y + dy;
711
+ if (tx < 0 || ty < 0 || tx >= width || ty >= height) continue;
712
+ const to = ty * width + tx;
713
+ if (drawn[to] === 1) continue;
714
+ sum += delta(at, to);
715
+ }
716
+ this.sums[this.index(dx, dy)] += sum / drawnAt.length;
717
+ }
718
+ }
719
+ }
720
+
721
+ /**
722
+ * The offset with the lowest figure, or `null` when no frame carried reference
723
+ * ink.
724
+ *
725
+ * Ties go to the smaller displacement and then to the lower `dy`, `dx`, so the
726
+ * answer is a function of the pixels and not of the iteration order —
727
+ * `A18_DETERMINISTIC_EMIT`'s discipline applied to a measurement. The identity
728
+ * therefore wins any tie it is in, which is what makes "no constant offset
729
+ * here" a reachable answer rather than an arbitrary one.
730
+ */
731
+ best(): OffsetGain | null {
732
+ if (this.counted === 0) return null;
733
+ let bestDx = 0;
734
+ let bestDy = 0;
735
+ let bestSum = this.sums[this.index(0, 0)];
736
+ for (let dy = -this.radius; dy <= this.radius; dy++) {
737
+ for (let dx = -this.radius; dx <= this.radius; dx++) {
738
+ const sum = this.sums[this.index(dx, dy)];
739
+ if (sum > bestSum) continue;
740
+ if (sum === bestSum && !closerToHome(dx, dy, bestDx, bestDy)) continue;
741
+ bestSum = sum;
742
+ bestDx = dx;
743
+ bestDy = dy;
744
+ }
745
+ }
746
+ return {
747
+ dx: bestDx,
748
+ dy: bestDy,
749
+ identity: this.sums[this.index(0, 0)] / this.counted,
750
+ best: bestSum / this.counted,
751
+ radius: this.radius,
752
+ frames: this.counted,
753
+ };
754
+ }
755
+
756
+ private index(dx: number, dy: number): number {
757
+ return (dy + this.radius) * this.span + (dx + this.radius);
758
+ }
759
+ }
760
+
761
+ /** Is `(dx, dy)` the smaller displacement, ties broken by `dy` then `dx`? */
762
+ function closerToHome(dx: number, dy: number, atX: number, atY: number): boolean {
763
+ const mine = dx * dx + dy * dy;
764
+ const theirs = atX * atX + atY * atY;
765
+ if (mine !== theirs) return mine < theirs;
766
+ if (dy !== atY) return dy < atY;
767
+ return dx < atX;
768
+ }
769
+
770
+ /** Is this offset worth moving a box for? See `REFINE_MIN_GAIN`. */
771
+ export function offsetIsWorthApplying(gain: OffsetGain): boolean {
772
+ if (gain.dx === 0 && gain.dy === 0) return false;
773
+ const won = gain.identity - gain.best;
774
+ return won >= REFINE_MIN_GAIN_MAE && gain.identity > 0 && won / gain.identity >= REFINE_MIN_GAIN;
775
+ }
776
+
777
+ /**
778
+ * The same box moved by whole frame pixels, at the same scale.
779
+ *
780
+ * `applyFit` with a scale of exactly 1, written out rather than routed through
781
+ * it, because the refined pass changes no scale at all and a fit-shaped argument
782
+ * with `scale: 1` in it would invite one.
783
+ */
784
+ export function shiftViewport(
785
+ viewport: Viewport,
786
+ dx: number,
787
+ dy: number,
788
+ pixelWidth: number,
789
+ pixelHeight: number,
790
+ ): Viewport {
791
+ const minX = viewport.minX - dx / viewport.scale;
792
+ const maxY = viewport.maxY + dy / viewport.scale;
793
+ const width = pixelWidth / viewport.scale;
794
+ const height = pixelHeight / viewport.scale;
795
+ return viewportOfSize(minX, maxY - height, width, height, viewport.scale, pixelWidth, pixelHeight);
796
+ }
797
+
798
+ /**
799
+ * The viewport that renders the candidate where the fit says it belongs.
800
+ *
801
+ * `projector` is `px = (wx − minX)·k`, `py = (maxY − wy)·k`, so asking for
802
+ * `px' = s·px + dx` is asking for `k' = s·k` and an origin moved by `dx/k'`. The
803
+ * pixel size is the frames' own and is never re-derived from the box — rounding it
804
+ * a second time would shift every measurement by up to half a pixel, which on
805
+ * these shots is louder than most of what is being measured.
806
+ */
807
+ export function applyFit(
808
+ viewport: Viewport,
809
+ fit: FramingFit,
810
+ pixelWidth: number,
811
+ pixelHeight: number,
812
+ ): Viewport {
813
+ const scale = viewport.scale * fit.scale;
814
+ const minX = viewport.minX - fit.dx / scale;
815
+ const maxY = viewport.maxY + fit.dy / scale;
816
+ const width = pixelWidth / scale;
817
+ const height = pixelHeight / scale;
818
+ return viewportOfSize(minX, maxY - height, width, height, scale, pixelWidth, pixelHeight);
819
+ }