pixelkiln 0.26.0 → 0.28.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 path28 from "path";
5
- import { existsSync as existsSync22 } from "fs";
6
- import { readFile as readFile22 } from "fs/promises";
4
+ import path30 from "path";
5
+ import { existsSync as existsSync23 } from "fs";
6
+ import { readFile as readFile25 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
9
9
  import { readFileSync, existsSync } from "fs";
@@ -134,10 +134,10 @@ import { createReadStream } from "fs";
134
134
  function sha256(data) {
135
135
  return createHash("sha256").update(data).digest("hex");
136
136
  }
137
- async function sha256File(path29) {
137
+ async function sha256File(path31) {
138
138
  return new Promise((resolve, reject) => {
139
139
  const hash = createHash("sha256");
140
- const stream = createReadStream(path29);
140
+ const stream = createReadStream(path31);
141
141
  stream.on("data", (chunk2) => hash.update(chunk2));
142
142
  stream.on("error", reject);
143
143
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -1617,11 +1617,11 @@ var PixelLabClient = class {
1617
1617
  * common than the failure mode of retrying (a duplicate object), and a
1618
1618
  * duplicate is visible and free to delete whereas a silent gap is neither.
1619
1619
  */
1620
- async request(path29, init, attempt = 0) {
1620
+ async request(path31, init, attempt = 0) {
1621
1621
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1622
1622
  let res;
1623
1623
  try {
1624
- res = await fetch(`${BASE}${path29}`, {
1624
+ res = await fetch(`${BASE}${path31}`, {
1625
1625
  ...init,
1626
1626
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
1627
1627
  headers: {
@@ -1633,24 +1633,24 @@ var PixelLabClient = class {
1633
1633
  } catch (err) {
1634
1634
  if (attempt < MAX_RETRIES) {
1635
1635
  await sleep(backoffMs(attempt));
1636
- return this.request(path29, init, attempt + 1);
1636
+ return this.request(path31, init, attempt + 1);
1637
1637
  }
1638
1638
  throw err;
1639
1639
  }
1640
1640
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
1641
1641
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
1642
1642
  await sleep(waitMs);
1643
- return this.request(path29, init, attempt + 1);
1643
+ return this.request(path31, init, attempt + 1);
1644
1644
  }
1645
1645
  const text = await res.text();
1646
1646
  if (!res.ok) {
1647
- throw new PixelLabError(`${init?.method ?? "GET"} ${path29} \u2192 ${res.status}`, res.status, text);
1647
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path31} \u2192 ${res.status}`, res.status, text);
1648
1648
  }
1649
1649
  if (!text) return {};
1650
1650
  try {
1651
1651
  return JSON.parse(text);
1652
1652
  } catch {
1653
- throw new Error(`${init?.method ?? "GET"} ${path29} returned invalid JSON`);
1653
+ throw new Error(`${init?.method ?? "GET"} ${path31} returned invalid JSON`);
1654
1654
  }
1655
1655
  }
1656
1656
  async balance() {
@@ -2626,9 +2626,9 @@ var RetroDiffusionClient = class {
2626
2626
  }
2627
2627
  return balance;
2628
2628
  }
2629
- async call(path29, init = {}) {
2629
+ async call(path31, init = {}) {
2630
2630
  if (!this.token) throw new Error("RD_API_KEY is not set");
2631
- const response = await this.request(`${this.baseUrl}${path29}`, {
2631
+ const response = await this.request(`${this.baseUrl}${path31}`, {
2632
2632
  ...init,
2633
2633
  headers: {
2634
2634
  "Content-Type": "application/json",
@@ -6872,10 +6872,10 @@ function isSha256Hash(value) {
6872
6872
  function parseCache(value) {
6873
6873
  return HashCacheSchema.parse(value);
6874
6874
  }
6875
- async function loadCache(path29) {
6876
- if (!existsSync11(path29)) return { version: 1, hashes: {} };
6875
+ async function loadCache(path31) {
6876
+ if (!existsSync11(path31)) return { version: 1, hashes: {} };
6877
6877
  try {
6878
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path29, "utf8")));
6878
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path31, "utf8")));
6879
6879
  if (!parsed.success) return { version: 1, hashes: {} };
6880
6880
  return {
6881
6881
  version: 1,
@@ -6887,18 +6887,18 @@ async function loadCache(path29) {
6887
6887
  return { version: 1, hashes: {} };
6888
6888
  }
6889
6889
  }
6890
- async function saveCache(path29, cache) {
6890
+ async function saveCache(path31, cache) {
6891
6891
  const sorted = {};
6892
6892
  for (const key of Object.keys(cache.hashes).sort()) {
6893
6893
  const hash = cache.hashes[key];
6894
6894
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6895
6895
  sorted[key] = hash;
6896
6896
  }
6897
- await mkdir4(pathModule.dirname(pathModule.resolve(path29)), { recursive: true });
6898
- const tmp = `${path29}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6897
+ await mkdir4(pathModule.dirname(pathModule.resolve(path31)), { recursive: true });
6898
+ const tmp = `${path31}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6899
6899
  try {
6900
6900
  await writeFile4(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6901
- await rename4(tmp, path29);
6901
+ await rename4(tmp, path31);
6902
6902
  } finally {
6903
6903
  await rm5(tmp, { force: true });
6904
6904
  }
@@ -7842,7 +7842,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
7842
7842
  // src/gallery/snapshot.ts
7843
7843
  import { createHash as createHash3 } from "crypto";
7844
7844
  import { existsSync as existsSync17 } from "fs";
7845
- import { readFile as readFile15, stat as stat3 } from "fs/promises";
7845
+ import { readFile as readFile16, stat as stat3 } from "fs/promises";
7846
7846
  import path22 from "path";
7847
7847
 
7848
7848
  // src/workspace.ts
@@ -7980,7 +7980,7 @@ function validateWorkspace(ws, dir) {
7980
7980
 
7981
7981
  // src/gallery/edit.ts
7982
7982
  import path21 from "path";
7983
- import { z as z6 } from "zod";
7983
+ import { z as z7 } from "zod";
7984
7984
 
7985
7985
  // src/manifest-edit.ts
7986
7986
  import { readFile as readFile14, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
@@ -8234,8 +8234,9 @@ async function applyManifestEdit(manifestPath, edit) {
8234
8234
  // src/pipeline/hand-edit.ts
8235
8235
  import { spawn as spawn2 } from "child_process";
8236
8236
  import { existsSync as existsSync16 } from "fs";
8237
- import { copyFile, mkdir as mkdir6 } from "fs/promises";
8237
+ import { copyFile, mkdir as mkdir6, readFile as readFile15, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
8238
8238
  import path20 from "path";
8239
+ import { z as z6 } from "zod";
8239
8240
  var HAND_EDIT_DIR = "edits";
8240
8241
  function handEditPath(loaded, spec) {
8241
8242
  const style = loaded.manifest.styles[spec.styleId];
@@ -8305,6 +8306,89 @@ async function detachHandEdit(loaded, spec, opts = {}) {
8305
8306
  });
8306
8307
  return { manifestSha256: result.sha256, changed: result.changed };
8307
8308
  }
8309
+ var HandEditCompanionSchema = z6.object({
8310
+ version: z6.literal(1),
8311
+ /** sha256 of the generated art the edit started from; null when it started from untracked art. */
8312
+ basedOn: z6.string().regex(/^[0-9a-f]{64}$/).nullable(),
8313
+ /** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
8314
+ sha256: z6.string().regex(/^[0-9a-f]{64}$/),
8315
+ /** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
8316
+ editor: z6.string().min(1),
8317
+ protocol: z6.number().int().positive(),
8318
+ savedAt: z6.string().datetime(),
8319
+ /** Basename of the layered project file beside the edit, when one was kept. */
8320
+ project: z6.string().min(1).nullable()
8321
+ }).strict();
8322
+ function handEditCompanionPath(editPath) {
8323
+ return editPath.replace(/\.png$/i, "") + ".edit.json";
8324
+ }
8325
+ function handEditProjectPath(editPath) {
8326
+ return editPath.replace(/\.png$/i, "") + ".pxo";
8327
+ }
8328
+ async function readHandEditCompanion(editPath) {
8329
+ let text;
8330
+ try {
8331
+ text = await readFile15(handEditCompanionPath(editPath), "utf8");
8332
+ } catch {
8333
+ return null;
8334
+ }
8335
+ try {
8336
+ return HandEditCompanionSchema.parse(JSON.parse(text));
8337
+ } catch {
8338
+ return null;
8339
+ }
8340
+ }
8341
+ var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
8342
+ async function saveHandEdit(loaded, lock, spec, input) {
8343
+ if (input.png.length > MAX_HAND_EDIT_BYTES) {
8344
+ throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8345
+ }
8346
+ if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
8347
+ throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8348
+ }
8349
+ let decoded;
8350
+ try {
8351
+ validateMedia(input.png, MediaType.PNG);
8352
+ decoded = decodePng(input.png);
8353
+ } catch (error) {
8354
+ throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
8355
+ }
8356
+ const base = handEditBase(spec, lock);
8357
+ const expected = base ? decodePng(await readFile15(base)) : { width: spec.width, height: spec.height };
8358
+ if (decoded.width !== expected.width || decoded.height !== expected.height) {
8359
+ throw new ManifestEditError(
8360
+ `the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
8361
+ );
8362
+ }
8363
+ const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
8364
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8365
+ const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
8366
+ const digest2 = sha256(input.png);
8367
+ const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
8368
+ const companion = {
8369
+ version: 1,
8370
+ basedOn,
8371
+ sha256: digest2,
8372
+ editor: input.editor,
8373
+ protocol: input.protocol,
8374
+ savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
8375
+ project: projectPath ? path20.basename(projectPath) : null
8376
+ };
8377
+ await replaceFile(started.editPath, input.png);
8378
+ if (projectPath && input.project) await replaceFile(projectPath, input.project);
8379
+ const companionPath = handEditCompanionPath(started.editPath);
8380
+ await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
8381
+ return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
8382
+ }
8383
+ async function replaceFile(file, bytes) {
8384
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
8385
+ try {
8386
+ await writeFile9(tmp, bytes);
8387
+ await rename7(tmp, file);
8388
+ } finally {
8389
+ await rm8(tmp, { force: true });
8390
+ }
8391
+ }
8308
8392
  function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
8309
8393
  const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
8310
8394
  const [program, ...args] = command;
@@ -8316,22 +8400,37 @@ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
8316
8400
  }
8317
8401
 
8318
8402
  // src/gallery/edit.ts
8319
- var HandEditRequestSchema = z6.discriminatedUnion("action", [
8320
- z6.object({
8321
- action: z6.literal("start-edit"),
8322
- project: z6.string().min(1).optional(),
8323
- assetId: z6.string().min(1),
8324
- styleId: z6.string().min(1),
8325
- expectedSha256: z6.string().regex(/^[0-9a-f]{64}$/),
8403
+ var Base64 = z7.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
8404
+ var HandEditRequestSchema = z7.discriminatedUnion("action", [
8405
+ z7.object({
8406
+ action: z7.literal("start-edit"),
8407
+ project: z7.string().min(1).optional(),
8408
+ assetId: z7.string().min(1),
8409
+ styleId: z7.string().min(1),
8410
+ expectedSha256: z7.string().regex(/^[0-9a-f]{64}$/),
8326
8411
  /** Also launch the author's editor on the file. */
8327
- open: z6.boolean().optional()
8412
+ open: z7.boolean().optional()
8328
8413
  }).strict(),
8329
- z6.object({
8330
- action: z6.literal("detach-edit"),
8331
- project: z6.string().min(1).optional(),
8332
- assetId: z6.string().min(1),
8333
- styleId: z6.string().min(1),
8334
- expectedSha256: z6.string().regex(/^[0-9a-f]{64}$/)
8414
+ z7.object({
8415
+ action: z7.literal("detach-edit"),
8416
+ project: z7.string().min(1).optional(),
8417
+ assetId: z7.string().min(1),
8418
+ styleId: z7.string().min(1),
8419
+ expectedSha256: z7.string().regex(/^[0-9a-f]{64}$/)
8420
+ }).strict(),
8421
+ /** From the in-browser editor: the page holds the bytes, the server validates and writes. */
8422
+ z7.object({
8423
+ action: z7.literal("save-edit"),
8424
+ project: z7.string().min(1).optional(),
8425
+ assetId: z7.string().min(1),
8426
+ styleId: z7.string().min(1),
8427
+ expectedSha256: z7.string().regex(/^[0-9a-f]{64}$/),
8428
+ png: Base64.min(1),
8429
+ /** The editor's layered project file, kept beside the edit. */
8430
+ pxo: Base64.optional(),
8431
+ /** Editor identity from the bridge's ready message. */
8432
+ editor: z7.string().min(1).max(200),
8433
+ protocol: z7.number().int().positive()
8335
8434
  }).strict()
8336
8435
  ]);
8337
8436
  function createGalleryEditHandler(opts) {
@@ -8352,6 +8451,15 @@ function createGalleryEditHandler(opts) {
8352
8451
  const command = (opts.openEditor ?? openInEditor)(started.editPath);
8353
8452
  log2(` opened with: ${command}`);
8354
8453
  }
8454
+ } else if (request.action === "save-edit") {
8455
+ const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
8456
+ png: Buffer.from(request.png, "base64"),
8457
+ ...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
8458
+ editor: request.editor,
8459
+ protocol: request.protocol,
8460
+ expectedSha256: request.expectedSha256
8461
+ });
8462
+ log2(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
8355
8463
  } else {
8356
8464
  const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
8357
8465
  if (result2.changed) log2(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
@@ -8382,6 +8490,7 @@ function createGalleryEditHandler(opts) {
8382
8490
  }
8383
8491
 
8384
8492
  // src/gallery/snapshot.ts
8493
+ var PROJECT_FILE_TYPE = "application/octet-stream";
8385
8494
  function galleryMediaId(absolutePath) {
8386
8495
  return createHash3("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
8387
8496
  }
@@ -8501,7 +8610,7 @@ async function buildGallerySnapshot(opts) {
8501
8610
  const media = /* @__PURE__ */ new Map();
8502
8611
  const plan = await buildPlan(specs, lock);
8503
8612
  const items = [];
8504
- const manifestText = await readFile15(loaded.path, "utf8");
8613
+ const manifestText = await readFile16(loaded.path, "utf8");
8505
8614
  const manifestSha256 = sha256(manifestText);
8506
8615
  let rawStyles = {};
8507
8616
  try {
@@ -8516,6 +8625,7 @@ async function buildGallerySnapshot(opts) {
8516
8625
  let outputs;
8517
8626
  let edit = null;
8518
8627
  let editStatus = null;
8628
+ let editMeta = null;
8519
8629
  if (entry && entry.outputs.length) {
8520
8630
  outputs = await Promise.all(
8521
8631
  entry.outputs.map(
@@ -8527,7 +8637,27 @@ async function buildGallerySnapshot(opts) {
8527
8637
  const editSha = existsSync17(editPath) ? await sha256File(editPath) : null;
8528
8638
  edit = await describeOutput(media, root, editPath, { sha256: editSha });
8529
8639
  const generated = outputs[0];
8530
- editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists ? "regenerated-since" : "edited";
8640
+ const companion = await readHandEditCompanion(editPath);
8641
+ if (companion) {
8642
+ const projectPath = handEditProjectPath(editPath);
8643
+ const project = companion.project && existsSync17(projectPath) ? await fileInfo(projectPath) : null;
8644
+ let projectUrl = null;
8645
+ if (project) {
8646
+ const id = galleryMediaId(projectPath);
8647
+ media.set(id, { path: projectPath, contentType: PROJECT_FILE_TYPE });
8648
+ projectUrl = `${galleryMediaRoute(id)}?v=${project.bytes}-${Date.parse(project.modifiedAt).toString(36)}`;
8649
+ }
8650
+ editMeta = {
8651
+ editor: companion.editor,
8652
+ savedAt: companion.savedAt,
8653
+ basedOn: companion.basedOn,
8654
+ changedSince: editSha !== null && editSha !== companion.sha256,
8655
+ project: project ? portableOutputPath(projectPath, root) : null,
8656
+ projectUrl
8657
+ };
8658
+ }
8659
+ const regenerated = companion ? generated?.sha256 !== void 0 && generated.sha256 !== null && companion.basedOn !== generated.sha256 : Boolean(generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists);
8660
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
8531
8661
  }
8532
8662
  } else if (spec.source) {
8533
8663
  outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
@@ -8577,6 +8707,7 @@ async function buildGallerySnapshot(opts) {
8577
8707
  source: spec.source ?? null,
8578
8708
  edit,
8579
8709
  editStatus,
8710
+ editMeta,
8580
8711
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8581
8712
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
8582
8713
  tags: spec.tags,
@@ -8633,6 +8764,7 @@ async function buildGallerySnapshot(opts) {
8633
8764
  source: null,
8634
8765
  edit: null,
8635
8766
  editStatus: null,
8767
+ editMeta: null,
8636
8768
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8637
8769
  refreshable: false,
8638
8770
  tags: [],
@@ -8774,7 +8906,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
8774
8906
  root: loaded.root,
8775
8907
  provider: loaded.manifest.provider,
8776
8908
  account: project.account ?? null,
8777
- manifestSha256: manifestSha256 ?? sha256(await readFile15(manifestPath, "utf8")),
8909
+ manifestSha256: manifestSha256 ?? sha256(await readFile16(manifestPath, "utf8")),
8778
8910
  entries: Object.keys(lock.entries).length,
8779
8911
  items: projectItems.length,
8780
8912
  spendByUnit: spendByUnit(lock),
@@ -8832,7 +8964,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
8832
8964
  import { spawn as spawn3 } from "child_process";
8833
8965
  import { randomBytes } from "crypto";
8834
8966
  import { createServer as createServer2 } from "http";
8835
- import { readFile as readFile16 } from "fs/promises";
8967
+ import { readFile as readFile17 } from "fs/promises";
8836
8968
 
8837
8969
  // src/gallery/page.ts
8838
8970
  function renderGallery(snapshot, opts = {}) {
@@ -8840,6 +8972,7 @@ function renderGallery(snapshot, opts = {}) {
8840
8972
  const session = JSON.stringify(opts.session ?? null);
8841
8973
  const editable = JSON.stringify(Boolean(opts.editable && opts.session));
8842
8974
  const generation = JSON.stringify(Boolean(opts.generation && opts.session));
8975
+ const editor = JSON.stringify(Boolean(opts.editor && opts.session));
8843
8976
  const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
8844
8977
  return `<!doctype html>
8845
8978
  <html lang="en">
@@ -9045,6 +9178,15 @@ function renderGallery(snapshot, opts = {}) {
9045
9178
  .job .acts button { padding:3px 9px; font-size:12px; }
9046
9179
  .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
9047
9180
  font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
9181
+ #tools { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; }
9182
+ #tools:empty { display:none; }
9183
+ .tool { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
9184
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
9185
+ .tool .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
9186
+ .tool .last { color:var(--dim); min-width:0; }
9187
+ .tool .acts { display:flex; gap:6px; }
9188
+ .tool .acts button { padding:3px 9px; font-size:12px; }
9189
+ .tool progress { grid-column:1 / -1; width:100%; height:6px; accent-color:var(--accent); }
9048
9190
  .budget { color:var(--dim); }
9049
9191
  .budget b { color:var(--text); }
9050
9192
  .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
@@ -9065,6 +9207,16 @@ function renderGallery(snapshot, opts = {}) {
9065
9207
  border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
9066
9208
  .sheet.review-host { width:min(1180px, 94vw); }
9067
9209
  .sheet.compare-host { width:min(1500px, 96vw); }
9210
+ .sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
9211
+ .sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
9212
+ .sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
9213
+ .sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
9214
+ .sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
9215
+ .sheet.editor-host .stage { position:relative; min-height:0; }
9216
+ .sheet.editor-host .stage iframe { position:absolute; inset:0; }
9217
+ .sheet.editor-host .loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--bg);
9218
+ color:var(--dim); text-align:center; padding:24px; }
9219
+ .sheet.editor-host .loading b { display:block; color:var(--text); margin-bottom:6px; }
9068
9220
  .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
9069
9221
  background:var(--panel); font-size:13px; flex-wrap:wrap; }
9070
9222
  .sheet .rbar span { color:var(--dim); }
@@ -9103,7 +9255,7 @@ function renderGallery(snapshot, opts = {}) {
9103
9255
  .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
9104
9256
  .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
9105
9257
  .tray > button { padding:5px 11px; font-size:12.5px; }
9106
- @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
9258
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
9107
9259
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
9108
9260
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
9109
9261
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
@@ -9151,6 +9303,7 @@ function renderGallery(snapshot, opts = {}) {
9151
9303
  <div class="totals" id="totals"></div>
9152
9304
  <div class="chips" id="chips"></div>
9153
9305
  <div id="jobs"></div>
9306
+ <div id="tools"></div>
9154
9307
  </header>
9155
9308
  <main id="root"></main>
9156
9309
  <footer>
@@ -9169,6 +9322,7 @@ const INITIAL = ${data};
9169
9322
  const SESSION = ${session};
9170
9323
  const EDITABLE = ${editable};
9171
9324
  const GENERATION = ${generation};
9325
+ const EDITOR = ${editor};
9172
9326
  let snap = INITIAL;
9173
9327
  const STATE_TONE = {
9174
9328
  ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
@@ -9336,6 +9490,92 @@ function renderJobs() {
9336
9490
  host.append(row);
9337
9491
  }
9338
9492
  }
9493
+ // ---- the in-browser editor (only when the server serves one) -------------
9494
+ // The build is a 46 MB web export fetched once into a user-level cache and
9495
+ // verified against hashes this PixelKiln release carries; the page only
9496
+ // reports and asks, the server downloads. Nothing is fetched without a click.
9497
+
9498
+ let ED = null;
9499
+ let edTimer = null;
9500
+ const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
9501
+ async function pollEditor() {
9502
+ if (!EDITOR) return;
9503
+ try {
9504
+ const res = await fetch('/api/editor', { cache: 'no-store' });
9505
+ if (!res.ok) throw new Error(await res.text());
9506
+ ED = await res.json();
9507
+ } catch (err) {
9508
+ ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
9509
+ }
9510
+ renderTools();
9511
+ clearTimeout(edTimer);
9512
+ if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
9513
+ else if (ui.open) renderDrawer();
9514
+ }
9515
+ async function installEditor() {
9516
+ try {
9517
+ const res = await fetch('/api/editor/install', {
9518
+ method: 'POST',
9519
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
9520
+ body: '{}',
9521
+ });
9522
+ if (!res.ok) throw new Error(await res.text());
9523
+ ED = await res.json();
9524
+ } catch (err) {
9525
+ ED = Object.assign({}, ED, { error: err.message });
9526
+ }
9527
+ renderTools();
9528
+ clearTimeout(edTimer);
9529
+ edTimer = setTimeout(pollEditor, 500);
9530
+ }
9531
+ function renderTools() {
9532
+ const host = $('tools');
9533
+ host.textContent = '';
9534
+ if (!EDITOR || !ED) return;
9535
+ const row = el('div', 'tool');
9536
+ const ph = el('span', 'ph');
9537
+ const last = el('span', 'last');
9538
+ const acts = el('div', 'acts');
9539
+ const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
9540
+ if (ED.installing) {
9541
+ const p = ED.installing;
9542
+ ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
9543
+ last.textContent = name + ': fetching ' + (p.file || 'the build') + ' \u2014 ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
9544
+ row.append(ph, last, acts);
9545
+ const bar = el('progress');
9546
+ if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
9547
+ row.append(bar);
9548
+ } else if (ED.installed) {
9549
+ ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
9550
+ last.textContent = name + ' is installed and verified; a record\u2019s Hand edit section opens sprites in it.';
9551
+ last.title = ED.dir || '';
9552
+ const b = el('button', null, 'Open editor');
9553
+ b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
9554
+ acts.append(b);
9555
+ row.append(ph, last, acts);
9556
+ } else if (ED.error) {
9557
+ ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
9558
+ last.textContent = name + ': ' + ED.error;
9559
+ const b = el('button', 'primary', 'Retry install');
9560
+ b.type = 'button'; b.onclick = installEditor;
9561
+ acts.append(b);
9562
+ row.append(ph, last, acts);
9563
+ } else if (!ED.release) {
9564
+ ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
9565
+ last.textContent = name + ': this PixelKiln version pins no published build.';
9566
+ row.append(ph, last, acts);
9567
+ } else {
9568
+ ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
9569
+ last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
9570
+ ED.release + ', verified against the hashes this version carries, and kept outside the project.';
9571
+ const b = el('button', 'primary', 'Install editor');
9572
+ b.type = 'button'; b.onclick = installEditor;
9573
+ acts.append(b);
9574
+ row.append(ph, last, acts);
9575
+ }
9576
+ host.append(row);
9577
+ }
9578
+
9339
9579
  function budgetLine() {
9340
9580
  const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
9341
9581
  const parts = [];
@@ -9442,8 +9682,183 @@ function openReview(jobId) {
9442
9682
  host.append(scrim, panel);
9443
9683
  }
9444
9684
  window.addEventListener('message', (e) => {
9445
- if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
9446
- setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
9685
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
9686
+ if (e.data.type === 'pixelkiln:review-applied') {
9687
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
9688
+ return;
9689
+ }
9690
+ if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
9691
+ });
9692
+
9693
+ // ---- the in-browser editor sheet -------------------------------------------
9694
+ // The page is the protocol host: it hands the editor the PNG and palette,
9695
+ // asks it for the image back, and does the authenticated write itself. The
9696
+ // iframe never sees the session token, and the server validates, sizes, and
9697
+ // declares the file the same way pixelkiln edit does.
9698
+
9699
+ let SHEET = null;
9700
+ const editorSource = (item) => item.edit && item.edit.exists ? item.edit
9701
+ : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
9702
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
9703
+ !(item.outputs.length > 1);
9704
+ const toBase64 = (buf) => new Promise((resolve, reject) => {
9705
+ const r = new FileReader();
9706
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
9707
+ r.onerror = () => reject(r.error);
9708
+ r.readAsDataURL(new Blob([buf]));
9709
+ });
9710
+ function sheetStatus(tone, text) {
9711
+ if (!SHEET) return;
9712
+ SHEET.status.textContent = '';
9713
+ SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
9714
+ }
9715
+ function sheetButtons() {
9716
+ if (!SHEET) return;
9717
+ const busy = !SHEET.opened || SHEET.pending !== null;
9718
+ SHEET.save.disabled = busy || !SHEET.dirty;
9719
+ SHEET.saveClose.disabled = busy;
9720
+ }
9721
+ function openEditorSheet(item) {
9722
+ if (SHEET) return;
9723
+ const src = editorSource(item);
9724
+ const host = $('dialog-host');
9725
+ host.textContent = '';
9726
+ const scrim = el('div', 'sheet-scrim');
9727
+ scrim.onclick = () => closeEditorSheet();
9728
+ const panel = el('div', 'sheet editor-host');
9729
+ const bar = el('div', 'rbar');
9730
+ const close = el('button', null, 'Close'); close.type = 'button';
9731
+ close.onclick = () => closeEditorSheet();
9732
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
9733
+ const status = el('span', 'st');
9734
+ const acts = el('div', 'acts');
9735
+ const msg = el('span', 'msg');
9736
+ const save = el('button', 'primary', 'Save to project'); save.type = 'button';
9737
+ save.onclick = () => requestEditorSave(false);
9738
+ const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
9739
+ saveClose.onclick = () => requestEditorSave(true);
9740
+ acts.append(msg, save, saveClose);
9741
+ bar.append(close, title, status, acts);
9742
+ const stage = el('div', 'stage');
9743
+ const frame = el('iframe');
9744
+ frame.title = 'Pixelorama';
9745
+ frame.src = ED.url;
9746
+ const loading = el('div', 'loading');
9747
+ const loadText = el('div');
9748
+ loadText.append(el('b', null, 'Loading the editor'), document.createTextNode('The first open compiles a 40 MB build; later opens come from the browser cache.'));
9749
+ loading.append(loadText);
9750
+ stage.append(frame, loading);
9751
+ panel.append(bar, stage);
9752
+ host.append(scrim, panel);
9753
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false };
9754
+ sheetStatus('cool', 'loading');
9755
+ sheetButtons();
9756
+ close.focus({ preventScroll: true });
9757
+ }
9758
+ // The edit file is what the editor gets; when a browser save kept the layered
9759
+ // project beside it, that goes along too and the editor restores the layers,
9760
+ // falling back to the flattened PNG if the file cannot be read.
9761
+ async function sendOpen() {
9762
+ const { item, source } = SHEET;
9763
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
9764
+ const projectUrl = source === item.edit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
9765
+ let png, pxo = null;
9766
+ try {
9767
+ const res = await fetch(source.url, { cache: 'no-store' });
9768
+ if (!res.ok) throw new Error(await res.text());
9769
+ png = await res.arrayBuffer();
9770
+ if (projectUrl) {
9771
+ const p = await fetch(projectUrl, { cache: 'no-store' });
9772
+ if (p.ok) pxo = await p.arrayBuffer();
9773
+ }
9774
+ } catch (err) {
9775
+ if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
9776
+ return;
9777
+ }
9778
+ if (!SHEET) return;
9779
+ const request = 'open-' + (++SHEET.requests);
9780
+ SHEET.sentProject = !!pxo;
9781
+ const message = {
9782
+ type: 'pixelkiln:open', request,
9783
+ asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
9784
+ png, palette: style ? style.palette : [],
9785
+ };
9786
+ const transfer = [png];
9787
+ if (pxo) { message.pxo = pxo; transfer.push(pxo); }
9788
+ SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
9789
+ sheetStatus('cool', 'opening');
9790
+ }
9791
+ function requestEditorSave(close) {
9792
+ if (!SHEET || !SHEET.opened || SHEET.pending) return;
9793
+ const request = 'save-' + (++SHEET.requests);
9794
+ SHEET.pending = { request, close };
9795
+ SHEET.msg.textContent = '';
9796
+ sheetStatus('cool', 'saving');
9797
+ sheetButtons();
9798
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
9799
+ }
9800
+ async function onEditorMessage(m) {
9801
+ switch (m.type) {
9802
+ case 'pixelkiln:ready':
9803
+ SHEET.ready = m;
9804
+ if (m.version !== ED.protocol) {
9805
+ SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
9806
+ sheetStatus('bad', 'mismatch');
9807
+ return;
9808
+ }
9809
+ sendOpen();
9810
+ break;
9811
+ case 'pixelkiln:opened':
9812
+ SHEET.opened = true;
9813
+ SHEET.loading.remove();
9814
+ if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file with its ' + (m.layers === 1 ? 'layer' : m.layers + ' layers') + ' restored');
9815
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file; its layer file could not be opened');
9816
+ else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
9817
+ sheetButtons();
9818
+ break;
9819
+ case 'pixelkiln:dirty':
9820
+ SHEET.dirty = !!m.dirty;
9821
+ if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
9822
+ sheetButtons();
9823
+ break;
9824
+ case 'pixelkiln:save': {
9825
+ const pending = SHEET.pending;
9826
+ if (!pending || pending.request !== m.request) return;
9827
+ try {
9828
+ const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
9829
+ if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
9830
+ await postHandEdit(SHEET.item, 'save-edit', body);
9831
+ const saved = snap.items.find((i) => i.id === SHEET.item.id);
9832
+ if (saved) SHEET.item = saved;
9833
+ SHEET.dirty = false;
9834
+ SHEET.pending = null;
9835
+ ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edit ? saved.edit.path : 'the edit') + '. mount and pack place it in place of the generated art.' };
9836
+ render();
9837
+ if (pending.close) { closeEditorSheet(true); return; }
9838
+ sheetStatus('ok', 'saved');
9839
+ } catch (err) {
9840
+ SHEET.pending = null;
9841
+ SHEET.msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh, then save again.' : '');
9842
+ sheetStatus('bad', 'not saved');
9843
+ }
9844
+ sheetButtons();
9845
+ break;
9846
+ }
9847
+ case 'pixelkiln:error':
9848
+ SHEET.msg.textContent = m.message || 'The editor reported an error.';
9849
+ if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
9850
+ break;
9851
+ }
9852
+ }
9853
+ function closeEditorSheet(force) {
9854
+ if (!SHEET) return;
9855
+ if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
9856
+ SHEET = null;
9857
+ $('dialog-host').textContent = '';
9858
+ refresh();
9859
+ }
9860
+ window.addEventListener('beforeunload', (e) => {
9861
+ if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
9447
9862
  });
9448
9863
 
9449
9864
  function visibleItems() {
@@ -10019,6 +10434,11 @@ function handEditSection(item) {
10019
10434
  row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
10020
10435
  if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
10021
10436
  row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
10437
+ if (item.editMeta) {
10438
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
10439
+ if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
10440
+ if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
10441
+ }
10022
10442
  s.append(dl);
10023
10443
  } else {
10024
10444
  s.append(el('div', 'state-dim', 'Touch it up in your own editor. The generated file stays untouched; the edit is a sibling file the manifest points at, and mount and pack place that instead.'));
@@ -10027,7 +10447,7 @@ function handEditSection(item) {
10027
10447
  const acts = el('div', 'hand-actions');
10028
10448
  const msg = el('span', 'msg');
10029
10449
  const go = async (label, action, extra, done) => {
10030
- const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
10450
+ const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
10031
10451
  b.onclick = async () => {
10032
10452
  b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
10033
10453
  try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
@@ -10035,8 +10455,20 @@ function handEditSection(item) {
10035
10455
  };
10036
10456
  acts.append(b);
10037
10457
  };
10458
+ if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
10459
+ if (ED.installed) {
10460
+ const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
10461
+ b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
10462
+ b.onclick = () => openEditorSheet(item);
10463
+ acts.append(b);
10464
+ } else if (ED.release && !ED.installing) {
10465
+ const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
10466
+ b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
10467
+ acts.append(b);
10468
+ }
10469
+ }
10038
10470
  if (item.edit) {
10039
- go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
10471
+ go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
10040
10472
  go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
10041
10473
  } else if (canStart) {
10042
10474
  go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file and opened it in your editor. Save there, then Refresh to see it here.');
@@ -10763,6 +11195,7 @@ document.addEventListener('keydown', (e) => {
10763
11195
  if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
10764
11196
  if (e.key === 'Escape') {
10765
11197
  if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
11198
+ if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
10766
11199
  if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
10767
11200
  if (ui.open) { e.preventDefault(); closeItem(); }
10768
11201
  return;
@@ -10784,6 +11217,7 @@ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
10784
11217
  render();
10785
11218
  if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
10786
11219
  if (GENERATION) pollJobs();
11220
+ if (EDITOR) pollEditor();
10787
11221
  if (ui.compare.length >= 2 && !ui.open) openCompare();
10788
11222
  </script>
10789
11223
  </body>
@@ -10792,7 +11226,16 @@ if (ui.compare.length >= 2 && !ui.open) openCompare();
10792
11226
  var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
10793
11227
 
10794
11228
  // src/gallery/server.ts
10795
- var MAX_EDIT_BYTES = 64 * 1024;
11229
+ var MAX_EDIT_BYTES = 48 * 1024 * 1024;
11230
+ var EDITOR_CSP = [
11231
+ "default-src 'self'",
11232
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
11233
+ "style-src 'self' 'unsafe-inline'",
11234
+ "img-src 'self' data: blob:",
11235
+ "worker-src 'self' blob:",
11236
+ "connect-src 'self'",
11237
+ "frame-ancestors 'self'"
11238
+ ].join("; ");
10796
11239
  async function readJsonBody(req) {
10797
11240
  const chunks = [];
10798
11241
  let bytes = 0;
@@ -10810,7 +11253,7 @@ async function readJsonBody(req) {
10810
11253
  async function serveGallery(opts) {
10811
11254
  const log2 = opts.onProgress ?? (() => {
10812
11255
  });
10813
- const session = opts.edit || opts.generate ? randomBytes(16).toString("hex") : null;
11256
+ const session = opts.edit || opts.generate || opts.editor ? randomBytes(16).toString("hex") : null;
10814
11257
  let media = /* @__PURE__ */ new Map();
10815
11258
  let loading = null;
10816
11259
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -10885,6 +11328,17 @@ async function serveGallery(opts) {
10885
11328
  }
10886
11329
  return;
10887
11330
  }
11331
+ if (req.method === "POST" && url2.pathname === "/api/editor/install") {
11332
+ if (!opts.editor || !session) return fail(405, "the in-browser editor is off for this gallery");
11333
+ try {
11334
+ const body = await guardedBody("editor installs");
11335
+ if (body === void 0) return;
11336
+ json(202, await opts.editor.install());
11337
+ } catch (err) {
11338
+ reportError("editor install", err);
11339
+ }
11340
+ return;
11341
+ }
10888
11342
  const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
10889
11343
  if (reviewMatch && opts.generate) {
10890
11344
  const [, jobId, rest = ""] = reviewMatch;
@@ -10930,7 +11384,7 @@ async function serveGallery(opts) {
10930
11384
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
10931
11385
  if (!asset) return fail(404, "no such review asset");
10932
11386
  try {
10933
- const bytes = await readFile16(asset.path);
11387
+ const bytes = await readFile17(asset.path);
10934
11388
  res.writeHead(200, {
10935
11389
  "Content-Type": asset.contentType,
10936
11390
  "Cache-Control": "no-store",
@@ -10948,6 +11402,32 @@ async function serveGallery(opts) {
10948
11402
  if (!opts.generate) return fail(404, "generation is off");
10949
11403
  return json(200, opts.generate.status());
10950
11404
  }
11405
+ if (url2.pathname === "/api/editor") {
11406
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
11407
+ try {
11408
+ return json(200, await opts.editor.status());
11409
+ } catch (err) {
11410
+ return reportError("editor status", err);
11411
+ }
11412
+ }
11413
+ const editorMatch = /^\/editor\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(url2.pathname);
11414
+ if (editorMatch) {
11415
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
11416
+ const file = await opts.editor.file(editorMatch[1], editorMatch[2]);
11417
+ if (!file) return fail(404, "that editor file is not installed; check /api/editor");
11418
+ res.writeHead(200, {
11419
+ "Content-Type": file.contentType,
11420
+ "Content-Length": file.bytes.length,
11421
+ // The release tag is in the path, so these bytes never change under
11422
+ // this URL; the 40 MB wasm should come from the browser cache.
11423
+ "Cache-Control": "public, max-age=31536000, immutable",
11424
+ "X-Content-Type-Options": "nosniff",
11425
+ "Cross-Origin-Resource-Policy": "same-origin",
11426
+ ...editorMatch[2] === "index.html" ? { "Content-Security-Policy": EDITOR_CSP } : {}
11427
+ });
11428
+ res.end(req.method === "HEAD" ? void 0 : file.bytes);
11429
+ return;
11430
+ }
10951
11431
  try {
10952
11432
  if (url2.pathname === "/") {
10953
11433
  const { snapshot } = await load();
@@ -10959,7 +11439,8 @@ async function serveGallery(opts) {
10959
11439
  res.end(renderGallery(snapshot, {
10960
11440
  ...session ? { session } : {},
10961
11441
  editable: Boolean(opts.edit),
10962
- generation: Boolean(opts.generate)
11442
+ generation: Boolean(opts.generate),
11443
+ editor: Boolean(opts.editor)
10963
11444
  }));
10964
11445
  return;
10965
11446
  }
@@ -10979,7 +11460,7 @@ async function serveGallery(opts) {
10979
11460
  if (!asset) return fail(404, "no such gallery media");
10980
11461
  let bytes;
10981
11462
  try {
10982
- bytes = await readFile16(asset.path);
11463
+ bytes = await readFile17(asset.path);
10983
11464
  } catch {
10984
11465
  return fail(404, "gallery media is no longer on disk; refresh the page");
10985
11466
  }
@@ -11025,16 +11506,16 @@ async function serveGallery(opts) {
11025
11506
 
11026
11507
  // src/gallery/generate.ts
11027
11508
  import { randomBytes as randomBytes2 } from "crypto";
11028
- import { z as z7 } from "zod";
11029
- var GenerateRequestSchema = z7.object({
11030
- project: z7.string().min(1).optional(),
11031
- keys: z7.array(z7.string().min(1)).min(1).max(500),
11509
+ import { z as z8 } from "zod";
11510
+ var GenerateRequestSchema = z8.object({
11511
+ project: z8.string().min(1).optional(),
11512
+ keys: z8.array(z8.string().min(1)).min(1).max(500),
11032
11513
  /** Regenerate work that is up to date, as `gen --force` does. */
11033
- force: z7.boolean().optional(),
11514
+ force: z8.boolean().optional(),
11034
11515
  /** Advance in-flight, review, or selected work without submitting anything. */
11035
- resume: z7.boolean().optional(),
11516
+ resume: z8.boolean().optional(),
11036
11517
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
11037
- refresh: z7.boolean().optional()
11518
+ refresh: z8.boolean().optional()
11038
11519
  }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
11039
11520
  var GenerateRequestError = class extends Error {
11040
11521
  constructor(message6, status = 400) {
@@ -11288,20 +11769,318 @@ function createGenerateHandlers(opts) {
11288
11769
  };
11289
11770
  }
11290
11771
 
11291
- // src/pipeline/salvage.ts
11292
- import { readFile as readFile17 } from "fs/promises";
11772
+ // src/gallery/editor.ts
11773
+ import { readFile as readFile19 } from "fs/promises";
11774
+ import path24 from "path";
11775
+
11776
+ // src/editor/install.ts
11777
+ import { createHash as createHash4 } from "crypto";
11293
11778
  import { existsSync as existsSync18 } from "fs";
11779
+ import { mkdir as mkdir7, readFile as readFile18, rename as rename8, rm as rm9, stat as stat4, writeFile as writeFile10 } from "fs/promises";
11780
+ import os2 from "os";
11294
11781
  import path23 from "path";
11782
+
11783
+ // tools/pixelorama-bridge/pin.json
11784
+ var pin_default = {
11785
+ pixelorama: "v1.2.2",
11786
+ godot: "4.7.2",
11787
+ extensionsApi: 9,
11788
+ protocol: 2,
11789
+ release: "editor-pixelorama-v1.2.2-pk.10",
11790
+ files: {
11791
+ "index.apple-touch-icon.png": {
11792
+ sha256: "ee5434873e0fed91e9a888c3ea5a8b79beac25999308be7a6eb41bf4ce640d27",
11793
+ bytes: 1036
11794
+ },
11795
+ "index.audio.position.worklet.js": {
11796
+ sha256: "be33985bc7160d6bf9646f259cd86b259cd67b02ccb297ee5c44f8ac84327bc8",
11797
+ bytes: 2973
11798
+ },
11799
+ "index.audio.worklet.js": {
11800
+ sha256: "5b476a9c9ce642c0ee4256436d1bc31d9c38f868aca0f9a8e2a57c18d2dec2a3",
11801
+ bytes: 7298
11802
+ },
11803
+ "index.html": {
11804
+ sha256: "ad777659ca39a22872437d44595b749c1637f83994d5f82d50dca861f2186b9c",
11805
+ bytes: 5442
11806
+ },
11807
+ "index.icon.png": {
11808
+ sha256: "42a327d23178ddc26cc97e3560ebca6bc7c575d2a1b12a4837f2462049ca5745",
11809
+ bytes: 1070
11810
+ },
11811
+ "index.js": {
11812
+ sha256: "33c94cb3175f3333b82e2a3be5e8e86f77986f0aa2042b1631f6367a4e5bb6ba",
11813
+ bytes: 279815
11814
+ },
11815
+ "index.pck": {
11816
+ sha256: "546a3f9851df0854c9f6052f29f1e559de79deb53b936d44a72c3475234c26ff",
11817
+ bytes: 6427868
11818
+ },
11819
+ "index.png": {
11820
+ sha256: "6e3ca3eb4a99e4c07bdd89ebc873b440ec0ffd66ce1955160da9e7ed80d538d7",
11821
+ bytes: 13558
11822
+ },
11823
+ "index.wasm": {
11824
+ sha256: "fc74679e3b97f76878947fcd4fbe1268cbfa6188182a2e33bbc3f5dc9bfa57d0",
11825
+ bytes: 39514754
11826
+ }
11827
+ }
11828
+ };
11829
+
11830
+ // src/editor/pin.ts
11831
+ var EDITOR_PIN = pin_default;
11832
+ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download";
11833
+
11834
+ // src/editor/install.ts
11835
+ var EditorInstallError = class extends Error {
11836
+ constructor(message6, file) {
11837
+ super(message6);
11838
+ this.file = file;
11839
+ this.name = "EditorInstallError";
11840
+ }
11841
+ file;
11842
+ };
11843
+ function editorToolsRoot() {
11844
+ const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
11845
+ if (override) return path23.resolve(override);
11846
+ if (process.platform === "darwin") return path23.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
11847
+ if (process.platform === "win32") {
11848
+ return path23.join(process.env.LOCALAPPDATA ?? path23.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
11849
+ }
11850
+ return path23.join(process.env.XDG_CACHE_HOME ?? path23.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
11851
+ }
11852
+ function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
11853
+ return path23.join(root, "pixelorama", pin.release ?? "unpublished");
11854
+ }
11855
+ function editorBaseUrl(pin = EDITOR_PIN) {
11856
+ const override = process.env.PIXELKILN_EDITOR_URL?.trim();
11857
+ if (override) return override.replace(/\/+$/, "");
11858
+ if (!pin.release) return null;
11859
+ return `${EDITOR_RELEASE_BASE}/${encodeURIComponent(pin.release)}`;
11860
+ }
11861
+ async function sha256Of(file) {
11862
+ try {
11863
+ return createHash4("sha256").update(await readFile18(file)).digest("hex");
11864
+ } catch {
11865
+ return null;
11866
+ }
11867
+ }
11868
+ async function editorStatus(opts = {}) {
11869
+ const pin = opts.pin ?? EDITOR_PIN;
11870
+ const dir = opts.dir ?? editorDir(pin);
11871
+ const missing = [];
11872
+ let installedBytes = 0;
11873
+ let totalBytes = 0;
11874
+ for (const [name, expected] of Object.entries(pin.files)) {
11875
+ totalBytes += expected.bytes;
11876
+ const file = path23.join(dir, name);
11877
+ const info = existsSync18(file) ? await stat4(file).catch(() => null) : null;
11878
+ if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
11879
+ installedBytes += expected.bytes;
11880
+ } else {
11881
+ missing.push(name);
11882
+ }
11883
+ }
11884
+ const pinned = Object.keys(pin.files).length > 0 && pin.release !== null;
11885
+ return {
11886
+ release: pin.release,
11887
+ dir,
11888
+ installed: pinned && missing.length === 0,
11889
+ missing: pinned ? missing : Object.keys(pin.files),
11890
+ totalBytes,
11891
+ installedBytes
11892
+ };
11893
+ }
11894
+ async function installEditor(opts = {}) {
11895
+ const pin = opts.pin ?? EDITOR_PIN;
11896
+ const dir = opts.dir ?? editorDir(pin);
11897
+ const baseUrl = opts.baseUrl ?? editorBaseUrl(pin);
11898
+ const doFetch = opts.fetch ?? fetch;
11899
+ const before = await editorStatus({ pin, dir });
11900
+ if (!pin.release || !Object.keys(pin.files).length) {
11901
+ throw new EditorInstallError(
11902
+ "this PixelKiln version pins no published editor build; upgrade, or set PIXELKILN_EDITOR_URL to a build you trust"
11903
+ );
11904
+ }
11905
+ if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
11906
+ await mkdir7(dir, { recursive: true });
11907
+ const downloaded = [];
11908
+ const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
11909
+ let fetchedBytes = before.installedBytes;
11910
+ const names = before.missing;
11911
+ for (const [index, name] of names.entries()) {
11912
+ const expected = pin.files[name];
11913
+ const report = (phase, received) => opts.onProgress?.({
11914
+ file: name,
11915
+ index,
11916
+ count: names.length,
11917
+ bytes: expected.bytes,
11918
+ totalBytes: before.totalBytes,
11919
+ fetchedBytes: fetchedBytes + received,
11920
+ phase
11921
+ });
11922
+ report("start", 0);
11923
+ const url = `${baseUrl}/${encodeURIComponent(name)}`;
11924
+ const response = await doFetch(url, { signal: opts.signal, redirect: "follow" });
11925
+ if (!response.ok) throw new EditorInstallError(`${name}: ${response.status} ${response.statusText} from ${url}`, name);
11926
+ const bytes = await readBody(response, expected.bytes, (received) => report("progress", received));
11927
+ if (bytes.length !== expected.bytes) {
11928
+ throw new EditorInstallError(`${name}: expected ${expected.bytes} bytes, got ${bytes.length}`, name);
11929
+ }
11930
+ const digest2 = createHash4("sha256").update(bytes).digest("hex");
11931
+ if (digest2 !== expected.sha256) {
11932
+ throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
11933
+ }
11934
+ const final = path23.join(dir, name);
11935
+ const tmp = `${final}.${process.pid}.part`;
11936
+ try {
11937
+ await writeFile10(tmp, bytes);
11938
+ await rename8(tmp, final);
11939
+ } finally {
11940
+ await rm9(tmp, { force: true });
11941
+ }
11942
+ fetchedBytes += bytes.length;
11943
+ downloaded.push(name);
11944
+ report("done", 0);
11945
+ }
11946
+ const after = await editorStatus({ pin, dir });
11947
+ if (!after.installed) {
11948
+ throw new EditorInstallError(`editor install is incomplete: ${after.missing.join(", ")}`);
11949
+ }
11950
+ return { ...after, downloaded, skipped };
11951
+ }
11952
+ async function readBody(response, expectedBytes, onBytes) {
11953
+ if (!response.body) return Buffer.from(await response.arrayBuffer());
11954
+ const chunks = [];
11955
+ let received = 0;
11956
+ const reader = response.body.getReader();
11957
+ for (; ; ) {
11958
+ const { done, value } = await reader.read();
11959
+ if (done) break;
11960
+ chunks.push(value);
11961
+ received += value.length;
11962
+ if (received > expectedBytes) {
11963
+ await reader.cancel().catch(() => {
11964
+ });
11965
+ break;
11966
+ }
11967
+ onBytes(received);
11968
+ }
11969
+ return Buffer.concat(chunks);
11970
+ }
11971
+ var EDITOR_CONTENT_TYPES = {
11972
+ ".html": "text/html; charset=utf-8",
11973
+ ".js": "text/javascript; charset=utf-8",
11974
+ ".wasm": "application/wasm",
11975
+ ".pck": "application/octet-stream",
11976
+ ".png": "image/png",
11977
+ ".json": "application/json; charset=utf-8"
11978
+ };
11979
+ function editorContentType(name) {
11980
+ return EDITOR_CONTENT_TYPES[path23.extname(name).toLowerCase()] ?? null;
11981
+ }
11982
+
11983
+ // src/gallery/editor.ts
11984
+ function editorRoute(release, name = "index.html") {
11985
+ return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
11986
+ }
11987
+ function createGalleryEditorHandlers(opts = {}) {
11988
+ const pin = opts.pin ?? EDITOR_PIN;
11989
+ const dir = opts.dir ?? editorDir(pin);
11990
+ const log2 = opts.onProgress ?? (() => {
11991
+ });
11992
+ let verified = null;
11993
+ let checking = null;
11994
+ let installing = null;
11995
+ let running = null;
11996
+ let error = null;
11997
+ const check = () => {
11998
+ if (!checking) {
11999
+ checking = editorStatus({ pin, dir }).then((status) => {
12000
+ verified = status;
12001
+ return status;
12002
+ }).finally(() => {
12003
+ checking = null;
12004
+ });
12005
+ }
12006
+ return checking;
12007
+ };
12008
+ const describe = (status) => ({
12009
+ ...status,
12010
+ pixelorama: pin.pixelorama,
12011
+ protocol: pin.protocol,
12012
+ url: status.installed && pin.release ? editorRoute(pin.release) : null,
12013
+ installing,
12014
+ error
12015
+ });
12016
+ return {
12017
+ release: pin.release,
12018
+ status: async () => describe(verified ?? await check()),
12019
+ install: async () => {
12020
+ if (!running) {
12021
+ error = null;
12022
+ installing = { file: "", index: 0, count: 0, bytes: 0, totalBytes: 0, fetchedBytes: 0, phase: "start" };
12023
+ log2(` installing the editor (Pixelorama ${pin.pixelorama}) into ${dir}`);
12024
+ running = installEditor({
12025
+ pin,
12026
+ dir,
12027
+ baseUrl: opts.baseUrl ?? editorBaseUrl(pin) ?? void 0,
12028
+ ...opts.fetch ? { fetch: opts.fetch } : {},
12029
+ onProgress: (progress) => {
12030
+ installing = progress;
12031
+ }
12032
+ }).then((result) => {
12033
+ verified = result;
12034
+ const mb = (result.totalBytes / 1e6).toFixed(1);
12035
+ log2(` editor ready: ${result.downloaded.length} file(s) fetched, ${result.skipped.length} already present (${mb} MB)`);
12036
+ }).catch((err) => {
12037
+ error = err instanceof Error ? err.message : String(err);
12038
+ verified = null;
12039
+ log2(` editor install failed: ${error}`);
12040
+ }).finally(() => {
12041
+ installing = null;
12042
+ running = null;
12043
+ });
12044
+ }
12045
+ return describe(verified ?? await check());
12046
+ },
12047
+ file: async (release, name) => {
12048
+ if (!pin.release || release !== pin.release) return null;
12049
+ const expected = pin.files[name];
12050
+ const contentType = editorContentType(name);
12051
+ if (!expected || !contentType) return null;
12052
+ const status = verified ?? await check();
12053
+ if (!status.installed) return null;
12054
+ let bytes;
12055
+ try {
12056
+ bytes = await readFile19(path24.join(dir, name));
12057
+ } catch {
12058
+ verified = null;
12059
+ return null;
12060
+ }
12061
+ if (bytes.length !== expected.bytes) {
12062
+ verified = null;
12063
+ return null;
12064
+ }
12065
+ return { bytes, contentType };
12066
+ }
12067
+ };
12068
+ }
12069
+
12070
+ // src/pipeline/salvage.ts
12071
+ import { readFile as readFile20 } from "fs/promises";
12072
+ import { existsSync as existsSync19 } from "fs";
12073
+ import path25 from "path";
11295
12074
  function providerClaimId(provider, objectId) {
11296
12075
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
11297
12076
  }
11298
12077
  async function loadClaims(lockPaths, opts = {}) {
11299
12078
  const claimed = /* @__PURE__ */ new Set();
11300
12079
  for (const p of lockPaths) {
11301
- if (!existsSync18(p)) throw new Error(`Claim lockfile not found: ${p}`);
12080
+ if (!existsSync19(p)) throw new Error(`Claim lockfile not found: ${p}`);
11302
12081
  let parsed;
11303
12082
  try {
11304
- parsed = parseLock(JSON.parse(await readFile17(p, "utf8")));
12083
+ parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
11305
12084
  } catch {
11306
12085
  throw new Error(`Claim lockfile is malformed: ${p}`);
11307
12086
  }
@@ -11349,20 +12128,20 @@ function matchStyleByPattern(prompt, manifest) {
11349
12128
  return null;
11350
12129
  }
11351
12130
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
11352
- const own = path23.resolve(ownManifestPath);
12131
+ const own = path25.resolve(ownManifestPath);
11353
12132
  const siblingManifestPaths = [
11354
12133
  .../* @__PURE__ */ new Set([
11355
12134
  ...workspaceManifestPaths,
11356
- ...claimPaths.map((c) => path23.join(path23.dirname(path23.resolve(c)), "pixelkiln.manifest.json"))
12135
+ ...claimPaths.map((c) => path25.join(path25.dirname(path25.resolve(c)), "pixelkiln.manifest.json"))
11357
12136
  ])
11358
12137
  ];
11359
12138
  const siblings = [];
11360
12139
  for (const siblingManifestPath of siblingManifestPaths) {
11361
- if (path23.resolve(siblingManifestPath) === own) continue;
11362
- if (!existsSync18(siblingManifestPath)) continue;
12140
+ if (path25.resolve(siblingManifestPath) === own) continue;
12141
+ if (!existsSync19(siblingManifestPath)) continue;
11363
12142
  try {
11364
12143
  const { manifest } = await loadManifest(siblingManifestPath);
11365
- siblings.push({ label: path23.basename(path23.dirname(siblingManifestPath)), manifest });
12144
+ siblings.push({ label: path25.basename(path25.dirname(siblingManifestPath)), manifest });
11366
12145
  } catch {
11367
12146
  }
11368
12147
  }
@@ -11581,15 +12360,15 @@ async function workspaceStatus(ws, dir) {
11581
12360
  }
11582
12361
 
11583
12362
  // src/pipeline/cache-health.ts
11584
- import { existsSync as existsSync19 } from "fs";
11585
- import { readFile as readFile18, readdir as readdir2, rm as rm8 } from "fs/promises";
11586
- import path24 from "path";
12363
+ import { existsSync as existsSync20 } from "fs";
12364
+ import { readFile as readFile21, readdir as readdir2, rm as rm10 } from "fs/promises";
12365
+ import path26 from "path";
11587
12366
  async function inspectCaches(lock, lockPath, options = {}) {
11588
- if (options.prune && !existsSync19(lockPath)) {
11589
- throw new Error(`Refusing to prune without an existing lockfile at ${path24.resolve(lockPath)}`);
12367
+ if (options.prune && !existsSync20(lockPath)) {
12368
+ throw new Error(`Refusing to prune without an existing lockfile at ${path26.resolve(lockPath)}`);
11590
12369
  }
11591
- const contentDir = path24.resolve(path24.dirname(lockPath), ".pixelkiln", "cache");
11592
- const remotePath = path24.resolve(cachePathFor(lockPath));
12370
+ const contentDir = path26.resolve(path26.dirname(lockPath), ".pixelkiln", "cache");
12371
+ const remotePath = path26.resolve(cachePathFor(lockPath));
11593
12372
  const referenced = new Set(
11594
12373
  Object.values(lock.entries).flatMap((entry) => entry.outputs.map((output) => output.sha256))
11595
12374
  );
@@ -11603,7 +12382,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11603
12382
  ]);
11604
12383
  for (const name of names) {
11605
12384
  try {
11606
- await rm8(path24.join(contentDir, name), { force: true });
12385
+ await rm10(path26.join(contentDir, name), { force: true });
11607
12386
  removed.contentFiles++;
11608
12387
  } catch {
11609
12388
  }
@@ -11612,7 +12391,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11612
12391
  await saveCache(remotePath, { version: 1, hashes: {} });
11613
12392
  removed.resetRemoteHashCache = true;
11614
12393
  } else if (remoteHashes.invalidIds.length) {
11615
- const cache = parseCache(JSON.parse(await readFile18(remotePath, "utf8")));
12394
+ const cache = parseCache(JSON.parse(await readFile21(remotePath, "utf8")));
11616
12395
  for (const id of remoteHashes.invalidIds) delete cache.hashes[id];
11617
12396
  removed.remoteHashEntries = remoteHashes.invalidIds.length;
11618
12397
  await saveCache(remotePath, cache);
@@ -11630,7 +12409,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11630
12409
  async function inspectContentCache(contentDir, referenced) {
11631
12410
  const report = {
11632
12411
  path: contentDir,
11633
- exists: existsSync19(contentDir),
12412
+ exists: existsSync20(contentDir),
11634
12413
  files: 0,
11635
12414
  bytes: 0,
11636
12415
  valid: 0,
@@ -11651,12 +12430,12 @@ async function inspectContentCache(contentDir, referenced) {
11651
12430
  continue;
11652
12431
  }
11653
12432
  report.files++;
11654
- const file = path24.join(contentDir, entry.name);
12433
+ const file = path26.join(contentDir, entry.name);
11655
12434
  const mediaType = mediaTypeFromExtension(entry.name);
11656
12435
  const expected = mediaType ? entry.name.slice(0, -4) : "";
11657
12436
  let bytes;
11658
12437
  try {
11659
- bytes = await readFile18(file);
12438
+ bytes = await readFile21(file);
11660
12439
  report.bytes += bytes.length;
11661
12440
  } catch (err) {
11662
12441
  report.invalid.push({
@@ -11694,7 +12473,7 @@ async function inspectContentCache(contentDir, referenced) {
11694
12473
  async function inspectRemoteHashCache(remotePath) {
11695
12474
  const report = {
11696
12475
  path: remotePath,
11697
- exists: existsSync19(remotePath),
12476
+ exists: existsSync20(remotePath),
11698
12477
  entries: 0,
11699
12478
  valid: 0,
11700
12479
  invalidIds: [],
@@ -11703,7 +12482,7 @@ async function inspectRemoteHashCache(remotePath) {
11703
12482
  if (!report.exists) return report;
11704
12483
  let cache;
11705
12484
  try {
11706
- cache = parseCache(JSON.parse(await readFile18(remotePath, "utf8")));
12485
+ cache = parseCache(JSON.parse(await readFile21(remotePath, "utf8")));
11707
12486
  } catch (err) {
11708
12487
  report.error = err instanceof Error ? err.message : String(err);
11709
12488
  return report;
@@ -11974,8 +12753,8 @@ function isRecord2(value) {
11974
12753
  }
11975
12754
 
11976
12755
  // src/pick/salvage-server.ts
11977
- import { mkdir as mkdir7, writeFile as writeFile9, readFile as readFile19 } from "fs/promises";
11978
- import path25 from "path";
12756
+ import { mkdir as mkdir8, writeFile as writeFile11, readFile as readFile22 } from "fs/promises";
12757
+ import path27 from "path";
11979
12758
 
11980
12759
  // src/pick/salvage-sheet.ts
11981
12760
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -12140,7 +12919,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12140
12919
  });
12141
12920
  const html = renderSalvageSheet(orphans, {
12142
12921
  styleId: ctx.styleId,
12143
- importDir: path25.relative(process.cwd(), ctx.importDir) || "."
12922
+ importDir: path27.relative(process.cwd(), ctx.importDir) || "."
12144
12923
  });
12145
12924
  const byId = new Map(orphans.map((o) => [o.id, o]));
12146
12925
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -12169,10 +12948,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12169
12948
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
12170
12949
  decodePng(buf);
12171
12950
  const assetId = idFromPrompt(orphan.prompt, taken);
12172
- const rel = path25.join("_salvaged", `${assetId}.png`);
12173
- const outFile = path25.resolve(ctx.importDir, rel);
12174
- await mkdir7(path25.dirname(outFile), { recursive: true });
12175
- await writeFile9(outFile, buf);
12951
+ const rel = path27.join("_salvaged", `${assetId}.png`);
12952
+ const outFile = path27.resolve(ctx.importDir, rel);
12953
+ await mkdir8(path27.dirname(outFile), { recursive: true });
12954
+ await writeFile11(outFile, buf);
12176
12955
  ctx.manifest.assets[assetId] = {
12177
12956
  prompt: orphan.prompt,
12178
12957
  promptByStyle: {},
@@ -12204,7 +12983,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12204
12983
  sourceUrl: durableSource,
12205
12984
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
12206
12985
  outputs: [{
12207
- path: portableOutputPath(outFile, path25.dirname(ctx.manifestPath)),
12986
+ path: portableOutputPath(outFile, path27.dirname(ctx.manifestPath)),
12208
12987
  sha256: sha256(buf)
12209
12988
  }],
12210
12989
  submittedAt: orphan.createdAt,
@@ -12228,9 +13007,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12228
13007
  }
12229
13008
  }
12230
13009
  await applyTags(provider, decisions, existingTags, { onProgress: log2 });
12231
- const raw = JSON.parse(await readFile19(ctx.manifestPath, "utf8"));
13010
+ const raw = JSON.parse(await readFile22(ctx.manifestPath, "utf8"));
12232
13011
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
12233
- await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
13012
+ await writeFile11(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12234
13013
  await saveLock(ctx.lockPath, ctx.lock);
12235
13014
  return result;
12236
13015
  }
@@ -12238,51 +13017,51 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12238
13017
  }
12239
13018
 
12240
13019
  // src/recipes.ts
12241
- import path26 from "path";
12242
- import { existsSync as existsSync20 } from "fs";
12243
- import { readdir as readdir3, readFile as readFile20 } from "fs/promises";
13020
+ import path28 from "path";
13021
+ import { existsSync as existsSync21 } from "fs";
13022
+ import { readdir as readdir3, readFile as readFile23 } from "fs/promises";
12244
13023
  import { fileURLToPath } from "url";
12245
- import { z as z8 } from "zod";
13024
+ import { z as z9 } from "zod";
12246
13025
  var SHA256_RE = /^[0-9a-f]{64}$/;
12247
13026
  var RECIPE_ID_RE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
12248
13027
  var VERSION_RE = /^\d+\.\d+\.\d+$/;
12249
13028
  var RECIPE_FILE = "pixelkiln.recipe.json";
12250
13029
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
12251
- var PortablePathSchema = z8.string().min(1).refine((value) => {
12252
- if (value.includes("\\") || path26.posix.isAbsolute(value)) return false;
13030
+ var PortablePathSchema = z9.string().min(1).refine((value) => {
13031
+ if (value.includes("\\") || path28.posix.isAbsolute(value)) return false;
12253
13032
  const parts = value.split("/");
12254
13033
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
12255
13034
  }, "expected a portable relative path without '.', '..', or backslashes");
12256
- var RecipeBindingSchema = z8.object({
12257
- nodeId: z8.string().min(1),
12258
- input: z8.string().min(1)
13035
+ var RecipeBindingSchema = z9.object({
13036
+ nodeId: z9.string().min(1),
13037
+ input: z9.string().min(1)
12259
13038
  }).strict();
12260
- var RecipeSchema = z8.object({
12261
- $schema: z8.string().url().optional(),
12262
- format: z8.literal("pixelkiln-recipe"),
12263
- schemaVersion: z8.literal(1),
12264
- id: z8.string().regex(RECIPE_ID_RE, "expected provider/name"),
12265
- version: z8.string().regex(VERSION_RE, "expected x.y.z"),
12266
- provider: z8.string().regex(/^[a-z0-9][a-z0-9-]*$/),
12267
- summary: z8.string().min(1).max(240),
12268
- files: z8.array(z8.object({
13039
+ var RecipeSchema = z9.object({
13040
+ $schema: z9.string().url().optional(),
13041
+ format: z9.literal("pixelkiln-recipe"),
13042
+ schemaVersion: z9.literal(1),
13043
+ id: z9.string().regex(RECIPE_ID_RE, "expected provider/name"),
13044
+ version: z9.string().regex(VERSION_RE, "expected x.y.z"),
13045
+ provider: z9.string().regex(/^[a-z0-9][a-z0-9-]*$/),
13046
+ summary: z9.string().min(1).max(240),
13047
+ files: z9.array(z9.object({
12269
13048
  path: PortablePathSchema,
12270
- role: z8.enum(["workflow", "reference"]),
12271
- sha256: z8.string().regex(SHA256_RE)
13049
+ role: z9.enum(["workflow", "reference"]),
13050
+ sha256: z9.string().regex(SHA256_RE)
12272
13051
  }).strict()).default([]),
12273
- models: z8.array(z8.object({
13052
+ models: z9.array(z9.object({
12274
13053
  path: PortablePathSchema,
12275
- sha256: z8.string().regex(SHA256_RE),
12276
- source: z8.string().url(),
12277
- license: z8.string().min(1)
13054
+ sha256: z9.string().regex(SHA256_RE),
13055
+ source: z9.string().url(),
13056
+ license: z9.string().min(1)
12278
13057
  }).strict()).default([]),
12279
- styleId: z8.string().regex(/^[a-z0-9][a-z0-9-]*$/),
13058
+ styleId: z9.string().regex(/^[a-z0-9][a-z0-9-]*$/),
12280
13059
  style: StyleSchema,
12281
- workflow: z8.object({
13060
+ workflow: z9.object({
12282
13061
  path: PortablePathSchema,
12283
- outputNodeId: z8.string().min(1),
12284
- numImages: z8.number().int().min(1).max(16),
12285
- bindings: z8.object({
13062
+ outputNodeId: z9.string().min(1),
13063
+ numImages: z9.number().int().min(1).max(16),
13064
+ bindings: z9.object({
12286
13065
  prompt: RecipeBindingSchema,
12287
13066
  width: RecipeBindingSchema.optional(),
12288
13067
  height: RecipeBindingSchema.optional(),
@@ -12293,67 +13072,67 @@ var RecipeSchema = z8.object({
12293
13072
  strength: RecipeBindingSchema.optional()
12294
13073
  }).strict()
12295
13074
  }).strict().optional(),
12296
- quality: z8.object({
12297
- stage: z8.enum(["composition-source", "production-candidate"]),
12298
- workingCanvas: z8.object({
12299
- width: z8.number().int().min(16),
12300
- height: z8.number().int().min(16)
13075
+ quality: z9.object({
13076
+ stage: z9.enum(["composition-source", "production-candidate"]),
13077
+ workingCanvas: z9.object({
13078
+ width: z9.number().int().min(16),
13079
+ height: z9.number().int().min(16)
12301
13080
  }).strict(),
12302
- recommendedNativeSize: z8.object({
12303
- min: z8.number().int().min(1),
12304
- max: z8.number().int().min(1)
13081
+ recommendedNativeSize: z9.object({
13082
+ min: z9.number().int().min(1),
13083
+ max: z9.number().int().min(1)
12305
13084
  }).strict(),
12306
- paletteColors: z8.object({
12307
- min: z8.number().int().min(1),
12308
- max: z8.number().int().min(1)
13085
+ paletteColors: z9.object({
13086
+ min: z9.number().int().min(1),
13087
+ max: z9.number().int().min(1)
12309
13088
  }).strict(),
12310
- background: z8.enum(["opaque", "transparent", "full-bleed"]),
12311
- checks: z8.array(z8.enum([
13089
+ background: z9.enum(["opaque", "transparent", "full-bleed"]),
13090
+ checks: z9.array(z9.enum([
12312
13091
  "prompt-coverage",
12313
13092
  "native-grid",
12314
13093
  "final-palette",
12315
13094
  "alpha-edge",
12316
13095
  "human-1x"
12317
13096
  ])).min(1),
12318
- notes: z8.array(z8.string().min(1)).default([])
13097
+ notes: z9.array(z9.string().min(1)).default([])
12319
13098
  }).strict(),
12320
- integrity: z8.object({
12321
- algorithm: z8.literal("sha256"),
12322
- digest: z8.string().regex(SHA256_RE)
13099
+ integrity: z9.object({
13100
+ algorithm: z9.literal("sha256"),
13101
+ digest: z9.string().regex(SHA256_RE)
12323
13102
  }).strict()
12324
13103
  }).strict().superRefine((recipe, context) => {
12325
13104
  if (recipe.style.provider && recipe.style.provider !== recipe.provider) {
12326
13105
  context.addIssue({
12327
- code: z8.ZodIssueCode.custom,
13106
+ code: z9.ZodIssueCode.custom,
12328
13107
  path: ["style", "provider"],
12329
13108
  message: "must match the recipe provider"
12330
13109
  });
12331
13110
  }
12332
13111
  const filePaths = recipe.files.map((file) => file.path);
12333
13112
  if (new Set(filePaths).size !== filePaths.length) {
12334
- context.addIssue({ code: z8.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
13113
+ context.addIssue({ code: z9.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
12335
13114
  }
12336
13115
  const modelPaths = recipe.models.map((model) => model.path);
12337
13116
  if (new Set(modelPaths).size !== modelPaths.length) {
12338
- context.addIssue({ code: z8.ZodIssueCode.custom, path: ["models"], message: "duplicate model path" });
13117
+ context.addIssue({ code: z9.ZodIssueCode.custom, path: ["models"], message: "duplicate model path" });
12339
13118
  }
12340
13119
  if (filePaths.includes(RECIPE_FILE)) {
12341
13120
  context.addIssue({
12342
- code: z8.ZodIssueCode.custom,
13121
+ code: z9.ZodIssueCode.custom,
12343
13122
  path: ["files"],
12344
13123
  message: `${RECIPE_FILE} cannot hash itself`
12345
13124
  });
12346
13125
  }
12347
13126
  if (recipe.quality.recommendedNativeSize.min > recipe.quality.recommendedNativeSize.max) {
12348
13127
  context.addIssue({
12349
- code: z8.ZodIssueCode.custom,
13128
+ code: z9.ZodIssueCode.custom,
12350
13129
  path: ["quality", "recommendedNativeSize"],
12351
13130
  message: "min cannot exceed max"
12352
13131
  });
12353
13132
  }
12354
13133
  if (recipe.quality.paletteColors.min > recipe.quality.paletteColors.max) {
12355
13134
  context.addIssue({
12356
- code: z8.ZodIssueCode.custom,
13135
+ code: z9.ZodIssueCode.custom,
12357
13136
  path: ["quality", "paletteColors"],
12358
13137
  message: "min cannot exceed max"
12359
13138
  });
@@ -12362,7 +13141,7 @@ var RecipeSchema = z8.object({
12362
13141
  (file) => file.path === recipe.workflow.path && file.role === "workflow"
12363
13142
  )) {
12364
13143
  context.addIssue({
12365
- code: z8.ZodIssueCode.custom,
13144
+ code: z9.ZodIssueCode.custom,
12366
13145
  path: ["workflow", "path"],
12367
13146
  message: "must name a file whose role is workflow"
12368
13147
  });
@@ -12370,7 +13149,7 @@ var RecipeSchema = z8.object({
12370
13149
  if (recipe.provider === "comfyui") {
12371
13150
  if (!recipe.workflow) {
12372
13151
  context.addIssue({
12373
- code: z8.ZodIssueCode.custom,
13152
+ code: z9.ZodIssueCode.custom,
12374
13153
  path: ["workflow"],
12375
13154
  message: "ComfyUI recipes require workflow metadata"
12376
13155
  });
@@ -12379,7 +13158,7 @@ var RecipeSchema = z8.object({
12379
13158
  const options = recipe.style.providerOptions.comfyui;
12380
13159
  if (!options || typeof options !== "object" || Array.isArray(options)) {
12381
13160
  context.addIssue({
12382
- code: z8.ZodIssueCode.custom,
13161
+ code: z9.ZodIssueCode.custom,
12383
13162
  path: ["style", "providerOptions", "comfyui"],
12384
13163
  message: "ComfyUI recipes require ComfyUI provider options"
12385
13164
  });
@@ -12393,7 +13172,7 @@ var RecipeSchema = z8.object({
12393
13172
  };
12394
13173
  if (JSON.stringify(canonical3(options)) !== JSON.stringify(canonical3(expected))) {
12395
13174
  context.addIssue({
12396
- code: z8.ZodIssueCode.custom,
13175
+ code: z9.ZodIssueCode.custom,
12397
13176
  path: ["style", "providerOptions", "comfyui"],
12398
13177
  message: "must match workflow metadata and use {{recipeDir}} for workflowFile"
12399
13178
  });
@@ -12425,10 +13204,10 @@ function message4(error) {
12425
13204
  return error instanceof Error ? error.message : String(error);
12426
13205
  }
12427
13206
  async function readRecipeFile(recipePath, bundled) {
12428
- const absolute = path26.resolve(recipePath);
13207
+ const absolute = path28.resolve(recipePath);
12429
13208
  let raw;
12430
13209
  try {
12431
- raw = JSON.parse(await readFile20(absolute, "utf8"));
13210
+ raw = JSON.parse(await readFile23(absolute, "utf8"));
12432
13211
  } catch (error) {
12433
13212
  throw new Error(`Could not read recipe ${absolute}: ${message4(error)}`, { cause: error });
12434
13213
  }
@@ -12439,13 +13218,13 @@ async function readRecipeFile(recipePath, bundled) {
12439
13218
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
12440
13219
  );
12441
13220
  }
12442
- return { recipe: parsed.data, path: absolute, dir: path26.dirname(absolute), bundled };
13221
+ return { recipe: parsed.data, path: absolute, dir: path28.dirname(absolute), bundled };
12443
13222
  }
12444
13223
  async function walkRecipeFiles(root) {
12445
- if (!existsSync20(root)) return [];
13224
+ if (!existsSync21(root)) return [];
12446
13225
  const out = [];
12447
13226
  for (const entry of await readdir3(root, { withFileTypes: true })) {
12448
- const resolved = path26.join(root, entry.name);
13227
+ const resolved = path28.join(root, entry.name);
12449
13228
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
12450
13229
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
12451
13230
  }
@@ -12456,7 +13235,7 @@ function bundledRecipeRoot() {
12456
13235
  }
12457
13236
  async function listBundledRecipes(root = bundledRecipeRoot()) {
12458
13237
  const recipes = await Promise.all(
12459
- (await walkRecipeFiles(path26.resolve(root))).map((file) => readRecipeFile(file, true))
13238
+ (await walkRecipeFiles(path28.resolve(root))).map((file) => readRecipeFile(file, true))
12460
13239
  );
12461
13240
  return recipes.sort(
12462
13241
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -12479,9 +13258,9 @@ function parseSelector(selector) {
12479
13258
  return { id: match[1], version: match[2] };
12480
13259
  }
12481
13260
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12482
- const local = path26.resolve(target);
12483
- if (existsSync20(local)) {
12484
- const recipePath = path26.basename(local) === RECIPE_FILE ? local : path26.join(local, RECIPE_FILE);
13261
+ const local = path28.resolve(target);
13262
+ if (existsSync21(local)) {
13263
+ const recipePath = path28.basename(local) === RECIPE_FILE ? local : path28.join(local, RECIPE_FILE);
12485
13264
  return readRecipeFile(recipePath, false);
12486
13265
  }
12487
13266
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -12495,8 +13274,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12495
13274
  return matches[0];
12496
13275
  }
12497
13276
  async function verifyFile(root, file) {
12498
- const absolute = path26.resolve(root, ...file.path.split("/"));
12499
- if (!existsSync20(absolute)) {
13277
+ const absolute = path28.resolve(root, ...file.path.split("/"));
13278
+ if (!existsSync21(absolute)) {
12500
13279
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
12501
13280
  }
12502
13281
  const actualSha256 = await sha256File(absolute);
@@ -12511,7 +13290,7 @@ async function verifyRecipe(target, options = {}) {
12511
13290
  const loaded = await resolveRecipe(target, options.bundledRoot);
12512
13291
  const actualIntegrity = recipeDigest(loaded.recipe);
12513
13292
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
12514
- const modelRoot = options.modelRoot ? path26.resolve(options.modelRoot) : null;
13293
+ const modelRoot = options.modelRoot ? path28.resolve(options.modelRoot) : null;
12515
13294
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
12516
13295
  ...await verifyFile(modelRoot, model),
12517
13296
  source: model.source,
@@ -12545,7 +13324,7 @@ async function verifyRecipe(target, options = {}) {
12545
13324
  };
12546
13325
  }
12547
13326
  function renderStyle(style, destination, cwd) {
12548
- const portableDir = path26.relative(cwd, destination).split(path26.sep).join("/") || ".";
13327
+ const portableDir = path28.relative(cwd, destination).split(path28.sep).join("/") || ".";
12549
13328
  const replace = (value) => {
12550
13329
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
12551
13330
  if (Array.isArray(value)) return value.map(replace);
@@ -12557,25 +13336,25 @@ function renderStyle(style, destination, cwd) {
12557
13336
  return replace(style);
12558
13337
  }
12559
13338
  async function installRecipe(target, options = {}) {
12560
- const cwd = path26.resolve(options.cwd ?? process.cwd());
13339
+ const cwd = path28.resolve(options.cwd ?? process.cwd());
12561
13340
  const loaded = await resolveRecipe(target, options.bundledRoot);
12562
13341
  const verification = await verifyRecipe(loaded.path);
12563
13342
  if (!verification.ok) {
12564
13343
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
12565
13344
  }
12566
- const destination = options.out ? path26.resolve(cwd, options.out) : path26.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
13345
+ const destination = options.out ? path28.resolve(cwd, options.out) : path28.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
12567
13346
  const sources = [
12568
13347
  { path: RECIPE_FILE, source: loaded.path },
12569
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path26.join(loaded.dir, ...file.path.split("/")) }))
13348
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path28.join(loaded.dir, ...file.path.split("/")) }))
12570
13349
  ];
12571
13350
  const files = await Promise.all(sources.map(async (file) => ({
12572
- path: path26.join(destination, ...file.path.split("/")),
12573
- data: await readFile20(file.source)
13351
+ path: path28.join(destination, ...file.path.split("/")),
13352
+ data: await readFile23(file.source)
12574
13353
  })));
12575
13354
  if (!options.force) {
12576
13355
  for (const file of files) {
12577
- if (!existsSync20(file.path)) continue;
12578
- const current = await readFile20(file.path);
13356
+ if (!existsSync21(file.path)) continue;
13357
+ const current = await readFile23(file.path);
12579
13358
  if (!current.equals(file.data)) {
12580
13359
  throw new Error(
12581
13360
  `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`
@@ -12598,15 +13377,15 @@ async function installRecipe(target, options = {}) {
12598
13377
  }
12599
13378
 
12600
13379
  // src/pipeline/quality-regression.ts
12601
- import path27 from "path";
12602
- import { existsSync as existsSync21 } from "fs";
12603
- import { readFile as readFile21 } from "fs/promises";
12604
- import { z as z9 } from "zod";
13380
+ import path29 from "path";
13381
+ import { existsSync as existsSync22 } from "fs";
13382
+ import { readFile as readFile24 } from "fs/promises";
13383
+ import { z as z10 } from "zod";
12605
13384
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
12606
13385
  var HEX_RE = /^#[0-9a-f]{6}$/;
12607
13386
  var MAX_REDMEAN_DISTANCE = 765;
12608
- var QualityPathSchema = z9.string().min(1).refine(
12609
- (value) => !path27.posix.isAbsolute(value) && !value.includes("\\"),
13387
+ var QualityPathSchema = z10.string().min(1).refine(
13388
+ (value) => !path29.posix.isAbsolute(value) && !value.includes("\\"),
12610
13389
  "expected a portable relative path"
12611
13390
  );
12612
13391
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -12619,53 +13398,53 @@ var DEFAULT_QUALITY_TOLERANCES = {
12619
13398
  maxEdgeContrastDrop: 0.03,
12620
13399
  maxIsolatedPixelIncrease: 5e-3
12621
13400
  };
12622
- var QualityToleranceSchema = z9.object({
12623
- requireExactHash: z9.boolean(),
12624
- maxColorCountIncrease: z9.number().int().min(0),
12625
- maxNewColors: z9.number().int().min(0),
12626
- maxTransparencyDelta: z9.number().min(0).max(1),
12627
- maxPartialAlphaIncrease: z9.number().min(0).max(1),
12628
- maxEdgeDensityDelta: z9.number().min(0).max(1),
12629
- maxEdgeContrastDrop: z9.number().min(0).max(1),
12630
- maxIsolatedPixelIncrease: z9.number().min(0).max(1)
13401
+ var QualityToleranceSchema = z10.object({
13402
+ requireExactHash: z10.boolean(),
13403
+ maxColorCountIncrease: z10.number().int().min(0),
13404
+ maxNewColors: z10.number().int().min(0),
13405
+ maxTransparencyDelta: z10.number().min(0).max(1),
13406
+ maxPartialAlphaIncrease: z10.number().min(0).max(1),
13407
+ maxEdgeDensityDelta: z10.number().min(0).max(1),
13408
+ maxEdgeContrastDrop: z10.number().min(0).max(1),
13409
+ maxIsolatedPixelIncrease: z10.number().min(0).max(1)
12631
13410
  }).strict();
12632
- var ImageQualityMetricsSchema = z9.object({
12633
- sha256: z9.string().regex(SHA256_RE2),
12634
- width: z9.number().int().min(1),
12635
- height: z9.number().int().min(1),
12636
- colorCount: z9.number().int().min(0),
12637
- colors: z9.array(z9.string().regex(HEX_RE)),
12638
- transparency: z9.number().min(0).max(1),
12639
- partialAlpha: z9.number().min(0).max(1),
12640
- edgeDensity: z9.number().min(0).max(1),
12641
- meanEdgeContrast: z9.number().min(0).max(1),
12642
- isolatedPixelRatio: z9.number().min(0).max(1)
13411
+ var ImageQualityMetricsSchema = z10.object({
13412
+ sha256: z10.string().regex(SHA256_RE2),
13413
+ width: z10.number().int().min(1),
13414
+ height: z10.number().int().min(1),
13415
+ colorCount: z10.number().int().min(0),
13416
+ colors: z10.array(z10.string().regex(HEX_RE)),
13417
+ transparency: z10.number().min(0).max(1),
13418
+ partialAlpha: z10.number().min(0).max(1),
13419
+ edgeDensity: z10.number().min(0).max(1),
13420
+ meanEdgeContrast: z10.number().min(0).max(1),
13421
+ isolatedPixelRatio: z10.number().min(0).max(1)
12643
13422
  }).strict();
12644
- var QualityBaselineCaseSchema = z9.object({
12645
- id: z9.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
13423
+ var QualityBaselineCaseSchema = z10.object({
13424
+ id: z10.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
12646
13425
  file: QualityPathSchema,
12647
- record: z9.object({
13426
+ record: z10.object({
12648
13427
  path: QualityPathSchema,
12649
- sha256: z9.string().regex(SHA256_RE2)
13428
+ sha256: z10.string().regex(SHA256_RE2)
12650
13429
  }).strict().optional(),
12651
13430
  expected: ImageQualityMetricsSchema,
12652
13431
  tolerances: QualityToleranceSchema
12653
13432
  }).strict();
12654
- var QualityBaselineSchema = z9.object({
12655
- $schema: z9.string().url().optional(),
12656
- format: z9.literal("pixelkiln-quality-baseline"),
12657
- schemaVersion: z9.literal(1),
12658
- cases: z9.array(QualityBaselineCaseSchema).min(1)
13433
+ var QualityBaselineSchema = z10.object({
13434
+ $schema: z10.string().url().optional(),
13435
+ format: z10.literal("pixelkiln-quality-baseline"),
13436
+ schemaVersion: z10.literal(1),
13437
+ cases: z10.array(QualityBaselineCaseSchema).min(1)
12659
13438
  }).strict().superRefine((baseline, context) => {
12660
13439
  const ids = baseline.cases.map((entry) => entry.id);
12661
13440
  if (new Set(ids).size !== ids.length) {
12662
- context.addIssue({ code: z9.ZodIssueCode.custom, path: ["cases"], message: "duplicate case id" });
13441
+ context.addIssue({ code: z10.ZodIssueCode.custom, path: ["cases"], message: "duplicate case id" });
12663
13442
  }
12664
13443
  });
12665
- var QualityInputSchema = z9.object({
12666
- id: z9.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
12667
- path: z9.string().min(1),
12668
- record: z9.string().min(1).optional(),
13444
+ var QualityInputSchema = z10.object({
13445
+ id: z10.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
13446
+ path: z10.string().min(1),
13447
+ record: z10.string().min(1).optional(),
12669
13448
  tolerances: QualityToleranceSchema.partial().optional()
12670
13449
  }).strict();
12671
13450
  function message5(error) {
@@ -12753,10 +13532,10 @@ function measureDecodedImageQuality(png, digest2) {
12753
13532
  };
12754
13533
  }
12755
13534
  async function measureImageQuality(file) {
12756
- const absolute = path27.resolve(file);
13535
+ const absolute = path29.resolve(file);
12757
13536
  let bytes;
12758
13537
  try {
12759
- bytes = await readFile21(absolute);
13538
+ bytes = await readFile24(absolute);
12760
13539
  } catch (error) {
12761
13540
  throw new Error(`Cannot read quality image ${absolute}: ${message5(error)}`, { cause: error });
12762
13541
  }
@@ -12767,7 +13546,7 @@ async function measureImageQuality(file) {
12767
13546
  }
12768
13547
  }
12769
13548
  function resolveQualityInputs(raw, inputsFilePath) {
12770
- const parsed = z9.array(QualityInputSchema).min(1).safeParse(raw);
13549
+ const parsed = z10.array(QualityInputSchema).min(1).safeParse(raw);
12771
13550
  if (!parsed.success) {
12772
13551
  throw new Error(
12773
13552
  "--inputs must be a non-empty JSON array of quality cases:\n" + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
@@ -12775,11 +13554,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
12775
13554
  }
12776
13555
  const ids = parsed.data.map((entry) => entry.id);
12777
13556
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
12778
- const root = path27.dirname(path27.resolve(inputsFilePath));
13557
+ const root = path29.dirname(path29.resolve(inputsFilePath));
12779
13558
  return parsed.data.map((entry) => ({
12780
13559
  id: entry.id,
12781
- file: path27.resolve(root, entry.path),
12782
- ...entry.record ? { record: path27.resolve(root, entry.record) } : {},
13560
+ file: path29.resolve(root, entry.path),
13561
+ ...entry.record ? { record: path29.resolve(root, entry.record) } : {},
12783
13562
  tolerances: QualityToleranceSchema.parse({
12784
13563
  ...DEFAULT_QUALITY_TOLERANCES,
12785
13564
  ...entry.tolerances
@@ -12787,7 +13566,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
12787
13566
  }));
12788
13567
  }
12789
13568
  function portableRelative2(from, to) {
12790
- return path27.relative(from, path27.resolve(to)).split(path27.sep).join("/") || ".";
13569
+ return path29.relative(from, path29.resolve(to)).split(path29.sep).join("/") || ".";
12791
13570
  }
12792
13571
  async function verifyRecordForImage(recordPath, imagePath) {
12793
13572
  const verification = await verifyArtifactBundle(recordPath);
@@ -12802,20 +13581,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
12802
13581
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
12803
13582
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
12804
13583
  }
12805
- const outputs = record.outputs.map((output) => path27.resolve(path27.dirname(recordPath), output.path));
12806
- if (!outputs.includes(path27.resolve(imagePath))) {
13584
+ const outputs = record.outputs.map((output) => path29.resolve(path29.dirname(recordPath), output.path));
13585
+ if (!outputs.includes(path29.resolve(imagePath))) {
12807
13586
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
12808
13587
  }
12809
13588
  }
12810
13589
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
12811
13590
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
12812
- const absolute = path27.resolve(baselinePath);
13591
+ const absolute = path29.resolve(baselinePath);
12813
13592
  for (const input of inputs) {
12814
- if (path27.resolve(input.file) === absolute || input.record && path27.resolve(input.record) === absolute) {
13593
+ if (path29.resolve(input.file) === absolute || input.record && path29.resolve(input.record) === absolute) {
12815
13594
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
12816
13595
  }
12817
13596
  }
12818
- const root = path27.dirname(absolute);
13597
+ const root = path29.dirname(absolute);
12819
13598
  const cases = [];
12820
13599
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
12821
13600
  const expected = await measureImageQuality(input.file);
@@ -12842,8 +13621,8 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
12842
13621
  cases
12843
13622
  });
12844
13623
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
12845
- if (existsSync21(absolute) && !options.force) {
12846
- const current = await readFile21(absolute);
13624
+ if (existsSync22(absolute) && !options.force) {
13625
+ const current = await readFile24(absolute);
12847
13626
  if (!current.equals(data)) {
12848
13627
  throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
12849
13628
  }
@@ -12852,10 +13631,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
12852
13631
  return { baseline, path: absolute, changed: result.changed.length > 0 };
12853
13632
  }
12854
13633
  async function readQualityBaseline(baselinePath) {
12855
- const absolute = path27.resolve(baselinePath);
13634
+ const absolute = path29.resolve(baselinePath);
12856
13635
  let raw;
12857
13636
  try {
12858
- raw = JSON.parse(await readFile21(absolute, "utf8"));
13637
+ raw = JSON.parse(await readFile24(absolute, "utf8"));
12859
13638
  } catch (error) {
12860
13639
  throw new Error(`Could not read quality baseline ${absolute}: ${message5(error)}`, { cause: error });
12861
13640
  }
@@ -12925,12 +13704,12 @@ function regressionViolations(expected, actual, tolerances) {
12925
13704
  return { violations, warnings };
12926
13705
  }
12927
13706
  async function checkQualityBaseline(baselinePath) {
12928
- const absolute = path27.resolve(baselinePath);
13707
+ const absolute = path29.resolve(baselinePath);
12929
13708
  const baseline = await readQualityBaseline(absolute);
12930
- const root = path27.dirname(absolute);
13709
+ const root = path29.dirname(absolute);
12931
13710
  const cases = [];
12932
13711
  for (const entry of baseline.cases) {
12933
- const file = path27.resolve(root, entry.file);
13712
+ const file = path29.resolve(root, entry.file);
12934
13713
  let actual = null;
12935
13714
  const violations = [];
12936
13715
  const warnings = [];
@@ -12943,7 +13722,7 @@ async function checkQualityBaseline(baselinePath) {
12943
13722
  violations.push(message5(error));
12944
13723
  }
12945
13724
  if (entry.record) {
12946
- const recordPath = path27.resolve(root, entry.record.path);
13725
+ const recordPath = path29.resolve(root, entry.record.path);
12947
13726
  try {
12948
13727
  const recordHash = await sha256File(recordPath);
12949
13728
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -13029,13 +13808,13 @@ async function runGallery(initial, reload, args, access2) {
13029
13808
  };
13030
13809
  const loadProject = async (project) => {
13031
13810
  const ctx = await access2.loadProject(project);
13032
- const dir = path28.dirname(ctx.loaded.path);
13811
+ const dir = path30.dirname(ctx.loaded.path);
13033
13812
  const declared = readEnvFiles(dir);
13034
13813
  const credentialNames = new Set(availableProviders().flatMap((id) => providerCredentialEnvs(providerFactory(id))));
13035
13814
  for (const [name, value] of Object.entries(declared)) {
13036
13815
  if (credentialNames.has(name) && name in process.env && process.env[name] !== value) {
13037
13816
  throw new Error(
13038
- `${path28.relative(process.cwd(), dir) || "."} sets ${name} to a different value than the one this gallery already loaded; run a separate gallery for that project so its work uses its own account.`
13817
+ `${path30.relative(process.cwd(), dir) || "."} sets ${name} to a different value than the one this gallery already loaded; run a separate gallery for that project so its work uses its own account.`
13039
13818
  );
13040
13819
  }
13041
13820
  }
@@ -13062,7 +13841,9 @@ async function runGallery(initial, reload, args, access2) {
13062
13841
  budget ? describeBudget(budget) : null
13063
13842
  ),
13064
13843
  ...args.edit ? { edit: createGalleryEditHandler({ manifestFor: access2.manifestFor, loadProject: access2.loadProject, reload, onProgress: log }) } : {},
13065
- ...budget ? { generate: createGenerateHandlers({ loadProject, providerFor, budget, reload, onProgress: log }) } : {}
13844
+ ...budget ? { generate: createGenerateHandlers({ loadProject, providerFor, budget, reload, onProgress: log }) } : {},
13845
+ // The editor exists to write hand edits back, so it follows the write gate.
13846
+ ...args.edit && !args.noEditor ? { editor: createGalleryEditorHandlers({ onProgress: log }) } : {}
13066
13847
  });
13067
13848
  await new Promise((resolve) => {
13068
13849
  const stop = () => {
@@ -13077,11 +13858,11 @@ async function runGallery(initial, reload, args, access2) {
13077
13858
  log("\n gallery closed");
13078
13859
  }
13079
13860
  async function provenanceFile(id, file) {
13080
- const absolute = path28.resolve(file);
13861
+ const absolute = path30.resolve(file);
13081
13862
  return {
13082
13863
  id,
13083
13864
  path: absolute,
13084
- sha256: existsSync22(absolute) ? await sha256File(absolute) : null,
13865
+ sha256: existsSync23(absolute) ? await sha256File(absolute) : null,
13085
13866
  included: true
13086
13867
  };
13087
13868
  }
@@ -13131,7 +13912,8 @@ var BOOL_FLAGS = [
13131
13912
  "--primary-only",
13132
13913
  "--prune",
13133
13914
  "--edit",
13134
- "--refresh"
13915
+ "--refresh",
13916
+ "--no-editor"
13135
13917
  ];
13136
13918
  var COMMANDS = [
13137
13919
  "init",
@@ -13158,6 +13940,7 @@ var COMMANDS = [
13158
13940
  "status",
13159
13941
  "gallery",
13160
13942
  "edit",
13943
+ "tools",
13161
13944
  "quality",
13162
13945
  "refine",
13163
13946
  "recipe",
@@ -13173,6 +13956,8 @@ var REFINE_SUBCOMMANDS = ["run", "approve", "check"];
13173
13956
  var RECIPE_SUBCOMMANDS = ["list", "inspect", "install", "verify"];
13174
13957
  var QUALITY_SUBCOMMANDS = ["snapshot", "check"];
13175
13958
  var EDIT_SUBCOMMANDS = ["start", "detach"];
13959
+ var TOOLS_SUBCOMMANDS = ["status", "install"];
13960
+ var TOOLS = ["editor"];
13176
13961
  function parseArgs(argv) {
13177
13962
  const [command = "help"] = argv;
13178
13963
  if (!COMMANDS.includes(command)) {
@@ -13236,6 +14021,22 @@ function parseArgs(argv) {
13236
14021
  throw new Error(`Unknown edit subcommand "${subcommand}". Known: ${EDIT_SUBCOMMANDS.join(", ")}`);
13237
14022
  }
13238
14023
  if (rest[0] === subcommand) rest = rest.slice(1);
14024
+ } else if (command === "tools") {
14025
+ subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "status" : rest[0];
14026
+ if (!TOOLS_SUBCOMMANDS.includes(subcommand)) {
14027
+ throw new Error(`Unknown tools subcommand "${subcommand}". Known: ${TOOLS_SUBCOMMANDS.join(", ")}`);
14028
+ }
14029
+ if (rest[0] === subcommand) rest = rest.slice(1);
14030
+ target = rest[0]?.startsWith("-") ? void 0 : rest[0];
14031
+ if (subcommand === "install" && target === void 0) {
14032
+ throw new Error(`tools install needs a tool name: ${TOOLS.join(", ")}`);
14033
+ }
14034
+ if (target !== void 0) {
14035
+ if (!TOOLS.includes(target)) {
14036
+ throw new Error(`Unknown tool "${target}". Known: ${TOOLS.join(", ")}`);
14037
+ }
14038
+ rest = rest.slice(1);
14039
+ }
13239
14040
  } else if (command === "refine") {
13240
14041
  subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "run" : rest[0];
13241
14042
  if (!REFINE_SUBCOMMANDS.includes(subcommand)) {
@@ -13349,7 +14150,7 @@ function parseArgs(argv) {
13349
14150
  return {
13350
14151
  command,
13351
14152
  manifest,
13352
- lock: get("--lock") ?? path28.join(path28.dirname(path28.resolve(manifest)), "pixelkiln.lock.json"),
14153
+ lock: get("--lock") ?? path30.join(path30.dirname(path30.resolve(manifest)), "pixelkiln.lock.json"),
13353
14154
  explicitLock: get("--lock"),
13354
14155
  styles: list("--style"),
13355
14156
  assets: list("--only"),
@@ -13364,6 +14165,7 @@ function parseArgs(argv) {
13364
14165
  noOpen: rest.includes("--no-open"),
13365
14166
  tag: rest.includes("--tag"),
13366
14167
  edit: rest.includes("--edit"),
14168
+ noEditor: rest.includes("--no-editor"),
13367
14169
  refresh: rest.includes("--refresh"),
13368
14170
  from: get("--from"),
13369
14171
  out: get("--out"),
@@ -13397,6 +14199,45 @@ function parseArgs(argv) {
13397
14199
  account: get("--account")
13398
14200
  };
13399
14201
  }
14202
+ async function runTools(args) {
14203
+ const mb = (bytes) => bytes < 1e6 ? `${(bytes / 1e3).toFixed(0)} kB` : `${(bytes / 1e6).toFixed(1)} MB`;
14204
+ if (args.subcommand === "status") {
14205
+ const status2 = await editorStatus();
14206
+ if (args.json) {
14207
+ log(JSON.stringify({ version: 1, editor: { ...status2, pixelorama: EDITOR_PIN.pixelorama, protocol: EDITOR_PIN.protocol } }, null, 2));
14208
+ return;
14209
+ }
14210
+ log(` editor Pixelorama ${EDITOR_PIN.pixelorama} (bridge protocol ${EDITOR_PIN.protocol})`);
14211
+ if (!status2.release) {
14212
+ log(" no published build is pinned by this PixelKiln version");
14213
+ return;
14214
+ }
14215
+ log(` release ${status2.release}`);
14216
+ log(` ${status2.installed ? "installed and verified" : status2.installedBytes ? `partial: ${status2.missing.length} of ${Object.keys(EDITOR_PIN.files).length} files missing` : "not installed"} \u2014 ${mb(status2.totalBytes)} in ${status2.dir}`);
14217
+ if (!status2.installed) log(" run: pixelkiln tools install editor");
14218
+ return;
14219
+ }
14220
+ const status = await editorStatus();
14221
+ if (status.installed) {
14222
+ log(` editor is already installed and verified in ${status.dir}`);
14223
+ return;
14224
+ }
14225
+ if (!status.release) {
14226
+ throw new Error("this PixelKiln version pins no published editor build; upgrade, or set PIXELKILN_EDITOR_URL to a build you trust");
14227
+ }
14228
+ log(` fetching ${status.missing.length} file(s), ${mb(status.totalBytes - status.installedBytes)}, from release ${status.release}`);
14229
+ try {
14230
+ const result = await installEditor({
14231
+ onProgress: (p) => {
14232
+ if (p.phase === "start") log(` ${p.file.padEnd(34)} ${mb(p.bytes).padStart(9)}`);
14233
+ }
14234
+ });
14235
+ log(` editor ready in ${result.dir} (${result.downloaded.length} fetched, ${result.skipped.length} already present, every hash verified)`);
14236
+ } catch (err) {
14237
+ if (err instanceof EditorInstallError) throw new Error(`editor install failed: ${err.message}`);
14238
+ throw err;
14239
+ }
14240
+ }
13400
14241
  var HELP = `pixelkiln \u2014 manifest-driven pixel art generation
13401
14242
 
13402
14243
  pixelkiln <command> [options]
@@ -13437,8 +14278,12 @@ Commands
13437
14278
  gallery Open a local read-only gallery of every generation and its
13438
14279
  provenance: prompt, provider, cost, outputs, lineage, quality.
13439
14280
  --workspace <catalog> shows every registered project at once;
13440
- --edit lets the page change prompts, sizes, tags, and add assets;
14281
+ --edit lets the page change prompts, sizes, tags, and add assets,
14282
+ and offers the in-browser pixel editor (--no-editor hides it);
13441
14283
  --budget enables Generate, Regenerate, and review under that ceiling.
14284
+ tools status/install editor: the in-browser editor (Pixelorama) is a
14285
+ 46 MB web build fetched once per release into a user cache and
14286
+ verified against pinned hashes. Offline once installed.
13442
14287
  workspace Register sibling projects and derive account-wide claims/status.
13443
14288
  add/remove/list/status/claims. Offline.
13444
14289
 
@@ -13478,6 +14323,7 @@ Options
13478
14323
  --yes, -y Skip the confirmation prompt
13479
14324
  --no-open Do not auto-open the browser (pick, salvage, gallery) or editor (edit)
13480
14325
  --edit gallery: allow manifest edits from the page (never spends)
14326
+ --no-editor gallery: do not offer or serve the in-browser editor
13481
14327
  --tag Also push tags upstream after fetch
13482
14328
  --refresh fetch: re-download and replace files whose object changed
13483
14329
  upstream (e.g. edited in PixelLab's editor); no generation
@@ -13496,6 +14342,7 @@ Examples
13496
14342
  pixelkiln gallery --workspace pixelkiln.workspace.json
13497
14343
  pixelkiln gallery --edit --budget 80
13498
14344
  pixelkiln edit --style base --only anvil
14345
+ pixelkiln tools install editor
13499
14346
  PIXELKILN_EDITOR="open -a Aseprite" pixelkiln edit --only anvil --style base
13500
14347
  pixelkiln adopt --tag
13501
14348
  pixelkiln pack --style heybud-premium
@@ -13655,10 +14502,10 @@ async function confirm(question, auto) {
13655
14502
  return answer === "y" || answer === "yes";
13656
14503
  }
13657
14504
  async function requireCompleteWorkspaceClaims(workspacePath) {
13658
- if (!existsSync22(workspacePath)) {
14505
+ if (!existsSync23(workspacePath)) {
13659
14506
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
13660
14507
  }
13661
- const dir = path28.dirname(path28.resolve(workspacePath));
14508
+ const dir = path30.dirname(path30.resolve(workspacePath));
13662
14509
  const ws = await loadWorkspace(workspacePath);
13663
14510
  const diagnostics = validateWorkspace(ws, dir);
13664
14511
  const errors = diagnostics.filter((d) => d.level === "error");
@@ -13679,26 +14526,30 @@ async function main() {
13679
14526
  }
13680
14527
  if (args.command === "--version" || args.command === "-v") {
13681
14528
  const pkg = JSON.parse(
13682
- await readFile22(new URL("../package.json", import.meta.url), "utf8")
14529
+ await readFile25(new URL("../package.json", import.meta.url), "utf8")
13683
14530
  );
13684
14531
  log(`${pkg.name} ${pkg.version}`);
13685
14532
  return;
13686
14533
  }
14534
+ if (args.command === "tools") {
14535
+ await runTools(args);
14536
+ return;
14537
+ }
13687
14538
  if (args.command === "quality") {
13688
14539
  if (args.subcommand === "snapshot") {
13689
14540
  if (!args.inputs) throw new Error("quality snapshot needs --inputs <quality-inputs.json>.");
13690
14541
  if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
13691
14542
  let raw;
13692
14543
  try {
13693
- raw = JSON.parse(await readFile22(path28.resolve(args.inputs), "utf8"));
14544
+ raw = JSON.parse(await readFile25(path30.resolve(args.inputs), "utf8"));
13694
14545
  } catch (error) {
13695
14546
  throw new Error(
13696
- `Could not read quality inputs ${path28.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
14547
+ `Could not read quality inputs ${path30.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
13697
14548
  { cause: error }
13698
14549
  );
13699
14550
  }
13700
14551
  const inputs = resolveQualityInputs(raw, args.inputs);
13701
- if (path28.resolve(args.inputs) === path28.resolve(args.out)) {
14552
+ if (path30.resolve(args.inputs) === path30.resolve(args.out)) {
13702
14553
  throw new Error("quality snapshot --out must not overwrite its --inputs file.");
13703
14554
  }
13704
14555
  const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
@@ -13711,7 +14562,7 @@ async function main() {
13711
14562
  }, null, 2));
13712
14563
  } else {
13713
14564
  log(
13714
- ` ${result.changed ? "wrote" : "unchanged"} ${path28.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
14565
+ ` ${result.changed ? "wrote" : "unchanged"} ${path30.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
13715
14566
  );
13716
14567
  log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
13717
14568
  }
@@ -13806,14 +14657,14 @@ async function main() {
13806
14657
  }, null, 2));
13807
14658
  } else {
13808
14659
  log(` installed ${result.recipe.id}@${result.recipe.version}`);
13809
- log(` ${path28.relative(process.cwd(), result.destination) || "."}`);
14660
+ log(` ${path30.relative(process.cwd(), result.destination) || "."}`);
13810
14661
  log(`
13811
14662
  Add this entry under your manifest's styles object:`);
13812
14663
  log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
13813
14664
  if (result.recipe.models.length) {
13814
14665
  log(`
13815
14666
  Models are not downloaded automatically. Verify them with:`);
13816
- log(` pixelkiln recipe verify ${path28.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
14667
+ log(` pixelkiln recipe verify ${path30.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
13817
14668
  }
13818
14669
  }
13819
14670
  return;
@@ -13847,12 +14698,12 @@ async function main() {
13847
14698
  ` recovered ${result2.detection.columns}x${result2.detection.rows} native grid (${result2.detection.confidence}: ${result2.detection.consensus})`
13848
14699
  );
13849
14700
  log(` enforced ${result2.palette.length}-color palette without dithering`);
13850
- log(` wrote ${path28.relative(process.cwd(), result2.output)}`);
13851
- log(` quality record: ${path28.relative(process.cwd(), result2.record)}`);
14701
+ log(` wrote ${path30.relative(process.cwd(), result2.output)}`);
14702
+ log(` quality record: ${path30.relative(process.cwd(), result2.record)}`);
13852
14703
  log(`
13853
14704
  Automated checks passed. Human 1\xD7 review is still required:`);
13854
14705
  log(
13855
- ` pixelkiln refine approve --from ${path28.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
14706
+ ` pixelkiln refine approve --from ${path30.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
13856
14707
  );
13857
14708
  }
13858
14709
  return;
@@ -13880,7 +14731,7 @@ async function main() {
13880
14731
  if (args.json) log(JSON.stringify(result2, null, 2));
13881
14732
  else {
13882
14733
  log(
13883
- result2.options.frameSet ? ` approved ${result2.outputs.length} frames in ${path28.relative(process.cwd(), result2.record)} by ${args.reviewer.trim()}` : ` approved ${path28.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`
14734
+ result2.options.frameSet ? ` approved ${result2.outputs.length} frames in ${path30.relative(process.cwd(), result2.record)} by ${args.reviewer.trim()}` : ` approved ${path30.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`
13884
14735
  );
13885
14736
  }
13886
14737
  return;
@@ -13889,14 +14740,14 @@ async function main() {
13889
14740
  if (args.json) {
13890
14741
  log(JSON.stringify(result, null, 2));
13891
14742
  } else {
13892
- log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path28.relative(process.cwd(), result.output || result.record)}`);
14743
+ log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path30.relative(process.cwd(), result.output || result.record)}`);
13893
14744
  for (const reason of result.reasons) log(` ${reason}`);
13894
14745
  }
13895
14746
  if (!result.safe) process.exitCode = 1;
13896
14747
  return;
13897
14748
  }
13898
14749
  if (args.command === "balance") {
13899
- loadEnvFiles(path28.dirname(path28.resolve(args.manifest)));
14750
+ loadEnvFiles(path30.dirname(path30.resolve(args.manifest)));
13900
14751
  loadEnvFiles(process.cwd());
13901
14752
  const loaded2 = await loadManifest(args.manifest);
13902
14753
  const selectedProvider = accountProviderId(loaded2.manifest, args.provider, "balance");
@@ -13909,31 +14760,31 @@ async function main() {
13909
14760
  }
13910
14761
  if (args.command === "init") {
13911
14762
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
13912
- const root = path28.resolve(args.from);
13913
- if (!existsSync22(root)) throw new Error(`No directory at ${root}`);
14763
+ const root = path30.resolve(args.from);
14764
+ if (!existsSync23(root)) throw new Error(`No directory at ${root}`);
13914
14765
  const generator = args.generator ?? "map";
13915
14766
  if (generator !== "1dir" && generator !== "map") {
13916
14767
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
13917
14768
  }
13918
- const target = path28.resolve(args.out ?? "pixelkiln.manifest.json");
14769
+ const target = path30.resolve(args.out ?? "pixelkiln.manifest.json");
13919
14770
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
13920
14771
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
13921
14772
  const manifest = buildManifest(
13922
- args.name ?? path28.basename(path28.dirname(target)),
14773
+ args.name ?? path30.basename(path30.dirname(target)),
13923
14774
  args.styles[0] ?? "base",
13924
14775
  generator,
13925
- path28.relative(path28.dirname(target), root) || ".",
14776
+ path30.relative(path30.dirname(target), root) || ".",
13926
14777
  assets
13927
14778
  );
13928
14779
  await writeManifestFile(target, manifest);
13929
- log(` scanned ${assets.length} PNG(s) under ${path28.relative(process.cwd(), root)}`);
14780
+ log(` scanned ${assets.length} PNG(s) under ${path30.relative(process.cwd(), root)}`);
13930
14781
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
13931
- log(` wrote ${path28.relative(process.cwd(), target)}`);
14782
+ log(` wrote ${path30.relative(process.cwd(), target)}`);
13932
14783
  log(`
13933
14784
  Prompts are intentionally empty. To recover the real ones from your`);
13934
14785
  log(` PixelLab account instead of inventing them:`);
13935
14786
  log(`
13936
- pixelkiln adopt --manifest ${path28.relative(process.cwd(), target)} --write-prompts
14787
+ pixelkiln adopt --manifest ${path30.relative(process.cwd(), target)} --write-prompts
13937
14788
  `);
13938
14789
  return;
13939
14790
  }
@@ -13981,10 +14832,10 @@ async function main() {
13981
14832
  if (args.primaryOnly || args.outputRoles.length) {
13982
14833
  throw new Error("--primary-only and --output-role require manifest-driven pack");
13983
14834
  }
13984
- const raw = JSON.parse(await readFile22(path28.resolve(args.inputs), "utf8"));
14835
+ const raw = JSON.parse(await readFile25(path30.resolve(args.inputs), "utf8"));
13985
14836
  const inputs = resolvePackInputs(raw, args.inputs);
13986
14837
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
13987
- const base = path28.resolve(args.out.replace(/\.png$/, ""));
14838
+ const base = path30.resolve(args.out.replace(/\.png$/, ""));
13988
14839
  const outputs = [
13989
14840
  { path: `${base}.png`, data: png },
13990
14841
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -13997,17 +14848,17 @@ async function main() {
13997
14848
  log(
13998
14849
  ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
13999
14850
  );
14000
- log(` ${path28.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
14851
+ log(` ${path30.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
14001
14852
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
14002
14853
  return;
14003
14854
  }
14004
14855
  if (args.command === "workspace") {
14005
- const workspacePath = path28.resolve(args.workspace ?? "pixelkiln.workspace.json");
14006
- const dir = path28.dirname(workspacePath);
14856
+ const workspacePath = path30.resolve(args.workspace ?? "pixelkiln.workspace.json");
14857
+ const dir = path30.dirname(workspacePath);
14007
14858
  if (args.subcommand === "add") {
14008
- const manifestPath = path28.resolve(args.target);
14859
+ const manifestPath = path30.resolve(args.target);
14009
14860
  const loadedTarget = await loadManifest(manifestPath);
14010
- const lockPath = args.explicitLock ? path28.resolve(args.explicitLock) : path28.join(path28.dirname(manifestPath), "pixelkiln.lock.json");
14861
+ const lockPath = args.explicitLock ? path30.resolve(args.explicitLock) : path30.join(path30.dirname(manifestPath), "pixelkiln.lock.json");
14011
14862
  const ws = await loadWorkspace(workspacePath);
14012
14863
  const id = args.name ?? loadedTarget.manifest.name;
14013
14864
  if (ws.projects.some((p) => p.id === id)) {
@@ -14027,10 +14878,10 @@ async function main() {
14027
14878
  ...args.account ? { account: args.account } : {}
14028
14879
  };
14029
14880
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
14030
- log(` registered "${id}" in ${path28.relative(process.cwd(), workspacePath)}`);
14881
+ log(` registered "${id}" in ${path30.relative(process.cwd(), workspacePath)}`);
14031
14882
  log(` manifest: ${project.manifest}`);
14032
14883
  log(` lock: ${project.lock}`);
14033
- if (!existsSync22(lockPath)) {
14884
+ if (!existsSync23(lockPath)) {
14034
14885
  log(
14035
14886
  ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
14036
14887
  );
@@ -14039,7 +14890,7 @@ async function main() {
14039
14890
  }
14040
14891
  if (args.subcommand === "remove") {
14041
14892
  const ws = await loadWorkspace(workspacePath);
14042
- const resolvedTarget = path28.resolve(args.target);
14893
+ const resolvedTarget = path30.resolve(args.target);
14043
14894
  const match = ws.projects.find(
14044
14895
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
14045
14896
  );
@@ -14050,11 +14901,11 @@ async function main() {
14050
14901
  version: 1,
14051
14902
  projects: ws.projects.filter((p) => p !== match)
14052
14903
  });
14053
- log(` removed "${match.id}" from ${path28.relative(process.cwd(), workspacePath)}`);
14904
+ log(` removed "${match.id}" from ${path30.relative(process.cwd(), workspacePath)}`);
14054
14905
  return;
14055
14906
  }
14056
14907
  if (args.subcommand === "list") {
14057
- if (!existsSync22(workspacePath)) {
14908
+ if (!existsSync23(workspacePath)) {
14058
14909
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14059
14910
  }
14060
14911
  const ws = await loadWorkspace(workspacePath);
@@ -14062,9 +14913,9 @@ async function main() {
14062
14913
  if (args.json) {
14063
14914
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
14064
14915
  } else if (!ws.projects.length) {
14065
- log(` no projects registered in ${path28.relative(process.cwd(), workspacePath)}`);
14916
+ log(` no projects registered in ${path30.relative(process.cwd(), workspacePath)}`);
14066
14917
  } else {
14067
- log(` ${ws.projects.length} project(s) in ${path28.relative(process.cwd(), workspacePath)}:`);
14918
+ log(` ${ws.projects.length} project(s) in ${path30.relative(process.cwd(), workspacePath)}:`);
14068
14919
  for (const p of ws.projects) {
14069
14920
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
14070
14921
  }
@@ -14074,7 +14925,7 @@ async function main() {
14074
14925
  return;
14075
14926
  }
14076
14927
  if (args.subcommand === "status") {
14077
- if (!existsSync22(workspacePath)) {
14928
+ if (!existsSync23(workspacePath)) {
14078
14929
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14079
14930
  }
14080
14931
  const ws = await loadWorkspace(workspacePath);
@@ -14082,7 +14933,7 @@ async function main() {
14082
14933
  if (args.json) {
14083
14934
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
14084
14935
  } else {
14085
- log(` workspace: ${path28.relative(process.cwd(), workspacePath)}`);
14936
+ log(` workspace: ${path30.relative(process.cwd(), workspacePath)}`);
14086
14937
  for (const p of report.projects) {
14087
14938
  if (p.error) {
14088
14939
  log(`
@@ -14129,8 +14980,8 @@ async function main() {
14129
14980
  }
14130
14981
  }
14131
14982
  if (args.command === "gallery" && args.workspace) {
14132
- const workspacePath = path28.resolve(args.workspace);
14133
- if (!existsSync22(workspacePath)) {
14983
+ const workspacePath = path30.resolve(args.workspace);
14984
+ if (!existsSync23(workspacePath)) {
14134
14985
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14135
14986
  }
14136
14987
  const filter = { styles: args.styles, assets: args.assets };
@@ -14150,7 +15001,7 @@ async function main() {
14150
15001
  const registered = async (projectId) => {
14151
15002
  const project = (await loadWorkspace(workspacePath)).projects.find((candidate) => candidate.id === projectId);
14152
15003
  if (!projectId || !project) throw new Error(`unknown workspace project "${projectId ?? ""}"`);
14153
- return resolveProject(path28.dirname(workspacePath), project);
15004
+ return resolveProject(path30.dirname(workspacePath), project);
14154
15005
  };
14155
15006
  await runGallery(initial, build, args, {
14156
15007
  manifestFor: async (projectId) => (await registered(projectId)).manifestPath,
@@ -14165,14 +15016,14 @@ async function main() {
14165
15016
  });
14166
15017
  return;
14167
15018
  }
14168
- if (!existsSync22(path28.resolve(args.manifest))) {
15019
+ if (!existsSync23(path30.resolve(args.manifest))) {
14169
15020
  throw new Error(
14170
- `No manifest at ${path28.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
15021
+ `No manifest at ${path30.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
14171
15022
  );
14172
15023
  }
14173
- const manifestDir = path28.dirname(path28.resolve(args.manifest));
15024
+ const manifestDir = path30.dirname(path30.resolve(args.manifest));
14174
15025
  const envFiles2 = [...loadEnvFiles(manifestDir)];
14175
- if (path28.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
15026
+ if (path30.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
14176
15027
  const loaded = await loadManifest(args.manifest);
14177
15028
  let specs = await resolveSpecs(loaded, {
14178
15029
  styles: args.styles,
@@ -14245,7 +15096,7 @@ async function main() {
14245
15096
  });
14246
15097
  };
14247
15098
  await runGallery(await build(), reload, args, {
14248
- manifestFor: () => path28.resolve(args.manifest),
15099
+ manifestFor: () => path30.resolve(args.manifest),
14249
15100
  loadProject: async () => {
14250
15101
  const freshLoaded = await loadManifest(args.manifest);
14251
15102
  const freshSpecs = await resolveSpecs(freshLoaded);
@@ -14320,7 +15171,7 @@ async function main() {
14320
15171
  log(` ${item.state.padEnd(16)} ${item.key} ${item.reason}`);
14321
15172
  if (item.state === "needs-approval") {
14322
15173
  log(
14323
- ` pixelkiln refine approve --from ${path28.relative(process.cwd(), item.record)} --reviewer "Your Name"`
15174
+ ` pixelkiln refine approve --from ${path30.relative(process.cwd(), item.record)} --reviewer "Your Name"`
14324
15175
  );
14325
15176
  }
14326
15177
  }
@@ -14411,7 +15262,7 @@ async function main() {
14411
15262
  let intact = true;
14412
15263
  for (const output of entry.outputs) {
14413
15264
  const file = resolveOutputPath(output.path, item.spec.root);
14414
- if (!existsSync22(file) || await sha256File(file) !== output.sha256) {
15265
+ if (!existsSync23(file) || await sha256File(file) !== output.sha256) {
14415
15266
  intact = false;
14416
15267
  break;
14417
15268
  }
@@ -14469,7 +15320,7 @@ async function main() {
14469
15320
  if (args.primaryOnly && args.outputRoles.length) {
14470
15321
  throw new Error("pack accepts either --primary-only or --output-role, not both");
14471
15322
  }
14472
- const manifestDir2 = path28.dirname(path28.resolve(args.manifest));
15323
+ const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
14473
15324
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
14474
15325
  for (const styleId of styleIds) {
14475
15326
  const packagingSpecs = await resolveSpecs(loaded, { styles: [styleId] });
@@ -14482,7 +15333,7 @@ async function main() {
14482
15333
  sources: manifestSources(loaded.manifest, styleId)
14483
15334
  });
14484
15335
  const style = loaded.manifest.styles[styleId];
14485
- const base = args.out ? path28.resolve(args.out.replace(/\.png$/, "")) : path28.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
15336
+ const base = args.out ? path30.resolve(args.out.replace(/\.png$/, "")) : path30.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
14486
15337
  const outputs = [
14487
15338
  { path: `${base}.png`, data: png },
14488
15339
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -14512,13 +15363,13 @@ async function main() {
14512
15363
  log(
14513
15364
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
14514
15365
  );
14515
- log(` ${path28.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15366
+ log(` ${path30.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
14516
15367
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
14517
15368
  }
14518
15369
  return;
14519
15370
  }
14520
15371
  if (args.command === "mount") {
14521
- const manifestDir2 = path28.dirname(path28.resolve(args.manifest));
15372
+ const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
14522
15373
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
14523
15374
  for (const styleId of styleIds) {
14524
15375
  const style = loaded.manifest.styles[styleId];
@@ -14557,7 +15408,7 @@ async function main() {
14557
15408
  sources,
14558
15409
  outputRoles
14559
15410
  );
14560
- const out = path28.resolve(manifestDir2, style.mount.out);
15411
+ const out = path30.resolve(manifestDir2, style.mount.out);
14561
15412
  const metadata = out.replace(/\.png$/, "") + ".json";
14562
15413
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
14563
15414
  const outputs = [
@@ -14577,7 +15428,7 @@ async function main() {
14577
15428
  await provenanceFile("$lock", args.lock),
14578
15429
  ...qualityRecords,
14579
15430
  ...artifactSources.filter(
14580
- (source) => source.id !== "$base" || path28.resolve(source.path) !== out
15431
+ (source) => source.id !== "$base" || path30.resolve(source.path) !== out
14581
15432
  )
14582
15433
  ],
14583
15434
  options: {
@@ -14590,7 +15441,7 @@ async function main() {
14590
15441
  log(
14591
15442
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
14592
15443
  );
14593
- log(` ${path28.relative(process.cwd(), out)} + atlas/provenance JSON`);
15444
+ log(` ${path30.relative(process.cwd(), out)} + atlas/provenance JSON`);
14594
15445
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
14595
15446
  }
14596
15447
  return;
@@ -14646,7 +15497,7 @@ async function main() {
14646
15497
  }
14647
15498
  if (args.command === "export") {
14648
15499
  const format = args.format ?? "generic";
14649
- const manifestDir2 = path28.dirname(path28.resolve(args.manifest));
15500
+ const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
14650
15501
  const selected = specs.filter((spec) => {
14651
15502
  if (spec.generator !== "tiles") return false;
14652
15503
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -14662,12 +15513,12 @@ async function main() {
14662
15513
  for (const spec of selected) {
14663
15514
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
14664
15515
  const style = loaded.manifest.styles[spec.styleId];
14665
- const defaultBase = path28.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
14666
- const base = args.out ? path28.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
15516
+ const defaultBase = path30.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
15517
+ const base = args.out ? path30.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
14667
15518
  const result = exportTileset(entry, spec, {
14668
15519
  format,
14669
15520
  manifestDir: manifestDir2,
14670
- imageName: path28.basename(`${base}.png`),
15521
+ imageName: path30.basename(`${base}.png`),
14671
15522
  columns: args.columns
14672
15523
  });
14673
15524
  const outputs = [
@@ -14685,7 +15536,7 @@ async function main() {
14685
15536
  asset: spec.assetId,
14686
15537
  columns: args.columns ?? null,
14687
15538
  format,
14688
- image: path28.basename(`${base}.png`),
15539
+ image: path30.basename(`${base}.png`),
14689
15540
  providerRules: result.generic.providerRules,
14690
15541
  style: spec.styleId,
14691
15542
  tileType: spec.tileType ?? null
@@ -14695,7 +15546,7 @@ async function main() {
14695
15546
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
14696
15547
  );
14697
15548
  log(
14698
- ` ${path28.relative(process.cwd(), base)}.png + ${path28.basename(base)}${result.extension} + .pixelkiln.json`
15549
+ ` ${path30.relative(process.cwd(), base)}.png + ${path30.basename(base)}${result.extension} + .pixelkiln.json`
14699
15550
  );
14700
15551
  }
14701
15552
  return;
@@ -14745,12 +15596,12 @@ async function main() {
14745
15596
  tagged ${n} object(s) upstream`);
14746
15597
  }
14747
15598
  if (args.writePrompts) {
14748
- const { filled, stillEmpty } = await writePromptsBack(path28.resolve(args.manifest), lock, {
15599
+ const { filled, stillEmpty } = await writePromptsBack(path30.resolve(args.manifest), lock, {
14749
15600
  onProgress: log,
14750
15601
  provider: provider.id,
14751
15602
  assetIds: specs.map((spec) => spec.assetId)
14752
15603
  });
14753
- log(` recovered ${filled} prompt(s) into ${path28.relative(process.cwd(), args.manifest)}`);
15604
+ log(` recovered ${filled} prompt(s) into ${path30.relative(process.cwd(), args.manifest)}`);
14754
15605
  const reloaded = await loadManifest(args.manifest);
14755
15606
  const rebased = (await resolveSpecs(reloaded, {
14756
15607
  styles: args.styles,
@@ -14779,12 +15630,12 @@ async function main() {
14779
15630
  const provider = providerFor(selectedAccountProvider);
14780
15631
  const jsonMode = args.dryRun && args.json;
14781
15632
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
14782
- const ownLock = path28.resolve(args.lock);
15633
+ const ownLock = path30.resolve(args.lock);
14783
15634
  let workspaceProjects = [];
14784
15635
  let workspaceDir = "";
14785
15636
  if (args.workspace) {
14786
- const workspacePath = path28.resolve(args.workspace);
14787
- workspaceDir = path28.dirname(workspacePath);
15637
+ const workspacePath = path30.resolve(args.workspace);
15638
+ workspaceDir = path30.dirname(workspacePath);
14788
15639
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
14789
15640
  workspaceProjects = complete.ws.projects;
14790
15641
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -14793,12 +15644,12 @@ async function main() {
14793
15644
  const lockPaths = [
14794
15645
  .../* @__PURE__ */ new Set([
14795
15646
  ...workspaceLockPaths,
14796
- ...existsSync22(ownLock) ? [ownLock] : [],
14797
- ...args.claims.map((c) => path28.resolve(c))
15647
+ ...existsSync23(ownLock) ? [ownLock] : [],
15648
+ ...args.claims.map((c) => path30.resolve(c))
14798
15649
  ])
14799
15650
  ];
14800
15651
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
14801
- for (const p of lockPaths) diag(` ${path28.relative(process.cwd(), p)}`);
15652
+ for (const p of lockPaths) diag(` ${path30.relative(process.cwd(), p)}`);
14802
15653
  if (!args.claims.length && !args.workspace) {
14803
15654
  diag(
14804
15655
  `
@@ -14806,7 +15657,7 @@ async function main() {
14806
15657
  pass every other project's lockfile via --claims a.json,b.json, or
14807
15658
  register every project in a workspace catalog and pass --workspace.`
14808
15659
  );
14809
- } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path28.resolve(args.manifest))) {
15660
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path30.resolve(args.manifest))) {
14810
15661
  diag(
14811
15662
  `
14812
15663
  This project's manifest is not registered in the workspace catalog. Its own
@@ -14890,7 +15741,7 @@ async function main() {
14890
15741
  manifestPath: loaded.path,
14891
15742
  manifest: accountManifest,
14892
15743
  styleId,
14893
- importDir: path28.resolve(loaded.root, style.outDir),
15744
+ importDir: path30.resolve(loaded.root, style.outDir),
14894
15745
  lock,
14895
15746
  lockPath: args.lock
14896
15747
  },