spine-rigc 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.ts CHANGED
@@ -214,6 +214,17 @@ function readSidecar(root: string): FramesSidecar | null {
214
214
  const raw = readFrameFile(root, join(root, FRAMES_SIDECAR)).toString('utf8');
215
215
  const parsed: unknown = JSON.parse(raw);
216
216
  if (typeof parsed !== 'object' || parsed === null) return null;
217
+ // Before the spec, because it is the more specific answer: a `check --out`
218
+ // directory carries this build's own spec, and what is wrong with it is not
219
+ // its version but what it is a picture OF — see `COMPARISON_FIELD`.
220
+ if (Object.prototype.hasOwnProperty.call(parsed, COMPARISON_FIELD)) {
221
+ throw new CheckError(
222
+ `${join(root, FRAMES_SIDECAR)} carries ${JSON.stringify(COMPARISON_FIELD)}: it was written by \`rigc check ` +
223
+ '--out`, and a check\'s pictures are a comparison, not a reference frame set — each one holds a reference ' +
224
+ 'pane, but the file is four panes and a table. Point --frames at the frames that comparison was made ' +
225
+ `against, which its ${JSON.stringify(COMPARISON_FIELD)}.frames names.`,
226
+ );
227
+ }
217
228
  const sidecar = parsed as FramesSidecar;
218
229
  if (sidecar.spec !== FRAMES_SPEC) {
219
230
  throw new CheckError(
@@ -1006,6 +1017,12 @@ export interface CheckOptions {
1006
1017
  * this adds a second render per compared frame beside them.
1007
1018
  */
1008
1019
  textureFrom?: { atlasText: string; atlasDir: string; label: string };
1020
+ /**
1021
+ * Keep the rasters behind the frames the report will list, for `--out` — see
1022
+ * `CheckPlates`. Nothing about the report changes when this is set: it is filled
1023
+ * in beside the comparison, from the plates the comparison was computed on.
1024
+ */
1025
+ plates?: CheckPlates;
1009
1026
  }
1010
1027
 
1011
1028
  // ---------------------------------------------------------------------------
@@ -1056,6 +1073,22 @@ export interface CheckOptions {
1056
1073
  */
1057
1074
  export function checkAgainstFrames(options: CheckOptions): CheckReport {
1058
1075
  const located = locateFrames(options.framesDir);
1076
+ // A frame set `render --slot`/`--hide` wrote is a picture of PART of a rig
1077
+ // (issue #835), and it is refused before anything is posed. The clause is the
1078
+ // skin mismatch's below, one step stronger: there two skins are two pictures
1079
+ // of one rig, here the reference is not a picture of a whole rig at all, so a
1080
+ // whole candidate compared against it would print a real figure about art the
1081
+ // reference leaves out. No flag makes that comparable — a warning would still
1082
+ // print the figure — so there is no remedy here but the reference's own.
1083
+ const subsetKey = located.sidecar?.slots !== undefined ? 'slots' : located.sidecar?.hidden !== undefined ? 'hidden' : null;
1084
+ if (subsetKey !== null) {
1085
+ const recorded = located.sidecar?.[subsetKey];
1086
+ throw new CheckError(
1087
+ `--frames ${options.framesDir} records a slot subset (${subsetKey}: ${
1088
+ Array.isArray(recorded) ? recorded.join(', ') : JSON.stringify(recorded)
1089
+ }) in ${FRAMES_SIDECAR}; a partial render is not a reference set — render the reference without --slot/--hide`,
1090
+ );
1091
+ }
1059
1092
  const notes: string[] = [];
1060
1093
 
1061
1094
  const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
@@ -1136,11 +1169,20 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1136
1169
  duration: disk[disk.length - 1].index / fps,
1137
1170
  },
1138
1171
  ];
1172
+ // Two sources write a set like this, and they need opposite advice (issue
1173
+ // #842): a rigc render older than the sidecar is fixed by rendering it again,
1174
+ // while a foreign player's frames predate nothing and no rigc tool renders
1175
+ // their source — what they need is the three things the sidecar would have
1176
+ // said, supplied by whoever made them.
1139
1177
  notes.push(
1140
- `no ${FRAMES_SIDECAR} at ${located.root} or beside it — this frame set predates the sidecar. The rate is ` +
1178
+ `no ${FRAMES_SIDECAR} at ${located.root} or beside it. The rate is ` +
1141
1179
  `--fps ${fps}${options.fps === undefined ? ' (the protocol default, not a measurement of these frames)' : ''} ` +
1142
- `and the background is this build's default (${BACKGROUND.join(', ')}). Re-render the set with ` +
1143
- 'bench/render_reference.ts and both become facts about the frames.',
1180
+ `and the background is this build's default, ${BACKGROUND.join(', ')}; neither is read from the frames. ` +
1181
+ 'A set with no sidecar is one of two things. A rigc render older than the sidecar: re-render it with ' +
1182
+ '`rigc render` (bench/render_reference.ts for an editor export) and both become facts about the frames. ' +
1183
+ `A foreign source — a Live2D, Unity or video player: render it onto an opaque background of ` +
1184
+ `${BACKGROUND.join(', ')}, pass --fps at the rate it was rendered, and name the directory after the ` +
1185
+ 'candidate animation it shows, or pass --as <that animation>.',
1144
1186
  );
1145
1187
  // The set root is the animation directory itself here, so reads resolve
1146
1188
  // against its parent the way a sidecar layout does.
@@ -1193,8 +1235,36 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1193
1235
  // `EDGE_FRACTION`. A handful of frames is enough: the level is a property of the
1194
1236
  // palette, not of a pose, and reading every frame twice to learn it is waste.
1195
1237
  const level = pairs.length === 0 ? BACKGROUND_TOLERANCE : edgeLevelOf(located.root, pairs, background);
1238
+ // Without a sidecar the background is an assumption, and it is an assumption
1239
+ // about a COLOUR: the box and the union alpha are found against it with alpha
1240
+ // unread. So a reference that is not opaque is refused rather than scored
1241
+ // (issue #842). Measured on gallery/look's `turn` with its background made
1242
+ // transparent and the candidate pinned by --viewport to the box the frames were
1243
+ // drawn in — where the same set over the assumed grey reads MAE 0.00 exactly —
1244
+ // it read 32.90 on every one of the 24 frames, which is the transparent area's
1245
+ // share of the frame times its distance from the grey: a figure about the
1246
+ // background, not the rig, and no framing moves it. A frame set WITH a sidecar
1247
+ // is `render`'s, which composites onto the colour it records, so it is not asked.
1248
+ const translucent: TranslucentFrame[] | null = located.sidecar ? null : [];
1196
1249
  const referenceBoxes =
1197
- pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
1250
+ pairs.length === 0
1251
+ ? []
1252
+ : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight, translucent);
1253
+ if (translucent !== null && translucent.length > 0) {
1254
+ const first = translucent[0];
1255
+ const shown = translucent.slice(0, TRANSLUCENT_NAMED).map((t) => basename(t.file));
1256
+ throw new CheckError(
1257
+ `--frames ${options.framesDir} has no ${FRAMES_SIDECAR}, and ${translucent.length} of its ${pairs.length} ` +
1258
+ `compared reference frame(s) are not opaque (${shown.join(', ')}${
1259
+ translucent.length > shown.length ? `, and ${translucent.length - shown.length} more` : ''
1260
+ }): ${basename(first.file)} has alpha ${first.alpha} at (${first.x}, ${first.y}), where every pixel must ` +
1261
+ `be 255. Without a sidecar the frames are read against the background colour ${BACKGROUND.join(', ')} ` +
1262
+ 'with alpha unread, so a pixel that is not opaque counts by its colour bytes alone: a transparent ' +
1263
+ 'background counts as drawn and puts the figure over the whole frame, a number about the transparent ' +
1264
+ 'area and not the rig, which --viewport does not change. Render the frames onto an opaque background of ' +
1265
+ `${BACKGROUND.join(', ')}.`,
1266
+ );
1267
+ }
1198
1268
 
1199
1269
  const scope: FramingScope = options.framing ?? 'per-shot';
1200
1270
  const slices = sliceBySet(prepared, referenceBoxes);
@@ -1254,7 +1324,14 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1254
1324
  topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
1255
1325
  }
1256
1326
  } else if (referenceBoxes.every((b) => b === null)) {
1257
- throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
1327
+ throw new CheckError(
1328
+ `no reference frame could be compared, so there is nothing to frame against${nothingToFrameWhy(
1329
+ prepared,
1330
+ posable.data.animations.map((a) => a.name),
1331
+ options.as,
1332
+ located.sidecar !== null,
1333
+ )}`,
1334
+ );
1258
1335
  } else if (scope === 'shared' || prepared.length === 1) {
1259
1336
  const framed = frameCandidate(
1260
1337
  prepared,
@@ -1404,7 +1481,18 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1404
1481
  for (let i = 0; i < prepared.length; i++) {
1405
1482
  const f = framings[i];
1406
1483
  animations.push(
1407
- checkOneSet(located.root, prepared[i], posable, f, background, chains, chainOfSlot, substitution, unmatched),
1484
+ checkOneSet(
1485
+ located.root,
1486
+ prepared[i],
1487
+ posable,
1488
+ f,
1489
+ background,
1490
+ chains,
1491
+ chainOfSlot,
1492
+ substitution,
1493
+ unmatched,
1494
+ options.plates ?? null,
1495
+ ),
1408
1496
  );
1409
1497
  }
1410
1498
 
@@ -1681,7 +1769,38 @@ function edgeLevelOf(root: string, pairs: FramePair[], background: RGBA): number
1681
1769
  return histogram.level();
1682
1770
  }
1683
1771
 
1684
- /** Each reference frame's own content box, and a check that they are one grid. */
1772
+ /** How many translucent reference frames the refusal names before it counts the rest. */
1773
+ const TRANSLUCENT_NAMED = 3;
1774
+
1775
+ /** The first pixel of a reference frame that is not fully opaque — see `referenceContentBoxes`. */
1776
+ interface TranslucentFrame {
1777
+ file: string;
1778
+ x: number;
1779
+ y: number;
1780
+ alpha: number;
1781
+ }
1782
+
1783
+ /** The first pixel whose alpha is not 255, in row order, or null when the frame is opaque. */
1784
+ function firstTranslucentPixel(plate: Plate): { x: number; y: number; alpha: number } | null {
1785
+ for (let i = 3; i < plate.data.length; i += 4) {
1786
+ if (plate.data[i] !== 255) {
1787
+ const at = (i - 3) / 4;
1788
+ return { x: at % plate.width, y: Math.floor(at / plate.width), alpha: plate.data[i] };
1789
+ }
1790
+ }
1791
+ return null;
1792
+ }
1793
+
1794
+ /**
1795
+ * Each reference frame's own content box, and a check that they are one grid.
1796
+ *
1797
+ * `translucent`, when given, collects every frame that is not fully opaque. The
1798
+ * content box and the union alpha are found against the background COLOUR and
1799
+ * alpha is never read (`backgroundDistance`), so a transparent pixel is drawn to
1800
+ * both of them whatever its colour bytes say. It is collected here rather than in
1801
+ * a pass of its own because this is where every compared frame is already
1802
+ * decoded, and a frame that is not compared cannot move a figure.
1803
+ */
1685
1804
  function referenceContentBoxes(
1686
1805
  root: string,
1687
1806
  pairs: FramePair[],
@@ -1689,6 +1808,7 @@ function referenceContentBoxes(
1689
1808
  level: number,
1690
1809
  pixelWidth: number,
1691
1810
  pixelHeight: number,
1811
+ translucent: TranslucentFrame[] | null,
1692
1812
  ): Array<ContentBox | null> {
1693
1813
  return pairs.map((pair) => {
1694
1814
  const plate = readPlateFrom(root, pair.file);
@@ -1698,6 +1818,10 @@ function referenceContentBoxes(
1698
1818
  'the frames and the sidecar disagree about their own size',
1699
1819
  );
1700
1820
  }
1821
+ if (translucent !== null) {
1822
+ const at = firstTranslucentPixel(plate);
1823
+ if (at !== null) translucent.push({ file: pair.file, ...at });
1824
+ }
1701
1825
  return contentBoxOfPlate(plate, background, level);
1702
1826
  });
1703
1827
  }
@@ -2354,6 +2478,46 @@ function prepareSet(
2354
2478
  };
2355
2479
  }
