spine-rigc 1.1.0 → 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 +4 -3
- package/cli.ts +289 -46
- package/docs/AUTHORING.md +134 -7
- 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 +181 -5
- package/src/checkpics.ts +293 -0
- package/src/preview.ts +223 -32
- package/src/render.ts +140 -2
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
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -83,8 +83,123 @@ export interface PreviewInput {
|
|
|
83
83
|
animations: string[];
|
|
84
84
|
/** What the page calls itself — the skeleton's path, for the tab and the header. */
|
|
85
85
|
label: string;
|
|
86
|
-
/** rigc's own version, for the generated-by line. */
|
|
86
|
+
/** rigc's own version, for the generated-by line and the header's gate line. */
|
|
87
87
|
version: string;
|
|
88
|
+
/** What the gate said about this candidate on THIS run — see `PreviewGate`. */
|
|
89
|
+
gate: PreviewGate;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The gate's reading of a candidate, taken when the page is written (issue #837).
|
|
94
|
+
*
|
|
95
|
+
* ⭐ Both strings are the validator's own lines, never a paraphrase: `summary`
|
|
96
|
+
* is the `N assertions: …` line `rigc validate <dir>` prints last and `refusal`
|
|
97
|
+
* is its first `FAIL` line, each without the report's gutter. The caller
|
|
98
|
+
* measures them on the run that writes the page, because a figure carried over
|
|
99
|
+
* from the build would be a claim about bytes nothing here read — and a bare
|
|
100
|
+
* directory re-gated is NOT what `build` gated: with no rig spec and no second
|
|
101
|
+
* compile, `A09` and `A18` report SKIP there, so the two runs print different
|
|
102
|
+
* counts over the same files and only this one's belongs on this page.
|
|
103
|
+
*
|
|
104
|
+
* `refusal` is `null` on green. A refused candidate is still previewed —
|
|
105
|
+
* looking at a red build is what this command is for — and its header says so
|
|
106
|
+
* in the gate's words.
|
|
107
|
+
*/
|
|
108
|
+
export interface PreviewGate {
|
|
109
|
+
summary: string;
|
|
110
|
+
refusal: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The header's gate line, as one element: which rigc, what ran, and what it said. */
|
|
114
|
+
export function gateLine(gate: PreviewGate, version: string): string {
|
|
115
|
+
const said =
|
|
116
|
+
`rigc ${escapeHtml(version)}, re-gated as <code>rigc validate <dir></code> gates it: ${escapeHtml(gate.summary)}`;
|
|
117
|
+
return gate.refusal === null
|
|
118
|
+
? `<span class="rigc-gate" data-gate="green">${said}</span>`
|
|
119
|
+
: `<span class="rigc-gate" data-gate="refused" style="opacity: 1; color: #7d1d1d; font-weight: 600">${said} — ` +
|
|
120
|
+
`refused: ${escapeHtml(gate.refusal)}</span>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What a header says its player is playing. */
|
|
124
|
+
function playedText(animation: string | null, animations: string[]): string {
|
|
125
|
+
return animation === null
|
|
126
|
+
? 'no animation — the setup pose'
|
|
127
|
+
: `${escapeHtml(animation)}${animations.length > 1 ? ` (of ${animations.length}; pick another in the controls)` : ''}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The header over one player: the path, what it plays, and the gate's line. */
|
|
131
|
+
function headerHtml(input: PreviewInput): string {
|
|
132
|
+
return (
|
|
133
|
+
`<header><b>${escapeHtml(input.label)}</b> <span>— ${playedText(input.animation, input.animations)}</span>` +
|
|
134
|
+
`<br>${gateLine(input.gate, input.version)}</header>`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One player's config. `animation` is left off a skeleton with none rather than
|
|
140
|
+
* set to null: the player checks for the key's presence, and the setup pose
|
|
141
|
+
* held still is the honest picture of a rig that has no animation to play.
|
|
142
|
+
*/
|
|
143
|
+
function previewConfig(input: PreviewInput): Record<string, unknown> {
|
|
144
|
+
const rawDataURIs: Record<string, string> = {
|
|
145
|
+
[SKELETON_KEY]: dataUri('application/json', input.skeletonText),
|
|
146
|
+
[ATLAS_KEY]: dataUri('text/plain', input.atlasText),
|
|
147
|
+
};
|
|
148
|
+
for (const page of input.pages) rawDataURIs[page.name] = dataUri('image/png', page.bytes);
|
|
149
|
+
const config: Record<string, unknown> = {
|
|
150
|
+
skeleton: SKELETON_KEY,
|
|
151
|
+
atlas: ATLAS_KEY,
|
|
152
|
+
rawDataURIs,
|
|
153
|
+
animations: input.animations,
|
|
154
|
+
showControls: true,
|
|
155
|
+
alpha: false,
|
|
156
|
+
backgroundColor: backgroundHex(),
|
|
157
|
+
};
|
|
158
|
+
if (input.animation !== null) config.animation = input.animation;
|
|
159
|
+
return config;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The licence paragraph, spelled once for the one-candidate page and the page
|
|
164
|
+
* of panes so the two cannot drift. The ballot keeps its own copy of the same
|
|
165
|
+
* words: its bytes are held by `vote`'s controls, not moved by this card.
|
|
166
|
+
*/
|
|
167
|
+
const PLAYER_LICENCE = ` It plays them in the Spine Web Player, which is NOT embedded: the script and
|
|
168
|
+
stylesheet below are loaded from unpkg. The Spine Runtimes are Copyright (c)
|
|
169
|
+
2013-2025 Esoteric Software LLC and are licensed under the Spine Runtimes
|
|
170
|
+
License Agreement — https://esotericsoftware.com/spine-runtimes-license — which
|
|
171
|
+
requires each user of a product integrating them to hold a Spine Editor
|
|
172
|
+
license. Nothing owned by Esoteric Software is redistributed by rigc.`;
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// panes — the layout `vote` built first, shared rather than copied
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The grid panes sit in, as the style lines the ballot has always carried: one
|
|
180
|
+
* column per pane, one column on a narrow screen. `buildBallot` calls this too,
|
|
181
|
+
* so a ballot's bytes are what they were and the two pages cannot drift.
|
|
182
|
+
*/
|
|
183
|
+
export function paneGridCss(count: number): string {
|
|
184
|
+
return ` #panes { display: grid; grid-template-columns: repeat(${count}, minmax(0, 1fr)); gap: 1px; background: rgba(0, 0, 0, 0.15); }
|
|
185
|
+
@media (max-width: 720px) { #panes { grid-template-columns: minmax(0, 1fr); } }
|
|
186
|
+
.pane { background: ${backgroundHex()}; display: flex; flex-direction: column; min-width: 0; }`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The box a pane's player mounts in, as the ballot styles it. */
|
|
190
|
+
export const PANE_STAGE_CSS = ' .stage { height: 52vh; min-height: 260px; }';
|
|
191
|
+
|
|
192
|
+
/** One pane: a heading the caller writes, over the element its player mounts in. */
|
|
193
|
+
export function paneSection(stageId: string, heading: string): string {
|
|
194
|
+
return `<section class="pane">
|
|
195
|
+
${heading}
|
|
196
|
+
<div class="stage" id="${stageId}"></div>
|
|
197
|
+
</section>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The element pane `i` (0-based) mounts its player in, numbered from 1 as the CLI numbers candidates. */
|
|
201
|
+
export function previewPaneId(i: number): string {
|
|
202
|
+
return `rigc-player-${i + 1}`;
|
|
88
203
|
}
|
|
89
204
|
|
|
90
205
|
/**
|
|
@@ -138,31 +253,8 @@ export function embeddedJson(value: unknown): string {
|
|
|
138
253
|
* page it produces has no dependency of its own except the player URL above.
|
|
139
254
|
*/
|
|
140
255
|
export function buildPreview(input: PreviewInput): string {
|
|
141
|
-
const
|
|
142
|
-
[SKELETON_KEY]: dataUri('application/json', input.skeletonText),
|
|
143
|
-
[ATLAS_KEY]: dataUri('text/plain', input.atlasText),
|
|
144
|
-
};
|
|
145
|
-
for (const page of input.pages) rawDataURIs[page.name] = dataUri('image/png', page.bytes);
|
|
146
|
-
|
|
147
|
-
// `animation` is left off a skeleton with none rather than set to null: the
|
|
148
|
-
// player checks for the key's presence, and the setup pose held still is the
|
|
149
|
-
// honest picture of a rig that has no animation to play.
|
|
150
|
-
const config: Record<string, unknown> = {
|
|
151
|
-
skeleton: SKELETON_KEY,
|
|
152
|
-
atlas: ATLAS_KEY,
|
|
153
|
-
rawDataURIs,
|
|
154
|
-
animations: input.animations,
|
|
155
|
-
showControls: true,
|
|
156
|
-
alpha: false,
|
|
157
|
-
backgroundColor: backgroundHex(),
|
|
158
|
-
};
|
|
159
|
-
if (input.animation !== null) config.animation = input.animation;
|
|
160
|
-
|
|
256
|
+
const config = previewConfig(input);
|
|
161
257
|
const label = escapeHtml(input.label);
|
|
162
|
-
const played =
|
|
163
|
-
input.animation === null
|
|
164
|
-
? 'no animation — the setup pose'
|
|
165
|
-
: `${escapeHtml(input.animation)}${input.animations.length > 1 ? ` (of ${input.animations.length}; pick another in the controls)` : ''}`;
|
|
166
258
|
|
|
167
259
|
return `<!doctype html>
|
|
168
260
|
<html lang="en">
|
|
@@ -176,12 +268,7 @@ export function buildPreview(input: PreviewInput): string {
|
|
|
176
268
|
The skeleton, the atlas and every atlas page are embedded in this file as data
|
|
177
269
|
URIs, so it plays on its own with no server and no sibling files.
|
|
178
270
|
|
|
179
|
-
|
|
180
|
-
stylesheet below are loaded from unpkg. The Spine Runtimes are Copyright (c)
|
|
181
|
-
2013-2025 Esoteric Software LLC and are licensed under the Spine Runtimes
|
|
182
|
-
License Agreement — https://esotericsoftware.com/spine-runtimes-license — which
|
|
183
|
-
requires each user of a product integrating them to hold a Spine Editor
|
|
184
|
-
license. Nothing owned by Esoteric Software is redistributed by rigc.
|
|
271
|
+
${PLAYER_LICENCE}
|
|
185
272
|
-->
|
|
186
273
|
<link rel="stylesheet" href="${PLAYER_STYLE_URL}">
|
|
187
274
|
<style>
|
|
@@ -202,7 +289,7 @@ export function buildPreview(input: PreviewInput): string {
|
|
|
202
289
|
</style>
|
|
203
290
|
</head>
|
|
204
291
|
<body>
|
|
205
|
-
|
|
292
|
+
${headerHtml(input)}
|
|
206
293
|
<div id="rigc-player"></div>
|
|
207
294
|
<p id="rigc-status">loading the Spine Web Player…</p>
|
|
208
295
|
<script src="${PLAYER_SCRIPT_URL}"></script>
|
|
@@ -241,3 +328,107 @@ export function buildPreview(input: PreviewInput): string {
|
|
|
241
328
|
</html>
|
|
242
329
|
`;
|
|
243
330
|
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Several candidates on one page, a pane per candidate in the order given
|
|
334
|
+
* (issue #837).
|
|
335
|
+
*
|
|
336
|
+
* ⭐ This page asks nothing, and that is the whole difference from the ballot
|
|
337
|
+
* whose layout it borrows. A ballot hides where each candidate came from
|
|
338
|
+
* because a voter who can see it is no longer comparing pictures; here the
|
|
339
|
+
* question is "show me these", so each pane is headed exactly as a
|
|
340
|
+
* one-candidate preview is — its path, what it plays, the gate's line for it —
|
|
341
|
+
* and each player offers every animation its own skeleton has. No manifest, no
|
|
342
|
+
* digests and no result form: there is no answer to record.
|
|
343
|
+
*
|
|
344
|
+
* Each pane plays its own candidate's animation (the one `--animation` names,
|
|
345
|
+
* or its own first). The ballot refuses two panes playing two animations
|
|
346
|
+
* because the labels are A and B and nothing would say so; here the header over
|
|
347
|
+
* each pane names what it plays.
|
|
348
|
+
*/
|
|
349
|
+
export function buildPreviewPanes(inputs: PreviewInput[], version: string): string {
|
|
350
|
+
const configs = inputs.map((input) => previewConfig(input));
|
|
351
|
+
const panes = inputs.map((input, i) => paneSection(previewPaneId(i), headerHtml(input))).join('\n');
|
|
352
|
+
|
|
353
|
+
return `<!doctype html>
|
|
354
|
+
<html lang="en">
|
|
355
|
+
<head>
|
|
356
|
+
<meta charset="utf-8">
|
|
357
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
358
|
+
<title>rigc preview — ${inputs.length} candidates</title>
|
|
359
|
+
<!--
|
|
360
|
+
Generated by rigc ${escapeHtml(version)} — https://github.com/firejune/rigc
|
|
361
|
+
|
|
362
|
+
Every candidate's skeleton, atlas and atlas pages are embedded in this file as
|
|
363
|
+
data URIs, so it plays on its own with no server and no sibling files.
|
|
364
|
+
|
|
365
|
+
${PLAYER_LICENCE}
|
|
366
|
+
-->
|
|
367
|
+
<link rel="stylesheet" href="${PLAYER_STYLE_URL}">
|
|
368
|
+
<style>
|
|
369
|
+
:root { color-scheme: light dark; }
|
|
370
|
+
html, body { margin: 0; min-height: 100%; }
|
|
371
|
+
body {
|
|
372
|
+
background: ${backgroundHex()};
|
|
373
|
+
color: #1a1a1a;
|
|
374
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
375
|
+
}
|
|
376
|
+
header { padding: 10px 14px; border-bottom: 1px solid rgba(0, 0, 0, 0.15); overflow-wrap: anywhere; }
|
|
377
|
+
header b { font-weight: 600; }
|
|
378
|
+
header span { opacity: 0.65; }
|
|
379
|
+
${paneGridCss(inputs.length)}
|
|
380
|
+
${PANE_STAGE_CSS}
|
|
381
|
+
#rigc-status { margin: 0; padding: 10px 14px; border-top: 1px solid rgba(0, 0, 0, 0.15); white-space: pre-wrap; }
|
|
382
|
+
#rigc-status[data-state="error"] { background: #7d1d1d; color: #fff; }
|
|
383
|
+
</style>
|
|
384
|
+
</head>
|
|
385
|
+
<body>
|
|
386
|
+
<div id="panes">
|
|
387
|
+
${panes}
|
|
388
|
+
</div>
|
|
389
|
+
<p id="rigc-status">loading the Spine Web Player…</p>
|
|
390
|
+
<script src="${PLAYER_SCRIPT_URL}"></script>
|
|
391
|
+
<script>
|
|
392
|
+
(function () {
|
|
393
|
+
var configs = ${embeddedJson(configs)};
|
|
394
|
+
var status = document.getElementById('rigc-status');
|
|
395
|
+
var state = { status: 'loading', message: null, players: [], ready: [], failed: [] };
|
|
396
|
+
window.rigcPreview = state;
|
|
397
|
+
function say(kind, message) {
|
|
398
|
+
state.status = kind;
|
|
399
|
+
state.message = message;
|
|
400
|
+
status.textContent = message;
|
|
401
|
+
status.setAttribute('data-state', kind);
|
|
402
|
+
}
|
|
403
|
+
if (typeof window.spine === 'undefined' || typeof window.spine.SpinePlayer !== 'function') {
|
|
404
|
+
say('error', 'The Spine Web Player did not load from ${PLAYER_SCRIPT_URL} — this page needs a network connection the first time it is opened.');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
for (var i = 0; i < configs.length; i++) {
|
|
408
|
+
(function (pane, config) {
|
|
409
|
+
config.success = function (player) {
|
|
410
|
+
state.players[pane - 1] = player;
|
|
411
|
+
if (state.ready.indexOf(pane) === -1) state.ready.push(pane);
|
|
412
|
+
if (state.failed.length === 0 && state.ready.length === configs.length) {
|
|
413
|
+
say('ready', 'all ' + configs.length + ' panes are playing in Spine Web Player ${PLAYER_LINE} — everything they draw is embedded in this file.');
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
config.error = function (player, message) {
|
|
417
|
+
state.players[pane - 1] = player;
|
|
418
|
+
state.failed.push('pane ' + pane + ': ' + String(message));
|
|
419
|
+
say('error', state.failed.join('\\n'));
|
|
420
|
+
};
|
|
421
|
+
try {
|
|
422
|
+
new window.spine.SpinePlayer('rigc-player-' + pane, config);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
state.failed.push('pane ' + pane + ': ' + String(err && err.message ? err.message : err));
|
|
425
|
+
say('error', state.failed.join('\\n'));
|
|
426
|
+
}
|
|
427
|
+
})(i + 1, configs[i]);
|
|
428
|
+
}
|
|
429
|
+
})();
|
|
430
|
+
</script>
|
|
431
|
+
</body>
|
|
432
|
+
</html>
|
|
433
|
+
`;
|
|
434
|
+
}
|