pixelkiln 0.10.0 → 0.12.0

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/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path19 from "path";
5
- import { existsSync as existsSync15 } from "fs";
6
- import { readFile as readFile15 } from "fs/promises";
4
+ import path22 from "path";
5
+ import { existsSync as existsSync17 } from "fs";
6
+ import { readFile as readFile17 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
9
9
  import { readFileSync, existsSync } from "fs";
@@ -105,17 +105,23 @@ function formatCost(unit, amount) {
105
105
 
106
106
  // src/providers/comfyui.ts
107
107
  import { randomUUID } from "crypto";
108
- import { readFile as readFile2 } from "fs/promises";
108
+ import { readFile } from "fs/promises";
109
109
  import path2 from "path";
110
110
 
111
111
  // src/hash.ts
112
112
  import { createHash } from "crypto";
113
- import { readFile } from "fs/promises";
113
+ import { createReadStream } from "fs";
114
114
  function sha256(data) {
115
115
  return createHash("sha256").update(data).digest("hex");
116
116
  }
117
- async function sha256File(path20) {
118
- return sha256(await readFile(path20));
117
+ async function sha256File(path23) {
118
+ return new Promise((resolve, reject) => {
119
+ const hash = createHash("sha256");
120
+ const stream = createReadStream(path23);
121
+ stream.on("data", (chunk2) => hash.update(chunk2));
122
+ stream.on("error", reject);
123
+ stream.on("end", () => resolve(hash.digest("hex")));
124
+ });
119
125
  }
120
126
  function specHash(spec, styleImageHashes, providerOptionIdentity = spec.providerOptions) {
121
127
  return sha256(
@@ -683,7 +689,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
683
689
  const workflowPath = path2.resolve(context.root, options.workflowFile);
684
690
  let raw;
685
691
  try {
686
- raw = await readFile2(workflowPath, "utf8");
692
+ raw = await readFile(workflowPath, "utf8");
687
693
  } catch (err) {
688
694
  throw new Error(
689
695
  `ComfyUI workflow for style "${context.styleId}" could not be read at ${workflowPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -974,9 +980,9 @@ function normalizeBaseUrl(value) {
974
980
  }
975
981
  function historyError(status) {
976
982
  const messages = Array.isArray(status.messages) ? status.messages : [];
977
- for (const message2 of messages) {
978
- if (!Array.isArray(message2) || message2[0] !== "execution_error" || !isObject(message2[1])) continue;
979
- const detail = message2[1].exception_message;
983
+ for (const message5 of messages) {
984
+ if (!Array.isArray(message5) || message5[0] !== "execution_error" || !isObject(message5[1])) continue;
985
+ const detail = message5[1].exception_message;
980
986
  if (typeof detail === "string" && detail.trim()) {
981
987
  return `ComfyUI execution failed: ${detail.trim().slice(0, 300)}`;
982
988
  }
@@ -1097,8 +1103,8 @@ function validateResponse(schema, raw, operation) {
1097
1103
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
1098
1104
  }
1099
1105
  var PixelLabError = class extends Error {
1100
- constructor(message2, status, body) {
1101
- super(message2);
1106
+ constructor(message5, status, body) {
1107
+ super(message5);
1102
1108
  this.status = status;
1103
1109
  this.body = body;
1104
1110
  this.name = "PixelLabError";
@@ -1123,11 +1129,11 @@ var PixelLabClient = class {
1123
1129
  * common than the failure mode of retrying (a duplicate object), and a
1124
1130
  * duplicate is visible and free to delete whereas a silent gap is neither.
1125
1131
  */
1126
- async request(path20, init, attempt = 0) {
1132
+ async request(path23, init, attempt = 0) {
1127
1133
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1128
1134
  let res;
1129
1135
  try {
1130
- res = await fetch(`${BASE}${path20}`, {
1136
+ res = await fetch(`${BASE}${path23}`, {
1131
1137
  ...init,
1132
1138
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
1133
1139
  headers: {
@@ -1139,24 +1145,24 @@ var PixelLabClient = class {
1139
1145
  } catch (err) {
1140
1146
  if (attempt < MAX_RETRIES) {
1141
1147
  await sleep(backoffMs(attempt));
1142
- return this.request(path20, init, attempt + 1);
1148
+ return this.request(path23, init, attempt + 1);
1143
1149
  }
1144
1150
  throw err;
1145
1151
  }
1146
1152
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
1147
1153
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
1148
1154
  await sleep(waitMs);
1149
- return this.request(path20, init, attempt + 1);
1155
+ return this.request(path23, init, attempt + 1);
1150
1156
  }
1151
1157
  const text = await res.text();
1152
1158
  if (!res.ok) {
1153
- throw new PixelLabError(`${init?.method ?? "GET"} ${path20} \u2192 ${res.status}`, res.status, text);
1159
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path23} \u2192 ${res.status}`, res.status, text);
1154
1160
  }
1155
1161
  if (!text) return {};
1156
1162
  try {
1157
1163
  return JSON.parse(text);
1158
1164
  } catch {
1159
- throw new Error(`${init?.method ?? "GET"} ${path20} returned invalid JSON`);
1165
+ throw new Error(`${init?.method ?? "GET"} ${path23} returned invalid JSON`);
1160
1166
  }
1161
1167
  }
1162
1168
  async balance() {
@@ -1996,9 +2002,9 @@ var RetroDiffusionClient = class {
1996
2002
  }
1997
2003
  return balance;
1998
2004
  }
1999
- async call(path20, init = {}) {
2005
+ async call(path23, init = {}) {
2000
2006
  if (!this.token) throw new Error("RD_API_KEY is not set");
2001
- const response = await this.request(`${this.baseUrl}${path20}`, {
2007
+ const response = await this.request(`${this.baseUrl}${path23}`, {
2002
2008
  ...init,
2003
2009
  headers: {
2004
2010
  "Content-Type": "application/json",
@@ -2262,8 +2268,8 @@ function retroError(value) {
2262
2268
  if (details.length) return details.join("; ");
2263
2269
  }
2264
2270
  if (record.detail && typeof record.detail === "object") {
2265
- const message2 = record.detail.message;
2266
- if (typeof message2 === "string") return message2;
2271
+ const message5 = record.detail.message;
2272
+ if (typeof message5 === "string") return message5;
2267
2273
  }
2268
2274
  return JSON.stringify(value);
2269
2275
  }
@@ -2315,13 +2321,13 @@ registerProvider({
2315
2321
  });
2316
2322
 
2317
2323
  // src/manifest.ts
2318
- import { readFile as readFile3 } from "fs/promises";
2324
+ import { readFile as readFile2 } from "fs/promises";
2319
2325
  import { existsSync as existsSync3 } from "fs";
2320
2326
  import path4 from "path";
2321
2327
  async function loadManifest(manifestPath) {
2322
2328
  const abs = path4.resolve(manifestPath);
2323
2329
  if (!existsSync3(abs)) throw new Error(`No manifest at ${abs}`);
2324
- const parsed = ManifestSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
2330
+ const parsed = ManifestSchema.safeParse(JSON.parse(await readFile2(abs, "utf8")));
2325
2331
  if (!parsed.success) {
2326
2332
  const issues = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
2327
2333
  throw new Error(`Manifest at ${abs} is invalid:
@@ -2370,7 +2376,7 @@ async function resolveSpecs(loaded, filter) {
2370
2376
  let hit = styleImageCache.get(abs);
2371
2377
  if (!hit) {
2372
2378
  if (!existsSync3(abs)) throw new Error(`Style image not found: ${abs}`);
2373
- const buf = await readFile3(abs);
2379
+ const buf = await readFile2(abs);
2374
2380
  const metadata = imageMetadata(buf);
2375
2381
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
2376
2382
  if (metadata.width < 1 || metadata.height < 1) {
@@ -2496,7 +2502,7 @@ async function resolveStyleImages(loaded, styleId) {
2496
2502
  const out = [];
2497
2503
  for (const img of style.styleImages) {
2498
2504
  const file = path4.resolve(loaded.root, img.path);
2499
- const buf = await readFile3(file);
2505
+ const buf = await readFile2(file);
2500
2506
  const metadata = imageMetadata(buf);
2501
2507
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
2502
2508
  if (metadata.width < 1 || metadata.height < 1) {
@@ -2543,13 +2549,13 @@ import path7 from "path";
2543
2549
  import path6 from "path";
2544
2550
 
2545
2551
  // src/lock.ts
2546
- import { readFile as readFile4, writeFile, rename, mkdir, rmdir, rm, stat } from "fs/promises";
2552
+ import { readFile as readFile3, writeFile, rename, mkdir, rmdir, rm, stat } from "fs/promises";
2547
2553
  import { existsSync as existsSync4 } from "fs";
2548
2554
  import path5 from "path";
2549
2555
  async function loadLock(lockPath) {
2550
2556
  if (!existsSync4(lockPath)) return { version: 2, entries: {} };
2551
2557
  try {
2552
- return parseLock(JSON.parse(await readFile4(lockPath, "utf8")));
2558
+ return parseLock(JSON.parse(await readFile3(lockPath, "utf8")));
2553
2559
  } catch (err) {
2554
2560
  throw new Error(
2555
2561
  `Lockfile at ${lockPath} is malformed:
@@ -2585,7 +2591,7 @@ async function writeLockWhileHeld(file, lock) {
2585
2591
  let disk = { version: 2, entries: {} };
2586
2592
  if (existsSync4(file)) {
2587
2593
  try {
2588
- disk = parseLock(JSON.parse(await readFile4(file, "utf8")));
2594
+ disk = parseLock(JSON.parse(await readFile3(file, "utf8")));
2589
2595
  } catch (err) {
2590
2596
  throw new Error(
2591
2597
  `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
@@ -3262,9 +3268,9 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3262
3268
  );
3263
3269
  } catch (err) {
3264
3270
  failed++;
3265
- const message2 = err instanceof Error ? err.message : String(err);
3266
- upsert(lock, key, { status: "failed", error: message2, cost: 0 });
3267
- log2(` FAILED ${key}: ${message2}`);
3271
+ const message5 = err instanceof Error ? err.message : String(err);
3272
+ upsert(lock, key, { status: "failed", error: message5, cost: 0 });
3273
+ log2(` FAILED ${key}: ${message5}`);
3268
3274
  }
3269
3275
  await saveLock(lockPath, lock);
3270
3276
  }
@@ -3335,7 +3341,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
3335
3341
 
3336
3342
  // src/pipeline/fetch.ts
3337
3343
  import { existsSync as existsSync6 } from "fs";
3338
- import { mkdir as mkdir2, readFile as readFile5, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
3344
+ import { mkdir as mkdir2, readFile as readFile4, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
3339
3345
  import path9 from "path";
3340
3346
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3341
3347
  const log2 = opts.onProgress ?? (() => {
@@ -3395,7 +3401,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3395
3401
  if (cacheDir) {
3396
3402
  await cacheMedia(
3397
3403
  cacheDir,
3398
- await readFile5(target),
3404
+ await readFile4(target),
3399
3405
  recorded.mediaType ?? MediaType.PNG,
3400
3406
  recorded.sha256
3401
3407
  );
@@ -3452,14 +3458,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3452
3458
  });
3453
3459
  result.downloaded++;
3454
3460
  } catch (err) {
3455
- const message2 = err instanceof Error ? err.message : String(err);
3461
+ const message5 = err instanceof Error ? err.message : String(err);
3456
3462
  upsert(lock, key, {
3457
3463
  status: "download-failed",
3458
3464
  outputs: mergeOutputs(entry.outputs, outputs),
3459
- error: `download failed: ${message2}`
3465
+ error: `download failed: ${message5}`
3460
3466
  });
3461
3467
  result.failed++;
3462
- log2(` FAILED ${key}: ${message2}`);
3468
+ log2(` FAILED ${key}: ${message5}`);
3463
3469
  }
3464
3470
  await saveLock(lockPath, lock);
3465
3471
  }
@@ -3472,7 +3478,7 @@ async function readCachedMedia(cacheDir, hash, mediaType) {
3472
3478
  const file = path9.join(cacheDir, cacheFileName(hash, mediaType));
3473
3479
  if (!existsSync6(file)) return null;
3474
3480
  try {
3475
- const buf = await readFile5(file);
3481
+ const buf = await readFile4(file);
3476
3482
  if (sha256(buf) !== hash) return null;
3477
3483
  validateMedia(buf, mediaType);
3478
3484
  return buf;
@@ -3529,7 +3535,7 @@ import { access, stat as stat2 } from "fs/promises";
3529
3535
  import path10 from "path";
3530
3536
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
3531
3537
  const checks = [];
3532
- const add = (id, level, message2) => checks.push({ id, level, message: message2 });
3538
+ const add = (id, level, message5) => checks.push({ id, level, message: message5 });
3533
3539
  add(
3534
3540
  "manifest",
3535
3541
  "ok",
@@ -3659,11 +3665,11 @@ async function nearestExistingDirectory(start) {
3659
3665
 
3660
3666
  // src/pipeline/adopt.ts
3661
3667
  import { existsSync as existsSync9 } from "fs";
3662
- import { readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
3668
+ import { readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
3663
3669
  import path11 from "path";
3664
3670
 
3665
3671
  // src/cache.ts
3666
- import { mkdir as mkdir3, readFile as readFile6, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
3672
+ import { mkdir as mkdir3, readFile as readFile5, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
3667
3673
  import { existsSync as existsSync8 } from "fs";
3668
3674
  import pathModule from "path";
3669
3675
  import { z as z3 } from "zod";
@@ -3679,10 +3685,10 @@ function isSha256Hash(value) {
3679
3685
  function parseCache(value) {
3680
3686
  return HashCacheSchema.parse(value);
3681
3687
  }
3682
- async function loadCache(path20) {
3683
- if (!existsSync8(path20)) return { version: 1, hashes: {} };
3688
+ async function loadCache(path23) {
3689
+ if (!existsSync8(path23)) return { version: 1, hashes: {} };
3684
3690
  try {
3685
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile6(path20, "utf8")));
3691
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile5(path23, "utf8")));
3686
3692
  if (!parsed.success) return { version: 1, hashes: {} };
3687
3693
  return {
3688
3694
  version: 1,
@@ -3694,18 +3700,18 @@ async function loadCache(path20) {
3694
3700
  return { version: 1, hashes: {} };
3695
3701
  }
3696
3702
  }
3697
- async function saveCache(path20, cache) {
3703
+ async function saveCache(path23, cache) {
3698
3704
  const sorted = {};
3699
3705
  for (const key of Object.keys(cache.hashes).sort()) {
3700
3706
  const hash = cache.hashes[key];
3701
3707
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
3702
3708
  sorted[key] = hash;
3703
3709
  }
3704
- await mkdir3(pathModule.dirname(pathModule.resolve(path20)), { recursive: true });
3705
- const tmp = `${path20}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3710
+ await mkdir3(pathModule.dirname(pathModule.resolve(path23)), { recursive: true });
3711
+ const tmp = `${path23}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3706
3712
  try {
3707
3713
  await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
3708
- await rename3(tmp, path20);
3714
+ await rename3(tmp, path23);
3709
3715
  } finally {
3710
3716
  await rm3(tmp, { force: true });
3711
3717
  }
@@ -3740,7 +3746,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
3740
3746
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
3741
3747
  continue;
3742
3748
  }
3743
- const bytes = await readFile7(spec.outFile);
3749
+ const bytes = await readFile6(spec.outFile);
3744
3750
  try {
3745
3751
  decodePng(bytes);
3746
3752
  } catch (err) {
@@ -3857,7 +3863,7 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
3857
3863
  async function writePromptsBack(manifestPath, lock, opts = {}) {
3858
3864
  const log2 = opts.onProgress ?? (() => {
3859
3865
  });
3860
- const raw = JSON.parse(await readFile7(manifestPath, "utf8"));
3866
+ const raw = JSON.parse(await readFile6(manifestPath, "utf8"));
3861
3867
  const best = /* @__PURE__ */ new Map();
3862
3868
  for (const entry of Object.values(lock.entries)) {
3863
3869
  if (!entry.objectId || !entry.prompt) continue;
@@ -4156,10 +4162,10 @@ function serveReviewPage(opts) {
4156
4162
  res.end(JSON.stringify(result));
4157
4163
  server.close(() => resolve(result));
4158
4164
  } catch (err) {
4159
- const message2 = err instanceof Error ? err.message : String(err);
4160
- log2(` apply failed: ${message2}`);
4165
+ const message5 = err instanceof Error ? err.message : String(err);
4166
+ log2(` apply failed: ${message5}`);
4161
4167
  res.writeHead(500, { "Content-Type": "text/plain" });
4162
- res.end(message2);
4168
+ res.end(message5);
4163
4169
  }
4164
4170
  return;
4165
4171
  }
@@ -4247,7 +4253,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
4247
4253
  }
4248
4254
 
4249
4255
  // src/pipeline/init.ts
4250
- import { readdir, readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
4256
+ import { readdir, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
4251
4257
  import { existsSync as existsSync10 } from "fs";
4252
4258
  import path12 from "path";
4253
4259
  function pngSize(buf) {
@@ -4277,7 +4283,7 @@ async function scanAssets(root, opts = {}) {
4277
4283
  const skipped = [];
4278
4284
  const seen = /* @__PURE__ */ new Map();
4279
4285
  for (const file of files.sort()) {
4280
- const size = pngSize(await readFile8(file));
4286
+ const size = pngSize(await readFile7(file));
4281
4287
  if (!size) {
4282
4288
  skipped.push(`${file} (not a readable PNG)`);
4283
4289
  continue;
@@ -4346,7 +4352,7 @@ async function writeManifestFile(target, manifest) {
4346
4352
  }
4347
4353
 
4348
4354
  // src/pipeline/salvage.ts
4349
- import { readFile as readFile9 } from "fs/promises";
4355
+ import { readFile as readFile8 } from "fs/promises";
4350
4356
  import { existsSync as existsSync11 } from "fs";
4351
4357
  import path13 from "path";
4352
4358
  async function loadClaims(lockPaths) {
@@ -4355,7 +4361,7 @@ async function loadClaims(lockPaths) {
4355
4361
  if (!existsSync11(p)) throw new Error(`Claim lockfile not found: ${p}`);
4356
4362
  let parsed;
4357
4363
  try {
4358
- parsed = parseLock(JSON.parse(await readFile9(p, "utf8")));
4364
+ parsed = parseLock(JSON.parse(await readFile8(p, "utf8")));
4359
4365
  } catch {
4360
4366
  throw new Error(`Claim lockfile is malformed: ${p}`);
4361
4367
  }
@@ -4526,7 +4532,7 @@ async function applyTags(provider, decisions, existing, opts = {}) {
4526
4532
  }
4527
4533
 
4528
4534
  // src/workspace.ts
4529
- import { mkdir as mkdir4, readFile as readFile10, rename as rename4, rm as rm4, writeFile as writeFile6 } from "fs/promises";
4535
+ import { mkdir as mkdir4, readFile as readFile9, rename as rename4, rm as rm4, writeFile as writeFile6 } from "fs/promises";
4530
4536
  import { existsSync as existsSync12 } from "fs";
4531
4537
  import path14 from "path";
4532
4538
  import { z as z4 } from "zod";
@@ -4556,7 +4562,7 @@ async function loadWorkspace(workspacePath) {
4556
4562
  if (!existsSync12(workspacePath)) return { version: 1, projects: [] };
4557
4563
  let raw;
4558
4564
  try {
4559
- raw = JSON.parse(await readFile10(workspacePath, "utf8"));
4565
+ raw = JSON.parse(await readFile9(workspacePath, "utf8"));
4560
4566
  } catch (err) {
4561
4567
  throw new Error(
4562
4568
  `Workspace catalog at ${workspacePath} is malformed:
@@ -4753,7 +4759,7 @@ async function workspaceStatus(ws, dir) {
4753
4759
  }
4754
4760
 
4755
4761
  // src/pipeline/audit.ts
4756
- import { readFile as readFile11 } from "fs/promises";
4762
+ import { readFile as readFile10 } from "fs/promises";
4757
4763
  import { existsSync as existsSync13 } from "fs";
4758
4764
  import path15 from "path";
4759
4765
  function colorDistance(a, b) {
@@ -4805,7 +4811,7 @@ async function auditStyle(loaded, specs, styleId, lock) {
4805
4811
  continue;
4806
4812
  }
4807
4813
  try {
4808
- const png = decodePng(await readFile11(output.absolutePath));
4814
+ const png = decodePng(await readFile10(output.absolutePath));
4809
4815
  const palette = extractPalette(png, 12);
4810
4816
  assets.push({
4811
4817
  assetId: spec.assetId,
@@ -4832,7 +4838,7 @@ async function auditStyle(loaded, specs, styleId, lock) {
4832
4838
  const abs = path15.resolve(loaded.root, img.path);
4833
4839
  if (!existsSync13(abs)) continue;
4834
4840
  try {
4835
- refPalettes.push(extractPalette(decodePng(await readFile11(abs)), 12));
4841
+ refPalettes.push(extractPalette(decodePng(await readFile10(abs)), 12));
4836
4842
  } catch {
4837
4843
  }
4838
4844
  }
@@ -4909,7 +4915,7 @@ function hex(c) {
4909
4915
 
4910
4916
  // src/pipeline/cache-health.ts
4911
4917
  import { existsSync as existsSync14 } from "fs";
4912
- import { readFile as readFile12, readdir as readdir2, rm as rm5 } from "fs/promises";
4918
+ import { readFile as readFile11, readdir as readdir2, rm as rm5 } from "fs/promises";
4913
4919
  import path16 from "path";
4914
4920
  async function inspectCaches(lock, lockPath, options = {}) {
4915
4921
  if (options.prune && !existsSync14(lockPath)) {
@@ -4939,7 +4945,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
4939
4945
  await saveCache(remotePath, { version: 1, hashes: {} });
4940
4946
  removed.resetRemoteHashCache = true;
4941
4947
  } else if (remoteHashes.invalidIds.length) {
4942
- const cache = parseCache(JSON.parse(await readFile12(remotePath, "utf8")));
4948
+ const cache = parseCache(JSON.parse(await readFile11(remotePath, "utf8")));
4943
4949
  for (const id of remoteHashes.invalidIds) delete cache.hashes[id];
4944
4950
  removed.remoteHashEntries = remoteHashes.invalidIds.length;
4945
4951
  await saveCache(remotePath, cache);
@@ -4983,7 +4989,7 @@ async function inspectContentCache(contentDir, referenced) {
4983
4989
  const expected = mediaType ? entry.name.slice(0, -4) : "";
4984
4990
  let bytes;
4985
4991
  try {
4986
- bytes = await readFile12(file);
4992
+ bytes = await readFile11(file);
4987
4993
  report.bytes += bytes.length;
4988
4994
  } catch (err) {
4989
4995
  report.invalid.push({
@@ -5030,7 +5036,7 @@ async function inspectRemoteHashCache(remotePath) {
5030
5036
  if (!report.exists) return report;
5031
5037
  let cache;
5032
5038
  try {
5033
- cache = parseCache(JSON.parse(await readFile12(remotePath, "utf8")));
5039
+ cache = parseCache(JSON.parse(await readFile11(remotePath, "utf8")));
5034
5040
  } catch (err) {
5035
5041
  report.error = err instanceof Error ? err.message : String(err);
5036
5042
  return report;
@@ -5044,110 +5050,927 @@ async function inspectRemoteHashCache(remotePath) {
5044
5050
  return report;
5045
5051
  }
5046
5052
 
5047
- // src/pipeline/tileset-export.ts
5048
- function normalizeTileRules(raw) {
5049
- if (!isRecord(raw)) return null;
5050
- const ruleType = raw.rule_type;
5051
- const arity = raw.arity;
5052
- if (ruleType !== "edge" && ruleType !== "corner" && ruleType !== "outline") return null;
5053
- if (arity !== 4 && arity !== 6) return null;
5054
- const masks = {};
5055
- if (isRecord(raw.tiles)) {
5056
- for (const [key, value] of Object.entries(raw.tiles)) {
5057
- const index = tileIndex(key);
5058
- const mask = typeof value === "number" ? value : isRecord(value) && typeof value.bitmask === "number" ? value.bitmask : isRecord(value) && typeof value.mask === "number" ? value.mask : null;
5059
- if (index !== null && mask !== null && Number.isInteger(mask) && mask >= 0) masks[index] = mask;
5053
+ // src/pipeline/refine.ts
5054
+ import path18 from "path";
5055
+ import { execFile } from "child_process";
5056
+ import { mkdtemp, readFile as readFile13, rm as rm7 } from "fs/promises";
5057
+ import { tmpdir } from "os";
5058
+ import { promisify } from "util";
5059
+
5060
+ // src/artifacts.ts
5061
+ import path17 from "path";
5062
+ import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
5063
+ import { mkdir as mkdir5, readFile as readFile12, rename as rename5, rm as rm6, writeFile as writeFile7 } from "fs/promises";
5064
+ var activeTransactions = /* @__PURE__ */ new Set();
5065
+ function message(error) {
5066
+ return error instanceof Error ? error.message : String(error);
5067
+ }
5068
+ function digest(data) {
5069
+ return createHash2("sha256").update(data).digest("hex");
5070
+ }
5071
+ function portableRelative(from, to) {
5072
+ return path17.relative(from, path17.resolve(to)).split(path17.sep).join("/") || ".";
5073
+ }
5074
+ function canonical2(value) {
5075
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
5076
+ if (typeof value === "number") {
5077
+ if (!Number.isFinite(value)) throw new Error("Artifact provenance numbers must be finite.");
5078
+ return value;
5079
+ }
5080
+ if (Array.isArray(value)) return value.map(canonical2);
5081
+ if (typeof value === "object") {
5082
+ const sorted = {};
5083
+ for (const key of Object.keys(value).sort()) {
5084
+ const item = value[key];
5085
+ if (item !== void 0) sorted[key] = canonical2(item);
5060
5086
  }
5087
+ return sorted;
5061
5088
  }
5062
- const connectivity = raw.connectivity === "same" || raw.connectivity === "other" ? raw.connectivity : void 0;
5063
- const terrains = Array.isArray(raw.terrains) ? raw.terrains.filter((item) => typeof item === "string") : [];
5064
- return {
5065
- ruleType,
5066
- arity,
5067
- ...connectivity ? { connectivity } : {},
5068
- terrains,
5069
- masks,
5070
- raw
5071
- };
5089
+ throw new Error(`Artifact provenance cannot contain ${typeof value} values.`);
5072
5090
  }
5073
- function exportTileset(entry, spec, options) {
5074
- const outputs = resolveSpecEntryOutputs(entry, spec);
5075
- if (!outputs.length) throw new Error(`${spec.styleId}/${spec.assetId} has no downloaded outputs`);
5076
- const packed = packSprites(
5077
- outputs.map((output) => ({
5078
- id: output.role ?? fallbackOutputRole(output.index),
5079
- path: output.absolutePath
5080
- })),
5081
- { columns: options.columns, order: "input" }
5082
- );
5083
- if (packed.skipped.length) {
5084
- throw new Error(
5085
- `Cannot export ${spec.styleId}/${spec.assetId}: ` + packed.skipped.map((item) => `${item.id}: ${item.reason}`).join("; ")
5086
- );
5091
+ function fingerprint(provenance) {
5092
+ return digest(JSON.stringify(canonical2({
5093
+ kind: provenance.kind,
5094
+ sources: provenance.sources,
5095
+ options: provenance.options,
5096
+ outputs: provenance.outputs
5097
+ })));
5098
+ }
5099
+ function parseArtifactManifest(absolute, data) {
5100
+ let raw;
5101
+ try {
5102
+ raw = JSON.parse(data);
5103
+ } catch (error) {
5104
+ throw new Error(`${absolute} is not valid JSON: ${message(error)}`, { cause: error });
5087
5105
  }
5088
- const metadata = entry.providerMetadata?.[entry.provider];
5089
- const rawRules = isRecord(metadata) ? metadata.tileRules : void 0;
5090
- const rules = normalizeTileRules(rawRules);
5091
- const frames = packed.atlas.frames;
5092
- const generic = buildGeneric(
5093
- spec,
5094
- options.imageName,
5095
- packed.atlas,
5096
- frames,
5097
- isRecord(rawRules) ? rawRules : null,
5098
- rules
5106
+ if (raw.format !== "pixelkiln-artifact-bundle" || raw.version !== 1 || raw.kind !== "pack" && raw.kind !== "mount" && raw.kind !== "tileset" && raw.kind !== "refine" || typeof raw.fingerprint !== "string" || raw.options === void 0 || !Array.isArray(raw.sources) || !Array.isArray(raw.outputs)) {
5107
+ throw new Error(`${absolute} is not a PixelKiln artifact bundle manifest.`);
5108
+ }
5109
+ for (const source of raw.sources) {
5110
+ if (!source || typeof source.id !== "string" || typeof source.path !== "string" || source.sha256 !== null && typeof source.sha256 !== "string" || typeof source.included !== "boolean") {
5111
+ throw new Error(`${absolute} contains an invalid artifact source.`);
5112
+ }
5113
+ }
5114
+ for (const output of raw.outputs) {
5115
+ if (!output || typeof output.path !== "string" || typeof output.sha256 !== "string") {
5116
+ throw new Error(`${absolute} contains an invalid artifact output.`);
5117
+ }
5118
+ }
5119
+ return raw;
5120
+ }
5121
+ async function readArtifactBundleManifest(manifestPath) {
5122
+ const absolute = path17.resolve(manifestPath);
5123
+ return parseArtifactManifest(absolute, await readFile12(absolute, "utf8"));
5124
+ }
5125
+ async function readOptional(file) {
5126
+ try {
5127
+ return await readFile12(file);
5128
+ } catch (error) {
5129
+ if (isCode(error, "ENOENT")) return null;
5130
+ throw error;
5131
+ }
5132
+ }
5133
+ function transactionMarker(journal) {
5134
+ return `${journal}.committed`;
5135
+ }
5136
+ function processIsAlive(pid) {
5137
+ if (!Number.isInteger(pid) || pid < 1) return false;
5138
+ try {
5139
+ process.kill(pid, 0);
5140
+ return true;
5141
+ } catch (error) {
5142
+ return !isCode(error, "ESRCH");
5143
+ }
5144
+ }
5145
+ function validTemporaryPath(candidate, destination, type) {
5146
+ return path17.dirname(candidate) === path17.dirname(destination) && path17.basename(candidate).startsWith(
5147
+ `.${path17.basename(destination)}.pixelkiln-${type}-`
5099
5148
  );
5100
- if (options.format === "generic") {
5101
- return {
5102
- png: packed.png,
5103
- extension: ".json",
5104
- document: JSON.stringify(generic, null, 2) + "\n",
5105
- generic,
5106
- sources: packed.sources
5107
- };
5149
+ }
5150
+ function parseTransaction(journal, data) {
5151
+ let raw;
5152
+ try {
5153
+ raw = JSON.parse(data);
5154
+ } catch (error) {
5155
+ throw new Error(`${journal} is not valid transaction JSON: ${message(error)}`, { cause: error });
5108
5156
  }
5109
- assertUniformTiles(generic);
5110
- if ((rawRules !== void 0 || entry.tileFeature) && !rules) {
5111
- throw new Error(
5112
- `${options.format} export needs recognized adjacency rules; use --format generic to retain this provider rule object without guessing`
5113
- );
5157
+ if (raw.format !== "pixelkiln-artifact-transaction" || raw.version !== 1 || !Number.isInteger(raw.pid) || typeof raw.createdAt !== "string" || !Array.isArray(raw.entries) || !raw.entries.length) {
5158
+ throw new Error(`${journal} is not a PixelKiln artifact transaction.`);
5114
5159
  }
5115
- if (rules?.arity === 6) {
5160
+ return raw;
5161
+ }
5162
+ async function removeTransactionFiles(journal) {
5163
+ await rm6(journal, { force: true });
5164
+ await rm6(transactionMarker(journal), { force: true });
5165
+ }
5166
+ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
5167
+ const journal = path17.resolve(recoveryFile);
5168
+ const bytes = await readOptional(journal);
5169
+ if (!bytes) {
5170
+ await rm6(transactionMarker(journal), { force: true });
5171
+ return;
5172
+ }
5173
+ if (activeTransactions.has(journal)) {
5174
+ throw new Error(`Another artifact write in this process is already using ${journal}.`);
5175
+ }
5176
+ const transaction = parseTransaction(journal, bytes.toString("utf8"));
5177
+ if (transaction.pid !== process.pid && processIsAlive(transaction.pid)) {
5116
5178
  throw new Error(
5117
- `${options.format} export does not yet map six-edge hex masks safely; use --format generic`
5179
+ `Artifact transaction ${journal} is still owned by live process ${transaction.pid}; refusing concurrent recovery.`
5118
5180
  );
5119
5181
  }
5120
- if (rules?.ruleType === "outline") {
5182
+ for (const entry of transaction.entries) {
5183
+ if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path17.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
5184
+ throw new Error(
5185
+ `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
5186
+ );
5187
+ }
5188
+ }
5189
+ const committed = await readOptional(transactionMarker(journal)) !== null;
5190
+ const errors = [];
5191
+ if (committed) {
5192
+ for (const entry of transaction.entries) {
5193
+ if (entry.backup) await remove2(entry.backup, errors);
5194
+ await remove2(entry.stage, errors);
5195
+ }
5196
+ } else {
5197
+ for (const entry of [...transaction.entries].reverse()) {
5198
+ const backup = entry.backup ? await readOptional(entry.backup) : null;
5199
+ const destination = await readOptional(entry.destination);
5200
+ if (backup) {
5201
+ if (destination && digest(destination) !== entry.sha256) {
5202
+ errors.push(
5203
+ `destination changed during interrupted recovery: ${entry.destination}; previous file remains at ${entry.backup}`
5204
+ );
5205
+ continue;
5206
+ }
5207
+ await remove2(entry.destination, errors);
5208
+ try {
5209
+ await rename5(entry.backup, entry.destination);
5210
+ } catch (error) {
5211
+ errors.push(
5212
+ `could not restore ${entry.destination}; previous file remains at ${entry.backup}: ${message(error)}`
5213
+ );
5214
+ }
5215
+ } else if (destination && digest(destination) === entry.sha256) {
5216
+ await remove2(entry.destination, errors);
5217
+ }
5218
+ await remove2(entry.stage, errors);
5219
+ }
5220
+ }
5221
+ if (errors.length) {
5121
5222
  throw new Error(
5122
- `${options.format} export cannot infer adjacency from outline/stamp-only rules; use --format generic`
5223
+ `Artifact transaction recovery needs attention; journal retained at ${journal}: ` + errors.join("; ")
5123
5224
  );
5124
5225
  }
5125
- if (options.format === "tiled") {
5126
- return {
5127
- png: packed.png,
5128
- extension: ".tsj",
5129
- document: JSON.stringify(buildTiled(generic), null, 2) + "\n",
5130
- generic,
5131
- sources: packed.sources
5132
- };
5133
- }
5134
- return {
5135
- png: packed.png,
5136
- extension: ".tres",
5137
- document: buildGodot(generic, spec),
5138
- generic,
5139
- sources: packed.sources
5140
- };
5226
+ await removeTransactionFiles(journal);
5141
5227
  }
5142
- function buildGeneric(spec, imageName, atlas, frames, providerRules, rules) {
5228
+ function createArtifactBundleManifest(manifestPath, outputs, provenance) {
5229
+ const root = path17.dirname(path17.resolve(manifestPath));
5230
+ const sources = provenance.sources.map((source) => ({
5231
+ ...source,
5232
+ path: portableRelative(root, source.path)
5233
+ }));
5234
+ const normalized = {
5235
+ kind: provenance.kind,
5236
+ sources,
5237
+ options: canonical2(provenance.options)
5238
+ };
5239
+ const manifestOutputs = outputs.map((output) => ({
5240
+ path: portableRelative(root, output.path),
5241
+ sha256: digest(output.data)
5242
+ }));
5143
5243
  return {
5144
- format: "pixelkiln-tileset",
5244
+ format: "pixelkiln-artifact-bundle",
5145
5245
  version: 1,
5146
- name: `${spec.styleId}/${spec.assetId}`,
5147
- style: spec.styleId,
5148
- asset: spec.assetId,
5149
- image: imageName,
5150
- tile: { ...atlas.cell },
5246
+ ...normalized,
5247
+ fingerprint: fingerprint({ ...normalized, outputs: manifestOutputs }),
5248
+ outputs: manifestOutputs
5249
+ };
5250
+ }
5251
+ function withArtifactManifest(manifestPath, outputs, provenance) {
5252
+ const manifest = createArtifactBundleManifest(manifestPath, outputs, provenance);
5253
+ return [
5254
+ ...outputs,
5255
+ { path: manifestPath, data: JSON.stringify(manifest, null, 2) + "\n" }
5256
+ ];
5257
+ }
5258
+ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
5259
+ const absoluteManifest = path17.resolve(manifestPath);
5260
+ const recoveryFile = `${absoluteManifest}.transaction`;
5261
+ const allowedDestinations = /* @__PURE__ */ new Set([
5262
+ absoluteManifest,
5263
+ ...outputs.map((output) => path17.resolve(output.path))
5264
+ ]);
5265
+ await recoverArtifactTransaction(recoveryFile, allowedDestinations);
5266
+ const existingManifestBytes = await readOptional(absoluteManifest);
5267
+ let previous = null;
5268
+ if (existingManifestBytes && !options.force) {
5269
+ try {
5270
+ previous = parseArtifactManifest(absoluteManifest, existingManifestBytes.toString("utf8"));
5271
+ const valid = previous.fingerprint === fingerprint(previous);
5272
+ if (!valid) throw new Error("its integrity fingerprint does not match");
5273
+ } catch (error) {
5274
+ throw new Error(
5275
+ `Refusing to replace unrecognized or modified provenance at ${absoluteManifest}: ${message(error)}. Pass { force: true } (CLI: --force) to take ownership of this artifact bundle.`,
5276
+ { cause: error }
5277
+ );
5278
+ }
5279
+ }
5280
+ if (!options.force) {
5281
+ const root = path17.dirname(absoluteManifest);
5282
+ const recorded = new Map(
5283
+ previous?.outputs.map((output) => [path17.resolve(root, output.path), output.sha256]) ?? []
5284
+ );
5285
+ const conflicts = [];
5286
+ for (const output of outputs) {
5287
+ const destination = path17.resolve(output.path);
5288
+ const current = await readOptional(destination);
5289
+ if (!current || digest(current) === digest(output.data)) continue;
5290
+ const expected = recorded.get(destination);
5291
+ if (!expected || digest(current) !== expected) conflicts.push(destination);
5292
+ }
5293
+ if (conflicts.length) {
5294
+ throw new Error(
5295
+ `Refusing to overwrite modified or unowned artifact file(s):
5296
+ ` + conflicts.map((file) => ` ${file}`).join("\n") + `
5297
+ Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
5298
+ );
5299
+ }
5300
+ }
5301
+ const { force: _force, ...bundleOptions } = options;
5302
+ return writeArtifactBundle(
5303
+ withArtifactManifest(absoluteManifest, outputs, provenance),
5304
+ { ...bundleOptions, recoveryFile }
5305
+ );
5306
+ }
5307
+ async function verifyArtifactBundle(manifestPath) {
5308
+ const absolute = path17.resolve(manifestPath);
5309
+ const raw = await readArtifactBundleManifest(absolute);
5310
+ const root = path17.dirname(absolute);
5311
+ const changedSources = [];
5312
+ const changedOutputs = [];
5313
+ for (const source of raw.sources) {
5314
+ let actual = null;
5315
+ try {
5316
+ actual = digest(await readFile12(path17.resolve(root, source.path)));
5317
+ } catch (error) {
5318
+ if (!isCode(error, "ENOENT")) throw error;
5319
+ }
5320
+ if (actual !== source.sha256) changedSources.push(source.id);
5321
+ }
5322
+ for (const output of raw.outputs) {
5323
+ try {
5324
+ if (digest(await readFile12(path17.resolve(root, output.path))) !== output.sha256) {
5325
+ changedOutputs.push(output.path);
5326
+ }
5327
+ } catch (error) {
5328
+ if (!isCode(error, "ENOENT")) throw error;
5329
+ changedOutputs.push(output.path);
5330
+ }
5331
+ }
5332
+ const fingerprintValid = raw.fingerprint === fingerprint({
5333
+ kind: raw.kind,
5334
+ sources: raw.sources,
5335
+ options: raw.options,
5336
+ outputs: raw.outputs
5337
+ });
5338
+ return {
5339
+ current: fingerprintValid && !changedSources.length && !changedOutputs.length,
5340
+ fingerprintValid,
5341
+ changedSources,
5342
+ changedOutputs
5343
+ };
5344
+ }
5345
+ function isCode(error, code) {
5346
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
5347
+ }
5348
+ async function remove2(file, errors) {
5349
+ try {
5350
+ await rm6(file, { force: true });
5351
+ } catch (error) {
5352
+ errors?.push(`could not remove ${file}: ${message(error)}`);
5353
+ }
5354
+ }
5355
+ async function rollback(prepared) {
5356
+ const errors = [];
5357
+ for (const artifact of [...prepared].reverse()) {
5358
+ if (artifact.promoted) await remove2(artifact.destination, errors);
5359
+ }
5360
+ for (const artifact of [...prepared].reverse()) {
5361
+ if (!artifact.backup || !artifact.backedUp) continue;
5362
+ try {
5363
+ await rename5(artifact.backup, artifact.destination);
5364
+ artifact.backedUp = false;
5365
+ } catch (error) {
5366
+ errors.push(
5367
+ `could not restore ${artifact.destination}; previous file remains at ${artifact.backup}: ${message(error)}`
5368
+ );
5369
+ }
5370
+ }
5371
+ for (const artifact of prepared) {
5372
+ if (artifact.stage) await remove2(artifact.stage, errors);
5373
+ }
5374
+ return errors;
5375
+ }
5376
+ async function writeArtifactBundle(files, options = {}) {
5377
+ if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
5378
+ const normalized = files.map((file) => {
5379
+ if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
5380
+ return { destination: path17.resolve(file.path), data: Buffer.from(file.data) };
5381
+ });
5382
+ const destinations = /* @__PURE__ */ new Set();
5383
+ for (const artifact of normalized) {
5384
+ if (destinations.has(artifact.destination)) {
5385
+ throw new Error(`Artifact bundle contains duplicate destination: ${artifact.destination}`);
5386
+ }
5387
+ destinations.add(artifact.destination);
5388
+ }
5389
+ if (options.recoveryFile) {
5390
+ await recoverArtifactTransaction(options.recoveryFile, destinations);
5391
+ }
5392
+ const changed = [];
5393
+ const unchanged = [];
5394
+ for (const artifact of normalized) {
5395
+ try {
5396
+ const current = await readFile12(artifact.destination);
5397
+ if (current.equals(artifact.data)) {
5398
+ unchanged.push(artifact.destination);
5399
+ } else {
5400
+ changed.push({ ...artifact, existed: true, backedUp: false, promoted: false });
5401
+ }
5402
+ } catch (error) {
5403
+ if (!isCode(error, "ENOENT")) throw error;
5404
+ changed.push({ ...artifact, existed: false, backedUp: false, promoted: false });
5405
+ }
5406
+ }
5407
+ if (!changed.length) return { changed: [], unchanged };
5408
+ const token = randomUUID3();
5409
+ for (const [index, artifact] of changed.entries()) {
5410
+ const basename = path17.basename(artifact.destination);
5411
+ artifact.stage = path17.join(
5412
+ path17.dirname(artifact.destination),
5413
+ `.${basename}.pixelkiln-stage-${token}-${index}`
5414
+ );
5415
+ if (artifact.existed) {
5416
+ artifact.backup = path17.join(
5417
+ path17.dirname(artifact.destination),
5418
+ `.${basename}.pixelkiln-backup-${token}-${index}`
5419
+ );
5420
+ }
5421
+ }
5422
+ const journal = options.recoveryFile ? path17.resolve(options.recoveryFile) : null;
5423
+ if (journal) {
5424
+ await mkdir5(path17.dirname(journal), { recursive: true });
5425
+ const transaction = {
5426
+ format: "pixelkiln-artifact-transaction",
5427
+ version: 1,
5428
+ pid: process.pid,
5429
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5430
+ entries: changed.map((artifact) => ({
5431
+ destination: artifact.destination,
5432
+ stage: artifact.stage,
5433
+ ...artifact.backup ? { backup: artifact.backup } : {},
5434
+ sha256: digest(artifact.data)
5435
+ }))
5436
+ };
5437
+ try {
5438
+ await writeFile7(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
5439
+ activeTransactions.add(journal);
5440
+ } catch (error) {
5441
+ throw new Error(
5442
+ `Could not start durable artifact transaction at ${journal}: ${message(error)}`,
5443
+ { cause: error }
5444
+ );
5445
+ }
5446
+ }
5447
+ try {
5448
+ for (const [index, artifact] of changed.entries()) {
5449
+ await mkdir5(path17.dirname(artifact.destination), { recursive: true });
5450
+ await options.beforeStage?.(artifact.destination, index);
5451
+ await writeFile7(artifact.stage, artifact.data, { flag: "wx" });
5452
+ }
5453
+ } catch (error) {
5454
+ const cleanupErrors2 = await rollback(changed);
5455
+ if (journal && !cleanupErrors2.length) await removeTransactionFiles(journal);
5456
+ if (journal) activeTransactions.delete(journal);
5457
+ const suffix = cleanupErrors2.length ? ` Cleanup also failed: ${cleanupErrors2.join("; ")}` : "";
5458
+ throw new Error(`Could not stage artifact bundle: ${message(error)}.${suffix}`, { cause: error });
5459
+ }
5460
+ let durableCommit = false;
5461
+ try {
5462
+ for (const artifact of changed) {
5463
+ if (!artifact.existed) continue;
5464
+ await rename5(artifact.destination, artifact.backup);
5465
+ artifact.backedUp = true;
5466
+ }
5467
+ for (const [index, artifact] of changed.entries()) {
5468
+ await options.beforePromote?.(artifact.destination, index);
5469
+ await rename5(artifact.stage, artifact.destination);
5470
+ artifact.stage = void 0;
5471
+ artifact.promoted = true;
5472
+ }
5473
+ if (journal) {
5474
+ await writeFile7(transactionMarker(journal), `${token}
5475
+ `, { flag: "wx" });
5476
+ durableCommit = true;
5477
+ await options.afterCommit?.();
5478
+ }
5479
+ } catch (error) {
5480
+ if (durableCommit) {
5481
+ if (journal) activeTransactions.delete(journal);
5482
+ throw new Error(
5483
+ `Artifact bundle was committed, but final cleanup was interrupted: ${message(error)}. The next write will finish cleanup from ${journal}.`,
5484
+ { cause: error }
5485
+ );
5486
+ }
5487
+ const rollbackErrors = await rollback(changed);
5488
+ if (journal && !rollbackErrors.length) await removeTransactionFiles(journal);
5489
+ if (journal) activeTransactions.delete(journal);
5490
+ const suffix = rollbackErrors.length ? ` Rollback needs attention: ${rollbackErrors.join("; ")}` : " Previous bundle restored.";
5491
+ throw new Error(`Could not promote artifact bundle: ${message(error)}.${suffix}`, { cause: error });
5492
+ }
5493
+ const cleanupErrors = [];
5494
+ for (const artifact of changed) {
5495
+ if (artifact.backup) await remove2(artifact.backup, cleanupErrors);
5496
+ }
5497
+ if (cleanupErrors.length) {
5498
+ if (journal) activeTransactions.delete(journal);
5499
+ throw new Error(
5500
+ `Artifact bundle was written, but old backup cleanup failed: ${cleanupErrors.join("; ")}`
5501
+ );
5502
+ }
5503
+ if (journal) {
5504
+ try {
5505
+ await removeTransactionFiles(journal);
5506
+ } finally {
5507
+ activeTransactions.delete(journal);
5508
+ }
5509
+ }
5510
+ return {
5511
+ changed: changed.map((artifact) => artifact.destination),
5512
+ unchanged
5513
+ };
5514
+ }
5515
+
5516
+ // src/pipeline/refine.ts
5517
+ var execFileAsync = promisify(execFile);
5518
+ var PIXEL_ART_FIXER_URL = "https://github.com/Retro-Diffusion/pixel-art-fixer";
5519
+ var PIXEL_ART_FIXER_REVISION = "ef376e57e1c272633ca2dbf5f29ec3fcf6596465";
5520
+ function message2(error) {
5521
+ return error instanceof Error ? error.message : String(error);
5522
+ }
5523
+ function qualityRecordPath(output) {
5524
+ if (!/\.png$/i.test(output)) throw new Error("Refined output must be a .png file.");
5525
+ return output.replace(/\.png$/i, ".pixelkiln.json");
5526
+ }
5527
+ function confidenceFor(consensus) {
5528
+ if (consensus.startsWith("fast:")) return "high";
5529
+ if (consensus === "arbitrated" || consensus === "forced" || consensus.startsWith("fastmode:") && consensus.includes("+")) return "medium";
5530
+ return "low";
5531
+ }
5532
+ var confidenceRank = { low: 0, medium: 1, high: 2 };
5533
+ function requireConfidence(value) {
5534
+ if (value === "low" || value === "medium" || value === "high") return value;
5535
+ throw new Error(`Unknown grid confidence "${value}"; expected low, medium, or high.`);
5536
+ }
5537
+ function firstJsonObject(text) {
5538
+ const start = text.indexOf("{");
5539
+ if (start < 0) throw new Error("Pixel Art Fixer did not print detection JSON.");
5540
+ let depth = 0;
5541
+ let string = false;
5542
+ let escaped = false;
5543
+ for (let i = start; i < text.length; i++) {
5544
+ const char = text[i];
5545
+ if (string) {
5546
+ if (escaped) escaped = false;
5547
+ else if (char === "\\") escaped = true;
5548
+ else if (char === '"') string = false;
5549
+ continue;
5550
+ }
5551
+ if (char === '"') string = true;
5552
+ else if (char === "{") depth++;
5553
+ else if (char === "}" && --depth === 0) {
5554
+ try {
5555
+ return JSON.parse(text.slice(start, i + 1));
5556
+ } catch (error) {
5557
+ throw new Error(`Pixel Art Fixer printed invalid detection JSON: ${message2(error)}`);
5558
+ }
5559
+ }
5560
+ }
5561
+ throw new Error("Pixel Art Fixer printed incomplete detection JSON.");
5562
+ }
5563
+ var PYTHON_FIXER_BRIDGE = [
5564
+ "import json, sys",
5565
+ "from pixelfixer.api import process",
5566
+ "result = process(open(sys.argv[1], 'rb').read(), mode='full')",
5567
+ "open(sys.argv[2], 'wb').write(result.pop('png'))",
5568
+ "print(json.dumps(result))"
5569
+ ].join("; ");
5570
+ function parseDetection(raw) {
5571
+ if (!raw || typeof raw !== "object") throw new Error("Pixel Art Fixer returned no detection result.");
5572
+ const value = raw;
5573
+ const stepX = Number(value.step_x);
5574
+ const stepY = Number(value.step_y);
5575
+ const columns = Number(value.cols);
5576
+ const rows = Number(value.rows);
5577
+ const consensus = value.consensus;
5578
+ if (!Number.isFinite(stepX) || stepX <= 0 || !Number.isFinite(stepY) || stepY <= 0 || !Number.isInteger(columns) || columns < 1 || !Number.isInteger(rows) || rows < 1 || typeof consensus !== "string" || !consensus) {
5579
+ throw new Error("Pixel Art Fixer returned an invalid grid detection result.");
5580
+ }
5581
+ return {
5582
+ stepX,
5583
+ stepY,
5584
+ columns,
5585
+ rows,
5586
+ consensus,
5587
+ confidence: confidenceFor(consensus)
5588
+ };
5589
+ }
5590
+ function normalizePalette(values) {
5591
+ const colors = /* @__PURE__ */ new Map();
5592
+ for (const value of values) {
5593
+ const rgb = parseHex(value);
5594
+ const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5595
+ colors.set(hex2, { hex: hex2, ...rgb });
5596
+ }
5597
+ if (colors.size < 2 || colors.size > 256) {
5598
+ throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5599
+ }
5600
+ return [...colors.values()];
5601
+ }
5602
+ function quantizeToPalette(png, palette) {
5603
+ const colors = normalizePalette(palette);
5604
+ const pixels = Buffer.from(png.pixels);
5605
+ for (let i = 0; i < pixels.length; i += 4) {
5606
+ const alpha = pixels[i + 3];
5607
+ if (alpha === 0) {
5608
+ pixels[i] = 0;
5609
+ pixels[i + 1] = 0;
5610
+ pixels[i + 2] = 0;
5611
+ continue;
5612
+ }
5613
+ const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5614
+ let nearest = colors[0];
5615
+ let distance = colorDistance(pixel, nearest);
5616
+ for (let j = 1; j < colors.length; j++) {
5617
+ const candidate = colors[j];
5618
+ const candidateDistance = colorDistance(pixel, candidate);
5619
+ if (candidateDistance < distance) {
5620
+ nearest = candidate;
5621
+ distance = candidateDistance;
5622
+ }
5623
+ }
5624
+ pixels[i] = nearest.r;
5625
+ pixels[i + 1] = nearest.g;
5626
+ pixels[i + 2] = nearest.b;
5627
+ }
5628
+ return { width: png.width, height: png.height, pixels };
5629
+ }
5630
+ function colorCount(png) {
5631
+ const colors = /* @__PURE__ */ new Set();
5632
+ for (let i = 0; i < png.pixels.length; i += 4) {
5633
+ if (png.pixels[i + 3] < 128) continue;
5634
+ colors.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
5635
+ }
5636
+ return colors.size;
5637
+ }
5638
+ function unexpectedPaletteColors(png, palette) {
5639
+ const allowed = new Set(palette.map((hex2) => {
5640
+ const color = parseHex(hex2);
5641
+ return color.r << 16 | color.g << 8 | color.b;
5642
+ }));
5643
+ const unexpected = /* @__PURE__ */ new Set();
5644
+ for (let i = 0; i < png.pixels.length; i += 4) {
5645
+ if (png.pixels[i + 3] === 0) continue;
5646
+ const color = png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2];
5647
+ if (!allowed.has(color)) unexpected.add(color);
5648
+ }
5649
+ return unexpected.size;
5650
+ }
5651
+ function auditRefinement(png, detection, palette, minTransparency, minGridConfidence) {
5652
+ const colors = colorCount(png);
5653
+ const unexpectedColors = unexpectedPaletteColors(png, palette);
5654
+ const transparency = transparencyRatio(png);
5655
+ const reasons = [];
5656
+ if (png.width !== detection.columns || png.height !== detection.rows) {
5657
+ reasons.push(
5658
+ `native output is ${png.width}x${png.height}, expected detected grid ${detection.columns}x${detection.rows}`
5659
+ );
5660
+ }
5661
+ if (colors > palette.length) reasons.push(`opaque color count ${colors} exceeds ${palette.length}`);
5662
+ if (unexpectedColors) reasons.push(`${unexpectedColors} visible color(s) are outside the final palette`);
5663
+ if (minTransparency !== void 0 && transparency < minTransparency) {
5664
+ reasons.push(
5665
+ `transparency ${(transparency * 100).toFixed(1)}% is below ${(minTransparency * 100).toFixed(1)}%`
5666
+ );
5667
+ }
5668
+ if (confidenceRank[detection.confidence] < confidenceRank[minGridConfidence]) {
5669
+ reasons.push(
5670
+ `grid confidence ${detection.confidence} is below required ${minGridConfidence} (${detection.consensus})`
5671
+ );
5672
+ }
5673
+ return {
5674
+ width: png.width,
5675
+ height: png.height,
5676
+ colorCount: colors,
5677
+ paletteConformant: unexpectedColors === 0,
5678
+ transparency,
5679
+ thresholds: {
5680
+ maxColors: palette.length,
5681
+ minTransparency: minTransparency ?? null,
5682
+ minGridConfidence
5683
+ },
5684
+ safe: reasons.length === 0,
5685
+ reasons
5686
+ };
5687
+ }
5688
+ function isGridConfidence(value) {
5689
+ return value === "low" || value === "medium" || value === "high";
5690
+ }
5691
+ function parseRecordOptions(manifest, record) {
5692
+ if (manifest.kind !== "refine") throw new Error(`${record} is not a PixelKiln refinement record.`);
5693
+ const raw = manifest.options;
5694
+ if (!raw || typeof raw !== "object") throw new Error(`${record} has invalid quality metadata.`);
5695
+ const options = raw;
5696
+ const grid = options.nativeGrid;
5697
+ const palette = options.palette;
5698
+ const audit = options.audit;
5699
+ const review = options.review;
5700
+ if (options.schema !== "pixelkiln-quality" || options.version !== 1 || !grid || grid.tool !== "pixel-art-fixer" || grid.url !== PIXEL_ART_FIXER_URL || typeof grid.revision !== "string" || !grid.revision || grid.protocol !== "python-api-v1" && grid.protocol !== "python-cli-v1" || !isGridConfidence(grid.confidence) || typeof grid.consensus !== "string" || !Number.isFinite(grid.stepX) || !Number.isFinite(grid.stepY) || !Number.isInteger(grid.sourceWidth) || !Number.isInteger(grid.sourceHeight) || !Number.isInteger(grid.nativeWidth) || !Number.isInteger(grid.nativeHeight) || !palette || !Array.isArray(palette.colors) || palette.colors.length < 2 || !palette.colors.every((color) => typeof color === "string") || palette.dither !== "none" || palette.distance !== "redmean" || !audit || typeof audit.safe !== "boolean" || typeof audit.paletteConformant !== "boolean" || !Array.isArray(audit.reasons) || !review || review.status !== "pending" && review.status !== "approved") {
5701
+ throw new Error(`${record} has invalid quality metadata.`);
5702
+ }
5703
+ return options;
5704
+ }
5705
+ async function refineAsset(options) {
5706
+ const source = path18.resolve(options.source);
5707
+ const output = path18.resolve(options.output);
5708
+ if (source === output) throw new Error("Refinement source and output must be different files.");
5709
+ const record = path18.resolve(qualityRecordPath(output));
5710
+ const palette = normalizePalette(options.palette).map((color) => color.hex);
5711
+ const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
5712
+ if (options.minTransparency !== void 0 && (!Number.isFinite(options.minTransparency) || options.minTransparency < 0 || options.minTransparency > 1)) throw new Error("minTransparency must be between 0 and 1.");
5713
+ let sourcePng;
5714
+ try {
5715
+ sourcePng = decodePng(await readFile13(source));
5716
+ } catch (error) {
5717
+ throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
5718
+ }
5719
+ const temp = await mkdtemp(path18.join(tmpdir(), "pixelkiln-refine-"));
5720
+ const recovered = path18.join(temp, "native.png");
5721
+ const compatibilityCommand = options.fixerCommand;
5722
+ const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
5723
+ const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
5724
+ try {
5725
+ let stdout;
5726
+ try {
5727
+ const result = await execFileAsync(fixerCommand, args, {
5728
+ encoding: "utf8",
5729
+ maxBuffer: 4 * 1024 * 1024,
5730
+ timeout: 10 * 60 * 1e3
5731
+ });
5732
+ stdout = result.stdout;
5733
+ } catch (error) {
5734
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
5735
+ const install = `Install the pinned Python package from ${PIXEL_ART_FIXER_URL} at ${options.fixerRevision ?? PIXEL_ART_FIXER_REVISION}, or pass --fixer-python.`;
5736
+ throw new Error(
5737
+ code === "ENOENT" ? `Pixel Art Fixer command not found: ${fixerCommand}. ${install}` : `Pixel Art Fixer failed: ${message2(error)}. ${install}`,
5738
+ { cause: error }
5739
+ );
5740
+ }
5741
+ const detection = parseDetection(firstJsonObject(stdout));
5742
+ const native = decodePng(await readFile13(recovered));
5743
+ const quantized = quantizeToPalette(native, palette);
5744
+ const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
5745
+ const audit = auditRefinement(
5746
+ quantized,
5747
+ detection,
5748
+ palette,
5749
+ options.minTransparency,
5750
+ minGridConfidence
5751
+ );
5752
+ if (!audit.safe) {
5753
+ throw new Error(`Refinement failed its quality audit: ${audit.reasons.join("; ")}`);
5754
+ }
5755
+ const provenanceSource = {
5756
+ id: "source",
5757
+ path: source,
5758
+ sha256: await sha256File(source),
5759
+ included: true
5760
+ };
5761
+ const recordOptions = {
5762
+ schema: "pixelkiln-quality",
5763
+ version: 1,
5764
+ nativeGrid: {
5765
+ tool: "pixel-art-fixer",
5766
+ url: PIXEL_ART_FIXER_URL,
5767
+ revision: options.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
5768
+ protocol: compatibilityCommand ? "python-cli-v1" : "python-api-v1",
5769
+ confidence: detection.confidence,
5770
+ consensus: detection.consensus,
5771
+ stepX: detection.stepX,
5772
+ stepY: detection.stepY,
5773
+ sourceWidth: sourcePng.width,
5774
+ sourceHeight: sourcePng.height,
5775
+ nativeWidth: quantized.width,
5776
+ nativeHeight: quantized.height
5777
+ },
5778
+ palette: { colors: palette, dither: "none", distance: "redmean" },
5779
+ audit,
5780
+ review: { status: "pending" }
5781
+ };
5782
+ await writeManagedArtifactBundle(
5783
+ record,
5784
+ [{ path: output, data: png }],
5785
+ { kind: "refine", sources: [provenanceSource], options: recordOptions },
5786
+ { force: options.force }
5787
+ );
5788
+ return { source, output, record, detection, palette, audit };
5789
+ } finally {
5790
+ await rm7(temp, { recursive: true, force: true });
5791
+ }
5792
+ }
5793
+ async function checkQualityRecord(recordPath) {
5794
+ const record = path18.resolve(recordPath);
5795
+ const manifest = await readArtifactBundleManifest(record);
5796
+ const options = parseRecordOptions(manifest, record);
5797
+ const verification = await verifyArtifactBundle(record);
5798
+ const output = manifest.outputs.length === 1 ? path18.resolve(path18.dirname(record), manifest.outputs[0].path) : "";
5799
+ const reasons = [];
5800
+ if (!verification.fingerprintValid) reasons.push("quality record fingerprint changed");
5801
+ for (const source of verification.changedSources) reasons.push(`source changed: ${source}`);
5802
+ for (const changed of verification.changedOutputs) reasons.push(`output changed: ${changed}`);
5803
+ if (manifest.sources.length !== 1) reasons.push("quality record must contain exactly one source");
5804
+ if (manifest.outputs.length !== 1) reasons.push("quality record must contain exactly one output");
5805
+ if (!options.audit.safe) reasons.push(...options.audit.reasons.map((reason) => `audit: ${reason}`));
5806
+ if (options.review.status !== "approved") reasons.push("human 1\xD7 review is pending");
5807
+ return {
5808
+ safe: reasons.length === 0,
5809
+ current: verification.current,
5810
+ approved: options.review.status === "approved",
5811
+ auditSafe: options.audit.safe,
5812
+ record,
5813
+ output,
5814
+ options,
5815
+ reasons
5816
+ };
5817
+ }
5818
+ async function approveQualityRecord(recordPath, approval) {
5819
+ const reviewer = approval.reviewer.trim();
5820
+ if (!reviewer) throw new Error("A human reviewer name is required.");
5821
+ const record = path18.resolve(recordPath);
5822
+ const manifest = await readArtifactBundleManifest(record);
5823
+ const options = parseRecordOptions(manifest, record);
5824
+ const verification = await verifyArtifactBundle(record);
5825
+ if (!verification.current) {
5826
+ const changes = [
5827
+ ...verification.changedSources.map((source) => `source ${source}`),
5828
+ ...verification.changedOutputs.map((output2) => `output ${output2}`),
5829
+ ...!verification.fingerprintValid ? ["quality metadata"] : []
5830
+ ];
5831
+ throw new Error(`Cannot approve a stale refinement; changed: ${changes.join(", ")}. Run refine again.`);
5832
+ }
5833
+ if (!options.audit.safe) {
5834
+ throw new Error(`Cannot approve a failed quality audit: ${options.audit.reasons.join("; ")}`);
5835
+ }
5836
+ if (manifest.sources.length !== 1 || manifest.outputs.length !== 1) {
5837
+ throw new Error("A refinement record must contain exactly one source and one output.");
5838
+ }
5839
+ const root = path18.dirname(record);
5840
+ const outputPath = path18.resolve(root, manifest.outputs[0].path);
5841
+ const output = await readFile13(outputPath);
5842
+ const sources = manifest.sources.map((source) => ({
5843
+ ...source,
5844
+ path: path18.resolve(root, source.path)
5845
+ }));
5846
+ const approved = {
5847
+ ...options,
5848
+ review: {
5849
+ status: "approved",
5850
+ reviewer,
5851
+ approvedAt: (approval.approvedAt ?? /* @__PURE__ */ new Date()).toISOString(),
5852
+ checklist: {
5853
+ nativeScale: true,
5854
+ crispEdges: true,
5855
+ subjectReadable: true,
5856
+ paletteSeparation: true,
5857
+ alphaAndSeams: true
5858
+ },
5859
+ ...approval.note?.trim() ? { note: approval.note.trim() } : {}
5860
+ }
5861
+ };
5862
+ await writeManagedArtifactBundle(
5863
+ record,
5864
+ [{ path: outputPath, data: output }],
5865
+ { kind: "refine", sources, options: approved }
5866
+ );
5867
+ return checkQualityRecord(record);
5868
+ }
5869
+
5870
+ // src/pipeline/tileset-export.ts
5871
+ function normalizeTileRules(raw) {
5872
+ if (!isRecord(raw)) return null;
5873
+ const ruleType = raw.rule_type;
5874
+ const arity = raw.arity;
5875
+ if (ruleType !== "edge" && ruleType !== "corner" && ruleType !== "outline") return null;
5876
+ if (arity !== 4 && arity !== 6) return null;
5877
+ const masks = {};
5878
+ if (isRecord(raw.tiles)) {
5879
+ for (const [key, value] of Object.entries(raw.tiles)) {
5880
+ const index = tileIndex(key);
5881
+ const mask = typeof value === "number" ? value : isRecord(value) && typeof value.bitmask === "number" ? value.bitmask : isRecord(value) && typeof value.mask === "number" ? value.mask : null;
5882
+ if (index !== null && mask !== null && Number.isInteger(mask) && mask >= 0) masks[index] = mask;
5883
+ }
5884
+ }
5885
+ const connectivity = raw.connectivity === "same" || raw.connectivity === "other" ? raw.connectivity : void 0;
5886
+ const terrains = Array.isArray(raw.terrains) ? raw.terrains.filter((item) => typeof item === "string") : [];
5887
+ return {
5888
+ ruleType,
5889
+ arity,
5890
+ ...connectivity ? { connectivity } : {},
5891
+ terrains,
5892
+ masks,
5893
+ raw
5894
+ };
5895
+ }
5896
+ function exportTileset(entry, spec, options) {
5897
+ const outputs = resolveSpecEntryOutputs(entry, spec);
5898
+ if (!outputs.length) throw new Error(`${spec.styleId}/${spec.assetId} has no downloaded outputs`);
5899
+ const packed = packSprites(
5900
+ outputs.map((output) => ({
5901
+ id: output.role ?? fallbackOutputRole(output.index),
5902
+ path: output.absolutePath
5903
+ })),
5904
+ { columns: options.columns, order: "input" }
5905
+ );
5906
+ if (packed.skipped.length) {
5907
+ throw new Error(
5908
+ `Cannot export ${spec.styleId}/${spec.assetId}: ` + packed.skipped.map((item) => `${item.id}: ${item.reason}`).join("; ")
5909
+ );
5910
+ }
5911
+ const metadata = entry.providerMetadata?.[entry.provider];
5912
+ const rawRules = isRecord(metadata) ? metadata.tileRules : void 0;
5913
+ const rules = normalizeTileRules(rawRules);
5914
+ const frames = packed.atlas.frames;
5915
+ const generic = buildGeneric(
5916
+ spec,
5917
+ options.imageName,
5918
+ packed.atlas,
5919
+ frames,
5920
+ isRecord(rawRules) ? rawRules : null,
5921
+ rules
5922
+ );
5923
+ if (options.format === "generic") {
5924
+ return {
5925
+ png: packed.png,
5926
+ extension: ".json",
5927
+ document: JSON.stringify(generic, null, 2) + "\n",
5928
+ generic,
5929
+ sources: packed.sources
5930
+ };
5931
+ }
5932
+ assertUniformTiles(generic);
5933
+ if ((rawRules !== void 0 || entry.tileFeature) && !rules) {
5934
+ throw new Error(
5935
+ `${options.format} export needs recognized adjacency rules; use --format generic to retain this provider rule object without guessing`
5936
+ );
5937
+ }
5938
+ if (rules?.arity === 6) {
5939
+ throw new Error(
5940
+ `${options.format} export does not yet map six-edge hex masks safely; use --format generic`
5941
+ );
5942
+ }
5943
+ if (rules?.ruleType === "outline") {
5944
+ throw new Error(
5945
+ `${options.format} export cannot infer adjacency from outline/stamp-only rules; use --format generic`
5946
+ );
5947
+ }
5948
+ if (options.format === "tiled") {
5949
+ return {
5950
+ png: packed.png,
5951
+ extension: ".tsj",
5952
+ document: JSON.stringify(buildTiled(generic), null, 2) + "\n",
5953
+ generic,
5954
+ sources: packed.sources
5955
+ };
5956
+ }
5957
+ return {
5958
+ png: packed.png,
5959
+ extension: ".tres",
5960
+ document: buildGodot(generic, spec),
5961
+ generic,
5962
+ sources: packed.sources
5963
+ };
5964
+ }
5965
+ function buildGeneric(spec, imageName, atlas, frames, providerRules, rules) {
5966
+ return {
5967
+ format: "pixelkiln-tileset",
5968
+ version: 1,
5969
+ name: `${spec.styleId}/${spec.assetId}`,
5970
+ style: spec.styleId,
5971
+ asset: spec.assetId,
5972
+ image: imageName,
5973
+ tile: { ...atlas.cell },
5151
5974
  sheet: { ...atlas.sheet, columns: atlas.columns },
5152
5975
  tiles: frames.map((frame, index) => {
5153
5976
  const sourceIndex = tileIndex(frame.id) ?? index;
@@ -5301,8 +6124,8 @@ function isRecord(value) {
5301
6124
  }
5302
6125
 
5303
6126
  // src/pick/salvage-server.ts
5304
- import { mkdir as mkdir5, writeFile as writeFile7, readFile as readFile13 } from "fs/promises";
5305
- import path17 from "path";
6127
+ import { mkdir as mkdir6, writeFile as writeFile8, readFile as readFile14 } from "fs/promises";
6128
+ import path19 from "path";
5306
6129
 
5307
6130
  // src/pick/salvage-sheet.ts
5308
6131
  var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -5467,7 +6290,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
5467
6290
  });
5468
6291
  const html = renderSalvageSheet(orphans, {
5469
6292
  styleId: ctx.styleId,
5470
- importDir: path17.relative(process.cwd(), ctx.importDir) || "."
6293
+ importDir: path19.relative(process.cwd(), ctx.importDir) || "."
5471
6294
  });
5472
6295
  const byId = new Map(orphans.map((o) => [o.id, o]));
5473
6296
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -5496,10 +6319,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
5496
6319
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
5497
6320
  decodePng(buf);
5498
6321
  const assetId = idFromPrompt(orphan.prompt, taken);
5499
- const rel = path17.join("_salvaged", `${assetId}.png`);
5500
- const outFile = path17.resolve(ctx.importDir, rel);
5501
- await mkdir5(path17.dirname(outFile), { recursive: true });
5502
- await writeFile7(outFile, buf);
6322
+ const rel = path19.join("_salvaged", `${assetId}.png`);
6323
+ const outFile = path19.resolve(ctx.importDir, rel);
6324
+ await mkdir6(path19.dirname(outFile), { recursive: true });
6325
+ await writeFile8(outFile, buf);
5503
6326
  ctx.manifest.assets[assetId] = {
5504
6327
  prompt: orphan.prompt,
5505
6328
  promptByStyle: {},
@@ -5525,7 +6348,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
5525
6348
  error: null,
5526
6349
  sourceUrl: orphan.previewUrl,
5527
6350
  outputs: [{
5528
- path: portableOutputPath(outFile, path17.dirname(ctx.manifestPath)),
6351
+ path: portableOutputPath(outFile, path19.dirname(ctx.manifestPath)),
5529
6352
  sha256: sha256(buf)
5530
6353
  }],
5531
6354
  submittedAt: orphan.createdAt,
@@ -5549,437 +6372,751 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
5549
6372
  }
5550
6373
  }
5551
6374
  await applyTags(provider, decisions, existingTags, { onProgress: log2 });
5552
- const raw = JSON.parse(await readFile13(ctx.manifestPath, "utf8"));
6375
+ const raw = JSON.parse(await readFile14(ctx.manifestPath, "utf8"));
5553
6376
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
5554
- await writeFile7(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
6377
+ await writeFile8(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
5555
6378
  await saveLock(ctx.lockPath, ctx.lock);
5556
6379
  return result;
5557
6380
  }
5558
6381
  });
5559
6382
  }
5560
6383
 
5561
- // src/artifacts.ts
5562
- import path18 from "path";
5563
- import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
5564
- import { mkdir as mkdir6, readFile as readFile14, rename as rename5, rm as rm6, writeFile as writeFile8 } from "fs/promises";
5565
- var activeTransactions = /* @__PURE__ */ new Set();
5566
- function message(error) {
5567
- return error instanceof Error ? error.message : String(error);
5568
- }
5569
- function digest(data) {
5570
- return createHash2("sha256").update(data).digest("hex");
5571
- }
5572
- function portableRelative(from, to) {
5573
- return path18.relative(from, path18.resolve(to)).split(path18.sep).join("/") || ".";
5574
- }
5575
- function canonical2(value) {
6384
+ // src/recipes.ts
6385
+ import path20 from "path";
6386
+ import { existsSync as existsSync15 } from "fs";
6387
+ import { readdir as readdir3, readFile as readFile15 } from "fs/promises";
6388
+ import { fileURLToPath } from "url";
6389
+ import { z as z5 } from "zod";
6390
+ var SHA256_RE = /^[0-9a-f]{64}$/;
6391
+ var RECIPE_ID_RE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
6392
+ var VERSION_RE = /^\d+\.\d+\.\d+$/;
6393
+ var RECIPE_FILE = "pixelkiln.recipe.json";
6394
+ var RECIPE_DIR_TOKEN = "{{recipeDir}}";
6395
+ var PortablePathSchema = z5.string().min(1).refine((value) => {
6396
+ if (value.includes("\\") || path20.posix.isAbsolute(value)) return false;
6397
+ const parts = value.split("/");
6398
+ return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
6399
+ }, "expected a portable relative path without '.', '..', or backslashes");
6400
+ var RecipeBindingSchema = z5.object({
6401
+ nodeId: z5.string().min(1),
6402
+ input: z5.string().min(1)
6403
+ }).strict();
6404
+ var RecipeSchema = z5.object({
6405
+ $schema: z5.string().url().optional(),
6406
+ format: z5.literal("pixelkiln-recipe"),
6407
+ schemaVersion: z5.literal(1),
6408
+ id: z5.string().regex(RECIPE_ID_RE, "expected provider/name"),
6409
+ version: z5.string().regex(VERSION_RE, "expected x.y.z"),
6410
+ provider: z5.string().regex(/^[a-z0-9][a-z0-9-]*$/),
6411
+ summary: z5.string().min(1).max(240),
6412
+ files: z5.array(z5.object({
6413
+ path: PortablePathSchema,
6414
+ role: z5.enum(["workflow", "reference"]),
6415
+ sha256: z5.string().regex(SHA256_RE)
6416
+ }).strict()).default([]),
6417
+ models: z5.array(z5.object({
6418
+ path: PortablePathSchema,
6419
+ sha256: z5.string().regex(SHA256_RE),
6420
+ source: z5.string().url(),
6421
+ license: z5.string().min(1)
6422
+ }).strict()).default([]),
6423
+ styleId: z5.string().regex(/^[a-z0-9][a-z0-9-]*$/),
6424
+ style: StyleSchema,
6425
+ workflow: z5.object({
6426
+ path: PortablePathSchema,
6427
+ outputNodeId: z5.string().min(1),
6428
+ numImages: z5.number().int().min(1).max(16),
6429
+ bindings: z5.object({
6430
+ prompt: RecipeBindingSchema,
6431
+ width: RecipeBindingSchema,
6432
+ height: RecipeBindingSchema,
6433
+ batchSize: RecipeBindingSchema,
6434
+ seed: RecipeBindingSchema.optional()
6435
+ }).strict()
6436
+ }).strict().optional(),
6437
+ quality: z5.object({
6438
+ stage: z5.enum(["composition-source", "production-candidate"]),
6439
+ workingCanvas: z5.object({
6440
+ width: z5.number().int().min(16),
6441
+ height: z5.number().int().min(16)
6442
+ }).strict(),
6443
+ recommendedNativeSize: z5.object({
6444
+ min: z5.number().int().min(1),
6445
+ max: z5.number().int().min(1)
6446
+ }).strict(),
6447
+ paletteColors: z5.object({
6448
+ min: z5.number().int().min(1),
6449
+ max: z5.number().int().min(1)
6450
+ }).strict(),
6451
+ background: z5.enum(["opaque", "transparent", "full-bleed"]),
6452
+ checks: z5.array(z5.enum([
6453
+ "prompt-coverage",
6454
+ "native-grid",
6455
+ "final-palette",
6456
+ "alpha-edge",
6457
+ "human-1x"
6458
+ ])).min(1),
6459
+ notes: z5.array(z5.string().min(1)).default([])
6460
+ }).strict(),
6461
+ integrity: z5.object({
6462
+ algorithm: z5.literal("sha256"),
6463
+ digest: z5.string().regex(SHA256_RE)
6464
+ }).strict()
6465
+ }).strict().superRefine((recipe, context) => {
6466
+ const filePaths = recipe.files.map((file) => file.path);
6467
+ if (new Set(filePaths).size !== filePaths.length) {
6468
+ context.addIssue({ code: z5.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
6469
+ }
6470
+ const modelPaths = recipe.models.map((model) => model.path);
6471
+ if (new Set(modelPaths).size !== modelPaths.length) {
6472
+ context.addIssue({ code: z5.ZodIssueCode.custom, path: ["models"], message: "duplicate model path" });
6473
+ }
6474
+ if (filePaths.includes(RECIPE_FILE)) {
6475
+ context.addIssue({
6476
+ code: z5.ZodIssueCode.custom,
6477
+ path: ["files"],
6478
+ message: `${RECIPE_FILE} cannot hash itself`
6479
+ });
6480
+ }
6481
+ if (recipe.quality.recommendedNativeSize.min > recipe.quality.recommendedNativeSize.max) {
6482
+ context.addIssue({
6483
+ code: z5.ZodIssueCode.custom,
6484
+ path: ["quality", "recommendedNativeSize"],
6485
+ message: "min cannot exceed max"
6486
+ });
6487
+ }
6488
+ if (recipe.quality.paletteColors.min > recipe.quality.paletteColors.max) {
6489
+ context.addIssue({
6490
+ code: z5.ZodIssueCode.custom,
6491
+ path: ["quality", "paletteColors"],
6492
+ message: "min cannot exceed max"
6493
+ });
6494
+ }
6495
+ if (recipe.workflow && !recipe.files.some(
6496
+ (file) => file.path === recipe.workflow.path && file.role === "workflow"
6497
+ )) {
6498
+ context.addIssue({
6499
+ code: z5.ZodIssueCode.custom,
6500
+ path: ["workflow", "path"],
6501
+ message: "must name a file whose role is workflow"
6502
+ });
6503
+ }
6504
+ if (recipe.provider === "comfyui") {
6505
+ if (!recipe.workflow) {
6506
+ context.addIssue({
6507
+ code: z5.ZodIssueCode.custom,
6508
+ path: ["workflow"],
6509
+ message: "ComfyUI recipes require workflow metadata"
6510
+ });
6511
+ return;
6512
+ }
6513
+ const options = recipe.style.providerOptions.comfyui;
6514
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
6515
+ context.addIssue({
6516
+ code: z5.ZodIssueCode.custom,
6517
+ path: ["style", "providerOptions", "comfyui"],
6518
+ message: "ComfyUI recipes require ComfyUI provider options"
6519
+ });
6520
+ return;
6521
+ }
6522
+ const expected = {
6523
+ workflowFile: `${RECIPE_DIR_TOKEN}/${recipe.workflow.path}`,
6524
+ outputNodeId: recipe.workflow.outputNodeId,
6525
+ numImages: recipe.workflow.numImages,
6526
+ bindings: recipe.workflow.bindings
6527
+ };
6528
+ if (JSON.stringify(canonical3(options)) !== JSON.stringify(canonical3(expected))) {
6529
+ context.addIssue({
6530
+ code: z5.ZodIssueCode.custom,
6531
+ path: ["style", "providerOptions", "comfyui"],
6532
+ message: "must match workflow metadata and use {{recipeDir}} for workflowFile"
6533
+ });
6534
+ }
6535
+ }
6536
+ });
6537
+ function canonical3(value) {
5576
6538
  if (value === null || typeof value === "string" || typeof value === "boolean") return value;
5577
6539
  if (typeof value === "number") {
5578
- if (!Number.isFinite(value)) throw new Error("Artifact provenance numbers must be finite.");
6540
+ if (!Number.isFinite(value)) throw new Error("Recipe numbers must be finite.");
5579
6541
  return value;
5580
6542
  }
5581
- if (Array.isArray(value)) return value.map(canonical2);
6543
+ if (Array.isArray(value)) return value.map(canonical3);
5582
6544
  if (typeof value === "object") {
5583
6545
  const sorted = {};
5584
6546
  for (const key of Object.keys(value).sort()) {
5585
6547
  const item = value[key];
5586
- if (item !== void 0) sorted[key] = canonical2(item);
6548
+ if (item !== void 0) sorted[key] = canonical3(item);
5587
6549
  }
5588
6550
  return sorted;
5589
6551
  }
5590
- throw new Error(`Artifact provenance cannot contain ${typeof value} values.`);
6552
+ throw new Error(`Recipe metadata cannot contain ${typeof value} values.`);
5591
6553
  }
5592
- function fingerprint(provenance) {
5593
- return digest(JSON.stringify(canonical2({
5594
- kind: provenance.kind,
5595
- sources: provenance.sources,
5596
- options: provenance.options,
5597
- outputs: provenance.outputs
5598
- })));
6554
+ function recipeDigest(recipe) {
6555
+ const { integrity: _integrity, ...payload } = recipe;
6556
+ return sha256(JSON.stringify(canonical3(payload)));
5599
6557
  }
5600
- function parseArtifactManifest(absolute, data) {
6558
+ function message3(error) {
6559
+ return error instanceof Error ? error.message : String(error);
6560
+ }
6561
+ async function readRecipeFile(recipePath, bundled) {
6562
+ const absolute = path20.resolve(recipePath);
5601
6563
  let raw;
5602
6564
  try {
5603
- raw = JSON.parse(data);
6565
+ raw = JSON.parse(await readFile15(absolute, "utf8"));
5604
6566
  } catch (error) {
5605
- throw new Error(`${absolute} is not valid JSON: ${message(error)}`, { cause: error });
6567
+ throw new Error(`Could not read recipe ${absolute}: ${message3(error)}`, { cause: error });
5606
6568
  }
5607
- if (raw.format !== "pixelkiln-artifact-bundle" || raw.version !== 1 || raw.kind !== "pack" && raw.kind !== "mount" && raw.kind !== "tileset" || typeof raw.fingerprint !== "string" || raw.options === void 0 || !Array.isArray(raw.sources) || !Array.isArray(raw.outputs)) {
5608
- throw new Error(`${absolute} is not a PixelKiln artifact bundle manifest.`);
5609
- }
5610
- for (const source of raw.sources) {
5611
- if (!source || typeof source.id !== "string" || typeof source.path !== "string" || source.sha256 !== null && typeof source.sha256 !== "string" || typeof source.included !== "boolean") {
5612
- throw new Error(`${absolute} contains an invalid artifact source.`);
5613
- }
5614
- }
5615
- for (const output of raw.outputs) {
5616
- if (!output || typeof output.path !== "string" || typeof output.sha256 !== "string") {
5617
- throw new Error(`${absolute} contains an invalid artifact output.`);
5618
- }
6569
+ const parsed = RecipeSchema.safeParse(raw);
6570
+ if (!parsed.success) {
6571
+ throw new Error(
6572
+ `Invalid recipe ${absolute}:
6573
+ ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
6574
+ );
5619
6575
  }
5620
- return raw;
6576
+ return { recipe: parsed.data, path: absolute, dir: path20.dirname(absolute), bundled };
5621
6577
  }
5622
- async function readOptional(file) {
5623
- try {
5624
- return await readFile14(file);
5625
- } catch (error) {
5626
- if (isCode(error, "ENOENT")) return null;
5627
- throw error;
6578
+ async function walkRecipeFiles(root) {
6579
+ if (!existsSync15(root)) return [];
6580
+ const out = [];
6581
+ for (const entry of await readdir3(root, { withFileTypes: true })) {
6582
+ const resolved = path20.join(root, entry.name);
6583
+ if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
6584
+ else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
5628
6585
  }
6586
+ return out;
5629
6587
  }
5630
- function transactionMarker(journal) {
5631
- return `${journal}.committed`;
5632
- }
5633
- function processIsAlive(pid) {
5634
- if (!Number.isInteger(pid) || pid < 1) return false;
5635
- try {
5636
- process.kill(pid, 0);
5637
- return true;
5638
- } catch (error) {
5639
- return !isCode(error, "ESRCH");
5640
- }
6588
+ function bundledRecipeRoot() {
6589
+ return fileURLToPath(new URL("../recipes/", import.meta.url));
5641
6590
  }
5642
- function validTemporaryPath(candidate, destination, type) {
5643
- return path18.dirname(candidate) === path18.dirname(destination) && path18.basename(candidate).startsWith(
5644
- `.${path18.basename(destination)}.pixelkiln-${type}-`
6591
+ async function listBundledRecipes(root = bundledRecipeRoot()) {
6592
+ const recipes = await Promise.all(
6593
+ (await walkRecipeFiles(path20.resolve(root))).map((file) => readRecipeFile(file, true))
6594
+ );
6595
+ return recipes.sort(
6596
+ (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
5645
6597
  );
5646
6598
  }
5647
- function parseTransaction(journal, data) {
5648
- let raw;
5649
- try {
5650
- raw = JSON.parse(data);
5651
- } catch (error) {
5652
- throw new Error(`${journal} is not valid transaction JSON: ${message(error)}`, { cause: error });
5653
- }
5654
- if (raw.format !== "pixelkiln-artifact-transaction" || raw.version !== 1 || !Number.isInteger(raw.pid) || typeof raw.createdAt !== "string" || !Array.isArray(raw.entries) || !raw.entries.length) {
5655
- throw new Error(`${journal} is not a PixelKiln artifact transaction.`);
6599
+ function compareVersions(a, b) {
6600
+ const av = a.split(".").map(Number);
6601
+ const bv = b.split(".").map(Number);
6602
+ for (let i = 0; i < 3; i++) {
6603
+ const difference = av[i] - bv[i];
6604
+ if (difference) return difference;
5656
6605
  }
5657
- return raw;
5658
- }
5659
- async function removeTransactionFiles(journal) {
5660
- await rm6(journal, { force: true });
5661
- await rm6(transactionMarker(journal), { force: true });
6606
+ return 0;
5662
6607
  }
5663
- async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
5664
- const journal = path18.resolve(recoveryFile);
5665
- const bytes = await readOptional(journal);
5666
- if (!bytes) {
5667
- await rm6(transactionMarker(journal), { force: true });
5668
- return;
5669
- }
5670
- if (activeTransactions.has(journal)) {
5671
- throw new Error(`Another artifact write in this process is already using ${journal}.`);
5672
- }
5673
- const transaction = parseTransaction(journal, bytes.toString("utf8"));
5674
- if (transaction.pid !== process.pid && processIsAlive(transaction.pid)) {
5675
- throw new Error(
5676
- `Artifact transaction ${journal} is still owned by live process ${transaction.pid}; refusing concurrent recovery.`
5677
- );
6608
+ function parseSelector(selector) {
6609
+ const match = selector.match(/^([^@]+?)(?:@(\d+\.\d+\.\d+))?$/);
6610
+ if (!match || !RECIPE_ID_RE.test(match[1])) {
6611
+ throw new Error(`Invalid recipe selector "${selector}". Expected provider/name or provider/name@x.y.z.`);
5678
6612
  }
5679
- for (const entry of transaction.entries) {
5680
- if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path18.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
5681
- throw new Error(
5682
- `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
5683
- );
5684
- }
5685
- }
5686
- const committed = await readOptional(transactionMarker(journal)) !== null;
5687
- const errors = [];
5688
- if (committed) {
5689
- for (const entry of transaction.entries) {
5690
- if (entry.backup) await remove2(entry.backup, errors);
5691
- await remove2(entry.stage, errors);
5692
- }
5693
- } else {
5694
- for (const entry of [...transaction.entries].reverse()) {
5695
- const backup = entry.backup ? await readOptional(entry.backup) : null;
5696
- const destination = await readOptional(entry.destination);
5697
- if (backup) {
5698
- if (destination && digest(destination) !== entry.sha256) {
5699
- errors.push(
5700
- `destination changed during interrupted recovery: ${entry.destination}; previous file remains at ${entry.backup}`
5701
- );
5702
- continue;
5703
- }
5704
- await remove2(entry.destination, errors);
5705
- try {
5706
- await rename5(entry.backup, entry.destination);
5707
- } catch (error) {
5708
- errors.push(
5709
- `could not restore ${entry.destination}; previous file remains at ${entry.backup}: ${message(error)}`
5710
- );
5711
- }
5712
- } else if (destination && digest(destination) === entry.sha256) {
5713
- await remove2(entry.destination, errors);
5714
- }
5715
- await remove2(entry.stage, errors);
5716
- }
6613
+ return { id: match[1], version: match[2] };
6614
+ }
6615
+ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6616
+ const local = path20.resolve(target);
6617
+ if (existsSync15(local)) {
6618
+ const recipePath = path20.basename(local) === RECIPE_FILE ? local : path20.join(local, RECIPE_FILE);
6619
+ return readRecipeFile(recipePath, false);
5717
6620
  }
5718
- if (errors.length) {
5719
- throw new Error(
5720
- `Artifact transaction recovery needs attention; journal retained at ${journal}: ` + errors.join("; ")
5721
- );
6621
+ if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
6622
+ throw new Error(`Recipe path not found: ${local}`);
5722
6623
  }
5723
- await removeTransactionFiles(journal);
6624
+ const selector = parseSelector(target);
6625
+ const matches = (await listBundledRecipes(bundledRoot)).filter(
6626
+ (entry) => entry.recipe.id === selector.id && (selector.version === void 0 || entry.recipe.version === selector.version)
6627
+ );
6628
+ if (!matches.length) throw new Error(`Bundled recipe not found: ${target}`);
6629
+ return matches[0];
5724
6630
  }
5725
- function createArtifactBundleManifest(manifestPath, outputs, provenance) {
5726
- const root = path18.dirname(path18.resolve(manifestPath));
5727
- const sources = provenance.sources.map((source) => ({
5728
- ...source,
5729
- path: portableRelative(root, source.path)
5730
- }));
5731
- const normalized = {
5732
- kind: provenance.kind,
5733
- sources,
5734
- options: canonical2(provenance.options)
6631
+ async function verifyFile(root, file) {
6632
+ const absolute = path20.resolve(root, ...file.path.split("/"));
6633
+ if (!existsSync15(absolute)) {
6634
+ return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
6635
+ }
6636
+ const actualSha256 = await sha256File(absolute);
6637
+ return {
6638
+ path: file.path,
6639
+ expectedSha256: file.sha256,
6640
+ actualSha256,
6641
+ status: actualSha256 === file.sha256 ? "ok" : "mismatch"
5735
6642
  };
5736
- const manifestOutputs = outputs.map((output) => ({
5737
- path: portableRelative(root, output.path),
5738
- sha256: digest(output.data)
6643
+ }
6644
+ async function verifyRecipe(target, options = {}) {
6645
+ const loaded = await resolveRecipe(target, options.bundledRoot);
6646
+ const actualIntegrity = recipeDigest(loaded.recipe);
6647
+ const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
6648
+ const modelRoot = options.modelRoot ? path20.resolve(options.modelRoot) : null;
6649
+ const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
6650
+ ...await verifyFile(modelRoot, model),
6651
+ source: model.source,
6652
+ license: model.license
6653
+ }))) : loaded.recipe.models.map((model) => ({
6654
+ path: model.path,
6655
+ expectedSha256: model.sha256,
6656
+ actualSha256: null,
6657
+ source: model.source,
6658
+ license: model.license,
6659
+ status: "unchecked"
5739
6660
  }));
6661
+ const integrityStatus = actualIntegrity === loaded.recipe.integrity.digest ? "ok" : "mismatch";
5740
6662
  return {
5741
- format: "pixelkiln-artifact-bundle",
5742
6663
  version: 1,
5743
- ...normalized,
5744
- fingerprint: fingerprint({ ...normalized, outputs: manifestOutputs }),
5745
- outputs: manifestOutputs
6664
+ ok: integrityStatus === "ok" && files.every((file) => file.status === "ok") && (!modelRoot || models.every((model) => model.status === "ok")),
6665
+ recipe: {
6666
+ id: loaded.recipe.id,
6667
+ version: loaded.recipe.version,
6668
+ path: loaded.path,
6669
+ bundled: loaded.bundled
6670
+ },
6671
+ integrity: {
6672
+ expectedSha256: loaded.recipe.integrity.digest,
6673
+ actualSha256: actualIntegrity,
6674
+ status: integrityStatus
6675
+ },
6676
+ files,
6677
+ models,
6678
+ modelRoot
5746
6679
  };
5747
6680
  }
5748
- function withArtifactManifest(manifestPath, outputs, provenance) {
5749
- const manifest = createArtifactBundleManifest(manifestPath, outputs, provenance);
5750
- return [
5751
- ...outputs,
5752
- { path: manifestPath, data: JSON.stringify(manifest, null, 2) + "\n" }
6681
+ function renderStyle(style, destination, cwd) {
6682
+ const portableDir = path20.relative(cwd, destination).split(path20.sep).join("/") || ".";
6683
+ const replace = (value) => {
6684
+ if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
6685
+ if (Array.isArray(value)) return value.map(replace);
6686
+ if (value && typeof value === "object") {
6687
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]));
6688
+ }
6689
+ return value;
6690
+ };
6691
+ return replace(style);
6692
+ }
6693
+ async function installRecipe(target, options = {}) {
6694
+ const cwd = path20.resolve(options.cwd ?? process.cwd());
6695
+ const loaded = await resolveRecipe(target, options.bundledRoot);
6696
+ const verification = await verifyRecipe(loaded.path);
6697
+ if (!verification.ok) {
6698
+ throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
6699
+ }
6700
+ const destination = options.out ? path20.resolve(cwd, options.out) : path20.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6701
+ const sources = [
6702
+ { path: RECIPE_FILE, source: loaded.path },
6703
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path20.join(loaded.dir, ...file.path.split("/")) }))
5753
6704
  ];
5754
- }
5755
- async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
5756
- const absoluteManifest = path18.resolve(manifestPath);
5757
- const recoveryFile = `${absoluteManifest}.transaction`;
5758
- const allowedDestinations = /* @__PURE__ */ new Set([
5759
- absoluteManifest,
5760
- ...outputs.map((output) => path18.resolve(output.path))
5761
- ]);
5762
- await recoverArtifactTransaction(recoveryFile, allowedDestinations);
5763
- const existingManifestBytes = await readOptional(absoluteManifest);
5764
- let previous = null;
5765
- if (existingManifestBytes && !options.force) {
5766
- try {
5767
- previous = parseArtifactManifest(absoluteManifest, existingManifestBytes.toString("utf8"));
5768
- const valid = previous.fingerprint === fingerprint(previous);
5769
- if (!valid) throw new Error("its integrity fingerprint does not match");
5770
- } catch (error) {
5771
- throw new Error(
5772
- `Refusing to replace unrecognized or modified provenance at ${absoluteManifest}: ${message(error)}. Pass { force: true } (CLI: --force) to take ownership of this artifact bundle.`,
5773
- { cause: error }
5774
- );
6705
+ const files = await Promise.all(sources.map(async (file) => ({
6706
+ path: path20.join(destination, ...file.path.split("/")),
6707
+ data: await readFile15(file.source)
6708
+ })));
6709
+ if (!options.force) {
6710
+ for (const file of files) {
6711
+ if (!existsSync15(file.path)) continue;
6712
+ const current = await readFile15(file.path);
6713
+ if (!current.equals(file.data)) {
6714
+ throw new Error(
6715
+ `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`
6716
+ );
6717
+ }
5775
6718
  }
5776
6719
  }
5777
- if (!options.force) {
5778
- const root = path18.dirname(absoluteManifest);
5779
- const recorded = new Map(
5780
- previous?.outputs.map((output) => [path18.resolve(root, output.path), output.sha256]) ?? []
5781
- );
5782
- const conflicts = [];
5783
- for (const output of outputs) {
5784
- const destination = path18.resolve(output.path);
5785
- const current = await readOptional(destination);
5786
- if (!current || digest(current) === digest(output.data)) continue;
5787
- const expected = recorded.get(destination);
5788
- if (!expected || digest(current) !== expected) conflicts.push(destination);
6720
+ const written = await writeArtifactBundle(files);
6721
+ return {
6722
+ recipe: loaded.recipe,
6723
+ destination,
6724
+ changed: written.changed,
6725
+ unchanged: written.unchanged,
6726
+ styleId: loaded.recipe.styleId,
6727
+ style: renderStyle(loaded.recipe.style, destination, cwd)
6728
+ };
6729
+ }
6730
+
6731
+ // src/pipeline/quality-regression.ts
6732
+ import path21 from "path";
6733
+ import { existsSync as existsSync16 } from "fs";
6734
+ import { readFile as readFile16 } from "fs/promises";
6735
+ import { z as z6 } from "zod";
6736
+ var SHA256_RE2 = /^[0-9a-f]{64}$/;
6737
+ var HEX_RE = /^#[0-9a-f]{6}$/;
6738
+ var MAX_REDMEAN_DISTANCE = 765;
6739
+ var QualityPathSchema = z6.string().min(1).refine(
6740
+ (value) => !path21.posix.isAbsolute(value) && !value.includes("\\"),
6741
+ "expected a portable relative path"
6742
+ );
6743
+ var DEFAULT_QUALITY_TOLERANCES = {
6744
+ requireExactHash: false,
6745
+ maxColorCountIncrease: 0,
6746
+ maxNewColors: 0,
6747
+ maxTransparencyDelta: 0.01,
6748
+ maxPartialAlphaIncrease: 0,
6749
+ maxEdgeDensityDelta: 0.03,
6750
+ maxEdgeContrastDrop: 0.03,
6751
+ maxIsolatedPixelIncrease: 5e-3
6752
+ };
6753
+ var QualityToleranceSchema = z6.object({
6754
+ requireExactHash: z6.boolean(),
6755
+ maxColorCountIncrease: z6.number().int().min(0),
6756
+ maxNewColors: z6.number().int().min(0),
6757
+ maxTransparencyDelta: z6.number().min(0).max(1),
6758
+ maxPartialAlphaIncrease: z6.number().min(0).max(1),
6759
+ maxEdgeDensityDelta: z6.number().min(0).max(1),
6760
+ maxEdgeContrastDrop: z6.number().min(0).max(1),
6761
+ maxIsolatedPixelIncrease: z6.number().min(0).max(1)
6762
+ }).strict();
6763
+ var ImageQualityMetricsSchema = z6.object({
6764
+ sha256: z6.string().regex(SHA256_RE2),
6765
+ width: z6.number().int().min(1),
6766
+ height: z6.number().int().min(1),
6767
+ colorCount: z6.number().int().min(0),
6768
+ colors: z6.array(z6.string().regex(HEX_RE)),
6769
+ transparency: z6.number().min(0).max(1),
6770
+ partialAlpha: z6.number().min(0).max(1),
6771
+ edgeDensity: z6.number().min(0).max(1),
6772
+ meanEdgeContrast: z6.number().min(0).max(1),
6773
+ isolatedPixelRatio: z6.number().min(0).max(1)
6774
+ }).strict();
6775
+ var QualityBaselineCaseSchema = z6.object({
6776
+ id: z6.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
6777
+ file: QualityPathSchema,
6778
+ record: z6.object({
6779
+ path: QualityPathSchema,
6780
+ sha256: z6.string().regex(SHA256_RE2)
6781
+ }).strict().optional(),
6782
+ expected: ImageQualityMetricsSchema,
6783
+ tolerances: QualityToleranceSchema
6784
+ }).strict();
6785
+ var QualityBaselineSchema = z6.object({
6786
+ $schema: z6.string().url().optional(),
6787
+ format: z6.literal("pixelkiln-quality-baseline"),
6788
+ schemaVersion: z6.literal(1),
6789
+ cases: z6.array(QualityBaselineCaseSchema).min(1)
6790
+ }).strict().superRefine((baseline, context) => {
6791
+ const ids = baseline.cases.map((entry) => entry.id);
6792
+ if (new Set(ids).size !== ids.length) {
6793
+ context.addIssue({ code: z6.ZodIssueCode.custom, path: ["cases"], message: "duplicate case id" });
6794
+ }
6795
+ });
6796
+ var QualityInputSchema = z6.object({
6797
+ id: z6.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
6798
+ path: z6.string().min(1),
6799
+ record: z6.string().min(1).optional(),
6800
+ tolerances: QualityToleranceSchema.partial().optional()
6801
+ }).strict();
6802
+ function message4(error) {
6803
+ return error instanceof Error ? error.message : String(error);
6804
+ }
6805
+ function round(value) {
6806
+ return Number(value.toFixed(6));
6807
+ }
6808
+ function pixelKey(pixels, offset) {
6809
+ const alpha = pixels[offset + 3];
6810
+ if (alpha === 0) return 0;
6811
+ return (pixels[offset] << 24 >>> 0 | pixels[offset + 1] << 16 | pixels[offset + 2] << 8 | alpha) >>> 0;
6812
+ }
6813
+ function edgeContrast(pixels, a, b) {
6814
+ const alphaA = pixels[a + 3];
6815
+ const alphaB = pixels[b + 3];
6816
+ const alphaDistance = Math.abs(alphaA - alphaB) / 255;
6817
+ if (alphaA === 0 || alphaB === 0) return alphaDistance;
6818
+ const rgbDistance = colorDistance(
6819
+ { r: pixels[a], g: pixels[a + 1], b: pixels[a + 2] },
6820
+ { r: pixels[b], g: pixels[b + 1], b: pixels[b + 2] }
6821
+ ) / MAX_REDMEAN_DISTANCE;
6822
+ return Math.min(1, Math.max(alphaDistance, rgbDistance));
6823
+ }
6824
+ function measureDecodedImageQuality(png, digest2) {
6825
+ const colors = /* @__PURE__ */ new Set();
6826
+ let transparent = 0;
6827
+ let partialAlpha = 0;
6828
+ let visible = 0;
6829
+ let isolated = 0;
6830
+ for (let offset = 0; offset < png.pixels.length; offset += 4) {
6831
+ const alpha = png.pixels[offset + 3];
6832
+ if (alpha === 0) {
6833
+ transparent++;
6834
+ continue;
5789
6835
  }
5790
- if (conflicts.length) {
5791
- throw new Error(
5792
- `Refusing to overwrite modified or unowned artifact file(s):
5793
- ` + conflicts.map((file) => ` ${file}`).join("\n") + `
5794
- Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
5795
- );
6836
+ visible++;
6837
+ if (alpha < 255) partialAlpha++;
6838
+ colors.add(png.pixels[offset] << 16 | png.pixels[offset + 1] << 8 | png.pixels[offset + 2]);
6839
+ const index = offset / 4;
6840
+ const x = index % png.width;
6841
+ const y = Math.floor(index / png.width);
6842
+ const key = pixelKey(png.pixels, offset);
6843
+ const hasSameNeighbor = x > 0 && pixelKey(png.pixels, offset - 4) === key || x + 1 < png.width && pixelKey(png.pixels, offset + 4) === key || y > 0 && pixelKey(png.pixels, offset - png.width * 4) === key || y + 1 < png.height && pixelKey(png.pixels, offset + png.width * 4) === key;
6844
+ if (!hasSameNeighbor) {
6845
+ isolated++;
6846
+ }
6847
+ }
6848
+ let neighborPairs = 0;
6849
+ let changedPairs = 0;
6850
+ let contrastTotal = 0;
6851
+ for (let y = 0; y < png.height; y++) {
6852
+ for (let x = 0; x < png.width; x++) {
6853
+ const offset = (y * png.width + x) * 4;
6854
+ if (x + 1 < png.width) {
6855
+ const neighbor = offset + 4;
6856
+ neighborPairs++;
6857
+ if (pixelKey(png.pixels, offset) !== pixelKey(png.pixels, neighbor)) {
6858
+ changedPairs++;
6859
+ contrastTotal += edgeContrast(png.pixels, offset, neighbor);
6860
+ }
6861
+ }
6862
+ if (y + 1 < png.height) {
6863
+ const neighbor = offset + png.width * 4;
6864
+ neighborPairs++;
6865
+ if (pixelKey(png.pixels, offset) !== pixelKey(png.pixels, neighbor)) {
6866
+ changedPairs++;
6867
+ contrastTotal += edgeContrast(png.pixels, offset, neighbor);
6868
+ }
6869
+ }
5796
6870
  }
5797
6871
  }
5798
- const { force: _force, ...bundleOptions } = options;
5799
- return writeArtifactBundle(
5800
- withArtifactManifest(absoluteManifest, outputs, provenance),
5801
- { ...bundleOptions, recoveryFile }
5802
- );
5803
- }
5804
- function isCode(error, code) {
5805
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
6872
+ const total = png.width * png.height;
6873
+ return {
6874
+ sha256: digest2,
6875
+ width: png.width,
6876
+ height: png.height,
6877
+ colorCount: colors.size,
6878
+ colors: [...colors].sort((a, b) => a - b).map((color) => `#${color.toString(16).padStart(6, "0")}`),
6879
+ transparency: round(total ? transparent / total : 0),
6880
+ partialAlpha: round(total ? partialAlpha / total : 0),
6881
+ edgeDensity: round(neighborPairs ? changedPairs / neighborPairs : 0),
6882
+ meanEdgeContrast: round(changedPairs ? contrastTotal / changedPairs : 0),
6883
+ isolatedPixelRatio: round(visible ? isolated / visible : 0)
6884
+ };
5806
6885
  }
5807
- async function remove2(file, errors) {
6886
+ async function measureImageQuality(file) {
6887
+ const absolute = path21.resolve(file);
6888
+ let bytes;
5808
6889
  try {
5809
- await rm6(file, { force: true });
6890
+ bytes = await readFile16(absolute);
5810
6891
  } catch (error) {
5811
- errors?.push(`could not remove ${file}: ${message(error)}`);
6892
+ throw new Error(`Cannot read quality image ${absolute}: ${message4(error)}`, { cause: error });
5812
6893
  }
5813
- }
5814
- async function rollback(prepared) {
5815
- const errors = [];
5816
- for (const artifact of [...prepared].reverse()) {
5817
- if (artifact.promoted) await remove2(artifact.destination, errors);
5818
- }
5819
- for (const artifact of [...prepared].reverse()) {
5820
- if (!artifact.backup || !artifact.backedUp) continue;
5821
- try {
5822
- await rename5(artifact.backup, artifact.destination);
5823
- artifact.backedUp = false;
5824
- } catch (error) {
5825
- errors.push(
5826
- `could not restore ${artifact.destination}; previous file remains at ${artifact.backup}: ${message(error)}`
5827
- );
5828
- }
6894
+ try {
6895
+ return measureDecodedImageQuality(decodePng(bytes), sha256(bytes));
6896
+ } catch (error) {
6897
+ throw new Error(`Cannot measure quality image ${absolute}: ${message4(error)}`, { cause: error });
5829
6898
  }
5830
- for (const artifact of prepared) {
5831
- if (artifact.stage) await remove2(artifact.stage, errors);
6899
+ }
6900
+ function resolveQualityInputs(raw, inputsFilePath) {
6901
+ const parsed = z6.array(QualityInputSchema).min(1).safeParse(raw);
6902
+ if (!parsed.success) {
6903
+ throw new Error(
6904
+ "--inputs must be a non-empty JSON array of quality cases:\n" + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
6905
+ );
5832
6906
  }
5833
- return errors;
6907
+ const ids = parsed.data.map((entry) => entry.id);
6908
+ if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
6909
+ const root = path21.dirname(path21.resolve(inputsFilePath));
6910
+ return parsed.data.map((entry) => ({
6911
+ id: entry.id,
6912
+ file: path21.resolve(root, entry.path),
6913
+ ...entry.record ? { record: path21.resolve(root, entry.record) } : {},
6914
+ tolerances: QualityToleranceSchema.parse({
6915
+ ...DEFAULT_QUALITY_TOLERANCES,
6916
+ ...entry.tolerances
6917
+ })
6918
+ }));
5834
6919
  }
5835
- async function writeArtifactBundle(files, options = {}) {
5836
- if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
5837
- const normalized = files.map((file) => {
5838
- if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
5839
- return { destination: path18.resolve(file.path), data: Buffer.from(file.data) };
5840
- });
5841
- const destinations = /* @__PURE__ */ new Set();
5842
- for (const artifact of normalized) {
5843
- if (destinations.has(artifact.destination)) {
5844
- throw new Error(`Artifact bundle contains duplicate destination: ${artifact.destination}`);
5845
- }
5846
- destinations.add(artifact.destination);
6920
+ function portableRelative2(from, to) {
6921
+ return path21.relative(from, path21.resolve(to)).split(path21.sep).join("/") || ".";
6922
+ }
6923
+ async function verifyRecordForImage(recordPath, imagePath) {
6924
+ const verification = await verifyArtifactBundle(recordPath);
6925
+ if (!verification.current) {
6926
+ const changed = [...verification.changedSources, ...verification.changedOutputs];
6927
+ throw new Error(
6928
+ `Quality record is not current: ${recordPath}` + (changed.length ? ` (${changed.join(", ")})` : "")
6929
+ );
5847
6930
  }
5848
- if (options.recoveryFile) {
5849
- await recoverArtifactTransaction(options.recoveryFile, destinations);
6931
+ const record = await readArtifactBundleManifest(recordPath);
6932
+ const options = record.options;
6933
+ if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
6934
+ throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
5850
6935
  }
5851
- const changed = [];
5852
- const unchanged = [];
5853
- for (const artifact of normalized) {
5854
- try {
5855
- const current = await readFile14(artifact.destination);
5856
- if (current.equals(artifact.data)) {
5857
- unchanged.push(artifact.destination);
5858
- } else {
5859
- changed.push({ ...artifact, existed: true, backedUp: false, promoted: false });
5860
- }
5861
- } catch (error) {
5862
- if (!isCode(error, "ENOENT")) throw error;
5863
- changed.push({ ...artifact, existed: false, backedUp: false, promoted: false });
5864
- }
6936
+ const outputs = record.outputs.map((output) => path21.resolve(path21.dirname(recordPath), output.path));
6937
+ if (!outputs.includes(path21.resolve(imagePath))) {
6938
+ throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
5865
6939
  }
5866
- if (!changed.length) return { changed: [], unchanged };
5867
- const token = randomUUID3();
5868
- for (const [index, artifact] of changed.entries()) {
5869
- const basename = path18.basename(artifact.destination);
5870
- artifact.stage = path18.join(
5871
- path18.dirname(artifact.destination),
5872
- `.${basename}.pixelkiln-stage-${token}-${index}`
5873
- );
5874
- if (artifact.existed) {
5875
- artifact.backup = path18.join(
5876
- path18.dirname(artifact.destination),
5877
- `.${basename}.pixelkiln-backup-${token}-${index}`
5878
- );
6940
+ }
6941
+ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
6942
+ if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
6943
+ const absolute = path21.resolve(baselinePath);
6944
+ for (const input of inputs) {
6945
+ if (path21.resolve(input.file) === absolute || input.record && path21.resolve(input.record) === absolute) {
6946
+ throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
6947
+ }
6948
+ }
6949
+ const root = path21.dirname(absolute);
6950
+ const cases = [];
6951
+ for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
6952
+ const expected = await measureImageQuality(input.file);
6953
+ let record;
6954
+ if (input.record) {
6955
+ await verifyRecordForImage(input.record, input.file);
6956
+ record = {
6957
+ path: portableRelative2(root, input.record),
6958
+ sha256: await sha256File(input.record)
6959
+ };
5879
6960
  }
6961
+ cases.push({
6962
+ id: input.id,
6963
+ file: portableRelative2(root, input.file),
6964
+ ...record ? { record } : {},
6965
+ expected,
6966
+ tolerances: input.tolerances
6967
+ });
5880
6968
  }
5881
- const journal = options.recoveryFile ? path18.resolve(options.recoveryFile) : null;
5882
- if (journal) {
5883
- await mkdir6(path18.dirname(journal), { recursive: true });
5884
- const transaction = {
5885
- format: "pixelkiln-artifact-transaction",
5886
- version: 1,
5887
- pid: process.pid,
5888
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5889
- entries: changed.map((artifact) => ({
5890
- destination: artifact.destination,
5891
- stage: artifact.stage,
5892
- ...artifact.backup ? { backup: artifact.backup } : {},
5893
- sha256: digest(artifact.data)
5894
- }))
5895
- };
5896
- try {
5897
- await writeFile8(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
5898
- activeTransactions.add(journal);
5899
- } catch (error) {
5900
- throw new Error(
5901
- `Could not start durable artifact transaction at ${journal}: ${message(error)}`,
5902
- { cause: error }
5903
- );
6969
+ const baseline = QualityBaselineSchema.parse({
6970
+ $schema: "https://unpkg.com/pixelkiln/schema/quality-baseline.schema.json",
6971
+ format: "pixelkiln-quality-baseline",
6972
+ schemaVersion: 1,
6973
+ cases
6974
+ });
6975
+ const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
6976
+ if (existsSync16(absolute) && !options.force) {
6977
+ const current = await readFile16(absolute);
6978
+ if (!current.equals(data)) {
6979
+ throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
5904
6980
  }
5905
6981
  }
6982
+ const result = await writeArtifactBundle([{ path: absolute, data }]);
6983
+ return { baseline, path: absolute, changed: result.changed.length > 0 };
6984
+ }
6985
+ async function readQualityBaseline(baselinePath) {
6986
+ const absolute = path21.resolve(baselinePath);
6987
+ let raw;
5906
6988
  try {
5907
- for (const [index, artifact] of changed.entries()) {
5908
- await mkdir6(path18.dirname(artifact.destination), { recursive: true });
5909
- await options.beforeStage?.(artifact.destination, index);
5910
- await writeFile8(artifact.stage, artifact.data, { flag: "wx" });
5911
- }
6989
+ raw = JSON.parse(await readFile16(absolute, "utf8"));
5912
6990
  } catch (error) {
5913
- const cleanupErrors2 = await rollback(changed);
5914
- if (journal && !cleanupErrors2.length) await removeTransactionFiles(journal);
5915
- if (journal) activeTransactions.delete(journal);
5916
- const suffix = cleanupErrors2.length ? ` Cleanup also failed: ${cleanupErrors2.join("; ")}` : "";
5917
- throw new Error(`Could not stage artifact bundle: ${message(error)}.${suffix}`, { cause: error });
6991
+ throw new Error(`Could not read quality baseline ${absolute}: ${message4(error)}`, { cause: error });
5918
6992
  }
5919
- let durableCommit = false;
5920
- try {
5921
- for (const artifact of changed) {
5922
- if (!artifact.existed) continue;
5923
- await rename5(artifact.destination, artifact.backup);
5924
- artifact.backedUp = true;
5925
- }
5926
- for (const [index, artifact] of changed.entries()) {
5927
- await options.beforePromote?.(artifact.destination, index);
5928
- await rename5(artifact.stage, artifact.destination);
5929
- artifact.stage = void 0;
5930
- artifact.promoted = true;
5931
- }
5932
- if (journal) {
5933
- await writeFile8(transactionMarker(journal), `${token}
5934
- `, { flag: "wx" });
5935
- durableCommit = true;
5936
- await options.afterCommit?.();
5937
- }
5938
- } catch (error) {
5939
- if (durableCommit) {
5940
- if (journal) activeTransactions.delete(journal);
5941
- throw new Error(
5942
- `Artifact bundle was committed, but final cleanup was interrupted: ${message(error)}. The next write will finish cleanup from ${journal}.`,
5943
- { cause: error }
5944
- );
5945
- }
5946
- const rollbackErrors = await rollback(changed);
5947
- if (journal && !rollbackErrors.length) await removeTransactionFiles(journal);
5948
- if (journal) activeTransactions.delete(journal);
5949
- const suffix = rollbackErrors.length ? ` Rollback needs attention: ${rollbackErrors.join("; ")}` : " Previous bundle restored.";
5950
- throw new Error(`Could not promote artifact bundle: ${message(error)}.${suffix}`, { cause: error });
6993
+ const parsed = QualityBaselineSchema.safeParse(raw);
6994
+ if (!parsed.success) {
6995
+ throw new Error(
6996
+ `Invalid quality baseline ${absolute}:
6997
+ ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
6998
+ );
5951
6999
  }
5952
- const cleanupErrors = [];
5953
- for (const artifact of changed) {
5954
- if (artifact.backup) await remove2(artifact.backup, cleanupErrors);
7000
+ return parsed.data;
7001
+ }
7002
+ function regressionViolations(expected, actual, tolerances) {
7003
+ const violations = [];
7004
+ const warnings = [];
7005
+ const changed = actual.sha256 !== expected.sha256;
7006
+ if (expected.width !== actual.width || expected.height !== actual.height) {
7007
+ violations.push(
7008
+ `dimensions changed from ${expected.width}x${expected.height} to ${actual.width}x${actual.height}`
7009
+ );
5955
7010
  }
5956
- if (cleanupErrors.length) {
5957
- if (journal) activeTransactions.delete(journal);
5958
- throw new Error(
5959
- `Artifact bundle was written, but old backup cleanup failed: ${cleanupErrors.join("; ")}`
7011
+ if (changed && tolerances.requireExactHash) violations.push("image hash changed");
7012
+ else if (changed) warnings.push("image hash changed; metric tolerances decide this case");
7013
+ const colorIncrease = actual.colorCount - expected.colorCount;
7014
+ if (colorIncrease > tolerances.maxColorCountIncrease) {
7015
+ violations.push(
7016
+ `color count increased by ${colorIncrease} (allowed ${tolerances.maxColorCountIncrease})`
5960
7017
  );
5961
7018
  }
5962
- if (journal) {
7019
+ const expectedColors = new Set(expected.colors);
7020
+ const newColors = actual.colors.filter((color) => !expectedColors.has(color));
7021
+ if (newColors.length > tolerances.maxNewColors) {
7022
+ violations.push(
7023
+ `${newColors.length} new color(s) exceed ${tolerances.maxNewColors}: ${newColors.slice(0, 8).join(", ")}`
7024
+ );
7025
+ }
7026
+ const transparencyDelta = Math.abs(actual.transparency - expected.transparency);
7027
+ if (transparencyDelta > tolerances.maxTransparencyDelta) {
7028
+ violations.push(
7029
+ `transparency changed by ${(transparencyDelta * 100).toFixed(2)} points (allowed ${(tolerances.maxTransparencyDelta * 100).toFixed(2)})`
7030
+ );
7031
+ }
7032
+ const partialAlphaIncrease = actual.partialAlpha - expected.partialAlpha;
7033
+ if (partialAlphaIncrease > tolerances.maxPartialAlphaIncrease) {
7034
+ violations.push(
7035
+ `partial-alpha pixels increased by ${(partialAlphaIncrease * 100).toFixed(2)} points (allowed ${(tolerances.maxPartialAlphaIncrease * 100).toFixed(2)})`
7036
+ );
7037
+ }
7038
+ const edgeDensityDelta = Math.abs(actual.edgeDensity - expected.edgeDensity);
7039
+ if (edgeDensityDelta > tolerances.maxEdgeDensityDelta) {
7040
+ violations.push(
7041
+ `edge density changed by ${(edgeDensityDelta * 100).toFixed(2)} points (allowed ${(tolerances.maxEdgeDensityDelta * 100).toFixed(2)})`
7042
+ );
7043
+ }
7044
+ const edgeContrastDrop = expected.meanEdgeContrast - actual.meanEdgeContrast;
7045
+ if (edgeContrastDrop > tolerances.maxEdgeContrastDrop) {
7046
+ violations.push(
7047
+ `mean edge contrast dropped by ${(edgeContrastDrop * 100).toFixed(2)} points (allowed ${(tolerances.maxEdgeContrastDrop * 100).toFixed(2)})`
7048
+ );
7049
+ }
7050
+ const isolatedIncrease = actual.isolatedPixelRatio - expected.isolatedPixelRatio;
7051
+ if (isolatedIncrease > tolerances.maxIsolatedPixelIncrease) {
7052
+ violations.push(
7053
+ `isolated-pixel ratio increased by ${(isolatedIncrease * 100).toFixed(2)} points (allowed ${(tolerances.maxIsolatedPixelIncrease * 100).toFixed(2)})`
7054
+ );
7055
+ }
7056
+ return { violations, warnings };
7057
+ }
7058
+ async function checkQualityBaseline(baselinePath) {
7059
+ const absolute = path21.resolve(baselinePath);
7060
+ const baseline = await readQualityBaseline(absolute);
7061
+ const root = path21.dirname(absolute);
7062
+ const cases = [];
7063
+ for (const entry of baseline.cases) {
7064
+ const file = path21.resolve(root, entry.file);
7065
+ let actual = null;
7066
+ const violations = [];
7067
+ const warnings = [];
5963
7068
  try {
5964
- await removeTransactionFiles(journal);
5965
- } finally {
5966
- activeTransactions.delete(journal);
7069
+ actual = await measureImageQuality(file);
7070
+ const regression = regressionViolations(entry.expected, actual, entry.tolerances);
7071
+ violations.push(...regression.violations);
7072
+ warnings.push(...regression.warnings);
7073
+ } catch (error) {
7074
+ violations.push(message4(error));
5967
7075
  }
7076
+ if (entry.record) {
7077
+ const recordPath = path21.resolve(root, entry.record.path);
7078
+ try {
7079
+ const recordHash = await sha256File(recordPath);
7080
+ if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
7081
+ await verifyRecordForImage(recordPath, file);
7082
+ } catch (error) {
7083
+ violations.push(message4(error));
7084
+ }
7085
+ }
7086
+ cases.push({
7087
+ id: entry.id,
7088
+ file,
7089
+ status: violations.length ? "fail" : "pass",
7090
+ hashChanged: actual !== null && actual.sha256 !== entry.expected.sha256,
7091
+ expected: entry.expected,
7092
+ actual,
7093
+ violations,
7094
+ warnings
7095
+ });
5968
7096
  }
7097
+ const failed = cases.filter((entry) => entry.status === "fail").length;
5969
7098
  return {
5970
- changed: changed.map((artifact) => artifact.destination),
5971
- unchanged
7099
+ version: 1,
7100
+ safe: failed === 0,
7101
+ baseline: absolute,
7102
+ summary: {
7103
+ total: cases.length,
7104
+ passed: cases.length - failed,
7105
+ failed,
7106
+ changed: cases.filter((entry) => entry.hashChanged).length
7107
+ },
7108
+ cases
5972
7109
  };
5973
7110
  }
5974
7111
 
5975
7112
  // src/cli.ts
5976
7113
  var log = (msg = "") => console.log(msg);
5977
7114
  async function provenanceFile(id, file) {
5978
- const absolute = path19.resolve(file);
7115
+ const absolute = path22.resolve(file);
5979
7116
  return {
5980
7117
  id,
5981
7118
  path: absolute,
5982
- sha256: existsSync15(absolute) ? await sha256File(absolute) : null,
7119
+ sha256: existsSync17(absolute) ? await sha256File(absolute) : null,
5983
7120
  included: true
5984
7121
  };
5985
7122
  }
@@ -6006,7 +7143,14 @@ var VALUE_FLAGS = [
6006
7143
  "--sigma",
6007
7144
  "--workspace",
6008
7145
  "--provider",
6009
- "--account"
7146
+ "--account",
7147
+ "--palette",
7148
+ "--fixer-python",
7149
+ "--fixer-revision",
7150
+ "--min-grid-confidence",
7151
+ "--reviewer",
7152
+ "--note",
7153
+ "--model-root"
6010
7154
  ];
6011
7155
  var BOOL_FLAGS = [
6012
7156
  "--force",
@@ -6045,6 +7189,9 @@ var COMMANDS = [
6045
7189
  "tag",
6046
7190
  "balance",
6047
7191
  "status",
7192
+ "quality",
7193
+ "refine",
7194
+ "recipe",
6048
7195
  "workspace",
6049
7196
  "help",
6050
7197
  "--help",
@@ -6053,6 +7200,9 @@ var COMMANDS = [
6053
7200
  "-v"
6054
7201
  ];
6055
7202
  var WORKSPACE_SUBCOMMANDS = ["add", "remove", "list", "status", "claims"];
7203
+ var REFINE_SUBCOMMANDS = ["run", "approve", "check"];
7204
+ var RECIPE_SUBCOMMANDS = ["list", "inspect", "install", "verify"];
7205
+ var QUALITY_SUBCOMMANDS = ["snapshot", "check"];
6056
7206
  function parseArgs(argv) {
6057
7207
  const [command = "help"] = argv;
6058
7208
  if (!COMMANDS.includes(command)) {
@@ -6061,7 +7211,36 @@ function parseArgs(argv) {
6061
7211
  let rest = argv.slice(1);
6062
7212
  let subcommand;
6063
7213
  let target;
6064
- if (command === "workspace") {
7214
+ if (command === "quality") {
7215
+ subcommand = rest[0];
7216
+ if (subcommand === void 0 || subcommand.startsWith("-")) {
7217
+ throw new Error(`quality needs a subcommand: ${QUALITY_SUBCOMMANDS.join(", ")}`);
7218
+ }
7219
+ if (!QUALITY_SUBCOMMANDS.includes(subcommand)) {
7220
+ throw new Error(
7221
+ `Unknown quality subcommand "${subcommand}". Known: ${QUALITY_SUBCOMMANDS.join(", ")}`
7222
+ );
7223
+ }
7224
+ rest = rest.slice(1);
7225
+ } else if (command === "recipe") {
7226
+ subcommand = rest[0];
7227
+ if (subcommand === void 0 || subcommand.startsWith("-")) {
7228
+ throw new Error(`recipe needs a subcommand: ${RECIPE_SUBCOMMANDS.join(", ")}`);
7229
+ }
7230
+ if (!RECIPE_SUBCOMMANDS.includes(subcommand)) {
7231
+ throw new Error(
7232
+ `Unknown recipe subcommand "${subcommand}". Known: ${RECIPE_SUBCOMMANDS.join(", ")}`
7233
+ );
7234
+ }
7235
+ rest = rest.slice(1);
7236
+ if (subcommand !== "list") {
7237
+ target = rest[0];
7238
+ if (target === void 0 || target.startsWith("-")) {
7239
+ throw new Error(`recipe ${subcommand} needs a recipe id, selector, or path.`);
7240
+ }
7241
+ rest = rest.slice(1);
7242
+ }
7243
+ } else if (command === "workspace") {
6065
7244
  subcommand = rest[0];
6066
7245
  if (subcommand === void 0 || subcommand.startsWith("-")) {
6067
7246
  throw new Error(`workspace needs a subcommand: ${WORKSPACE_SUBCOMMANDS.join(", ")}`);
@@ -6081,6 +7260,14 @@ function parseArgs(argv) {
6081
7260
  }
6082
7261
  rest = rest.slice(1);
6083
7262
  }
7263
+ } else if (command === "refine") {
7264
+ subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "run" : rest[0];
7265
+ if (!REFINE_SUBCOMMANDS.includes(subcommand)) {
7266
+ throw new Error(
7267
+ `Unknown refine subcommand "${subcommand}". Known: ${REFINE_SUBCOMMANDS.join(", ")}`
7268
+ );
7269
+ }
7270
+ if (rest[0] === subcommand) rest = rest.slice(1);
6084
7271
  }
6085
7272
  for (let i = 0; i < rest.length; i++) {
6086
7273
  const token = rest[i];
@@ -6155,10 +7342,16 @@ function parseArgs(argv) {
6155
7342
  }
6156
7343
  return value;
6157
7344
  };
7345
+ const rawGridConfidence = get("--min-grid-confidence");
7346
+ if (rawGridConfidence !== void 0 && rawGridConfidence !== "low" && rawGridConfidence !== "medium" && rawGridConfidence !== "high") {
7347
+ throw new Error(
7348
+ `--min-grid-confidence must be low, medium, or high, got "${rawGridConfidence}"`
7349
+ );
7350
+ }
6158
7351
  return {
6159
7352
  command,
6160
7353
  manifest,
6161
- lock: get("--lock") ?? path19.join(path19.dirname(path19.resolve(manifest)), "pixelkiln.lock.json"),
7354
+ lock: get("--lock") ?? path22.join(path22.dirname(path22.resolve(manifest)), "pixelkiln.lock.json"),
6162
7355
  explicitLock: get("--lock"),
6163
7356
  styles: list("--style"),
6164
7357
  assets: list("--only"),
@@ -6189,6 +7382,13 @@ function parseArgs(argv) {
6189
7382
  minTransparency: numberOption("--min-transparency", { min: 0, max: 1 }),
6190
7383
  maxColors: numberOption("--max-colors", { min: 1, integer: true }),
6191
7384
  sigma: numberOption("--sigma", { min: Number.EPSILON }),
7385
+ palette: list("--palette"),
7386
+ fixerPython: get("--fixer-python"),
7387
+ fixerRevision: get("--fixer-revision"),
7388
+ minGridConfidence: rawGridConfidence,
7389
+ reviewer: get("--reviewer"),
7390
+ note: get("--note"),
7391
+ modelRoot: get("--model-root"),
6192
7392
  subcommand,
6193
7393
  workspace: get("--workspace"),
6194
7394
  target,
@@ -6215,6 +7415,10 @@ Commands
6215
7415
  salvage Triage account objects no lockfile claims. Recovers usable art.
6216
7416
  One session per matching style unless --style forces a single one.
6217
7417
  audit Measure how consistently a style's assets hold together. Offline.
7418
+ refine Recover a native pixel grid, enforce a final palette, audit it,
7419
+ and attach a verifiable human approval. run/approve/check. Offline.
7420
+ recipe List, inspect, install, or verify versioned workflow packs. Offline.
7421
+ quality Snapshot or check measurable image regressions. Offline.
6218
7422
  cache Inspect local recovery/object-hash caches; optionally verify or prune.
6219
7423
  pack Composite sprites into a PNG/atlas/provenance bundle. Offline.
6220
7424
  mount Write a style's sprites into their declared cells of an existing
@@ -6232,7 +7436,7 @@ Commands
6232
7436
  Options
6233
7437
  --columns <n> pack/export: sprites or tiles per row (default: near-square)
6234
7438
  --port <n> Local review-server port (default: choose a free port)
6235
- --inputs <path> pack: JSON [{id,path}] instead of the lockfile; needs --out
7439
+ --inputs <path> pack/quality snapshot: JSON input list; needs --out
6236
7440
  --format <format> export: generic (default), tiled, or godot
6237
7441
  --output-role <r> pack: include only this output role (repeatable)
6238
7442
  --primary-only pack: include only unambiguous primary/single outputs
@@ -6240,16 +7444,23 @@ Options
6240
7444
  --min-transparency <0..1> audit: minimum transparent canvas share
6241
7445
  --max-colors <n> audit: maximum distinct opaque colors
6242
7446
  --sigma <n> audit: relative outlier cutoff (default: 1.5)
7447
+ --palette <hexes> refine: final comma-separated #rrggbb colors (repeatable)
7448
+ --fixer-python <path> refine: Python with Pixel Art Fixer installed
7449
+ --fixer-revision <sha> refine: Pixel Art Fixer revision to record
7450
+ --min-grid-confidence <level> refine: high (default), medium, or low
7451
+ --reviewer <name> refine approve: human reviewer recorded in provenance
7452
+ --note <text> refine approve: optional review note
7453
+ --model-root <dir> recipe verify: also hash required local model files
6243
7454
  --prune cache: remove invalid/unreferenced local cache data
6244
7455
  --manifest <path> Default: pixelkiln.manifest.json
6245
7456
  --lock <path> Default: pixelkiln.lock.json beside the manifest
6246
7457
  --style a,b Restrict to these styles
6247
7458
  --only id1,id2 Restrict to these asset ids
6248
7459
  --budget <n> Refuse to spend more than n provider cost units
6249
- --force Regenerate; derived commands also take over modified/unowned output
7460
+ --force Regenerate; also replace changed recipe files or quality baselines
6250
7461
  --dry-run Never spend; doctor also skips provider connectivity
6251
7462
  --all salvage --dry-run: list every unclaimed object, not just the first 30
6252
- --json Machine-readable output where supported, including plan/audit/doctor/cache
7463
+ --json Machine-readable output where supported, including quality checks
6253
7464
  --check plan/audit/cache: exit nonzero when the selected state is unsafe
6254
7465
  --yes, -y Skip the confirmation prompt
6255
7466
  --no-open Do not auto-open the browser during pick
@@ -6257,7 +7468,7 @@ Options
6257
7468
  --claims a.json,b Other projects' lockfiles (salvage; required if account is shared)
6258
7469
  --workspace <path> Workspace catalog (default: pixelkiln.workspace.json). Also
6259
7470
  derives salvage's claim set instead of repeated --claims.
6260
- --from <dir> Source tree for init
7471
+ --from <path> Source for init/refine, or baseline for quality check
6261
7472
  --write-prompts adopt: recover prompts into the manifest
6262
7473
 
6263
7474
  Examples
@@ -6269,6 +7480,14 @@ Examples
6269
7480
  pixelkiln pack --inputs sprites.json --out dist/sheet # no manifest needed
6270
7481
  pixelkiln mount --style ground
6271
7482
  pixelkiln export --style ground --only terrain --format tiled
7483
+ pixelkiln refine --from source.png --out final.png --palette "#101820,#f2aa4c"
7484
+ pixelkiln refine approve --from final.pixelkiln.json --reviewer "Your Name"
7485
+ pixelkiln refine check --from final.pixelkiln.json
7486
+ pixelkiln recipe list
7487
+ pixelkiln recipe install comfyui/pixel-art-xl-environment
7488
+ pixelkiln recipe verify pixelkiln-recipes/comfyui/pixel-art-xl-environment/1.0.0 --model-root /path/to/ComfyUI/models
7489
+ pixelkiln quality snapshot --inputs quality-inputs.json --out pixelkiln.quality.json
7490
+ pixelkiln quality check --from pixelkiln.quality.json
6272
7491
  pixelkiln workspace add ../other-game/pixelkiln.manifest.json
6273
7492
  pixelkiln workspace status --json
6274
7493
  pixelkiln salvage --workspace pixelkiln.workspace.json
@@ -6313,10 +7532,10 @@ async function confirm(question, auto) {
6313
7532
  return answer === "y" || answer === "yes";
6314
7533
  }
6315
7534
  async function requireCompleteWorkspaceClaims(workspacePath) {
6316
- if (!existsSync15(workspacePath)) {
7535
+ if (!existsSync17(workspacePath)) {
6317
7536
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
6318
7537
  }
6319
- const dir = path19.dirname(path19.resolve(workspacePath));
7538
+ const dir = path22.dirname(path22.resolve(workspacePath));
6320
7539
  const ws = await loadWorkspace(workspacePath);
6321
7540
  const diagnostics = validateWorkspace(ws, dir);
6322
7541
  const errors = diagnostics.filter((d) => d.level === "error");
@@ -6337,13 +7556,214 @@ async function main() {
6337
7556
  }
6338
7557
  if (args.command === "--version" || args.command === "-v") {
6339
7558
  const pkg = JSON.parse(
6340
- await readFile15(new URL("../package.json", import.meta.url), "utf8")
7559
+ await readFile17(new URL("../package.json", import.meta.url), "utf8")
6341
7560
  );
6342
7561
  log(`${pkg.name} ${pkg.version}`);
6343
7562
  return;
6344
7563
  }
7564
+ if (args.command === "quality") {
7565
+ if (args.subcommand === "snapshot") {
7566
+ if (!args.inputs) throw new Error("quality snapshot needs --inputs <quality-inputs.json>.");
7567
+ if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
7568
+ let raw;
7569
+ try {
7570
+ raw = JSON.parse(await readFile17(path22.resolve(args.inputs), "utf8"));
7571
+ } catch (error) {
7572
+ throw new Error(
7573
+ `Could not read quality inputs ${path22.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
7574
+ { cause: error }
7575
+ );
7576
+ }
7577
+ const inputs = resolveQualityInputs(raw, args.inputs);
7578
+ if (path22.resolve(args.inputs) === path22.resolve(args.out)) {
7579
+ throw new Error("quality snapshot --out must not overwrite its --inputs file.");
7580
+ }
7581
+ const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
7582
+ if (args.json) {
7583
+ log(JSON.stringify({
7584
+ version: 1,
7585
+ path: result.path,
7586
+ changed: result.changed,
7587
+ baseline: result.baseline
7588
+ }, null, 2));
7589
+ } else {
7590
+ log(
7591
+ ` ${result.changed ? "wrote" : "unchanged"} ${path22.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
7592
+ );
7593
+ log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
7594
+ }
7595
+ return;
7596
+ }
7597
+ if (!args.from) throw new Error("quality check needs --from <pixelkiln.quality.json>.");
7598
+ const report = await checkQualityBaseline(args.from);
7599
+ if (args.json) {
7600
+ log(JSON.stringify(report, null, 2));
7601
+ } else {
7602
+ for (const entry of report.cases) {
7603
+ log(` ${entry.status === "pass" ? "ok" : "ERROR"} ${entry.id}`);
7604
+ for (const violation of entry.violations) log(` ${violation}`);
7605
+ for (const warning of entry.warnings) log(` WARN ${warning}`);
7606
+ }
7607
+ log(
7608
+ `
7609
+ ${report.summary.passed}/${report.summary.total} passed` + (report.summary.changed ? ` \xB7 ${report.summary.changed} image hash(es) changed` : "")
7610
+ );
7611
+ log(` Metrics catch structural regressions; they do not replace art review.`);
7612
+ }
7613
+ if (!report.safe) process.exitCode = 1;
7614
+ return;
7615
+ }
7616
+ if (args.command === "recipe") {
7617
+ if (args.subcommand === "list") {
7618
+ const recipes = await listBundledRecipes();
7619
+ if (args.json) {
7620
+ log(JSON.stringify({
7621
+ version: 1,
7622
+ recipes: recipes.map(({ recipe }) => ({
7623
+ id: recipe.id,
7624
+ version: recipe.version,
7625
+ provider: recipe.provider,
7626
+ summary: recipe.summary,
7627
+ selector: `${recipe.id}@${recipe.version}`
7628
+ }))
7629
+ }, null, 2));
7630
+ } else {
7631
+ log(` ${recipes.length} bundled recipe(s):`);
7632
+ for (const { recipe } of recipes) {
7633
+ log(` ${(recipe.id + "@" + recipe.version).padEnd(52)} ${recipe.summary}`);
7634
+ }
7635
+ }
7636
+ return;
7637
+ }
7638
+ const target = args.target;
7639
+ if (args.subcommand === "inspect") {
7640
+ const { recipe, path: recipePath, bundled } = await resolveRecipe(target);
7641
+ if (args.json) {
7642
+ log(JSON.stringify({ ...recipe, path: recipePath, bundled }, null, 2));
7643
+ } else {
7644
+ log(` ${recipe.id}@${recipe.version}`);
7645
+ log(` ${recipe.summary}`);
7646
+ log(` provider: ${recipe.provider} \xB7 style: ${recipe.styleId} \xB7 stage: ${recipe.quality.stage}`);
7647
+ log(
7648
+ ` native target: ${recipe.quality.recommendedNativeSize.min}\u2013${recipe.quality.recommendedNativeSize.max}px \xB7 palette: ${recipe.quality.paletteColors.min}\u2013${recipe.quality.paletteColors.max} colors`
7649
+ );
7650
+ if (recipe.workflow) log(` workflow: ${recipe.workflow.path} \xB7 ${recipe.workflow.numImages} candidate(s)`);
7651
+ for (const model of recipe.models) log(` model: ${model.path} \xB7 ${model.license}`);
7652
+ log(` source: ${recipePath}${bundled ? " (bundled)" : ""}`);
7653
+ }
7654
+ return;
7655
+ }
7656
+ if (args.subcommand === "verify") {
7657
+ const report = await verifyRecipe(target, { modelRoot: args.modelRoot });
7658
+ if (args.json) {
7659
+ log(JSON.stringify(report, null, 2));
7660
+ } else {
7661
+ log(` ${report.ok ? "ok" : "ERROR"} ${report.recipe.id}@${report.recipe.version}`);
7662
+ log(` ${report.integrity.status.padEnd(9)} recipe metadata`);
7663
+ for (const file of report.files) log(` ${file.status.padEnd(9)} ${file.path}`);
7664
+ for (const model of report.models) log(` ${model.status.padEnd(9)} ${model.path}`);
7665
+ if (!report.modelRoot && report.models.length) {
7666
+ log(` models were not checked; pass --model-root <ComfyUI/models> to verify this workstation`);
7667
+ }
7668
+ }
7669
+ if (!report.ok) process.exitCode = 1;
7670
+ return;
7671
+ }
7672
+ if (args.subcommand === "install") {
7673
+ const result = await installRecipe(target, { out: args.out, force: args.force });
7674
+ if (args.json) {
7675
+ log(JSON.stringify({
7676
+ version: 1,
7677
+ recipe: `${result.recipe.id}@${result.recipe.version}`,
7678
+ destination: result.destination,
7679
+ changed: result.changed,
7680
+ unchanged: result.unchanged,
7681
+ styleId: result.styleId,
7682
+ style: result.style
7683
+ }, null, 2));
7684
+ } else {
7685
+ log(` installed ${result.recipe.id}@${result.recipe.version}`);
7686
+ log(` ${path22.relative(process.cwd(), result.destination) || "."}`);
7687
+ log(`
7688
+ Add this entry under your manifest's styles object:`);
7689
+ log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
7690
+ if (result.recipe.models.length) {
7691
+ log(`
7692
+ Models are not downloaded automatically. Verify them with:`);
7693
+ log(` pixelkiln recipe verify ${path22.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
7694
+ }
7695
+ }
7696
+ return;
7697
+ }
7698
+ }
7699
+ if (args.command === "refine") {
7700
+ if (!args.from) {
7701
+ throw new Error(
7702
+ args.subcommand === "run" ? "refine needs --from <source.png>." : `refine ${args.subcommand} needs --from <output.pixelkiln.json>.`
7703
+ );
7704
+ }
7705
+ if (args.subcommand === "run") {
7706
+ if (!args.out) throw new Error("refine needs --out <final.png>.");
7707
+ if (!args.palette.length) {
7708
+ throw new Error("refine needs an explicit final palette via --palette <#rrggbb,...>.");
7709
+ }
7710
+ const result2 = await refineAsset({
7711
+ source: args.from,
7712
+ output: args.out,
7713
+ palette: args.palette,
7714
+ fixerPython: args.fixerPython,
7715
+ fixerRevision: args.fixerRevision,
7716
+ minGridConfidence: args.minGridConfidence,
7717
+ minTransparency: args.minTransparency,
7718
+ force: args.force
7719
+ });
7720
+ if (args.json) {
7721
+ log(JSON.stringify(result2, null, 2));
7722
+ } else {
7723
+ log(
7724
+ ` recovered ${result2.detection.columns}x${result2.detection.rows} native grid (${result2.detection.confidence}: ${result2.detection.consensus})`
7725
+ );
7726
+ log(` enforced ${result2.palette.length}-color palette without dithering`);
7727
+ log(` wrote ${path22.relative(process.cwd(), result2.output)}`);
7728
+ log(` quality record: ${path22.relative(process.cwd(), result2.record)}`);
7729
+ log(`
7730
+ Automated checks passed. Human 1\xD7 review is still required:`);
7731
+ log(
7732
+ ` pixelkiln refine approve --from ${path22.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
7733
+ );
7734
+ }
7735
+ return;
7736
+ }
7737
+ if (args.subcommand === "approve") {
7738
+ if (!args.reviewer?.trim()) {
7739
+ throw new Error("refine approve needs --reviewer <name>.");
7740
+ }
7741
+ log(` Inspect the final PNG at 1\xD7 and integer zoom.`);
7742
+ log(` Confirm crisp edges, readable forms, palette separation, alpha, and seams.`);
7743
+ if (!await confirm(" Record this asset as human-approved?", args.yes)) {
7744
+ log(" approval not recorded");
7745
+ return;
7746
+ }
7747
+ const result2 = await approveQualityRecord(args.from, {
7748
+ reviewer: args.reviewer,
7749
+ note: args.note
7750
+ });
7751
+ if (args.json) log(JSON.stringify(result2, null, 2));
7752
+ else log(` approved ${path22.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`);
7753
+ return;
7754
+ }
7755
+ const result = await checkQualityRecord(args.from);
7756
+ if (args.json) {
7757
+ log(JSON.stringify(result, null, 2));
7758
+ } else {
7759
+ log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path22.relative(process.cwd(), result.output || result.record)}`);
7760
+ for (const reason of result.reasons) log(` ${reason}`);
7761
+ }
7762
+ if (!result.safe) process.exitCode = 1;
7763
+ return;
7764
+ }
6345
7765
  if (args.command === "balance") {
6346
- loadEnvFiles(path19.dirname(path19.resolve(args.manifest)));
7766
+ loadEnvFiles(path22.dirname(path22.resolve(args.manifest)));
6347
7767
  loadEnvFiles(process.cwd());
6348
7768
  const loaded2 = await loadManifest(args.manifest);
6349
7769
  const p = createProvider(loaded2.manifest.provider, "online");
@@ -6355,31 +7775,31 @@ async function main() {
6355
7775
  }
6356
7776
  if (args.command === "init") {
6357
7777
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
6358
- const root = path19.resolve(args.from);
6359
- if (!existsSync15(root)) throw new Error(`No directory at ${root}`);
7778
+ const root = path22.resolve(args.from);
7779
+ if (!existsSync17(root)) throw new Error(`No directory at ${root}`);
6360
7780
  const generator = args.generator ?? "map";
6361
7781
  if (generator !== "1dir" && generator !== "map") {
6362
7782
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
6363
7783
  }
6364
- const target = path19.resolve(args.out ?? "pixelkiln.manifest.json");
7784
+ const target = path22.resolve(args.out ?? "pixelkiln.manifest.json");
6365
7785
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
6366
7786
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
6367
7787
  const manifest = buildManifest(
6368
- args.name ?? path19.basename(path19.dirname(target)),
7788
+ args.name ?? path22.basename(path22.dirname(target)),
6369
7789
  args.styles[0] ?? "base",
6370
7790
  generator,
6371
- path19.relative(path19.dirname(target), root) || ".",
7791
+ path22.relative(path22.dirname(target), root) || ".",
6372
7792
  assets
6373
7793
  );
6374
7794
  await writeManifestFile(target, manifest);
6375
- log(` scanned ${assets.length} PNG(s) under ${path19.relative(process.cwd(), root)}`);
7795
+ log(` scanned ${assets.length} PNG(s) under ${path22.relative(process.cwd(), root)}`);
6376
7796
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
6377
- log(` wrote ${path19.relative(process.cwd(), target)}`);
7797
+ log(` wrote ${path22.relative(process.cwd(), target)}`);
6378
7798
  log(`
6379
7799
  Prompts are intentionally empty. To recover the real ones from your`);
6380
7800
  log(` PixelLab account instead of inventing them:`);
6381
7801
  log(`
6382
- pixelkiln adopt --manifest ${path19.relative(process.cwd(), target)} --write-prompts
7802
+ pixelkiln adopt --manifest ${path22.relative(process.cwd(), target)} --write-prompts
6383
7803
  `);
6384
7804
  return;
6385
7805
  }
@@ -6427,10 +7847,10 @@ async function main() {
6427
7847
  if (args.primaryOnly || args.outputRoles.length) {
6428
7848
  throw new Error("--primary-only and --output-role require manifest-driven pack");
6429
7849
  }
6430
- const raw = JSON.parse(await readFile15(path19.resolve(args.inputs), "utf8"));
7850
+ const raw = JSON.parse(await readFile17(path22.resolve(args.inputs), "utf8"));
6431
7851
  const inputs = resolvePackInputs(raw, args.inputs);
6432
7852
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
6433
- const base = path19.resolve(args.out.replace(/\.png$/, ""));
7853
+ const base = path22.resolve(args.out.replace(/\.png$/, ""));
6434
7854
  const outputs = [
6435
7855
  { path: `${base}.png`, data: png },
6436
7856
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -6443,17 +7863,17 @@ async function main() {
6443
7863
  log(
6444
7864
  ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
6445
7865
  );
6446
- log(` ${path19.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
7866
+ log(` ${path22.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
6447
7867
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
6448
7868
  return;
6449
7869
  }
6450
7870
  if (args.command === "workspace") {
6451
- const workspacePath = path19.resolve(args.workspace ?? "pixelkiln.workspace.json");
6452
- const dir = path19.dirname(workspacePath);
7871
+ const workspacePath = path22.resolve(args.workspace ?? "pixelkiln.workspace.json");
7872
+ const dir = path22.dirname(workspacePath);
6453
7873
  if (args.subcommand === "add") {
6454
- const manifestPath = path19.resolve(args.target);
7874
+ const manifestPath = path22.resolve(args.target);
6455
7875
  const loadedTarget = await loadManifest(manifestPath);
6456
- const lockPath = args.explicitLock ? path19.resolve(args.explicitLock) : path19.join(path19.dirname(manifestPath), "pixelkiln.lock.json");
7876
+ const lockPath = args.explicitLock ? path22.resolve(args.explicitLock) : path22.join(path22.dirname(manifestPath), "pixelkiln.lock.json");
6457
7877
  const ws = await loadWorkspace(workspacePath);
6458
7878
  const id = args.name ?? loadedTarget.manifest.name;
6459
7879
  if (ws.projects.some((p) => p.id === id)) {
@@ -6473,10 +7893,10 @@ async function main() {
6473
7893
  ...args.account ? { account: args.account } : {}
6474
7894
  };
6475
7895
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
6476
- log(` registered "${id}" in ${path19.relative(process.cwd(), workspacePath)}`);
7896
+ log(` registered "${id}" in ${path22.relative(process.cwd(), workspacePath)}`);
6477
7897
  log(` manifest: ${project.manifest}`);
6478
7898
  log(` lock: ${project.lock}`);
6479
- if (!existsSync15(lockPath)) {
7899
+ if (!existsSync17(lockPath)) {
6480
7900
  log(
6481
7901
  ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
6482
7902
  );
@@ -6485,7 +7905,7 @@ async function main() {
6485
7905
  }
6486
7906
  if (args.subcommand === "remove") {
6487
7907
  const ws = await loadWorkspace(workspacePath);
6488
- const resolvedTarget = path19.resolve(args.target);
7908
+ const resolvedTarget = path22.resolve(args.target);
6489
7909
  const match = ws.projects.find(
6490
7910
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
6491
7911
  );
@@ -6496,11 +7916,11 @@ async function main() {
6496
7916
  version: 1,
6497
7917
  projects: ws.projects.filter((p) => p !== match)
6498
7918
  });
6499
- log(` removed "${match.id}" from ${path19.relative(process.cwd(), workspacePath)}`);
7919
+ log(` removed "${match.id}" from ${path22.relative(process.cwd(), workspacePath)}`);
6500
7920
  return;
6501
7921
  }
6502
7922
  if (args.subcommand === "list") {
6503
- if (!existsSync15(workspacePath)) {
7923
+ if (!existsSync17(workspacePath)) {
6504
7924
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
6505
7925
  }
6506
7926
  const ws = await loadWorkspace(workspacePath);
@@ -6508,9 +7928,9 @@ async function main() {
6508
7928
  if (args.json) {
6509
7929
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
6510
7930
  } else if (!ws.projects.length) {
6511
- log(` no projects registered in ${path19.relative(process.cwd(), workspacePath)}`);
7931
+ log(` no projects registered in ${path22.relative(process.cwd(), workspacePath)}`);
6512
7932
  } else {
6513
- log(` ${ws.projects.length} project(s) in ${path19.relative(process.cwd(), workspacePath)}:`);
7933
+ log(` ${ws.projects.length} project(s) in ${path22.relative(process.cwd(), workspacePath)}:`);
6514
7934
  for (const p of ws.projects) {
6515
7935
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
6516
7936
  }
@@ -6520,7 +7940,7 @@ async function main() {
6520
7940
  return;
6521
7941
  }
6522
7942
  if (args.subcommand === "status") {
6523
- if (!existsSync15(workspacePath)) {
7943
+ if (!existsSync17(workspacePath)) {
6524
7944
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
6525
7945
  }
6526
7946
  const ws = await loadWorkspace(workspacePath);
@@ -6528,7 +7948,7 @@ async function main() {
6528
7948
  if (args.json) {
6529
7949
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
6530
7950
  } else {
6531
- log(` workspace: ${path19.relative(process.cwd(), workspacePath)}`);
7951
+ log(` workspace: ${path22.relative(process.cwd(), workspacePath)}`);
6532
7952
  for (const p of report.projects) {
6533
7953
  if (p.error) {
6534
7954
  log(`
@@ -6572,14 +7992,14 @@ async function main() {
6572
7992
  return;
6573
7993
  }
6574
7994
  }
6575
- if (!existsSync15(path19.resolve(args.manifest))) {
7995
+ if (!existsSync17(path22.resolve(args.manifest))) {
6576
7996
  throw new Error(
6577
- `No manifest at ${path19.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
7997
+ `No manifest at ${path22.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
6578
7998
  );
6579
7999
  }
6580
- const manifestDir = path19.dirname(path19.resolve(args.manifest));
8000
+ const manifestDir = path22.dirname(path22.resolve(args.manifest));
6581
8001
  const envFiles = [...loadEnvFiles(manifestDir)];
6582
- if (path19.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
8002
+ if (path22.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
6583
8003
  const loaded = await loadManifest(args.manifest);
6584
8004
  const estimator = createProvider(loaded.manifest.provider, "offline");
6585
8005
  const specs = await resolveSpecs(loaded, {
@@ -6656,7 +8076,7 @@ async function main() {
6656
8076
  let intact = true;
6657
8077
  for (const output of entry.outputs) {
6658
8078
  const file = resolveOutputPath(output.path, item.spec.root);
6659
- if (!existsSync15(file) || await sha256File(file) !== output.sha256) {
8079
+ if (!existsSync17(file) || await sha256File(file) !== output.sha256) {
6660
8080
  intact = false;
6661
8081
  break;
6662
8082
  }
@@ -6714,7 +8134,7 @@ async function main() {
6714
8134
  if (args.primaryOnly && args.outputRoles.length) {
6715
8135
  throw new Error("pack accepts either --primary-only or --output-role, not both");
6716
8136
  }
6717
- const manifestDir2 = path19.dirname(path19.resolve(args.manifest));
8137
+ const manifestDir2 = path22.dirname(path22.resolve(args.manifest));
6718
8138
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
6719
8139
  for (const styleId of styleIds) {
6720
8140
  const { png, atlas, skipped, sources } = packStyle(lock, styleId, manifestDir2, {
@@ -6723,7 +8143,7 @@ async function main() {
6723
8143
  primaryOnly: args.primaryOnly
6724
8144
  });
6725
8145
  const style = loaded.manifest.styles[styleId];
6726
- const base = args.out ? path19.resolve(args.out.replace(/\.png$/, "")) : path19.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
8146
+ const base = args.out ? path22.resolve(args.out.replace(/\.png$/, "")) : path22.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
6727
8147
  const outputs = [
6728
8148
  { path: `${base}.png`, data: png },
6729
8149
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -6746,13 +8166,13 @@ async function main() {
6746
8166
  log(
6747
8167
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
6748
8168
  );
6749
- log(` ${path19.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
8169
+ log(` ${path22.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
6750
8170
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
6751
8171
  }
6752
8172
  return;
6753
8173
  }
6754
8174
  if (args.command === "mount") {
6755
- const manifestDir2 = path19.dirname(path19.resolve(args.manifest));
8175
+ const manifestDir2 = path22.dirname(path22.resolve(args.manifest));
6756
8176
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
6757
8177
  for (const styleId of styleIds) {
6758
8178
  const style = loaded.manifest.styles[styleId];
@@ -6782,7 +8202,7 @@ async function main() {
6782
8202
  sources,
6783
8203
  outputRoles
6784
8204
  );
6785
- const out = path19.resolve(manifestDir2, style.mount.out);
8205
+ const out = path22.resolve(manifestDir2, style.mount.out);
6786
8206
  const metadata = out.replace(/\.png$/, "") + ".json";
6787
8207
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
6788
8208
  const outputs = [
@@ -6795,7 +8215,7 @@ async function main() {
6795
8215
  await provenanceFile("$manifest", args.manifest),
6796
8216
  await provenanceFile("$lock", args.lock),
6797
8217
  ...artifactSources.filter(
6798
- (source) => source.id !== "$base" || path19.resolve(source.path) !== out
8218
+ (source) => source.id !== "$base" || path22.resolve(source.path) !== out
6799
8219
  )
6800
8220
  ],
6801
8221
  options: {
@@ -6808,7 +8228,7 @@ async function main() {
6808
8228
  log(
6809
8229
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
6810
8230
  );
6811
- log(` ${path19.relative(process.cwd(), out)} + atlas/provenance JSON`);
8231
+ log(` ${path22.relative(process.cwd(), out)} + atlas/provenance JSON`);
6812
8232
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
6813
8233
  }
6814
8234
  return;
@@ -6864,7 +8284,7 @@ async function main() {
6864
8284
  }
6865
8285
  if (args.command === "export") {
6866
8286
  const format = args.format ?? "generic";
6867
- const manifestDir2 = path19.dirname(path19.resolve(args.manifest));
8287
+ const manifestDir2 = path22.dirname(path22.resolve(args.manifest));
6868
8288
  const selected = specs.filter((spec) => {
6869
8289
  if (spec.generator !== "tiles") return false;
6870
8290
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -6880,12 +8300,12 @@ async function main() {
6880
8300
  for (const spec of selected) {
6881
8301
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
6882
8302
  const style = loaded.manifest.styles[spec.styleId];
6883
- const defaultBase = path19.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
6884
- const base = args.out ? path19.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
8303
+ const defaultBase = path22.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
8304
+ const base = args.out ? path22.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
6885
8305
  const result = exportTileset(entry, spec, {
6886
8306
  format,
6887
8307
  manifestDir: manifestDir2,
6888
- imageName: path19.basename(`${base}.png`),
8308
+ imageName: path22.basename(`${base}.png`),
6889
8309
  columns: args.columns
6890
8310
  });
6891
8311
  const outputs = [
@@ -6903,7 +8323,7 @@ async function main() {
6903
8323
  asset: spec.assetId,
6904
8324
  columns: args.columns ?? null,
6905
8325
  format,
6906
- image: path19.basename(`${base}.png`),
8326
+ image: path22.basename(`${base}.png`),
6907
8327
  providerRules: result.generic.providerRules,
6908
8328
  style: spec.styleId,
6909
8329
  tileType: spec.tileType ?? null
@@ -6913,7 +8333,7 @@ async function main() {
6913
8333
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
6914
8334
  );
6915
8335
  log(
6916
- ` ${path19.relative(process.cwd(), base)}.png + ${path19.basename(base)}${result.extension} + .pixelkiln.json`
8336
+ ` ${path22.relative(process.cwd(), base)}.png + ${path22.basename(base)}${result.extension} + .pixelkiln.json`
6917
8337
  );
6918
8338
  }
6919
8339
  return;
@@ -6953,10 +8373,10 @@ async function main() {
6953
8373
  tagged ${n} object(s) upstream`);
6954
8374
  }
6955
8375
  if (args.writePrompts) {
6956
- const { filled, stillEmpty } = await writePromptsBack(path19.resolve(args.manifest), lock, {
8376
+ const { filled, stillEmpty } = await writePromptsBack(path22.resolve(args.manifest), lock, {
6957
8377
  onProgress: log
6958
8378
  });
6959
- log(` recovered ${filled} prompt(s) into ${path19.relative(process.cwd(), args.manifest)}`);
8379
+ log(` recovered ${filled} prompt(s) into ${path22.relative(process.cwd(), args.manifest)}`);
6960
8380
  const reloaded = await loadManifest(args.manifest);
6961
8381
  const rebased = await resolveSpecs(reloaded, {
6962
8382
  styles: args.styles,
@@ -6985,12 +8405,12 @@ async function main() {
6985
8405
  if (args.command === "salvage") {
6986
8406
  const jsonMode = args.dryRun && args.json;
6987
8407
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
6988
- const ownLock = path19.resolve(args.lock);
8408
+ const ownLock = path22.resolve(args.lock);
6989
8409
  let workspaceProjects = [];
6990
8410
  let workspaceDir = "";
6991
8411
  if (args.workspace) {
6992
- const workspacePath = path19.resolve(args.workspace);
6993
- workspaceDir = path19.dirname(workspacePath);
8412
+ const workspacePath = path22.resolve(args.workspace);
8413
+ workspaceDir = path22.dirname(workspacePath);
6994
8414
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
6995
8415
  workspaceProjects = complete.ws.projects;
6996
8416
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -6999,12 +8419,12 @@ async function main() {
6999
8419
  const lockPaths = [
7000
8420
  .../* @__PURE__ */ new Set([
7001
8421
  ...workspaceLockPaths,
7002
- ...existsSync15(ownLock) ? [ownLock] : [],
7003
- ...args.claims.map((c) => path19.resolve(c))
8422
+ ...existsSync17(ownLock) ? [ownLock] : [],
8423
+ ...args.claims.map((c) => path22.resolve(c))
7004
8424
  ])
7005
8425
  ];
7006
8426
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
7007
- for (const p of lockPaths) diag(` ${path19.relative(process.cwd(), p)}`);
8427
+ for (const p of lockPaths) diag(` ${path22.relative(process.cwd(), p)}`);
7008
8428
  if (!args.claims.length && !args.workspace) {
7009
8429
  diag(
7010
8430
  `
@@ -7012,7 +8432,7 @@ async function main() {
7012
8432
  pass every other project's lockfile via --claims a.json,b.json, or
7013
8433
  register every project in a workspace catalog and pass --workspace.`
7014
8434
  );
7015
- } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path19.resolve(args.manifest))) {
8435
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path22.resolve(args.manifest))) {
7016
8436
  diag(
7017
8437
  `
7018
8438
  This project's manifest is not registered in the workspace catalog. Its own
@@ -7096,7 +8516,7 @@ async function main() {
7096
8516
  manifestPath: loaded.path,
7097
8517
  manifest: loaded.manifest,
7098
8518
  styleId,
7099
- importDir: path19.resolve(loaded.root, style.outDir),
8519
+ importDir: path22.resolve(loaded.root, style.outDir),
7100
8520
  lock,
7101
8521
  lockPath: args.lock
7102
8522
  },