2356
2480
 
2481
+ /**
2482
+ * What the "nothing to frame against" refusal adds when the sets can say why.
2483
+ *
2484
+ * The one reason a whole run has no reference box that a reader can act on is a
2485
+ * name that matched no candidate animation, and `prepareSet` already composed
2486
+ * that per set — then the refusal fired before any set was reported, so the one
2487
+ * sentence that named the fix never reached the reader (issue #842). This says
2488
+ * where each name came from, because the fix differs: a directory name is
2489
+ * renamed or overridden, an `--as` is corrected. Empty when every set matched,
2490
+ * so the refusal's other causes keep the sentence they had.
2491
+ */
2492
+ function nothingToFrameWhy(
2493
+ prepared: PreparedSet[],
2494
+ have: string[],
2495
+ as: string | undefined,
2496
+ sidecar: boolean,
2497
+ ): string {
2498
+ const unmatched = prepared.filter((p) => p.missing !== null);
2499
+ if (unmatched.length === 0) return '';
2500
+ const declared = `it declares [${have.join(', ') || 'none'}]`;
2501
+ if (as !== undefined) {
2502
+ return (
2503
+ `: --as ${JSON.stringify(as)} names no animation of the candidate — ${declared}. --as takes one candidate ` +
2504
+ 'animation name, the one these frames show'
2505
+ );
2506
+ }
2507
+ const tried = unmatched.map((p) => {
2508
+ const name = JSON.stringify(p.set.animation);
2509
+ if (sidecar) return `${name} (what ${FRAMES_SIDECAR} records for set ${JSON.stringify(p.set.dir)})`;
2510
+ return p.set.dir === p.set.animation
2511
+ ? `${name} (the directory's own name)`
2512
+ : `${name} (the directory ${JSON.stringify(p.set.dir)}, its @fps suffix dropped)`;
2513
+ });
2514
+ return (
2515
+ `: the frames were matched to a candidate animation by name, and the candidate has no animation called ` +
2516
+ `${tried.join(', ')} — ${declared}. Pass --as <name> with the one these frames show, or name the ` +
2517
+ 'directory after it'
2518
+ );
2519
+ }
2520
+
2357
2521
  function checkOneSet(
2358
2522
  root: string,
2359
2523
  prepared: PreparedSet,
@@ -2367,6 +2531,8 @@ function checkOneSet(
2367
2531
  substitution: TextureSubstitution | null,
2368
2532
  /** Region names it could not reach, unioned across every set by the caller. */
2369
2533
  unmatched: Set<string>,
2534
+ /** Where to keep the rasters of the frames the report will list — `null` keeps none. */
2535
+ plates: CheckPlates | null,
2370
2536
  ): AnimationCheck {
2371
2537
  const { set } = prepared;
2372
2538
  const viewport = framing.viewport;
@@ -2437,6 +2603,7 @@ function checkOneSet(
2437
2603
  // because `substituteTexture` prefixes every name it writes.
2438
2604
  const floorPages = substitution === null ? null : new Map([...posable.pages, ...substitution.pages]);
2439
2605
  const floorSum = { floor: 0, aboveFloor: 0, floorReference: 0, aboveFloorReference: 0 };
2606
+ plates?.begin(set.dir, prepared.pairs.length);
2440
2607
 
2441
2608
  for (const { index, file, frame } of prepared.pairs) {
2442
2609
  const reference = readPlateFrom(root, file);
@@ -2447,7 +2614,7 @@ function checkOneSet(
2447
2614
  for (const name of swapped.unmatched) unmatched.add(name);
2448
2615
  floorPlate = renderFrame(swapped.frame, floorPages, viewport, background);
2449
2616
  }
2450
- const check = checkOneFrame(
2617
+ const { check, coverage } = checkOneFrame(
2451
2618
  index,
2452
2619
  file,
2453
2620
  frame,
@@ -2462,6 +2629,9 @@ function checkOneSet(
2462
2629
  );
2463
2630
  check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
2464
2631
  previous = { index, candidate: rendered, reference };
2632
+ // After the change is known, because whether a frame will be listed depends
2633
+ // on it — see `CheckPlates.offer`.
2634
+ plates?.offer(set.dir, check, { reference, candidate: rendered, coverage });
2465
2635
  frames.push(check);
2466
2636
  maeSum += check.mae;
2467
2637
  maeReferenceSum += check.maeReference;
@@ -2827,7 +2997,7 @@ function checkOneFrame(
2827
2997
  * see `TextureFloor`. `null` is the ordinary case and costs nothing.
2828
2998
  */
2829
2999
  floorPlate: Plate | null,
2830
- ): FrameCheck {
3000
+ ): { check: FrameCheck; coverage: Uint8Array } {
2831
3001
  const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
2832
3002
  // Only worth the transform when something was drawn to be nearest TO.
2833
3003
  const nearest =
@@ -2911,7 +3081,7 @@ function checkOneFrame(
2911
3081
  }
2912
3082
  }
2913
3083
 
2914
- return {
3084
+ const check: FrameCheck = {
2915
3085
  index,
2916
3086
  file,
2917
3087
  mae: union === 0 ? 0 : sum / union,
@@ -2943,6 +3113,9 @@ function checkOneFrame(
2943
3113
  aboveFloorReference: referencePixels === 0 ? 0 : aboveReferenceSum / referencePixels,
2944
3114
  },
2945
3115
  };
3116
+ // The coverage goes back beside the figures because the union it defines is the
3117
+ // one a `--out` difference pane is drawn over — see `CheckPlates`.
3118
+ return { check, coverage };
2946
3119
  }
2947
3120
 
2948
3121
  // ---------------------------------------------------------------------------
@@ -3653,7 +3826,7 @@ function chainRollup(report: CheckReport): ChainRollup[] {
3653
3826
  * ranked by MAE is exactly the listing that leaves them out. Rung 6's f65–f68 sit
3654
3827
  * near the bottom of that ranking.
3655
3828
  */
3656
- function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
3829
+ export function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
3657
3830
  if (allFrames || anim.frames.length <= LIST_EVERY) return anim.frames;
3658
3831
  const chosen = new Set(
3659
3832
  [...anim.frames]
@@ -3665,6 +3838,129 @@ function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
3665
3838
  return anim.frames.filter((f) => chosen.has(f.index));
3666
3839
  }
3667
3840
 
3841
+ /**
3842
+ * The field of a `frames.json` that says the directory is `check --out`'s
3843
+ * pictures and not a frame set — see `src/checkpics.ts`. `check --frames`
3844
+ * refuses a sidecar carrying it, by this name.
3845
+ */
3846
+ export const COMPARISON_FIELD = 'comparison';
3847
+
3848
+ /** The three rasters one frame's figures were computed on. */
3849
+ export interface ComparedPlates {
3850
+ /** The reference frame, as read from `--frames`. */
3851
+ reference: Plate;
3852
+ /** The candidate, rendered onto the reference's grid over the frames' background. */
3853
+ candidate: Plate;
3854
+ /** Which pixels the candidate's geometry covers, 1 or 0 — half of the union alpha. */
3855
+ coverage: Uint8Array;
3856
+ }
3857
+
3858
+ /**
3859
+ * The rasters behind the frames a report will list, kept at the moment they were
3860
+ * compared — what `check --out` draws its pictures from.
3861
+ *
3862
+ * ## Why they have to be kept, and why not all of them
3863
+ *
3864
+ * `checkOneSet` holds a frame's two plates for exactly one iteration (and the
3865
+ * previous frame's for the change measure); the difference is never a raster at
3866
+ * all, only a running sum. And which frames are *worth reading* is not known
3867
+ * until the set is finished, because it is the worst by MAE over all of them —
3868
+ * `framesToList` decides it at print time. So the choice is between re-rendering
3869
+ * the listed frames afterwards and keeping them now, and re-rendering is rejected:
3870
+ * a second render that agreed with the first would be a claim about the picture,
3871
+ * and this is meant to be the record of it.
3872
+ *
3873
+ * Keeping every frame is the other simple answer and it does not scale: one
3874
+ * frame at 256x116 is 261 KiB of plates and coverage, and a 300-frame set at
3875
+ * 512x512 would hold 675 MiB. So a set longer than the listing threshold keeps a
3876
+ * running top `WORST_FRAMES` by MAE — ties to the earlier index, which is the
3877
+ * order `framesToList`'s stable sort gives them — plus every frame whose change
3878
+ * disagrees, and drops the rest as it goes. `--all-frames` keeps everything,
3879
+ * because then everything is listed.
3880
+ *
3881
+ * 🔒 `framesToList` stays the one derivation of the listing. This only has to
3882
+ * keep a superset of it, and `writeCheckPictures` refuses by name a listed frame
3883
+ * it finds nothing kept for, so the two cannot disagree in silence.
3884
+ */
3885
+ export class CheckPlates {
3886
+ /** Whether every compared frame is kept — `--all-frames`. */
3887
+ readonly every: boolean;
3888
+ private readonly kept = new Map<string, Map<number, ComparedPlates>>();
3889
+ /** Per set: whether the whole set will be listed, so everything is kept. */
3890
+ private readonly whole = new Map<string, boolean>();
3891
+ /** Per set: the running worst by MAE, worst first, at most `WORST_FRAMES`. */
3892
+ private readonly ranked = new Map<string, Array<{ index: number; mae: number }>>();
3893
+ /** Per set: frames kept because their change disagrees, whatever their MAE. */
3894
+ private readonly disagreeing = new Map<string, Set<number>>();
3895
+ private held = 0;
3896
+ /** The most bytes of raster this held at any one time — the cost `--out` adds. */
3897
+ peakBytes = 0;
3898
+
3899
+ constructor(opts: { allFrames: boolean }) {
3900
+ this.every = opts.allFrames;
3901
+ }
3902
+
3903
+ /** A set is about to be compared, over this many frame pairs. */
3904
+ begin(dir: string, compared: number): void {
3905
+ this.kept.set(dir, new Map());
3906
+ this.whole.set(dir, this.every || compared <= LIST_EVERY);
3907
+ this.ranked.set(dir, []);
3908
+ this.disagreeing.set(dir, new Set());
3909
+ }
3910
+
3911
+ /** One frame has been compared: keep its plates if it can be listed. */
3912
+ offer(dir: string, check: FrameCheck, plates: ComparedPlates): void {
3913
+ const kept = this.kept.get(dir);
3914
+ const ranked = this.ranked.get(dir);
3915
+ const disagreeing = this.disagreeing.get(dir);
3916
+ if (kept === undefined || ranked === undefined || disagreeing === undefined) {
3917
+ throw new Error(`CheckPlates: set ${JSON.stringify(dir)} was offered a frame before it began`);
3918
+ }
3919
+ if (this.whole.get(dir) === true) {
3920
+ this.keep(kept, check.index, plates);
3921
+ return;
3922
+ }
3923
+ const disagrees = check.change !== null && check.change.verdict !== 'agrees';
3924
+ if (disagrees) disagreeing.add(check.index);
3925
+ // Frames arrive in index order, so a later frame that only ties the last
3926
+ // ranked one loses to it — exactly as the stable sort would place them.
3927
+ const enters = ranked.length < WORST_FRAMES || check.mae > ranked[ranked.length - 1].mae;
3928
+ if (enters) {
3929
+ let at = ranked.findIndex((r) => check.mae > r.mae);
3930
+ if (at < 0) at = ranked.length;
3931
+ ranked.splice(at, 0, { index: check.index, mae: check.mae });
3932
+ if (ranked.length > WORST_FRAMES) {
3933
+ const out = ranked.pop() as { index: number; mae: number };
3934
+ if (!disagreeing.has(out.index)) this.drop(kept, out.index);
3935
+ }
3936
+ }
3937
+ if (enters || disagrees) this.keep(kept, check.index, plates);
3938
+ }
3939
+
3940
+ /** The plates kept for one frame of one set, if any. */
3941
+ of(dir: string, index: number): ComparedPlates | undefined {
3942
+ return this.kept.get(dir)?.get(index);
3943
+ }
3944
+
3945
+ private keep(kept: Map<number, ComparedPlates>, index: number, plates: ComparedPlates): void {
3946
+ if (kept.has(index)) return;
3947
+ kept.set(index, plates);
3948
+ this.held += bytesOf(plates);
3949
+ if (this.held > this.peakBytes) this.peakBytes = this.held;
3950
+ }
3951
+
3952
+ private drop(kept: Map<number, ComparedPlates>, index: number): void {
3953
+ const plates = kept.get(index);
3954
+ if (plates === undefined) return;
3955
+ kept.delete(index);
3956
+ this.held -= bytesOf(plates);
3957
+ }
3958
+ }
3959
+
3960
+ function bytesOf(plates: ComparedPlates): number {
3961
+ return plates.reference.data.length + plates.candidate.data.length + plates.coverage.length;
3962
+ }
3963
+
3668
3964
  /** The per-frame change measure, as the animation's own summary line. */
3669
3965
  function changeSummary(anim: AnimationCheck): string {
3670
3966
  if (anim.changePairs === 0) {