spine-rigc 0.4.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 +336 -223
- package/bin/rigc.cjs +40 -0
- package/cli.ts +558 -49
- package/docs/AUTHORING.md +538 -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
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. */
|
package/src/types.ts
CHANGED
|
@@ -573,6 +573,14 @@ export interface CompiledImage {
|
|
|
573
573
|
absPath: string;
|
|
574
574
|
width: number;
|
|
575
575
|
height: number;
|
|
576
|
+
/**
|
|
577
|
+
* A per-pixel alpha channel, and only that — colour types 4 and 6.
|
|
578
|
+
*
|
|
579
|
+
* ⚠️ Not "this part can be transparent": indexed and greyscale art keeps its
|
|
580
|
+
* transparency in a `tRNS` chunk and reads `false` here. Anything asking
|
|
581
|
+
* whether the art can draw a transparent pixel wants `PngInfo.hasTransparency`
|
|
582
|
+
* ([`src/png.ts`](png.ts)), which is the distinction A19 got wrong (#215).
|
|
583
|
+
*/
|
|
576
584
|
hasAlpha: boolean;
|
|
577
585
|
isBase: boolean;
|
|
578
586
|
}
|
package/src/validate.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
SkeletonJson,
|
|
30
30
|
TextureAtlas,
|
|
31
31
|
} from '@esotericsoftware/spine-core';
|
|
32
|
-
import { readPngInfo } from './png.ts';
|
|
32
|
+
import { colourTypeName, readPngInfo } from './png.ts';
|
|
33
33
|
import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
|
|
34
34
|
import type { RigInfo } from './types.ts';
|
|
35
35
|
|
|
@@ -53,15 +53,31 @@ export interface Failure {
|
|
|
53
53
|
* - `spine` — is this valid Spine 4.3 that any runtime will play correctly?
|
|
54
54
|
* - `spine-html` — the above, plus this project's renderer and archetype policy.
|
|
55
55
|
*
|
|
56
|
-
* ⚠️ `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* ⚠️ `validate()` has NO default profile — `ValidateInput.profile` is required,
|
|
57
|
+
* and that is deliberate. A silent default here can only be wrong in one of two
|
|
58
|
+
* directions: loosen it and a caller who did not ask gets a weaker gate than the
|
|
59
|
+
* one they think they ran; tighten it and foreign data is refused by a policy the
|
|
60
|
+
* caller has no stake in. Issue #221 flipped the CLI to `spine` while several
|
|
61
|
+
* internal callers still wanted `spine-html`, at which point one constant could
|
|
62
|
+
* no longer honestly serve both — so the choice is made at every call site now,
|
|
63
|
+
* by the caller who knows which question they are asking.
|
|
59
64
|
*/
|
|
60
65
|
export type ValidateProfile = 'spine' | 'spine-html';
|
|
61
66
|
|
|
62
67
|
export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-html'];
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
/**
|
|
70
|
+
* What the CLI uses when `--profile` is absent — and ONLY the CLI. This is not
|
|
71
|
+
* `validate()`'s default; that function has none (above).
|
|
72
|
+
*
|
|
73
|
+
* `spine` since issue #221. The published package's pitch is "the output imports
|
|
74
|
+
* into the Spine editor", which is exactly the question `spine` asks, and a
|
|
75
|
+
* stranger's first build was being judged instead against one renderer's policy
|
|
76
|
+
* and one project's canvas budget — 14 rules they have no stake in, with the
|
|
77
|
+
* escape hatch documented only in prose. Defaults beat prose. `spine-html` is
|
|
78
|
+
* still one flag away, and every report names the profile that judged it.
|
|
79
|
+
*/
|
|
80
|
+
export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
|
|
65
81
|
|
|
66
82
|
/**
|
|
67
83
|
* What kind of rule each assertion is. Every assertion has an entry, and
|
|
@@ -136,8 +152,11 @@ export interface ValidateInput {
|
|
|
136
152
|
* stats line says `rig=absent` so a green run cannot be mistaken for a full one.
|
|
137
153
|
*/
|
|
138
154
|
rig?: RigInfo;
|
|
139
|
-
/**
|
|
140
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Which body of rules to apply. Required, and deliberately so — there is no
|
|
157
|
+
* default to fall into. See ValidateProfile.
|
|
158
|
+
*/
|
|
159
|
+
profile: ValidateProfile;
|
|
141
160
|
}
|
|
142
161
|
|
|
143
162
|
export interface ValidateReport {
|
|
@@ -215,7 +234,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
215
234
|
const skipped: ValidateReport['skipped'] = [];
|
|
216
235
|
const profileSkipped: ValidateReport['profileSkipped'] = [];
|
|
217
236
|
const stats: Record<string, number | string> = {};
|
|
218
|
-
const profile = input.profile
|
|
237
|
+
const profile = input.profile;
|
|
219
238
|
/** True when this profile's rulebook includes the policy layer. */
|
|
220
239
|
const policy = profile === 'spine-html';
|
|
221
240
|
|
|
@@ -1297,11 +1316,25 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1297
1316
|
}
|
|
1298
1317
|
}
|
|
1299
1318
|
});
|
|
1300
|
-
// An overlay part must
|
|
1301
|
-
// would paint
|
|
1302
|
-
// formation's whole claim is that the still frame has no seam. The base
|
|
1319
|
+
// An overlay part must be able to draw a transparent pixel or it cannot be an
|
|
1320
|
+
// overlay: it would paint a solid rectangle over the untouched base, and an
|
|
1321
|
+
// overlay formation's whole claim is that the still frame has no seam. The base
|
|
1303
1322
|
// plate itself is the one page allowed to be opaque, and it identifies
|
|
1304
1323
|
// itself structurally — it is the region that covers the whole stage.
|
|
1324
|
+
//
|
|
1325
|
+
// ⭐ Transparency is not the same thing as an alpha CHANNEL, and this assertion
|
|
1326
|
+
// used to conflate them (#215). Colour types 4 and 6 store alpha per pixel;
|
|
1327
|
+
// types 0, 2 and 3 store it in a `tRNS` chunk instead, and indexed+tRNS is the
|
|
1328
|
+
// ordinary output of ImageMagick, Photoshop's PNG-8 export, GIMP's indexed
|
|
1329
|
+
// mode, aseprite and pngquant. Judging on the colour type alone refused art
|
|
1330
|
+
// that was never broken — seven of one author's nine hand-drawn parts, on their
|
|
1331
|
+
// first build — and told them, untruthfully, that the file held no transparency
|
|
1332
|
+
// at all. That audit is also why the CLI's default is now `spine`, which does
|
|
1333
|
+
// not run this rule at all (#221): a stranger reaches this refusal only by
|
|
1334
|
+
// asking for `spine-html`. It still has to be both TRUE and actionable when it
|
|
1335
|
+
// fires, and it now has to name the profile it belongs to as well as the one
|
|
1336
|
+
// that does not ask — the reader opted in, and the message is where they find
|
|
1337
|
+
// out what they opted into.
|
|
1305
1338
|
check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => {
|
|
1306
1339
|
if (!atlas) return;
|
|
1307
1340
|
const stageW = skeletonData?.width ?? 0;
|
|
@@ -1313,15 +1346,27 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1313
1346
|
if (region) basePages.add(region.page.name);
|
|
1314
1347
|
}
|
|
1315
1348
|
}
|
|
1349
|
+
// The escape hatch is only worth naming when it is reachable: with no stage
|
|
1350
|
+
// size to measure against, `basePages` is empty and no image can qualify, so
|
|
1351
|
+
// pointing at it would send the reader after a door that is not there.
|
|
1352
|
+
const exemption =
|
|
1353
|
+
stageW && stageH
|
|
1354
|
+
? `Only the one image big enough to cover the whole stage (${stageW}x${stageH}) may be opaque.`
|
|
1355
|
+
: 'The one image that covers the whole stage may be opaque, but this skeleton declares no stage size, so ' +
|
|
1356
|
+
'nothing here qualifies.';
|
|
1316
1357
|
for (const page of atlas.pages) {
|
|
1317
1358
|
const abs = resolve(input.atlasDir, page.name);
|
|
1318
1359
|
if (!existsSync(abs)) continue;
|
|
1319
1360
|
const info = readPngInfo(abs);
|
|
1320
|
-
if (info.
|
|
1361
|
+
if (info.hasTransparency) continue;
|
|
1321
1362
|
if (basePages.has(page.name)) continue; // full-stage base plate: opaque is correct
|
|
1322
1363
|
fail(
|
|
1323
1364
|
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
1324
|
-
`
|
|
1365
|
+
`part image "${page.name}" cannot be transparent anywhere: it is colour type ${info.colourType} ` +
|
|
1366
|
+
`(${colourTypeName(info.colourType)}) with no tRNS chunk, so it would paint a solid rectangle over ` +
|
|
1367
|
+
'whatever is drawn behind it. Re-export it with transparency — as RGBA, or as an indexed or greyscale ' +
|
|
1368
|
+
`PNG that keeps its tRNS chunk. ${exemption} This is renderer policy, and it belongs to --profile ` +
|
|
1369
|
+
'spine-html: the default --profile spine does not run this check.',
|
|
1325
1370
|
);
|
|
1326
1371
|
}
|
|
1327
1372
|
});
|