@magicpixelart/cli 0.5.12 → 0.5.14
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 +51 -0
- package/README.md +9 -8
- package/dist/api.js +40 -4
- package/dist/commands/connect.js +14 -10
- package/dist/commands/init.js +3 -0
- package/dist/commands/push.js +89 -47
- package/dist/commands/start.js +2 -3
- package/dist/commands/sync.js +118 -30
- package/dist/index.js +2 -2
- package/dist/util/engineConnect.js +32 -0
- package/dist/util/gameScan.js +117 -56
- package/dist/util/pullDecision.js +30 -0
- package/dist/util/syncPath.js +31 -0
- package/dist/util/unityFilter.js +54 -2
- package/dist/util/watchCopy.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.14] — 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Watch no longer re-downloads the whole game on catch-up.** Connect
|
|
15
|
+
originals almost never match MagicPixel's re-encoded composite hash, so
|
|
16
|
+
comparing those bytes queued ~every PNG (`492/3116…`) and would overwrite
|
|
17
|
+
the Unity files. Pull now skips a connected sprite unless the cloud
|
|
18
|
+
composite actually changed (or an editor save cleared the cached hash).
|
|
19
|
+
Ingest collision slugs (`ground-ice-snow-4`) alias onto the original PNG
|
|
20
|
+
instead of dumping copies into `Assets/MagicPixel`.
|
|
21
|
+
- **`connect <folder>` actually narrows the default `**` working set.** Adding
|
|
22
|
+
a folder used to OR with `**` and keep every sprite. `connect '**'` restores
|
|
23
|
+
all sprites. Godot's `.godot/` import cache is not ingested.
|
|
24
|
+
- **Editor saves of connected sprites still pull when the Unity flag is
|
|
25
|
+
missing.** A save nulls `artboard_index`; the next manifest often omits
|
|
26
|
+
`unity`. Watch writes those pixels back to the original game PNG. `lastSync`
|
|
27
|
+
advances past every row the fetch saw, including skipped drafts.
|
|
28
|
+
- **Watch no longer sits silent on `Checking your game files…`.** After a
|
|
29
|
+
pull, the upload half hashes local PNGs; the status line now shows
|
|
30
|
+
`1,200 / 3,126` instead of a frozen timestamp. The game-tree walk is reused
|
|
31
|
+
for 30s so each 2s poll does not re-list thousands of folders.
|
|
32
|
+
- **Ctrl+C drain message** says `Ctrl+C again` instead of `undefined`.
|
|
33
|
+
- **Single-artboard downloads no longer composite in the edge worker.**
|
|
34
|
+
Unity ingest sheets (including 2048/4096 tiles) are served from storage
|
|
35
|
+
so a catch-up download does not 546 the isolate. The CLI writes those
|
|
36
|
+
bytes even when they differ from a cached composite hash.
|
|
37
|
+
|
|
38
|
+
## [0.5.13] — 2026-08-26
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Connect no longer hangs at `0 sprites · 1 folder`.** Full Unity games
|
|
43
|
+
(`ProjectSettings` present) scan `Assets/` only — listing Library and the
|
|
44
|
+
rest of the project root blocked the spinner before any sprite was found.
|
|
45
|
+
Directory reads stream so the path keeps updating (`Assets/Sprites`), and
|
|
46
|
+
nested walks no longer deadlock on the folder limiter.
|
|
47
|
+
- **Push no longer aborts the rest of a connect on a 502.** Batches of 8
|
|
48
|
+
(down from 20) persist after each success. A gateway error splits the
|
|
49
|
+
batch instead of crashing; already-uploaded sprites are remembered so a
|
|
50
|
+
re-run skips them.
|
|
51
|
+
- **`connect` copy.** Re-running `connect '**'` no longer prints
|
|
52
|
+
`connect pattern already present: **`. First-time connect says
|
|
53
|
+
`now syncing all sprites in your game`.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **Unity/Godot/GameMaker default to all game sprites.** `start` and `sync`
|
|
58
|
+
ingest the game tree without a separate `connect '**'`. Use `connect` only
|
|
59
|
+
to narrow folders. Daily command is `sync --watch`.
|
|
60
|
+
|
|
10
61
|
## [0.5.12] — 2026-08-26
|
|
11
62
|
|
|
12
63
|
### Fixed
|
package/README.md
CHANGED
|
@@ -24,14 +24,15 @@ Unity project or UPM package (`Assets`/`assets`, `Editor`/`Runtime`), Godot
|
|
|
24
24
|
npx @magicpixelart/cli start
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
That walks you through everything: detects your framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys),
|
|
27
|
+
That walks you through everything: detects your engine or framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys), and syncs sprites both ways. Unity, Godot, and GameMaker default to all game PNGs.
|
|
28
|
+
|
|
29
|
+
Then leave this running while you work:
|
|
28
30
|
|
|
29
31
|
```bash
|
|
30
|
-
npx @magicpixelart/cli
|
|
31
|
-
npx @magicpixelart/cli connect 'assets/Sprites/**'
|
|
32
|
+
npx @magicpixelart/cli sync --watch
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`.
|
|
35
|
+
After `npm i -D @magicpixelart/cli` you can type `magicpixel sync --watch`. Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`. To sync only some folders: `npx @magicpixelart/cli connect 'assets/Sprites/**'`.
|
|
35
36
|
|
|
36
37
|
When it finishes:
|
|
37
38
|
|
|
@@ -109,9 +110,9 @@ No bundler config, no runtime, no extra package.
|
|
|
109
110
|
| `logout` | Remove the stored API key. |
|
|
110
111
|
| `doctor` | Print a one-screen diagnostic — paste it to your AI agent when something breaks. |
|
|
111
112
|
| `repair [--dry-run] [-y]` | Self-heal a broken sync: validate key → quarantine `state.json` → prune empty dirs → full re-sync. |
|
|
112
|
-
| `sync [...flags]` |
|
|
113
|
-
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and
|
|
114
|
-
| `connect <glob>` |
|
|
113
|
+
| `sync [...flags]` | Two-way: pull MagicPixel edits and push changed game sprites. |
|
|
114
|
+
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and connected game sprites) back to MagicPixel. |
|
|
115
|
+
| `connect <glob>` | Limit which game folders sync (default is all sprites on Unity/Godot/GameMaker). |
|
|
115
116
|
| `search <query>` | Search indexed game PNGs (no network). |
|
|
116
117
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
117
118
|
| `list` | Print matching manifest as a table. |
|
|
@@ -146,7 +147,7 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
146
147
|
| `outDir` | `string` | framework-dependent | Where PNGs (and `index.ts`) are written. |
|
|
147
148
|
| `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
|
|
148
149
|
| `exclude` | `string[]` | `[]` | Globs to exclude. |
|
|
149
|
-
| `connect` | `string[]` | `[]`
|
|
150
|
+
| `connect` | `string[]` | `["**"]` on engines | Game PNG globs to keep in Connected. Empty on JS projects. |
|
|
150
151
|
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
151
152
|
| `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
|
|
152
153
|
| `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
|
package/dist/api.js
CHANGED
|
@@ -280,15 +280,15 @@ export function retryAfterMsFromResponse(res) {
|
|
|
280
280
|
}
|
|
281
281
|
return 0;
|
|
282
282
|
}
|
|
283
|
-
/**
|
|
284
|
-
export const PUSH_BATCH_SIZE =
|
|
283
|
+
/** CLI send size. The server allows 20; smaller batches survive Worker 502s. */
|
|
284
|
+
export const PUSH_BATCH_SIZE = 8;
|
|
285
285
|
const DEFAULT_INGEST_ENDPOINT = 'https://magicpixel.art/api/public/integration/ingest';
|
|
286
286
|
export function resolveIngestEndpoint() {
|
|
287
287
|
const override = process.env.MAGICPIXEL_INGEST_ENDPOINT?.trim();
|
|
288
288
|
return override ? validateEndpointUrl(override) : DEFAULT_INGEST_ENDPOINT;
|
|
289
289
|
}
|
|
290
290
|
/** Post one batch of sprites. Shares the transient-retry policy with pulls. */
|
|
291
|
-
export async function pushSprites(sprites) {
|
|
291
|
+
export async function pushSprites(sprites, retryOpts) {
|
|
292
292
|
const url = resolveIngestEndpoint();
|
|
293
293
|
return retryTransient('push', async () => {
|
|
294
294
|
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
@@ -308,5 +308,41 @@ export async function pushSprites(sprites) {
|
|
|
308
308
|
}
|
|
309
309
|
const bodyText = await res.text();
|
|
310
310
|
throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'push'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
|
|
311
|
-
});
|
|
311
|
+
}, retryOpts);
|
|
312
|
+
}
|
|
313
|
+
function isTransientPushError(e) {
|
|
314
|
+
return e instanceof ApiError && (e.status >= 500 || e.status === 429);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Push a batch, splitting in half on persistent 502/5xx so one fat request
|
|
318
|
+
* cannot abort the rest of a connect/sync.
|
|
319
|
+
*/
|
|
320
|
+
export async function pushSpritesAdaptive(sprites, retryOpts) {
|
|
321
|
+
if (sprites.length === 0)
|
|
322
|
+
return [];
|
|
323
|
+
try {
|
|
324
|
+
return await pushSprites(sprites, retryOpts);
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
if (!isTransientPushError(e))
|
|
328
|
+
throw e;
|
|
329
|
+
if (sprites.length === 1) {
|
|
330
|
+
return [{ key: sprites[0].key, status: 'error', message: e.message }];
|
|
331
|
+
}
|
|
332
|
+
const mid = Math.ceil(sprites.length / 2);
|
|
333
|
+
const left = await pushSpritesAdaptive(sprites.slice(0, mid), retryOpts);
|
|
334
|
+
try {
|
|
335
|
+
const right = await pushSpritesAdaptive(sprites.slice(mid), retryOpts);
|
|
336
|
+
return [...left, ...right];
|
|
337
|
+
}
|
|
338
|
+
catch (rightErr) {
|
|
339
|
+
// Left half already landed — don't drop those results when the rest fails.
|
|
340
|
+
const right = sprites.slice(mid).map((s) => ({
|
|
341
|
+
key: s.key,
|
|
342
|
+
status: 'error',
|
|
343
|
+
message: rightErr.message,
|
|
344
|
+
}));
|
|
345
|
+
return [...left, ...right];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
312
348
|
}
|
package/dist/commands/connect.js
CHANGED
|
@@ -4,23 +4,27 @@ 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 { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
|
|
7
|
+
import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
|
|
7
8
|
import { cmd } from '../util/invoke.js';
|
|
8
9
|
import { runPush } from './push.js';
|
|
10
|
+
/** Plain-language label for a connect glob (`**` is not meaningful to players). */
|
|
11
|
+
export function describeWorkingSet(glob) {
|
|
12
|
+
return isAllSpritesGlob(glob) ? 'all sprites in your game' : glob.trim();
|
|
13
|
+
}
|
|
9
14
|
/**
|
|
10
|
-
* Add a working-set glob
|
|
11
|
-
*
|
|
15
|
+
* Add or narrow a working-set glob, then ingest matching game PNGs.
|
|
16
|
+
* A specific folder replaces the default `**`. Sync writes edits back to
|
|
17
|
+
* the original files — not a copy under outDir.
|
|
12
18
|
*/
|
|
13
19
|
export async function connectCommand(glob) {
|
|
14
20
|
const pattern = assertSafeGlob(glob);
|
|
15
21
|
const config = await loadConfig();
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
const next = nextConnectGlobs(config.connect, pattern);
|
|
23
|
+
const changed = next.length !== config.connect.length || next.some((g, i) => g !== config.connect[i]);
|
|
24
|
+
if (changed) {
|
|
25
|
+
config.connect = next;
|
|
19
26
|
await saveConfig(config);
|
|
20
|
-
console.log(kleur.green(`✓
|
|
21
|
-
}
|
|
22
|
-
else {
|
|
23
|
-
console.log(kleur.dim(` connect pattern already present: ${pattern}`));
|
|
27
|
+
console.log(kleur.green(`✓ now syncing ${describeWorkingSet(pattern)}`));
|
|
24
28
|
}
|
|
25
29
|
const kind = await detectProjectKind();
|
|
26
30
|
if (!isEngineKind(kind)) {
|
|
@@ -44,7 +48,7 @@ export async function connectCommand(glob) {
|
|
|
44
48
|
return;
|
|
45
49
|
}
|
|
46
50
|
const n = matched.entries.length;
|
|
47
|
-
console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'}
|
|
51
|
+
console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'} to sync (of ${index.files.length} in your game` +
|
|
48
52
|
(matched.capped ? `, ${matched.total} matched` : '') +
|
|
49
53
|
`).`));
|
|
50
54
|
if (n >= 200) {
|
package/dist/commands/init.js
CHANGED
|
@@ -9,6 +9,7 @@ import { detectProjectKind, suggestOutDir, hasPackageJson, isStaticOutDir, suppo
|
|
|
9
9
|
import { assertSafeOutDir } from '../util/security.js';
|
|
10
10
|
import { atomicWrite } from '../util/atomicWrite.js';
|
|
11
11
|
import { cmd } from '../util/invoke.js';
|
|
12
|
+
import { DEFAULT_ENGINE_CONNECT } from '../util/engineConnect.js';
|
|
12
13
|
const WATCH_SCRIPT_NAME = 'magicpixel:watch';
|
|
13
14
|
const WATCH_SCRIPT_CMD = 'magicpixel sync --watch';
|
|
14
15
|
export async function initCommand(opts) {
|
|
@@ -23,6 +24,8 @@ export async function initCommand(opts) {
|
|
|
23
24
|
const kind = await detectProjectKind();
|
|
24
25
|
const suggestedOutDir = suggestOutDir(kind);
|
|
25
26
|
config.outDir = suggestedOutDir;
|
|
27
|
+
if (isEngineKind(kind))
|
|
28
|
+
config.connect = [...DEFAULT_ENGINE_CONNECT];
|
|
26
29
|
const pkgExists = hasPackageJson();
|
|
27
30
|
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
28
31
|
let addWatchScript = pkgExists && !isEngineKind(kind);
|
package/dist/commands/push.js
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
import kleur from 'kleur';
|
|
10
10
|
import ora from 'ora';
|
|
11
11
|
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
12
|
-
import { dirname } from 'node:path';
|
|
13
|
-
import { relative, resolve } from 'node:path';
|
|
12
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
14
13
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
15
|
-
import {
|
|
14
|
+
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
15
|
+
import { PUSH_BATCH_SIZE, pushSpritesAdaptive } from '../api.js';
|
|
16
16
|
import { hashFile } from '../util/hash.js';
|
|
17
17
|
import { createLimit } from '../util/limit.js';
|
|
18
18
|
import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
|
|
@@ -35,7 +35,7 @@ export async function pushCommand(opts = {}) {
|
|
|
35
35
|
* set is ingested without a second command).
|
|
36
36
|
*/
|
|
37
37
|
export async function runPush(opts = {}) {
|
|
38
|
-
const config = await loadConfig();
|
|
38
|
+
const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
|
|
39
39
|
const state = await loadState();
|
|
40
40
|
return runPushWith(config, state, opts);
|
|
41
41
|
}
|
|
@@ -68,10 +68,22 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
68
68
|
const candidates = [];
|
|
69
69
|
const cwdAbs = resolve(process.cwd());
|
|
70
70
|
let skippedUnsafe = 0;
|
|
71
|
-
const
|
|
72
|
-
const byRel = indexSyncedBySourceRel(
|
|
71
|
+
const knownSynced = state.synced ?? {};
|
|
72
|
+
const byRel = indexSyncedBySourceRel(knownSynced);
|
|
73
73
|
const scan = createLimit(PUSH_SCAN_CONCURRENCY);
|
|
74
|
-
|
|
74
|
+
const toHash = [...absByKey.entries()];
|
|
75
|
+
let hashed = 0;
|
|
76
|
+
const reportHash = () => {
|
|
77
|
+
hashed++;
|
|
78
|
+
const n = hashed.toLocaleString('en-US');
|
|
79
|
+
const t = toHash.length.toLocaleString('en-US');
|
|
80
|
+
if (spinner)
|
|
81
|
+
spinner.text = `Checking your game files… ${n} / ${t}`;
|
|
82
|
+
else if (opts.onStatus && (hashed === 1 || hashed === toHash.length || hashed % 25 === 0)) {
|
|
83
|
+
opts.onStatus(`Checking your game files… ${n} / ${t}`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
await Promise.all(toHash.map(([key, abs]) => scan(async () => {
|
|
75
87
|
try {
|
|
76
88
|
if (!verifiedIoPaths.has(abs)) {
|
|
77
89
|
await assertSafeIoPath(abs, cwdAbs);
|
|
@@ -80,29 +92,33 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
80
92
|
}
|
|
81
93
|
catch {
|
|
82
94
|
skippedUnsafe++;
|
|
95
|
+
reportHash();
|
|
83
96
|
return;
|
|
84
97
|
}
|
|
85
98
|
const entry = entryByKey.get(key);
|
|
86
99
|
const sourceRel = entry?.sourceRel ?? sourceByKey.get(key);
|
|
87
|
-
const stateKey = resolveSyncedKey({ key, sourceRel },
|
|
88
|
-
const known = stateKey ?
|
|
100
|
+
const stateKey = resolveSyncedKey({ key, sourceRel }, knownSynced, byRel);
|
|
101
|
+
const known = stateKey ? knownSynced[stateKey] : undefined;
|
|
89
102
|
const hint = known?.diskSha256 && known.diskMtimeMs != null && known.diskSize != null
|
|
90
103
|
? { sha256: known.diskSha256, mtimeMs: known.diskMtimeMs, size: known.diskSize }
|
|
91
104
|
: undefined;
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
105
|
+
const hashedFile = await hashFile(abs, hint);
|
|
106
|
+
if (!hashedFile) {
|
|
107
|
+
reportHash();
|
|
94
108
|
return;
|
|
109
|
+
}
|
|
95
110
|
const segments = entry
|
|
96
111
|
? entry.adoptRel.replace(/\.png$/i, '').split('/')
|
|
97
112
|
: key.split('/');
|
|
98
113
|
candidates.push({
|
|
99
114
|
key,
|
|
100
115
|
segments,
|
|
101
|
-
diskSha256:
|
|
116
|
+
diskSha256: hashedFile.sha256,
|
|
102
117
|
...(sourceRel ? { sourceRel } : {}),
|
|
103
|
-
diskMtimeMs:
|
|
104
|
-
diskSize:
|
|
118
|
+
diskMtimeMs: hashedFile.mtimeMs,
|
|
119
|
+
diskSize: hashedFile.size,
|
|
105
120
|
});
|
|
121
|
+
reportHash();
|
|
106
122
|
})));
|
|
107
123
|
const folderTreeKeys = new Set(matched.entries.map((e) => e.key));
|
|
108
124
|
for (const k of absByKey.keys()) {
|
|
@@ -183,43 +199,37 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
183
199
|
}
|
|
184
200
|
const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
|
|
185
201
|
const results = [];
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
202
|
+
let synced = { ...(state.synced ?? {}) };
|
|
203
|
+
try {
|
|
204
|
+
for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
|
|
205
|
+
const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
|
|
206
|
+
const batchResults = await pushSpritesAdaptive(batch);
|
|
207
|
+
results.push(...batchResults);
|
|
208
|
+
synced = await commitPushResults({
|
|
209
|
+
synced,
|
|
210
|
+
batchResults,
|
|
211
|
+
candidates,
|
|
212
|
+
sourceByKey,
|
|
213
|
+
shaByKey,
|
|
214
|
+
fpByKey,
|
|
215
|
+
outDir: config.outDir,
|
|
216
|
+
});
|
|
217
|
+
await saveState({ ...state, synced });
|
|
218
|
+
if (progress)
|
|
219
|
+
progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
progress?.fail(`Push stopped after ${results.length}/${sprites.length}. ${e.message}`);
|
|
224
|
+
if (results.length > 0)
|
|
225
|
+
await saveState({ ...state, synced });
|
|
226
|
+
throw e;
|
|
192
227
|
}
|
|
193
228
|
const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
|
|
194
|
-
const failedKeys = new Set(results.filter((r) => r.status === 'conflict' || r.status === 'error').map((r) => r.key));
|
|
195
|
-
const fingerprinted = applyDiskFingerprints(state.synced ?? {}, candidates.filter((c) => !failedKeys.has(c.key)));
|
|
196
|
-
const nextSynced = { ...fingerprinted.next };
|
|
197
229
|
for (const r of results) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const sourceRel = sourceByKey.get(r.key) ?? sourceByKey.get(stateKey);
|
|
201
|
-
if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
|
|
202
|
-
if (!sourceRel)
|
|
203
|
-
await renameLocalSprite(config.outDir, r.key, stateKey);
|
|
204
|
-
}
|
|
205
|
-
if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
|
|
206
|
-
if (stateKey !== r.key)
|
|
207
|
-
delete nextSynced[r.key];
|
|
208
|
-
const diskSha256 = shaByKey.get(r.key) ?? shaByKey.get(stateKey) ?? nextSynced[stateKey]?.diskSha256;
|
|
209
|
-
const fp = fpByKey.get(r.key) ?? fpByKey.get(stateKey);
|
|
210
|
-
nextSynced[stateKey] = {
|
|
211
|
-
assetId: r.assetId,
|
|
212
|
-
layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
|
|
213
|
-
sha256: r.sha256,
|
|
214
|
-
layers: 1,
|
|
215
|
-
...(diskSha256 ? { diskSha256 } : {}),
|
|
216
|
-
...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
|
|
217
|
-
...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
|
|
218
|
-
...(sourceRel ? { sourceRel } : {}),
|
|
219
|
-
};
|
|
220
|
-
}
|
|
230
|
+
if (r.status in counts)
|
|
231
|
+
counts[r.status]++;
|
|
221
232
|
}
|
|
222
|
-
await saveState({ ...state, synced: nextSynced });
|
|
223
233
|
const failed = counts.conflict + counts.error;
|
|
224
234
|
const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}` +
|
|
225
235
|
(counts.conflict ? `, conflicts ${counts.conflict}` : '') +
|
|
@@ -248,6 +258,38 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
248
258
|
process.exitCode = 1;
|
|
249
259
|
return { ...counts, imported: matched.entries.length };
|
|
250
260
|
}
|
|
261
|
+
async function commitPushResults(opts) {
|
|
262
|
+
const { batchResults, candidates, sourceByKey, shaByKey, fpByKey, outDir } = opts;
|
|
263
|
+
const failedKeys = new Set(batchResults.filter((r) => r.status === 'conflict' || r.status === 'error').map((r) => r.key));
|
|
264
|
+
const batchKeys = new Set(batchResults.map((r) => r.key));
|
|
265
|
+
const fingerprinted = applyDiskFingerprints(opts.synced, candidates.filter((c) => batchKeys.has(c.key) && !failedKeys.has(c.key)));
|
|
266
|
+
const nextSynced = { ...fingerprinted.next };
|
|
267
|
+
for (const r of batchResults) {
|
|
268
|
+
const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
|
|
269
|
+
const sourceRel = sourceByKey.get(r.key) ?? sourceByKey.get(stateKey);
|
|
270
|
+
if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
|
|
271
|
+
if (!sourceRel)
|
|
272
|
+
await renameLocalSprite(outDir, r.key, stateKey);
|
|
273
|
+
}
|
|
274
|
+
if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
|
|
275
|
+
if (stateKey !== r.key)
|
|
276
|
+
delete nextSynced[r.key];
|
|
277
|
+
const diskSha256 = shaByKey.get(r.key) ?? shaByKey.get(stateKey) ?? nextSynced[stateKey]?.diskSha256;
|
|
278
|
+
const fp = fpByKey.get(r.key) ?? fpByKey.get(stateKey);
|
|
279
|
+
nextSynced[stateKey] = {
|
|
280
|
+
assetId: r.assetId,
|
|
281
|
+
layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
|
|
282
|
+
sha256: r.sha256,
|
|
283
|
+
layers: 1,
|
|
284
|
+
...(diskSha256 ? { diskSha256 } : {}),
|
|
285
|
+
...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
|
|
286
|
+
...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
|
|
287
|
+
...(sourceRel ? { sourceRel } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return nextSynced;
|
|
292
|
+
}
|
|
251
293
|
/** Persist disk sha256 onto existing synced rows so the next scan can skip. */
|
|
252
294
|
async function persistDiskFingerprints(state, candidates) {
|
|
253
295
|
const { next, changed } = applyDiskFingerprints(state.synced ?? {}, candidates);
|
package/dist/commands/start.js
CHANGED
|
@@ -19,7 +19,7 @@ import { cmd } from '../util/invoke.js';
|
|
|
19
19
|
*/
|
|
20
20
|
export async function startCommand(opts = {}) {
|
|
21
21
|
console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
|
|
22
|
-
console.log(kleur.dim(' This links your game project
|
|
22
|
+
console.log(kleur.dim(' This links your game project and syncs sprites both ways.'));
|
|
23
23
|
console.log();
|
|
24
24
|
const kind = await detectProjectKind();
|
|
25
25
|
// Engine projects (including Unity UPM packages) have no package.json
|
|
@@ -109,8 +109,7 @@ export async function startCommand(opts = {}) {
|
|
|
109
109
|
console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
110
110
|
}
|
|
111
111
|
console.log();
|
|
112
|
-
console.log(kleur.dim(
|
|
113
|
-
console.log(kleur.dim(' Connected sprites write back to their original game path. New MagicPixel art lands in outDir.'));
|
|
112
|
+
console.log(kleur.dim(' Game sprites write back to their original path. New MagicPixel art lands in outDir.'));
|
|
114
113
|
console.log();
|
|
115
114
|
if (await hasDevScript()) {
|
|
116
115
|
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
2
|
import ora from 'ora';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
3
|
import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
|
|
5
4
|
import { existsSync } from 'node:fs';
|
|
6
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
7
6
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
7
|
+
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
8
8
|
import { fetchAllManifest, fetchAssetBytes, ApiError, getLastProjectInfo } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
10
10
|
import { pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
|
|
@@ -13,9 +13,9 @@ import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitInd
|
|
|
13
13
|
import { assertPathInsideRoot, assertSafeIoPath } from '../util/security.js';
|
|
14
14
|
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
15
15
|
import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
|
|
16
|
-
import { collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
|
|
16
|
+
import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
|
|
17
17
|
import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
|
|
18
|
-
import {
|
|
18
|
+
import { applyUnityPullPolicy, isWorkingSetEntry, workingSetPullKeys } from '../util/unityFilter.js';
|
|
19
19
|
import { selectFullSyncOrphans } from '../util/prunePolicy.js';
|
|
20
20
|
import { runTmpJanitor } from '../util/tmpJanitor.js';
|
|
21
21
|
import { friendlyFsError } from '../util/errors.js';
|
|
@@ -24,6 +24,7 @@ import { formatBytes } from '../util/format.js';
|
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
26
|
import { formatWatchSpriteLine } from '../util/watchCopy.js';
|
|
27
|
+
import { shouldPullEntry } from '../util/pullDecision.js';
|
|
27
28
|
import { runPush } from './push.js';
|
|
28
29
|
export async function syncCommand(opts) {
|
|
29
30
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
@@ -32,10 +33,16 @@ export async function syncCommand(opts) {
|
|
|
32
33
|
// never race a concurrent in-flight write. See util/tmpJanitor.ts.
|
|
33
34
|
try {
|
|
34
35
|
const cfg = await loadConfig();
|
|
35
|
-
|
|
36
|
+
try {
|
|
37
|
+
await runTmpJanitor(cfg.outDir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* janitor is best-effort — never let it block a sync */
|
|
41
|
+
}
|
|
42
|
+
await ensureEngineConnect(cfg, process.cwd(), !opts.dryRun);
|
|
36
43
|
}
|
|
37
44
|
catch {
|
|
38
|
-
/*
|
|
45
|
+
/* missing/invalid config is reported by runOnce / watchLoop */
|
|
39
46
|
}
|
|
40
47
|
if (opts.watch) {
|
|
41
48
|
await watchLoop(opts);
|
|
@@ -105,6 +112,11 @@ async function watchLoop(opts) {
|
|
|
105
112
|
// tick counts (90 / 300) which made the slowdown timing scale with intervalSec.
|
|
106
113
|
const IDLE_SOFT_BACKOFF_SECONDS = 180;
|
|
107
114
|
const IDLE_HARD_BACKOFF_SECONDS = 900;
|
|
115
|
+
// Re-walk the game tree on this cadence so new PNGs are ingested without
|
|
116
|
+
// listing thousands of folders on every 2s tick.
|
|
117
|
+
const GAME_INDEX_TTL_MS = 30_000;
|
|
118
|
+
let gameIndexCache = header.gameIndex;
|
|
119
|
+
let gameIndexAt = Date.now();
|
|
108
120
|
// Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
|
|
109
121
|
// tick exits immediately instead of waiting out the full backoff (which can
|
|
110
122
|
// be 60s at the error ceiling). Initialised to a no-op so the first
|
|
@@ -119,9 +131,11 @@ async function watchLoop(opts) {
|
|
|
119
131
|
stopping = true;
|
|
120
132
|
wakeStop();
|
|
121
133
|
process.stdout.write('\x1b[2K\r');
|
|
134
|
+
const sig = signal === 'SIGTERM' ? 'SIGTERM' : 'SIGINT';
|
|
122
135
|
if (inFlight) {
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
const again = sig === 'SIGINT' ? 'Ctrl+C' : 'SIGTERM';
|
|
137
|
+
console.log(kleur.dim(`Still finishing this check… (${again} again to stop now)`));
|
|
138
|
+
process.once(sig, () => process.exit(sig === 'SIGINT' ? 130 : 143));
|
|
125
139
|
}
|
|
126
140
|
else {
|
|
127
141
|
if (!opts.quiet)
|
|
@@ -143,7 +157,18 @@ async function watchLoop(opts) {
|
|
|
143
157
|
};
|
|
144
158
|
try {
|
|
145
159
|
onStatus('Checking MagicPixel…');
|
|
146
|
-
const
|
|
160
|
+
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
161
|
+
? gameIndexCache
|
|
162
|
+
: undefined;
|
|
163
|
+
const r = await runOnce({ ...opts, watch: false }, {
|
|
164
|
+
watchMode: true,
|
|
165
|
+
onStatus,
|
|
166
|
+
gameIndex: reuseIndex,
|
|
167
|
+
onGameIndex: (idx) => {
|
|
168
|
+
gameIndexCache = idx;
|
|
169
|
+
gameIndexAt = Date.now();
|
|
170
|
+
},
|
|
171
|
+
});
|
|
147
172
|
// Reset backoff on any successful tick. Resume message fires once when
|
|
148
173
|
// we recover from an auth pause — `getApiKey()` re-reads
|
|
149
174
|
// .magicpixel/credentials on every call, so a `magicpixel login` in
|
|
@@ -242,7 +267,6 @@ async function watchLoop(opts) {
|
|
|
242
267
|
}
|
|
243
268
|
}
|
|
244
269
|
finally {
|
|
245
|
-
header.gameIndex = undefined;
|
|
246
270
|
inFlight = false;
|
|
247
271
|
}
|
|
248
272
|
};
|
|
@@ -371,8 +395,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
371
395
|
if (!spinner)
|
|
372
396
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
373
397
|
let manifest;
|
|
398
|
+
// Capture before the Unity filter drops rows — lastSync must not jump past
|
|
399
|
+
// an editor save whose `unity` flag was omitted on this tick.
|
|
400
|
+
let observedUpdatedAt = null;
|
|
374
401
|
try {
|
|
375
402
|
manifest = await fetchAllManifest(config, since);
|
|
403
|
+
observedUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
|
|
376
404
|
const projectInfo = getLastProjectInfo();
|
|
377
405
|
const projectSuffix = projectInfo && verbose
|
|
378
406
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
@@ -413,14 +441,19 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
413
441
|
onProgress: (p) => {
|
|
414
442
|
const n = p.pngs.toLocaleString('en-US');
|
|
415
443
|
const f = p.folders.toLocaleString('en-US');
|
|
416
|
-
onStatus?.(p.
|
|
417
|
-
? `Looking through your game sprites… ${n} sprites · ${f} folders`
|
|
418
|
-
:
|
|
444
|
+
onStatus?.(p.current
|
|
445
|
+
? `Looking through your game sprites… ${n} sprites · ${f} folders · ${p.current}`
|
|
446
|
+
: p.folders > 0
|
|
447
|
+
? `Looking through your game sprites… ${n} sprites · ${f} folders`
|
|
448
|
+
: `Looking through your game sprites… ${n}`);
|
|
419
449
|
},
|
|
420
450
|
})
|
|
421
451
|
: { files: [], capped: false });
|
|
452
|
+
if (!runOpts.gameIndex)
|
|
453
|
+
runOpts.onGameIndex?.(gameIndex);
|
|
422
454
|
const connected = matchConnectGlobs(gameIndex, config.connect ?? []);
|
|
423
455
|
const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
|
|
456
|
+
aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
|
|
424
457
|
if (verbose && gameIndex.capped) {
|
|
425
458
|
console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
|
|
426
459
|
}
|
|
@@ -432,6 +465,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
432
465
|
(connected.capped ? ` of ${connected.total} matched` : '')));
|
|
433
466
|
}
|
|
434
467
|
const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
|
|
468
|
+
const workingSet = workingSetPullKeys(sourceByKey, state.synced);
|
|
435
469
|
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
436
470
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
437
471
|
// the sprite from the game project, not leave a stale copy behind.
|
|
@@ -442,14 +476,25 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
442
476
|
// response must never delete art from someone's game project.
|
|
443
477
|
const unknownFlagPaths = new Set();
|
|
444
478
|
if (projectKind === 'Unity') {
|
|
445
|
-
const filtered =
|
|
479
|
+
const filtered = applyUnityPullPolicy(manifest, {
|
|
480
|
+
syncAll: config.unitySyncAll,
|
|
481
|
+
alwaysPull: (entry) => isWorkingSetEntry(entry, workingSet),
|
|
482
|
+
});
|
|
446
483
|
if (filtered.unknown.length > 0) {
|
|
447
|
-
|
|
448
|
-
|
|
484
|
+
const n = filtered.unknown.length;
|
|
485
|
+
const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a "Sync to Unity" flag — skipped (existing files kept).`;
|
|
486
|
+
if (verbose) {
|
|
487
|
+
console.log(kleur.yellow(`! ${msg}`));
|
|
488
|
+
console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
onStatus?.(msg);
|
|
492
|
+
}
|
|
449
493
|
}
|
|
450
494
|
const unknownSet = new Set(filtered.unknown);
|
|
495
|
+
const pullSet = new Set(filtered.entries);
|
|
451
496
|
for (const entry of manifest) {
|
|
452
|
-
if (
|
|
497
|
+
if (pullSet.has(entry))
|
|
453
498
|
continue;
|
|
454
499
|
const p = pathForKey(entry.key);
|
|
455
500
|
if (unknownSet.has(entry)) {
|
|
@@ -480,7 +525,11 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
480
525
|
bytesSaved: 0,
|
|
481
526
|
renamed: [],
|
|
482
527
|
};
|
|
483
|
-
|
|
528
|
+
if (observedUpdatedAt) {
|
|
529
|
+
const lastSync = state.lastSync && state.lastSync > observedUpdatedAt ? state.lastSync : observedUpdatedAt;
|
|
530
|
+
await saveState({ ...state, lastSync });
|
|
531
|
+
}
|
|
532
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
484
533
|
return empty;
|
|
485
534
|
}
|
|
486
535
|
const skipped = manifest.length - filtered.entries.length;
|
|
@@ -524,10 +573,54 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
524
573
|
const toDownload = [];
|
|
525
574
|
let bytesSaved = 0;
|
|
526
575
|
let unchanged = 0;
|
|
576
|
+
const synced = state.synced ?? {};
|
|
577
|
+
const cloudShaByAssetId = new Map();
|
|
578
|
+
for (const s of Object.values(synced)) {
|
|
579
|
+
if (!s.assetId || !s.sha256)
|
|
580
|
+
continue;
|
|
581
|
+
const prev = cloudShaByAssetId.get(s.assetId);
|
|
582
|
+
if (prev && prev !== s.sha256)
|
|
583
|
+
cloudShaByAssetId.set(s.assetId, 'ambiguous');
|
|
584
|
+
else if (!prev)
|
|
585
|
+
cloudShaByAssetId.set(s.assetId, s.sha256);
|
|
586
|
+
}
|
|
587
|
+
const previousCloudSha = (entry) => {
|
|
588
|
+
const direct = synced[entry.key]?.sha256;
|
|
589
|
+
if (direct)
|
|
590
|
+
return direct;
|
|
591
|
+
for (const k of entry.previous_keys ?? []) {
|
|
592
|
+
const sha = synced[k]?.sha256;
|
|
593
|
+
if (sha)
|
|
594
|
+
return sha;
|
|
595
|
+
}
|
|
596
|
+
if (entry.asset_id) {
|
|
597
|
+
const sha = cloudShaByAssetId.get(entry.asset_id);
|
|
598
|
+
if (sha && sha !== 'ambiguous')
|
|
599
|
+
return sha;
|
|
600
|
+
}
|
|
601
|
+
return undefined;
|
|
602
|
+
};
|
|
527
603
|
await Promise.all(manifest.map((entry) => diffLimit(async () => {
|
|
604
|
+
const previousCloudSha256 = previousCloudSha(entry);
|
|
605
|
+
const inWorkingSet = isWorkingSetEntry(entry, workingSet);
|
|
606
|
+
// Unchanged cloud composite: skip hashing the original PNG (it almost
|
|
607
|
+
// never matches) unless the file is gone and we need to restore it.
|
|
608
|
+
if (entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
|
|
609
|
+
shaByEntryId.set(entry.id, null);
|
|
610
|
+
unchanged++;
|
|
611
|
+
if (entry.size_bytes)
|
|
612
|
+
bytesSaved += entry.size_bytes;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
528
615
|
const localSha = await fileSha256(pathFor(entry));
|
|
529
616
|
shaByEntryId.set(entry.id, localSha);
|
|
530
|
-
|
|
617
|
+
const pull = shouldPullEntry({
|
|
618
|
+
cloudSha256: entry.sha256,
|
|
619
|
+
localSha256: localSha,
|
|
620
|
+
previousCloudSha256,
|
|
621
|
+
inWorkingSet,
|
|
622
|
+
});
|
|
623
|
+
if (!pull) {
|
|
531
624
|
unchanged++;
|
|
532
625
|
if (entry.size_bytes)
|
|
533
626
|
bytesSaved += entry.size_bytes;
|
|
@@ -766,14 +859,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
766
859
|
result.bytesSaved += entry.size_bytes;
|
|
767
860
|
}
|
|
768
861
|
else {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
// Wrap any of mkdir/writeFile so the user sees a friendly
|
|
776
|
-
// multi-line diagnostic instead of a raw `EACCES: permission denied`.
|
|
862
|
+
// Do not require bytes to match `entry.sha256`. Single-artboard
|
|
863
|
+
// downloads serve storage_path (so 2048/4096 Unity tiles do not
|
|
864
|
+
// 546 the isolate); that PNG can differ from a cached composite hash.
|
|
777
865
|
//
|
|
778
866
|
// NOTE: Asset PNGs deliberately use a direct writeFile rather than
|
|
779
867
|
// atomicWrite. Vite's chokidar watcher only suppresses atomic-rename
|
|
@@ -976,7 +1064,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
976
1064
|
// rows whose `updated_at` lived in the gap between the manifest snapshot
|
|
977
1065
|
// and the next poll (notably when only metadata changed — e.g. artboard
|
|
978
1066
|
// renames — and the row updated between fetch start and save).
|
|
979
|
-
const maxUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
|
|
1067
|
+
const maxUpdatedAt = observedUpdatedAt ?? maxIsoTimestamp(manifest.map((e) => e.updated_at));
|
|
980
1068
|
if (maxUpdatedAt) {
|
|
981
1069
|
// Take the later of: newest row we saw, or the prior cursor. Never
|
|
982
1070
|
// rewind — an incremental sync that returned 0 rows must keep the
|
|
@@ -1048,16 +1136,16 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1048
1136
|
printRenames(renamed, { withHints: true });
|
|
1049
1137
|
}
|
|
1050
1138
|
onStatus?.('Checking your game files…');
|
|
1051
|
-
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode);
|
|
1139
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
1052
1140
|
if (result.failed)
|
|
1053
1141
|
process.exitCode = 1;
|
|
1054
1142
|
return result;
|
|
1055
1143
|
}
|
|
1056
|
-
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose) {
|
|
1144
|
+
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
|
|
1057
1145
|
if (pushEnabled === false)
|
|
1058
1146
|
return null;
|
|
1059
1147
|
try {
|
|
1060
|
-
return await runPush({ quiet: !verbose, gameIndex });
|
|
1148
|
+
return await runPush({ quiet: !verbose, gameIndex, onStatus });
|
|
1061
1149
|
}
|
|
1062
1150
|
catch (e) {
|
|
1063
1151
|
if (announceErrors) {
|
package/dist/index.js
CHANGED
|
@@ -83,7 +83,7 @@ program
|
|
|
83
83
|
.action(wrap("repair", async (opts) => repairCommand(opts)));
|
|
84
84
|
program
|
|
85
85
|
.command('sync')
|
|
86
|
-
.description('
|
|
86
|
+
.description('Two-way sync: pull MagicPixel edits, push your game sprites')
|
|
87
87
|
.option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
|
|
88
88
|
.option('--dry-run', 'Print the plan without writing files')
|
|
89
89
|
.option('--full', 'Ignore lastSync state; re-fetch the full manifest')
|
|
@@ -101,7 +101,7 @@ program
|
|
|
101
101
|
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
102
102
|
program
|
|
103
103
|
.command('connect <glob>')
|
|
104
|
-
.description('
|
|
104
|
+
.description('Limit which game folders sync (default is all sprites)')
|
|
105
105
|
.addHelpText('after', '\nExamples:\n $ magicpixel connect \'assets/Sprites/Hero/**\'\n $ magicpixel connect Runtime/UI/hud.png\n')
|
|
106
106
|
.action(wrap("connect", async (glob) => connectCommand(glob)));
|
|
107
107
|
program
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { saveConfig } from '../config.js';
|
|
2
|
+
import { detectProjectKind, isEngineKind } from './framework.js';
|
|
3
|
+
export const DEFAULT_ENGINE_CONNECT = ['**'];
|
|
4
|
+
const ALL_SPRITES_GLOBS = new Set(['**', '**/*', '**/**', '**/*.png', '**/**/*.png']);
|
|
5
|
+
export function isAllSpritesGlob(glob) {
|
|
6
|
+
return ALL_SPRITES_GLOBS.has(glob.trim());
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Next `connect` list after the user adds a glob.
|
|
10
|
+
* A specific folder replaces a default `**` (narrowing). `**` restores all
|
|
11
|
+
* sprites. Other globs accumulate. Stray `**` left over from an earlier
|
|
12
|
+
* default is dropped when narrowing so OR-matching cannot keep everything.
|
|
13
|
+
*/
|
|
14
|
+
export function nextConnectGlobs(current, pattern) {
|
|
15
|
+
if (isAllSpritesGlob(pattern))
|
|
16
|
+
return [...DEFAULT_ENGINE_CONNECT];
|
|
17
|
+
const withoutAll = current.filter((g) => !isAllSpritesGlob(g));
|
|
18
|
+
if (withoutAll.includes(pattern))
|
|
19
|
+
return withoutAll;
|
|
20
|
+
return [...withoutAll, pattern];
|
|
21
|
+
}
|
|
22
|
+
export async function ensureEngineConnect(config, cwd = process.cwd(), persist = true) {
|
|
23
|
+
if (config.connect.length > 0)
|
|
24
|
+
return config;
|
|
25
|
+
const kind = await detectProjectKind(cwd);
|
|
26
|
+
if (!isEngineKind(kind))
|
|
27
|
+
return config;
|
|
28
|
+
const next = { ...config, connect: [...DEFAULT_ENGINE_CONNECT] };
|
|
29
|
+
if (persist)
|
|
30
|
+
await saveConfig(next, cwd);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
package/dist/util/gameScan.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* globs select a working set; sync writes back to those original paths.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import {
|
|
7
|
+
import { opendir } from 'node:fs/promises';
|
|
8
8
|
import { relative, resolve } from 'node:path';
|
|
9
9
|
import { isEngineKind, resolveChildDir } from './framework.js';
|
|
10
10
|
import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
|
|
@@ -36,6 +36,7 @@ export const GAME_SCAN_SKIP_HIDDEN = new Set([
|
|
|
36
36
|
'.git',
|
|
37
37
|
'.svn',
|
|
38
38
|
'.hg',
|
|
39
|
+
'.godot',
|
|
39
40
|
'.magicpixel',
|
|
40
41
|
'.vs',
|
|
41
42
|
'.vscode',
|
|
@@ -52,8 +53,12 @@ export const MAX_GAME_INDEX = 10_000;
|
|
|
52
53
|
export const MAX_GAME_CONNECT = MAX_GAME_INDEX;
|
|
53
54
|
export const GAME_INDEX_CAP_HINT = `Game index hit ${MAX_GAME_INDEX} PNGs — later files aren't searchable or connectable.`;
|
|
54
55
|
export function connectCapMessage(total, ingested) {
|
|
55
|
-
return (`${total}
|
|
56
|
-
`
|
|
56
|
+
return (`${total} sprites match — syncing the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
|
|
57
|
+
`Connect a smaller folder if you don't need all of them.`);
|
|
58
|
+
}
|
|
59
|
+
/** True for a full Unity game project (not an embedded UPM package). */
|
|
60
|
+
function isUnityGameProject(cwd, assetRoot) {
|
|
61
|
+
return Boolean(assetRoot && existsSync(resolve(cwd, 'ProjectSettings')));
|
|
57
62
|
}
|
|
58
63
|
export function gameScanRoot(kind) {
|
|
59
64
|
switch (kind) {
|
|
@@ -101,34 +106,69 @@ function toEntry(abs, cwdAbs, assetRoot) {
|
|
|
101
106
|
function fmtCount(n, noun) {
|
|
102
107
|
return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`;
|
|
103
108
|
}
|
|
109
|
+
function shortPath(rel) {
|
|
110
|
+
const n = rel.replace(/\\/g, '/');
|
|
111
|
+
if (!n || n === '.')
|
|
112
|
+
return '';
|
|
113
|
+
return n.length <= 48 ? n : `…${n.slice(-47)}`;
|
|
114
|
+
}
|
|
104
115
|
/** Spinner / status copy while walking the game tree. */
|
|
105
116
|
export function countingSpritesText(progress = 0) {
|
|
106
117
|
const pngs = typeof progress === 'number' ? progress : progress.pngs;
|
|
107
118
|
const folders = typeof progress === 'number' ? 0 : progress.folders;
|
|
108
|
-
|
|
119
|
+
const current = typeof progress === 'number' ? undefined : progress.current;
|
|
120
|
+
if (pngs === 0 && folders === 0 && !current)
|
|
109
121
|
return 'Counting sprites in your game…';
|
|
110
122
|
const spriteBit = fmtCount(pngs, 'sprite');
|
|
123
|
+
const parts = [spriteBit];
|
|
111
124
|
if (folders > 0)
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
parts.push(fmtCount(folders, 'folder'));
|
|
126
|
+
const where = current ? shortPath(current) : '';
|
|
127
|
+
if (where)
|
|
128
|
+
parts.push(where);
|
|
129
|
+
return `Counting sprites in your game… ${parts.join(' · ')}`;
|
|
114
130
|
}
|
|
115
131
|
function bindScanProgress(onProgress) {
|
|
116
132
|
if (!onProgress)
|
|
117
133
|
return () => { };
|
|
118
134
|
let lastAt = 0;
|
|
119
|
-
let
|
|
120
|
-
let
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
let pending = null;
|
|
136
|
+
let timer = null;
|
|
137
|
+
const flush = (p) => {
|
|
138
|
+
pending = null;
|
|
139
|
+
lastAt = Date.now();
|
|
140
|
+
onProgress(p);
|
|
141
|
+
};
|
|
142
|
+
const visit = (pngs, folders, current) => {
|
|
143
|
+
const next = { pngs, folders, current };
|
|
124
144
|
const now = Date.now();
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
if (lastAt === 0 || now - lastAt >= 80) {
|
|
146
|
+
if (timer) {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
timer = null;
|
|
149
|
+
}
|
|
150
|
+
flush(next);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
pending = next;
|
|
154
|
+
if (!timer) {
|
|
155
|
+
timer = setTimeout(() => {
|
|
156
|
+
timer = null;
|
|
157
|
+
if (pending)
|
|
158
|
+
flush(pending);
|
|
159
|
+
}, 80 - (now - lastAt));
|
|
160
|
+
timer.unref();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
visit.flush = () => {
|
|
164
|
+
if (timer) {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
timer = null;
|
|
130
167
|
}
|
|
168
|
+
if (pending)
|
|
169
|
+
flush(pending);
|
|
131
170
|
};
|
|
171
|
+
return visit;
|
|
132
172
|
}
|
|
133
173
|
function samePath(a, b) {
|
|
134
174
|
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
|
|
@@ -148,7 +188,12 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
|
|
|
148
188
|
const stats = { folders: 0 };
|
|
149
189
|
const io = createLimit(SCAN_WALK_CONCURRENCY);
|
|
150
190
|
const report = bindScanProgress(opts.onProgress);
|
|
151
|
-
|
|
191
|
+
// Full Unity games: walk Assets/ only. Listing the project root waits on
|
|
192
|
+
// Library/Packages/cloud placeholders and looks hung at "1 folder".
|
|
193
|
+
const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
|
|
194
|
+
const preferFirst = startAt === scanRoot ? assetRoot : null;
|
|
195
|
+
await walkPngs(startAt, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, preferFirst);
|
|
196
|
+
report.flush?.();
|
|
152
197
|
opts.onProgress?.({ pngs: found.length, folders: stats.folders });
|
|
153
198
|
found.sort((a, b) => a.localeCompare(b));
|
|
154
199
|
const capped = found.length > MAX_GAME_INDEX;
|
|
@@ -178,58 +223,74 @@ export function searchGameIndex(index, query) {
|
|
|
178
223
|
return [];
|
|
179
224
|
return index.files.filter((e) => e.sourceRel.toLowerCase().includes(q) || e.key.includes(q) || e.adoptRel.toLowerCase().includes(q));
|
|
180
225
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
226
|
+
function folderLabel(dir, root) {
|
|
227
|
+
return relative(root, dir).replace(/\\/g, '/') || '.';
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* List one directory. The limiter wraps *only* this listing so a parent does
|
|
231
|
+
* not hold a slot while waiting on children (that deadlocks wide Unity trees).
|
|
232
|
+
*/
|
|
233
|
+
async function listDir(dir, root, destNorm, out, limit, onVisit, stats, preferFirst, current) {
|
|
234
|
+
const preferred = [];
|
|
235
|
+
const other = [];
|
|
236
|
+
let dh;
|
|
188
237
|
try {
|
|
189
|
-
|
|
238
|
+
dh = await opendir(dir);
|
|
190
239
|
}
|
|
191
240
|
catch {
|
|
192
|
-
return;
|
|
241
|
+
return { preferred, other };
|
|
193
242
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
if (ent.isDirectory()) {
|
|
210
|
-
if (shouldSkipDir(ent.name))
|
|
243
|
+
let lastBeat = Date.now();
|
|
244
|
+
try {
|
|
245
|
+
for await (const ent of dh) {
|
|
246
|
+
if (out.length >= limit)
|
|
247
|
+
break;
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
if (now - lastBeat >= 80) {
|
|
250
|
+
lastBeat = now;
|
|
251
|
+
onVisit(out.length, stats.folders, current);
|
|
252
|
+
}
|
|
253
|
+
if (shouldSkipDir(ent.name) || ent.isSymbolicLink())
|
|
211
254
|
continue;
|
|
212
|
-
|
|
255
|
+
const full = resolve(dir, ent.name);
|
|
256
|
+
try {
|
|
257
|
+
assertPathInsideRoot(full, root, 'scan');
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
213
260
|
continue;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
261
|
+
}
|
|
262
|
+
if (ent.isDirectory()) {
|
|
263
|
+
if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
|
|
264
|
+
continue;
|
|
265
|
+
if (preferFirst && samePath(full, preferFirst))
|
|
266
|
+
preferred.push(full);
|
|
267
|
+
else
|
|
268
|
+
other.push(full);
|
|
269
|
+
}
|
|
270
|
+
else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
|
|
271
|
+
out.push(full);
|
|
272
|
+
onVisit(out.length, stats.folders, current);
|
|
273
|
+
}
|
|
222
274
|
}
|
|
223
275
|
}
|
|
276
|
+
catch {
|
|
277
|
+
/* unreadable mid-listing */
|
|
278
|
+
}
|
|
279
|
+
return { preferred, other };
|
|
280
|
+
}
|
|
281
|
+
async function walkPngs(dir, root, destNorm, out, limit, io, onVisit, stats, preferFirst) {
|
|
282
|
+
if (out.length >= limit)
|
|
283
|
+
return;
|
|
284
|
+
stats.folders++;
|
|
285
|
+
const current = folderLabel(dir, root);
|
|
286
|
+
onVisit(out.length, stats.folders, current);
|
|
287
|
+
const { preferred, other } = await io(() => listDir(dir, root, destNorm, out, limit, onVisit, stats, preferFirst, current));
|
|
224
288
|
if (out.length >= limit)
|
|
225
289
|
return;
|
|
226
|
-
// Engine asset tree first (Unity `Assets/`) so sprite counts appear before
|
|
227
|
-
// sibling folders like Builds/Editor. Nested walks keep preferFirst so a
|
|
228
|
-
// misplaced `Assets` dir is still prioritized if we ever see it.
|
|
229
290
|
for (const d of preferred) {
|
|
230
291
|
await walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null);
|
|
231
292
|
}
|
|
232
293
|
if (other.length === 0 || out.length >= limit)
|
|
233
294
|
return;
|
|
234
|
-
await Promise.all(other.map((d) =>
|
|
295
|
+
await Promise.all(other.map((d) => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null)));
|
|
235
296
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a manifest entry should be written to disk.
|
|
3
|
+
*
|
|
4
|
+
* Connected game PNGs are usually *not* the same bytes as MagicPixel's
|
|
5
|
+
* re-encoded composite. Comparing those hashes would re-download the whole
|
|
6
|
+
* library on every catch-up and overwrite the originals. Pull only when the
|
|
7
|
+
* cloud composite actually changed since we last recorded it, or when there
|
|
8
|
+
* is no local file to protect.
|
|
9
|
+
*/
|
|
10
|
+
export function shouldPullEntry(opts) {
|
|
11
|
+
const cloud = opts.cloudSha256 ?? null;
|
|
12
|
+
const local = opts.localSha256;
|
|
13
|
+
const prev = opts.previousCloudSha256;
|
|
14
|
+
// Missing on disk → restore from cloud (MagicPixel-only art and deletions).
|
|
15
|
+
if (!local)
|
|
16
|
+
return true;
|
|
17
|
+
if (cloud && cloud === local)
|
|
18
|
+
return false;
|
|
19
|
+
// Cloud composite unchanged since last pull/push — keep the original PNG.
|
|
20
|
+
if (cloud && prev && cloud === prev)
|
|
21
|
+
return false;
|
|
22
|
+
if (opts.inWorkingSet) {
|
|
23
|
+
// Never recorded a cloud hash: disk is the source of truth (connect/ingest).
|
|
24
|
+
if (!prev)
|
|
25
|
+
return false;
|
|
26
|
+
// Editor save often nulls the cached hash; treat that as a cloud change.
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
package/dist/util/syncPath.js
CHANGED
|
@@ -37,6 +37,37 @@ export function collectSourceRelMap(connected, synced) {
|
|
|
37
37
|
}
|
|
38
38
|
return map;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Ingest appends `-2`, `-3`, … to the *document* slug when the library already
|
|
42
|
+
* has that name. The live connect index still uses the unsuffixed key, so a
|
|
43
|
+
* catch-up pull would treat `ground-ice-snow-4/…` as new MagicPixel art and
|
|
44
|
+
* dump thousands of copies into outDir. Alias those keys onto the original
|
|
45
|
+
* PNG when the unsuffixed key is already in the working set.
|
|
46
|
+
*/
|
|
47
|
+
export function stripDocCollisionSuffix(key) {
|
|
48
|
+
const parts = key.split('/');
|
|
49
|
+
if (parts.length < 2)
|
|
50
|
+
return key;
|
|
51
|
+
const docIdx = parts.length - 2;
|
|
52
|
+
const stripped = parts[docIdx].replace(/-(\d+)$/, '');
|
|
53
|
+
if (stripped === parts[docIdx])
|
|
54
|
+
return key;
|
|
55
|
+
const next = parts.slice();
|
|
56
|
+
next[docIdx] = stripped;
|
|
57
|
+
return next.join('/');
|
|
58
|
+
}
|
|
59
|
+
export function aliasCollisionKeys(sourceByKey, manifestKeys) {
|
|
60
|
+
for (const key of manifestKeys) {
|
|
61
|
+
if (sourceByKey.has(key))
|
|
62
|
+
continue;
|
|
63
|
+
const base = stripDocCollisionSuffix(key);
|
|
64
|
+
if (base === key)
|
|
65
|
+
continue;
|
|
66
|
+
const rel = sourceByKey.get(base);
|
|
67
|
+
if (rel)
|
|
68
|
+
sourceByKey.set(key, rel);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
40
71
|
/**
|
|
41
72
|
* When the cloud assigned a different composite key than the index key,
|
|
42
73
|
* push under the cloud key so we update instead of adopting a duplicate.
|
package/dist/util/unityFilter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-artboard Unity sync opt-in (strict).
|
|
2
|
+
* Per-artboard Unity sync opt-in (strict), with a working-set override.
|
|
3
3
|
*
|
|
4
4
|
* In the editor, each artboard has a "Sync to Unity" checkbox (and library
|
|
5
5
|
* folders have a folder-level switch that cascades). A project may hold 100
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
* Strict opt-in: `unity === true` syncs, anything else does not. A missing
|
|
10
10
|
* flag means the server didn't say (older edge deploy, or a budget-starved
|
|
11
11
|
* manifest backfill) — those entries are excluded and reported via
|
|
12
|
-
* `
|
|
12
|
+
* `unknown` so the caller can warn instead of silently syncing the whole
|
|
13
13
|
* library into someone's game project.
|
|
14
|
+
*
|
|
15
|
+
* Exception: sprites already in the game (connect working set / previously
|
|
16
|
+
* synced keys) always pull. An editor save nulls `artboard_index` and the
|
|
17
|
+
* next manifest often omits `unity` (or rebuilds it as false because the
|
|
18
|
+
* layer JSON dropped `syncToUnity`). Those saves must still write back to
|
|
19
|
+
* the original PNG.
|
|
14
20
|
*/
|
|
15
21
|
export function filterUnityManifest(manifest, opts = {}) {
|
|
16
22
|
if (opts.syncAll)
|
|
@@ -23,3 +29,49 @@ export function filterUnityManifest(manifest, opts = {}) {
|
|
|
23
29
|
unknown,
|
|
24
30
|
};
|
|
25
31
|
}
|
|
32
|
+
/** Keys we already write to on disk — connect globs plus those sprites' cloud aliases. */
|
|
33
|
+
export function workingSetPullKeys(sourceByKey, synced) {
|
|
34
|
+
const keys = new Set(sourceByKey.keys());
|
|
35
|
+
const assetIds = new Set();
|
|
36
|
+
if (synced) {
|
|
37
|
+
for (const [key, sprite] of Object.entries(synced)) {
|
|
38
|
+
if (!keys.has(key))
|
|
39
|
+
continue;
|
|
40
|
+
if (sprite.assetId)
|
|
41
|
+
assetIds.add(sprite.assetId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { keys, assetIds };
|
|
45
|
+
}
|
|
46
|
+
export function isWorkingSetEntry(entry, pull) {
|
|
47
|
+
if (entry.key && pull.keys.has(entry.key))
|
|
48
|
+
return true;
|
|
49
|
+
if (entry.previous_keys?.some((k) => pull.keys.has(k)))
|
|
50
|
+
return true;
|
|
51
|
+
if (entry.asset_id && pull.assetIds.has(entry.asset_id)) {
|
|
52
|
+
// Same document as a connected sprite. Post-save fallback (flag omitted)
|
|
53
|
+
// must still pull; explicitly unflagged sibling artboards must not dump
|
|
54
|
+
// into outDir.
|
|
55
|
+
return entry.unity !== false;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Flagged artboards, plus any working-set sprite whose Unity flag is missing
|
|
61
|
+
* or false after an editor save / index rebuild.
|
|
62
|
+
*/
|
|
63
|
+
export function applyUnityPullPolicy(manifest, opts = {}) {
|
|
64
|
+
const filtered = filterUnityManifest(manifest, { syncAll: opts.syncAll });
|
|
65
|
+
if (opts.syncAll || !opts.alwaysPull)
|
|
66
|
+
return filtered;
|
|
67
|
+
const already = new Set(filtered.entries);
|
|
68
|
+
const extra = manifest.filter((e) => !already.has(e) && opts.alwaysPull(e));
|
|
69
|
+
if (extra.length === 0)
|
|
70
|
+
return filtered;
|
|
71
|
+
const extraSet = new Set(extra);
|
|
72
|
+
return {
|
|
73
|
+
entries: filtered.entries.concat(extra),
|
|
74
|
+
unknown: filtered.unknown.filter((e) => !extraSet.has(e)),
|
|
75
|
+
noneFlagged: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/util/watchCopy.js
CHANGED
|
@@ -16,6 +16,6 @@ export function formatWatchSpriteLine(c) {
|
|
|
16
16
|
return ` Sprites: ${fmt(set)} in your game · ${fmt(pulled)} last pulled from MagicPixel`;
|
|
17
17
|
}
|
|
18
18
|
if (set > 0)
|
|
19
|
-
return ` Sprites: ${fmt(set)} in your
|
|
19
|
+
return ` Sprites: ${fmt(set)} in your game`;
|
|
20
20
|
return ` Sprites: ${fmt(pulled)}`;
|
|
21
21
|
}
|