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/README.md +4 -3
- package/cli.ts +289 -46
- package/docs/AUTHORING.md +168 -8
- package/docs/MOTION.md +1 -0
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +25 -2
- package/src/ballot.ts +9 -12
- package/src/check.ts +307 -11
- package/src/checkpics.ts +293 -0
- package/src/preview.ts +223 -32
- package/src/render.ts +308 -50
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
|
|
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
|
|
1143
|
-
'
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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) {
|