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.
- package/NOTICE.md +21 -0
- package/README.md +323 -224
- package/bin/rigc.cjs +40 -0
- package/cli.ts +558 -49
- package/docs/AUTHORING.md +60 -23
- package/docs/PROMPTING.md +106 -0
- package/docs/SPEC_COVERAGE.md +16 -14
- package/package.json +4 -2
- package/src/compile.ts +189 -142
- package/src/emit.ts +88 -0
- package/src/json-position.ts +253 -0
- package/src/png.ts +72 -7
- package/src/preview.ts +243 -0
- package/src/render.ts +58 -0
- package/src/types.ts +8 -0
- package/src/validate.ts +58 -13
- package/tools/plate.ts +164 -21
|
@@ -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
|
|
5
|
-
* it
|
|
6
|
-
* puts first
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
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, '&')
|
|
115
|
+
.replace(/</g, '<')
|
|
116
|
+
.replace(/>/g, '>')
|
|
117
|
+
.replace(/"/g, '"')
|
|
118
|
+
.replace(/'/g, ''');
|
|
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. */
|