@magicpixelart/cli 0.6.2 → 0.6.3
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 +9 -0
- package/README.md +16 -0
- package/dist/api.js +25 -0
- package/dist/commands/spine.js +187 -0
- package/dist/commands/sync.js +8 -0
- package/dist/config.js +1 -0
- package/dist/index.js +8 -0
- package/dist/util/gameScan.js +1 -1
- package/dist/util/spine.js +444 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to `@magicpixelart/cli` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.6.3] - 2026-10-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Spine rigs attach themselves: `sync` and `start` find Spine 4.3 skeleton exports in your game folders, match them to the synced part folders by file name, and upload them so MagicPixel shows the animated preview (e.g. `Spine rig rabbit (front, side, back) → rabbit/ (509 skins), parts fallback rabbit-pixel/`). Only changed rigs are sent; a failed upload never fails the sync.
|
|
14
|
+
- `magicpixel spine [--dry-run] [--force]` runs that detection on its own and prints what it found.
|
|
15
|
+
- `MAGICPIXEL_SPINE_ENDPOINT` overrides the rig upload endpoint (testing only).
|
|
16
|
+
|
|
8
17
|
## [0.6.2] - 2026-10-10
|
|
9
18
|
|
|
10
19
|
### Fixed
|
package/README.md
CHANGED
|
@@ -115,6 +115,7 @@ Run `magicpixel --help` for a short "Common tasks" list. A folder isn't syncing?
|
|
|
115
115
|
| `sync [...flags]` | Two-way: pull MagicPixel edits and push changed game sprites. |
|
|
116
116
|
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and connected game sprites) back to MagicPixel. |
|
|
117
117
|
| `resync <folder> [--dry-run] [-y]` | Local files win for one folder: overwrite MagicPixel copies, move cloud-only files to Trash. |
|
|
118
|
+
| `spine [--dry-run] [--force]` | Find Spine skeleton exports and attach them to their synced part folders, so MagicPixel shows the animated preview. Also runs after every `sync`. |
|
|
118
119
|
| `connect <glob>` | Limit which game folders sync (default is all sprites on Unity/Godot/GameMaker). |
|
|
119
120
|
| `search <query>` | Search indexed game PNGs (no network). |
|
|
120
121
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
@@ -186,6 +187,21 @@ npx @magicpixelart/cli@latest start # or: magicpixel consolidate
|
|
|
186
187
|
- Run any command from a game folder and it uses the root setup. Folder names you type are read from where you are: `sync --only Runtime` inside `kr-core` means `kr-core/Runtime`.
|
|
187
188
|
- Add a folder later with `magicpixel connect "kr-puzzle/**"`.
|
|
188
189
|
|
|
190
|
+
## Spine rigs (animated preview)
|
|
191
|
+
|
|
192
|
+
`sync` (and `start`) look for Spine skeleton exports (`.json`, Spine 4.3) anywhere in your game folders — also in folders that aren't synced, like packed `RemoteBundleAssets` — and match them to synced folders by part file name (a `head_front` attachment ↔ `head_front.png`):
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
⬆️ Spine rig rabbit (front, side, back) → rabbit/ (509 skins), parts fallback rabbit-pixel/
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- Skeletons in one directory that share parts (`rabbit_front/side/back.json`) become one rig with a view each.
|
|
199
|
+
- A folder whose PNGs are mostly the rig's parts is a skin; when several skins sit side by side the rig is attached to their parent, so every skin gets the preview.
|
|
200
|
+
- The folder outside it holding most parts (`rabbit-pixel/`) fills in parts a skin lacks.
|
|
201
|
+
- Only changed rigs are uploaded (hash kept in `.magicpixel/state.json`). A failed upload only warns; sync carries on.
|
|
202
|
+
|
|
203
|
+
Run `magicpixel spine --dry-run` to see what would be attached.
|
|
204
|
+
|
|
189
205
|
## Plain output
|
|
190
206
|
|
|
191
207
|
Add `--plain` to any command (or set `NO_COLOR=1` or `MAGICPIXEL_PLAIN=1`) for output without colors, emoji or boxes. Piped output and CI logs are plain automatically.
|
package/dist/api.js
CHANGED
|
@@ -458,6 +458,31 @@ export async function pushSpritesAdaptive(sprites, retryOpts) {
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
460
|
}
|
|
461
|
+
const DEFAULT_SPINE_ENDPOINT = 'https://magicpixel.art/api/public/integration/spine';
|
|
462
|
+
export function resolveSpineEndpoint() {
|
|
463
|
+
const override = process.env.MAGICPIXEL_SPINE_ENDPOINT?.trim();
|
|
464
|
+
return override ? validateEndpointUrl(override) : DEFAULT_SPINE_ENDPOINT;
|
|
465
|
+
}
|
|
466
|
+
/** Upload up to 10 rigs; the server attaches each to its library folder. */
|
|
467
|
+
export async function pushSpineRigs(rigs, retryOpts) {
|
|
468
|
+
const url = resolveSpineEndpoint();
|
|
469
|
+
return retryTransient('spine', async () => {
|
|
470
|
+
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
471
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify({ rigs }) }, { timeoutMs: ASSET_TIMEOUT_MS });
|
|
472
|
+
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
473
|
+
if (res.ok) {
|
|
474
|
+
const data = (await res.json());
|
|
475
|
+
const results = data && typeof data === 'object' ? data.results : undefined;
|
|
476
|
+
if (!Array.isArray(results)) {
|
|
477
|
+
throw new ApiError(502, withRequestId('spine: unexpected server response shape (results missing).', serverRequestId), serverRequestId);
|
|
478
|
+
}
|
|
479
|
+
return results;
|
|
480
|
+
}
|
|
481
|
+
const bodyText = await res.text();
|
|
482
|
+
const errorCode = errorCodeFromResponse(res);
|
|
483
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, 'spine', errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
484
|
+
}, retryOpts);
|
|
485
|
+
}
|
|
461
486
|
// ---------- folder resync (local folder is the source of truth) -------------
|
|
462
487
|
export function resolveResyncEndpoint() {
|
|
463
488
|
return resolveIngestEndpoint().replace(/\/ingest$/, '/resync');
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magicpixel spine` (and the tail of every `sync` / `start`): find the game's
|
|
3
|
+
* Spine skeleton exports, match them to the synced part folders, and upload
|
|
4
|
+
* them so MagicPixel's animated preview appears without attaching anything by
|
|
5
|
+
* hand. Only rigs whose skeletons or folders changed since the last upload are
|
|
6
|
+
* sent (hash remembered in `.magicpixel/state.json`).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import kleur from 'kleur';
|
|
11
|
+
import { loadConfig, loadState, saveState } from '../config.js';
|
|
12
|
+
import { ApiError, pushSpineRigs } from '../api.js';
|
|
13
|
+
import { detectProjectKind, isEngineKind, readWorkspaceMembers } from '../util/framework.js';
|
|
14
|
+
import { indexGamePngs, matchConnectGlobs } from '../util/gameScan.js';
|
|
15
|
+
import { batchRigs, describeRig, detectRigs, findSkeletonFiles, rigsToUpload, rigVersionSupported, SUPPORTED_SPINE_VERSION, syncedPngsFromEntries, } from '../util/spine.js';
|
|
16
|
+
import { icon, spinner as makeSpinner, ui } from '../util/ui.js';
|
|
17
|
+
/** Folders to look for skeleton exports in: each game folder of a workspace, else the setup folder. */
|
|
18
|
+
export function skeletonRoots(cwd) {
|
|
19
|
+
const members = readWorkspaceMembers(cwd);
|
|
20
|
+
if (!members)
|
|
21
|
+
return [cwd];
|
|
22
|
+
const dirs = members.map((m) => resolve(cwd, m)).filter((d) => existsSync(d));
|
|
23
|
+
return dirs.length ? dirs : [cwd];
|
|
24
|
+
}
|
|
25
|
+
export async function runSpineRigs(opts = {}) {
|
|
26
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
27
|
+
const empty = { skeletons: 0, rigs: [], skippedFiles: [] };
|
|
28
|
+
const config = opts.config ?? (await loadConfig(cwd));
|
|
29
|
+
const kind = await detectProjectKind(cwd);
|
|
30
|
+
if (!isEngineKind(kind))
|
|
31
|
+
return empty;
|
|
32
|
+
const index = opts.gameIndex ?? (await indexGamePngs(kind, cwd, config.outDir));
|
|
33
|
+
const entries = matchConnectGlobs(index, config.connect ?? [], config.exclude).entries;
|
|
34
|
+
const found = await findSkeletonFiles(skeletonRoots(cwd), cwd, [
|
|
35
|
+
resolve(cwd, config.outDir),
|
|
36
|
+
resolve(cwd, '.magicpixel'),
|
|
37
|
+
]);
|
|
38
|
+
if (found.skeletons.length === 0)
|
|
39
|
+
return { ...empty, skippedFiles: found.skipped };
|
|
40
|
+
const rigs = entries.length ? detectRigs(found.skeletons, syncedPngsFromEntries(entries), cwd) : [];
|
|
41
|
+
const state = await loadState(cwd);
|
|
42
|
+
const pending = new Set(opts.force ? rigs : rigsToUpload(rigs, state.spineRigs));
|
|
43
|
+
const outcomes = new Map(rigs.map((rig) => [rig, { rig, status: pending.has(rig) ? 'dry-run' : 'up-to-date' }]));
|
|
44
|
+
for (const rig of pending) {
|
|
45
|
+
if (!rigVersionSupported(rig)) {
|
|
46
|
+
outcomes.set(rig, {
|
|
47
|
+
rig,
|
|
48
|
+
status: 'unsupported',
|
|
49
|
+
error: `exported from Spine ${rig.spineVersion || 'an unknown version'}; the preview reads Spine ${SUPPORTED_SPINE_VERSION} exports`,
|
|
50
|
+
});
|
|
51
|
+
pending.delete(rig);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!opts.dryRun && pending.size > 0) {
|
|
55
|
+
const upload = opts.upload ?? pushSpineRigs;
|
|
56
|
+
const record = {};
|
|
57
|
+
for (const batch of batchRigs([...pending])) {
|
|
58
|
+
try {
|
|
59
|
+
const results = await upload(batch.map((r) => ({
|
|
60
|
+
name: r.name,
|
|
61
|
+
folderPath: r.folderPath,
|
|
62
|
+
baseFolderPath: r.baseFolderPath,
|
|
63
|
+
views: r.views.map((v) => ({ name: v.name, json: v.file.json })),
|
|
64
|
+
hash: r.hash,
|
|
65
|
+
})));
|
|
66
|
+
batch.forEach((rig, i) => {
|
|
67
|
+
const res = results[i]?.name === rig.name ? results[i] : results.find((x) => x?.name === rig.name);
|
|
68
|
+
const status = res?.status ?? 'error';
|
|
69
|
+
outcomes.set(rig, { rig, status, ...(res?.error || !res ? { error: res?.error ?? 'no result from the server' } : {}) });
|
|
70
|
+
if (status === 'saved' || status === 'unchanged')
|
|
71
|
+
record[rig.folderPath.join('/')] = rig.hash;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const message = spineErrorMessage(e);
|
|
76
|
+
for (const rig of batch)
|
|
77
|
+
outcomes.set(rig, { rig, status: 'error', error: message });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (Object.keys(record).length > 0) {
|
|
81
|
+
// Re-read: the push that just ran saved its own state.
|
|
82
|
+
const fresh = await loadState(cwd);
|
|
83
|
+
fresh.spineRigs = { ...(fresh.spineRigs ?? {}), ...record };
|
|
84
|
+
await saveState(fresh, cwd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { skeletons: found.skeletons.length, rigs: rigs.map((r) => outcomes.get(r)), skippedFiles: found.skipped };
|
|
88
|
+
}
|
|
89
|
+
function spineErrorMessage(e) {
|
|
90
|
+
if (e instanceof ApiError && e.status === 404)
|
|
91
|
+
return 'this MagicPixel server does not accept Spine rigs yet';
|
|
92
|
+
return (e?.message ?? String(e)).split('\n')[0];
|
|
93
|
+
}
|
|
94
|
+
/** One line per rig, in the CLI's status-line style. */
|
|
95
|
+
export function formatSpineOutcome(o) {
|
|
96
|
+
const line = describeRig(o.rig);
|
|
97
|
+
switch (o.status) {
|
|
98
|
+
case 'saved':
|
|
99
|
+
return `${icon('up')} ${line}`;
|
|
100
|
+
case 'unchanged':
|
|
101
|
+
case 'up-to-date':
|
|
102
|
+
return `${icon('ok')} ${kleur.dim(line)}`;
|
|
103
|
+
case 'dry-run':
|
|
104
|
+
return `${icon('ghost')} ${line}`;
|
|
105
|
+
case 'missing-folder':
|
|
106
|
+
return ui.warn(`${line} — that folder isn't in MagicPixel yet`);
|
|
107
|
+
case 'unsupported':
|
|
108
|
+
case 'error':
|
|
109
|
+
return ui.warn(`${line}: ${o.error ?? 'upload failed'}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let warnedThisProcess = false;
|
|
113
|
+
/**
|
|
114
|
+
* Sync hook: never throws. `verbose` prints every rig; otherwise only uploads
|
|
115
|
+
* and problems. A failure is reported once per process.
|
|
116
|
+
*/
|
|
117
|
+
export async function syncSpineRigsQuietly(opts) {
|
|
118
|
+
try {
|
|
119
|
+
const outcome = await (opts.run ?? runSpineRigs)({ config: opts.config, gameIndex: opts.gameIndex, dryRun: opts.dryRun });
|
|
120
|
+
if (opts.quiet)
|
|
121
|
+
return outcome;
|
|
122
|
+
const lines = outcome.rigs.filter((o) => {
|
|
123
|
+
if (opts.verbose)
|
|
124
|
+
return true;
|
|
125
|
+
if (o.status === 'saved')
|
|
126
|
+
return true;
|
|
127
|
+
if (o.status === 'error' || o.status === 'missing-folder' || o.status === 'unsupported') {
|
|
128
|
+
if (warnedThisProcess)
|
|
129
|
+
return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
});
|
|
134
|
+
if (lines.some((o) => o.status === 'error' || o.status === 'missing-folder' || o.status === 'unsupported')) {
|
|
135
|
+
warnedThisProcess = true;
|
|
136
|
+
}
|
|
137
|
+
if (lines.length) {
|
|
138
|
+
console.log();
|
|
139
|
+
for (const o of lines)
|
|
140
|
+
console.log(formatSpineOutcome(o));
|
|
141
|
+
}
|
|
142
|
+
return outcome;
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
if (!opts.quiet && !warnedThisProcess) {
|
|
146
|
+
warnedThisProcess = true;
|
|
147
|
+
console.log();
|
|
148
|
+
console.log(ui.warn(`couldn't upload Spine rigs: ${spineErrorMessage(e)}`));
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Test seam. */
|
|
154
|
+
export function __resetSpineWarnedForTesting() {
|
|
155
|
+
warnedThisProcess = false;
|
|
156
|
+
}
|
|
157
|
+
export async function spineCommand(opts = {}) {
|
|
158
|
+
const spin = makeSpinner('Looking for Spine skeleton exports…').start();
|
|
159
|
+
let outcome;
|
|
160
|
+
try {
|
|
161
|
+
outcome = await runSpineRigs(opts);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
spin.stop();
|
|
165
|
+
}
|
|
166
|
+
for (const f of outcome.skippedFiles)
|
|
167
|
+
console.log(ui.warn(`${f.rel}: ${f.reason}, skipped`));
|
|
168
|
+
if (outcome.skeletons === 0) {
|
|
169
|
+
console.log(`${icon('tip')} No Spine skeleton exports (.json) found in this project.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (outcome.rigs.length === 0) {
|
|
173
|
+
console.log(ui.warn(`Found ${outcome.skeletons} Spine skeleton${outcome.skeletons === 1 ? '' : 's'}, but no synced folder holds their part PNGs.`));
|
|
174
|
+
console.log(kleur.dim(' Parts are matched by file name (head_front.png for a "head_front" attachment).'));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const o of outcome.rigs)
|
|
178
|
+
console.log(formatSpineOutcome(o));
|
|
179
|
+
if (opts.dryRun) {
|
|
180
|
+
console.log();
|
|
181
|
+
console.log(ui.dryRun());
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const failed = outcome.rigs.filter((o) => o.status === 'error' || o.status === 'missing-folder').length;
|
|
185
|
+
if (failed)
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -28,6 +28,7 @@ import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../uti
|
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
30
30
|
import { runPush } from './push.js';
|
|
31
|
+
import { syncSpineRigsQuietly } from './spine.js';
|
|
31
32
|
import { claimResyncRequest, reportResyncRequest } from '../api.js';
|
|
32
33
|
import { runResync } from './resync.js';
|
|
33
34
|
import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
|
|
@@ -1597,10 +1598,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1597
1598
|
if (!runOpts.watchMode && !isPlain())
|
|
1598
1599
|
console.log(kleur.dim(syncFooterHint()));
|
|
1599
1600
|
}
|
|
1601
|
+
// Spine skeleton exports → animated preview in MagicPixel. Watchers check
|
|
1602
|
+
// once per run (the scan reads every .json head); never fails the sync.
|
|
1603
|
+
if (config.push !== false && (!runOpts.watchMode || !spineCheckedThisProcess)) {
|
|
1604
|
+
spineCheckedThisProcess = true;
|
|
1605
|
+
await syncSpineRigsQuietly({ config, gameIndex: fullGameIndex, verbose, quiet: !live });
|
|
1606
|
+
}
|
|
1600
1607
|
if (result.failed)
|
|
1601
1608
|
process.exitCode = 1;
|
|
1602
1609
|
return { ...result, pushed, ...(strays ? { strays } : {}) };
|
|
1603
1610
|
}
|
|
1611
|
+
let spineCheckedThisProcess = false;
|
|
1604
1612
|
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1605
1613
|
if (pushEnabled === false)
|
|
1606
1614
|
return null;
|
package/dist/config.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { repairCommand } from './commands/repair.js';
|
|
|
16
16
|
import { startCommand } from './commands/start.js';
|
|
17
17
|
import { connectCommand } from './commands/connect.js';
|
|
18
18
|
import { resyncCommand } from './commands/resync.js';
|
|
19
|
+
import { spineCommand } from './commands/spine.js';
|
|
19
20
|
import { searchCommand } from './commands/search.js';
|
|
20
21
|
import { whyCommand } from './commands/why.js';
|
|
21
22
|
import { consolidateCommand } from './commands/consolidate.js';
|
|
@@ -147,6 +148,13 @@ program
|
|
|
147
148
|
.option('--dry-run', 'Show what would change without sending anything')
|
|
148
149
|
.addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
|
|
149
150
|
.action(wrap("resync", async (folder, opts) => resyncCommand(resolveInvokedPath(folder), opts)));
|
|
151
|
+
program
|
|
152
|
+
.command('spine')
|
|
153
|
+
.description('Find Spine skeleton exports and attach them to their part folders in MagicPixel (also runs on every sync)')
|
|
154
|
+
.option('--dry-run', 'Show the rigs found without uploading anything')
|
|
155
|
+
.option('--force', 'Upload every rig, even ones unchanged since the last upload')
|
|
156
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel spine --dry-run\n $ magicpixel spine\n')
|
|
157
|
+
.action(wrap("spine", async (opts) => spineCommand(opts)));
|
|
150
158
|
program
|
|
151
159
|
.command('connect <glob>')
|
|
152
160
|
.description('Limit which game folders sync (default is all sprites)')
|
package/dist/util/gameScan.js
CHANGED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spine rig auto-detection: find the game's exported skeleton JSONs, work out
|
|
3
|
+
* which synced library folders hold their part PNGs, and describe the rigs the
|
|
4
|
+
* MagicPixel preview should use.
|
|
5
|
+
*
|
|
6
|
+
* King Rabbit layout (the case this was built for):
|
|
7
|
+
* Editor/RemoteBundleAssets/rabbit/base/rabbit_{front,side,back}.json skeletons (not synced)
|
|
8
|
+
* .SpineRaw/rabbit/<skin>/head_front.png … one folder per skin
|
|
9
|
+
* .SpineRaw/rabbit-pixel/… default parts + shadow
|
|
10
|
+
* → one rig "rabbit" with views front/side/back, bound to `rabbit/` (every
|
|
11
|
+
* skin folder below it), with `rabbit-pixel/` supplying missing parts.
|
|
12
|
+
*
|
|
13
|
+
* Detection is pure apart from `findSkeletonFiles`, so the matching rules are
|
|
14
|
+
* unit-testable without a game tree.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { open, opendir, readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
19
|
+
import { shouldSkipDir } from './gameScan.js';
|
|
20
|
+
import { gameImportAdoptPath } from './pushPlan.js';
|
|
21
|
+
import { createLimit } from './limit.js';
|
|
22
|
+
/** The only Spine version the MagicPixel preview reads (major.minor). */
|
|
23
|
+
export const SUPPORTED_SPINE_VERSION = '4.3';
|
|
24
|
+
/** Server limits (see `src/lib/integration/spine.server.ts`). */
|
|
25
|
+
export const MAX_SKELETON_BYTES = 8 * 1024 * 1024;
|
|
26
|
+
export const MAX_VIEWS_PER_RIG = 6;
|
|
27
|
+
export const MAX_RIGS_PER_REQUEST = 10;
|
|
28
|
+
/** Bytes read to decide whether a .json is a skeleton export. */
|
|
29
|
+
const HEAD_BYTES = 300;
|
|
30
|
+
/** Stop after this many skeletons; a game with more is not what this is for. */
|
|
31
|
+
const MAX_SKELETONS = 200;
|
|
32
|
+
/** A folder is a skin folder when at least this share of its PNGs are rig images. */
|
|
33
|
+
const SKIN_SHARE = 0.5;
|
|
34
|
+
/** Base folder / view grouping need at least this share of the rig's images. */
|
|
35
|
+
const COVER_SHARE = 0.5;
|
|
36
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
37
|
+
/** Attachment types that draw an image (no `type` means region). */
|
|
38
|
+
const IMAGE_ATTACHMENTS = new Set([undefined, 'region', 'mesh', 'linkedmesh']);
|
|
39
|
+
/** Cheap pre-check on the first bytes of a .json: Spine exports open with `{"skeleton":{…"spine":"4.3.23"`. */
|
|
40
|
+
export function looksLikeSkeleton(head) {
|
|
41
|
+
return head.includes('"skeleton"') && head.includes('"spine"');
|
|
42
|
+
}
|
|
43
|
+
/** `"4.3.23"` → `"4.3"`; null when it doesn't look like a version. */
|
|
44
|
+
export function spineMinorVersion(version) {
|
|
45
|
+
const m = /^(\d+)\.(\d+)/.exec(version.trim());
|
|
46
|
+
return m ? `${m[1]}.${m[2]}` : null;
|
|
47
|
+
}
|
|
48
|
+
function sequencePaths(path, sequence) {
|
|
49
|
+
const count = Number(sequence.count ?? 0);
|
|
50
|
+
const start = Number(sequence.start ?? 1);
|
|
51
|
+
const digits = Number(sequence.digits ?? 0);
|
|
52
|
+
const out = [];
|
|
53
|
+
for (let i = 0; i < count && i < 10_000; i++)
|
|
54
|
+
out.push(path + String(start + i).padStart(digits, '0'));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function skinEntries(skins) {
|
|
58
|
+
if (Array.isArray(skins))
|
|
59
|
+
return skins.filter(isObject).map((s) => (isObject(s.attachments) ? s.attachments : {}));
|
|
60
|
+
if (isObject(skins))
|
|
61
|
+
return Object.values(skins).filter(isObject);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* What matching needs from a skeleton export, mirroring the app's
|
|
66
|
+
* `readSkeletonInfo` (`src/lib/spine/rig.ts`): every image any skin draws
|
|
67
|
+
* (`path ?? name ?? key`, sequences expanded). Null when it isn't a skeleton.
|
|
68
|
+
*/
|
|
69
|
+
export function readSkeletonImages(json) {
|
|
70
|
+
if (!isObject(json) || !isObject(json.skeleton) || !Array.isArray(json.bones))
|
|
71
|
+
return null;
|
|
72
|
+
const images = new Set();
|
|
73
|
+
for (const slots of skinEntries(json.skins)) {
|
|
74
|
+
for (const attachments of Object.values(slots)) {
|
|
75
|
+
if (!isObject(attachments))
|
|
76
|
+
continue;
|
|
77
|
+
for (const [key, attachment] of Object.entries(attachments)) {
|
|
78
|
+
if (!isObject(attachment))
|
|
79
|
+
continue;
|
|
80
|
+
if (!IMAGE_ATTACHMENTS.has(attachment.type))
|
|
81
|
+
continue;
|
|
82
|
+
const path = String(attachment.path ?? attachment.name ?? key);
|
|
83
|
+
if (isObject(attachment.sequence))
|
|
84
|
+
sequencePaths(path, attachment.sequence).forEach((p) => images.add(p));
|
|
85
|
+
else
|
|
86
|
+
images.add(path);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { spineVersion: String(json.skeleton.spine ?? ''), images: [...images].sort() };
|
|
91
|
+
}
|
|
92
|
+
/** File-name key an image path matches: its last segment, lower-cased. */
|
|
93
|
+
export function imageKey(path) {
|
|
94
|
+
return path.slice(path.lastIndexOf('/') + 1).toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Short view names: the part after the shared prefix (`rabbit_front.json`,
|
|
98
|
+
* `rabbit_side.json` → "front", "side"). Same rule as the app's `viewNames`.
|
|
99
|
+
*/
|
|
100
|
+
export function viewNames(fileNames) {
|
|
101
|
+
const bases = fileNames.map((f) => f.replace(/\.json$/i, ''));
|
|
102
|
+
if (bases.length < 2)
|
|
103
|
+
return bases;
|
|
104
|
+
let prefix = bases[0];
|
|
105
|
+
for (const b of bases)
|
|
106
|
+
while (!b.startsWith(prefix))
|
|
107
|
+
prefix = prefix.slice(0, -1);
|
|
108
|
+
const cut = Math.max(prefix.lastIndexOf('_'), prefix.lastIndexOf('-'), prefix.lastIndexOf('.')) + 1;
|
|
109
|
+
const names = bases.map((b) => b.slice(cut));
|
|
110
|
+
return names.every(Boolean) && new Set(names).size === names.length ? names : bases;
|
|
111
|
+
}
|
|
112
|
+
/** Rig name: the shared file prefix (`rabbit`), else the file / directory name. */
|
|
113
|
+
export function rigName(fileNames, dirName) {
|
|
114
|
+
const bases = fileNames.map((f) => f.replace(/\.json$/i, ''));
|
|
115
|
+
if (bases.length === 1)
|
|
116
|
+
return bases[0];
|
|
117
|
+
const views = viewNames(fileNames);
|
|
118
|
+
if (views.some((v, i) => v !== bases[i]) && views.every((v, i) => bases[i].endsWith(v))) {
|
|
119
|
+
const prefix = bases[0].slice(0, bases[0].length - views[0].length).replace(/[_.-]+$/, '');
|
|
120
|
+
if (prefix)
|
|
121
|
+
return prefix;
|
|
122
|
+
}
|
|
123
|
+
return dirName;
|
|
124
|
+
}
|
|
125
|
+
const VIEW_ORDER = ['front', 'side', 'back'];
|
|
126
|
+
function viewRank(name) {
|
|
127
|
+
const i = VIEW_ORDER.indexOf(name.toLowerCase());
|
|
128
|
+
return i === -1 ? VIEW_ORDER.length : i;
|
|
129
|
+
}
|
|
130
|
+
async function readHead(abs) {
|
|
131
|
+
const fh = await open(abs, 'r');
|
|
132
|
+
try {
|
|
133
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
134
|
+
const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
|
|
135
|
+
return buf.subarray(0, bytesRead).toString('utf8');
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await fh.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Walk `roots` for Spine skeleton JSONs. Skips the same engine/VCS folders as
|
|
143
|
+
* the sprite scan (Library, Temp, node_modules, .git, …) and `skipDirs`.
|
|
144
|
+
*/
|
|
145
|
+
export async function findSkeletonFiles(roots, base, skipDirs = []) {
|
|
146
|
+
const skip = new Set(skipDirs.map((d) => resolve(d).replace(/\\/g, '/').toLowerCase()));
|
|
147
|
+
const io = createLimit(8);
|
|
148
|
+
const candidates = [];
|
|
149
|
+
const walk = async (dir, depth) => {
|
|
150
|
+
if (depth > 24 || skip.has(dir.replace(/\\/g, '/').toLowerCase()))
|
|
151
|
+
return;
|
|
152
|
+
const subdirs = [];
|
|
153
|
+
await io(async () => {
|
|
154
|
+
let dh;
|
|
155
|
+
try {
|
|
156
|
+
dh = await opendir(dir);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
for await (const ent of dh) {
|
|
163
|
+
if (ent.isSymbolicLink())
|
|
164
|
+
continue;
|
|
165
|
+
if (ent.isDirectory()) {
|
|
166
|
+
if (!shouldSkipDir(ent.name))
|
|
167
|
+
subdirs.push(resolve(dir, ent.name));
|
|
168
|
+
}
|
|
169
|
+
else if (ent.isFile() && ent.name.toLowerCase().endsWith('.json')) {
|
|
170
|
+
candidates.push(resolve(dir, ent.name));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* unreadable mid-listing */
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
await Promise.all(subdirs.map((d) => walk(d, depth + 1)));
|
|
179
|
+
};
|
|
180
|
+
for (const root of new Set(roots.map((r) => resolve(r))))
|
|
181
|
+
await walk(root, 0);
|
|
182
|
+
candidates.sort();
|
|
183
|
+
const skeletons = [];
|
|
184
|
+
const skipped = [];
|
|
185
|
+
await Promise.all(candidates.map((abs) => io(async () => {
|
|
186
|
+
if (skeletons.length >= MAX_SKELETONS)
|
|
187
|
+
return;
|
|
188
|
+
const rel = relative(base, abs).replace(/\\/g, '/');
|
|
189
|
+
try {
|
|
190
|
+
if (!looksLikeSkeleton(await readHead(abs)))
|
|
191
|
+
return;
|
|
192
|
+
const size = (await stat(abs)).size;
|
|
193
|
+
if (size > MAX_SKELETON_BYTES) {
|
|
194
|
+
skipped.push({ rel, reason: 'larger than 8 MB' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const text = await readFile(abs, 'utf8');
|
|
198
|
+
const json = JSON.parse(text);
|
|
199
|
+
const info = readSkeletonImages(json);
|
|
200
|
+
if (!info)
|
|
201
|
+
return;
|
|
202
|
+
skeletons.push({ abs, rel, text, json, ...info });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* not JSON after all / unreadable — not a skeleton we can use */
|
|
206
|
+
}
|
|
207
|
+
})));
|
|
208
|
+
skeletons.sort((a, b) => a.abs.localeCompare(b.abs));
|
|
209
|
+
skipped.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
210
|
+
return { skeletons, skipped };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Library folder of each connected game PNG. Uses the same disk → library
|
|
214
|
+
* mapping as push (`gameImportAdoptPath`), so `.SpineRaw/rabbit/spider/head_front.png`
|
|
215
|
+
* lives in folder `spineraw / rabbit / spider`.
|
|
216
|
+
*/
|
|
217
|
+
export function syncedPngsFromEntries(entries) {
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
const adopted = gameImportAdoptPath(e.adoptRel.replace(/\.png$/i, '').split('/'));
|
|
221
|
+
const n = adopted.path.length - 2;
|
|
222
|
+
if (n < 1)
|
|
223
|
+
continue;
|
|
224
|
+
out.push({
|
|
225
|
+
folder: adopted.path.slice(0, n),
|
|
226
|
+
folderNames: adopted.pathNames.slice(0, n),
|
|
227
|
+
name: basename(e.abs).replace(/\.png$/i, '').toLowerCase(),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
function indexFolders(pngs) {
|
|
233
|
+
const folders = new Map();
|
|
234
|
+
for (const p of pngs) {
|
|
235
|
+
const key = p.folder.join('/');
|
|
236
|
+
let f = folders.get(key);
|
|
237
|
+
if (!f)
|
|
238
|
+
folders.set(key, (f = { key, segments: p.folder, names: p.folderNames, pngs: new Set() }));
|
|
239
|
+
f.pngs.add(p.name);
|
|
240
|
+
}
|
|
241
|
+
return folders;
|
|
242
|
+
}
|
|
243
|
+
function countIn(set, keys) {
|
|
244
|
+
let n = 0;
|
|
245
|
+
for (const k of keys)
|
|
246
|
+
if (set.has(k))
|
|
247
|
+
n++;
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
function isInside(segments, root) {
|
|
251
|
+
return segments.length >= root.length && root.every((s, i) => segments[i] === s);
|
|
252
|
+
}
|
|
253
|
+
/** Groups skeletons in one directory into rigs (views of the same character). */
|
|
254
|
+
export function groupSkeletons(skeletons, folders) {
|
|
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
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Two skeletons are views of one rig when their images overlap heavily, or
|
|
279
|
+
* when one synced folder holds most of both (views whose parts are named per
|
|
280
|
+
* view, `head_front` / `head_side`, still share every skin folder).
|
|
281
|
+
*/
|
|
282
|
+
function sameRig(a, b, folders) {
|
|
283
|
+
if (a.size === 0 || b.size === 0)
|
|
284
|
+
return false;
|
|
285
|
+
if (countIn(a, b) >= COVER_SHARE * Math.min(a.size, b.size))
|
|
286
|
+
return true;
|
|
287
|
+
for (const f of folders.values()) {
|
|
288
|
+
if (countIn(f.pngs, a) >= COVER_SHARE * a.size && countIn(f.pngs, b) >= COVER_SHARE * b.size)
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
export function rigHash(rig) {
|
|
294
|
+
const h = createHash('sha256');
|
|
295
|
+
h.update(JSON.stringify({
|
|
296
|
+
name: rig.name,
|
|
297
|
+
folderPath: rig.folderPath,
|
|
298
|
+
baseFolderPath: rig.baseFolderPath,
|
|
299
|
+
views: rig.views.map((v) => [v.name, createHash('sha256').update(v.text).digest('hex')]),
|
|
300
|
+
}));
|
|
301
|
+
return h.digest('hex');
|
|
302
|
+
}
|
|
303
|
+
const label = (names) => `${names[names.length - 1]}/`;
|
|
304
|
+
/**
|
|
305
|
+
* Turns skeleton files + the synced PNGs into rigs bound to library folders.
|
|
306
|
+
* Rigs whose images match no synced folder are left out.
|
|
307
|
+
*/
|
|
308
|
+
export function detectRigs(skeletons, pngs, base = process.cwd()) {
|
|
309
|
+
const folders = indexFolders(pngs);
|
|
310
|
+
const rigs = [];
|
|
311
|
+
for (const group of groupSkeletons(skeletons, folders)) {
|
|
312
|
+
const files = [...group].sort((a, b) => a.abs.localeCompare(b.abs)).slice(0, MAX_VIEWS_PER_RIG);
|
|
313
|
+
const names = viewNames(files.map((f) => basename(f.abs)));
|
|
314
|
+
const views = files
|
|
315
|
+
.map((file, i) => ({ name: names[i], file }))
|
|
316
|
+
.sort((a, b) => viewRank(a.name) - viewRank(b.name) || a.name.localeCompare(b.name));
|
|
317
|
+
const keys = new Set(files.flatMap((f) => f.images.map(imageKey)));
|
|
318
|
+
if (keys.size === 0)
|
|
319
|
+
continue;
|
|
320
|
+
// Skin folders: mostly made of this rig's images.
|
|
321
|
+
const skins = [];
|
|
322
|
+
for (const f of folders.values()) {
|
|
323
|
+
const hits = countIn(f.pngs, keys);
|
|
324
|
+
if (hits > 0 && hits >= SKIN_SHARE * f.pngs.size)
|
|
325
|
+
skins.push({ f, hits });
|
|
326
|
+
}
|
|
327
|
+
if (skins.length === 0)
|
|
328
|
+
continue;
|
|
329
|
+
// Several skin folders side by side → bind to their parent (`rabbit/`).
|
|
330
|
+
const byParent = new Map();
|
|
331
|
+
for (const { f, hits } of skins) {
|
|
332
|
+
if (f.segments.length < 2)
|
|
333
|
+
continue;
|
|
334
|
+
const key = f.segments.slice(0, -1).join('/');
|
|
335
|
+
const p = byParent.get(key) ?? { segments: f.segments.slice(0, -1), names: f.names.slice(0, -1), count: 0, hits: 0 };
|
|
336
|
+
p.count++;
|
|
337
|
+
p.hits += hits;
|
|
338
|
+
byParent.set(key, p);
|
|
339
|
+
}
|
|
340
|
+
const parent = [...byParent.values()]
|
|
341
|
+
.filter((p) => p.count >= 2)
|
|
342
|
+
.sort((a, b) => b.count - a.count || b.hits - a.hits || a.segments.join('/').localeCompare(b.segments.join('/')))[0];
|
|
343
|
+
let folderPath;
|
|
344
|
+
let folderNames;
|
|
345
|
+
let skinCount;
|
|
346
|
+
if (parent) {
|
|
347
|
+
folderPath = parent.segments;
|
|
348
|
+
folderNames = parent.names;
|
|
349
|
+
skinCount = parent.count;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const best = [...skins].sort((a, b) => b.hits - a.hits || a.f.key.localeCompare(b.f.key))[0];
|
|
353
|
+
folderPath = best.f.segments;
|
|
354
|
+
folderNames = best.f.names;
|
|
355
|
+
skinCount = 1;
|
|
356
|
+
}
|
|
357
|
+
// Base folder: outside the bound folder, holding the most rig images (≥ half).
|
|
358
|
+
let baseFolder = null;
|
|
359
|
+
for (const f of folders.values()) {
|
|
360
|
+
if (isInside(f.segments, folderPath))
|
|
361
|
+
continue;
|
|
362
|
+
const hits = countIn(f.pngs, keys);
|
|
363
|
+
if (!baseFolder || hits > baseFolder.hits || (hits === baseFolder.hits && f.key < baseFolder.f.key))
|
|
364
|
+
baseFolder = { f, hits };
|
|
365
|
+
}
|
|
366
|
+
if (baseFolder && baseFolder.hits < COVER_SHARE * keys.size)
|
|
367
|
+
baseFolder = null;
|
|
368
|
+
const name = rigName(files.map((f) => basename(f.abs)), basename(dirname(files[0].abs)));
|
|
369
|
+
const baseFolderPath = baseFolder?.f.segments ?? null;
|
|
370
|
+
rigs.push({
|
|
371
|
+
name,
|
|
372
|
+
dir: relative(base, dirname(files[0].abs)).replace(/\\/g, '/') || '.',
|
|
373
|
+
views,
|
|
374
|
+
spineVersion: files[0].spineVersion,
|
|
375
|
+
folderPath,
|
|
376
|
+
folderLabel: label(folderNames),
|
|
377
|
+
skins: skinCount,
|
|
378
|
+
baseFolderPath,
|
|
379
|
+
baseFolderLabel: baseFolder ? label(baseFolder.f.names) : null,
|
|
380
|
+
hash: rigHash({ name, folderPath, baseFolderPath, views: views.map((v) => ({ name: v.name, text: v.file.text })) }),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return onePerFolder(rigs).sort((a, b) => a.name.localeCompare(b.name) || a.folderPath.join('/').localeCompare(b.folderPath.join('/')));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A folder holds one rig. When several exports bind to the same folder (an
|
|
387
|
+
* 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.
|
|
389
|
+
*/
|
|
390
|
+
function onePerFolder(rigs) {
|
|
391
|
+
const score = (r) => [
|
|
392
|
+
rigVersionSupported(r) ? 1 : 0,
|
|
393
|
+
r.views.length,
|
|
394
|
+
new Set(r.views.flatMap((v) => v.file.images)).size,
|
|
395
|
+
];
|
|
396
|
+
const best = new Map();
|
|
397
|
+
for (const r of rigs) {
|
|
398
|
+
const key = r.folderPath.join('/');
|
|
399
|
+
const cur = best.get(key);
|
|
400
|
+
if (!cur) {
|
|
401
|
+
best.set(key, r);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const [a, b] = [score(r), score(cur)];
|
|
405
|
+
const i = a.findIndex((v, k) => v !== b[k]);
|
|
406
|
+
if (i !== -1 && a[i] > b[i])
|
|
407
|
+
best.set(key, r);
|
|
408
|
+
}
|
|
409
|
+
return [...best.values()];
|
|
410
|
+
}
|
|
411
|
+
/** True when the preview can read this rig's exports. */
|
|
412
|
+
export function rigVersionSupported(rig) {
|
|
413
|
+
return rig.views.every((v) => spineMinorVersion(v.file.spineVersion) === SUPPORTED_SPINE_VERSION);
|
|
414
|
+
}
|
|
415
|
+
/** `Spine rig rabbit (front, side, back) → rabbit/ (509 skins), parts fallback rabbit-pixel/` */
|
|
416
|
+
export function describeRig(rig) {
|
|
417
|
+
const views = rig.views.length > 1 || rig.views[0]?.name !== rig.name ? ` (${rig.views.map((v) => v.name).join(', ')})` : '';
|
|
418
|
+
const skins = rig.skins > 1 ? ` (${rig.skins.toLocaleString('en-US')} skins)` : '';
|
|
419
|
+
const base = rig.baseFolderLabel ? `, parts fallback ${rig.baseFolderLabel}` : '';
|
|
420
|
+
return `Spine rig ${rig.name}${views} → ${rig.folderLabel}${skins}${base}`;
|
|
421
|
+
}
|
|
422
|
+
/** Rigs whose hash differs from the one last uploaded for that folder. */
|
|
423
|
+
export function rigsToUpload(rigs, uploaded) {
|
|
424
|
+
return rigs.filter((r) => uploaded?.[r.folderPath.join('/')] !== r.hash);
|
|
425
|
+
}
|
|
426
|
+
/** Batches for the upload endpoint: ≤ 10 rigs and about `maxBytes` of skeleton text each. */
|
|
427
|
+
export function batchRigs(rigs, maxBytes = 16 * 1024 * 1024) {
|
|
428
|
+
const out = [];
|
|
429
|
+
let cur = [];
|
|
430
|
+
let bytes = 0;
|
|
431
|
+
for (const r of rigs) {
|
|
432
|
+
const size = r.views.reduce((n, v) => n + v.file.text.length, 0);
|
|
433
|
+
if (cur.length > 0 && (cur.length >= MAX_RIGS_PER_REQUEST || bytes + size > maxBytes)) {
|
|
434
|
+
out.push(cur);
|
|
435
|
+
cur = [];
|
|
436
|
+
bytes = 0;
|
|
437
|
+
}
|
|
438
|
+
cur.push(r);
|
|
439
|
+
bytes += size;
|
|
440
|
+
}
|
|
441
|
+
if (cur.length)
|
|
442
|
+
out.push(cur);
|
|
443
|
+
return out;
|
|
444
|
+
}
|