spine-rigc 0.2.1
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/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/cli.ts
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* rigc — the rig compiler.
|
|
4
|
+
*
|
|
5
|
+
* bun cli.ts build --rig <path> --motion <path> --out <dir> [--manifest <path>]
|
|
6
|
+
* bun cli.ts build --cut <name> --cuts <cuts.json>
|
|
7
|
+
* bun cli.ts explain --rig <path> --motion <path> --out <dir>
|
|
8
|
+
* bun cli.ts explain --cut <name> --cuts <cuts.json>
|
|
9
|
+
* bun cli.ts validate <dir> re-run the gate on artifacts on disk
|
|
10
|
+
* bun cli.ts check --candidate <dir> --frames <dir> compare against pictures
|
|
11
|
+
*
|
|
12
|
+
* `build` emits ONLY if validate is green. That ordering is the point: the
|
|
13
|
+
* compiler is allowed to be wrong, it is not allowed to leave the wrong thing
|
|
14
|
+
* on disk.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ Green is a claim about VALIDITY and about nothing else. The gate has no way
|
|
17
|
+
* to know whether the animation is the one that was asked for — a build with
|
|
18
|
+
* every easing reversed passes it — so `check` is the other half of the loop, and
|
|
19
|
+
* it is a separate command because it needs something the gate does not have: a
|
|
20
|
+
* picture of what the result is supposed to look like.
|
|
21
|
+
*
|
|
22
|
+
* rigc knows nothing about any particular project. A cut is a rig spec, a motion
|
|
23
|
+
* spec and an output directory — plus a cut manifest when there is measured art
|
|
24
|
+
* behind it — and a `cuts.json` is a named table of them:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "my_cut": { "rig": "…/rigs/my_rig.rig.json", "motion": "…/my.motion.json",
|
|
28
|
+
* "out": "…/spine", "manifest": "…/manifest.json" }
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Its paths resolve against the cuts.json file itself, so the table travels
|
|
32
|
+
* with the project that owns the art rather than with this repository.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { dirname, join, resolve } from 'node:path';
|
|
36
|
+
import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
|
|
37
|
+
import { compile, CompileError, type CompileOptions } from './src/compile.ts';
|
|
38
|
+
import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
|
|
39
|
+
import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
|
|
40
|
+
import { DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
|
|
41
|
+
import type { CompileResult, MotionSpec } from './src/types.ts';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One entry of a cuts.json, every path relative to the cuts.json file.
|
|
45
|
+
*
|
|
46
|
+
* `rig` is required — it is the skeleton's structure, and until it was a file
|
|
47
|
+
* that structure was three hard-coded tables in the compiler. `manifest` is
|
|
48
|
+
* optional: a skeleton with no measured art behind it has none, and then the rig
|
|
49
|
+
* spec carries its own attachments and stage size.
|
|
50
|
+
*/
|
|
51
|
+
export interface CutEntry {
|
|
52
|
+
rig: string;
|
|
53
|
+
motion: string;
|
|
54
|
+
out: string;
|
|
55
|
+
manifest?: string;
|
|
56
|
+
/** Base directory for the rig spec's `image` references, if not the rig's own. */
|
|
57
|
+
images?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type CutTable = Record<string, CutEntry>;
|
|
61
|
+
|
|
62
|
+
class UsageError extends Error {}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// argument parsing
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The flags that are switches rather than `--flag value` pairs.
|
|
70
|
+
*
|
|
71
|
+
* Listed by name rather than inferred from "the next argument looks like a
|
|
72
|
+
* flag": inferring it would turn `--out --json report.json` — a real typo, a
|
|
73
|
+
* missing value — into a silently accepted switch plus a stray positional.
|
|
74
|
+
*/
|
|
75
|
+
const BOOLEAN_FLAGS = new Set(['all-frames']);
|
|
76
|
+
|
|
77
|
+
/** `--flag value` pairs plus the leftover positionals, in order. */
|
|
78
|
+
function parseArgs(argv: string[]): { flags: Record<string, string>; positional: string[] } {
|
|
79
|
+
const flags: Record<string, string> = {};
|
|
80
|
+
const positional: string[] = [];
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const arg = argv[i];
|
|
83
|
+
if (arg.startsWith('--')) {
|
|
84
|
+
const eq = arg.indexOf('=');
|
|
85
|
+
if (eq !== -1) {
|
|
86
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
87
|
+
} else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
|
|
88
|
+
flags[arg.slice(2)] = 'true';
|
|
89
|
+
} else {
|
|
90
|
+
const next = argv[i + 1];
|
|
91
|
+
if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
|
|
92
|
+
flags[arg.slice(2)] = next;
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
positional.push(arg);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { flags, positional };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a cuts.json and resolve its three paths against the file's own
|
|
104
|
+
* directory. Anchoring on the table rather than on the process cwd is what lets
|
|
105
|
+
* the same command work from anywhere in the owning project.
|
|
106
|
+
*/
|
|
107
|
+
function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
|
|
108
|
+
const abs = resolve(cutsPath);
|
|
109
|
+
if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
|
|
110
|
+
const parsed: unknown = JSON.parse(readFileSync(abs, 'utf8'));
|
|
111
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
112
|
+
throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
|
|
113
|
+
}
|
|
114
|
+
return { dir: dirname(abs), table: parsed as CutTable };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOptions {
|
|
118
|
+
for (const key of ['rig', 'motion', 'out'] as const) {
|
|
119
|
+
if (typeof entry?.[key] !== 'string') throw new UsageError(`cut ${JSON.stringify(name)} has no "${key}" path`);
|
|
120
|
+
}
|
|
121
|
+
const opts: CompileOptions = {
|
|
122
|
+
rigPath: resolve(dir, entry.rig),
|
|
123
|
+
motionPath: resolve(dir, entry.motion),
|
|
124
|
+
outDir: resolve(dir, entry.out),
|
|
125
|
+
};
|
|
126
|
+
if (entry.manifest !== undefined) opts.manifestPath = resolve(dir, entry.manifest);
|
|
127
|
+
if (entry.images !== undefined) opts.imagesDir = resolve(dir, entry.images);
|
|
128
|
+
return opts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the cut a command was pointed at, either spelled out on the command
|
|
133
|
+
* line or looked up by name in a cuts.json.
|
|
134
|
+
*/
|
|
135
|
+
function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
|
|
136
|
+
const explicit =
|
|
137
|
+
flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
|
|
138
|
+
if (explicit) {
|
|
139
|
+
if (flags.cut !== undefined || flags.cuts !== undefined) {
|
|
140
|
+
throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
|
|
141
|
+
}
|
|
142
|
+
for (const key of ['rig', 'motion', 'out'] as const) {
|
|
143
|
+
if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
|
|
144
|
+
}
|
|
145
|
+
const opts: CompileOptions = {
|
|
146
|
+
rigPath: resolve(flags.rig),
|
|
147
|
+
motionPath: resolve(flags.motion),
|
|
148
|
+
outDir: resolve(flags.out),
|
|
149
|
+
};
|
|
150
|
+
if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
|
|
151
|
+
if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
|
|
152
|
+
return { label: flags.rig, opts };
|
|
153
|
+
}
|
|
154
|
+
if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
|
|
155
|
+
if (flags.cuts === undefined) throw new UsageError('--cut needs --cuts <cuts.json> to look the name up in');
|
|
156
|
+
const { dir, table } = readCutTable(flags.cuts);
|
|
157
|
+
const entry = table[flags.cut];
|
|
158
|
+
if (!entry) {
|
|
159
|
+
throw new UsageError(
|
|
160
|
+
`unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return { label: flags.cut, opts: entryToOptions(dir, flags.cut, entry) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// commands
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read `--profile`, defaulting to the profile every caller had before the flag
|
|
172
|
+
* existed. An unknown name is a usage error rather than a silent fallback: the
|
|
173
|
+
* fallback would be `spine-html`, so a typo would quietly re-apply the strictest
|
|
174
|
+
* rulebook to data the caller was trying to exempt.
|
|
175
|
+
*/
|
|
176
|
+
function readProfile(flags: Record<string, string>): ValidateProfile {
|
|
177
|
+
const raw = flags.profile;
|
|
178
|
+
if (raw === undefined) return DEFAULT_PROFILE;
|
|
179
|
+
const found = VALIDATE_PROFILES.find((p) => p === raw);
|
|
180
|
+
if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function runGate(result: CompileResult, opts: CompileOptions, profile: ValidateProfile): number {
|
|
185
|
+
// The determinism check compares a second, independent compile.
|
|
186
|
+
const again = compile(opts);
|
|
187
|
+
const report = validate({
|
|
188
|
+
skeletonText: result.skeletonText,
|
|
189
|
+
atlasText: result.atlasText,
|
|
190
|
+
atlasDir: opts.outDir,
|
|
191
|
+
declaredDurations: result.declaredDurations,
|
|
192
|
+
reEmit: { skeletonText: again.skeletonText, atlasText: again.atlasText },
|
|
193
|
+
rig: result.rig,
|
|
194
|
+
profile,
|
|
195
|
+
});
|
|
196
|
+
for (const line of reportLines(report)) console.log(line);
|
|
197
|
+
console.log(
|
|
198
|
+
` .. ${Object.entries(report.stats)
|
|
199
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
200
|
+
.join(' ')}`,
|
|
201
|
+
);
|
|
202
|
+
return report.failures.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cmdBuild(flags: Record<string, string>): void {
|
|
206
|
+
const { label, opts } = resolveCut(flags);
|
|
207
|
+
const profile = readProfile(flags);
|
|
208
|
+
console.log(`rigc build ${label}`);
|
|
209
|
+
const result = compile(opts);
|
|
210
|
+
|
|
211
|
+
console.log(` .. ${result.images.length} part page(s):`);
|
|
212
|
+
for (const img of result.images) {
|
|
213
|
+
console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${img.page}`);
|
|
214
|
+
}
|
|
215
|
+
for (const d of result.droppedStates) {
|
|
216
|
+
console.log(` DROP ${d.slot}/${d.state}: no PNG at ${d.path} (state not emitted)`);
|
|
217
|
+
}
|
|
218
|
+
// "The optional slots are optional" is a claim about this code path, so this
|
|
219
|
+
// code path says which ones it left out rather than being silently right.
|
|
220
|
+
for (const a of result.absentParts) {
|
|
221
|
+
console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
|
|
222
|
+
}
|
|
223
|
+
for (const m of result.meshes) {
|
|
224
|
+
console.log(
|
|
225
|
+
` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles ` +
|
|
226
|
+
`(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
for (const ph of result.physics) {
|
|
230
|
+
console.log(
|
|
231
|
+
` PHYS ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] ` +
|
|
232
|
+
`mix=${ph.mix}${ph.drivesMesh ? ' <- drives a mesh: its canvas re-rasterises while the spring settles' : ''}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
|
|
237
|
+
const failures = runGate(result, opts, profile);
|
|
238
|
+
if (failures > 0) {
|
|
239
|
+
console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
mkdirSync(opts.outDir, { recursive: true });
|
|
244
|
+
writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
|
|
245
|
+
writeFileSync(join(opts.outDir, 'skeleton.atlas'), result.atlasText);
|
|
246
|
+
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
|
|
247
|
+
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Where a pair of artifacts lives, given what the caller pointed at.
|
|
252
|
+
*
|
|
253
|
+
* Two shapes, because rigc's own output and a foreign export are named
|
|
254
|
+
* differently and both have to be gateable. rigc writes `skeleton.json` +
|
|
255
|
+
* `skeleton.atlas` into a directory. Everybody else writes whatever the editor
|
|
256
|
+
* called the project, and the official examples are not even consistent with
|
|
257
|
+
* themselves — `7-anticipation/export/` holds `sack-pro.json`, `spineboy/export/`
|
|
258
|
+
* holds two skeletons and two atlases.
|
|
259
|
+
*
|
|
260
|
+
* ⚠️ When more than one atlas sits beside the skeleton, this refuses to choose.
|
|
261
|
+
* Guessing by name would be wrong on the corpus that motivated it: `spineboy-ess`
|
|
262
|
+
* shares a longer prefix with `spineboy-run.atlas` than with the `spineboy.atlas`
|
|
263
|
+
* it actually uses, so the plausible heuristic picks the wrong file and every
|
|
264
|
+
* attachment then resolves against the wrong pixels — silently, which is the
|
|
265
|
+
* exact failure mode this tool exists to remove.
|
|
266
|
+
*/
|
|
267
|
+
function resolveArtifacts(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string } {
|
|
268
|
+
const abs = resolve(target);
|
|
269
|
+
if (!existsSync(abs)) throw new UsageError(`nothing at ${abs}`);
|
|
270
|
+
if (statSync(abs).isDirectory()) {
|
|
271
|
+
return {
|
|
272
|
+
skeletonPath: join(abs, 'skeleton.json'),
|
|
273
|
+
atlasPath: atlasFlag ? resolve(atlasFlag) : join(abs, 'skeleton.atlas'),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (!abs.endsWith('.json')) throw new UsageError(`${abs} is neither a directory nor a .json skeleton`);
|
|
277
|
+
if (atlasFlag) return { skeletonPath: abs, atlasPath: resolve(atlasFlag) };
|
|
278
|
+
const dir = dirname(abs);
|
|
279
|
+
const atlases = readdirSync(dir)
|
|
280
|
+
.filter((f) => f.endsWith('.atlas'))
|
|
281
|
+
.sort();
|
|
282
|
+
if (atlases.length === 1) return { skeletonPath: abs, atlasPath: join(dir, atlases[0]) };
|
|
283
|
+
if (atlases.length === 0) throw new UsageError(`no .atlas beside ${abs}; name one with --atlas <path>`);
|
|
284
|
+
throw new UsageError(
|
|
285
|
+
`${atlases.length} atlases beside ${abs} (${atlases.join(', ')}); name the right one with --atlas <path> ` +
|
|
286
|
+
'— guessing by filename is how an attachment quietly resolves against the wrong page',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function cmdValidate(flags: Record<string, string>, positional: string[]): void {
|
|
291
|
+
// A bare directory validates what is on disk. Naming the cut as well lets the
|
|
292
|
+
// gate re-derive the declared durations and the structural expectations, which
|
|
293
|
+
// a directory alone cannot supply — and the report says which it had.
|
|
294
|
+
const named = flags.cut !== undefined || flags.rig !== undefined;
|
|
295
|
+
const profile = readProfile(flags);
|
|
296
|
+
const derivedOpts = named ? resolveCut(flags).opts : null;
|
|
297
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas);
|
|
298
|
+
console.log(`rigc validate ${skeletonPath}`);
|
|
299
|
+
console.log(` .. atlas ${atlasPath}`);
|
|
300
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
301
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
302
|
+
const derived = derivedOpts ? compile(derivedOpts) : null;
|
|
303
|
+
|
|
304
|
+
const report = validate({
|
|
305
|
+
skeletonText,
|
|
306
|
+
atlasText,
|
|
307
|
+
atlasDir: dirname(atlasPath),
|
|
308
|
+
declaredDurations: derived?.declaredDurations,
|
|
309
|
+
rig: derived?.rig,
|
|
310
|
+
profile,
|
|
311
|
+
});
|
|
312
|
+
for (const line of reportLines(report)) console.log(line);
|
|
313
|
+
if (report.failures.length > 0) {
|
|
314
|
+
console.error(`rigc: ${report.failures.length} assertion(s) failed`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
console.log('rigc: green');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function cmdDiff(flags: Record<string, string>, positional: string[]): void {
|
|
321
|
+
const [candidate, reference] = positional;
|
|
322
|
+
if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
|
|
323
|
+
const candidatePath = resolve(candidate);
|
|
324
|
+
const referencePath = resolve(reference);
|
|
325
|
+
for (const path of [candidatePath, referencePath]) {
|
|
326
|
+
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
327
|
+
}
|
|
328
|
+
const report = diffSkeletons(
|
|
329
|
+
JSON.parse(readFileSync(candidatePath, 'utf8')),
|
|
330
|
+
JSON.parse(readFileSync(referencePath, 'utf8')),
|
|
331
|
+
);
|
|
332
|
+
console.log('rigc diff');
|
|
333
|
+
for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
|
|
334
|
+
if (flags.json !== undefined) {
|
|
335
|
+
// ⚠️ `...report` carries its OWN `candidate` and `reference` — the raw
|
|
336
|
+
// per-side counts. Spelling the paths under those two names put them on
|
|
337
|
+
// the losing side of the spread, so the written report named neither file
|
|
338
|
+
// it compared. The paths get their own keys.
|
|
339
|
+
writeJson(flags.json, { candidatePath, referencePath, ...report });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* check — how close does the candidate LOOK to the reference frames?
|
|
345
|
+
*
|
|
346
|
+
* ⭐ The gate cannot see a wrong animation. It parses, it steps, it refuses the
|
|
347
|
+
* degenerate — and a rig whose easings are all reversed passes it green, which is
|
|
348
|
+
* not a hypothetical: ladder rung 1's first honest run shipped exactly that build
|
|
349
|
+
* and the validator was structurally incapable of noticing. `diff` cannot see it
|
|
350
|
+
* either, because it compares structure and a reversed curve is the same curve
|
|
351
|
+
* count. The only thing that can is a picture, so this renders the candidate into
|
|
352
|
+
* the reference's own frame and compares pixels.
|
|
353
|
+
*
|
|
354
|
+
* 🔒 It never reads the reference skeleton — see `src/check.ts`. That is what
|
|
355
|
+
* keeps it usable **inside** an authoring loop rather than at the finish line the
|
|
356
|
+
* way `bench` is: an author may run it as often as they like without their run
|
|
357
|
+
* stopping being an authoring run.
|
|
358
|
+
*
|
|
359
|
+
* There is no pass mark, for the same reason `diff` has none.
|
|
360
|
+
*/
|
|
361
|
+
function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps' | 'viewport' | 'as'> {
|
|
362
|
+
const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as'> = {};
|
|
363
|
+
if (flags.fps !== undefined) {
|
|
364
|
+
const fps = Number(flags.fps);
|
|
365
|
+
if (!Number.isFinite(fps) || fps <= 0) throw new UsageError('--fps must be a positive number');
|
|
366
|
+
out.fps = fps;
|
|
367
|
+
}
|
|
368
|
+
if (flags.viewport !== undefined) {
|
|
369
|
+
const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
|
|
370
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
371
|
+
throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
|
|
372
|
+
}
|
|
373
|
+
if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
|
|
374
|
+
out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
375
|
+
}
|
|
376
|
+
if (flags.as !== undefined) out.as = flags.as;
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function runCheck(candidate: string, atlasFlag: string | undefined, framesDir: string, flags: Record<string, string>): CheckReport {
|
|
381
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
|
|
382
|
+
return checkAgainstFrames({
|
|
383
|
+
skeletonText: readFileSync(skeletonPath, 'utf8'),
|
|
384
|
+
atlasText: readFileSync(atlasPath, 'utf8'),
|
|
385
|
+
atlasDir: dirname(atlasPath),
|
|
386
|
+
framesDir,
|
|
387
|
+
labels: { skeleton: skeletonPath, atlas: atlasPath },
|
|
388
|
+
...readCheckFlags(flags),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function cmdCheck(flags: Record<string, string>): void {
|
|
393
|
+
if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
|
|
394
|
+
if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
|
|
395
|
+
const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
|
|
396
|
+
console.log('rigc check');
|
|
397
|
+
for (const line of checkLines(report, { allFrames: flags['all-frames'] !== undefined })) console.log(line);
|
|
398
|
+
if (flags.json !== undefined) writeJson(flags.json, report);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function writeJson(target: string, body: unknown): void {
|
|
402
|
+
const out = resolve(target);
|
|
403
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
404
|
+
writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
|
|
405
|
+
console.log(`rigc: wrote ${out}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* bench — run one rung of the benchmark ladder against a candidate rig.
|
|
410
|
+
*
|
|
411
|
+
* Two questions, asked in this order and never merged:
|
|
412
|
+
*
|
|
413
|
+
* 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
|
|
414
|
+
* and it is the only part with a pass/fail. `spine-html` is not the default
|
|
415
|
+
* here (it is everywhere else): the thing being reproduced is an editor
|
|
416
|
+
* export, and holding it to this project's renderer policy would fail rungs
|
|
417
|
+
* for reasons the rung is not about.
|
|
418
|
+
* 2. How close is it, structurally, to the reference? That is `diff`, and it
|
|
419
|
+
* has no threshold at all. There is no score to pass, on purpose — see
|
|
420
|
+
* `src/diff.ts`. A rung is called cleared by a human reading the measures,
|
|
421
|
+
* and `docs/LADDER.md` records that judgement.
|
|
422
|
+
*
|
|
423
|
+
* ⚠️ The candidate is validated against the SPINE profile and compared against
|
|
424
|
+
* the reference; the reference is never validated here. It is editor output and
|
|
425
|
+
* is the definition of correct for this exercise, so gating it would be gating
|
|
426
|
+
* the yardstick with the ruler.
|
|
427
|
+
*/
|
|
428
|
+
function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
429
|
+
const rungId = positional[0];
|
|
430
|
+
if (!rungId) throw new UsageError(`bench takes a rung: ${RUNG_IDS.join(' | ')}`);
|
|
431
|
+
const rung = findRung(rungId);
|
|
432
|
+
if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
|
|
433
|
+
if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
|
|
434
|
+
|
|
435
|
+
// bench judges a reproduction of editor output, so `spine` is the default.
|
|
436
|
+
const profile = flags.profile === undefined ? 'spine' : readProfile(flags);
|
|
437
|
+
const exportDir = resolve(import.meta.dir, 'examples', rung.example, 'export');
|
|
438
|
+
if (!existsSync(exportDir)) {
|
|
439
|
+
throw new UsageError(
|
|
440
|
+
`no example corpus at ${exportDir} — run \`bun run fetch-examples\` first (examples/ is gitignored, not shipped)`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
|
|
445
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
446
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
447
|
+
|
|
448
|
+
console.log(`rigc bench rung ${rung.id} — ${rung.example}`);
|
|
449
|
+
console.log(` gates ${rung.gates}`);
|
|
450
|
+
console.log(` candidate ${skeletonPath}`);
|
|
451
|
+
console.log(` atlas ${atlasPath}`);
|
|
452
|
+
console.log('');
|
|
453
|
+
|
|
454
|
+
console.log(` ── validate (profile ${profile}) ──`);
|
|
455
|
+
const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), profile });
|
|
456
|
+
for (const line of reportLines(report)) console.log(` ${line}`);
|
|
457
|
+
console.log('');
|
|
458
|
+
|
|
459
|
+
const candidateJson: unknown = JSON.parse(skeletonText);
|
|
460
|
+
const diffs: Array<{ skeleton: RungSkeleton; reference: string; report: DiffReport }> = [];
|
|
461
|
+
for (const skeleton of rung.skeletons) {
|
|
462
|
+
const referencePath = join(exportDir, skeleton.file);
|
|
463
|
+
if (!existsSync(referencePath)) {
|
|
464
|
+
console.error(` MISSING ${referencePath} — re-run \`bun run fetch-examples\``);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const role = skeleton.role === 'stretch' ? ' (stretch — reported, does not count)' : '';
|
|
468
|
+
console.log(` ── diff vs ${rung.example}/${skeleton.label}${role} ──`);
|
|
469
|
+
const diff = diffSkeletons(candidateJson, JSON.parse(readFileSync(referencePath, 'utf8')));
|
|
470
|
+
for (const line of diffLines(diff, { candidate: skeletonPath, reference: referencePath })) console.log(` ${line}`);
|
|
471
|
+
console.log('');
|
|
472
|
+
diffs.push({ skeleton, reference: referencePath, report: diff });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Third, optional and third for a reason: is it the same MOTION? `diff`
|
|
476
|
+
// compares structure, and a reversed easing is the same key count and the same
|
|
477
|
+
// curve kind — so a row of this ladder carrying only `validate` and `diff`
|
|
478
|
+
// records a rig that could be animated backwards. `--frames` folds `check`'s
|
|
479
|
+
// table into the report so a future row carries both.
|
|
480
|
+
let check: CheckReport | null = null;
|
|
481
|
+
if (flags.frames !== undefined) {
|
|
482
|
+
console.log(` ── check vs frames ${resolve(flags.frames)} ──`);
|
|
483
|
+
check = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
|
|
484
|
+
for (const line of checkLines(check, { allFrames: flags['all-frames'] !== undefined })) console.log(` ${line}`);
|
|
485
|
+
console.log('');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
console.log(' ── summary ──');
|
|
489
|
+
console.log(` validate ${report.failures.length === 0 ? 'green' : `${report.failures.length} FAILED`} (profile ${profile})`);
|
|
490
|
+
for (const d of diffs) {
|
|
491
|
+
const means = d.report.sections.map((s) => `${s.name}=${s.ratio.toFixed(3)}`).join(' ');
|
|
492
|
+
console.log(` ${d.skeleton.label.padEnd(10)} ${means}${d.skeleton.role === 'stretch' ? ' [stretch]' : ''}`);
|
|
493
|
+
// Second line, not folded into the first: the figures above are the ones
|
|
494
|
+
// every bench.json on disk already carries, and a ladder record is worth
|
|
495
|
+
// less the moment its headline stops meaning what the older ones meant.
|
|
496
|
+
// The sections whose measures are dominated by name-keyed ones get their
|
|
497
|
+
// name-agnostic figure printed beside — issue #21.
|
|
498
|
+
const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
|
|
499
|
+
if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
|
|
500
|
+
}
|
|
501
|
+
if (check) {
|
|
502
|
+
// The framing goes first because it is upstream of every MAE below it: a
|
|
503
|
+
// summary that reported those numbers without saying how the two shots were
|
|
504
|
+
// put on each other is how issue #34 stayed invisible for two ladder runs.
|
|
505
|
+
const framing = check.framingFit;
|
|
506
|
+
if (framing) {
|
|
507
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
508
|
+
const how = !framing.applied
|
|
509
|
+
? 'measured only — --viewport pinned'
|
|
510
|
+
: framing.source === 'declared'
|
|
511
|
+
? `frames.json's own box, the candidate measured into it`
|
|
512
|
+
: `fitted to the candidate's pixels, ${framing.passes} pass(es)${framing.settled ? '' : framing.cycled ? ', cycling' : ', unsettled'}`;
|
|
513
|
+
console.log(
|
|
514
|
+
` framing fit x${framing.fit.scale.toFixed(6)} rms ${framing.fit.rms.toFixed(2)}px union residual ` +
|
|
515
|
+
`${signed(framing.fit.residualWidth)} x ${signed(framing.fit.residualHeight)}px (${how})`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
for (const anim of check.animations) {
|
|
519
|
+
const attributed = anim.compared - anim.framesWithoutDrift;
|
|
520
|
+
const drift =
|
|
521
|
+
anim.worstDriftFrame < 0
|
|
522
|
+
? 'no slot attributable in any of them'
|
|
523
|
+
: `worst slot drift ${anim.worstDrift.toFixed(1)}px, attributed in ${attributed}`;
|
|
524
|
+
// The per-frame change count is carried here and not only in `check`'s own
|
|
525
|
+
// table because it is the one figure a flat MAE cannot imply: a shot can be
|
|
526
|
+
// right at every frame and still hold or blink at the wrong moments.
|
|
527
|
+
const change =
|
|
528
|
+
anim.changeDisagreements === 0
|
|
529
|
+
? ''
|
|
530
|
+
: `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
|
|
531
|
+
console.log(
|
|
532
|
+
` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
|
|
533
|
+
`over ${anim.compared} frame(s) ${drift}${change}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
|
|
538
|
+
console.log(' Without it this report says nothing about whether the ANIMATION is right.');
|
|
539
|
+
}
|
|
540
|
+
console.log(' Section figures are means of their own measures. There is no rung score:');
|
|
541
|
+
console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
|
|
542
|
+
|
|
543
|
+
if (flags.json !== undefined) {
|
|
544
|
+
writeJson(flags.json, {
|
|
545
|
+
rung: rung.id,
|
|
546
|
+
example: rung.example,
|
|
547
|
+
gates: rung.gates,
|
|
548
|
+
profile,
|
|
549
|
+
candidate: { skeleton: skeletonPath, atlas: atlasPath },
|
|
550
|
+
validate: report,
|
|
551
|
+
// `referencePath`, not `reference`: a DiffReport already has a
|
|
552
|
+
// `reference` of its own (the raw counts), and the spread wins.
|
|
553
|
+
diffs: diffs.map((d) => ({
|
|
554
|
+
label: d.skeleton.label,
|
|
555
|
+
role: d.skeleton.role,
|
|
556
|
+
referencePath: d.reference,
|
|
557
|
+
...d.report,
|
|
558
|
+
})),
|
|
559
|
+
check,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (report.failures.length > 0) {
|
|
564
|
+
console.error(`rigc: candidate is not valid Spine — ${report.failures.length} assertion(s) failed`);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function cmdExplain(flags: Record<string, string>): void {
|
|
570
|
+
const { label, opts } = resolveCut(flags);
|
|
571
|
+
const result = compile(opts);
|
|
572
|
+
const motion = JSON.parse(readFileSync(opts.motionPath, 'utf8')) as MotionSpec;
|
|
573
|
+
|
|
574
|
+
console.log(`rigc explain ${label}`);
|
|
575
|
+
console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
|
|
576
|
+
|
|
577
|
+
// The crop note describes where the numbers CAME from, and without a manifest
|
|
578
|
+
// they came from the rig spec's own literals — there is no crop to be relative
|
|
579
|
+
// to. Printing it anyway told a rung-3 author their bone positions were in a
|
|
580
|
+
// coordinate system that did not exist in their rig.
|
|
581
|
+
const frame = opts.manifestPath ? ' (crop y-down -> spine y-up, origin at the bottom-left of the crop)' : ' (spine world: y up)';
|
|
582
|
+
console.log(`\nbones${frame}`);
|
|
583
|
+
for (const b of result.skeleton.bones) {
|
|
584
|
+
// `rotation` is the axis keystone and the grips' radial facing, so it earns
|
|
585
|
+
// a column even though it is absent on most bones.
|
|
586
|
+
const rot = b.rotation === undefined ? '' : ` rotation=${b.rotation}`;
|
|
587
|
+
console.log(` ${b.name.padEnd(12)} parent=${(b.parent ?? '-').padEnd(10)} x=${b.x ?? 0} y=${b.y ?? 0}${rot}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
console.log('\nslots (array order IS the draw order)');
|
|
591
|
+
for (const s of result.skeleton.slots) {
|
|
592
|
+
const atts = Object.keys(result.skeleton.skins[0].attachments[s.name] ?? {});
|
|
593
|
+
console.log(
|
|
594
|
+
` ${s.name.padEnd(12)} bone=${s.bone.padEnd(12)} setup=${(s.attachment ?? 'null').padEnd(22)} color=${s.color ?? 'ffffffff'} attachments=[${atts.join(', ')}]`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
console.log('\nanimations');
|
|
599
|
+
for (const [animName, anim] of Object.entries(result.skeleton.animations)) {
|
|
600
|
+
const spec = motion.animations[animName];
|
|
601
|
+
console.log(` ${animName} declared=${spec.duration}s loop=${spec.loop}`);
|
|
602
|
+
for (const [boneName, timelines] of Object.entries(anim.bones ?? {})) {
|
|
603
|
+
// "(mesh tier)" is a claim about what the bone DRIVES, and it was printed
|
|
604
|
+
// on every bone track regardless — which reads, on a rig with no mesh in
|
|
605
|
+
// it at all, as though the track were deforming one.
|
|
606
|
+
const drives = result.meshBones.includes(boneName) ? ' <- drives a mesh' : '';
|
|
607
|
+
for (const [timelineName, keys] of Object.entries(timelines)) {
|
|
608
|
+
console.log(` ${boneName}.${timelineName} ${keys.length} key(s)${drives}`);
|
|
609
|
+
for (const key of keys) {
|
|
610
|
+
const fields = Object.entries(key)
|
|
611
|
+
.filter(([k]) => k !== 'time' && k !== 'curve')
|
|
612
|
+
.map(([k, v]) => `${k}=${String(v)}`)
|
|
613
|
+
.join(' ');
|
|
614
|
+
const curve = Array.isArray(key.curve)
|
|
615
|
+
? `bezier[${key.curve.length}]`
|
|
616
|
+
: key.curve === 'stepped'
|
|
617
|
+
? 'stepped'
|
|
618
|
+
: 'linear';
|
|
619
|
+
console.log(` t=${String(key.time).padEnd(7)} ${fields.padEnd(30)} ${curve}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
for (const [slotName, timelines] of Object.entries(anim.slots ?? {})) {
|
|
624
|
+
for (const [timelineName, keys] of Object.entries(timelines)) {
|
|
625
|
+
console.log(` ${slotName}.${timelineName} ${keys.length} key(s)`);
|
|
626
|
+
for (const key of keys) {
|
|
627
|
+
const curve = key.curve;
|
|
628
|
+
const shape = Array.isArray(curve)
|
|
629
|
+
? `bezier[${curve.length}] ${curve.slice(12).join(', ')} <- alpha channel, absolute (t,v)`
|
|
630
|
+
: curve === 'stepped'
|
|
631
|
+
? 'stepped'
|
|
632
|
+
: timelineName === 'attachment'
|
|
633
|
+
? 'stepped (attachment timelines always are)'
|
|
634
|
+
: 'linear';
|
|
635
|
+
const value = 'color' in key ? `#${String(key.color)}` : `attachment=${String(key.name)}`;
|
|
636
|
+
console.log(` t=${String(key.time).padEnd(7)} ${value.padEnd(30)} ${shape}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// The draw-order timeline names no target, so it hangs off the animation
|
|
641
|
+
// rather than off a slot — and a timeline `explain` did not print would be a
|
|
642
|
+
// timeline nobody could check without reading the emitted JSON.
|
|
643
|
+
if (anim.drawOrder) {
|
|
644
|
+
console.log(` drawOrder ${anim.drawOrder.length} key(s) <- whole animation, offsets against the SETUP order`);
|
|
645
|
+
for (const key of anim.drawOrder) {
|
|
646
|
+
const offsets = Array.isArray(key.offsets)
|
|
647
|
+
? (key.offsets as Array<{ slot: string; offset: number }>)
|
|
648
|
+
.map((o) => `${o.slot}${o.offset >= 0 ? '+' : ''}${o.offset}`)
|
|
649
|
+
.join(' ')
|
|
650
|
+
: 'back to the setup order';
|
|
651
|
+
console.log(` t=${String(key.time).padEnd(7)} ${offsets}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (result.physics.length) {
|
|
657
|
+
console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
|
|
658
|
+
for (const ph of result.physics) {
|
|
659
|
+
console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (result.meshes.length) {
|
|
664
|
+
console.log('\nmeshes (ring tier: rim ring pinned on the window edge, seam ring pinned on the mask contour)');
|
|
665
|
+
for (const m of result.meshes) {
|
|
666
|
+
console.log(
|
|
667
|
+
` ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (result.droppedStates.length) {
|
|
673
|
+
console.log('\ndropped states (listed in the manifest, no PNG on disk)');
|
|
674
|
+
for (const d of result.droppedStates) console.log(` ${d.slot}/${d.state} ${d.path}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
console.log('\nmix table (player config, not skeleton JSON)');
|
|
678
|
+
console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const USAGE = [
|
|
682
|
+
'usage:',
|
|
683
|
+
' bun cli.ts build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>]',
|
|
684
|
+
' bun cli.ts build --cut <name> --cuts <cuts.json>',
|
|
685
|
+
' bun cli.ts explain (same arguments as build)',
|
|
686
|
+
' bun cli.ts validate <dir | skeleton.json> [--atlas <path>]',
|
|
687
|
+
' bun cli.ts diff <candidate.json> <reference.json> [--json <out>]',
|
|
688
|
+
' bun cli.ts check --candidate <dir | skeleton.json> --frames <dir>',
|
|
689
|
+
` bun cli.ts bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>]`,
|
|
690
|
+
'',
|
|
691
|
+
'build and validate take --profile spine|spine-html (default spine-html):',
|
|
692
|
+
' spine is this valid Spine 4.3 that any runtime plays correctly?',
|
|
693
|
+
' spine-html the above, plus this project\'s renderer and archetype policy.',
|
|
694
|
+
'',
|
|
695
|
+
'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
|
|
696
|
+
'there by its own drawn pixels, and compares. It reads the frames and never the',
|
|
697
|
+
'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
|
|
698
|
+
'see a wrong animation and this can:',
|
|
699
|
+
' --frames <dir> a rendered frame set (a skeleton root, or one animation dir)',
|
|
700
|
+
' --atlas <path> the candidate\'s atlas, when it is not beside the skeleton',
|
|
701
|
+
' --fps <n> only for a frame set with no frames.json sidecar',
|
|
702
|
+
' --viewport x,y,w,h pin the candidate\'s world box, y up, instead of fitting it',
|
|
703
|
+
' --as <name> the candidate animation to play, when it is named differently',
|
|
704
|
+
' --all-frames print every frame, not just the worst by MAE',
|
|
705
|
+
' --json <out> the whole per-frame, per-slot report',
|
|
706
|
+
'',
|
|
707
|
+
'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
|
|
708
|
+
' "manifest": "..." (optional) } }, with every path',
|
|
709
|
+
'resolved relative to the cuts.json file itself.',
|
|
710
|
+
].join('\n');
|
|
711
|
+
|
|
712
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
713
|
+
try {
|
|
714
|
+
const { flags, positional } = parseArgs(rest);
|
|
715
|
+
if (command === 'build') cmdBuild(flags);
|
|
716
|
+
else if (command === 'validate') cmdValidate(flags, positional);
|
|
717
|
+
else if (command === 'explain') cmdExplain(flags);
|
|
718
|
+
else if (command === 'diff') cmdDiff(flags, positional);
|
|
719
|
+
else if (command === 'check') cmdCheck(flags);
|
|
720
|
+
else if (command === 'bench') cmdBench(flags, positional);
|
|
721
|
+
else {
|
|
722
|
+
console.error(USAGE);
|
|
723
|
+
process.exit(2);
|
|
724
|
+
}
|
|
725
|
+
} catch (err) {
|
|
726
|
+
if (err instanceof UsageError) {
|
|
727
|
+
console.error(`rigc: ${err.message}\n\n${USAGE}`);
|
|
728
|
+
process.exit(2);
|
|
729
|
+
}
|
|
730
|
+
if (err instanceof CompileError) {
|
|
731
|
+
console.error(`rigc compile error: ${err.message}`);
|
|
732
|
+
process.exit(1);
|
|
733
|
+
}
|
|
734
|
+
if (err instanceof CheckError) {
|
|
735
|
+
console.error(`rigc check error: ${err.message}`);
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
throw err;
|
|
739
|
+
}
|