@magicpixelart/cli 0.6.4 → 0.6.5
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/CHANGELOG.md +6 -0
- package/dist/api.js +17 -6
- package/dist/commands/spine.js +36 -9
- package/dist/util/spine.js +34 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.5] - 2026-10-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Spine rigs bind to the folder named like them (`bubble.json` → `bubble/`, `glyph_heart.json` → `heart/`) before falling back to matching part names, which are too generic for props (`bubble` had bound to `Environment/`, `mushrooms` to `turret/`).
|
|
14
|
+
- A rig an earlier upload bound to a folder that no rig binds to now is taken off that folder, so one object's rig no longer shows on another's parts. `spine --dry-run` lists these.
|
|
15
|
+
|
|
10
16
|
## [0.6.4] - 2026-10-17
|
|
11
17
|
|
|
12
18
|
### Fixed
|
package/dist/api.js
CHANGED
|
@@ -463,26 +463,37 @@ export function resolveSpineEndpoint() {
|
|
|
463
463
|
const override = process.env.MAGICPIXEL_SPINE_ENDPOINT?.trim();
|
|
464
464
|
return override ? validateEndpointUrl(override) : DEFAULT_SPINE_ENDPOINT;
|
|
465
465
|
}
|
|
466
|
-
/**
|
|
467
|
-
|
|
466
|
+
/** POST to the spine endpoint; returns the parsed JSON body. */
|
|
467
|
+
async function postSpine(body, retryOpts) {
|
|
468
468
|
const url = resolveSpineEndpoint();
|
|
469
469
|
return retryTransient('spine', async () => {
|
|
470
470
|
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
471
|
-
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify(
|
|
471
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify(body) }, { timeoutMs: ASSET_TIMEOUT_MS });
|
|
472
472
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
473
473
|
if (res.ok) {
|
|
474
474
|
const data = (await res.json());
|
|
475
|
-
|
|
476
|
-
if (!Array.isArray(results)) {
|
|
475
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.results)) {
|
|
477
476
|
throw new ApiError(502, withRequestId('spine: unexpected server response shape (results missing).', serverRequestId), serverRequestId);
|
|
478
477
|
}
|
|
479
|
-
return
|
|
478
|
+
return data;
|
|
480
479
|
}
|
|
481
480
|
const bodyText = await res.text();
|
|
482
481
|
const errorCode = errorCodeFromResponse(res);
|
|
483
482
|
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, 'spine', errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
484
483
|
}, retryOpts);
|
|
485
484
|
}
|
|
485
|
+
/** Upload up to 10 rigs; the server attaches each to its library folder. */
|
|
486
|
+
export async function pushSpineRigs(rigs, retryOpts) {
|
|
487
|
+
return (await postSpine({ rigs }, retryOpts)).results;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Take this CLI's rigs off folders it no longer binds them to. Returns the
|
|
491
|
+
* folder paths (`a/b`) the server cleared; an older server clears none.
|
|
492
|
+
*/
|
|
493
|
+
export async function removeSpineRigs(folderPaths, retryOpts) {
|
|
494
|
+
const data = await postSpine({ rigs: [], remove: folderPaths }, retryOpts);
|
|
495
|
+
return Array.isArray(data.removed) ? data.removed.filter((p) => typeof p === 'string') : [];
|
|
496
|
+
}
|
|
486
497
|
// ---------- folder resync (local folder is the source of truth) -------------
|
|
487
498
|
export function resolveResyncEndpoint() {
|
|
488
499
|
return resolveIngestEndpoint().replace(/\/ingest$/, '/resync');
|
package/dist/commands/spine.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
10
10
|
import { resolve } from 'node:path';
|
|
11
11
|
import kleur from 'kleur';
|
|
12
12
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
13
|
-
import { ApiError, pushSpineRigs } from '../api.js';
|
|
13
|
+
import { ApiError, pushSpineRigs, removeSpineRigs } from '../api.js';
|
|
14
14
|
import { detectProjectKind, isEngineKind, readWorkspaceMembers } from '../util/framework.js';
|
|
15
15
|
import { indexGamePngs, matchConnectGlobs } from '../util/gameScan.js';
|
|
16
16
|
import { batchRigs, describeRig, detectRigs, findSkeletonFiles, rigsToUpload, rigVersionSupported, SUPPORTED_SPINE_VERSION, syncedPngsFromEntries, } from '../util/spine.js';
|
|
@@ -25,7 +25,7 @@ export function skeletonRoots(cwd) {
|
|
|
25
25
|
}
|
|
26
26
|
export async function runSpineRigs(opts = {}) {
|
|
27
27
|
const cwd = opts.cwd ?? process.cwd();
|
|
28
|
-
const empty = { skeletons: 0, rigs: [], skippedFiles: [] };
|
|
28
|
+
const empty = { skeletons: 0, rigs: [], skippedFiles: [], removed: [] };
|
|
29
29
|
const config = opts.config ?? (await loadConfig(cwd));
|
|
30
30
|
const kind = await detectProjectKind(cwd);
|
|
31
31
|
if (!isEngineKind(kind))
|
|
@@ -52,9 +52,9 @@ export async function runSpineRigs(opts = {}) {
|
|
|
52
52
|
pending.delete(rig);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
const record = {};
|
|
55
56
|
if (!opts.dryRun && pending.size > 0) {
|
|
56
57
|
const upload = opts.upload ?? pushSpineRigs;
|
|
57
|
-
const record = {};
|
|
58
58
|
for (const batch of batchRigs([...pending])) {
|
|
59
59
|
try {
|
|
60
60
|
const payload = await Promise.all(batch.map(async (r) => ({
|
|
@@ -82,14 +82,38 @@ export async function runSpineRigs(opts = {}) {
|
|
|
82
82
|
outcomes.set(rig, { rig, status: 'error', error: message });
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
}
|
|
86
|
+
// Folders an earlier upload bound a rig to that no rig binds to now (an
|
|
87
|
+
// export was deleted, or better matching moved it): take those rigs off, so
|
|
88
|
+
// the preview doesn't show one object's rig on another's parts. Skipped when
|
|
89
|
+
// nothing was detected at all, which is more likely a broken scan.
|
|
90
|
+
const bound = new Set(rigs.map((r) => r.folderPath.join('/')));
|
|
91
|
+
const stale = rigs.length ? Object.keys(state.spineRigs ?? {}).filter((k) => !bound.has(k)) : [];
|
|
92
|
+
let removed = [];
|
|
93
|
+
if (!opts.dryRun && stale.length > 0) {
|
|
94
|
+
try {
|
|
95
|
+
removed = await (opts.remove ?? removeSpineRigs)(stale.map((k) => k.split('/')));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* tried again next run */
|
|
90
99
|
}
|
|
91
100
|
}
|
|
92
|
-
|
|
101
|
+
if (Object.keys(record).length > 0 || removed.length > 0) {
|
|
102
|
+
// Re-read: the push that just ran saved its own state.
|
|
103
|
+
const fresh = await loadState(cwd);
|
|
104
|
+
const next = { ...(fresh.spineRigs ?? {}), ...record };
|
|
105
|
+
for (const k of removed)
|
|
106
|
+
if (!bound.has(k))
|
|
107
|
+
delete next[k];
|
|
108
|
+
fresh.spineRigs = next;
|
|
109
|
+
await saveState(fresh, cwd);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
skeletons: found.skeletons.length,
|
|
113
|
+
rigs: rigs.map((r) => outcomes.get(r)),
|
|
114
|
+
skippedFiles: found.skipped,
|
|
115
|
+
removed: opts.dryRun ? stale : removed,
|
|
116
|
+
};
|
|
93
117
|
}
|
|
94
118
|
function spineErrorMessage(e) {
|
|
95
119
|
if (e instanceof ApiError && e.status === 404)
|
|
@@ -188,6 +212,9 @@ export async function spineCommand(opts = {}) {
|
|
|
188
212
|
}
|
|
189
213
|
for (const o of outcome.rigs)
|
|
190
214
|
console.log(formatSpineOutcome(o));
|
|
215
|
+
for (const k of outcome.removed) {
|
|
216
|
+
console.log(`${icon(opts.dryRun ? 'ghost' : 'ok')} ${kleur.dim(`${opts.dryRun ? 'Would take' : 'Took'} the old Spine rig off ${k}/`)}`);
|
|
217
|
+
}
|
|
191
218
|
if (opts.dryRun) {
|
|
192
219
|
console.log();
|
|
193
220
|
console.log(ui.dryRun());
|
package/dist/util/spine.js
CHANGED
|
@@ -277,6 +277,18 @@ export function rigHash(rig) {
|
|
|
277
277
|
return h.digest('hex');
|
|
278
278
|
}
|
|
279
279
|
const label = (names) => `${names[names.length - 1]}/`;
|
|
280
|
+
/** Folder-name key: `container_item`, `Container Item` and `container-item` all read `containeritem`. */
|
|
281
|
+
const folderKey = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
282
|
+
/**
|
|
283
|
+
* Folder names a rig's parts are likely kept under, best first: the rig name,
|
|
284
|
+
* then the rig name past its first word (King Rabbit's `glyph_heart.json`
|
|
285
|
+
* draws the parts in `heart/`). Not the skeleton's directory: that is often
|
|
286
|
+
* generic (`base/`).
|
|
287
|
+
*/
|
|
288
|
+
export function rigFolderNames(rigName) {
|
|
289
|
+
const rest = rigName.replace(/^[^_\-. ]+[_\-. ]+/, '');
|
|
290
|
+
return [...new Set([folderKey(rigName), folderKey(rest)])].filter(Boolean);
|
|
291
|
+
}
|
|
280
292
|
/**
|
|
281
293
|
* Turns skeleton files + the synced PNGs into rigs bound to library folders.
|
|
282
294
|
* Rigs whose images match no synced folder are left out.
|
|
@@ -293,13 +305,29 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
293
305
|
const keys = new Set(files.flatMap((f) => f.images.map(imageKey)));
|
|
294
306
|
if (keys.size === 0)
|
|
295
307
|
continue;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
308
|
+
const name = rigName(files.map((f) => basename(f.abs)), basename(dirname(files[0].abs)));
|
|
309
|
+
// Skin folders: a folder named like the rig (or one directly below it)
|
|
310
|
+
// that holds any of its images, else folders mostly made of its images.
|
|
311
|
+
// Part names alone are too generic for props (`shadow`, `glow`, `base`):
|
|
312
|
+
// `bubble` would bind to `Environment/`, `mushrooms` to `turret/`.
|
|
313
|
+
const inFolders = (keep) => {
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const f of folders.values()) {
|
|
316
|
+
const hits = countIn(f.pngs, keys);
|
|
317
|
+
if (hits > 0 && keep(f, hits))
|
|
318
|
+
out.push({ f, hits });
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
};
|
|
322
|
+
let skins = [];
|
|
323
|
+
for (const key of rigFolderNames(name)) {
|
|
324
|
+
const named = (segments) => segments.length > 0 && folderKey(segments[segments.length - 1]) === key;
|
|
325
|
+
skins = inFolders((f) => named(f.segments) || named(f.segments.slice(0, -1)));
|
|
326
|
+
if (skins.length)
|
|
327
|
+
break;
|
|
302
328
|
}
|
|
329
|
+
if (skins.length === 0)
|
|
330
|
+
skins = inFolders((f, hits) => hits >= SKIN_SHARE * f.pngs.size);
|
|
303
331
|
if (skins.length === 0)
|
|
304
332
|
continue;
|
|
305
333
|
// Several skin folders side by side → bind to their parent (`rabbit/`).
|
|
@@ -341,7 +369,6 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
341
369
|
}
|
|
342
370
|
if (baseFolder && baseFolder.hits < COVER_SHARE * keys.size)
|
|
343
371
|
baseFolder = null;
|
|
344
|
-
const name = rigName(files.map((f) => basename(f.abs)), basename(dirname(files[0].abs)));
|
|
345
372
|
const baseFolderPath = baseFolder?.f.segments ?? null;
|
|
346
373
|
rigs.push({
|
|
347
374
|
name,
|