@magicpixelart/cli 0.6.3 → 0.6.4
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 +8 -0
- package/dist/commands/spine.js +14 -2
- package/dist/util/spine.js +23 -45
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.4] - 2026-10-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Spine rigs in big games: every skeleton export is scanned (0.6.3 stopped at 200, which could miss the main character's rig).
|
|
14
|
+
- Only files named as views of one character (`rabbit_front`, `rabbit_side`, `rabbit_back`) become one rig; unrelated exports in the same folder no longer merge.
|
|
15
|
+
- When two rigs match the same folder, the one named like the folder wins (`rabbit` over a goat that reuses the rabbit's parts).
|
|
16
|
+
- Sync says when it finds Spine exports but no synced folder for them, instead of staying silent.
|
|
17
|
+
|
|
10
18
|
## [0.6.3] - 2026-10-17
|
|
11
19
|
|
|
12
20
|
### Added
|
package/dist/commands/spine.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* sent (hash remembered in `.magicpixel/state.json`).
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
9
10
|
import { resolve } from 'node:path';
|
|
10
11
|
import kleur from 'kleur';
|
|
11
12
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
@@ -56,13 +57,17 @@ export async function runSpineRigs(opts = {}) {
|
|
|
56
57
|
const record = {};
|
|
57
58
|
for (const batch of batchRigs([...pending])) {
|
|
58
59
|
try {
|
|
59
|
-
const
|
|
60
|
+
const payload = await Promise.all(batch.map(async (r) => ({
|
|
60
61
|
name: r.name,
|
|
61
62
|
folderPath: r.folderPath,
|
|
62
63
|
baseFolderPath: r.baseFolderPath,
|
|
63
|
-
views: r.views.map((v) => ({
|
|
64
|
+
views: await Promise.all(r.views.map(async (v) => ({
|
|
65
|
+
name: v.name,
|
|
66
|
+
json: JSON.parse(await readFile(v.file.abs, 'utf8')),
|
|
67
|
+
}))),
|
|
64
68
|
hash: r.hash,
|
|
65
69
|
})));
|
|
70
|
+
const results = await upload(payload);
|
|
66
71
|
batch.forEach((rig, i) => {
|
|
67
72
|
const res = results[i]?.name === rig.name ? results[i] : results.find((x) => x?.name === rig.name);
|
|
68
73
|
const status = res?.status ?? 'error';
|
|
@@ -119,6 +124,13 @@ export async function syncSpineRigsQuietly(opts) {
|
|
|
119
124
|
const outcome = await (opts.run ?? runSpineRigs)({ config: opts.config, gameIndex: opts.gameIndex, dryRun: opts.dryRun });
|
|
120
125
|
if (opts.quiet)
|
|
121
126
|
return outcome;
|
|
127
|
+
if (outcome.skeletons > 0 && outcome.rigs.length === 0 && !warnedThisProcess) {
|
|
128
|
+
// Say why no preview appears instead of staying silent.
|
|
129
|
+
warnedThisProcess = true;
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(kleur.dim(`${icon('tip')} Found ${outcome.skeletons} Spine skeleton export${outcome.skeletons === 1 ? '' : 's'}, but no synced folder holds their part PNGs. Run \`${'npx @magicpixelart/cli spine --dry-run'}\` for details.`));
|
|
132
|
+
return outcome;
|
|
133
|
+
}
|
|
122
134
|
const lines = outcome.rigs.filter((o) => {
|
|
123
135
|
if (opts.verbose)
|
|
124
136
|
return true;
|
package/dist/util/spine.js
CHANGED
|
@@ -28,7 +28,6 @@ export const MAX_RIGS_PER_REQUEST = 10;
|
|
|
28
28
|
/** Bytes read to decide whether a .json is a skeleton export. */
|
|
29
29
|
const HEAD_BYTES = 300;
|
|
30
30
|
/** Stop after this many skeletons; a game with more is not what this is for. */
|
|
31
|
-
const MAX_SKELETONS = 200;
|
|
32
31
|
/** A folder is a skin folder when at least this share of its PNGs are rig images. */
|
|
33
32
|
const SKIN_SHARE = 0.5;
|
|
34
33
|
/** Base folder / view grouping need at least this share of the rig's images. */
|
|
@@ -183,8 +182,6 @@ export async function findSkeletonFiles(roots, base, skipDirs = []) {
|
|
|
183
182
|
const skeletons = [];
|
|
184
183
|
const skipped = [];
|
|
185
184
|
await Promise.all(candidates.map((abs) => io(async () => {
|
|
186
|
-
if (skeletons.length >= MAX_SKELETONS)
|
|
187
|
-
return;
|
|
188
185
|
const rel = relative(base, abs).replace(/\\/g, '/');
|
|
189
186
|
try {
|
|
190
187
|
if (!looksLikeSkeleton(await readHead(abs)))
|
|
@@ -199,7 +196,8 @@ export async function findSkeletonFiles(roots, base, skipDirs = []) {
|
|
|
199
196
|
const info = readSkeletonImages(json);
|
|
200
197
|
if (!info)
|
|
201
198
|
return;
|
|
202
|
-
|
|
199
|
+
// Keep only what matching needs: a big game has hundreds of exports.
|
|
200
|
+
skeletons.push({ abs, rel, sha256: createHash('sha256').update(text).digest('hex'), size, ...info });
|
|
203
201
|
}
|
|
204
202
|
catch {
|
|
205
203
|
/* not JSON after all / unreadable — not a skeleton we can use */
|
|
@@ -250,45 +248,23 @@ function countIn(set, keys) {
|
|
|
250
248
|
function isInside(segments, root) {
|
|
251
249
|
return segments.length >= root.length && root.every((s, i) => segments[i] === s);
|
|
252
250
|
}
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
const byDir = new Map();
|
|
256
|
-
for (const s of skeletons) {
|
|
257
|
-
const d = dirname(s.abs);
|
|
258
|
-
byDir.set(d, [...(byDir.get(d) ?? []), s]);
|
|
259
|
-
}
|
|
260
|
-
const groups = [];
|
|
261
|
-
for (const files of byDir.values()) {
|
|
262
|
-
const keys = files.map((f) => new Set(f.images.map(imageKey)));
|
|
263
|
-
const parent = files.map((_, i) => i);
|
|
264
|
-
const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
|
|
265
|
-
for (let i = 0; i < files.length; i++) {
|
|
266
|
-
for (let j = i + 1; j < files.length; j++) {
|
|
267
|
-
if (sameRig(keys[i], keys[j], folders))
|
|
268
|
-
parent[find(i)] = find(j);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
const byRoot = new Map();
|
|
272
|
-
files.forEach((f, i) => byRoot.set(find(i), [...(byRoot.get(find(i)) ?? []), f]));
|
|
273
|
-
groups.push(...byRoot.values());
|
|
274
|
-
}
|
|
275
|
-
return groups;
|
|
276
|
-
}
|
|
251
|
+
/** Camera-facing suffixes that mark one view of a character (`rabbit_front.json`). */
|
|
252
|
+
const VIEW_SUFFIX = /[_-](front|side|back|north|south|east|west|left|right|up|down)$/i;
|
|
277
253
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
254
|
+
* Groups skeletons into rigs. Only files in one directory whose names differ
|
|
255
|
+
* by a view suffix (`rabbit_front`, `rabbit_side`, `rabbit_back`) are views of
|
|
256
|
+
* one character; every other export is a rig of its own, even when it shares
|
|
257
|
+
* a folder or part names with others (a `Spine/` folder of unrelated props).
|
|
281
258
|
*/
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return true;
|
|
259
|
+
export function groupSkeletons(skeletons) {
|
|
260
|
+
const groups = new Map();
|
|
261
|
+
for (const s of skeletons) {
|
|
262
|
+
const stem = basename(s.abs).replace(/\.json$/i, '');
|
|
263
|
+
const character = stem.replace(VIEW_SUFFIX, '');
|
|
264
|
+
const key = character !== stem ? `${dirname(s.abs)}\0${character.toLowerCase()}` : s.abs;
|
|
265
|
+
groups.set(key, [...(groups.get(key) ?? []), s]);
|
|
290
266
|
}
|
|
291
|
-
return
|
|
267
|
+
return [...groups.values()];
|
|
292
268
|
}
|
|
293
269
|
export function rigHash(rig) {
|
|
294
270
|
const h = createHash('sha256');
|
|
@@ -296,7 +272,7 @@ export function rigHash(rig) {
|
|
|
296
272
|
name: rig.name,
|
|
297
273
|
folderPath: rig.folderPath,
|
|
298
274
|
baseFolderPath: rig.baseFolderPath,
|
|
299
|
-
views: rig.views.map((v) => [v.name,
|
|
275
|
+
views: rig.views.map((v) => [v.name, v.sha256]),
|
|
300
276
|
}));
|
|
301
277
|
return h.digest('hex');
|
|
302
278
|
}
|
|
@@ -308,7 +284,7 @@ const label = (names) => `${names[names.length - 1]}/`;
|
|
|
308
284
|
export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
309
285
|
const folders = indexFolders(pngs);
|
|
310
286
|
const rigs = [];
|
|
311
|
-
for (const group of groupSkeletons(skeletons
|
|
287
|
+
for (const group of groupSkeletons(skeletons)) {
|
|
312
288
|
const files = [...group].sort((a, b) => a.abs.localeCompare(b.abs)).slice(0, MAX_VIEWS_PER_RIG);
|
|
313
289
|
const names = viewNames(files.map((f) => basename(f.abs)));
|
|
314
290
|
const views = files
|
|
@@ -377,7 +353,7 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
377
353
|
skins: skinCount,
|
|
378
354
|
baseFolderPath,
|
|
379
355
|
baseFolderLabel: baseFolder ? label(baseFolder.f.names) : null,
|
|
380
|
-
hash: rigHash({ name, folderPath, baseFolderPath, views: views.map((v) => ({ name: v.name,
|
|
356
|
+
hash: rigHash({ name, folderPath, baseFolderPath, views: views.map((v) => ({ name: v.name, sha256: v.file.sha256 })) }),
|
|
381
357
|
});
|
|
382
358
|
}
|
|
383
359
|
return onePerFolder(rigs).sort((a, b) => a.name.localeCompare(b.name) || a.folderPath.join('/').localeCompare(b.folderPath.join('/')));
|
|
@@ -385,11 +361,13 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
385
361
|
/**
|
|
386
362
|
* A folder holds one rig. When several exports bind to the same folder (an
|
|
387
363
|
* old 3.8 copy next to the 4.3 one), keep the one the preview can read, then
|
|
388
|
-
* the one with the most views and images.
|
|
364
|
+
* the one named like the folder, then the one with the most views and images.
|
|
389
365
|
*/
|
|
390
366
|
function onePerFolder(rigs) {
|
|
391
367
|
const score = (r) => [
|
|
392
368
|
rigVersionSupported(r) ? 1 : 0,
|
|
369
|
+
// `rabbit_*.json` over a goat that reuses the rabbit's part names.
|
|
370
|
+
r.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') === r.folderPath[r.folderPath.length - 1] ? 1 : 0,
|
|
393
371
|
r.views.length,
|
|
394
372
|
new Set(r.views.flatMap((v) => v.file.images)).size,
|
|
395
373
|
];
|
|
@@ -429,7 +407,7 @@ export function batchRigs(rigs, maxBytes = 16 * 1024 * 1024) {
|
|
|
429
407
|
let cur = [];
|
|
430
408
|
let bytes = 0;
|
|
431
409
|
for (const r of rigs) {
|
|
432
|
-
const size = r.views.reduce((n, v) => n + v.file.
|
|
410
|
+
const size = r.views.reduce((n, v) => n + v.file.size, 0);
|
|
433
411
|
if (cur.length > 0 && (cur.length >= MAX_RIGS_PER_REQUEST || bytes + size > maxBytes)) {
|
|
434
412
|
out.push(cur);
|
|
435
413
|
cur = [];
|