@magicpixelart/cli 0.6.3 → 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 +14 -0
- package/dist/api.js +17 -6
- package/dist/commands/spine.js +50 -11
- package/dist/util/spine.js +57 -52
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ 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
|
+
|
|
16
|
+
## [0.6.4] - 2026-10-17
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Spine rigs in big games: every skeleton export is scanned (0.6.3 stopped at 200, which could miss the main character's rig).
|
|
20
|
+
- 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.
|
|
21
|
+
- When two rigs match the same folder, the one named like the folder wins (`rabbit` over a goat that reuses the rabbit's parts).
|
|
22
|
+
- Sync says when it finds Spine exports but no synced folder for them, instead of staying silent.
|
|
23
|
+
|
|
10
24
|
## [0.6.3] - 2026-10-17
|
|
11
25
|
|
|
12
26
|
### Added
|
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
|
@@ -6,10 +6,11 @@
|
|
|
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';
|
|
12
|
-
import { ApiError, pushSpineRigs } from '../api.js';
|
|
13
|
+
import { ApiError, pushSpineRigs, removeSpineRigs } from '../api.js';
|
|
13
14
|
import { detectProjectKind, isEngineKind, readWorkspaceMembers } from '../util/framework.js';
|
|
14
15
|
import { indexGamePngs, matchConnectGlobs } from '../util/gameScan.js';
|
|
15
16
|
import { batchRigs, describeRig, detectRigs, findSkeletonFiles, rigsToUpload, rigVersionSupported, SUPPORTED_SPINE_VERSION, syncedPngsFromEntries, } from '../util/spine.js';
|
|
@@ -24,7 +25,7 @@ export function skeletonRoots(cwd) {
|
|
|
24
25
|
}
|
|
25
26
|
export async function runSpineRigs(opts = {}) {
|
|
26
27
|
const cwd = opts.cwd ?? process.cwd();
|
|
27
|
-
const empty = { skeletons: 0, rigs: [], skippedFiles: [] };
|
|
28
|
+
const empty = { skeletons: 0, rigs: [], skippedFiles: [], removed: [] };
|
|
28
29
|
const config = opts.config ?? (await loadConfig(cwd));
|
|
29
30
|
const kind = await detectProjectKind(cwd);
|
|
30
31
|
if (!isEngineKind(kind))
|
|
@@ -51,18 +52,22 @@ export async function runSpineRigs(opts = {}) {
|
|
|
51
52
|
pending.delete(rig);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
const record = {};
|
|
54
56
|
if (!opts.dryRun && pending.size > 0) {
|
|
55
57
|
const upload = opts.upload ?? pushSpineRigs;
|
|
56
|
-
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';
|
|
@@ -77,14 +82,38 @@ export async function runSpineRigs(opts = {}) {
|
|
|
77
82
|
outcomes.set(rig, { rig, status: 'error', error: message });
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 */
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
|
-
|
|
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
|
+
};
|
|
88
117
|
}
|
|
89
118
|
function spineErrorMessage(e) {
|
|
90
119
|
if (e instanceof ApiError && e.status === 404)
|
|
@@ -119,6 +148,13 @@ export async function syncSpineRigsQuietly(opts) {
|
|
|
119
148
|
const outcome = await (opts.run ?? runSpineRigs)({ config: opts.config, gameIndex: opts.gameIndex, dryRun: opts.dryRun });
|
|
120
149
|
if (opts.quiet)
|
|
121
150
|
return outcome;
|
|
151
|
+
if (outcome.skeletons > 0 && outcome.rigs.length === 0 && !warnedThisProcess) {
|
|
152
|
+
// Say why no preview appears instead of staying silent.
|
|
153
|
+
warnedThisProcess = true;
|
|
154
|
+
console.log();
|
|
155
|
+
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.`));
|
|
156
|
+
return outcome;
|
|
157
|
+
}
|
|
122
158
|
const lines = outcome.rigs.filter((o) => {
|
|
123
159
|
if (opts.verbose)
|
|
124
160
|
return true;
|
|
@@ -176,6 +212,9 @@ export async function spineCommand(opts = {}) {
|
|
|
176
212
|
}
|
|
177
213
|
for (const o of outcome.rigs)
|
|
178
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
|
+
}
|
|
179
218
|
if (opts.dryRun) {
|
|
180
219
|
console.log();
|
|
181
220
|
console.log(ui.dryRun());
|
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,11 +272,23 @@ 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
|
}
|
|
303
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
|
+
}
|
|
304
292
|
/**
|
|
305
293
|
* Turns skeleton files + the synced PNGs into rigs bound to library folders.
|
|
306
294
|
* Rigs whose images match no synced folder are left out.
|
|
@@ -308,7 +296,7 @@ const label = (names) => `${names[names.length - 1]}/`;
|
|
|
308
296
|
export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
309
297
|
const folders = indexFolders(pngs);
|
|
310
298
|
const rigs = [];
|
|
311
|
-
for (const group of groupSkeletons(skeletons
|
|
299
|
+
for (const group of groupSkeletons(skeletons)) {
|
|
312
300
|
const files = [...group].sort((a, b) => a.abs.localeCompare(b.abs)).slice(0, MAX_VIEWS_PER_RIG);
|
|
313
301
|
const names = viewNames(files.map((f) => basename(f.abs)));
|
|
314
302
|
const views = files
|
|
@@ -317,13 +305,29 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
317
305
|
const keys = new Set(files.flatMap((f) => f.images.map(imageKey)));
|
|
318
306
|
if (keys.size === 0)
|
|
319
307
|
continue;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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;
|
|
326
328
|
}
|
|
329
|
+
if (skins.length === 0)
|
|
330
|
+
skins = inFolders((f, hits) => hits >= SKIN_SHARE * f.pngs.size);
|
|
327
331
|
if (skins.length === 0)
|
|
328
332
|
continue;
|
|
329
333
|
// Several skin folders side by side → bind to their parent (`rabbit/`).
|
|
@@ -365,7 +369,6 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
365
369
|
}
|
|
366
370
|
if (baseFolder && baseFolder.hits < COVER_SHARE * keys.size)
|
|
367
371
|
baseFolder = null;
|
|
368
|
-
const name = rigName(files.map((f) => basename(f.abs)), basename(dirname(files[0].abs)));
|
|
369
372
|
const baseFolderPath = baseFolder?.f.segments ?? null;
|
|
370
373
|
rigs.push({
|
|
371
374
|
name,
|
|
@@ -377,7 +380,7 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
377
380
|
skins: skinCount,
|
|
378
381
|
baseFolderPath,
|
|
379
382
|
baseFolderLabel: baseFolder ? label(baseFolder.f.names) : null,
|
|
380
|
-
hash: rigHash({ name, folderPath, baseFolderPath, views: views.map((v) => ({ name: v.name,
|
|
383
|
+
hash: rigHash({ name, folderPath, baseFolderPath, views: views.map((v) => ({ name: v.name, sha256: v.file.sha256 })) }),
|
|
381
384
|
});
|
|
382
385
|
}
|
|
383
386
|
return onePerFolder(rigs).sort((a, b) => a.name.localeCompare(b.name) || a.folderPath.join('/').localeCompare(b.folderPath.join('/')));
|
|
@@ -385,11 +388,13 @@ export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
|
385
388
|
/**
|
|
386
389
|
* A folder holds one rig. When several exports bind to the same folder (an
|
|
387
390
|
* 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.
|
|
391
|
+
* the one named like the folder, then the one with the most views and images.
|
|
389
392
|
*/
|
|
390
393
|
function onePerFolder(rigs) {
|
|
391
394
|
const score = (r) => [
|
|
392
395
|
rigVersionSupported(r) ? 1 : 0,
|
|
396
|
+
// `rabbit_*.json` over a goat that reuses the rabbit's part names.
|
|
397
|
+
r.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') === r.folderPath[r.folderPath.length - 1] ? 1 : 0,
|
|
393
398
|
r.views.length,
|
|
394
399
|
new Set(r.views.flatMap((v) => v.file.images)).size,
|
|
395
400
|
];
|
|
@@ -429,7 +434,7 @@ export function batchRigs(rigs, maxBytes = 16 * 1024 * 1024) {
|
|
|
429
434
|
let cur = [];
|
|
430
435
|
let bytes = 0;
|
|
431
436
|
for (const r of rigs) {
|
|
432
|
-
const size = r.views.reduce((n, v) => n + v.file.
|
|
437
|
+
const size = r.views.reduce((n, v) => n + v.file.size, 0);
|
|
433
438
|
if (cur.length > 0 && (cur.length >= MAX_RIGS_PER_REQUEST || bytes + size > maxBytes)) {
|
|
434
439
|
out.push(cur);
|
|
435
440
|
cur = [];
|