@magicpixelart/cli 0.6.1 → 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 +21 -0
- package/README.md +18 -0
- package/dist/api.js +25 -0
- package/dist/commands/connect.js +7 -4
- package/dist/commands/consolidate.js +4 -3
- package/dist/commands/push.js +16 -5
- package/dist/commands/spine.js +187 -0
- package/dist/commands/start.js +3 -0
- package/dist/commands/sync.js +12 -1
- package/dist/commands/why.js +18 -4
- package/dist/config.js +1 -0
- package/dist/index.js +11 -1
- package/dist/util/commandTips.js +33 -0
- package/dist/util/engineConnect.js +39 -0
- package/dist/util/gameScan.js +1 -1
- package/dist/util/spine.js +444 -0
- package/dist/util/ui.js +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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
|
+
|
|
17
|
+
## [0.6.2] - 2026-10-10
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Merging setups cleans folder patterns (`//`, stray `~`, duplicates, ones a broader folder covers).
|
|
21
|
+
- `connect` cleans typed patterns and rejects empty ones.
|
|
22
|
+
- Sync warns when a hidden art folder (e.g. `.SpineRaw/rabbit`) has PNGs nothing watches, with the exact `connect` command.
|
|
23
|
+
- `why` lists watched patterns once and prints a ready-to-run `connect` command.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Upload progress shows the file's folder path (e.g. `…/rabbit/agni/ear_left_front`), not just its name.
|
|
27
|
+
- "Useful commands" after `start`, "Common tasks" in `--help`, and a short hint after `sync`, all from one shared list.
|
|
28
|
+
|
|
8
29
|
## [0.6.1] - 2026-10-10
|
|
9
30
|
|
|
10
31
|
### Changed
|
package/README.md
CHANGED
|
@@ -102,6 +102,8 @@ No bundler config, no runtime, no extra package.
|
|
|
102
102
|
|
|
103
103
|
## Commands
|
|
104
104
|
|
|
105
|
+
Run `magicpixel --help` for a short "Common tasks" list. A folder isn't syncing? `magicpixel why <folder>` prints the `connect` command that fixes it.
|
|
106
|
+
|
|
105
107
|
| Command | What it does |
|
|
106
108
|
| --- | --- |
|
|
107
109
|
| `start [--force]` | One-command bootstrap. Init + login + first sync. The only command to tell new users to run. |
|
|
@@ -113,6 +115,7 @@ No bundler config, no runtime, no extra package.
|
|
|
113
115
|
| `sync [...flags]` | Two-way: pull MagicPixel edits and push changed game sprites. |
|
|
114
116
|
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and connected game sprites) back to MagicPixel. |
|
|
115
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`. |
|
|
116
119
|
| `connect <glob>` | Limit which game folders sync (default is all sprites on Unity/Godot/GameMaker). |
|
|
117
120
|
| `search <query>` | Search indexed game PNGs (no network). |
|
|
118
121
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
@@ -184,6 +187,21 @@ npx @magicpixelart/cli@latest start # or: magicpixel consolidate
|
|
|
184
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`.
|
|
185
188
|
- Add a folder later with `magicpixel connect "kr-puzzle/**"`.
|
|
186
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
|
+
|
|
187
205
|
## Plain output
|
|
188
206
|
|
|
189
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');
|
package/dist/commands/connect.js
CHANGED
|
@@ -4,7 +4,7 @@ import { loadConfig, saveConfig } from '../config.js';
|
|
|
4
4
|
import { assertSafeGlob } from '../util/security.js';
|
|
5
5
|
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
6
6
|
import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
|
|
7
|
-
import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
|
|
7
|
+
import { isAllSpritesGlob, nextConnectGlobs, normalizeConnectGlob, tidyConnectGlobs } from '../util/engineConnect.js';
|
|
8
8
|
import { cmd } from '../util/invoke.js';
|
|
9
9
|
import { runPush } from './push.js';
|
|
10
10
|
/** Plain-language label for a connect glob (`**` is not meaningful to players). */
|
|
@@ -17,14 +17,17 @@ export function describeWorkingSet(glob) {
|
|
|
17
17
|
* the original files — not a copy under outDir.
|
|
18
18
|
*/
|
|
19
19
|
export async function connectCommand(glob) {
|
|
20
|
-
const pattern = assertSafeGlob(glob);
|
|
20
|
+
const pattern = normalizeConnectGlob(assertSafeGlob(glob));
|
|
21
|
+
if (!pattern)
|
|
22
|
+
throw new Error(`"${glob}" isn't a folder pattern. Example: ${cmd('connect')} 'Assets/Sprites/**'`);
|
|
21
23
|
const config = await loadConfig();
|
|
22
|
-
const next = nextConnectGlobs(config.connect, pattern);
|
|
24
|
+
const next = tidyConnectGlobs(nextConnectGlobs(config.connect.map(normalizeConnectGlob), pattern));
|
|
23
25
|
const changed = next.length !== config.connect.length || next.some((g, i) => g !== config.connect[i]);
|
|
24
26
|
if (changed) {
|
|
27
|
+
const added = !config.connect.map(normalizeConnectGlob).includes(pattern);
|
|
25
28
|
config.connect = next;
|
|
26
29
|
await saveConfig(config);
|
|
27
|
-
console.log(ui.ok(`now syncing ${describeWorkingSet(pattern)}`));
|
|
30
|
+
console.log(ui.ok(added ? `now syncing ${describeWorkingSet(pattern)}` : 'tidied your folder patterns'));
|
|
28
31
|
}
|
|
29
32
|
const kind = await detectProjectKind();
|
|
30
33
|
if (!isEngineKind(kind)) {
|
|
@@ -10,6 +10,7 @@ import { readCredentialsSync, writeCredentials } from '../util/credentials.js';
|
|
|
10
10
|
import { indexGamePngs } from '../util/gameScan.js';
|
|
11
11
|
import { walkOutDirPngs } from '../util/paths.js';
|
|
12
12
|
import { anchorGlob, detectWorkspaceMembers } from '../util/workspace.js';
|
|
13
|
+
import { normalizeConnectGlob, tidyConnectGlobs } from '../util/engineConnect.js';
|
|
13
14
|
import { detectEngineKind, readWorkspaceMembers } from '../util/framework.js';
|
|
14
15
|
import { cmd } from '../util/invoke.js';
|
|
15
16
|
import { ensureGitignore } from './init.js';
|
|
@@ -30,9 +31,9 @@ export function mergeSetups(members, children) {
|
|
|
30
31
|
const configured = children.filter((c) => c.config);
|
|
31
32
|
const first = configured[0]?.config ?? {};
|
|
32
33
|
const uniq = (xs) => [...new Set(xs)];
|
|
33
|
-
const connect =
|
|
34
|
+
const connect = tidyConnectGlobs(configured.flatMap((c) => {
|
|
34
35
|
const globs = c.config.connect?.length ? c.config.connect : ['**'];
|
|
35
|
-
return globs.map((g) => anchorGlob(c.member, g));
|
|
36
|
+
return globs.map((g) => anchorGlob(c.member, normalizeConnectGlob(g)));
|
|
36
37
|
}));
|
|
37
38
|
const exclude = uniq(configured.flatMap((c) => (c.config.exclude ?? []).map((g) => anchorGlob(c.member, g))));
|
|
38
39
|
const include = uniq(configured.flatMap((c) => c.config.include ?? defaultConfig.include));
|
|
@@ -169,7 +170,7 @@ export async function consolidateCommand(opts = {}) {
|
|
|
169
170
|
if (current) {
|
|
170
171
|
merged.config = {
|
|
171
172
|
...current,
|
|
172
|
-
connect:
|
|
173
|
+
connect: tidyConnectGlobs([...(current.connect ?? []), ...merged.config.connect]),
|
|
173
174
|
exclude: [...new Set([...(current.exclude ?? []), ...merged.config.exclude])],
|
|
174
175
|
};
|
|
175
176
|
}
|
package/dist/commands/push.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* `POST /api/public/integration/ingest`.
|
|
8
8
|
*/
|
|
9
9
|
import kleur from 'kleur';
|
|
10
|
-
import { icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
|
|
10
|
+
import { icon, progressBar, shortenPath, spinner as makeSpinner, ui, visibleWidth } from '../util/ui.js';
|
|
11
11
|
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
12
12
|
import { dirname, relative, resolve } from 'node:path';
|
|
13
13
|
import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
|
|
14
|
-
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
14
|
+
import { ensureEngineConnect, unwatchedHiddenFolders } from '../util/engineConnect.js';
|
|
15
15
|
import { PUSH_BATCH_SIZE, pushSpritesAdaptive, fetchManifestPage, getLastProjectInfo } from '../api.js';
|
|
16
16
|
import { hashFile } from '../util/hash.js';
|
|
17
17
|
import { createLimit } from '../util/limit.js';
|
|
@@ -80,6 +80,13 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
80
80
|
: { files: [] });
|
|
81
81
|
const matched = matchConnectGlobs(index, config.connect ?? [], config.exclude);
|
|
82
82
|
const sourceByKey = collectSourceRelMap(matched.entries, state.synced);
|
|
83
|
+
if (!quiet) {
|
|
84
|
+
const watched = new Set(matched.entries.map((e) => e.key));
|
|
85
|
+
for (const [folder, n] of unwatchedHiddenFolders(index.files, watched, config.exclude ?? []).slice(0, 5)) {
|
|
86
|
+
console.log(ui.warn(`${n.toLocaleString('en-US')} PNGs in ${folder} aren't watched.`));
|
|
87
|
+
console.log(ui.fix(`${cmd('connect')} '${folder}/**'`));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
83
90
|
const disk = await walkOutDirPngs(config.outDir);
|
|
84
91
|
const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
|
|
85
92
|
const entryByKey = new Map(matched.entries.map((e) => [e.key, e]));
|
|
@@ -256,10 +263,14 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
256
263
|
});
|
|
257
264
|
await saveState({ ...state, synced });
|
|
258
265
|
const doneN = Math.min(i + batch.length, sprites.length);
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
const last = batch[batch.length - 1];
|
|
267
|
+
const where = last ? fpByKey.get(last.key)?.sourceRel ?? last.key : '';
|
|
268
|
+
if (progress) {
|
|
269
|
+
const head = `Uploading ${progressBar(doneN, sprites.length)} `;
|
|
270
|
+
progress.text = head + kleur.dim(shortenPath(where, (process.stdout.columns ?? 80) - visibleWidth(head) - 4));
|
|
271
|
+
}
|
|
261
272
|
else
|
|
262
|
-
opts.onStatus?.(`${icon('up')} Uploading ${doneN}/${sprites.length} ${
|
|
273
|
+
opts.onStatus?.(`${icon('up')} Uploading ${doneN}/${sprites.length} ${shortenPath(where, 60)}…`);
|
|
263
274
|
}
|
|
264
275
|
}
|
|
265
276
|
catch (e) {
|
|
@@ -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/start.js
CHANGED
|
@@ -25,6 +25,7 @@ import { assertKeyValid } from '../util/auth.js';
|
|
|
25
25
|
import { ApiError } from '../api.js';
|
|
26
26
|
import { connectCommand } from './connect.js';
|
|
27
27
|
import { isAllSpritesGlob } from '../util/engineConnect.js';
|
|
28
|
+
import { COMMAND_TIPS, formatTipsBlock } from '../util/commandTips.js';
|
|
28
29
|
/** Validate a key; only auth rejections map to 'rejected' — other failures throw. */
|
|
29
30
|
export async function checkKey(key, config) {
|
|
30
31
|
try {
|
|
@@ -215,6 +216,8 @@ export async function startCommand(opts = {}) {
|
|
|
215
216
|
console.log();
|
|
216
217
|
console.log(kleur.dim(' Game sprites write back to their original path. New MagicPixel art lands in outDir.'));
|
|
217
218
|
console.log();
|
|
219
|
+
console.log(formatTipsBlock(COMMAND_TIPS.slice(1)));
|
|
220
|
+
console.log();
|
|
218
221
|
if (await hasDevScript()) {
|
|
219
222
|
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
|
|
220
223
|
console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
|
-
import { canRedraw, card, fitLine, formatDuration, icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
|
|
2
|
+
import { canRedraw, card, isPlain, fitLine, formatDuration, icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
|
|
3
3
|
import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
@@ -28,11 +28,13 @@ 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';
|
|
34
35
|
import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
|
|
35
36
|
import { placeBesideSiblings, splitStrayDownloads, straySkipMessage } from '../util/strayGuard.js';
|
|
37
|
+
import { syncFooterHint } from '../util/commandTips.js';
|
|
36
38
|
/** How often the watcher asks for a library "Resync from game" request. */
|
|
37
39
|
const RESYNC_POLL_MS = 15_000;
|
|
38
40
|
let lastResyncPollAt = 0;
|
|
@@ -1593,11 +1595,20 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1593
1595
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
1594
1596
|
if (renamed.length > 0)
|
|
1595
1597
|
printRenames(renamed, { withHints: true });
|
|
1598
|
+
if (!runOpts.watchMode && !isPlain())
|
|
1599
|
+
console.log(kleur.dim(syncFooterHint()));
|
|
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 });
|
|
1596
1606
|
}
|
|
1597
1607
|
if (result.failed)
|
|
1598
1608
|
process.exitCode = 1;
|
|
1599
1609
|
return { ...result, pushed, ...(strays ? { strays } : {}) };
|
|
1600
1610
|
}
|
|
1611
|
+
let spineCheckedThisProcess = false;
|
|
1601
1612
|
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1602
1613
|
if (pushEnabled === false)
|
|
1603
1614
|
return null;
|
package/dist/commands/why.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
|
-
import { spinner as makeSpinner } from '../util/ui.js';
|
|
2
|
+
import { spinner as makeSpinner, ui } from '../util/ui.js';
|
|
3
3
|
import { loadConfig, loadState } from '../config.js';
|
|
4
4
|
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
5
5
|
import { countingSpritesText, entryMatchesGlob, indexGamePngs, rootSpriteDotDirs } from '../util/gameScan.js';
|
|
@@ -27,10 +27,10 @@ export function explainSprite(entry, config, synced, currentSha) {
|
|
|
27
27
|
return { kind: 'unchanged' };
|
|
28
28
|
return { kind: 'changed', ...(typeof known.layers === 'number' ? { layers: known.layers } : {}) };
|
|
29
29
|
}
|
|
30
|
-
export function describeVerdict(v
|
|
30
|
+
export function describeVerdict(v) {
|
|
31
31
|
switch (v.kind) {
|
|
32
32
|
case 'not-watched':
|
|
33
|
-
return
|
|
33
|
+
return 'not watched — no folder pattern covers it';
|
|
34
34
|
case 'excluded':
|
|
35
35
|
return `skipped by exclude rule "${v.rule}" — remove it from "exclude" in magicpixel.json to sync this file`;
|
|
36
36
|
case 'new':
|
|
@@ -73,9 +73,13 @@ export async function whyCommand(pathArg) {
|
|
|
73
73
|
const synced = state.synced ?? {};
|
|
74
74
|
const counts = new Map();
|
|
75
75
|
const lines = [];
|
|
76
|
+
let unwatched = 0;
|
|
76
77
|
for (const e of files) {
|
|
77
78
|
const sha = (await hashFile(e.abs))?.sha256 ?? null;
|
|
78
|
-
const
|
|
79
|
+
const verdict = explainSprite(e, config, synced, sha);
|
|
80
|
+
if (verdict.kind === 'not-watched')
|
|
81
|
+
unwatched++;
|
|
82
|
+
const text = describeVerdict(verdict);
|
|
79
83
|
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
80
84
|
if (lines.length < 20)
|
|
81
85
|
lines.push(` ${e.sourceRel}\n ${kleur.dim('→')} ${text}`);
|
|
@@ -88,4 +92,14 @@ export async function whyCommand(pathArg) {
|
|
|
88
92
|
console.log(lines.join('\n'));
|
|
89
93
|
if (files.length > lines.length)
|
|
90
94
|
console.log(kleur.dim(` …and ${files.length - lines.length} more`));
|
|
95
|
+
if (unwatched > 0) {
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(kleur.dim(` Watching: ${(config.connect ?? []).join(', ') || 'nothing'}`));
|
|
98
|
+
console.log(ui.fix(whyConnectCommand(files[0].sourceRel, target), ' '));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Ready-to-run connect command for the folder the user asked about. */
|
|
102
|
+
export function whyConnectCommand(firstRel, target) {
|
|
103
|
+
const folder = firstRel.toLowerCase() === target ? firstRel.split('/').slice(0, -1).join('/') : firstRel.slice(0, target.length);
|
|
104
|
+
return `${cmd('connect')} '${folder ? `${folder}/**` : '**'}'`;
|
|
91
105
|
}
|
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';
|
|
@@ -25,6 +26,7 @@ import { cleanStraysCommand } from './commands/cleanStrays.js';
|
|
|
25
26
|
import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
|
|
26
27
|
import { CLI_VERSION } from './version.js';
|
|
27
28
|
import { cmd } from './util/invoke.js';
|
|
29
|
+
import { formatHelpTips } from './util/commandTips.js';
|
|
28
30
|
// Node version guard
|
|
29
31
|
const major = Number(process.versions.node.split('.')[0]);
|
|
30
32
|
if (major < 18) {
|
|
@@ -54,7 +56,8 @@ const program = new Command();
|
|
|
54
56
|
program
|
|
55
57
|
.name('magicpixel')
|
|
56
58
|
.description('Sync MagicPixel pixel-art assets to your local project')
|
|
57
|
-
.version(CLI_VERSION)
|
|
59
|
+
.version(CLI_VERSION)
|
|
60
|
+
.addHelpText('after', formatHelpTips());
|
|
58
61
|
const wrap = (commandName, fn) => async (...args) => {
|
|
59
62
|
try {
|
|
60
63
|
await fn(...args);
|
|
@@ -145,6 +148,13 @@ program
|
|
|
145
148
|
.option('--dry-run', 'Show what would change without sending anything')
|
|
146
149
|
.addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
|
|
147
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)));
|
|
148
158
|
program
|
|
149
159
|
.command('connect <glob>')
|
|
150
160
|
.description('Limit which game folders sync (default is all sprites)')
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common commands shown after `start`, in `--help`, and hinted after `sync`.
|
|
3
|
+
* One list so the three places never drift apart.
|
|
4
|
+
*/
|
|
5
|
+
import kleur from 'kleur';
|
|
6
|
+
import { cmd } from './invoke.js';
|
|
7
|
+
export const COMMAND_TIPS = [
|
|
8
|
+
{ run: 'sync --watch', what: 'keep sprites fresh while you work' },
|
|
9
|
+
{ run: "connect '<folder>/**'", what: 'add a folder to sync (hidden ones like .SpineRaw/... too)' },
|
|
10
|
+
{ run: 'sync --only <folder>', what: 'sync just one folder' },
|
|
11
|
+
{ run: 'why <folder>', what: "see why files aren't uploading" },
|
|
12
|
+
{ run: 'resync <folder>', what: 'local files win for one folder' },
|
|
13
|
+
{ run: 'clean-strays', what: 'remove copies in the wrong place' },
|
|
14
|
+
{ run: 'status', what: 'check your setup and last sync' },
|
|
15
|
+
{ run: 'doctor', what: 'diagnose problems' },
|
|
16
|
+
];
|
|
17
|
+
const width = Math.max(...COMMAND_TIPS.map((t) => t.run.length));
|
|
18
|
+
/** "Useful commands" block for the end of `start`. */
|
|
19
|
+
export function formatTipsBlock(tips = COMMAND_TIPS) {
|
|
20
|
+
const prefix = cmd('').trimEnd();
|
|
21
|
+
return [
|
|
22
|
+
kleur.bold(' Useful commands'),
|
|
23
|
+
...tips.map((t) => ` ${kleur.cyan(`${prefix} ${t.run.padEnd(width)}`)} ${kleur.dim(t.what)}`),
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
/** "Common tasks" section for `magicpixel --help` (no colors: commander prints raw). */
|
|
27
|
+
export function formatHelpTips() {
|
|
28
|
+
return ['', 'Common tasks:', ...COMMAND_TIPS.map((t) => ` $ magicpixel ${t.run.padEnd(width)} # ${t.what}`), ''].join('\n');
|
|
29
|
+
}
|
|
30
|
+
/** One-line hint after a one-shot sync. */
|
|
31
|
+
export function syncFooterHint() {
|
|
32
|
+
return ` Not seeing a folder? Run \`${cmd('why')} <folder>\`. All commands: \`${cmd('--help')}\`.`;
|
|
33
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { saveConfig } from '../config.js';
|
|
2
2
|
import { detectProjectKind, isEngineKind } from './framework.js';
|
|
3
|
+
import { entryMatchesGlob } from './gameScan.js';
|
|
3
4
|
export const DEFAULT_ENGINE_CONNECT = ['**'];
|
|
4
5
|
const ALL_SPRITES_GLOBS = new Set(['**', '**/*', '**/**', '**/*.png', '**/**/*.png']);
|
|
5
6
|
export function isAllSpritesGlob(glob) {
|
|
@@ -19,6 +20,44 @@ export function nextConnectGlobs(current, pattern) {
|
|
|
19
20
|
return withoutAll;
|
|
20
21
|
return [...withoutAll, pattern];
|
|
21
22
|
}
|
|
23
|
+
/** Clean one folder pattern: `\` → `/`, collapse `//`, drop `./`, a trailing `/` or stray `~`. */
|
|
24
|
+
export function normalizeConnectGlob(glob) {
|
|
25
|
+
return glob
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/\\/g, '/')
|
|
28
|
+
.replace(/\/{2,}/g, '/')
|
|
29
|
+
.replace(/^(\.\/)+/, '')
|
|
30
|
+
.replace(/~+$/, '')
|
|
31
|
+
.replace(/\/+$/, '');
|
|
32
|
+
}
|
|
33
|
+
/** Normalize, dedupe, and drop patterns already covered by a broader `<folder>/**`. */
|
|
34
|
+
export function tidyConnectGlobs(globs) {
|
|
35
|
+
const clean = [...new Set(globs.map(normalizeConnectGlob).filter(Boolean))];
|
|
36
|
+
const roots = clean.filter((g) => g.endsWith('/**')).map((g) => g.slice(0, -3).toLowerCase());
|
|
37
|
+
return clean.filter((g) => {
|
|
38
|
+
const base = (g.endsWith('/**') ? g.slice(0, -3) : g).toLowerCase();
|
|
39
|
+
return !roots.some((r) => r !== base && (base === r || base.startsWith(`${r}/`)))
|
|
40
|
+
&& !(roots.includes(base) && !g.endsWith('/**'));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Hidden art folders (`<member>/.SpineRaw/<name>`) with PNGs that no pattern
|
|
45
|
+
* watches. Returns `[folder, count]`, largest first.
|
|
46
|
+
*/
|
|
47
|
+
export function unwatchedHiddenFolders(files, watchedKeys, exclude = [], min = 10) {
|
|
48
|
+
const counts = new Map();
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
if (watchedKeys.has(f.key) || exclude.some((g) => entryMatchesGlob(f, g)))
|
|
51
|
+
continue;
|
|
52
|
+
const parts = f.sourceRel.split('/');
|
|
53
|
+
const i = parts.findIndex((p) => p.startsWith('.') && p.length > 1);
|
|
54
|
+
if (i < 0 || i + 2 >= parts.length)
|
|
55
|
+
continue;
|
|
56
|
+
const folder = parts.slice(0, i + 2).join('/');
|
|
57
|
+
counts.set(folder, (counts.get(folder) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
return [...counts].filter(([, n]) => n >= min).sort((a, b) => b[1] - a[1]);
|
|
60
|
+
}
|
|
22
61
|
export async function ensureEngineConnect(config, cwd = process.cwd(), persist = true) {
|
|
23
62
|
if (config.connect.length > 0)
|
|
24
63
|
return config;
|
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
|
+
}
|
package/dist/util/ui.js
CHANGED
|
@@ -71,6 +71,25 @@ export function visibleWidth(s) {
|
|
|
71
71
|
return w;
|
|
72
72
|
}
|
|
73
73
|
/** Cut a plain line to `max` visible columns. */
|
|
74
|
+
/**
|
|
75
|
+
* Path that fits in `max` columns, keeping the end (the file and its parent
|
|
76
|
+
* folders) and dropping leading folders: `…/rabbit/agni/ear_left_front.png`.
|
|
77
|
+
*/
|
|
78
|
+
export function shortenPath(path, max) {
|
|
79
|
+
const p = path.replace(/\.png$/i, '');
|
|
80
|
+
const limit = Math.max(12, max);
|
|
81
|
+
if (p.length <= limit)
|
|
82
|
+
return p;
|
|
83
|
+
const parts = p.split('/');
|
|
84
|
+
let out = parts[parts.length - 1];
|
|
85
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
86
|
+
const next = `${parts[i]}/${out}`;
|
|
87
|
+
if (next.length + 2 > limit)
|
|
88
|
+
break;
|
|
89
|
+
out = next;
|
|
90
|
+
}
|
|
91
|
+
return out.length + 2 <= limit ? `…/${out}` : `…${out.slice(-(limit - 1))}`;
|
|
92
|
+
}
|
|
74
93
|
export function fitLine(line, columns) {
|
|
75
94
|
const max = Math.max(20, (columns ?? 80) - 1);
|
|
76
95
|
if (visibleWidth(line) <= max)
|