spine-rigc 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -5
- package/cli.ts +562 -48
- package/docs/AUTHORING.md +156 -7
- package/docs/MOTION.md +1 -0
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +42 -12
- package/skills/{face → rigc-face}/SKILL.md +11 -7
- package/skills/{ingest → rigc-ingest}/SKILL.md +10 -6
- package/skills/{motion → rigc-motion}/SKILL.md +11 -7
- package/skills/{rigging → rigc-rigging}/SKILL.md +11 -7
- package/src/ballot.ts +9 -12
- package/src/check.ts +181 -5
- package/src/checkpics.ts +293 -0
- package/src/preview.ts +223 -32
- package/src/render.ts +140 -2
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);
|
|
@@ -1404,7 +1437,18 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
1404
1437
|
for (let i = 0; i < prepared.length; i++) {
|
|
1405
1438
|
const f = framings[i];
|
|
1406
1439
|
animations.push(
|
|
1407
|
-
checkOneSet(
|
|
1440
|
+
checkOneSet(
|
|
1441
|
+
located.root,
|
|
1442
|
+
prepared[i],
|
|
1443
|
+
posable,
|
|
1444
|
+
f,
|
|
1445
|
+
background,
|
|
1446
|
+
chains,
|
|
1447
|
+
chainOfSlot,
|
|
1448
|
+
substitution,
|
|
1449
|
+
unmatched,
|
|
1450
|
+
options.plates ?? null,
|
|
1451
|
+
),
|
|
1408
1452
|
);
|
|
1409
1453
|
}
|
|
1410
1454
|
|
|
@@ -2367,6 +2411,8 @@ function checkOneSet(
|
|
|
2367
2411
|
substitution: TextureSubstitution | null,
|
|
2368
2412
|
/** Region names it could not reach, unioned across every set by the caller. */
|
|
2369
2413
|
unmatched: Set<string>,
|
|
2414
|
+
/** Where to keep the rasters of the frames the report will list — `null` keeps none. */
|
|
2415
|
+
plates: CheckPlates | null,
|
|
2370
2416
|
): AnimationCheck {
|
|
2371
2417
|
const { set } = prepared;
|
|
2372
2418
|
const viewport = framing.viewport;
|
|
@@ -2437,6 +2483,7 @@ function checkOneSet(
|
|
|
2437
2483
|
// because `substituteTexture` prefixes every name it writes.
|
|
2438
2484
|
const floorPages = substitution === null ? null : new Map([...posable.pages, ...substitution.pages]);
|
|
2439
2485
|
const floorSum = { floor: 0, aboveFloor: 0, floorReference: 0, aboveFloorReference: 0 };
|
|
2486
|
+
plates?.begin(set.dir, prepared.pairs.length);
|
|
2440
2487
|
|
|
2441
2488
|
for (const { index, file, frame } of prepared.pairs) {
|
|
2442
2489
|
const reference = readPlateFrom(root, file);
|
|
@@ -2447,7 +2494,7 @@ function checkOneSet(
|
|
|
2447
2494
|
for (const name of swapped.unmatched) unmatched.add(name);
|
|
2448
2495
|
floorPlate = renderFrame(swapped.frame, floorPages, viewport, background);
|
|
2449
2496
|
}
|
|
2450
|
-
const check = checkOneFrame(
|
|
2497
|
+
const { check, coverage } = checkOneFrame(
|
|
2451
2498
|
index,
|
|
2452
2499
|
file,
|
|
2453
2500
|
frame,
|
|
@@ -2462,6 +2509,9 @@ function checkOneSet(
|
|
|
2462
2509
|
);
|
|
2463
2510
|
check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
|
|
2464
2511
|
previous = { index, candidate: rendered, reference };
|
|
2512
|
+
// After the change is known, because whether a frame will be listed depends
|
|
2513
|
+
// on it — see `CheckPlates.offer`.
|
|
2514
|
+
plates?.offer(set.dir, check, { reference, candidate: rendered, coverage });
|
|
2465
2515
|
frames.push(check);
|
|
2466
2516
|
maeSum += check.mae;
|
|
2467
2517
|
maeReferenceSum += check.maeReference;
|
|
@@ -2827,7 +2877,7 @@ function checkOneFrame(
|
|
|
2827
2877
|
* see `TextureFloor`. `null` is the ordinary case and costs nothing.
|
|
2828
2878
|
*/
|
|
2829
2879
|
floorPlate: Plate | null,
|
|
2830
|
-
): FrameCheck {
|
|
2880
|
+
): { check: FrameCheck; coverage: Uint8Array } {
|
|
2831
2881
|
const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
|
|
2832
2882
|
// Only worth the transform when something was drawn to be nearest TO.
|
|
2833
2883
|
const nearest =
|
|
@@ -2911,7 +2961,7 @@ function checkOneFrame(
|
|
|
2911
2961
|
}
|
|
2912
2962
|
}
|
|
2913
2963
|
|
|
2914
|
-
|
|
2964
|
+
const check: FrameCheck = {
|
|
2915
2965
|
index,
|
|
2916
2966
|
file,
|
|
2917
2967
|
mae: union === 0 ? 0 : sum / union,
|
|
@@ -2943,6 +2993,9 @@ function checkOneFrame(
|
|
|
2943
2993
|
aboveFloorReference: referencePixels === 0 ? 0 : aboveReferenceSum / referencePixels,
|
|
2944
2994
|
},
|
|
2945
2995
|
};
|
|
2996
|
+
// The coverage goes back beside the figures because the union it defines is the
|
|
2997
|
+
// one a `--out` difference pane is drawn over — see `CheckPlates`.
|
|
2998
|
+
return { check, coverage };
|
|
2946
2999
|
}
|
|
2947
3000
|
|
|
2948
3001
|
// ---------------------------------------------------------------------------
|
|
@@ -3653,7 +3706,7 @@ function chainRollup(report: CheckReport): ChainRollup[] {
|
|
|
3653
3706
|
* ranked by MAE is exactly the listing that leaves them out. Rung 6's f65–f68 sit
|
|
3654
3707
|
* near the bottom of that ranking.
|
|
3655
3708
|
*/
|
|
3656
|
-
function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
|
|
3709
|
+
export function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
|
|
3657
3710
|
if (allFrames || anim.frames.length <= LIST_EVERY) return anim.frames;
|
|
3658
3711
|
const chosen = new Set(
|
|
3659
3712
|
[...anim.frames]
|
|
@@ -3665,6 +3718,129 @@ function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
|
|
|
3665
3718
|
return anim.frames.filter((f) => chosen.has(f.index));
|
|
3666
3719
|
}
|
|
3667
3720
|
|
|
3721
|
+
/**
|
|
3722
|
+
* The field of a `frames.json` that says the directory is `check --out`'s
|
|
3723
|
+
* pictures and not a frame set — see `src/checkpics.ts`. `check --frames`
|
|
3724
|
+
* refuses a sidecar carrying it, by this name.
|
|
3725
|
+
*/
|
|
3726
|
+
export const COMPARISON_FIELD = 'comparison';
|
|
3727
|
+
|
|
3728
|
+
/** The three rasters one frame's figures were computed on. */
|
|
3729
|
+
export interface ComparedPlates {
|
|
3730
|
+
/** The reference frame, as read from `--frames`. */
|
|
3731
|
+
reference: Plate;
|
|
3732
|
+
/** The candidate, rendered onto the reference's grid over the frames' background. */
|
|
3733
|
+
candidate: Plate;
|
|
3734
|
+
/** Which pixels the candidate's geometry covers, 1 or 0 — half of the union alpha. */
|
|
3735
|
+
coverage: Uint8Array;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/**
|
|
3739
|
+
* The rasters behind the frames a report will list, kept at the moment they were
|
|
3740
|
+
* compared — what `check --out` draws its pictures from.
|
|
3741
|
+
*
|
|
3742
|
+
* ## Why they have to be kept, and why not all of them
|
|
3743
|
+
*
|
|
3744
|
+
* `checkOneSet` holds a frame's two plates for exactly one iteration (and the
|
|
3745
|
+
* previous frame's for the change measure); the difference is never a raster at
|
|
3746
|
+
* all, only a running sum. And which frames are *worth reading* is not known
|
|
3747
|
+
* until the set is finished, because it is the worst by MAE over all of them —
|
|
3748
|
+
* `framesToList` decides it at print time. So the choice is between re-rendering
|
|
3749
|
+
* the listed frames afterwards and keeping them now, and re-rendering is rejected:
|
|
3750
|
+
* a second render that agreed with the first would be a claim about the picture,
|
|
3751
|
+
* and this is meant to be the record of it.
|
|
3752
|
+
*
|
|
3753
|
+
* Keeping every frame is the other simple answer and it does not scale: one
|
|
3754
|
+
* frame at 256x116 is 261 KiB of plates and coverage, and a 300-frame set at
|
|
3755
|
+
* 512x512 would hold 675 MiB. So a set longer than the listing threshold keeps a
|
|
3756
|
+
* running top `WORST_FRAMES` by MAE — ties to the earlier index, which is the
|
|
3757
|
+
* order `framesToList`'s stable sort gives them — plus every frame whose change
|
|
3758
|
+
* disagrees, and drops the rest as it goes. `--all-frames` keeps everything,
|
|
3759
|
+
* because then everything is listed.
|
|
3760
|
+
*
|
|
3761
|
+
* 🔒 `framesToList` stays the one derivation of the listing. This only has to
|
|
3762
|
+
* keep a superset of it, and `writeCheckPictures` refuses by name a listed frame
|
|
3763
|
+
* it finds nothing kept for, so the two cannot disagree in silence.
|
|
3764
|
+
*/
|
|
3765
|
+
export class CheckPlates {
|
|
3766
|
+
/** Whether every compared frame is kept — `--all-frames`. */
|
|
3767
|
+
readonly every: boolean;
|
|
3768
|
+
private readonly kept = new Map<string, Map<number, ComparedPlates>>();
|
|
3769
|
+
/** Per set: whether the whole set will be listed, so everything is kept. */
|
|
3770
|
+
private readonly whole = new Map<string, boolean>();
|
|
3771
|
+
/** Per set: the running worst by MAE, worst first, at most `WORST_FRAMES`. */
|
|
3772
|
+
private readonly ranked = new Map<string, Array<{ index: number; mae: number }>>();
|
|
3773
|
+
/** Per set: frames kept because their change disagrees, whatever their MAE. */
|
|
3774
|
+
private readonly disagreeing = new Map<string, Set<number>>();
|
|
3775
|
+
private held = 0;
|
|
3776
|
+
/** The most bytes of raster this held at any one time — the cost `--out` adds. */
|
|
3777
|
+
peakBytes = 0;
|
|
3778
|
+
|
|
3779
|
+
constructor(opts: { allFrames: boolean }) {
|
|
3780
|
+
this.every = opts.allFrames;
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
/** A set is about to be compared, over this many frame pairs. */
|
|
3784
|
+
begin(dir: string, compared: number): void {
|
|
3785
|
+
this.kept.set(dir, new Map());
|
|
3786
|
+
this.whole.set(dir, this.every || compared <= LIST_EVERY);
|
|
3787
|
+
this.ranked.set(dir, []);
|
|
3788
|
+
this.disagreeing.set(dir, new Set());
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
/** One frame has been compared: keep its plates if it can be listed. */
|
|
3792
|
+
offer(dir: string, check: FrameCheck, plates: ComparedPlates): void {
|
|
3793
|
+
const kept = this.kept.get(dir);
|
|
3794
|
+
const ranked = this.ranked.get(dir);
|
|
3795
|
+
const disagreeing = this.disagreeing.get(dir);
|
|
3796
|
+
if (kept === undefined || ranked === undefined || disagreeing === undefined) {
|
|
3797
|
+
throw new Error(`CheckPlates: set ${JSON.stringify(dir)} was offered a frame before it began`);
|
|
3798
|
+
}
|
|
3799
|
+
if (this.whole.get(dir) === true) {
|
|
3800
|
+
this.keep(kept, check.index, plates);
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
const disagrees = check.change !== null && check.change.verdict !== 'agrees';
|
|
3804
|
+
if (disagrees) disagreeing.add(check.index);
|
|
3805
|
+
// Frames arrive in index order, so a later frame that only ties the last
|
|
3806
|
+
// ranked one loses to it — exactly as the stable sort would place them.
|
|
3807
|
+
const enters = ranked.length < WORST_FRAMES || check.mae > ranked[ranked.length - 1].mae;
|
|
3808
|
+
if (enters) {
|
|
3809
|
+
let at = ranked.findIndex((r) => check.mae > r.mae);
|
|
3810
|
+
if (at < 0) at = ranked.length;
|
|
3811
|
+
ranked.splice(at, 0, { index: check.index, mae: check.mae });
|
|
3812
|
+
if (ranked.length > WORST_FRAMES) {
|
|
3813
|
+
const out = ranked.pop() as { index: number; mae: number };
|
|
3814
|
+
if (!disagreeing.has(out.index)) this.drop(kept, out.index);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
if (enters || disagrees) this.keep(kept, check.index, plates);
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
/** The plates kept for one frame of one set, if any. */
|
|
3821
|
+
of(dir: string, index: number): ComparedPlates | undefined {
|
|
3822
|
+
return this.kept.get(dir)?.get(index);
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
private keep(kept: Map<number, ComparedPlates>, index: number, plates: ComparedPlates): void {
|
|
3826
|
+
if (kept.has(index)) return;
|
|
3827
|
+
kept.set(index, plates);
|
|
3828
|
+
this.held += bytesOf(plates);
|
|
3829
|
+
if (this.held > this.peakBytes) this.peakBytes = this.held;
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
private drop(kept: Map<number, ComparedPlates>, index: number): void {
|
|
3833
|
+
const plates = kept.get(index);
|
|
3834
|
+
if (plates === undefined) return;
|
|
3835
|
+
kept.delete(index);
|
|
3836
|
+
this.held -= bytesOf(plates);
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
function bytesOf(plates: ComparedPlates): number {
|
|
3841
|
+
return plates.reference.data.length + plates.candidate.data.length + plates.coverage.length;
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3668
3844
|
/** The per-frame change measure, as the animation's own summary line. */
|
|
3669
3845
|
function changeSummary(anim: AnimationCheck): string {
|
|
3670
3846
|
if (anim.changePairs === 0) {
|
package/src/checkpics.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rigc check --out — the picture each of `check`'s numbers came from.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `check` renders the candidate onto the reference frames' own grid, subtracts,
|
|
7
|
+
* and prints the result as numbers. A number says *how much*; only a picture says
|
|
8
|
+
* *where*. An agent porting a sample drew the same face composite twice at a
|
|
9
|
+
* 20 px offset — a ghost hairline — with a green gate and a contact sheet of
|
|
10
|
+
* ~100 px tiles in hand, and did not see it (issue #834). The comparison that
|
|
11
|
+
* would have shown it was already computed and thrown away.
|
|
12
|
+
*
|
|
13
|
+
* So for every frame the report lists under *the frames worth reading* (every
|
|
14
|
+
* compared frame under `--all-frames`), this composes one image, left to right:
|
|
15
|
+
*
|
|
16
|
+
* 1. **reference** — the frame as read from `--frames`;
|
|
17
|
+
* 2. **candidate** — the candidate exactly as `check` rendered it onto that grid;
|
|
18
|
+
* 3. **difference** — per pixel, the largest of the three channel differences
|
|
19
|
+
* `|candidate − reference|`, 0..255 on a fixed grey ramp, over the pixels the
|
|
20
|
+
* MAE averages over (the union alpha) and transparent everywhere else;
|
|
21
|
+
* 4. **overlay** — the two at 50 % each, so a displaced edge reads as a double line.
|
|
22
|
+
*
|
|
23
|
+
* Each pane is at the grid's **native size**. An upscaled difference is a
|
|
24
|
+
* difference the instrument invented: a resampling filter moves ink into pixels
|
|
25
|
+
* the comparison never measured. The picture is only ever the comparison.
|
|
26
|
+
*
|
|
27
|
+
* ## 🔒 Nothing here is a new number
|
|
28
|
+
*
|
|
29
|
+
* Every figure burned into a picture is read off the `CheckReport` the table was
|
|
30
|
+
* printed from, at the precision the table prints it — the difference pane's MAE,
|
|
31
|
+
* the per-side change counts, the union — and the per-slot rows are the frame's
|
|
32
|
+
* own `slots`. The rasters are the ones the figures were computed on, kept by
|
|
33
|
+
* `CheckPlates` at the moment they were compared, not re-rendered afterwards: a
|
|
34
|
+
* second render that happened to agree would be a claim, and this is a record.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ Two things a picture cannot say, and it says neither: which side is right,
|
|
37
|
+
* and anything about a frame it does not show.
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import {
|
|
42
|
+
COMPARISON_FIELD,
|
|
43
|
+
framesToList,
|
|
44
|
+
type AnimationCheck,
|
|
45
|
+
type CheckPlates,
|
|
46
|
+
type CheckReport,
|
|
47
|
+
type ComparedPlates,
|
|
48
|
+
type Framing,
|
|
49
|
+
type FrameCheck,
|
|
50
|
+
} from './check.ts';
|
|
51
|
+
import { FRAMES_SIDECAR, FRAMES_SPEC } from './render.ts';
|
|
52
|
+
import { isContent } from './framing.ts';
|
|
53
|
+
import { isAttributable } from './slots.ts';
|
|
54
|
+
import { encodePng, Plate, type RGBA } from '../tools/plate.ts';
|
|
55
|
+
import { GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
56
|
+
|
|
57
|
+
/** The four panes, left to right, by the names their labels carry. */
|
|
58
|
+
export const PANES = ['reference', 'candidate', 'difference', 'overlay'] as const;
|
|
59
|
+
export type Pane = (typeof PANES)[number];
|
|
60
|
+
|
|
61
|
+
/** Transparent pixels between two panes, and between the panes and the slot rows. */
|
|
62
|
+
export const PANE_GAP = 4;
|
|
63
|
+
/** Pixels of strip around a line of text, above and below. */
|
|
64
|
+
const TEXT_PAD = 2;
|
|
65
|
+
/** One line of 5x7 text with its padding: the label strip, and each slot row. */
|
|
66
|
+
export const TEXT_ROW = GLYPH_H + 2 * TEXT_PAD;
|
|
67
|
+
/** The strips' own colour, and the text's — opaque, so the labels read on any viewer background. */
|
|
68
|
+
const STRIP: RGBA = [0, 0, 0, 255];
|
|
69
|
+
const INK: RGBA = [255, 255, 255, 255];
|
|
70
|
+
|
|
71
|
+
const f2 = (n: number): string => n.toFixed(2);
|
|
72
|
+
const frameName = (index: number): string => `f${String(index).padStart(4, '0')}`;
|
|
73
|
+
|
|
74
|
+
/** Where one pane sits in a picture, in picture pixels. */
|
|
75
|
+
export interface PaneRect {
|
|
76
|
+
pane: Pane;
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
width: number;
|
|
80
|
+
height: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The geometry of one picture — exported so a reader can find a pane without
|
|
85
|
+
* knowing how the picture was drawn, which is what the selftest does.
|
|
86
|
+
*
|
|
87
|
+
* A column is as wide as its pane, or as its label when the label is wider: the
|
|
88
|
+
* PANE stays at native size either way, and a label clipped to fit a small grid
|
|
89
|
+
* would drop the one figure it exists to carry.
|
|
90
|
+
*/
|
|
91
|
+
export function pictureLayout(
|
|
92
|
+
gridWidth: number,
|
|
93
|
+
gridHeight: number,
|
|
94
|
+
labels: readonly string[],
|
|
95
|
+
rows: readonly string[],
|
|
96
|
+
): { width: number; height: number; panes: PaneRect[]; rowsTop: number } {
|
|
97
|
+
const columns = labels.map((label) => Math.max(gridWidth, textWidth(label, 1) + 2 * TEXT_PAD));
|
|
98
|
+
const panes: PaneRect[] = [];
|
|
99
|
+
let x = 0;
|
|
100
|
+
PANES.forEach((pane, i) => {
|
|
101
|
+
panes.push({ pane, x, y: TEXT_ROW, width: gridWidth, height: gridHeight });
|
|
102
|
+
x += columns[i] + (i < PANES.length - 1 ? PANE_GAP : 0);
|
|
103
|
+
});
|
|
104
|
+
const widestRow = rows.reduce((w, row) => Math.max(w, textWidth(row, 1) + 2 * TEXT_PAD), 0);
|
|
105
|
+
const rowsTop = TEXT_ROW + gridHeight + PANE_GAP;
|
|
106
|
+
return { width: Math.max(x, widestRow), height: rowsTop + rows.length * TEXT_ROW, panes, rowsTop };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The label above each pane: the frame, the pane, and the pane's own figure from the table. */
|
|
110
|
+
export function paneLabels(frame: FrameCheck): string[] {
|
|
111
|
+
const at = frameName(frame.index);
|
|
112
|
+
const moved = (n: number | undefined): string => (n === undefined ? '-' : String(n));
|
|
113
|
+
return [
|
|
114
|
+
// The table's `ref Δ` and `Δpx` columns: how far each side moved since its own previous frame.
|
|
115
|
+
`${at} reference ref d ${moved(frame.change?.reference)}`,
|
|
116
|
+
`${at} candidate dpx ${moved(frame.change?.candidate)}`,
|
|
117
|
+
`${at} difference mae ${f2(frame.mae)}`,
|
|
118
|
+
`${at} overlay union ${frame.unionPixels}`,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One row per slot in the frame's own `slots`, in the order the report holds them.
|
|
124
|
+
*
|
|
125
|
+
* The drift is printed at the table's precision, and a slot with no attributable
|
|
126
|
+
* drift says which of the two reasons it is rather than printing a number — the
|
|
127
|
+
* rule `src/slots.ts` owns for every figure beside a part's name. The frame's
|
|
128
|
+
* worst slot, the one the table's row names, is marked `>`.
|
|
129
|
+
*/
|
|
130
|
+
export function slotRows(frame: FrameCheck): string[] {
|
|
131
|
+
return frame.slots.map((track) => {
|
|
132
|
+
const mark = track.slot === frame.worstSlot ? '>' : ' ';
|
|
133
|
+
if (!isAttributable(track)) {
|
|
134
|
+
return `${mark} ${track.slot} - ${track.candidate === null ? 'not drawn' : 'no attributable drift'}`;
|
|
135
|
+
}
|
|
136
|
+
const how = track.method === 'template' ? `tmpl ${(track.confidence ?? 0).toFixed(2)}` : 'component';
|
|
137
|
+
return `${mark} ${track.slot} ${(track.drift as number).toFixed(1)} px ${how}`;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Compose one frame's picture from the rasters its figures were computed on. */
|
|
142
|
+
export function composePicture(frame: FrameCheck, plates: ComparedPlates, background: RGBA): Plate {
|
|
143
|
+
const { reference, candidate, coverage } = plates;
|
|
144
|
+
const w = reference.width;
|
|
145
|
+
const h = reference.height;
|
|
146
|
+
const labels = paneLabels(frame);
|
|
147
|
+
const rows = slotRows(frame);
|
|
148
|
+
const layout = pictureLayout(w, h, labels, rows);
|
|
149
|
+
const out = new Plate(layout.width, layout.height);
|
|
150
|
+
|
|
151
|
+
layout.panes.forEach((rect, i) => {
|
|
152
|
+
out.rect(rect.x, 0, Math.max(rect.width, textWidth(labels[i], 1) + 2 * TEXT_PAD), TEXT_ROW, STRIP);
|
|
153
|
+
out.text(labels[i], rect.x + TEXT_PAD, TEXT_PAD, 1, INK);
|
|
154
|
+
});
|
|
155
|
+
const [ref, cand, diff, over] = layout.panes;
|
|
156
|
+
for (let y = 0; y < h; y++) {
|
|
157
|
+
for (let x = 0; x < w; x++) {
|
|
158
|
+
const a = candidate.get(x, y);
|
|
159
|
+
const b = reference.get(x, y);
|
|
160
|
+
out.set(ref.x + x, ref.y + y, b);
|
|
161
|
+
out.set(cand.x + x, cand.y + y, a);
|
|
162
|
+
// The union `checkOneFrame` averages over: what the candidate's geometry
|
|
163
|
+
// covers, or what the reference drew — the same predicate, not a lookalike.
|
|
164
|
+
if (coverage[y * w + x] === 1 || isContent(reference, x, y, background)) {
|
|
165
|
+
const v = Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
|
|
166
|
+
out.set(diff.x + x, diff.y + y, [v, v, v, 255]);
|
|
167
|
+
}
|
|
168
|
+
out.set(over.x + x, over.y + y, [
|
|
169
|
+
Math.round((a[0] + b[0]) / 2),
|
|
170
|
+
Math.round((a[1] + b[1]) / 2),
|
|
171
|
+
Math.round((a[2] + b[2]) / 2),
|
|
172
|
+
Math.round((a[3] + b[3]) / 2),
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
rows.forEach((row, i) => {
|
|
177
|
+
const top = layout.rowsTop + i * TEXT_ROW;
|
|
178
|
+
out.rect(0, top, layout.width, TEXT_ROW, STRIP);
|
|
179
|
+
out.text(row, TEXT_PAD, top + TEXT_PAD, 1, INK);
|
|
180
|
+
});
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What `--out` wrote for one frame set. */
|
|
185
|
+
export interface PicturesWritten {
|
|
186
|
+
/** The set's directory, as the report names it. */
|
|
187
|
+
dir: string;
|
|
188
|
+
/** Absolute path of the directory the pictures went into. */
|
|
189
|
+
path: string;
|
|
190
|
+
/** The frame indices written, in index order. */
|
|
191
|
+
frames: number[];
|
|
192
|
+
/** Whether that is every compared frame rather than the frames worth reading. */
|
|
193
|
+
every: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Write the pictures for a report into `outDir`, and `frames.json` beside them.
|
|
198
|
+
*
|
|
199
|
+
* `<outDir>/<set>/` is cleared and rewritten for every set the report holds, as
|
|
200
|
+
* `render` clears its own: a set that lists fewer frames this run would otherwise
|
|
201
|
+
* keep last run's pictures beside this run's, and a stale picture of a comparison
|
|
202
|
+
* is indistinguishable from a current one. Nothing else under `outDir` is touched.
|
|
203
|
+
*
|
|
204
|
+
* ⚠️ Throws, naming the frame, when `plates` does not hold a frame the report
|
|
205
|
+
* lists. That is not a reachable input: it is `CheckPlates`'s retention and
|
|
206
|
+
* `framesToList` disagreeing about which frames are worth reading, and a picture
|
|
207
|
+
* set with a hole in it would be the silent version of that disagreement.
|
|
208
|
+
*/
|
|
209
|
+
export function writeCheckPictures(
|
|
210
|
+
outDir: string,
|
|
211
|
+
report: CheckReport,
|
|
212
|
+
plates: CheckPlates,
|
|
213
|
+
opts: { allFrames: boolean },
|
|
214
|
+
): PicturesWritten[] {
|
|
215
|
+
mkdirSync(outDir, { recursive: true });
|
|
216
|
+
const written: PicturesWritten[] = [];
|
|
217
|
+
for (const anim of report.animations) {
|
|
218
|
+
const dir = join(outDir, anim.dir);
|
|
219
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true });
|
|
220
|
+
const listed = anim.compared === 0 ? [] : framesToList(anim, opts.allFrames);
|
|
221
|
+
if (listed.length > 0) mkdirSync(dir, { recursive: true });
|
|
222
|
+
for (const frame of listed) {
|
|
223
|
+
const held = plates.of(anim.dir, frame.index);
|
|
224
|
+
if (held === undefined) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`check --out: set ${JSON.stringify(anim.dir)} lists ${frameName(frame.index)} as worth reading and no ` +
|
|
227
|
+
'raster was kept for it — CheckPlates and framesToList disagree about the listing (a defect in rigc)',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const picture = composePicture(frame, held, report.background);
|
|
231
|
+
writeFileSync(join(dir, `${frameName(frame.index)}.png`), encodePng(picture.width, picture.height, picture.data));
|
|
232
|
+
}
|
|
233
|
+
written.push({
|
|
234
|
+
dir: anim.dir,
|
|
235
|
+
path: dir,
|
|
236
|
+
frames: listed.map((f) => f.index),
|
|
237
|
+
every: listed.length === anim.frames.length,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
writeFileSync(join(outDir, FRAMES_SIDECAR), `${JSON.stringify(picturesSidecar(report, written, opts.allFrames), null, 2)}\n`);
|
|
241
|
+
return written;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* What the pictures are OF — the file a reader opens first, under the name a
|
|
246
|
+
* frame set's sidecar has, so "what is this directory" is answered in the place
|
|
247
|
+
* it is always answered.
|
|
248
|
+
*
|
|
249
|
+
* 🔒 The field named by `COMPARISON_FIELD` is what makes it not a frame set, and
|
|
250
|
+
* `check --frames` refuses a sidecar that carries it, by that name: the reference
|
|
251
|
+
* pane inside each picture is a frame, but the file is a comparison, and scoring
|
|
252
|
+
* a candidate against a picture of a comparison is a number about the wrong
|
|
253
|
+
* thing. There is deliberately no top-level `viewport`: under the per-shot scope
|
|
254
|
+
* every set was measured in its own box, so the box is recorded per set, and one
|
|
255
|
+
* box at the top would be a claim the run did not make.
|
|
256
|
+
*/
|
|
257
|
+
function picturesSidecar(report: CheckReport, written: PicturesWritten[], allFrames: boolean): Record<string, unknown> {
|
|
258
|
+
return {
|
|
259
|
+
spec: FRAMES_SPEC,
|
|
260
|
+
[COMPARISON_FIELD]: {
|
|
261
|
+
candidate: report.candidate,
|
|
262
|
+
frames: report.framesDir,
|
|
263
|
+
skin: report.skin,
|
|
264
|
+
referenceSkin: report.referenceSkin,
|
|
265
|
+
framingScope: report.framingScope,
|
|
266
|
+
grid: gridOf(report),
|
|
267
|
+
panes: [...PANES],
|
|
268
|
+
listing: allFrames ? 'every compared frame (--all-frames)' : 'the frames worth reading',
|
|
269
|
+
textureFrom: report.textureFrom === null ? null : report.textureFrom.atlas,
|
|
270
|
+
},
|
|
271
|
+
background: report.background,
|
|
272
|
+
sets: report.animations.map((anim: AnimationCheck, i) => {
|
|
273
|
+
const viewport: Framing = anim.viewport;
|
|
274
|
+
return {
|
|
275
|
+
dir: anim.dir,
|
|
276
|
+
animation: anim.animation,
|
|
277
|
+
candidateAnimation: anim.candidateAnimation,
|
|
278
|
+
fps: anim.fps,
|
|
279
|
+
compared: anim.compared,
|
|
280
|
+
written: written[i].frames.length,
|
|
281
|
+
frames: written[i].frames,
|
|
282
|
+
framing: anim.framing,
|
|
283
|
+
viewport,
|
|
284
|
+
};
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The comparison grid — the reference frames' own pixel size, which every set shares. */
|
|
290
|
+
function gridOf(report: CheckReport): { pixelWidth: number; pixelHeight: number } | null {
|
|
291
|
+
const v = report.referenceViewport ?? report.animations[0]?.viewport ?? null;
|
|
292
|
+
return v === null ? null : { pixelWidth: v.pixelWidth, pixelHeight: v.pixelHeight };
|
|
293
|
+
}
|