pixelkiln 0.23.0 → 0.25.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,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path26 from "path";
5
- import { existsSync as existsSync21 } from "fs";
4
+ import path28 from "path";
5
+ import { existsSync as existsSync22 } from "fs";
6
6
  import { readFile as readFile22 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
@@ -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(path27) {
137
+ async function sha256File(path29) {
138
138
  return new Promise((resolve, reject) => {
139
139
  const hash = createHash("sha256");
140
- const stream = createReadStream(path27);
140
+ const stream = createReadStream(path29);
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(path27, init, attempt = 0) {
1620
+ async request(path29, 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}${path27}`, {
1624
+ res = await fetch(`${BASE}${path29}`, {
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(path27, init, attempt + 1);
1636
+ return this.request(path29, 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(path27, init, attempt + 1);
1643
+ return this.request(path29, init, attempt + 1);
1644
1644
  }
1645
1645
  const text = await res.text();
1646
1646
  if (!res.ok) {
1647
- throw new PixelLabError(`${init?.method ?? "GET"} ${path27} \u2192 ${res.status}`, res.status, text);
1647
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path29} \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"} ${path27} returned invalid JSON`);
1653
+ throw new Error(`${init?.method ?? "GET"} ${path29} returned invalid JSON`);
1654
1654
  }
1655
1655
  }
1656
1656
  async balance() {
@@ -2112,6 +2112,15 @@ var AssetSchema = z2.object({
2112
2112
  * that wasn't. `prompt` still records what was asked for.
2113
2113
  */
2114
2114
  source: z2.string().optional(),
2115
+ /**
2116
+ * Per-style replacement for `source`, keyed by style id.
2117
+ *
2118
+ * A hand edit belongs to one generation: the same asset generated in two
2119
+ * styles is two different pictures, so the touch-up of one must not be
2120
+ * placed for the other. `pixelkiln edit` writes here when an asset is in
2121
+ * more than one style, and to `source` when it is in exactly one.
2122
+ */
2123
+ sourceByStyle: z2.record(z2.string().min(1)).default({}),
2115
2124
  /** Controlled generation derived from another asset in the same style. */
2116
2125
  revision: RevisionSchema.optional(),
2117
2126
  /**
@@ -2146,7 +2155,7 @@ var AssetSchema = z2.object({
2146
2155
  z2.array(z2.union([z2.string(), z2.number().finite(), z2.boolean()])).min(2).max(64)
2147
2156
  ])).default({})
2148
2157
  }).strict().superRefine((asset, context) => {
2149
- if (asset.source && asset.revision) {
2158
+ if ((asset.source || Object.keys(asset.sourceByStyle).length) && asset.revision) {
2150
2159
  context.addIssue({
2151
2160
  code: z2.ZodIssueCode.custom,
2152
2161
  message: "source and revision are mutually exclusive",
@@ -2280,6 +2289,10 @@ function lockKey(styleId, assetId) {
2280
2289
  }
2281
2290
 
2282
2291
  // src/providers/pixellab.ts
2292
+ function pixelLabObjectUrl(generator, objectId) {
2293
+ if (!objectId || generator !== "map" && generator !== "1dir") return null;
2294
+ return `https://www.pixellab.ai/objects/${encodeURIComponent(objectId)}`;
2295
+ }
2283
2296
  var PixelLabProvider = class _PixelLabProvider {
2284
2297
  constructor(client) {
2285
2298
  this.client = client;
@@ -2613,9 +2626,9 @@ var RetroDiffusionClient = class {
2613
2626
  }
2614
2627
  return balance;
2615
2628
  }
2616
- async call(path27, init = {}) {
2629
+ async call(path29, init = {}) {
2617
2630
  if (!this.token) throw new Error("RD_API_KEY is not set");
2618
- const response = await this.request(`${this.baseUrl}${path27}`, {
2631
+ const response = await this.request(`${this.baseUrl}${path29}`, {
2619
2632
  ...init,
2620
2633
  headers: {
2621
2634
  "Content-Type": "application/json",
@@ -3784,6 +3797,11 @@ ${issues}`);
3784
3797
  unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
3785
3798
  }
3786
3799
  }
3800
+ for (const styleId of Object.keys(asset.sourceByStyle)) {
3801
+ if (!styleIds.has(styleId)) {
3802
+ unknownReferences.push(`assets.${assetId}.sourceByStyle: unknown style "${styleId}"`);
3803
+ }
3804
+ }
3787
3805
  if (asset.revision) {
3788
3806
  if (!parsed.data.assets[asset.revision.from]) {
3789
3807
  unknownReferences.push(
@@ -4048,7 +4066,7 @@ async function resolveSpecs(loaded, filter) {
4048
4066
  }
4049
4067
  } : {},
4050
4068
  tags,
4051
- source: asset.source,
4069
+ source: asset.sourceByStyle[styleId] ?? asset.source,
4052
4070
  // Revision identity is attached after every dependency in this style
4053
4071
  // has a concrete output target. Finalization below computes the hash.
4054
4072
  specHash: ""
@@ -4306,16 +4324,24 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
4306
4324
  }
4307
4325
  const prefix = `${styleId}/`;
4308
4326
  const entries = Object.entries(lock.entries).filter(([key]) => key.startsWith(prefix));
4309
- if (!entries.length) {
4327
+ const sources = options.sources ?? {};
4328
+ if (!entries.length && !Object.keys(sources).length) {
4310
4329
  throw new Error(
4311
4330
  `No locked assets for style "${styleId}". Run \`pixelkiln gen --style ${styleId}\` first.`
4312
4331
  );
4313
4332
  }
4314
4333
  const inputs = [];
4315
4334
  const noOutput = [];
4335
+ const locked = /* @__PURE__ */ new Set();
4316
4336
  for (const [key, entry] of entries) {
4317
4337
  const id = key.slice(prefix.length);
4338
+ locked.add(id);
4318
4339
  const outputs = resolveEntryOutputs(entry, id, manifestDir);
4340
+ const source = sources[id];
4341
+ if (source && outputs.length <= 1) {
4342
+ inputs.push({ id, path: path7.resolve(manifestDir, source) });
4343
+ continue;
4344
+ }
4319
4345
  let selected = outputs;
4320
4346
  if (options.outputRoles?.length) {
4321
4347
  selected = outputs.filter(
@@ -4332,6 +4358,9 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
4332
4358
  }
4333
4359
  for (const output of selected) inputs.push({ id: output.id, path: output.absolutePath });
4334
4360
  }
4361
+ for (const [id, source] of Object.entries(sources)) {
4362
+ if (!locked.has(id)) inputs.push({ id, path: path7.resolve(manifestDir, source) });
4363
+ }
4335
4364
  if (!inputs.length) {
4336
4365
  throw new Error(`No readable sprites for style "${styleId}" \u2014 ${noOutput.length} skipped.`);
4337
4366
  }
@@ -6423,14 +6452,16 @@ function shouldPersistSourceUrl(value) {
6423
6452
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6424
6453
  const log2 = opts.onProgress ?? (() => {
6425
6454
  });
6426
- const result = { downloaded: 0, skipped: 0, failed: 0 };
6455
+ const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
6427
6456
  const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
6428
6457
  normalizeLockOutputPaths(lock, specs);
6429
6458
  const cacheDir = opts.cacheDir === false ? null : path14.resolve(opts.cacheDir ?? path14.join(path14.dirname(lockPath), ".pixelkiln", "cache"));
6430
6459
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
6431
6460
  if (e.provider !== provider.id || !specByKey.has(key)) return false;
6432
6461
  if (e.status === "selected" || e.status === "download-failed") return true;
6433
- if (!opts.repair || e.status !== "downloaded") return false;
6462
+ if (e.status !== "downloaded") return false;
6463
+ if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
6464
+ if (!opts.repair) return false;
6434
6465
  const spec = specByKey.get(key);
6435
6466
  return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync9(resolveOutputPath(output.path, spec.root)));
6436
6467
  });
@@ -6462,6 +6493,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6462
6493
  continue;
6463
6494
  }
6464
6495
  const outputs = [];
6496
+ let wrote = false;
6465
6497
  try {
6466
6498
  for (let index = 0; index < sources.length; index++) {
6467
6499
  const source = sources[index];
@@ -6469,41 +6501,55 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6469
6501
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
6470
6502
  );
6471
6503
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
6504
+ let fresh = null;
6505
+ if (opts.refresh) {
6506
+ if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
6507
+ fresh = await provider.download(source.url);
6508
+ if (recorded && sha256(fresh) === recorded.sha256) {
6509
+ log2(` current ${path14.relative(process.cwd(), target)}`);
6510
+ outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
6511
+ continue;
6512
+ }
6513
+ }
6472
6514
  if (existsSync9(target)) {
6473
6515
  const currentHash = await sha256File(target);
6474
6516
  if (recorded && currentHash === recorded.sha256) {
6475
- if (cacheDir) {
6476
- await cacheMedia(
6477
- cacheDir,
6478
- await readFile7(target),
6479
- recorded.mediaType ?? MediaType.PNG,
6480
- recorded.sha256
6481
- );
6517
+ if (!fresh) {
6518
+ if (cacheDir) {
6519
+ await cacheMedia(
6520
+ cacheDir,
6521
+ await readFile7(target),
6522
+ recorded.mediaType ?? MediaType.PNG,
6523
+ recorded.sha256
6524
+ );
6525
+ }
6526
+ outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
6527
+ continue;
6482
6528
  }
6483
- outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
6484
- continue;
6485
- }
6486
- const superseded = (entry.supersededOutputs ?? []).find(
6487
- (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
6488
- );
6489
- if (!opts.force) {
6490
- if (!recorded && superseded && currentHash === superseded.sha256) {
6491
- } else if (recorded || superseded) {
6492
- throw new Error(
6493
- `refusing to overwrite modified output ${target}; pass --force to replace it`
6494
- );
6495
- } else {
6496
- throw new Error(
6497
- `refusing to overwrite untracked output ${target}; pass --force to replace it`
6498
- );
6529
+ log2(` changed ${path14.relative(process.cwd(), target)} (upstream)`);
6530
+ } else {
6531
+ const superseded = (entry.supersededOutputs ?? []).find(
6532
+ (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
6533
+ );
6534
+ if (!opts.force) {
6535
+ if (!recorded && superseded && currentHash === superseded.sha256) {
6536
+ } else if (recorded || superseded) {
6537
+ throw new Error(
6538
+ `refusing to overwrite modified output ${target}; pass --force to replace it`
6539
+ );
6540
+ } else {
6541
+ throw new Error(
6542
+ `refusing to overwrite untracked output ${target}; pass --force to replace it`
6543
+ );
6544
+ }
6499
6545
  }
6546
+ log2(
6547
+ ` replace ${path14.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
6548
+ );
6500
6549
  }
6501
- log2(
6502
- ` replace ${path14.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
6503
- );
6504
6550
  }
6505
6551
  const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
6506
- let buf = recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null;
6552
+ let buf = fresh ?? (recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null);
6507
6553
  if (buf) {
6508
6554
  log2(` cached ${path14.relative(process.cwd(), target)}`);
6509
6555
  } else {
@@ -6535,8 +6581,13 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6535
6581
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
6536
6582
  await saveLock(lockPath, lock);
6537
6583
  await rename3(tmp, target);
6584
+ wrote = true;
6538
6585
  log2(` wrote ${path14.relative(process.cwd(), target)}`);
6539
6586
  }
6587
+ if (opts.refresh && !wrote) {
6588
+ result.unchanged = (result.unchanged ?? 0) + 1;
6589
+ continue;
6590
+ }
6540
6591
  const persistentSources = sources.filter((source) => shouldPersistSourceUrl(source.url));
6541
6592
  upsert(lock, key, {
6542
6593
  status: "downloaded",
@@ -6821,10 +6872,10 @@ function isSha256Hash(value) {
6821
6872
  function parseCache(value) {
6822
6873
  return HashCacheSchema.parse(value);
6823
6874
  }
6824
- async function loadCache(path27) {
6825
- if (!existsSync11(path27)) return { version: 1, hashes: {} };
6875
+ async function loadCache(path29) {
6876
+ if (!existsSync11(path29)) return { version: 1, hashes: {} };
6826
6877
  try {
6827
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path27, "utf8")));
6878
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path29, "utf8")));
6828
6879
  if (!parsed.success) return { version: 1, hashes: {} };
6829
6880
  return {
6830
6881
  version: 1,
@@ -6836,18 +6887,18 @@ async function loadCache(path27) {
6836
6887
  return { version: 1, hashes: {} };
6837
6888
  }
6838
6889
  }
6839
- async function saveCache(path27, cache) {
6890
+ async function saveCache(path29, cache) {
6840
6891
  const sorted = {};
6841
6892
  for (const key of Object.keys(cache.hashes).sort()) {
6842
6893
  const hash = cache.hashes[key];
6843
6894
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6844
6895
  sorted[key] = hash;
6845
6896
  }
6846
- await mkdir4(pathModule.dirname(pathModule.resolve(path27)), { recursive: true });
6847
- const tmp = `${path27}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6897
+ await mkdir4(pathModule.dirname(pathModule.resolve(path29)), { recursive: true });
6898
+ const tmp = `${path29}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6848
6899
  try {
6849
6900
  await writeFile4(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6850
- await rename4(tmp, path27);
6901
+ await rename4(tmp, path29);
6851
6902
  } finally {
6852
6903
  await rm5(tmp, { force: true });
6853
6904
  }
@@ -7096,6 +7147,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
7096
7147
  assets[asset.id] = {
7097
7148
  prompt: "",
7098
7149
  promptByStyle: {},
7150
+ sourceByStyle: {},
7099
7151
  category: asset.category || void 0,
7100
7152
  file: asset.file,
7101
7153
  tags: [],
@@ -7789,9 +7841,9 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
7789
7841
 
7790
7842
  // src/gallery/snapshot.ts
7791
7843
  import { createHash as createHash3 } from "crypto";
7792
- import { existsSync as existsSync16 } from "fs";
7844
+ import { existsSync as existsSync17 } from "fs";
7793
7845
  import { readFile as readFile15, stat as stat3 } from "fs/promises";
7794
- import path20 from "path";
7846
+ import path22 from "path";
7795
7847
 
7796
7848
  // src/workspace.ts
7797
7849
  import { mkdir as mkdir5, readFile as readFile13, rename as rename5, rm as rm6, writeFile as writeFile7 } from "fs/promises";
@@ -7927,6 +7979,10 @@ function validateWorkspace(ws, dir) {
7927
7979
  }
7928
7980
 
7929
7981
  // src/gallery/edit.ts
7982
+ import path21 from "path";
7983
+ import { z as z6 } from "zod";
7984
+
7985
+ // src/manifest-edit.ts
7930
7986
  import { readFile as readFile14, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
7931
7987
  import path19 from "path";
7932
7988
  import { z as z5 } from "zod";
@@ -7973,6 +8029,22 @@ var ManifestEditSchema = z5.discriminatedUnion("action", [
7973
8029
  expectedSha256: HexSha,
7974
8030
  asset: NewAssetSchema
7975
8031
  }).strict(),
8032
+ z5.object({
8033
+ action: z5.literal("set-source"),
8034
+ project: z5.string().min(1).optional(),
8035
+ assetId: z5.string().min(1),
8036
+ styleId: z5.string().min(1),
8037
+ /** Manifest-relative path of the art that stands in for this asset in this style. */
8038
+ source: z5.string().min(1),
8039
+ expectedSha256: HexSha
8040
+ }).strict(),
8041
+ z5.object({
8042
+ action: z5.literal("clear-source"),
8043
+ project: z5.string().min(1).optional(),
8044
+ assetId: z5.string().min(1),
8045
+ styleId: z5.string().min(1),
8046
+ expectedSha256: HexSha
8047
+ }).strict(),
7976
8048
  z5.object({
7977
8049
  action: z5.literal("patch-style"),
7978
8050
  project: z5.string().min(1).optional(),
@@ -8023,8 +8095,38 @@ function setOrDelete(target, key, value) {
8023
8095
  if (value === null || value === void 0) delete target[key];
8024
8096
  else target[key] = value;
8025
8097
  }
8098
+ function assetStyles(raw, asset) {
8099
+ const own = Array.isArray(asset.styles) ? asset.styles : [];
8100
+ return own.length ? own : Object.keys(raw.styles ?? {});
8101
+ }
8026
8102
  function applyEdit(raw, edit) {
8027
8103
  raw.assets ??= {};
8104
+ if (edit.action === "set-source" || edit.action === "clear-source") {
8105
+ const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
8106
+ if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
8107
+ if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
8108
+ throw new ManifestEditError(`unknown style "${edit.styleId}"`);
8109
+ }
8110
+ if (!assetStyles(raw, asset2).includes(edit.styleId)) {
8111
+ throw new ManifestEditError(`asset "${edit.assetId}" is not in style "${edit.styleId}"`);
8112
+ }
8113
+ const single = assetStyles(raw, asset2).length === 1;
8114
+ const byStyle = { ...asset2.sourceByStyle ?? {} };
8115
+ if (edit.action === "set-source") {
8116
+ if (single) {
8117
+ asset2.source = edit.source;
8118
+ delete byStyle[edit.styleId];
8119
+ } else {
8120
+ byStyle[edit.styleId] = edit.source;
8121
+ }
8122
+ } else {
8123
+ delete byStyle[edit.styleId];
8124
+ if (single) delete asset2.source;
8125
+ }
8126
+ if (Object.keys(byStyle).length) asset2.sourceByStyle = byStyle;
8127
+ else delete asset2.sourceByStyle;
8128
+ return;
8129
+ }
8028
8130
  if (edit.action === "patch-style") {
8029
8131
  const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
8030
8132
  if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
@@ -8128,10 +8230,134 @@ async function applyManifestEdit(manifestPath, edit) {
8128
8230
  }
8129
8231
  return { manifestPath: absolute, sha256: sha256(next), changed: true };
8130
8232
  }
8233
+
8234
+ // src/pipeline/hand-edit.ts
8235
+ import { spawn as spawn2 } from "child_process";
8236
+ import { existsSync as existsSync16 } from "fs";
8237
+ import { copyFile, mkdir as mkdir6 } from "fs/promises";
8238
+ import path20 from "path";
8239
+ var HAND_EDIT_DIR = "edits";
8240
+ function handEditPath(loaded, spec) {
8241
+ const style = loaded.manifest.styles[spec.styleId];
8242
+ const outDir = path20.resolve(loaded.root, style?.outDir ?? ".");
8243
+ const relative = path20.relative(outDir, spec.outFile);
8244
+ const inside = relative && !relative.startsWith("..") && !path20.isAbsolute(relative);
8245
+ const file = inside ? relative : path20.basename(spec.outFile);
8246
+ return path20.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8247
+ }
8248
+ function handEditBase(spec, lock) {
8249
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8250
+ if (entry) {
8251
+ if (entry.outputs.length !== 1) return null;
8252
+ const primary = primaryOutput(entry);
8253
+ if (!primary || primary.mediaType && primary.mediaType !== "image/png") return null;
8254
+ const file = currentEntryOutputPath(entry, spec, 0);
8255
+ return existsSync16(file) ? file : null;
8256
+ }
8257
+ return existsSync16(spec.outFile) ? spec.outFile : null;
8258
+ }
8259
+ async function startHandEdit(loaded, lock, spec, opts = {}) {
8260
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8261
+ if (entry && entry.outputs.length > 1) {
8262
+ throw new ManifestEditError(
8263
+ `${spec.styleId}/${spec.assetId} has ${entry.outputs.length} outputs; hand edits currently cover single-image assets`
8264
+ );
8265
+ }
8266
+ const existing = spec.source ? path20.resolve(loaded.root, spec.source) : null;
8267
+ const editPath = existing ?? handEditPath(loaded, spec);
8268
+ let created = false;
8269
+ if (!existsSync16(editPath)) {
8270
+ const base = handEditBase(spec, lock);
8271
+ if (!base) {
8272
+ throw new ManifestEditError(
8273
+ `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
8274
+ );
8275
+ }
8276
+ await mkdir6(path20.dirname(editPath), { recursive: true });
8277
+ await copyFile(base, editPath);
8278
+ created = true;
8279
+ }
8280
+ const source = portableOutputPath(editPath, loaded.root);
8281
+ let declared = false;
8282
+ let manifestSha256 = opts.expectedSha256 ?? await sha256File(loaded.path);
8283
+ if (spec.source !== source) {
8284
+ const result = await applyManifestEdit(loaded.path, {
8285
+ action: "set-source",
8286
+ assetId: spec.assetId,
8287
+ styleId: spec.styleId,
8288
+ source,
8289
+ expectedSha256: manifestSha256
8290
+ });
8291
+ declared = result.changed;
8292
+ manifestSha256 = result.sha256;
8293
+ }
8294
+ return { editPath, source, created, declared, manifestSha256 };
8295
+ }
8296
+ async function detachHandEdit(loaded, spec, opts = {}) {
8297
+ if (!spec.source) {
8298
+ return { manifestSha256: opts.expectedSha256 ?? await sha256File(loaded.path), changed: false };
8299
+ }
8300
+ const result = await applyManifestEdit(loaded.path, {
8301
+ action: "clear-source",
8302
+ assetId: spec.assetId,
8303
+ styleId: spec.styleId,
8304
+ expectedSha256: opts.expectedSha256 ?? await sha256File(loaded.path)
8305
+ });
8306
+ return { manifestSha256: result.sha256, changed: result.changed };
8307
+ }
8308
+ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
8309
+ const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
8310
+ const [program, ...args] = command;
8311
+ const child = spawn2(program, [...args, file], { stdio: "ignore", detached: true });
8312
+ child.on("error", () => {
8313
+ });
8314
+ child.unref();
8315
+ return command.join(" ");
8316
+ }
8317
+
8318
+ // 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}$/),
8326
+ /** Also launch the author's editor on the file. */
8327
+ open: z6.boolean().optional()
8328
+ }).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}$/)
8335
+ }).strict()
8336
+ ]);
8131
8337
  function createGalleryEditHandler(opts) {
8132
8338
  const log2 = opts.onProgress ?? (() => {
8133
8339
  });
8134
8340
  return async (body) => {
8341
+ const handEdit = HandEditRequestSchema.safeParse(body);
8342
+ if (handEdit.success) {
8343
+ if (!opts.loadProject) throw new ManifestEditError("hand edits are not available in this gallery");
8344
+ const request = handEdit.data;
8345
+ const ctx = await opts.loadProject(request.project);
8346
+ const spec = ctx.specs.find((candidate) => lockKey(candidate.styleId, candidate.assetId) === lockKey(request.styleId, request.assetId));
8347
+ if (!spec) throw new ManifestEditError(`"${request.styleId}/${request.assetId}" is not declared by the manifest`);
8348
+ if (request.action === "start-edit") {
8349
+ const started = await startHandEdit(ctx.loaded, ctx.lock, spec, { expectedSha256: request.expectedSha256 });
8350
+ log2(` hand edit ${started.created ? "created" : "found"}: ${started.source}${started.declared ? " (declared in the manifest)" : ""}`);
8351
+ if (request.open) {
8352
+ const command = (opts.openEditor ?? openInEditor)(started.editPath);
8353
+ log2(` opened with: ${command}`);
8354
+ }
8355
+ } else {
8356
+ const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
8357
+ if (result2.changed) log2(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
8358
+ }
8359
+ return opts.reload();
8360
+ }
8135
8361
  const parsed = ManifestEditSchema.safeParse(body);
8136
8362
  if (!parsed.success) {
8137
8363
  throw new ManifestEditError(
@@ -8146,9 +8372,9 @@ function createGalleryEditHandler(opts) {
8146
8372
  }
8147
8373
  const result = await applyManifestEdit(manifestPath, parsed.data);
8148
8374
  if (result.changed) {
8149
- const relative = path19.relative(process.cwd(), result.manifestPath);
8375
+ const relative = path21.relative(process.cwd(), result.manifestPath);
8150
8376
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
8151
- const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
8377
+ const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.action === "set-source" || parsed.data.action === "clear-source" ? `${parsed.data.styleId}/${parsed.data.assetId} source` : parsed.data.assetId;
8152
8378
  log2(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
8153
8379
  }
8154
8380
  return opts.reload();
@@ -8157,7 +8383,7 @@ function createGalleryEditHandler(opts) {
8157
8383
 
8158
8384
  // src/gallery/snapshot.ts
8159
8385
  function galleryMediaId(absolutePath) {
8160
- return createHash3("sha256").update(path20.resolve(absolutePath)).digest("hex").slice(0, 24);
8386
+ return createHash3("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
8161
8387
  }
8162
8388
  function galleryMediaRoute(id) {
8163
8389
  return `/media/${id}`;
@@ -8182,23 +8408,24 @@ function metadataFps(entry) {
8182
8408
  }
8183
8409
  return null;
8184
8410
  }
8185
- async function fileBytes(absolutePath) {
8411
+ async function fileInfo(absolutePath) {
8186
8412
  try {
8187
8413
  const info = await stat3(absolutePath);
8188
- return info.isFile() ? info.size : null;
8414
+ return info.isFile() ? { bytes: info.size, modifiedAt: info.mtime.toISOString() } : null;
8189
8415
  } catch {
8190
8416
  return null;
8191
8417
  }
8192
8418
  }
8193
8419
  async function describeOutput(media, root, absolutePath, recorded = {}) {
8194
- const bytes = await fileBytes(absolutePath);
8195
- const exists = bytes !== null;
8420
+ const info = await fileInfo(absolutePath);
8421
+ const exists = info !== null;
8196
8422
  const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
8197
8423
  let url = null;
8198
8424
  if (exists && mediaType) {
8199
8425
  const id = galleryMediaId(absolutePath);
8200
8426
  media.set(id, { path: absolutePath, contentType: mediaType });
8201
- url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
8427
+ const version = recorded.sha256 ?? `${info.bytes}-${Date.parse(info.modifiedAt).toString(36)}`;
8428
+ url = `${galleryMediaRoute(id)}?v=${version.slice(0, 24)}`;
8202
8429
  }
8203
8430
  return {
8204
8431
  path: portableOutputPath(absolutePath, root),
@@ -8207,17 +8434,18 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
8207
8434
  role: recorded.role ?? null,
8208
8435
  mediaType,
8209
8436
  exists,
8210
- bytes,
8437
+ bytes: info?.bytes ?? null,
8438
+ modifiedAt: info?.modifiedAt ?? null,
8211
8439
  url
8212
8440
  };
8213
8441
  }
8214
8442
  async function describeQuality(media, root, inspection) {
8215
8443
  const outputPaths = inspection.outputs ?? [inspection.output];
8216
8444
  const outputs = await Promise.all(
8217
- outputPaths.map((file) => describeOutput(media, root, path20.resolve(root, file)))
8445
+ outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
8218
8446
  );
8219
- const recordPath = path20.resolve(root, inspection.record);
8220
- const recordExists = existsSync16(recordPath);
8447
+ const recordPath = path22.resolve(root, inspection.record);
8448
+ const recordExists = existsSync17(recordPath);
8221
8449
  let options = null;
8222
8450
  let check = null;
8223
8451
  if (recordExists) {
@@ -8236,7 +8464,7 @@ async function describeQuality(media, root, inspection) {
8236
8464
  return {
8237
8465
  state: inspection.state,
8238
8466
  reason: inspection.reason,
8239
- output: portableOutputPath(path20.resolve(root, inspection.output), root),
8467
+ output: portableOutputPath(path22.resolve(root, inspection.output), root),
8240
8468
  record: portableOutputPath(recordPath, root),
8241
8469
  recordExists,
8242
8470
  outputs,
@@ -8286,15 +8514,24 @@ async function buildGallerySnapshot(opts) {
8286
8514
  const entry = lock.entries[key];
8287
8515
  const asset = loaded.manifest.assets[spec.assetId] ?? null;
8288
8516
  let outputs;
8517
+ let edit = null;
8518
+ let editStatus = null;
8289
8519
  if (entry && entry.outputs.length) {
8290
8520
  outputs = await Promise.all(
8291
8521
  entry.outputs.map(
8292
8522
  (output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
8293
8523
  )
8294
8524
  );
8525
+ if (spec.source) {
8526
+ const editPath = path22.resolve(root, spec.source);
8527
+ const editSha = existsSync17(editPath) ? await sha256File(editPath) : null;
8528
+ edit = await describeOutput(media, root, editPath, { sha256: editSha });
8529
+ 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";
8531
+ }
8295
8532
  } else if (spec.source) {
8296
- outputs = [await describeOutput(media, root, path20.resolve(root, spec.source))];
8297
- } else if (!entry && existsSync16(spec.outFile)) {
8533
+ outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
8534
+ } else if (!entry && existsSync17(spec.outFile)) {
8298
8535
  outputs = [await describeOutput(media, root, spec.outFile)];
8299
8536
  } else {
8300
8537
  outputs = [];
@@ -8338,6 +8575,10 @@ async function buildGallerySnapshot(opts) {
8338
8575
  providerMetadata: entry?.providerMetadata ?? {},
8339
8576
  asset,
8340
8577
  source: spec.source ?? null,
8578
+ edit,
8579
+ editStatus,
8580
+ upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8581
+ refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
8341
8582
  tags: spec.tags,
8342
8583
  category: asset?.category ?? null
8343
8584
  });
@@ -8390,6 +8631,10 @@ async function buildGallerySnapshot(opts) {
8390
8631
  providerMetadata: entry.providerMetadata ?? {},
8391
8632
  asset: null,
8392
8633
  source: null,
8634
+ edit: null,
8635
+ editStatus: null,
8636
+ upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8637
+ refreshable: false,
8393
8638
  tags: [],
8394
8639
  category: null
8395
8640
  });
@@ -8457,7 +8702,7 @@ async function buildGallerySnapshot(opts) {
8457
8702
  id: loaded.manifest.name,
8458
8703
  name: loaded.manifest.name,
8459
8704
  manifest: loaded.path,
8460
- lock: path20.resolve(opts.lockPath),
8705
+ lock: path22.resolve(opts.lockPath),
8461
8706
  root,
8462
8707
  provider: loaded.manifest.provider,
8463
8708
  account: null,
@@ -8482,7 +8727,7 @@ async function buildGallerySnapshot(opts) {
8482
8727
  return { snapshot, media };
8483
8728
  }
8484
8729
  async function buildWorkspaceGallerySnapshot(opts) {
8485
- const dir = path20.dirname(path20.resolve(opts.workspacePath));
8730
+ const dir = path22.dirname(path22.resolve(opts.workspacePath));
8486
8731
  const media = /* @__PURE__ */ new Map();
8487
8732
  const projects = [];
8488
8733
  const items = [];
@@ -8525,7 +8770,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
8525
8770
  id: project.id,
8526
8771
  name: loaded.manifest.name,
8527
8772
  manifest: manifestPath,
8528
- lock: path20.resolve(lockPath),
8773
+ lock: path22.resolve(lockPath),
8529
8774
  root: loaded.root,
8530
8775
  provider: loaded.manifest.provider,
8531
8776
  account: project.account ?? null,
@@ -8540,8 +8785,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
8540
8785
  id: project.id,
8541
8786
  name: project.id,
8542
8787
  manifest: manifestPath,
8543
- lock: path20.resolve(lockPath),
8544
- root: path20.dirname(manifestPath),
8788
+ lock: path22.resolve(lockPath),
8789
+ root: path22.dirname(manifestPath),
8545
8790
  provider: project.provider,
8546
8791
  account: project.account ?? null,
8547
8792
  manifestSha256: null,
@@ -8567,7 +8812,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
8567
8812
  version: 1,
8568
8813
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
8569
8814
  project: null,
8570
- workspace: { path: path20.resolve(opts.workspacePath), dir, projects },
8815
+ workspace: { path: path22.resolve(opts.workspacePath), dir, projects },
8571
8816
  filter,
8572
8817
  totals: {
8573
8818
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -8584,7 +8829,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
8584
8829
  }
8585
8830
 
8586
8831
  // src/gallery/server.ts
8587
- import { spawn as spawn2 } from "child_process";
8832
+ import { spawn as spawn3 } from "child_process";
8588
8833
  import { randomBytes } from "crypto";
8589
8834
  import { createServer as createServer2 } from "http";
8590
8835
  import { readFile as readFile16 } from "fs/promises";
@@ -8860,6 +9105,14 @@ function renderGallery(snapshot, opts = {}) {
8860
9105
  .tray > button { padding:5px 11px; font-size:12.5px; }
8861
9106
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
8862
9107
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
9108
+ .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
9109
+ .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
9110
+ background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
9111
+ .pair figure img { image-rendering:pixelated; display:block; max-width:100%; }
9112
+ .pair figcaption { font:11px/1.4 var(--mono); color:var(--dim); background:var(--panel); padding:2px 6px; margin-top:6px; }
9113
+ .card .pen { position:absolute; right:6px; top:6px; background:var(--panel); color:var(--accent-soft); border:1px solid var(--accent);
9114
+ font:700 11px/1 var(--mono); padding:3px 5px; }
9115
+ .hand-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
8863
9116
  .drawer .gen button.primary { padding:7px 14px; }
8864
9117
  @media (max-width: 720px) {
8865
9118
  .bar, .totals, .chips, .style, footer { padding-inline:14px; }
@@ -9059,7 +9312,7 @@ function renderJobs() {
9059
9312
  const row = el('div', 'job');
9060
9313
  const ph = el('span', 'ph');
9061
9314
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
9062
- const what = (job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
9315
+ const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
9063
9316
  (job.project ? ' in ' + job.project : '');
9064
9317
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
9065
9318
  last.title = job.keys.join('\\n');
@@ -9098,13 +9351,15 @@ function budgetLine() {
9098
9351
 
9099
9352
  // The confirm step: what will be sent, what it is estimated to cost, and what
9100
9353
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
9101
- function generateDialog(items, { project = null, force = false, resume = false } = {}) {
9354
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false } = {}) {
9102
9355
  const host = $('dialog-host');
9103
9356
  host.textContent = '';
9104
9357
  const wrap = el('div', 'dialog');
9105
9358
  const form = el('form');
9106
- form.append(el('h3', null, resume ? 'Resume ' + items.length + (items.length === 1 ? ' asset' : ' assets')
9107
- : (force ? 'Regenerate ' : 'Generate ') + items.length + (items.length === 1 ? ' asset' : ' assets')));
9359
+ const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
9360
+ form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
9361
+ : resume ? 'Resume ' + noun
9362
+ : (force ? 'Regenerate ' : 'Generate ') + noun));
9108
9363
  const table = el('table');
9109
9364
  const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
9110
9365
  table.append(thead);
@@ -9112,16 +9367,18 @@ function generateDialog(items, { project = null, force = false, resume = false }
9112
9367
  for (const item of items) {
9113
9368
  const tr = el('tr');
9114
9369
  tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
9115
- el('td', 'n', resume ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
9370
+ el('td', 'n', resume || refresh ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
9116
9371
  table.append(tr);
9117
- if (!resume) {
9372
+ if (!resume && !refresh) {
9118
9373
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
9119
9374
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
9120
9375
  }
9121
9376
  }
9122
9377
  form.append(table);
9123
9378
  const sum = el('div', 'sum');
9124
- if (resume) {
9379
+ if (refresh) {
9380
+ sum.append(el('div', null, 'Re-downloads each object from the provider and replaces the local file only when the object changed upstream \u2014 for example after editing it in the provider\u2019s own editor. Unchanged objects are left alone; a file you changed locally is refused. Nothing is submitted.'));
9381
+ } else if (resume) {
9125
9382
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
9126
9383
  } else {
9127
9384
  for (const [provider, g] of byProvider) {
@@ -9138,7 +9395,7 @@ function generateDialog(items, { project = null, force = false, resume = false }
9138
9395
  }
9139
9396
  form.append(sum);
9140
9397
  const actions = el('div', 'actions');
9141
- const go = el('button', 'primary', resume ? 'Resume' : 'Generate');
9398
+ const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
9142
9399
  go.type = 'submit';
9143
9400
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
9144
9401
  const msg = el('span', 'msg');
@@ -9152,6 +9409,7 @@ function generateDialog(items, { project = null, force = false, resume = false }
9152
9409
  if (project) body.project = project;
9153
9410
  if (force) body.force = true;
9154
9411
  if (resume) body.resume = true;
9412
+ if (refresh) body.refresh = true;
9155
9413
  const job = await postGenerate(body);
9156
9414
  host.textContent = '';
9157
9415
  GEN.jobs.unshift(job);
@@ -9306,7 +9564,8 @@ function clearFilters() {
9306
9564
 
9307
9565
  function thumb(item) {
9308
9566
  const cell = el('div', 'cell');
9309
- const shown = item.outputs.filter((o) => o.url);
9567
+ // A hand edit is what ships, so it is what the card shows.
9568
+ const shown = item.edit && item.edit.url && item.editStatus === 'edited' ? [item.edit] : item.outputs.filter((o) => o.url);
9310
9569
  if (!shown.length) {
9311
9570
  cell.classList.add('none');
9312
9571
  cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
@@ -9357,6 +9616,9 @@ function card(item) {
9357
9616
  body.append(meta);
9358
9617
  const cell = thumb(item);
9359
9618
  if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
9619
+ if (item.editStatus === 'edited' || item.editStatus === 'regenerated-since') {
9620
+ const pen = el('span', 'pen', '\u270E'); pen.title = 'hand-edited'; cell.append(pen);
9621
+ }
9360
9622
  c.append(cell, body);
9361
9623
  c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
9362
9624
  return c;
@@ -9437,6 +9699,13 @@ function renderMain(items) {
9437
9699
  g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
9438
9700
  tools.append(g);
9439
9701
  }
9702
+ const refreshable = snap.items.filter((i) => i.project === s.project && i.styleId === s.id && i.refreshable && i.upstreamUrl);
9703
+ if (GENERATION && refreshable.length) {
9704
+ const r = el('button', null, 'Pull upstream ' + refreshable.length); r.type = 'button';
9705
+ r.title = 'Re-download objects edited in the provider\u2019s own editor; no cost';
9706
+ r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
9707
+ tools.append(r);
9708
+ }
9440
9709
  const styleKey = 'style:' + (s.project || '') + ':' + s.id;
9441
9710
  if (EDITABLE && s.outDir) {
9442
9711
  const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
@@ -9530,6 +9799,30 @@ function candidatesControl(style) {
9530
9799
  return wrap;
9531
9800
  }
9532
9801
 
9802
+ function upstreamSection(item) {
9803
+ if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
9804
+ const s = el('section', 'meta');
9805
+ s.append(el('h3', null, 'Upstream'));
9806
+ const dl = el('dl');
9807
+ if (item.upstreamUrl) {
9808
+ const a = el('a', null, 'Open in ' + item.provider + ' \u2197');
9809
+ a.href = item.upstreamUrl; a.target = '_blank'; a.rel = 'noopener';
9810
+ row(dl, 'object', a);
9811
+ row(dl, 'note', item.provider === 'pixellab'
9812
+ ? 'The account object this generation came from. Edit it there with PixelLab\u2019s editor, then pull the changes back here; the lockfile records the new bytes as this generation.'
9813
+ : 'The provider\u2019s page for this object.');
9814
+ }
9815
+ s.append(dl);
9816
+ if (GENERATION && item.refreshable) {
9817
+ const acts = el('div', 'hand-actions');
9818
+ const b = el('button', null, 'Pull upstream changes \xB7 no cost'); b.type = 'button';
9819
+ b.onclick = () => generateDialog([item], { project: item.project, refresh: true });
9820
+ acts.append(b);
9821
+ s.append(acts);
9822
+ }
9823
+ return s;
9824
+ }
9825
+
9533
9826
  function generateActions(item) {
9534
9827
  const row = el('div', 'gen');
9535
9828
  if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
@@ -9683,6 +9976,78 @@ function openCompare() {
9683
9976
  close.focus({ preventScroll: true });
9684
9977
  }
9685
9978
 
9979
+ // ---- hand edits -------------------------------------------------------------
9980
+
9981
+ const EDIT_STATUS_TEXT = {
9982
+ same: 'A copy of the generated art, not changed yet. Open it in your editor and save.',
9983
+ edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
9984
+ 'regenerated-since': 'The generated art changed after this edit was saved \u2014 the edit is based on an older generation.',
9985
+ missing: 'The declared file is not on disk.',
9986
+ };
9987
+ async function postHandEdit(item, action, extra) {
9988
+ const pr = projectOf(item);
9989
+ const body = { action, assetId: item.assetId, styleId: item.styleId, expectedSha256: pr.manifestSha256, ...extra };
9990
+ if (item.project) body.project = item.project;
9991
+ snap = await postEdit(body);
9992
+ }
9993
+ function handEditSection(item) {
9994
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 &&
9995
+ item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png';
9996
+ if (!item.edit && !canStart) return null;
9997
+ const s = el('section', 'meta');
9998
+ s.append(el('h3', null, 'Hand edit'));
9999
+ if (item.edit) {
10000
+ const pair = el('div', 'pair');
10001
+ const shared = displayScale(item.width, item.height, 200, 160);
10002
+ const fig = (out, label) => {
10003
+ const f = el('figure');
10004
+ if (out && out.url) {
10005
+ const img = el('img'); img.src = out.url; img.alt = label;
10006
+ if (shared >= 1) { img.width = item.width * shared; img.height = item.height * shared; }
10007
+ f.append(img);
10008
+ } else f.append(el('span', 'state-dim', 'not on disk'));
10009
+ f.append(el('figcaption', null, label));
10010
+ return f;
10011
+ };
10012
+ pair.append(fig(item.outputs[0], 'generated'), fig(item.edit, 'edit'));
10013
+ s.append(pair);
10014
+ const dl = el('dl');
10015
+ const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
10016
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
10017
+ row(dl, 'status', st);
10018
+ row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
10019
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
10020
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
10021
+ row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
10022
+ s.append(dl);
10023
+ } else {
10024
+ 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.'));
10025
+ }
10026
+ if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
10027
+ const acts = el('div', 'hand-actions');
10028
+ const msg = el('span', 'msg');
10029
+ const go = async (label, action, extra, done) => {
10030
+ const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
10031
+ b.onclick = async () => {
10032
+ b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
10033
+ try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
10034
+ catch (err) { b.disabled = false; msg.className = 'msg bad'; msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : ''); }
10035
+ };
10036
+ acts.append(b);
10037
+ };
10038
+ if (item.edit) {
10039
+ go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
10040
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
10041
+ } else if (canStart) {
10042
+ 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.');
10043
+ go('Create edit file only', 'start-edit', { open: false }, 'Created the edit file and declared it in the manifest.');
10044
+ }
10045
+ acts.append(msg);
10046
+ s.append(acts);
10047
+ }
10048
+ return s;
10049
+ }
10050
+
9686
10051
  // ---- editing (only when the server minted a session) ---------------------
9687
10052
 
9688
10053
  // Styles a parent edit reaches: every descendant (via extends) that does not
@@ -10174,6 +10539,11 @@ function renderDrawer() {
10174
10539
  body.append(s);
10175
10540
  }
10176
10541
 
10542
+ const hand = handEditSection(item);
10543
+ if (hand) body.append(hand);
10544
+ const upstream = upstreamSection(item);
10545
+ if (upstream) body.append(upstream);
10546
+
10177
10547
  if (item.revision || item.revisionParentKey) {
10178
10548
  const { s, dl } = section('Lineage');
10179
10549
  row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
@@ -10641,7 +11011,7 @@ async function serveGallery(opts) {
10641
11011
  });
10642
11012
  opts.onReady?.(url);
10643
11013
  if (opts.open !== false && process.platform === "darwin") {
10644
- spawn2("open", [url], { stdio: "ignore", detached: true }).unref();
11014
+ spawn3("open", [url], { stdio: "ignore", detached: true }).unref();
10645
11015
  }
10646
11016
  return {
10647
11017
  url,
@@ -10655,15 +11025,17 @@ async function serveGallery(opts) {
10655
11025
 
10656
11026
  // src/gallery/generate.ts
10657
11027
  import { randomBytes as randomBytes2 } from "crypto";
10658
- import { z as z6 } from "zod";
10659
- var GenerateRequestSchema = z6.object({
10660
- project: z6.string().min(1).optional(),
10661
- keys: z6.array(z6.string().min(1)).min(1).max(500),
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),
10662
11032
  /** Regenerate work that is up to date, as `gen --force` does. */
10663
- force: z6.boolean().optional(),
11033
+ force: z7.boolean().optional(),
10664
11034
  /** Advance in-flight, review, or selected work without submitting anything. */
10665
- resume: z6.boolean().optional()
10666
- }).strict();
11035
+ resume: z7.boolean().optional(),
11036
+ /** Re-download downloaded outputs and replace files whose object changed upstream. */
11037
+ refresh: z7.boolean().optional()
11038
+ }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
10667
11039
  var GenerateRequestError = class extends Error {
10668
11040
  constructor(message6, status = 400) {
10669
11041
  super(message6);
@@ -10721,6 +11093,20 @@ function createGenerateHandlers(opts) {
10721
11093
  }
10722
11094
  return groups;
10723
11095
  };
11096
+ async function refresh(job, ctx, specs) {
11097
+ job.phase = "fetching";
11098
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11099
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
11100
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
11101
+ onProgress: (line) => say(job, line),
11102
+ refresh: true
11103
+ });
11104
+ job.counts.downloaded += res.downloaded;
11105
+ say(job, `${providerId}: ${res.downloaded} changed upstream and replaced, ${res.unchanged ?? 0} unchanged, ${res.skipped} skipped, ${res.failed} failed`);
11106
+ }
11107
+ job.phase = "done";
11108
+ job.finishedAt = now().toISOString();
11109
+ }
10724
11110
  async function settle(job, ctx, specs) {
10725
11111
  job.phase = "polling";
10726
11112
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
@@ -10754,6 +11140,10 @@ function createGenerateHandlers(opts) {
10754
11140
  }
10755
11141
  async function run(job, ctx, specs, groups) {
10756
11142
  try {
11143
+ if (job.mode === "refresh") {
11144
+ await refresh(job, ctx, specs);
11145
+ return;
11146
+ }
10757
11147
  if (job.mode === "generate") {
10758
11148
  job.phase = "submitting";
10759
11149
  for (const group of groups) {
@@ -10817,7 +11207,7 @@ function createGenerateHandlers(opts) {
10817
11207
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
10818
11208
  }
10819
11209
  let groups = [];
10820
- if (!request.resume) {
11210
+ if (!request.resume && !request.refresh) {
10821
11211
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
10822
11212
  if (!plan.actionable.length) {
10823
11213
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -10837,7 +11227,7 @@ function createGenerateHandlers(opts) {
10837
11227
  id: randomBytes2(8).toString("hex"),
10838
11228
  project: request.project ?? null,
10839
11229
  keys: request.keys,
10840
- mode: request.resume ? "resume" : "generate",
11230
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
10841
11231
  phase: "queued",
10842
11232
  startedAt: now().toISOString(),
10843
11233
  finishedAt: null,
@@ -10849,7 +11239,7 @@ function createGenerateHandlers(opts) {
10849
11239
  };
10850
11240
  jobs.set(job.id, job);
10851
11241
  contexts.set(job.id, ctx);
10852
- say(job, request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
11242
+ say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
10853
11243
  void run(job, ctx, specs, groups);
10854
11244
  return job;
10855
11245
  },
@@ -10900,15 +11290,15 @@ function createGenerateHandlers(opts) {
10900
11290
 
10901
11291
  // src/pipeline/salvage.ts
10902
11292
  import { readFile as readFile17 } from "fs/promises";
10903
- import { existsSync as existsSync17 } from "fs";
10904
- import path21 from "path";
11293
+ import { existsSync as existsSync18 } from "fs";
11294
+ import path23 from "path";
10905
11295
  function providerClaimId(provider, objectId) {
10906
11296
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
10907
11297
  }
10908
11298
  async function loadClaims(lockPaths, opts = {}) {
10909
11299
  const claimed = /* @__PURE__ */ new Set();
10910
11300
  for (const p of lockPaths) {
10911
- if (!existsSync17(p)) throw new Error(`Claim lockfile not found: ${p}`);
11301
+ if (!existsSync18(p)) throw new Error(`Claim lockfile not found: ${p}`);
10912
11302
  let parsed;
10913
11303
  try {
10914
11304
  parsed = parseLock(JSON.parse(await readFile17(p, "utf8")));
@@ -10959,20 +11349,20 @@ function matchStyleByPattern(prompt, manifest) {
10959
11349
  return null;
10960
11350
  }
10961
11351
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
10962
- const own = path21.resolve(ownManifestPath);
11352
+ const own = path23.resolve(ownManifestPath);
10963
11353
  const siblingManifestPaths = [
10964
11354
  .../* @__PURE__ */ new Set([
10965
11355
  ...workspaceManifestPaths,
10966
- ...claimPaths.map((c) => path21.join(path21.dirname(path21.resolve(c)), "pixelkiln.manifest.json"))
11356
+ ...claimPaths.map((c) => path23.join(path23.dirname(path23.resolve(c)), "pixelkiln.manifest.json"))
10967
11357
  ])
10968
11358
  ];
10969
11359
  const siblings = [];
10970
11360
  for (const siblingManifestPath of siblingManifestPaths) {
10971
- if (path21.resolve(siblingManifestPath) === own) continue;
10972
- if (!existsSync17(siblingManifestPath)) continue;
11361
+ if (path23.resolve(siblingManifestPath) === own) continue;
11362
+ if (!existsSync18(siblingManifestPath)) continue;
10973
11363
  try {
10974
11364
  const { manifest } = await loadManifest(siblingManifestPath);
10975
- siblings.push({ label: path21.basename(path21.dirname(siblingManifestPath)), manifest });
11365
+ siblings.push({ label: path23.basename(path23.dirname(siblingManifestPath)), manifest });
10976
11366
  } catch {
10977
11367
  }
10978
11368
  }
@@ -11191,15 +11581,15 @@ async function workspaceStatus(ws, dir) {
11191
11581
  }
11192
11582
 
11193
11583
  // src/pipeline/cache-health.ts
11194
- import { existsSync as existsSync18 } from "fs";
11584
+ import { existsSync as existsSync19 } from "fs";
11195
11585
  import { readFile as readFile18, readdir as readdir2, rm as rm8 } from "fs/promises";
11196
- import path22 from "path";
11586
+ import path24 from "path";
11197
11587
  async function inspectCaches(lock, lockPath, options = {}) {
11198
- if (options.prune && !existsSync18(lockPath)) {
11199
- throw new Error(`Refusing to prune without an existing lockfile at ${path22.resolve(lockPath)}`);
11588
+ if (options.prune && !existsSync19(lockPath)) {
11589
+ throw new Error(`Refusing to prune without an existing lockfile at ${path24.resolve(lockPath)}`);
11200
11590
  }
11201
- const contentDir = path22.resolve(path22.dirname(lockPath), ".pixelkiln", "cache");
11202
- const remotePath = path22.resolve(cachePathFor(lockPath));
11591
+ const contentDir = path24.resolve(path24.dirname(lockPath), ".pixelkiln", "cache");
11592
+ const remotePath = path24.resolve(cachePathFor(lockPath));
11203
11593
  const referenced = new Set(
11204
11594
  Object.values(lock.entries).flatMap((entry) => entry.outputs.map((output) => output.sha256))
11205
11595
  );
@@ -11213,7 +11603,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11213
11603
  ]);
11214
11604
  for (const name of names) {
11215
11605
  try {
11216
- await rm8(path22.join(contentDir, name), { force: true });
11606
+ await rm8(path24.join(contentDir, name), { force: true });
11217
11607
  removed.contentFiles++;
11218
11608
  } catch {
11219
11609
  }
@@ -11240,7 +11630,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11240
11630
  async function inspectContentCache(contentDir, referenced) {
11241
11631
  const report = {
11242
11632
  path: contentDir,
11243
- exists: existsSync18(contentDir),
11633
+ exists: existsSync19(contentDir),
11244
11634
  files: 0,
11245
11635
  bytes: 0,
11246
11636
  valid: 0,
@@ -11261,7 +11651,7 @@ async function inspectContentCache(contentDir, referenced) {
11261
11651
  continue;
11262
11652
  }
11263
11653
  report.files++;
11264
- const file = path22.join(contentDir, entry.name);
11654
+ const file = path24.join(contentDir, entry.name);
11265
11655
  const mediaType = mediaTypeFromExtension(entry.name);
11266
11656
  const expected = mediaType ? entry.name.slice(0, -4) : "";
11267
11657
  let bytes;
@@ -11304,7 +11694,7 @@ async function inspectContentCache(contentDir, referenced) {
11304
11694
  async function inspectRemoteHashCache(remotePath) {
11305
11695
  const report = {
11306
11696
  path: remotePath,
11307
- exists: existsSync18(remotePath),
11697
+ exists: existsSync19(remotePath),
11308
11698
  entries: 0,
11309
11699
  valid: 0,
11310
11700
  invalidIds: [],
@@ -11584,8 +11974,8 @@ function isRecord2(value) {
11584
11974
  }
11585
11975
 
11586
11976
  // src/pick/salvage-server.ts
11587
- import { mkdir as mkdir6, writeFile as writeFile9, readFile as readFile19 } from "fs/promises";
11588
- import path23 from "path";
11977
+ import { mkdir as mkdir7, writeFile as writeFile9, readFile as readFile19 } from "fs/promises";
11978
+ import path25 from "path";
11589
11979
 
11590
11980
  // src/pick/salvage-sheet.ts
11591
11981
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -11750,7 +12140,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11750
12140
  });
11751
12141
  const html = renderSalvageSheet(orphans, {
11752
12142
  styleId: ctx.styleId,
11753
- importDir: path23.relative(process.cwd(), ctx.importDir) || "."
12143
+ importDir: path25.relative(process.cwd(), ctx.importDir) || "."
11754
12144
  });
11755
12145
  const byId = new Map(orphans.map((o) => [o.id, o]));
11756
12146
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -11779,13 +12169,14 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11779
12169
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
11780
12170
  decodePng(buf);
11781
12171
  const assetId = idFromPrompt(orphan.prompt, taken);
11782
- const rel = path23.join("_salvaged", `${assetId}.png`);
11783
- const outFile = path23.resolve(ctx.importDir, rel);
11784
- await mkdir6(path23.dirname(outFile), { recursive: true });
12172
+ const rel = path25.join("_salvaged", `${assetId}.png`);
12173
+ const outFile = path25.resolve(ctx.importDir, rel);
12174
+ await mkdir7(path25.dirname(outFile), { recursive: true });
11785
12175
  await writeFile9(outFile, buf);
11786
12176
  ctx.manifest.assets[assetId] = {
11787
12177
  prompt: orphan.prompt,
11788
12178
  promptByStyle: {},
12179
+ sourceByStyle: {},
11789
12180
  category: "_salvaged",
11790
12181
  file: rel,
11791
12182
  tags: ["salvaged"],
@@ -11813,7 +12204,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11813
12204
  sourceUrl: durableSource,
11814
12205
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
11815
12206
  outputs: [{
11816
- path: portableOutputPath(outFile, path23.dirname(ctx.manifestPath)),
12207
+ path: portableOutputPath(outFile, path25.dirname(ctx.manifestPath)),
11817
12208
  sha256: sha256(buf)
11818
12209
  }],
11819
12210
  submittedAt: orphan.createdAt,
@@ -11847,51 +12238,51 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11847
12238
  }
11848
12239
 
11849
12240
  // src/recipes.ts
11850
- import path24 from "path";
11851
- import { existsSync as existsSync19 } from "fs";
12241
+ import path26 from "path";
12242
+ import { existsSync as existsSync20 } from "fs";
11852
12243
  import { readdir as readdir3, readFile as readFile20 } from "fs/promises";
11853
12244
  import { fileURLToPath } from "url";
11854
- import { z as z7 } from "zod";
12245
+ import { z as z8 } from "zod";
11855
12246
  var SHA256_RE = /^[0-9a-f]{64}$/;
11856
12247
  var RECIPE_ID_RE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
11857
12248
  var VERSION_RE = /^\d+\.\d+\.\d+$/;
11858
12249
  var RECIPE_FILE = "pixelkiln.recipe.json";
11859
12250
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
11860
- var PortablePathSchema = z7.string().min(1).refine((value) => {
11861
- if (value.includes("\\") || path24.posix.isAbsolute(value)) return false;
12251
+ var PortablePathSchema = z8.string().min(1).refine((value) => {
12252
+ if (value.includes("\\") || path26.posix.isAbsolute(value)) return false;
11862
12253
  const parts = value.split("/");
11863
12254
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
11864
12255
  }, "expected a portable relative path without '.', '..', or backslashes");
11865
- var RecipeBindingSchema = z7.object({
11866
- nodeId: z7.string().min(1),
11867
- input: z7.string().min(1)
12256
+ var RecipeBindingSchema = z8.object({
12257
+ nodeId: z8.string().min(1),
12258
+ input: z8.string().min(1)
11868
12259
  }).strict();
11869
- var RecipeSchema = z7.object({
11870
- $schema: z7.string().url().optional(),
11871
- format: z7.literal("pixelkiln-recipe"),
11872
- schemaVersion: z7.literal(1),
11873
- id: z7.string().regex(RECIPE_ID_RE, "expected provider/name"),
11874
- version: z7.string().regex(VERSION_RE, "expected x.y.z"),
11875
- provider: z7.string().regex(/^[a-z0-9][a-z0-9-]*$/),
11876
- summary: z7.string().min(1).max(240),
11877
- files: z7.array(z7.object({
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({
11878
12269
  path: PortablePathSchema,
11879
- role: z7.enum(["workflow", "reference"]),
11880
- sha256: z7.string().regex(SHA256_RE)
12270
+ role: z8.enum(["workflow", "reference"]),
12271
+ sha256: z8.string().regex(SHA256_RE)
11881
12272
  }).strict()).default([]),
11882
- models: z7.array(z7.object({
12273
+ models: z8.array(z8.object({
11883
12274
  path: PortablePathSchema,
11884
- sha256: z7.string().regex(SHA256_RE),
11885
- source: z7.string().url(),
11886
- license: z7.string().min(1)
12275
+ sha256: z8.string().regex(SHA256_RE),
12276
+ source: z8.string().url(),
12277
+ license: z8.string().min(1)
11887
12278
  }).strict()).default([]),
11888
- styleId: z7.string().regex(/^[a-z0-9][a-z0-9-]*$/),
12279
+ styleId: z8.string().regex(/^[a-z0-9][a-z0-9-]*$/),
11889
12280
  style: StyleSchema,
11890
- workflow: z7.object({
12281
+ workflow: z8.object({
11891
12282
  path: PortablePathSchema,
11892
- outputNodeId: z7.string().min(1),
11893
- numImages: z7.number().int().min(1).max(16),
11894
- bindings: z7.object({
12283
+ outputNodeId: z8.string().min(1),
12284
+ numImages: z8.number().int().min(1).max(16),
12285
+ bindings: z8.object({
11895
12286
  prompt: RecipeBindingSchema,
11896
12287
  width: RecipeBindingSchema.optional(),
11897
12288
  height: RecipeBindingSchema.optional(),
@@ -11902,67 +12293,67 @@ var RecipeSchema = z7.object({
11902
12293
  strength: RecipeBindingSchema.optional()
11903
12294
  }).strict()
11904
12295
  }).strict().optional(),
11905
- quality: z7.object({
11906
- stage: z7.enum(["composition-source", "production-candidate"]),
11907
- workingCanvas: z7.object({
11908
- width: z7.number().int().min(16),
11909
- height: z7.number().int().min(16)
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)
11910
12301
  }).strict(),
11911
- recommendedNativeSize: z7.object({
11912
- min: z7.number().int().min(1),
11913
- max: z7.number().int().min(1)
12302
+ recommendedNativeSize: z8.object({
12303
+ min: z8.number().int().min(1),
12304
+ max: z8.number().int().min(1)
11914
12305
  }).strict(),
11915
- paletteColors: z7.object({
11916
- min: z7.number().int().min(1),
11917
- max: z7.number().int().min(1)
12306
+ paletteColors: z8.object({
12307
+ min: z8.number().int().min(1),
12308
+ max: z8.number().int().min(1)
11918
12309
  }).strict(),
11919
- background: z7.enum(["opaque", "transparent", "full-bleed"]),
11920
- checks: z7.array(z7.enum([
12310
+ background: z8.enum(["opaque", "transparent", "full-bleed"]),
12311
+ checks: z8.array(z8.enum([
11921
12312
  "prompt-coverage",
11922
12313
  "native-grid",
11923
12314
  "final-palette",
11924
12315
  "alpha-edge",
11925
12316
  "human-1x"
11926
12317
  ])).min(1),
11927
- notes: z7.array(z7.string().min(1)).default([])
12318
+ notes: z8.array(z8.string().min(1)).default([])
11928
12319
  }).strict(),
11929
- integrity: z7.object({
11930
- algorithm: z7.literal("sha256"),
11931
- digest: z7.string().regex(SHA256_RE)
12320
+ integrity: z8.object({
12321
+ algorithm: z8.literal("sha256"),
12322
+ digest: z8.string().regex(SHA256_RE)
11932
12323
  }).strict()
11933
12324
  }).strict().superRefine((recipe, context) => {
11934
12325
  if (recipe.style.provider && recipe.style.provider !== recipe.provider) {
11935
12326
  context.addIssue({
11936
- code: z7.ZodIssueCode.custom,
12327
+ code: z8.ZodIssueCode.custom,
11937
12328
  path: ["style", "provider"],
11938
12329
  message: "must match the recipe provider"
11939
12330
  });
11940
12331
  }
11941
12332
  const filePaths = recipe.files.map((file) => file.path);
11942
12333
  if (new Set(filePaths).size !== filePaths.length) {
11943
- context.addIssue({ code: z7.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
12334
+ context.addIssue({ code: z8.ZodIssueCode.custom, path: ["files"], message: "duplicate file path" });
11944
12335
  }
11945
12336
  const modelPaths = recipe.models.map((model) => model.path);
11946
12337
  if (new Set(modelPaths).size !== modelPaths.length) {
11947
- context.addIssue({ code: z7.ZodIssueCode.custom, path: ["models"], message: "duplicate model path" });
12338
+ context.addIssue({ code: z8.ZodIssueCode.custom, path: ["models"], message: "duplicate model path" });
11948
12339
  }
11949
12340
  if (filePaths.includes(RECIPE_FILE)) {
11950
12341
  context.addIssue({
11951
- code: z7.ZodIssueCode.custom,
12342
+ code: z8.ZodIssueCode.custom,
11952
12343
  path: ["files"],
11953
12344
  message: `${RECIPE_FILE} cannot hash itself`
11954
12345
  });
11955
12346
  }
11956
12347
  if (recipe.quality.recommendedNativeSize.min > recipe.quality.recommendedNativeSize.max) {
11957
12348
  context.addIssue({
11958
- code: z7.ZodIssueCode.custom,
12349
+ code: z8.ZodIssueCode.custom,
11959
12350
  path: ["quality", "recommendedNativeSize"],
11960
12351
  message: "min cannot exceed max"
11961
12352
  });
11962
12353
  }
11963
12354
  if (recipe.quality.paletteColors.min > recipe.quality.paletteColors.max) {
11964
12355
  context.addIssue({
11965
- code: z7.ZodIssueCode.custom,
12356
+ code: z8.ZodIssueCode.custom,
11966
12357
  path: ["quality", "paletteColors"],
11967
12358
  message: "min cannot exceed max"
11968
12359
  });
@@ -11971,7 +12362,7 @@ var RecipeSchema = z7.object({
11971
12362
  (file) => file.path === recipe.workflow.path && file.role === "workflow"
11972
12363
  )) {
11973
12364
  context.addIssue({
11974
- code: z7.ZodIssueCode.custom,
12365
+ code: z8.ZodIssueCode.custom,
11975
12366
  path: ["workflow", "path"],
11976
12367
  message: "must name a file whose role is workflow"
11977
12368
  });
@@ -11979,7 +12370,7 @@ var RecipeSchema = z7.object({
11979
12370
  if (recipe.provider === "comfyui") {
11980
12371
  if (!recipe.workflow) {
11981
12372
  context.addIssue({
11982
- code: z7.ZodIssueCode.custom,
12373
+ code: z8.ZodIssueCode.custom,
11983
12374
  path: ["workflow"],
11984
12375
  message: "ComfyUI recipes require workflow metadata"
11985
12376
  });
@@ -11988,7 +12379,7 @@ var RecipeSchema = z7.object({
11988
12379
  const options = recipe.style.providerOptions.comfyui;
11989
12380
  if (!options || typeof options !== "object" || Array.isArray(options)) {
11990
12381
  context.addIssue({
11991
- code: z7.ZodIssueCode.custom,
12382
+ code: z8.ZodIssueCode.custom,
11992
12383
  path: ["style", "providerOptions", "comfyui"],
11993
12384
  message: "ComfyUI recipes require ComfyUI provider options"
11994
12385
  });
@@ -12002,7 +12393,7 @@ var RecipeSchema = z7.object({
12002
12393
  };
12003
12394
  if (JSON.stringify(canonical3(options)) !== JSON.stringify(canonical3(expected))) {
12004
12395
  context.addIssue({
12005
- code: z7.ZodIssueCode.custom,
12396
+ code: z8.ZodIssueCode.custom,
12006
12397
  path: ["style", "providerOptions", "comfyui"],
12007
12398
  message: "must match workflow metadata and use {{recipeDir}} for workflowFile"
12008
12399
  });
@@ -12034,7 +12425,7 @@ function message4(error) {
12034
12425
  return error instanceof Error ? error.message : String(error);
12035
12426
  }
12036
12427
  async function readRecipeFile(recipePath, bundled) {
12037
- const absolute = path24.resolve(recipePath);
12428
+ const absolute = path26.resolve(recipePath);
12038
12429
  let raw;
12039
12430
  try {
12040
12431
  raw = JSON.parse(await readFile20(absolute, "utf8"));
@@ -12048,13 +12439,13 @@ async function readRecipeFile(recipePath, bundled) {
12048
12439
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
12049
12440
  );
12050
12441
  }
12051
- return { recipe: parsed.data, path: absolute, dir: path24.dirname(absolute), bundled };
12442
+ return { recipe: parsed.data, path: absolute, dir: path26.dirname(absolute), bundled };
12052
12443
  }
12053
12444
  async function walkRecipeFiles(root) {
12054
- if (!existsSync19(root)) return [];
12445
+ if (!existsSync20(root)) return [];
12055
12446
  const out = [];
12056
12447
  for (const entry of await readdir3(root, { withFileTypes: true })) {
12057
- const resolved = path24.join(root, entry.name);
12448
+ const resolved = path26.join(root, entry.name);
12058
12449
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
12059
12450
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
12060
12451
  }
@@ -12065,7 +12456,7 @@ function bundledRecipeRoot() {
12065
12456
  }
12066
12457
  async function listBundledRecipes(root = bundledRecipeRoot()) {
12067
12458
  const recipes = await Promise.all(
12068
- (await walkRecipeFiles(path24.resolve(root))).map((file) => readRecipeFile(file, true))
12459
+ (await walkRecipeFiles(path26.resolve(root))).map((file) => readRecipeFile(file, true))
12069
12460
  );
12070
12461
  return recipes.sort(
12071
12462
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -12088,9 +12479,9 @@ function parseSelector(selector) {
12088
12479
  return { id: match[1], version: match[2] };
12089
12480
  }
12090
12481
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12091
- const local = path24.resolve(target);
12092
- if (existsSync19(local)) {
12093
- const recipePath = path24.basename(local) === RECIPE_FILE ? local : path24.join(local, RECIPE_FILE);
12482
+ const local = path26.resolve(target);
12483
+ if (existsSync20(local)) {
12484
+ const recipePath = path26.basename(local) === RECIPE_FILE ? local : path26.join(local, RECIPE_FILE);
12094
12485
  return readRecipeFile(recipePath, false);
12095
12486
  }
12096
12487
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -12104,8 +12495,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12104
12495
  return matches[0];
12105
12496
  }
12106
12497
  async function verifyFile(root, file) {
12107
- const absolute = path24.resolve(root, ...file.path.split("/"));
12108
- if (!existsSync19(absolute)) {
12498
+ const absolute = path26.resolve(root, ...file.path.split("/"));
12499
+ if (!existsSync20(absolute)) {
12109
12500
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
12110
12501
  }
12111
12502
  const actualSha256 = await sha256File(absolute);
@@ -12120,7 +12511,7 @@ async function verifyRecipe(target, options = {}) {
12120
12511
  const loaded = await resolveRecipe(target, options.bundledRoot);
12121
12512
  const actualIntegrity = recipeDigest(loaded.recipe);
12122
12513
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
12123
- const modelRoot = options.modelRoot ? path24.resolve(options.modelRoot) : null;
12514
+ const modelRoot = options.modelRoot ? path26.resolve(options.modelRoot) : null;
12124
12515
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
12125
12516
  ...await verifyFile(modelRoot, model),
12126
12517
  source: model.source,
@@ -12154,7 +12545,7 @@ async function verifyRecipe(target, options = {}) {
12154
12545
  };
12155
12546
  }
12156
12547
  function renderStyle(style, destination, cwd) {
12157
- const portableDir = path24.relative(cwd, destination).split(path24.sep).join("/") || ".";
12548
+ const portableDir = path26.relative(cwd, destination).split(path26.sep).join("/") || ".";
12158
12549
  const replace = (value) => {
12159
12550
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
12160
12551
  if (Array.isArray(value)) return value.map(replace);
@@ -12166,24 +12557,24 @@ function renderStyle(style, destination, cwd) {
12166
12557
  return replace(style);
12167
12558
  }
12168
12559
  async function installRecipe(target, options = {}) {
12169
- const cwd = path24.resolve(options.cwd ?? process.cwd());
12560
+ const cwd = path26.resolve(options.cwd ?? process.cwd());
12170
12561
  const loaded = await resolveRecipe(target, options.bundledRoot);
12171
12562
  const verification = await verifyRecipe(loaded.path);
12172
12563
  if (!verification.ok) {
12173
12564
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
12174
12565
  }
12175
- const destination = options.out ? path24.resolve(cwd, options.out) : path24.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
12566
+ const destination = options.out ? path26.resolve(cwd, options.out) : path26.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
12176
12567
  const sources = [
12177
12568
  { path: RECIPE_FILE, source: loaded.path },
12178
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path24.join(loaded.dir, ...file.path.split("/")) }))
12569
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path26.join(loaded.dir, ...file.path.split("/")) }))
12179
12570
  ];
12180
12571
  const files = await Promise.all(sources.map(async (file) => ({
12181
- path: path24.join(destination, ...file.path.split("/")),
12572
+ path: path26.join(destination, ...file.path.split("/")),
12182
12573
  data: await readFile20(file.source)
12183
12574
  })));
12184
12575
  if (!options.force) {
12185
12576
  for (const file of files) {
12186
- if (!existsSync19(file.path)) continue;
12577
+ if (!existsSync20(file.path)) continue;
12187
12578
  const current = await readFile20(file.path);
12188
12579
  if (!current.equals(file.data)) {
12189
12580
  throw new Error(
@@ -12207,15 +12598,15 @@ async function installRecipe(target, options = {}) {
12207
12598
  }
12208
12599
 
12209
12600
  // src/pipeline/quality-regression.ts
12210
- import path25 from "path";
12211
- import { existsSync as existsSync20 } from "fs";
12601
+ import path27 from "path";
12602
+ import { existsSync as existsSync21 } from "fs";
12212
12603
  import { readFile as readFile21 } from "fs/promises";
12213
- import { z as z8 } from "zod";
12604
+ import { z as z9 } from "zod";
12214
12605
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
12215
12606
  var HEX_RE = /^#[0-9a-f]{6}$/;
12216
12607
  var MAX_REDMEAN_DISTANCE = 765;
12217
- var QualityPathSchema = z8.string().min(1).refine(
12218
- (value) => !path25.posix.isAbsolute(value) && !value.includes("\\"),
12608
+ var QualityPathSchema = z9.string().min(1).refine(
12609
+ (value) => !path27.posix.isAbsolute(value) && !value.includes("\\"),
12219
12610
  "expected a portable relative path"
12220
12611
  );
12221
12612
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -12228,53 +12619,53 @@ var DEFAULT_QUALITY_TOLERANCES = {
12228
12619
  maxEdgeContrastDrop: 0.03,
12229
12620
  maxIsolatedPixelIncrease: 5e-3
12230
12621
  };
12231
- var QualityToleranceSchema = z8.object({
12232
- requireExactHash: z8.boolean(),
12233
- maxColorCountIncrease: z8.number().int().min(0),
12234
- maxNewColors: z8.number().int().min(0),
12235
- maxTransparencyDelta: z8.number().min(0).max(1),
12236
- maxPartialAlphaIncrease: z8.number().min(0).max(1),
12237
- maxEdgeDensityDelta: z8.number().min(0).max(1),
12238
- maxEdgeContrastDrop: z8.number().min(0).max(1),
12239
- maxIsolatedPixelIncrease: z8.number().min(0).max(1)
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)
12240
12631
  }).strict();
12241
- var ImageQualityMetricsSchema = z8.object({
12242
- sha256: z8.string().regex(SHA256_RE2),
12243
- width: z8.number().int().min(1),
12244
- height: z8.number().int().min(1),
12245
- colorCount: z8.number().int().min(0),
12246
- colors: z8.array(z8.string().regex(HEX_RE)),
12247
- transparency: z8.number().min(0).max(1),
12248
- partialAlpha: z8.number().min(0).max(1),
12249
- edgeDensity: z8.number().min(0).max(1),
12250
- meanEdgeContrast: z8.number().min(0).max(1),
12251
- isolatedPixelRatio: z8.number().min(0).max(1)
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)
12252
12643
  }).strict();
12253
- var QualityBaselineCaseSchema = z8.object({
12254
- id: z8.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
12644
+ var QualityBaselineCaseSchema = z9.object({
12645
+ id: z9.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
12255
12646
  file: QualityPathSchema,
12256
- record: z8.object({
12647
+ record: z9.object({
12257
12648
  path: QualityPathSchema,
12258
- sha256: z8.string().regex(SHA256_RE2)
12649
+ sha256: z9.string().regex(SHA256_RE2)
12259
12650
  }).strict().optional(),
12260
12651
  expected: ImageQualityMetricsSchema,
12261
12652
  tolerances: QualityToleranceSchema
12262
12653
  }).strict();
12263
- var QualityBaselineSchema = z8.object({
12264
- $schema: z8.string().url().optional(),
12265
- format: z8.literal("pixelkiln-quality-baseline"),
12266
- schemaVersion: z8.literal(1),
12267
- cases: z8.array(QualityBaselineCaseSchema).min(1)
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)
12268
12659
  }).strict().superRefine((baseline, context) => {
12269
12660
  const ids = baseline.cases.map((entry) => entry.id);
12270
12661
  if (new Set(ids).size !== ids.length) {
12271
- context.addIssue({ code: z8.ZodIssueCode.custom, path: ["cases"], message: "duplicate case id" });
12662
+ context.addIssue({ code: z9.ZodIssueCode.custom, path: ["cases"], message: "duplicate case id" });
12272
12663
  }
12273
12664
  });
12274
- var QualityInputSchema = z8.object({
12275
- id: z8.string().regex(/^[a-z0-9][a-z0-9/_-]*$/),
12276
- path: z8.string().min(1),
12277
- record: z8.string().min(1).optional(),
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(),
12278
12669
  tolerances: QualityToleranceSchema.partial().optional()
12279
12670
  }).strict();
12280
12671
  function message5(error) {
@@ -12362,7 +12753,7 @@ function measureDecodedImageQuality(png, digest2) {
12362
12753
  };
12363
12754
  }
12364
12755
  async function measureImageQuality(file) {
12365
- const absolute = path25.resolve(file);
12756
+ const absolute = path27.resolve(file);
12366
12757
  let bytes;
12367
12758
  try {
12368
12759
  bytes = await readFile21(absolute);
@@ -12376,7 +12767,7 @@ async function measureImageQuality(file) {
12376
12767
  }
12377
12768
  }
12378
12769
  function resolveQualityInputs(raw, inputsFilePath) {
12379
- const parsed = z8.array(QualityInputSchema).min(1).safeParse(raw);
12770
+ const parsed = z9.array(QualityInputSchema).min(1).safeParse(raw);
12380
12771
  if (!parsed.success) {
12381
12772
  throw new Error(
12382
12773
  "--inputs must be a non-empty JSON array of quality cases:\n" + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
@@ -12384,11 +12775,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
12384
12775
  }
12385
12776
  const ids = parsed.data.map((entry) => entry.id);
12386
12777
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
12387
- const root = path25.dirname(path25.resolve(inputsFilePath));
12778
+ const root = path27.dirname(path27.resolve(inputsFilePath));
12388
12779
  return parsed.data.map((entry) => ({
12389
12780
  id: entry.id,
12390
- file: path25.resolve(root, entry.path),
12391
- ...entry.record ? { record: path25.resolve(root, entry.record) } : {},
12781
+ file: path27.resolve(root, entry.path),
12782
+ ...entry.record ? { record: path27.resolve(root, entry.record) } : {},
12392
12783
  tolerances: QualityToleranceSchema.parse({
12393
12784
  ...DEFAULT_QUALITY_TOLERANCES,
12394
12785
  ...entry.tolerances
@@ -12396,7 +12787,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
12396
12787
  }));
12397
12788
  }
12398
12789
  function portableRelative2(from, to) {
12399
- return path25.relative(from, path25.resolve(to)).split(path25.sep).join("/") || ".";
12790
+ return path27.relative(from, path27.resolve(to)).split(path27.sep).join("/") || ".";
12400
12791
  }
12401
12792
  async function verifyRecordForImage(recordPath, imagePath) {
12402
12793
  const verification = await verifyArtifactBundle(recordPath);
@@ -12411,20 +12802,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
12411
12802
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
12412
12803
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
12413
12804
  }
12414
- const outputs = record.outputs.map((output) => path25.resolve(path25.dirname(recordPath), output.path));
12415
- if (!outputs.includes(path25.resolve(imagePath))) {
12805
+ const outputs = record.outputs.map((output) => path27.resolve(path27.dirname(recordPath), output.path));
12806
+ if (!outputs.includes(path27.resolve(imagePath))) {
12416
12807
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
12417
12808
  }
12418
12809
  }
12419
12810
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
12420
12811
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
12421
- const absolute = path25.resolve(baselinePath);
12812
+ const absolute = path27.resolve(baselinePath);
12422
12813
  for (const input of inputs) {
12423
- if (path25.resolve(input.file) === absolute || input.record && path25.resolve(input.record) === absolute) {
12814
+ if (path27.resolve(input.file) === absolute || input.record && path27.resolve(input.record) === absolute) {
12424
12815
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
12425
12816
  }
12426
12817
  }
12427
- const root = path25.dirname(absolute);
12818
+ const root = path27.dirname(absolute);
12428
12819
  const cases = [];
12429
12820
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
12430
12821
  const expected = await measureImageQuality(input.file);
@@ -12451,7 +12842,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
12451
12842
  cases
12452
12843
  });
12453
12844
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
12454
- if (existsSync20(absolute) && !options.force) {
12845
+ if (existsSync21(absolute) && !options.force) {
12455
12846
  const current = await readFile21(absolute);
12456
12847
  if (!current.equals(data)) {
12457
12848
  throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
@@ -12461,7 +12852,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
12461
12852
  return { baseline, path: absolute, changed: result.changed.length > 0 };
12462
12853
  }
12463
12854
  async function readQualityBaseline(baselinePath) {
12464
- const absolute = path25.resolve(baselinePath);
12855
+ const absolute = path27.resolve(baselinePath);
12465
12856
  let raw;
12466
12857
  try {
12467
12858
  raw = JSON.parse(await readFile21(absolute, "utf8"));
@@ -12534,12 +12925,12 @@ function regressionViolations(expected, actual, tolerances) {
12534
12925
  return { violations, warnings };
12535
12926
  }
12536
12927
  async function checkQualityBaseline(baselinePath) {
12537
- const absolute = path25.resolve(baselinePath);
12928
+ const absolute = path27.resolve(baselinePath);
12538
12929
  const baseline = await readQualityBaseline(absolute);
12539
- const root = path25.dirname(absolute);
12930
+ const root = path27.dirname(absolute);
12540
12931
  const cases = [];
12541
12932
  for (const entry of baseline.cases) {
12542
- const file = path25.resolve(root, entry.file);
12933
+ const file = path27.resolve(root, entry.file);
12543
12934
  let actual = null;
12544
12935
  const violations = [];
12545
12936
  const warnings = [];
@@ -12552,7 +12943,7 @@ async function checkQualityBaseline(baselinePath) {
12552
12943
  violations.push(message5(error));
12553
12944
  }
12554
12945
  if (entry.record) {
12555
- const recordPath = path25.resolve(root, entry.record.path);
12946
+ const recordPath = path27.resolve(root, entry.record.path);
12556
12947
  try {
12557
12948
  const recordHash = await sha256File(recordPath);
12558
12949
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -12638,13 +13029,13 @@ async function runGallery(initial, reload, args, access2) {
12638
13029
  };
12639
13030
  const loadProject = async (project) => {
12640
13031
  const ctx = await access2.loadProject(project);
12641
- const dir = path26.dirname(ctx.loaded.path);
13032
+ const dir = path28.dirname(ctx.loaded.path);
12642
13033
  const declared = readEnvFiles(dir);
12643
13034
  const credentialNames = new Set(availableProviders().flatMap((id) => providerCredentialEnvs(providerFactory(id))));
12644
13035
  for (const [name, value] of Object.entries(declared)) {
12645
13036
  if (credentialNames.has(name) && name in process.env && process.env[name] !== value) {
12646
13037
  throw new Error(
12647
- `${path26.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.`
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.`
12648
13039
  );
12649
13040
  }
12650
13041
  }
@@ -12670,7 +13061,7 @@ async function runGallery(initial, reload, args, access2) {
12670
13061
  args.edit,
12671
13062
  budget ? describeBudget(budget) : null
12672
13063
  ),
12673
- ...args.edit ? { edit: createGalleryEditHandler({ manifestFor: access2.manifestFor, reload, onProgress: log }) } : {},
13064
+ ...args.edit ? { edit: createGalleryEditHandler({ manifestFor: access2.manifestFor, loadProject: access2.loadProject, reload, onProgress: log }) } : {},
12674
13065
  ...budget ? { generate: createGenerateHandlers({ loadProject, providerFor, budget, reload, onProgress: log }) } : {}
12675
13066
  });
12676
13067
  await new Promise((resolve) => {
@@ -12686,11 +13077,11 @@ async function runGallery(initial, reload, args, access2) {
12686
13077
  log("\n gallery closed");
12687
13078
  }
12688
13079
  async function provenanceFile(id, file) {
12689
- const absolute = path26.resolve(file);
13080
+ const absolute = path28.resolve(file);
12690
13081
  return {
12691
13082
  id,
12692
13083
  path: absolute,
12693
- sha256: existsSync21(absolute) ? await sha256File(absolute) : null,
13084
+ sha256: existsSync22(absolute) ? await sha256File(absolute) : null,
12694
13085
  included: true
12695
13086
  };
12696
13087
  }
@@ -12739,7 +13130,8 @@ var BOOL_FLAGS = [
12739
13130
  "--write-prompts",
12740
13131
  "--primary-only",
12741
13132
  "--prune",
12742
- "--edit"
13133
+ "--edit",
13134
+ "--refresh"
12743
13135
  ];
12744
13136
  var COMMANDS = [
12745
13137
  "init",
@@ -12765,6 +13157,7 @@ var COMMANDS = [
12765
13157
  "balance",
12766
13158
  "status",
12767
13159
  "gallery",
13160
+ "edit",
12768
13161
  "quality",
12769
13162
  "refine",
12770
13163
  "recipe",
@@ -12779,6 +13172,7 @@ var WORKSPACE_SUBCOMMANDS = ["add", "remove", "list", "status", "claims"];
12779
13172
  var REFINE_SUBCOMMANDS = ["run", "approve", "check"];
12780
13173
  var RECIPE_SUBCOMMANDS = ["list", "inspect", "install", "verify"];
12781
13174
  var QUALITY_SUBCOMMANDS = ["snapshot", "check"];
13175
+ var EDIT_SUBCOMMANDS = ["start", "detach"];
12782
13176
  function parseArgs(argv) {
12783
13177
  const [command = "help"] = argv;
12784
13178
  if (!COMMANDS.includes(command)) {
@@ -12836,6 +13230,12 @@ function parseArgs(argv) {
12836
13230
  }
12837
13231
  rest = rest.slice(1);
12838
13232
  }
13233
+ } else if (command === "edit") {
13234
+ subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "start" : rest[0];
13235
+ if (!EDIT_SUBCOMMANDS.includes(subcommand)) {
13236
+ throw new Error(`Unknown edit subcommand "${subcommand}". Known: ${EDIT_SUBCOMMANDS.join(", ")}`);
13237
+ }
13238
+ if (rest[0] === subcommand) rest = rest.slice(1);
12839
13239
  } else if (command === "refine") {
12840
13240
  subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "run" : rest[0];
12841
13241
  if (!REFINE_SUBCOMMANDS.includes(subcommand)) {
@@ -12949,7 +13349,7 @@ function parseArgs(argv) {
12949
13349
  return {
12950
13350
  command,
12951
13351
  manifest,
12952
- lock: get("--lock") ?? path26.join(path26.dirname(path26.resolve(manifest)), "pixelkiln.lock.json"),
13352
+ lock: get("--lock") ?? path28.join(path28.dirname(path28.resolve(manifest)), "pixelkiln.lock.json"),
12953
13353
  explicitLock: get("--lock"),
12954
13354
  styles: list("--style"),
12955
13355
  assets: list("--only"),
@@ -12964,6 +13364,7 @@ function parseArgs(argv) {
12964
13364
  noOpen: rest.includes("--no-open"),
12965
13365
  tag: rest.includes("--tag"),
12966
13366
  edit: rest.includes("--edit"),
13367
+ refresh: rest.includes("--refresh"),
12967
13368
  from: get("--from"),
12968
13369
  out: get("--out"),
12969
13370
  generator: get("--generator"),
@@ -13030,6 +13431,9 @@ Commands
13030
13431
  tag Push manifest tags to the objects upstream (free).
13031
13432
  balance Show the provider's remaining balance.
13032
13433
  status Summarise the lockfile.
13434
+ edit Hand-edit an asset in your own editor: copies the generated PNG to
13435
+ <outDir>/edits/, declares it as the asset's source, and opens it.
13436
+ edit detach places the generated art again (the file is kept).
13033
13437
  gallery Open a local read-only gallery of every generation and its
13034
13438
  provenance: prompt, provider, cost, outputs, lineage, quality.
13035
13439
  --workspace <catalog> shows every registered project at once;
@@ -13072,9 +13476,11 @@ Options
13072
13476
  and the gallery snapshot (no server)
13073
13477
  --check plan/audit/cache: exit nonzero when the selected state is unsafe
13074
13478
  --yes, -y Skip the confirmation prompt
13075
- --no-open Do not auto-open the browser during pick, salvage, or gallery
13479
+ --no-open Do not auto-open the browser (pick, salvage, gallery) or editor (edit)
13076
13480
  --edit gallery: allow manifest edits from the page (never spends)
13077
13481
  --tag Also push tags upstream after fetch
13482
+ --refresh fetch: re-download and replace files whose object changed
13483
+ upstream (e.g. edited in PixelLab's editor); no generation
13078
13484
  --claims a.json,b Other projects' lockfiles (salvage; required if account is shared)
13079
13485
  --workspace <path> Workspace catalog (default: pixelkiln.workspace.json). Also
13080
13486
  derives salvage's claim set instead of repeated --claims,
@@ -13089,6 +13495,8 @@ Examples
13089
13495
  pixelkiln gallery --style heybud-premium
13090
13496
  pixelkiln gallery --workspace pixelkiln.workspace.json
13091
13497
  pixelkiln gallery --edit --budget 80
13498
+ pixelkiln edit --style base --only anvil
13499
+ PIXELKILN_EDITOR="open -a Aseprite" pixelkiln edit --only anvil --style base
13092
13500
  pixelkiln adopt --tag
13093
13501
  pixelkiln pack --style heybud-premium
13094
13502
  pixelkiln pack --inputs sprites.json --out dist/sheet # no manifest needed
@@ -13165,6 +13573,15 @@ function printResumeActions(specs, lock) {
13165
13573
  }
13166
13574
  return actions.length;
13167
13575
  }
13576
+ function manifestSources(manifest, styleId) {
13577
+ const sources = {};
13578
+ for (const [assetId, asset] of Object.entries(manifest.assets)) {
13579
+ if (asset.styles.length && !asset.styles.includes(styleId)) continue;
13580
+ const source = asset.sourceByStyle[styleId] ?? asset.source;
13581
+ if (source) sources[assetId] = source;
13582
+ }
13583
+ return sources;
13584
+ }
13168
13585
  function manifestProviderIds(manifest) {
13169
13586
  return [...new Set(
13170
13587
  Object.values(manifest.styles).map((style) => style.provider ?? manifest.provider)
@@ -13238,10 +13655,10 @@ async function confirm(question, auto) {
13238
13655
  return answer === "y" || answer === "yes";
13239
13656
  }
13240
13657
  async function requireCompleteWorkspaceClaims(workspacePath) {
13241
- if (!existsSync21(workspacePath)) {
13658
+ if (!existsSync22(workspacePath)) {
13242
13659
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
13243
13660
  }
13244
- const dir = path26.dirname(path26.resolve(workspacePath));
13661
+ const dir = path28.dirname(path28.resolve(workspacePath));
13245
13662
  const ws = await loadWorkspace(workspacePath);
13246
13663
  const diagnostics = validateWorkspace(ws, dir);
13247
13664
  const errors = diagnostics.filter((d) => d.level === "error");
@@ -13273,15 +13690,15 @@ async function main() {
13273
13690
  if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
13274
13691
  let raw;
13275
13692
  try {
13276
- raw = JSON.parse(await readFile22(path26.resolve(args.inputs), "utf8"));
13693
+ raw = JSON.parse(await readFile22(path28.resolve(args.inputs), "utf8"));
13277
13694
  } catch (error) {
13278
13695
  throw new Error(
13279
- `Could not read quality inputs ${path26.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
13696
+ `Could not read quality inputs ${path28.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
13280
13697
  { cause: error }
13281
13698
  );
13282
13699
  }
13283
13700
  const inputs = resolveQualityInputs(raw, args.inputs);
13284
- if (path26.resolve(args.inputs) === path26.resolve(args.out)) {
13701
+ if (path28.resolve(args.inputs) === path28.resolve(args.out)) {
13285
13702
  throw new Error("quality snapshot --out must not overwrite its --inputs file.");
13286
13703
  }
13287
13704
  const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
@@ -13294,7 +13711,7 @@ async function main() {
13294
13711
  }, null, 2));
13295
13712
  } else {
13296
13713
  log(
13297
- ` ${result.changed ? "wrote" : "unchanged"} ${path26.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
13714
+ ` ${result.changed ? "wrote" : "unchanged"} ${path28.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
13298
13715
  );
13299
13716
  log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
13300
13717
  }
@@ -13389,14 +13806,14 @@ async function main() {
13389
13806
  }, null, 2));
13390
13807
  } else {
13391
13808
  log(` installed ${result.recipe.id}@${result.recipe.version}`);
13392
- log(` ${path26.relative(process.cwd(), result.destination) || "."}`);
13809
+ log(` ${path28.relative(process.cwd(), result.destination) || "."}`);
13393
13810
  log(`
13394
13811
  Add this entry under your manifest's styles object:`);
13395
13812
  log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
13396
13813
  if (result.recipe.models.length) {
13397
13814
  log(`
13398
13815
  Models are not downloaded automatically. Verify them with:`);
13399
- log(` pixelkiln recipe verify ${path26.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
13816
+ log(` pixelkiln recipe verify ${path28.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
13400
13817
  }
13401
13818
  }
13402
13819
  return;
@@ -13430,12 +13847,12 @@ async function main() {
13430
13847
  ` recovered ${result2.detection.columns}x${result2.detection.rows} native grid (${result2.detection.confidence}: ${result2.detection.consensus})`
13431
13848
  );
13432
13849
  log(` enforced ${result2.palette.length}-color palette without dithering`);
13433
- log(` wrote ${path26.relative(process.cwd(), result2.output)}`);
13434
- log(` quality record: ${path26.relative(process.cwd(), result2.record)}`);
13850
+ log(` wrote ${path28.relative(process.cwd(), result2.output)}`);
13851
+ log(` quality record: ${path28.relative(process.cwd(), result2.record)}`);
13435
13852
  log(`
13436
13853
  Automated checks passed. Human 1\xD7 review is still required:`);
13437
13854
  log(
13438
- ` pixelkiln refine approve --from ${path26.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
13855
+ ` pixelkiln refine approve --from ${path28.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
13439
13856
  );
13440
13857
  }
13441
13858
  return;
@@ -13463,7 +13880,7 @@ async function main() {
13463
13880
  if (args.json) log(JSON.stringify(result2, null, 2));
13464
13881
  else {
13465
13882
  log(
13466
- result2.options.frameSet ? ` approved ${result2.outputs.length} frames in ${path26.relative(process.cwd(), result2.record)} by ${args.reviewer.trim()}` : ` approved ${path26.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`
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()}`
13467
13884
  );
13468
13885
  }
13469
13886
  return;
@@ -13472,14 +13889,14 @@ async function main() {
13472
13889
  if (args.json) {
13473
13890
  log(JSON.stringify(result, null, 2));
13474
13891
  } else {
13475
- log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path26.relative(process.cwd(), result.output || result.record)}`);
13892
+ log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path28.relative(process.cwd(), result.output || result.record)}`);
13476
13893
  for (const reason of result.reasons) log(` ${reason}`);
13477
13894
  }
13478
13895
  if (!result.safe) process.exitCode = 1;
13479
13896
  return;
13480
13897
  }
13481
13898
  if (args.command === "balance") {
13482
- loadEnvFiles(path26.dirname(path26.resolve(args.manifest)));
13899
+ loadEnvFiles(path28.dirname(path28.resolve(args.manifest)));
13483
13900
  loadEnvFiles(process.cwd());
13484
13901
  const loaded2 = await loadManifest(args.manifest);
13485
13902
  const selectedProvider = accountProviderId(loaded2.manifest, args.provider, "balance");
@@ -13492,31 +13909,31 @@ async function main() {
13492
13909
  }
13493
13910
  if (args.command === "init") {
13494
13911
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
13495
- const root = path26.resolve(args.from);
13496
- if (!existsSync21(root)) throw new Error(`No directory at ${root}`);
13912
+ const root = path28.resolve(args.from);
13913
+ if (!existsSync22(root)) throw new Error(`No directory at ${root}`);
13497
13914
  const generator = args.generator ?? "map";
13498
13915
  if (generator !== "1dir" && generator !== "map") {
13499
13916
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
13500
13917
  }
13501
- const target = path26.resolve(args.out ?? "pixelkiln.manifest.json");
13918
+ const target = path28.resolve(args.out ?? "pixelkiln.manifest.json");
13502
13919
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
13503
13920
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
13504
13921
  const manifest = buildManifest(
13505
- args.name ?? path26.basename(path26.dirname(target)),
13922
+ args.name ?? path28.basename(path28.dirname(target)),
13506
13923
  args.styles[0] ?? "base",
13507
13924
  generator,
13508
- path26.relative(path26.dirname(target), root) || ".",
13925
+ path28.relative(path28.dirname(target), root) || ".",
13509
13926
  assets
13510
13927
  );
13511
13928
  await writeManifestFile(target, manifest);
13512
- log(` scanned ${assets.length} PNG(s) under ${path26.relative(process.cwd(), root)}`);
13929
+ log(` scanned ${assets.length} PNG(s) under ${path28.relative(process.cwd(), root)}`);
13513
13930
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
13514
- log(` wrote ${path26.relative(process.cwd(), target)}`);
13931
+ log(` wrote ${path28.relative(process.cwd(), target)}`);
13515
13932
  log(`
13516
13933
  Prompts are intentionally empty. To recover the real ones from your`);
13517
13934
  log(` PixelLab account instead of inventing them:`);
13518
13935
  log(`
13519
- pixelkiln adopt --manifest ${path26.relative(process.cwd(), target)} --write-prompts
13936
+ pixelkiln adopt --manifest ${path28.relative(process.cwd(), target)} --write-prompts
13520
13937
  `);
13521
13938
  return;
13522
13939
  }
@@ -13564,10 +13981,10 @@ async function main() {
13564
13981
  if (args.primaryOnly || args.outputRoles.length) {
13565
13982
  throw new Error("--primary-only and --output-role require manifest-driven pack");
13566
13983
  }
13567
- const raw = JSON.parse(await readFile22(path26.resolve(args.inputs), "utf8"));
13984
+ const raw = JSON.parse(await readFile22(path28.resolve(args.inputs), "utf8"));
13568
13985
  const inputs = resolvePackInputs(raw, args.inputs);
13569
13986
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
13570
- const base = path26.resolve(args.out.replace(/\.png$/, ""));
13987
+ const base = path28.resolve(args.out.replace(/\.png$/, ""));
13571
13988
  const outputs = [
13572
13989
  { path: `${base}.png`, data: png },
13573
13990
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -13580,17 +13997,17 @@ async function main() {
13580
13997
  log(
13581
13998
  ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
13582
13999
  );
13583
- log(` ${path26.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
14000
+ log(` ${path28.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
13584
14001
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
13585
14002
  return;
13586
14003
  }
13587
14004
  if (args.command === "workspace") {
13588
- const workspacePath = path26.resolve(args.workspace ?? "pixelkiln.workspace.json");
13589
- const dir = path26.dirname(workspacePath);
14005
+ const workspacePath = path28.resolve(args.workspace ?? "pixelkiln.workspace.json");
14006
+ const dir = path28.dirname(workspacePath);
13590
14007
  if (args.subcommand === "add") {
13591
- const manifestPath = path26.resolve(args.target);
14008
+ const manifestPath = path28.resolve(args.target);
13592
14009
  const loadedTarget = await loadManifest(manifestPath);
13593
- const lockPath = args.explicitLock ? path26.resolve(args.explicitLock) : path26.join(path26.dirname(manifestPath), "pixelkiln.lock.json");
14010
+ const lockPath = args.explicitLock ? path28.resolve(args.explicitLock) : path28.join(path28.dirname(manifestPath), "pixelkiln.lock.json");
13594
14011
  const ws = await loadWorkspace(workspacePath);
13595
14012
  const id = args.name ?? loadedTarget.manifest.name;
13596
14013
  if (ws.projects.some((p) => p.id === id)) {
@@ -13610,10 +14027,10 @@ async function main() {
13610
14027
  ...args.account ? { account: args.account } : {}
13611
14028
  };
13612
14029
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
13613
- log(` registered "${id}" in ${path26.relative(process.cwd(), workspacePath)}`);
14030
+ log(` registered "${id}" in ${path28.relative(process.cwd(), workspacePath)}`);
13614
14031
  log(` manifest: ${project.manifest}`);
13615
14032
  log(` lock: ${project.lock}`);
13616
- if (!existsSync21(lockPath)) {
14033
+ if (!existsSync22(lockPath)) {
13617
14034
  log(
13618
14035
  ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
13619
14036
  );
@@ -13622,7 +14039,7 @@ async function main() {
13622
14039
  }
13623
14040
  if (args.subcommand === "remove") {
13624
14041
  const ws = await loadWorkspace(workspacePath);
13625
- const resolvedTarget = path26.resolve(args.target);
14042
+ const resolvedTarget = path28.resolve(args.target);
13626
14043
  const match = ws.projects.find(
13627
14044
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
13628
14045
  );
@@ -13633,11 +14050,11 @@ async function main() {
13633
14050
  version: 1,
13634
14051
  projects: ws.projects.filter((p) => p !== match)
13635
14052
  });
13636
- log(` removed "${match.id}" from ${path26.relative(process.cwd(), workspacePath)}`);
14053
+ log(` removed "${match.id}" from ${path28.relative(process.cwd(), workspacePath)}`);
13637
14054
  return;
13638
14055
  }
13639
14056
  if (args.subcommand === "list") {
13640
- if (!existsSync21(workspacePath)) {
14057
+ if (!existsSync22(workspacePath)) {
13641
14058
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
13642
14059
  }
13643
14060
  const ws = await loadWorkspace(workspacePath);
@@ -13645,9 +14062,9 @@ async function main() {
13645
14062
  if (args.json) {
13646
14063
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
13647
14064
  } else if (!ws.projects.length) {
13648
- log(` no projects registered in ${path26.relative(process.cwd(), workspacePath)}`);
14065
+ log(` no projects registered in ${path28.relative(process.cwd(), workspacePath)}`);
13649
14066
  } else {
13650
- log(` ${ws.projects.length} project(s) in ${path26.relative(process.cwd(), workspacePath)}:`);
14067
+ log(` ${ws.projects.length} project(s) in ${path28.relative(process.cwd(), workspacePath)}:`);
13651
14068
  for (const p of ws.projects) {
13652
14069
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
13653
14070
  }
@@ -13657,7 +14074,7 @@ async function main() {
13657
14074
  return;
13658
14075
  }
13659
14076
  if (args.subcommand === "status") {
13660
- if (!existsSync21(workspacePath)) {
14077
+ if (!existsSync22(workspacePath)) {
13661
14078
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
13662
14079
  }
13663
14080
  const ws = await loadWorkspace(workspacePath);
@@ -13665,7 +14082,7 @@ async function main() {
13665
14082
  if (args.json) {
13666
14083
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
13667
14084
  } else {
13668
- log(` workspace: ${path26.relative(process.cwd(), workspacePath)}`);
14085
+ log(` workspace: ${path28.relative(process.cwd(), workspacePath)}`);
13669
14086
  for (const p of report.projects) {
13670
14087
  if (p.error) {
13671
14088
  log(`
@@ -13712,8 +14129,8 @@ async function main() {
13712
14129
  }
13713
14130
  }
13714
14131
  if (args.command === "gallery" && args.workspace) {
13715
- const workspacePath = path26.resolve(args.workspace);
13716
- if (!existsSync21(workspacePath)) {
14132
+ const workspacePath = path28.resolve(args.workspace);
14133
+ if (!existsSync22(workspacePath)) {
13717
14134
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
13718
14135
  }
13719
14136
  const filter = { styles: args.styles, assets: args.assets };
@@ -13733,7 +14150,7 @@ async function main() {
13733
14150
  const registered = async (projectId) => {
13734
14151
  const project = (await loadWorkspace(workspacePath)).projects.find((candidate) => candidate.id === projectId);
13735
14152
  if (!projectId || !project) throw new Error(`unknown workspace project "${projectId ?? ""}"`);
13736
- return resolveProject(path26.dirname(workspacePath), project);
14153
+ return resolveProject(path28.dirname(workspacePath), project);
13737
14154
  };
13738
14155
  await runGallery(initial, build, args, {
13739
14156
  manifestFor: async (projectId) => (await registered(projectId)).manifestPath,
@@ -13748,14 +14165,14 @@ async function main() {
13748
14165
  });
13749
14166
  return;
13750
14167
  }
13751
- if (!existsSync21(path26.resolve(args.manifest))) {
14168
+ if (!existsSync22(path28.resolve(args.manifest))) {
13752
14169
  throw new Error(
13753
- `No manifest at ${path26.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
14170
+ `No manifest at ${path28.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
13754
14171
  );
13755
14172
  }
13756
- const manifestDir = path26.dirname(path26.resolve(args.manifest));
14173
+ const manifestDir = path28.dirname(path28.resolve(args.manifest));
13757
14174
  const envFiles2 = [...loadEnvFiles(manifestDir)];
13758
- if (path26.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
14175
+ if (path28.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
13759
14176
  const loaded = await loadManifest(args.manifest);
13760
14177
  let specs = await resolveSpecs(loaded, {
13761
14178
  styles: args.styles,
@@ -13828,7 +14245,7 @@ async function main() {
13828
14245
  });
13829
14246
  };
13830
14247
  await runGallery(await build(), reload, args, {
13831
- manifestFor: () => path26.resolve(args.manifest),
14248
+ manifestFor: () => path28.resolve(args.manifest),
13832
14249
  loadProject: async () => {
13833
14250
  const freshLoaded = await loadManifest(args.manifest);
13834
14251
  const freshSpecs = await resolveSpecs(freshLoaded);
@@ -13839,6 +14256,29 @@ async function main() {
13839
14256
  });
13840
14257
  return;
13841
14258
  }
14259
+ if (args.command === "edit") {
14260
+ if (!args.assets.length) throw new Error("edit needs --only <asset id> (and --style when the asset is in several styles).");
14261
+ if (specs.length !== 1) {
14262
+ throw new Error(
14263
+ specs.length ? `edit works on one asset in one style; --only ${args.assets.join(",")} resolves to ${specs.length}: ` + specs.map((spec2) => `${spec2.styleId}/${spec2.assetId}`).join(", ") + ". Add --style." : "edit matched no asset."
14264
+ );
14265
+ }
14266
+ const spec = specs[0];
14267
+ if (args.subcommand === "detach") {
14268
+ const result = await detachHandEdit(loaded, spec);
14269
+ log(result.changed ? ` detached the hand edit for ${spec.styleId}/${spec.assetId}; the generated art is placed again (the edit file is kept)` : ` ${spec.styleId}/${spec.assetId} has no hand edit`);
14270
+ return;
14271
+ }
14272
+ const started = await startHandEdit(loaded, lock, spec);
14273
+ log(` ${started.created ? "created" : "found"} ${started.source}${started.declared ? " and declared it as the asset's source" : ""}`);
14274
+ if (args.noOpen) {
14275
+ log(` edit it with your own tool, then run pixelkiln plan; mount and pack place it in place of the generated art`);
14276
+ } else {
14277
+ const command = openInEditor(started.editPath);
14278
+ log(` opened with: ${command}${process.env.PIXELKILN_EDITOR ? "" : " (set PIXELKILN_EDITOR to choose the program)"}`);
14279
+ }
14280
+ return;
14281
+ }
13842
14282
  const plan = await buildPlan(specs, lock, { force: args.force });
13843
14283
  if (args.command === "refine") {
13844
14284
  const pathOwnedFlags = [
@@ -13880,7 +14320,7 @@ async function main() {
13880
14320
  log(` ${item.state.padEnd(16)} ${item.key} ${item.reason}`);
13881
14321
  if (item.state === "needs-approval") {
13882
14322
  log(
13883
- ` pixelkiln refine approve --from ${path26.relative(process.cwd(), item.record)} --reviewer "Your Name"`
14323
+ ` pixelkiln refine approve --from ${path28.relative(process.cwd(), item.record)} --reviewer "Your Name"`
13884
14324
  );
13885
14325
  }
13886
14326
  }
@@ -13971,7 +14411,7 @@ async function main() {
13971
14411
  let intact = true;
13972
14412
  for (const output of entry.outputs) {
13973
14413
  const file = resolveOutputPath(output.path, item.spec.root);
13974
- if (!existsSync21(file) || await sha256File(file) !== output.sha256) {
14414
+ if (!existsSync22(file) || await sha256File(file) !== output.sha256) {
13975
14415
  intact = false;
13976
14416
  break;
13977
14417
  }
@@ -14029,7 +14469,7 @@ async function main() {
14029
14469
  if (args.primaryOnly && args.outputRoles.length) {
14030
14470
  throw new Error("pack accepts either --primary-only or --output-role, not both");
14031
14471
  }
14032
- const manifestDir2 = path26.dirname(path26.resolve(args.manifest));
14472
+ const manifestDir2 = path28.dirname(path28.resolve(args.manifest));
14033
14473
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
14034
14474
  for (const styleId of styleIds) {
14035
14475
  const packagingSpecs = await resolveSpecs(loaded, { styles: [styleId] });
@@ -14038,10 +14478,11 @@ async function main() {
14038
14478
  columns: args.columns,
14039
14479
  outputRoles: args.outputRoles,
14040
14480
  primaryOnly: args.primaryOnly,
14041
- sourceOverrides: qualitySources
14481
+ sourceOverrides: qualitySources,
14482
+ sources: manifestSources(loaded.manifest, styleId)
14042
14483
  });
14043
14484
  const style = loaded.manifest.styles[styleId];
14044
- const base = args.out ? path26.resolve(args.out.replace(/\.png$/, "")) : path26.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
14485
+ const base = args.out ? path28.resolve(args.out.replace(/\.png$/, "")) : path28.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
14045
14486
  const outputs = [
14046
14487
  { path: `${base}.png`, data: png },
14047
14488
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -14071,13 +14512,13 @@ async function main() {
14071
14512
  log(
14072
14513
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
14073
14514
  );
14074
- log(` ${path26.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
14515
+ log(` ${path28.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
14075
14516
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
14076
14517
  }
14077
14518
  return;
14078
14519
  }
14079
14520
  if (args.command === "mount") {
14080
- const manifestDir2 = path26.dirname(path26.resolve(args.manifest));
14521
+ const manifestDir2 = path28.dirname(path28.resolve(args.manifest));
14081
14522
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
14082
14523
  for (const styleId of styleIds) {
14083
14524
  const style = loaded.manifest.styles[styleId];
@@ -14092,10 +14533,11 @@ async function main() {
14092
14533
  const cells = {};
14093
14534
  const sources = {};
14094
14535
  const outputRoles = {};
14536
+ const styleSources = manifestSources(loaded.manifest, styleId);
14095
14537
  for (const [assetId, asset] of Object.entries(loaded.manifest.assets)) {
14096
14538
  if (!asset.cell) continue;
14097
14539
  cells[assetId] = asset.cell;
14098
- if (asset.source) sources[assetId] = asset.source;
14540
+ if (styleSources[assetId]) sources[assetId] = styleSources[assetId];
14099
14541
  if (asset.outputRole) outputRoles[assetId] = asset.outputRole;
14100
14542
  }
14101
14543
  const packagingSpecs = await resolveSpecs(loaded, { styles: [styleId] });
@@ -14115,7 +14557,7 @@ async function main() {
14115
14557
  sources,
14116
14558
  outputRoles
14117
14559
  );
14118
- const out = path26.resolve(manifestDir2, style.mount.out);
14560
+ const out = path28.resolve(manifestDir2, style.mount.out);
14119
14561
  const metadata = out.replace(/\.png$/, "") + ".json";
14120
14562
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
14121
14563
  const outputs = [
@@ -14135,7 +14577,7 @@ async function main() {
14135
14577
  await provenanceFile("$lock", args.lock),
14136
14578
  ...qualityRecords,
14137
14579
  ...artifactSources.filter(
14138
- (source) => source.id !== "$base" || path26.resolve(source.path) !== out
14580
+ (source) => source.id !== "$base" || path28.resolve(source.path) !== out
14139
14581
  )
14140
14582
  ],
14141
14583
  options: {
@@ -14148,7 +14590,7 @@ async function main() {
14148
14590
  log(
14149
14591
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
14150
14592
  );
14151
- log(` ${path26.relative(process.cwd(), out)} + atlas/provenance JSON`);
14593
+ log(` ${path28.relative(process.cwd(), out)} + atlas/provenance JSON`);
14152
14594
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
14153
14595
  }
14154
14596
  return;
@@ -14204,7 +14646,7 @@ async function main() {
14204
14646
  }
14205
14647
  if (args.command === "export") {
14206
14648
  const format = args.format ?? "generic";
14207
- const manifestDir2 = path26.dirname(path26.resolve(args.manifest));
14649
+ const manifestDir2 = path28.dirname(path28.resolve(args.manifest));
14208
14650
  const selected = specs.filter((spec) => {
14209
14651
  if (spec.generator !== "tiles") return false;
14210
14652
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -14220,12 +14662,12 @@ async function main() {
14220
14662
  for (const spec of selected) {
14221
14663
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
14222
14664
  const style = loaded.manifest.styles[spec.styleId];
14223
- const defaultBase = path26.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
14224
- const base = args.out ? path26.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
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;
14225
14667
  const result = exportTileset(entry, spec, {
14226
14668
  format,
14227
14669
  manifestDir: manifestDir2,
14228
- imageName: path26.basename(`${base}.png`),
14670
+ imageName: path28.basename(`${base}.png`),
14229
14671
  columns: args.columns
14230
14672
  });
14231
14673
  const outputs = [
@@ -14243,7 +14685,7 @@ async function main() {
14243
14685
  asset: spec.assetId,
14244
14686
  columns: args.columns ?? null,
14245
14687
  format,
14246
- image: path26.basename(`${base}.png`),
14688
+ image: path28.basename(`${base}.png`),
14247
14689
  providerRules: result.generic.providerRules,
14248
14690
  style: spec.styleId,
14249
14691
  tileType: spec.tileType ?? null
@@ -14253,7 +14695,7 @@ async function main() {
14253
14695
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
14254
14696
  );
14255
14697
  log(
14256
- ` ${path26.relative(process.cwd(), base)}.png + ${path26.basename(base)}${result.extension} + .pixelkiln.json`
14698
+ ` ${path28.relative(process.cwd(), base)}.png + ${path28.basename(base)}${result.extension} + .pixelkiln.json`
14257
14699
  );
14258
14700
  }
14259
14701
  return;
@@ -14303,12 +14745,12 @@ async function main() {
14303
14745
  tagged ${n} object(s) upstream`);
14304
14746
  }
14305
14747
  if (args.writePrompts) {
14306
- const { filled, stillEmpty } = await writePromptsBack(path26.resolve(args.manifest), lock, {
14748
+ const { filled, stillEmpty } = await writePromptsBack(path28.resolve(args.manifest), lock, {
14307
14749
  onProgress: log,
14308
14750
  provider: provider.id,
14309
14751
  assetIds: specs.map((spec) => spec.assetId)
14310
14752
  });
14311
- log(` recovered ${filled} prompt(s) into ${path26.relative(process.cwd(), args.manifest)}`);
14753
+ log(` recovered ${filled} prompt(s) into ${path28.relative(process.cwd(), args.manifest)}`);
14312
14754
  const reloaded = await loadManifest(args.manifest);
14313
14755
  const rebased = (await resolveSpecs(reloaded, {
14314
14756
  styles: args.styles,
@@ -14337,12 +14779,12 @@ async function main() {
14337
14779
  const provider = providerFor(selectedAccountProvider);
14338
14780
  const jsonMode = args.dryRun && args.json;
14339
14781
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
14340
- const ownLock = path26.resolve(args.lock);
14782
+ const ownLock = path28.resolve(args.lock);
14341
14783
  let workspaceProjects = [];
14342
14784
  let workspaceDir = "";
14343
14785
  if (args.workspace) {
14344
- const workspacePath = path26.resolve(args.workspace);
14345
- workspaceDir = path26.dirname(workspacePath);
14786
+ const workspacePath = path28.resolve(args.workspace);
14787
+ workspaceDir = path28.dirname(workspacePath);
14346
14788
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
14347
14789
  workspaceProjects = complete.ws.projects;
14348
14790
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -14351,12 +14793,12 @@ async function main() {
14351
14793
  const lockPaths = [
14352
14794
  .../* @__PURE__ */ new Set([
14353
14795
  ...workspaceLockPaths,
14354
- ...existsSync21(ownLock) ? [ownLock] : [],
14355
- ...args.claims.map((c) => path26.resolve(c))
14796
+ ...existsSync22(ownLock) ? [ownLock] : [],
14797
+ ...args.claims.map((c) => path28.resolve(c))
14356
14798
  ])
14357
14799
  ];
14358
14800
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
14359
- for (const p of lockPaths) diag(` ${path26.relative(process.cwd(), p)}`);
14801
+ for (const p of lockPaths) diag(` ${path28.relative(process.cwd(), p)}`);
14360
14802
  if (!args.claims.length && !args.workspace) {
14361
14803
  diag(
14362
14804
  `
@@ -14364,7 +14806,7 @@ async function main() {
14364
14806
  pass every other project's lockfile via --claims a.json,b.json, or
14365
14807
  register every project in a workspace catalog and pass --workspace.`
14366
14808
  );
14367
- } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path26.resolve(args.manifest))) {
14809
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path28.resolve(args.manifest))) {
14368
14810
  diag(
14369
14811
  `
14370
14812
  This project's manifest is not registered in the workspace catalog. Its own
@@ -14448,7 +14890,7 @@ async function main() {
14448
14890
  manifestPath: loaded.path,
14449
14891
  manifest: accountManifest,
14450
14892
  styleId,
14451
- importDir: path26.resolve(loaded.root, style.outDir),
14893
+ importDir: path28.resolve(loaded.root, style.outDir),
14452
14894
  lock,
14453
14895
  lockPath: args.lock
14454
14896
  },
@@ -14683,17 +15125,19 @@ async function main() {
14683
15125
  if (args.command === "fetch" || args.command === "restore" || args.command === "gen") {
14684
15126
  log(`
14685
15127
  downloading\u2026`);
14686
- const total = { downloaded: 0, skipped: 0, failed: 0, tagged: 0 };
15128
+ const total = { downloaded: 0, skipped: 0, failed: 0, tagged: 0, unchanged: 0 };
14687
15129
  for (const [providerId, providerSpecsForRun] of specsByRecordedProvider(specs, lock)) {
14688
15130
  const groupProvider = providerFor(providerId);
14689
15131
  const res = await fetchAssets(groupProvider, providerSpecsForRun, lock, args.lock, {
14690
15132
  onProgress: log,
14691
15133
  repair: args.command === "restore",
14692
- force: args.force
15134
+ force: args.force,
15135
+ refresh: args.command === "fetch" && args.refresh
14693
15136
  });
14694
15137
  total.downloaded += res.downloaded;
14695
15138
  total.skipped += res.skipped;
14696
15139
  total.failed += res.failed;
15140
+ total.unchanged += res.unchanged ?? 0;
14697
15141
  if (args.tag) {
14698
15142
  total.tagged += await pushTags(groupProvider, providerSpecsForRun, lock, {
14699
15143
  onProgress: log
@@ -14702,7 +15146,7 @@ async function main() {
14702
15146
  }
14703
15147
  log(
14704
15148
  `
14705
- downloaded ${total.downloaded}, skipped ${total.skipped}, failed ${total.failed}`
15149
+ downloaded ${total.downloaded}, skipped ${total.skipped}, failed ${total.failed}` + (args.refresh ? `, unchanged upstream ${total.unchanged}` : "")
14706
15150
  );
14707
15151
  if (total.failed) process.exitCode = 1;
14708
15152
  if (args.tag) log(` tagged ${total.tagged} object(s) upstream`);