spine-rigc 0.5.0 → 0.6.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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Best-effort line/column for a `JSON.parse` failure.
3
+ *
4
+ * Bun's `JSON.parse` (JavaScriptCore) names the fault — `JSON Parse error:
5
+ * Property name must be a string literal` — and never a position, so a caller
6
+ * only ever learns which file was bad, not where inside it. This module fills
7
+ * that gap without a dependency: it re-walks the text through a minimal,
8
+ * hand-written JSON grammar and stops at the first place that grammar
9
+ * disagrees with the text.
10
+ *
11
+ * ⚠️ This is not a JSON parser used to parse anything — `JSON.parse` still owns
12
+ * every successful parse and every error MESSAGE. `locateJsonError` only ever
13
+ * runs after `JSON.parse` has already thrown, purely to attribute a position to
14
+ * the failure it already found. That is why it does not have to be a complete,
15
+ * spec-perfect grammar: for the ordinary mistakes (an unquoted key, a trailing
16
+ * comma, a missing colon, an unterminated string) the two parsers diverge at
17
+ * the same character, so the position is exact. Wherever this scanner is
18
+ * looser or stricter than `JSON.parse` in some corner, the position can be
19
+ * early rather than exactly the runtime's own stopping point — which is why
20
+ * it is documented, and named, as best-effort rather than exact.
21
+ */
22
+
23
+ export interface JsonPosition {
24
+ /** 1-based, like every editor and every compiler diagnostic. */
25
+ line: number;
26
+ column: number;
27
+ }
28
+
29
+ /** Thrown internally the moment the grammar walk disagrees with the text. */
30
+ class ScanStopped {
31
+ constructor(readonly position: JsonPosition) {}
32
+ }
33
+
34
+ class Cursor {
35
+ private i = 0;
36
+ private line = 1;
37
+ private column = 1;
38
+
39
+ constructor(private readonly text: string) {}
40
+
41
+ get done(): boolean {
42
+ return this.i >= this.text.length;
43
+ }
44
+
45
+ peek(): string | undefined {
46
+ return this.text[this.i];
47
+ }
48
+
49
+ startsWith(s: string): boolean {
50
+ return this.text.startsWith(s, this.i);
51
+ }
52
+
53
+ advance(): void {
54
+ if (this.text[this.i] === '\n') {
55
+ this.line++;
56
+ this.column = 1;
57
+ } else {
58
+ this.column++;
59
+ }
60
+ this.i++;
61
+ }
62
+
63
+ position(): JsonPosition {
64
+ return { line: this.line, column: this.column };
65
+ }
66
+ }
67
+
68
+ function isDigit(c: string | undefined): boolean {
69
+ return c !== undefined && c >= '0' && c <= '9';
70
+ }
71
+
72
+ function isHexDigit(c: string | undefined): boolean {
73
+ return c !== undefined && ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'));
74
+ }
75
+
76
+ function isWhitespace(c: string | undefined): boolean {
77
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r';
78
+ }
79
+
80
+ function stop(cur: Cursor): never {
81
+ throw new ScanStopped(cur.position());
82
+ }
83
+
84
+ function skipWhitespace(cur: Cursor): void {
85
+ while (!cur.done && isWhitespace(cur.peek())) cur.advance();
86
+ }
87
+
88
+ function skipLiteral(cur: Cursor, length: number): void {
89
+ for (let k = 0; k < length; k++) cur.advance();
90
+ }
91
+
92
+ const STRING_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't']);
93
+
94
+ function scanString(cur: Cursor): void {
95
+ cur.advance(); // opening quote
96
+ for (;;) {
97
+ if (cur.done) stop(cur);
98
+ const c = cur.peek()!;
99
+ if (c === '"') {
100
+ cur.advance();
101
+ return;
102
+ }
103
+ if (c === '\\') {
104
+ cur.advance();
105
+ const esc = cur.peek();
106
+ if (esc !== undefined && STRING_ESCAPES.has(esc)) {
107
+ cur.advance();
108
+ continue;
109
+ }
110
+ if (esc === 'u') {
111
+ cur.advance();
112
+ for (let k = 0; k < 4; k++) {
113
+ if (!isHexDigit(cur.peek())) stop(cur);
114
+ cur.advance();
115
+ }
116
+ continue;
117
+ }
118
+ stop(cur); // unknown escape
119
+ }
120
+ if (c.charCodeAt(0) < 0x20) stop(cur); // raw control character in a string
121
+ cur.advance();
122
+ }
123
+ }
124
+
125
+ function scanNumber(cur: Cursor): void {
126
+ if (cur.peek() === '-') cur.advance();
127
+ if (cur.peek() === '0') {
128
+ cur.advance();
129
+ } else if (isDigit(cur.peek())) {
130
+ while (isDigit(cur.peek())) cur.advance();
131
+ } else {
132
+ stop(cur);
133
+ }
134
+ if (cur.peek() === '.') {
135
+ cur.advance();
136
+ if (!isDigit(cur.peek())) stop(cur);
137
+ while (isDigit(cur.peek())) cur.advance();
138
+ }
139
+ if (cur.peek() === 'e' || cur.peek() === 'E') {
140
+ cur.advance();
141
+ if (cur.peek() === '+' || cur.peek() === '-') cur.advance();
142
+ if (!isDigit(cur.peek())) stop(cur);
143
+ while (isDigit(cur.peek())) cur.advance();
144
+ }
145
+ }
146
+
147
+ function scanValue(cur: Cursor): void {
148
+ skipWhitespace(cur);
149
+ if (cur.done) stop(cur);
150
+ const c = cur.peek();
151
+ if (c === '{') return scanObject(cur);
152
+ if (c === '[') return scanArray(cur);
153
+ if (c === '"') return scanString(cur);
154
+ if (c === '-' || isDigit(c)) return scanNumber(cur);
155
+ if (cur.startsWith('true')) return skipLiteral(cur, 4);
156
+ if (cur.startsWith('false')) return skipLiteral(cur, 5);
157
+ if (cur.startsWith('null')) return skipLiteral(cur, 4);
158
+ stop(cur);
159
+ }
160
+
161
+ function scanObject(cur: Cursor): void {
162
+ cur.advance(); // '{'
163
+ skipWhitespace(cur);
164
+ if (cur.peek() === '}') {
165
+ cur.advance();
166
+ return;
167
+ }
168
+ for (;;) {
169
+ skipWhitespace(cur);
170
+ // The classic unquoted-key mistake stops here — this is
171
+ // "Property name must be a string literal" in JavaScriptCore's own words.
172
+ if (cur.peek() !== '"') stop(cur);
173
+ scanString(cur);
174
+ skipWhitespace(cur);
175
+ if (cur.peek() !== ':') stop(cur);
176
+ cur.advance();
177
+ scanValue(cur);
178
+ skipWhitespace(cur);
179
+ const c = cur.peek();
180
+ if (c === ',') {
181
+ cur.advance();
182
+ continue;
183
+ }
184
+ if (c === '}') {
185
+ cur.advance();
186
+ return;
187
+ }
188
+ stop(cur);
189
+ }
190
+ }
191
+
192
+ function scanArray(cur: Cursor): void {
193
+ cur.advance(); // '['
194
+ skipWhitespace(cur);
195
+ if (cur.peek() === ']') {
196
+ cur.advance();
197
+ return;
198
+ }
199
+ for (;;) {
200
+ scanValue(cur);
201
+ skipWhitespace(cur);
202
+ const c = cur.peek();
203
+ if (c === ',') {
204
+ cur.advance();
205
+ skipWhitespace(cur);
206
+ continue;
207
+ }
208
+ if (c === ']') {
209
+ cur.advance();
210
+ return;
211
+ }
212
+ stop(cur);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Where, in `text`, a strict JSON parse first disagrees with it. Best-effort:
218
+ * see the module doc for what that means and why.
219
+ */
220
+ export function locateJsonError(text: string): JsonPosition {
221
+ const cur = new Cursor(text);
222
+ try {
223
+ scanValue(cur);
224
+ skipWhitespace(cur);
225
+ if (!cur.done) stop(cur); // trailing content after the one top-level value
226
+ } catch (err) {
227
+ if (err instanceof ScanStopped) return err.position;
228
+ throw err;
229
+ }
230
+ // The grammar walk above found nothing wrong, even though `JSON.parse` did —
231
+ // this scanner is looser than the runtime parser in whatever way this file's
232
+ // fault is. Rather than claim a location it did not actually find, it points
233
+ // at the end of the file.
234
+ return cur.position();
235
+ }
236
+
237
+ /**
238
+ * `JSON.parse`, with a `(line N, column N)` suffix appended to the message
239
+ * whenever it throws a `SyntaxError` — the runtime's own message, positioned.
240
+ * Any other error (a non-JSON exception `JSON.parse` never actually throws,
241
+ * kept here only for type safety) passes through unchanged.
242
+ */
243
+ export function parseJsonWithPosition(text: string): unknown {
244
+ try {
245
+ return JSON.parse(text);
246
+ } catch (err) {
247
+ if (err instanceof SyntaxError) {
248
+ const { line, column } = locateJsonError(text);
249
+ throw new SyntaxError(`${err.message} (line ${line}, column ${column})`);
250
+ }
251
+ throw err;
252
+ }
253
+ }
package/src/png.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  /**
2
2
  * PNG header reader.
3
3
  *
4
- * The only thing rigc needs from a part PNG is its true pixel size and whether
5
- * it carries an alpha channel, and both live in the IHDR chunk that every PNG
6
- * puts first. Parsing 26 bytes keeps the compiler dependency-free: no image
7
- * library, and nothing on the module path but rigc itself.
4
+ * The only things rigc needs from a part PNG are its true pixel size and whether
5
+ * it can draw a transparent pixel. The size is in the IHDR chunk that every PNG
6
+ * puts first; transparency is in the IHDR's colour type OR in a `tRNS` chunk a
7
+ * little further in, so the reader walks the file's small leading chunks and
8
+ * stops at the pixel data. That keeps the compiler dependency-free — no image
9
+ * library, and nothing on the module path but rigc itself — while still reading
10
+ * every place the answer can be written down.
11
+ *
12
+ * ⚠️ It reads a header, not an image: "can this file draw a transparent pixel",
13
+ * never "does it". Whether the art actually has a transparent margin is a
14
+ * question about pixels, and the tools that measure pixels decode the whole file
15
+ * ([`tools/plate.ts`](../tools/plate.ts)).
8
16
  *
9
17
  * Measuring instead of trusting is the whole point: an atlas `size:` that
10
18
  * disagrees with the file loads clean and collapses the UVs silently.
@@ -13,21 +21,72 @@ import { readFileSync } from 'node:fs';
13
21
 
14
22
  const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
15
23
 
16
- /** PNG colour types that carry a per-pixel alpha channel. */
24
+ /**
25
+ * PNG colour types that carry a per-pixel alpha channel.
26
+ *
27
+ * ⭐ False here does NOT mean opaque, and reading it that way is what #215 was:
28
+ * types 0, 2 and 3 can all carry a `tRNS` chunk instead — a palette alpha table
29
+ * for indexed art, one invisible colour for the other two — and indexed+tRNS is
30
+ * the ordinary output of ImageMagick, Photoshop's PNG-8 export, GIMP's indexed
31
+ * mode, aseprite and pngquant. `hasTransparency` is the field to judge art by;
32
+ * this one answers the narrower question of where the alpha is stored.
33
+ */
17
34
  const COLOUR_TYPE_HAS_ALPHA: Record<number, boolean> = {
18
35
  0: false, // greyscale
19
36
  2: false, // truecolour
20
- 3: false, // indexed (may have tRNS, but not a straight alpha channel)
37
+ 3: false, // indexed (transparency, if any, is in tRNS)
21
38
  4: true, // greyscale + alpha
22
39
  6: true, // truecolour + alpha
23
40
  };
24
41
 
42
+ /** The spec's name for each colour type, for messages that have to name one. */
43
+ const COLOUR_TYPE_NAMES: Record<number, string> = {
44
+ 0: 'greyscale',
45
+ 2: 'truecolour',
46
+ 3: 'indexed',
47
+ 4: 'greyscale + alpha',
48
+ 6: 'truecolour + alpha',
49
+ };
50
+
51
+ /** How to say a colour type out loud. Unknown types print as themselves. */
52
+ export function colourTypeName(colourType: number): string {
53
+ return COLOUR_TYPE_NAMES[colourType] ?? 'unrecognised';
54
+ }
55
+
25
56
  export interface PngInfo {
26
57
  width: number;
27
58
  height: number;
28
59
  bitDepth: number;
29
60
  colourType: number;
61
+ /** A per-pixel alpha channel in the pixel data: colour types 4 and 6, and only those. */
30
62
  hasAlpha: boolean;
63
+ /** A `tRNS` chunk: a palette alpha table (type 3), or one invisible colour (types 0 and 2). */
64
+ hasTrns: boolean;
65
+ /** Either of the above — the file is able to draw a transparent pixel. */
66
+ hasTransparency: boolean;
67
+ }
68
+
69
+ /**
70
+ * Walk the chunk list looking for `tRNS`, stopping where it can no longer appear.
71
+ *
72
+ * The spec orders `tRNS` after `PLTE` and before the first `IDAT`, so this reads
73
+ * only the file's small leading chunks and never touches the compressed bulk. A
74
+ * length that would run past the end of the file ends the walk rather than
75
+ * throwing: a truncated PNG is A17 and A06's business, and answering "no tRNS"
76
+ * about a file nobody can open is the same answer either way.
77
+ */
78
+ function scanForTrns(buf: Buffer): boolean {
79
+ let at = 8; // past the signature; the first chunk is IHDR
80
+ while (at + 8 <= buf.length) {
81
+ const length = buf.readUInt32BE(at);
82
+ const type = buf.toString('latin1', at + 4, at + 8);
83
+ if (type === 'tRNS') return true;
84
+ if (type === 'IDAT' || type === 'IEND') return false;
85
+ const next = at + 12 + length; // 4 length + 4 type + body + 4 CRC
86
+ if (next <= at || next > buf.length) return false;
87
+ at = next;
88
+ }
89
+ return false;
31
90
  }
32
91
 
33
92
  export function readPngInfo(path: string): PngInfo {
@@ -40,11 +99,17 @@ export function readPngInfo(path: string): PngInfo {
40
99
  throw new Error(`PNG does not start with IHDR: ${path}`);
41
100
  }
42
101
  const colourType = buf.readUInt8(25);
102
+ const hasAlpha = COLOUR_TYPE_HAS_ALPHA[colourType] ?? false;
103
+ // A file with an alpha channel cannot also carry tRNS, so the scan is skipped
104
+ // for the types that already answered — which is every PNG rigc itself writes.
105
+ const hasTrns = hasAlpha ? false : scanForTrns(buf);
43
106
  return {
44
107
  width: buf.readUInt32BE(16),
45
108
  height: buf.readUInt32BE(20),
46
109
  bitDepth: buf.readUInt8(24),
47
110
  colourType,
48
- hasAlpha: COLOUR_TYPE_HAS_ALPHA[colourType] ?? false,
111
+ hasAlpha,
112
+ hasTrns,
113
+ hasTransparency: hasAlpha || hasTrns,
49
114
  };
50
115
  }
package/src/preview.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The single-file preview — a compiled artifact playing in Esoteric Software's
3
+ * own web player, with nothing beside it.
4
+ *
5
+ * ⭐ Why this exists at all. `validate` answers "is this valid Spine", `check`
6
+ * answers "does it match these frames", and neither of them can answer the
7
+ * question a first user actually has: *does it look right?* A rig whose head sits
8
+ * visibly off its torso compiles green, loads in `spine-core` and steps
9
+ * numerically clean — the offsets are the ones the spec asked for. The only
10
+ * remedy for that class of error is looking, and until this command the package
11
+ * offered no way to look (issue #216).
12
+ *
13
+ * ## Why the official player, and not our own renderer
14
+ *
15
+ * There is a rasteriser in [`render.ts`](render.ts) already, and `rigc render`
16
+ * uses it. This is deliberately the other thing: `SpinePlayer` is the runtime
17
+ * Esoteric ships, so a rig that plays here has been played by the reference
18
+ * implementation rather than by ours. Every picture our own code draws is, at
19
+ * some level, rigc checking its own work — this one is not, which is why issue
20
+ * #151 settled on it as the surface a human votes on.
21
+ *
22
+ * ## Why one file, and what `rawDataURIs` is doing
23
+ *
24
+ * `SpinePlayer` normally fetches its skeleton, atlas and pages over HTTP, which
25
+ * would make a preview a directory plus a web server — a thing to set up rather
26
+ * than a thing to open. Its `rawDataURIs` option maps each of those paths to a
27
+ * `data:` URI instead, and the player's downloader takes the mapping in
28
+ * preference to the network. So the whole artifact — skeleton JSON, atlas text
29
+ * and every page's PNG bytes — is embedded, and the result is one `.html` file
30
+ * that plays when double-clicked and can be attached to a message.
31
+ *
32
+ * ⚠️ The keys are the paths the player asks for, and they must match exactly.
33
+ * `config.atlas` has no directory part, so the player's own texture resolution
34
+ * (`parentPath + pageName`) asks for each page under **the name the atlas spells**
35
+ * — `../parts/torso.png` and all. That is why the atlas text is embedded verbatim
36
+ * rather than rewritten to flat names: the file that plays is the file that was
37
+ * built, which is the entire value of the interop proof.
38
+ *
39
+ * ## The player is referenced, never vendored
40
+ *
41
+ * The `<script>` and `<link>` point at unpkg. Nothing Esoteric owns is copied
42
+ * into this repository or into the published package — see [NOTICE.md](../NOTICE.md)
43
+ * — and the generated page belongs to whoever ran the command. The cost is that
44
+ * the file needs the network the first time it is opened, which the page says out
45
+ * loud when the script does not arrive.
46
+ */
47
+ import { SPINE_VERSION } from './compile.ts';
48
+ import { BACKGROUND } from './render.ts';
49
+
50
+ /**
51
+ * The Spine Web Player line the generated page loads.
52
+ *
53
+ * Derived from the runtime line the compiler emits rather than written down
54
+ * twice: a page playing 4.2 data in a 4.3 player (or the reverse) is a failure
55
+ * mode nobody would look for, and this makes the two impossible to edit apart.
56
+ *
57
+ * The **patch** is deliberately a wildcard where `SPINE_VERSION` is exact. Data
58
+ * compatibility is a property of the minor line, and pinning a patch would break
59
+ * every generated page on the day spine-core ships a version the player did not.
60
+ */
61
+ export const PLAYER_LINE = `${SPINE_VERSION.split('.').slice(0, 2).join('.')}.*`;
62
+ export const PLAYER_SCRIPT_URL = `https://unpkg.com/@esotericsoftware/spine-player@${PLAYER_LINE}/dist/iife/spine-player.js`;
63
+ export const PLAYER_STYLE_URL = `https://unpkg.com/@esotericsoftware/spine-player@${PLAYER_LINE}/dist/spine-player.css`;
64
+
65
+ /** The name the embedded skeleton and atlas are asked for under, inside the page. */
66
+ export const SKELETON_KEY = 'skeleton.json';
67
+ export const ATLAS_KEY = 'skeleton.atlas';
68
+
69
+ /** One atlas page, named exactly as the atlas spells it, with its bytes. */
70
+ export interface PreviewPage {
71
+ /** The page name from the atlas text — a path, quite possibly a relative one. */
72
+ name: string;
73
+ bytes: Uint8Array;
74
+ }
75
+
76
+ export interface PreviewInput {
77
+ skeletonText: string;
78
+ atlasText: string;
79
+ pages: PreviewPage[];
80
+ /** The animation to autoplay and loop, or `null` for a skeleton with none. */
81
+ animation: string | null;
82
+ /** Every animation the page offers in its picker. */
83
+ animations: string[];
84
+ /** What the page calls itself — the skeleton's path, for the tab and the header. */
85
+ label: string;
86
+ /** rigc's own version, for the generated-by line. */
87
+ version: string;
88
+ }
89
+
90
+ /**
91
+ * A `data:` URI the player's downloader will take the fast path on.
92
+ *
93
+ * ⚠️ Base64 for the text assets too, not only for the PNGs. The downloader
94
+ * decides a value is a data URI rather than an alias by asking whether it
95
+ * contains a `.` — and a percent-encoded skeleton JSON is full of them, so the
96
+ * un-encoded form would be handed to `XMLHttpRequest` as a URL instead. Base64's
97
+ * alphabet has no `.` in it, so this always lands on the branch that decodes.
98
+ */
99
+ export function dataUri(mime: string, body: string | Uint8Array): string {
100
+ const base64 = (typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.from(body)).toString('base64');
101
+ return `data:${mime};base64,${base64}`;
102
+ }
103
+
104
+ /** `#rrggbb` for the player's background, so a preview and `rigc render` agree. */
105
+ function backgroundHex(): string {
106
+ return `#${BACKGROUND.slice(0, 3)
107
+ .map((c) => c.toString(16).padStart(2, '0'))
108
+ .join('')}`;
109
+ }
110
+
111
+ /** The five characters that can end an element or an attribute early. */
112
+ export function escapeHtml(text: string): string {
113
+ return text
114
+ .replace(/&/g, '&amp;')
115
+ .replace(/</g, '&lt;')
116
+ .replace(/>/g, '&gt;')
117
+ .replace(/"/g, '&quot;')
118
+ .replace(/'/g, '&#39;');
119
+ }
120
+
121
+ /**
122
+ * JSON for embedding inside a `<script>` element.
123
+ *
124
+ * The escape is on `<` alone and it is enough: an HTML parser ends a script at
125
+ * `</script`, and it cannot see one if no `<` survives. `<` is the same
126
+ * string to a JSON reader, so nothing about the value changes. Page names come
127
+ * out of a file somebody else wrote, which is exactly why this is not optional.
128
+ */
129
+ function embeddedJson(value: unknown): string {
130
+ return JSON.stringify(value).replace(/</g, '\\u003c');
131
+ }
132
+
133
+ /**
134
+ * The whole page, as text.
135
+ *
136
+ * There is no template file and no build step: this is a `.ts` string, so the
137
+ * published package carries it the way it carries every other module, and the
138
+ * page it produces has no dependency of its own except the player URL above.
139
+ */
140
+ export function buildPreview(input: PreviewInput): string {
141
+ const rawDataURIs: Record<string, string> = {
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
+
161
+ 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
+
167
+ return `<!doctype html>
168
+ <html lang="en">
169
+ <head>
170
+ <meta charset="utf-8">
171
+ <meta name="viewport" content="width=device-width, initial-scale=1">
172
+ <title>rigc preview — ${label}</title>
173
+ <!--
174
+ Generated by rigc ${escapeHtml(input.version)} — https://github.com/firejune/rigc
175
+
176
+ The skeleton, the atlas and every atlas page are embedded in this file as data
177
+ URIs, so it plays on its own with no server and no sibling files.
178
+
179
+ It plays them in the Spine Web Player, which is NOT embedded: the script and
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.
185
+ -->
186
+ <link rel="stylesheet" href="${PLAYER_STYLE_URL}">
187
+ <style>
188
+ :root { color-scheme: light dark; }
189
+ html, body { margin: 0; height: 100%; }
190
+ body {
191
+ display: flex; flex-direction: column;
192
+ background: ${backgroundHex()};
193
+ color: #1a1a1a;
194
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
195
+ }
196
+ header { padding: 10px 14px; border-bottom: 1px solid rgba(0, 0, 0, 0.15); }
197
+ header b { font-weight: 600; }
198
+ header span { opacity: 0.65; }
199
+ #rigc-player { flex: 1 1 auto; min-height: 0; }
200
+ #rigc-status { margin: 0; padding: 10px 14px; border-top: 1px solid rgba(0, 0, 0, 0.15); white-space: pre-wrap; }
201
+ #rigc-status[data-state="error"] { background: #7d1d1d; color: #fff; }
202
+ </style>
203
+ </head>
204
+ <body>
205
+ <header><b>${label}</b> <span>— ${played}</span></header>
206
+ <div id="rigc-player"></div>
207
+ <p id="rigc-status">loading the Spine Web Player…</p>
208
+ <script src="${PLAYER_SCRIPT_URL}"></script>
209
+ <script>
210
+ (function () {
211
+ var status = document.getElementById('rigc-status');
212
+ var state = { status: 'loading', message: null, player: null };
213
+ window.rigcPreview = state;
214
+ function say(kind, message) {
215
+ state.status = kind;
216
+ state.message = message;
217
+ status.textContent = message;
218
+ status.setAttribute('data-state', kind);
219
+ }
220
+ if (typeof window.spine === 'undefined' || typeof window.spine.SpinePlayer !== 'function') {
221
+ 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.');
222
+ return;
223
+ }
224
+ var config = ${embeddedJson(config)};
225
+ config.success = function (player) {
226
+ state.player = player;
227
+ say('ready', 'playing in Spine Web Player ${PLAYER_LINE} — everything it is drawing is embedded in this file.');
228
+ };
229
+ config.error = function (player, message) {
230
+ state.player = player;
231
+ say('error', String(message));
232
+ };
233
+ try {
234
+ new window.spine.SpinePlayer('rigc-player', config);
235
+ } catch (err) {
236
+ say('error', String(err && err.message ? err.message : err));
237
+ }
238
+ })();
239
+ </script>
240
+ </body>
241
+ </html>
242
+ `;
243
+ }
package/src/render.ts CHANGED
@@ -144,6 +144,11 @@ export const SHEET_COLUMNS = 8;
144
144
  export const SHEET_FILE = 'contact.png';
145
145
  /** One pixel of rule between tiles, and one around the outside. */
146
146
  export const SHEET_GAP = 1;
147
+ /** Default long side of one contact-sheet tile, in pixels. */
148
+ export const SHEET_TILE = 128;
149
+ /** The rule between tiles, and the frame number drawn in each. */
150
+ export const SHEET_RULE: RGBA = [176, 176, 176, 255];
151
+ export const SHEET_LABEL: RGBA = [96, 96, 96, 255];
147
152
 
148
153
  /** One rendered frame directory: which animation, at what rate, and what is on disk. */
149
154
  export interface FrameSet {
@@ -285,6 +290,19 @@ export function loadPosable(skeletonPath: string, atlasPath: string, atlasDir: s
285
290
  return posableFromText(readFileSync(skeletonPath, 'utf8'), readFileSync(atlasPath, 'utf8'), atlasDir);
286
291
  }
287
292
 
293
+ /**
294
+ * The page names an atlas declares, in the order it declares them.
295
+ *
296
+ * Through `TextureAtlas` rather than by reading the lines: a page name and a
297
+ * region name are both unindented in the atlas format, so anything that told them
298
+ * apart here would be a second opinion about the file's syntax — and the one
299
+ * caller that needs this list (`rigc preview`, embedding each page) has to agree
300
+ * exactly with the player that will ask for them by name.
301
+ */
302
+ export function atlasPageNames(atlasText: string): string[] {
303
+ return new TextureAtlas(atlasText).pages.map((page) => page.name);
304
+ }
305
+
288
306
  /** Same, for artifacts held in memory rather than on disk. */
289
307
  export function posableFromText(skeletonText: string, atlasText: string, atlasDir: string): Posable {
290
308
  const atlas = new TextureAtlas(atlasText);
@@ -911,6 +929,46 @@ export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: V
911
929
  return plate;
912
930
  }
913
931
 
932
+ /**
933
+ * Every frame of one animation as one labelled grid, row major.
934
+ *
935
+ * Not decoration: rung 3's subject is *spacing* — how far a thing travels
936
+ * between two consecutive frames — and that is a comparison across frames. A
937
+ * reader flipping through 65 separate files is comparing against memory.
938
+ *
939
+ * ⭐ It lives here rather than beside either caller because the layout is a
940
+ * CONTRACT: `bench/render_reference.ts` writes the grid, `rigc render` writes the
941
+ * same grid for a user's own build, and `src/check.ts` reads a sheet's tiles back
942
+ * out of it (issue #36). Three programs reading one geometry is one definition or
943
+ * it is a bug waiting for the day two of them are edited apart.
944
+ */
945
+ export function contactSheet(frames: Frame[], pages: Map<string, Plate>, viewport: Viewport, tile: number): Plate {
946
+ const tileScale = tile / Math.max(viewport.width, viewport.height);
947
+ const tileW = Math.max(1, Math.round(viewport.width * tileScale));
948
+ const tileH = Math.max(1, Math.round(viewport.height * tileScale));
949
+ const columns = Math.min(SHEET_COLUMNS, frames.length);
950
+ const rows = Math.ceil(frames.length / columns);
951
+ const sheet = new Plate(columns * (tileW + SHEET_GAP) + SHEET_GAP, rows * (tileH + SHEET_GAP) + SHEET_GAP);
952
+ fill(sheet, SHEET_RULE);
953
+ const base = projector(viewport);
954
+ frames.forEach((frame, i) => {
955
+ const col = i % columns;
956
+ const row = Math.floor(i / columns);
957
+ const ox = col * (tileW + SHEET_GAP) + SHEET_GAP;
958
+ const oy = row * (tileH + SHEET_GAP) + SHEET_GAP;
959
+ const plate = new Plate(tileW, tileH);
960
+ fill(plate, BACKGROUND);
961
+ const project = (wx: number, wy: number): [number, number] => {
962
+ const [px, py] = base(wx, wy);
963
+ return [px * tileScale, py * tileScale];
964
+ };
965
+ for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
966
+ plate.text(String(i), 2, 2, 1, SHEET_LABEL);
967
+ for (let y = 0; y < tileH; y++) for (let x = 0; x < tileW; x++) sheet.set(ox + x, oy + y, plate.get(x, y));
968
+ });
969
+ return sheet;
970
+ }
971
+
914
972
  /** Where one thing landed in a frame, in frame pixels. */
915
973
  export interface Footprint {
916
974
  /** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */