pixelkiln 0.20.2 → 0.22.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/index.cjs CHANGED
@@ -36,6 +36,8 @@ __export(index_exports, {
36
36
  DEFAULT_RATE_LIMIT: () => DEFAULT_RATE_LIMIT,
37
37
  FAKE_PNG: () => FAKE_PNG,
38
38
  FakeProvider: () => FakeProvider,
39
+ GenerateRequestError: () => GenerateRequestError,
40
+ GenerateRequestSchema: () => GenerateRequestSchema,
39
41
  GeneratorSchema: () => GeneratorSchema,
40
42
  GridConfidenceSchema: () => GridConfidenceSchema,
41
43
  ImageQualityMetricsSchema: () => ImageQualityMetricsSchema,
@@ -44,6 +46,9 @@ __export(index_exports, {
44
46
  LockSchema: () => LockSchema,
45
47
  MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
46
48
  MAX_RETRIES: () => MAX_RETRIES,
49
+ ManifestDriftError: () => ManifestDriftError,
50
+ ManifestEditError: () => ManifestEditError,
51
+ ManifestEditSchema: () => ManifestEditSchema,
47
52
  ManifestInputSchema: () => ManifestInputSchema,
48
53
  ManifestSchema: () => ManifestSchema,
49
54
  MediaType: () => MediaType,
@@ -66,13 +71,16 @@ __export(index_exports, {
66
71
  WorkspaceProjectSchema: () => WorkspaceProjectSchema,
67
72
  WorkspaceSchema: () => WorkspaceSchema,
68
73
  adopt: () => adopt,
74
+ applyManifestEdit: () => applyManifestEdit,
69
75
  applyTags: () => applyTags,
70
76
  approveQualityRecord: () => approveQualityRecord,
71
77
  auditStyle: () => auditStyle,
72
78
  availableProviders: () => availableProviders,
73
79
  backoffMs: () => backoffMs,
80
+ buildGallerySnapshot: () => buildGallerySnapshot,
74
81
  buildManifest: () => buildManifest,
75
82
  buildPlan: () => buildPlan,
83
+ buildWorkspaceGallerySnapshot: () => buildWorkspaceGallerySnapshot,
76
84
  bundledRecipeRoot: () => bundledRecipeRoot,
77
85
  cacheFileName: () => cacheFileName,
78
86
  candidateCount: () => candidateCount,
@@ -82,6 +90,8 @@ __export(index_exports, {
82
90
  colorDistance: () => colorDistance,
83
91
  countNumberedDescriptions: () => countNumberedDescriptions,
84
92
  createArtifactBundleManifest: () => createArtifactBundleManifest,
93
+ createGalleryEditHandler: () => createGalleryEditHandler,
94
+ createGenerateHandlers: () => createGenerateHandlers,
85
95
  createProvider: () => createProvider,
86
96
  currentEntryOutputPath: () => currentEntryOutputPath,
87
97
  currentOutputPath: () => currentOutputPath,
@@ -94,6 +104,8 @@ __export(index_exports, {
94
104
  fetchAssets: () => fetchAssets,
95
105
  findOrphans: () => findOrphans,
96
106
  formatCost: () => formatCost,
107
+ galleryMediaId: () => galleryMediaId,
108
+ galleryMediaRoute: () => galleryMediaRoute,
97
109
  generationCost: () => generationCost,
98
110
  groupOrphansByStyle: () => groupOrphansByStyle,
99
111
  hex: () => hex,
@@ -133,6 +145,7 @@ __export(index_exports, {
133
145
  pngSize: () => pngSize,
134
146
  poll: () => poll,
135
147
  portableOutputPath: () => portableOutputPath,
148
+ prepareReview: () => prepareReview,
136
149
  primaryOutput: () => primaryOutput,
137
150
  providerCredentialEnvs: () => providerCredentialEnvs,
138
151
  providerFactory: () => providerFactory,
@@ -148,6 +161,7 @@ __export(index_exports, {
148
161
  refineQualityProfiles: () => refineQualityProfiles,
149
162
  registerProvider: () => registerProvider,
150
163
  remove: () => remove,
164
+ renderGallery: () => renderGallery,
151
165
  renderSalvageSheet: () => renderSalvageSheet,
152
166
  renderSheet: () => renderSheet,
153
167
  requireApprovedQualitySources: () => requireApprovedQualitySources,
@@ -176,6 +190,7 @@ __export(index_exports, {
176
190
  saveWorkspace: () => saveWorkspace,
177
191
  scanAssets: () => scanAssets,
178
192
  selectEntryOutput: () => selectEntryOutput,
193
+ serveGallery: () => serveGallery,
179
194
  sha256: () => sha256,
180
195
  sha256File: () => sha256File,
181
196
  shouldPersistSourceUrl: () => shouldPersistSourceUrl,
@@ -718,8 +733,8 @@ function validateResponse(schema, raw, operation) {
718
733
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
719
734
  }
720
735
  var PixelLabError = class extends Error {
721
- constructor(message5, status, body) {
722
- super(message5);
736
+ constructor(message6, status, body) {
737
+ super(message6);
723
738
  this.status = status;
724
739
  this.body = body;
725
740
  this.name = "PixelLabError";
@@ -744,11 +759,11 @@ var PixelLabClient = class {
744
759
  * common than the failure mode of retrying (a duplicate object), and a
745
760
  * duplicate is visible and free to delete whereas a silent gap is neither.
746
761
  */
747
- async request(path23, init, attempt = 0) {
762
+ async request(path25, init, attempt = 0) {
748
763
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
749
764
  let res;
750
765
  try {
751
- res = await fetch(`${BASE}${path23}`, {
766
+ res = await fetch(`${BASE}${path25}`, {
752
767
  ...init,
753
768
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
754
769
  headers: {
@@ -760,24 +775,24 @@ var PixelLabClient = class {
760
775
  } catch (err) {
761
776
  if (attempt < MAX_RETRIES) {
762
777
  await sleep(backoffMs(attempt));
763
- return this.request(path23, init, attempt + 1);
778
+ return this.request(path25, init, attempt + 1);
764
779
  }
765
780
  throw err;
766
781
  }
767
782
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
768
783
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
769
784
  await sleep(waitMs);
770
- return this.request(path23, init, attempt + 1);
785
+ return this.request(path25, init, attempt + 1);
771
786
  }
772
787
  const text = await res.text();
773
788
  if (!res.ok) {
774
- throw new PixelLabError(`${init?.method ?? "GET"} ${path23} \u2192 ${res.status}`, res.status, text);
789
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path25} \u2192 ${res.status}`, res.status, text);
775
790
  }
776
791
  if (!text) return {};
777
792
  try {
778
793
  return JSON.parse(text);
779
794
  } catch {
780
- throw new Error(`${init?.method ?? "GET"} ${path23} returned invalid JSON`);
795
+ throw new Error(`${init?.method ?? "GET"} ${path25} returned invalid JSON`);
781
796
  }
782
797
  }
783
798
  async balance() {
@@ -1824,9 +1839,9 @@ var RetroDiffusionClient = class {
1824
1839
  }
1825
1840
  return balance;
1826
1841
  }
1827
- async call(path23, init = {}) {
1842
+ async call(path25, init = {}) {
1828
1843
  if (!this.token) throw new Error("RD_API_KEY is not set");
1829
- const response = await this.request(`${this.baseUrl}${path23}`, {
1844
+ const response = await this.request(`${this.baseUrl}${path25}`, {
1830
1845
  ...init,
1831
1846
  headers: {
1832
1847
  "Content-Type": "application/json",
@@ -2128,8 +2143,8 @@ function retroError(value) {
2128
2143
  if (details.length) return details.join("; ");
2129
2144
  }
2130
2145
  if (record.detail && typeof record.detail === "object") {
2131
- const message5 = record.detail.message;
2132
- if (typeof message5 === "string") return message5;
2146
+ const message6 = record.detail.message;
2147
+ if (typeof message6 === "string") return message6;
2133
2148
  }
2134
2149
  return JSON.stringify(value);
2135
2150
  }
@@ -2585,13 +2600,13 @@ function validProgress(progress) {
2585
2600
  }
2586
2601
  return Math.round(progress * 100);
2587
2602
  }
2588
- function finiteNonnegative(value, message5) {
2589
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(message5);
2603
+ function finiteNonnegative(value, message6) {
2604
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(message6);
2590
2605
  return value;
2591
2606
  }
2592
- function optionalFiniteNonnegative(value, message5) {
2607
+ function optionalFiniteNonnegative(value, message6) {
2593
2608
  if (value == null) return 0;
2594
- return finiteNonnegative(value, message5);
2609
+ return finiteNonnegative(value, message6);
2595
2610
  }
2596
2611
  function numericRecord(value) {
2597
2612
  if (value == null) return {};
@@ -2629,10 +2644,10 @@ var import_node_fs2 = require("fs");
2629
2644
  function sha256(data) {
2630
2645
  return (0, import_node_crypto2.createHash)("sha256").update(data).digest("hex");
2631
2646
  }
2632
- async function sha256File(path23) {
2647
+ async function sha256File(path25) {
2633
2648
  return new Promise((resolve, reject) => {
2634
2649
  const hash = (0, import_node_crypto2.createHash)("sha256");
2635
- const stream = (0, import_node_fs2.createReadStream)(path23);
2650
+ const stream = (0, import_node_fs2.createReadStream)(path25);
2636
2651
  stream.on("data", (chunk2) => hash.update(chunk2));
2637
2652
  stream.on("error", reject);
2638
2653
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -3521,9 +3536,9 @@ function normalizeBaseUrl(value) {
3521
3536
  }
3522
3537
  function historyError(status) {
3523
3538
  const messages = Array.isArray(status.messages) ? status.messages : [];
3524
- for (const message5 of messages) {
3525
- if (!Array.isArray(message5) || message5[0] !== "execution_error" || !isObject(message5[1])) continue;
3526
- const detail = message5[1].exception_message;
3539
+ for (const message6 of messages) {
3540
+ if (!Array.isArray(message6) || message6[0] !== "execution_error" || !isObject(message6[1])) continue;
3541
+ const detail = message6[1].exception_message;
3527
3542
  if (typeof detail === "string" && detail.trim()) {
3528
3543
  return `ComfyUI execution failed: ${detail.trim().slice(0, 300)}`;
3529
3544
  }
@@ -6956,10 +6971,10 @@ function isSha256Hash(value) {
6956
6971
  function parseCache(value) {
6957
6972
  return HashCacheSchema.parse(value);
6958
6973
  }
6959
- async function loadCache(path23) {
6960
- if (!(0, import_node_fs11.existsSync)(path23)) return { version: 1, hashes: {} };
6974
+ async function loadCache(path25) {
6975
+ if (!(0, import_node_fs11.existsSync)(path25)) return { version: 1, hashes: {} };
6961
6976
  try {
6962
- const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises9.readFile)(path23, "utf8")));
6977
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises9.readFile)(path25, "utf8")));
6963
6978
  if (!parsed.success) return { version: 1, hashes: {} };
6964
6979
  return {
6965
6980
  version: 1,
@@ -6971,18 +6986,18 @@ async function loadCache(path23) {
6971
6986
  return { version: 1, hashes: {} };
6972
6987
  }
6973
6988
  }
6974
- async function saveCache(path23, cache) {
6989
+ async function saveCache(path25, cache) {
6975
6990
  const sorted = {};
6976
6991
  for (const key of Object.keys(cache.hashes).sort()) {
6977
6992
  const hash = cache.hashes[key];
6978
6993
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6979
6994
  sorted[key] = hash;
6980
6995
  }
6981
- await (0, import_promises9.mkdir)(import_node_path14.default.dirname(import_node_path14.default.resolve(path23)), { recursive: true });
6982
- const tmp = `${path23}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6996
+ await (0, import_promises9.mkdir)(import_node_path14.default.dirname(import_node_path14.default.resolve(path25)), { recursive: true });
6997
+ const tmp = `${path25}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6983
6998
  try {
6984
6999
  await (0, import_promises9.writeFile)(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6985
- await (0, import_promises9.rename)(tmp, path23);
7000
+ await (0, import_promises9.rename)(tmp, path25);
6986
7001
  } finally {
6987
7002
  await (0, import_promises9.rm)(tmp, { force: true });
6988
7003
  }
@@ -7813,7 +7828,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7813
7828
  ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
7814
7829
  );
7815
7830
  } catch (err) {
7816
- const message5 = err instanceof Error ? err.message : String(err);
7831
+ const message6 = err instanceof Error ? err.message : String(err);
7817
7832
  const entry = lock.entries[key];
7818
7833
  if (entry.jobId && entry.submissionComplete === true) {
7819
7834
  upsert(lock, key, { status: "processing", error: null });
@@ -7825,10 +7840,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7825
7840
  failed++;
7826
7841
  upsert(lock, key, {
7827
7842
  status: "failed",
7828
- error: message5,
7843
+ error: message6,
7829
7844
  cost: entry.jobId ? estimate.amount : 0
7830
7845
  });
7831
- log(` FAILED ${key}: ${message5}`);
7846
+ log(` FAILED ${key}: ${message6}`);
7832
7847
  }
7833
7848
  }
7834
7849
  await saveLock(lockPath, lock);
@@ -8057,14 +8072,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
8057
8072
  });
8058
8073
  result.downloaded++;
8059
8074
  } catch (err) {
8060
- const message5 = err instanceof Error ? err.message : String(err);
8075
+ const message6 = err instanceof Error ? err.message : String(err);
8061
8076
  upsert(lock, key, {
8062
8077
  status: "download-failed",
8063
8078
  outputs: mergeOutputs(entry.outputs, outputs),
8064
- error: `download failed: ${message5}`
8079
+ error: `download failed: ${message6}`
8065
8080
  });
8066
8081
  result.failed++;
8067
- log(` FAILED ${key}: ${message5}`);
8082
+ log(` FAILED ${key}: ${message6}`);
8068
8083
  }
8069
8084
  await saveLock(lockPath, lock);
8070
8085
  }
@@ -8134,7 +8149,7 @@ var import_promises12 = require("fs/promises");
8134
8149
  var import_node_path18 = __toESM(require("path"), 1);
8135
8150
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
8136
8151
  const checks = [];
8137
- const add = (id, level, message5) => checks.push({ id, level, message: message5 });
8152
+ const add = (id, level, message6) => checks.push({ id, level, message: message6 });
8138
8153
  add(
8139
8154
  "manifest",
8140
8155
  "ok",
@@ -8438,9 +8453,107 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
8438
8453
  return count;
8439
8454
  }
8440
8455
 
8456
+ // src/pick/server.ts
8457
+ var import_node_fs18 = require("fs");
8458
+ var import_promises16 = require("fs/promises");
8459
+
8460
+ // src/pipeline/init.ts
8461
+ var import_promises14 = require("fs/promises");
8462
+ var import_node_fs17 = require("fs");
8463
+ var import_node_path20 = __toESM(require("path"), 1);
8464
+ function pngSize(buf) {
8465
+ if (buf.length < 24) return null;
8466
+ const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
8467
+ if (!isPng) return null;
8468
+ if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
8469
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
8470
+ }
8471
+ function slugify(value) {
8472
+ return value.replace(/\.png$/i, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
8473
+ }
8474
+ async function walk(dir, exclude) {
8475
+ const out = [];
8476
+ for (const entry of await (0, import_promises14.readdir)(dir, { withFileTypes: true })) {
8477
+ const full = import_node_path20.default.join(dir, entry.name);
8478
+ if (exclude.some((x) => full.includes(x))) continue;
8479
+ if (entry.isDirectory()) out.push(...await walk(full, exclude));
8480
+ else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
8481
+ }
8482
+ return out;
8483
+ }
8484
+ async function scanAssets(root, opts = {}) {
8485
+ const exclude = opts.exclude ?? [];
8486
+ const files = await walk(root, exclude);
8487
+ const assets = [];
8488
+ const skipped = [];
8489
+ const seen = /* @__PURE__ */ new Map();
8490
+ for (const file of files.sort()) {
8491
+ const size = pngSize(await (0, import_promises14.readFile)(file));
8492
+ if (!size) {
8493
+ skipped.push(`${file} (not a readable PNG)`);
8494
+ continue;
8495
+ }
8496
+ const rel = import_node_path20.default.relative(root, file);
8497
+ const category = import_node_path20.default.dirname(rel) === "." ? "" : import_node_path20.default.dirname(rel);
8498
+ let id = slugify(import_node_path20.default.basename(rel));
8499
+ if (seen.has(id)) {
8500
+ const n = seen.get(id) + 1;
8501
+ seen.set(id, n);
8502
+ id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
8503
+ } else {
8504
+ seen.set(id, 1);
8505
+ }
8506
+ assets.push({ id, category, file: rel, width: size.width, height: size.height });
8507
+ }
8508
+ return { assets, skipped };
8509
+ }
8510
+ function buildManifest(name, styleId, generator, outDir, scanned) {
8511
+ const squares = scanned.filter((a) => a.width === a.height);
8512
+ const commonSize = squares.length ? mode(squares.map((a) => a.width)) : 64;
8513
+ const assets = {};
8514
+ for (const asset of scanned) {
8515
+ assets[asset.id] = {
8516
+ prompt: "",
8517
+ promptByStyle: {},
8518
+ category: asset.category || void 0,
8519
+ file: asset.file,
8520
+ tags: [],
8521
+ styles: [],
8522
+ providerInputs: {},
8523
+ ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
8524
+ };
8525
+ }
8526
+ return {
8527
+ name,
8528
+ provider: "pixellab",
8529
+ styles: {
8530
+ [styleId]: {
8531
+ generator,
8532
+ size: commonSize,
8533
+ // Matches the schema default: a scaffolded style is for sprites, and
8534
+ // a sprite wants its background stripped.
8535
+ noBackground: true,
8536
+ promptPrefix: "",
8537
+ promptSuffix: "",
8538
+ styleImages: [],
8539
+ palette: [],
8540
+ outDir,
8541
+ tags: [name],
8542
+ providerOptions: {}
8543
+ }
8544
+ },
8545
+ assets
8546
+ };
8547
+ }
8548
+ function mode(values) {
8549
+ const counts = /* @__PURE__ */ new Map();
8550
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
8551
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
8552
+ }
8553
+
8441
8554
  // src/pick/sheet.ts
8442
8555
  var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8443
- function renderSheet(groups) {
8556
+ function renderSheet(groups, options = {}) {
8444
8557
  const safe = groups.map((g) => ({
8445
8558
  ...g,
8446
8559
  styleId: escapeHtml(g.styleId),
@@ -8454,6 +8567,11 @@ function renderSheet(groups) {
8454
8567
  } : {}
8455
8568
  }));
8456
8569
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
8570
+ const apply = JSON.stringify({
8571
+ url: options.applyUrl ?? "/apply",
8572
+ headers: options.applyHeaders ?? {},
8573
+ embedded: options.embedded === true
8574
+ }).replace(/<\//g, "<\\/");
8457
8575
  return `<!doctype html>
8458
8576
  <html lang="en">
8459
8577
  <head>
@@ -8496,7 +8614,7 @@ function renderSheet(groups) {
8496
8614
  align-items:start; }
8497
8615
  .review.no-context { display:block; }
8498
8616
  .context { border:1px solid var(--line); padding:8px; background:var(--panel-deep);
8499
- min-width:0; }
8617
+ min-width:0; display:grid; gap:12px; align-content:start; }
8500
8618
  .context-label { display:flex; justify-content:space-between; gap:8px; margin-bottom:7px;
8501
8619
  color:var(--dim); font-size:10.5px; font-variant-numeric:tabular-nums; }
8502
8620
  .context img { display:block; width:100%; height:auto; max-height:420px; object-fit:contain;
@@ -8565,6 +8683,7 @@ function renderSheet(groups) {
8565
8683
  </footer>
8566
8684
  <script>
8567
8685
  const GROUPS = ${data};
8686
+ const APPLY = ${apply};
8568
8687
  const picks = new Map();
8569
8688
  const root = document.getElementById('root');
8570
8689
  document.getElementById('total').textContent = GROUPS.length;
@@ -8585,24 +8704,40 @@ GROUPS.forEach((g, gi) => {
8585
8704
  '<div class="ghead"><span class="style">' + g.styleId + '</span>' +
8586
8705
  '<span class="aid">' + g.assetId + '</span>' +
8587
8706
  '<span class="prompt">' + g.prompt + '</span></div>' +
8588
- '<div class="review' + (g.revision ? '' : ' no-context') + '">' +
8707
+ '<div class="review' + (g.revision || g.current ? '' : ' no-context') + '">' +
8589
8708
  '<div class="frames"></div></div>';
8590
8709
  const review = el.querySelector('.review');
8591
8710
  const frames = el.querySelector('.frames');
8592
- if (g.revision) {
8593
- const context = document.createElement('aside');
8594
- context.className = 'context';
8711
+ const contextPanel = (identityText, dimensionsText, url, alt) => {
8712
+ const block = document.createElement('div');
8595
8713
  const label = document.createElement('div');
8596
8714
  label.className = 'context-label';
8597
8715
  const identity = document.createElement('span');
8598
- identity.textContent = g.revision.mode.toUpperCase() + ' \xB7 SOURCE ' + g.revision.sourceAssetId;
8716
+ identity.textContent = identityText;
8599
8717
  const dimensions = document.createElement('span');
8600
- dimensions.textContent = g.revision.width + '\xD7' + g.revision.height;
8718
+ dimensions.textContent = dimensionsText;
8601
8719
  label.append(identity, dimensions);
8602
- const source = document.createElement('img');
8603
- source.src = g.revision.sourceUrl;
8604
- source.alt = 'Revision source ' + g.revision.sourceAssetId;
8605
- context.append(label, source);
8720
+ const img = document.createElement('img');
8721
+ img.src = url;
8722
+ img.alt = alt;
8723
+ block.append(label, img);
8724
+ return block;
8725
+ };
8726
+ if (g.revision || g.current) {
8727
+ const context = document.createElement('aside');
8728
+ context.className = 'context';
8729
+ if (g.revision) {
8730
+ context.append(contextPanel(
8731
+ g.revision.mode.toUpperCase() + ' \xB7 SOURCE ' + g.revision.sourceAssetId,
8732
+ g.revision.width + '\xD7' + g.revision.height,
8733
+ g.revision.sourceUrl, 'Revision source ' + g.revision.sourceAssetId));
8734
+ }
8735
+ if (g.current) {
8736
+ context.append(contextPanel(
8737
+ 'CURRENT ART \xB7 replaced if you choose',
8738
+ g.current.width + '\xD7' + g.current.height,
8739
+ g.current.url, 'Current ' + g.assetId));
8740
+ }
8606
8741
  review.insertBefore(context, frames);
8607
8742
  }
8608
8743
  if (g.mode === 'frame-set') {
@@ -8786,13 +8921,17 @@ document.getElementById('submit').onclick = async () => {
8786
8921
  status.textContent = 'applying\u2026';
8787
8922
  const selections = [...picks.entries()].map(([gi, index]) => ({ key: GROUPS[gi].key, index }));
8788
8923
  try {
8789
- const res = await fetch('/apply', {
8790
- method: 'POST', headers: { 'Content-Type': 'application/json' },
8924
+ const res = await fetch(APPLY.url, {
8925
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...APPLY.headers },
8791
8926
  body: JSON.stringify({ selections }),
8792
8927
  });
8793
8928
  if (!res.ok) throw new Error(await res.text());
8794
- status.textContent = 'done \u2014 you can close this tab';
8929
+ const result = await res.json();
8930
+ status.textContent = APPLY.embedded ? 'applied' : 'done \u2014 you can close this tab';
8795
8931
  document.body.style.opacity = '.6';
8932
+ if (APPLY.embedded && window.parent !== window) {
8933
+ window.parent.postMessage({ type: 'pixelkiln:review-applied', result }, location.origin);
8934
+ }
8796
8935
  } catch (err) {
8797
8936
  status.textContent = 'failed: ' + err.message;
8798
8937
  btn.disabled = false;
@@ -8806,7 +8945,7 @@ document.getElementById('submit').onclick = async () => {
8806
8945
  // src/pick/review-server.ts
8807
8946
  var import_node_http = require("http");
8808
8947
  var import_node_child_process2 = require("child_process");
8809
- var import_promises14 = require("fs/promises");
8948
+ var import_promises15 = require("fs/promises");
8810
8949
  function serveReviewPage(opts) {
8811
8950
  const log = opts.onProgress ?? (() => {
8812
8951
  });
@@ -8815,7 +8954,7 @@ function serveReviewPage(opts) {
8815
8954
  if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
8816
8955
  try {
8817
8956
  const asset = opts.assets.get(req.url);
8818
- const bytes = await (0, import_promises14.readFile)(asset.path);
8957
+ const bytes = await (0, import_promises15.readFile)(asset.path);
8819
8958
  res.writeHead(200, {
8820
8959
  "Content-Type": asset.contentType,
8821
8960
  "Cache-Control": "no-store",
@@ -8864,10 +9003,10 @@ function serveReviewPage(opts) {
8864
9003
  res.end(JSON.stringify(result));
8865
9004
  server.close(() => resolve(result));
8866
9005
  } catch (err) {
8867
- const message5 = err instanceof Error ? err.message : String(err);
8868
- log(` apply failed: ${message5}`);
9006
+ const message6 = err instanceof Error ? err.message : String(err);
9007
+ log(` apply failed: ${message6}`);
8869
9008
  res.writeHead(500, { "Content-Type": "text/plain" });
8870
- res.end(message5);
9009
+ res.end(message6);
8871
9010
  }
8872
9011
  return;
8873
9012
  }
@@ -8887,7 +9026,23 @@ function serveReviewPage(opts) {
8887
9026
  }
8888
9027
 
8889
9028
  // src/pick/server.ts
8890
- async function runPicker(provider, lock, lockPath, opts = {}) {
9029
+ async function currentArt(entry, spec) {
9030
+ if (!spec || !entry.supersededOutputs?.length) return null;
9031
+ const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
9032
+ if (!previous) return null;
9033
+ const file = resolveOutputPath(previous.path, spec.root);
9034
+ if (!(0, import_node_fs18.existsSync)(file)) return null;
9035
+ const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
9036
+ if (!contentType) return null;
9037
+ let width = entry.width;
9038
+ let height = entry.height;
9039
+ if (contentType === "image/png") {
9040
+ const size = pngSize(await (0, import_promises16.readFile)(file));
9041
+ if (size) ({ width, height } = size);
9042
+ }
9043
+ return { path: file, contentType, width, height };
9044
+ }
9045
+ async function prepareReview(provider, lock, opts = {}) {
8891
9046
  const log = opts.onProgress ?? (() => {
8892
9047
  });
8893
9048
  const selectedKeys = opts.keys ? new Set(opts.keys) : null;
@@ -8895,6 +9050,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8895
9050
  (opts.specs ?? []).map((spec) => [lockKey(spec.styleId, spec.assetId), spec])
8896
9051
  );
8897
9052
  const reviewAssets = /* @__PURE__ */ new Map();
9053
+ const prefix = opts.routePrefix ?? "";
8898
9054
  const groups = [];
8899
9055
  for (const [key, entry] of Object.entries(lock.entries)) {
8900
9056
  if (entry.provider !== provider.id || selectedKeys && !selectedKeys.has(key)) continue;
@@ -8904,13 +9060,18 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8904
9060
  const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
8905
9061
  if (state.status !== "review" && state.status !== "review-set" || (state.status === "review" ? !state.candidateUrls.length : !state.frameUrls.length)) continue;
8906
9062
  const frameUrls = state.status === "review" ? state.candidateUrls : state.frameUrls;
8907
- const sourceRoute = spec?.revision ? `/revision-source/${encodeURIComponent(String(groups.length))}` : null;
9063
+ const sourceRoute = spec?.revision ? `${prefix}/revision-source/${encodeURIComponent(String(groups.length))}` : null;
8908
9064
  if (sourceRoute && spec?.revision) {
8909
9065
  reviewAssets.set(sourceRoute, {
8910
9066
  path: spec.revision.sourceFile,
8911
9067
  contentType: spec.revision.sourceFormat === "jpeg" ? "image/jpeg" : "image/png"
8912
9068
  });
8913
9069
  }
9070
+ const current = await currentArt(entry, spec);
9071
+ const currentRoute = current ? `${prefix}/current-art/${encodeURIComponent(String(groups.length))}` : null;
9072
+ if (current && currentRoute) {
9073
+ reviewAssets.set(currentRoute, { path: current.path, contentType: current.contentType });
9074
+ }
8914
9075
  groups.push({
8915
9076
  key,
8916
9077
  assetId: entry.assetId,
@@ -8933,33 +9094,20 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8933
9094
  width: spec.revision.sourceWidth,
8934
9095
  height: spec.revision.sourceHeight
8935
9096
  }
8936
- } : {}
9097
+ } : {},
9098
+ ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
8937
9099
  });
8938
9100
  } catch (err) {
8939
9101
  log(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);
8940
9102
  }
8941
9103
  }
8942
- if (groups.length === 0) return { selected: 0, skipped: 0 };
8943
- const html = renderSheet(groups);
9104
+ if (groups.length === 0) return null;
8944
9105
  const byKey = new Map(groups.map((g) => [g.key, g]));
8945
- return serveReviewPage({
8946
- html,
8947
- port: opts.port,
8948
- open: opts.open,
8949
- onProgress: log,
9106
+ return {
9107
+ groups,
8950
9108
  assets: reviewAssets,
8951
- onReady: (url) => {
8952
- const info = { url, keys: groups.map((group) => group.key) };
8953
- if (opts.onReady) {
8954
- opts.onReady(info);
8955
- } else {
8956
- log(`
8957
- ${groups.length} asset(s) awaiting selection: ${url}`);
8958
- log(` (leave this running; it exits once you apply)
8959
- `);
8960
- }
8961
- },
8962
- handleApply: async (body) => {
9109
+ html: (options) => renderSheet(groups, options),
9110
+ apply: async (body, lockPath) => {
8963
9111
  const { selections } = body;
8964
9112
  let selected = 0;
8965
9113
  for (const { key, index } of selections) {
@@ -9019,117 +9167,3120 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
9019
9167
  await saveLock(lockPath, lock);
9020
9168
  return { selected, skipped: groups.length - selected };
9021
9169
  }
9170
+ };
9171
+ }
9172
+ async function runPicker(provider, lock, lockPath, opts = {}) {
9173
+ const log = opts.onProgress ?? (() => {
9174
+ });
9175
+ const session = await prepareReview(provider, lock, {
9176
+ onProgress: log,
9177
+ keys: opts.keys,
9178
+ specs: opts.specs
9179
+ });
9180
+ if (!session) return { selected: 0, skipped: 0 };
9181
+ const groups = session.groups;
9182
+ return serveReviewPage({
9183
+ html: session.html(),
9184
+ port: opts.port,
9185
+ open: opts.open,
9186
+ onProgress: log,
9187
+ assets: session.assets,
9188
+ onReady: (url) => {
9189
+ const info = { url, keys: groups.map((group) => group.key) };
9190
+ if (opts.onReady) {
9191
+ opts.onReady(info);
9192
+ } else {
9193
+ log(`
9194
+ ${groups.length} asset(s) awaiting selection: ${url}`);
9195
+ log(` (leave this running; it exits once you apply)
9196
+ `);
9197
+ }
9198
+ },
9199
+ handleApply: (body) => session.apply(body, lockPath)
9022
9200
  });
9023
9201
  }
9024
9202
 
9025
- // src/pipeline/init.ts
9026
- var import_promises15 = require("fs/promises");
9027
- var import_node_fs17 = require("fs");
9028
- var import_node_path20 = __toESM(require("path"), 1);
9029
- function pngSize(buf) {
9030
- if (buf.length < 24) return null;
9031
- const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
9032
- if (!isPng) return null;
9033
- if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
9034
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
9035
- }
9036
- function slugify(value) {
9037
- return value.replace(/\.png$/i, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
9203
+ // src/gallery/snapshot.ts
9204
+ var import_node_crypto6 = require("crypto");
9205
+ var import_node_fs20 = require("fs");
9206
+ var import_promises19 = require("fs/promises");
9207
+ var import_node_path23 = __toESM(require("path"), 1);
9208
+
9209
+ // src/workspace.ts
9210
+ var import_promises17 = require("fs/promises");
9211
+ var import_node_fs19 = require("fs");
9212
+ var import_node_path21 = __toESM(require("path"), 1);
9213
+ var import_zod6 = require("zod");
9214
+ var WorkspaceProjectSchema = import_zod6.z.object({
9215
+ id: import_zod6.z.string().min(1),
9216
+ /** Manifest path, relative to the catalog file's own directory. */
9217
+ manifest: import_zod6.z.string().min(1),
9218
+ /** Lockfile path, relative to the catalog file's own directory. */
9219
+ lock: import_zod6.z.string().min(1),
9220
+ provider: import_zod6.z.string().min(1).default("pixellab"),
9221
+ /** Free-form label for a shared account, e.g. distinguishing sandboxes. */
9222
+ account: import_zod6.z.string().optional()
9223
+ }).strict();
9224
+ var WorkspaceSchema = import_zod6.z.object({
9225
+ version: import_zod6.z.literal(1),
9226
+ projects: import_zod6.z.array(WorkspaceProjectSchema).default([])
9227
+ }).strict();
9228
+ function parseWorkspace(raw) {
9229
+ const parsed = WorkspaceSchema.safeParse(raw);
9230
+ if (parsed.success) return parsed.data;
9231
+ throw new Error(
9232
+ `Workspace catalog is not valid v1:
9233
+ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
9234
+ );
9038
9235
  }
9039
- async function walk(dir, exclude) {
9040
- const out = [];
9041
- for (const entry of await (0, import_promises15.readdir)(dir, { withFileTypes: true })) {
9042
- const full = import_node_path20.default.join(dir, entry.name);
9043
- if (exclude.some((x) => full.includes(x))) continue;
9044
- if (entry.isDirectory()) out.push(...await walk(full, exclude));
9045
- else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
9236
+ async function loadWorkspace(workspacePath) {
9237
+ if (!(0, import_node_fs19.existsSync)(workspacePath)) return { version: 1, projects: [] };
9238
+ let raw;
9239
+ try {
9240
+ raw = JSON.parse(await (0, import_promises17.readFile)(workspacePath, "utf8"));
9241
+ } catch (err) {
9242
+ throw new Error(
9243
+ `Workspace catalog at ${workspacePath} is malformed:
9244
+ ${err instanceof Error ? err.message : String(err)}`
9245
+ );
9046
9246
  }
9047
- return out;
9247
+ return parseWorkspace(raw);
9048
9248
  }
9049
- async function scanAssets(root, opts = {}) {
9050
- const exclude = opts.exclude ?? [];
9051
- const files = await walk(root, exclude);
9052
- const assets = [];
9053
- const skipped = [];
9054
- const seen = /* @__PURE__ */ new Map();
9055
- for (const file of files.sort()) {
9056
- const size = pngSize(await (0, import_promises15.readFile)(file));
9057
- if (!size) {
9058
- skipped.push(`${file} (not a readable PNG)`);
9059
- continue;
9060
- }
9061
- const rel = import_node_path20.default.relative(root, file);
9062
- const category = import_node_path20.default.dirname(rel) === "." ? "" : import_node_path20.default.dirname(rel);
9063
- let id = slugify(import_node_path20.default.basename(rel));
9064
- if (seen.has(id)) {
9065
- const n = seen.get(id) + 1;
9066
- seen.set(id, n);
9067
- id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
9068
- } else {
9069
- seen.set(id, 1);
9070
- }
9071
- assets.push({ id, category, file: rel, width: size.width, height: size.height });
9249
+ async function saveWorkspace(workspacePath, ws) {
9250
+ const sorted = {
9251
+ version: 1,
9252
+ projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
9253
+ };
9254
+ await (0, import_promises17.mkdir)(import_node_path21.default.dirname(import_node_path21.default.resolve(workspacePath)), { recursive: true });
9255
+ const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9256
+ try {
9257
+ await (0, import_promises17.writeFile)(tmp, JSON.stringify(sorted, null, 2) + "\n");
9258
+ await (0, import_promises17.rename)(tmp, workspacePath);
9259
+ } finally {
9260
+ await (0, import_promises17.rm)(tmp, { force: true });
9072
9261
  }
9073
- return { assets, skipped };
9074
9262
  }
9075
- function buildManifest(name, styleId, generator, outDir, scanned) {
9076
- const squares = scanned.filter((a) => a.width === a.height);
9077
- const commonSize = squares.length ? mode(squares.map((a) => a.width)) : 64;
9078
- const assets = {};
9079
- for (const asset of scanned) {
9080
- assets[asset.id] = {
9081
- prompt: "",
9082
- promptByStyle: {},
9083
- category: asset.category || void 0,
9084
- file: asset.file,
9085
- tags: [],
9086
- styles: [],
9087
- providerInputs: {},
9088
- ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
9089
- };
9263
+ function toPortablePath(dir, absolute) {
9264
+ return import_node_path21.default.relative(dir, absolute).split(import_node_path21.default.sep).join("/");
9265
+ }
9266
+ function resolveProject(dir, project) {
9267
+ return {
9268
+ manifestPath: import_node_path21.default.resolve(dir, project.manifest.split("/").join(import_node_path21.default.sep)),
9269
+ lockPath: import_node_path21.default.resolve(dir, project.lock.split("/").join(import_node_path21.default.sep))
9270
+ };
9271
+ }
9272
+ function validateWorkspace(ws, dir) {
9273
+ const diagnostics = [];
9274
+ const idCounts = /* @__PURE__ */ new Map();
9275
+ const lockOwners = /* @__PURE__ */ new Map();
9276
+ const manifestOwners = /* @__PURE__ */ new Map();
9277
+ for (const project of ws.projects) {
9278
+ idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
9279
+ const { manifestPath, lockPath } = resolveProject(dir, project);
9280
+ lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
9281
+ manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
9282
+ if (import_node_path21.default.isAbsolute(project.manifest) || import_node_path21.default.isAbsolute(project.lock)) {
9283
+ diagnostics.push({
9284
+ id: "absolute-path",
9285
+ level: "warning",
9286
+ message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
9287
+ });
9288
+ }
9289
+ if (!(0, import_node_fs19.existsSync)(manifestPath)) {
9290
+ diagnostics.push({
9291
+ id: "missing-manifest",
9292
+ level: "error",
9293
+ message: `project "${project.id}" manifest not found: ${manifestPath}`
9294
+ });
9295
+ }
9296
+ if (!(0, import_node_fs19.existsSync)(lockPath)) {
9297
+ diagnostics.push({
9298
+ id: "missing-lock",
9299
+ level: "error",
9300
+ message: `project "${project.id}" lockfile not found: ${lockPath}`
9301
+ });
9302
+ }
9303
+ }
9304
+ for (const [id, count] of idCounts) {
9305
+ if (count > 1) {
9306
+ diagnostics.push({
9307
+ id: "duplicate-id",
9308
+ level: "error",
9309
+ message: `project id "${id}" is registered ${count} times`
9310
+ });
9311
+ }
9312
+ }
9313
+ for (const [lockPath, ids] of lockOwners) {
9314
+ if (ids.length > 1) {
9315
+ diagnostics.push({
9316
+ id: "duplicate-lock",
9317
+ level: "error",
9318
+ message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
9319
+ });
9320
+ }
9321
+ }
9322
+ for (const [manifestPath, ids] of manifestOwners) {
9323
+ if (ids.length > 1) {
9324
+ diagnostics.push({
9325
+ id: "duplicate-manifest",
9326
+ level: "warning",
9327
+ message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
9328
+ });
9329
+ }
9330
+ }
9331
+ const providers = new Set(ws.projects.map((p) => p.provider));
9332
+ if (providers.size > 1) {
9333
+ diagnostics.push({
9334
+ id: "mixed-provider",
9335
+ level: "warning",
9336
+ message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
9337
+ });
9338
+ }
9339
+ return diagnostics;
9340
+ }
9341
+
9342
+ // src/gallery/edit.ts
9343
+ var import_promises18 = require("fs/promises");
9344
+ var import_node_path22 = __toESM(require("path"), 1);
9345
+ var import_zod7 = require("zod");
9346
+ var HexSha = import_zod7.z.string().regex(/^[0-9a-f]{64}$/);
9347
+ var AssetPatchSchema = import_zod7.z.object({
9348
+ prompt: import_zod7.z.string().optional(),
9349
+ /** Per-style override for the style being viewed; null removes it. */
9350
+ promptForStyle: import_zod7.z.object({ styleId: import_zod7.z.string().min(1), prompt: import_zod7.z.string().nullable() }).optional(),
9351
+ category: import_zod7.z.string().nullable().optional(),
9352
+ width: import_zod7.z.number().int().nullable().optional(),
9353
+ height: import_zod7.z.number().int().nullable().optional(),
9354
+ size: import_zod7.z.number().int().nullable().optional(),
9355
+ tags: import_zod7.z.array(import_zod7.z.string()).optional()
9356
+ }).strict();
9357
+ var NewAssetSchema = import_zod7.z.object({
9358
+ prompt: import_zod7.z.string(),
9359
+ category: import_zod7.z.string().optional(),
9360
+ width: import_zod7.z.number().int().optional(),
9361
+ height: import_zod7.z.number().int().optional(),
9362
+ size: import_zod7.z.number().int().optional(),
9363
+ tags: import_zod7.z.array(import_zod7.z.string()).optional(),
9364
+ /** Restrict to these styles; empty or omitted means every style. */
9365
+ styles: import_zod7.z.array(import_zod7.z.string().min(1)).optional()
9366
+ }).strict();
9367
+ var CANDIDATE_OPTION = {
9368
+ retrodiffusion: "numImages",
9369
+ comfyui: "numImages",
9370
+ scenario: "numOutputs"
9371
+ };
9372
+ var ManifestEditSchema = import_zod7.z.discriminatedUnion("action", [
9373
+ import_zod7.z.object({
9374
+ action: import_zod7.z.literal("patch-asset"),
9375
+ /** Workspace project id; omitted for a single-project gallery. */
9376
+ project: import_zod7.z.string().min(1).optional(),
9377
+ assetId: import_zod7.z.string().min(1),
9378
+ /** Manifest bytes the page last saw; a mismatch refuses the write. */
9379
+ expectedSha256: HexSha,
9380
+ patch: AssetPatchSchema
9381
+ }).strict(),
9382
+ import_zod7.z.object({
9383
+ action: import_zod7.z.literal("add-asset"),
9384
+ project: import_zod7.z.string().min(1).optional(),
9385
+ assetId: import_zod7.z.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
9386
+ expectedSha256: HexSha,
9387
+ asset: NewAssetSchema
9388
+ }).strict(),
9389
+ import_zod7.z.object({
9390
+ action: import_zod7.z.literal("patch-style"),
9391
+ project: import_zod7.z.string().min(1).optional(),
9392
+ styleId: import_zod7.z.string().min(1),
9393
+ expectedSha256: HexSha,
9394
+ patch: import_zod7.z.object({
9395
+ /** Candidates per generation, written to the provider's own option. */
9396
+ candidates: import_zod7.z.number().int().min(1).max(64).optional(),
9397
+ /** Empty clears the style's own value (inherit, or the default). */
9398
+ promptPrefix: import_zod7.z.string().optional(),
9399
+ promptSuffix: import_zod7.z.string().optional(),
9400
+ /** `#rrggbb` values; null or empty clears the style's own palette. */
9401
+ palette: import_zod7.z.array(import_zod7.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional()
9402
+ }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
9403
+ }).strict()
9404
+ ]);
9405
+ var ManifestDriftError = class extends Error {
9406
+ status = 409;
9407
+ constructor(manifestPath) {
9408
+ super(`${manifestPath} changed on disk since this page loaded; refresh and try again`);
9409
+ this.name = "ManifestDriftError";
9410
+ }
9411
+ };
9412
+ var ManifestEditError = class extends Error {
9413
+ status = 400;
9414
+ constructor(message6) {
9415
+ super(message6);
9416
+ this.name = "ManifestEditError";
9417
+ }
9418
+ };
9419
+ function serializeLike(original, value) {
9420
+ const indentMatch = /\n([ \t]+)"/.exec(original);
9421
+ const indent = indentMatch ? indentMatch[1].startsWith(" ") ? " " : indentMatch[1].length : 2;
9422
+ return JSON.stringify(value, null, indent) + (original.endsWith("\n") ? "\n" : "");
9423
+ }
9424
+ function styleProvider(raw, styleId, seen = /* @__PURE__ */ new Set()) {
9425
+ const style = raw.styles?.[styleId];
9426
+ if (!style || seen.has(styleId)) return raw.provider ?? "pixellab";
9427
+ if (style.provider) return style.provider;
9428
+ seen.add(styleId);
9429
+ return style.extends ? styleProvider(raw, style.extends, seen) : raw.provider ?? "pixellab";
9430
+ }
9431
+ function setOrDelete(target, key, value) {
9432
+ if (value === null || value === void 0) delete target[key];
9433
+ else target[key] = value;
9434
+ }
9435
+ function applyEdit(raw, edit) {
9436
+ raw.assets ??= {};
9437
+ if (edit.action === "patch-style") {
9438
+ const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
9439
+ if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
9440
+ const { patch: patch2 } = edit;
9441
+ if (patch2.candidates !== void 0) {
9442
+ const provider = styleProvider(raw, edit.styleId);
9443
+ const option = CANDIDATE_OPTION[provider];
9444
+ if (!option) {
9445
+ throw new ManifestEditError(
9446
+ provider === "pixellab" ? "PixelLab's candidate count follows the generator and size: map and pixflux return one image; a 1dir style returns 4\u201364 for its size" : `provider "${provider}" has no candidate-count option`
9447
+ );
9448
+ }
9449
+ const options = { ...style.providerOptions ?? {} };
9450
+ options[provider] = { ...options[provider] ?? {}, [option]: patch2.candidates };
9451
+ style.providerOptions = options;
9452
+ }
9453
+ if (patch2.promptPrefix !== void 0) setOrDelete(style, "promptPrefix", patch2.promptPrefix || null);
9454
+ if (patch2.promptSuffix !== void 0) setOrDelete(style, "promptSuffix", patch2.promptSuffix || null);
9455
+ if (patch2.palette !== void 0) {
9456
+ const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
9457
+ setOrDelete(style, "palette", colors.length ? colors : null);
9458
+ }
9459
+ return;
9460
+ }
9461
+ if (edit.action === "add-asset") {
9462
+ if (Object.hasOwn(raw.assets, edit.assetId)) {
9463
+ throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
9464
+ }
9465
+ const { styles, tags, ...rest } = edit.asset;
9466
+ const asset2 = { ...rest };
9467
+ if (tags?.length) asset2.tags = tags;
9468
+ if (styles?.length) {
9469
+ for (const styleId of styles) {
9470
+ if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
9471
+ throw new ManifestEditError(`unknown style "${styleId}"`);
9472
+ }
9473
+ }
9474
+ asset2.styles = styles;
9475
+ }
9476
+ raw.assets[edit.assetId] = asset2;
9477
+ return;
9478
+ }
9479
+ const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
9480
+ if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
9481
+ const { patch } = edit;
9482
+ if (patch.prompt !== void 0) asset.prompt = patch.prompt;
9483
+ if (patch.promptForStyle) {
9484
+ const { styleId, prompt } = patch.promptForStyle;
9485
+ if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
9486
+ throw new ManifestEditError(`unknown style "${styleId}"`);
9487
+ }
9488
+ const byStyle = { ...asset.promptByStyle ?? {} };
9489
+ if (prompt === null) delete byStyle[styleId];
9490
+ else byStyle[styleId] = prompt;
9491
+ if (Object.keys(byStyle).length) asset.promptByStyle = byStyle;
9492
+ else delete asset.promptByStyle;
9493
+ }
9494
+ if ("category" in patch) setOrDelete(asset, "category", patch.category);
9495
+ if ("width" in patch) setOrDelete(asset, "width", patch.width);
9496
+ if ("height" in patch) setOrDelete(asset, "height", patch.height);
9497
+ if ("size" in patch) setOrDelete(asset, "size", patch.size);
9498
+ if (patch.tags !== void 0) {
9499
+ if (patch.tags.length) asset.tags = patch.tags;
9500
+ else delete asset.tags;
9501
+ }
9502
+ }
9503
+ async function applyManifestEdit(manifestPath, edit) {
9504
+ const absolute = import_node_path22.default.resolve(manifestPath);
9505
+ const original = await (0, import_promises18.readFile)(absolute, "utf8");
9506
+ const before = sha256(original);
9507
+ if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
9508
+ let raw;
9509
+ try {
9510
+ raw = JSON.parse(original);
9511
+ } catch (error) {
9512
+ throw new ManifestEditError(
9513
+ `manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
9514
+ );
9515
+ }
9516
+ applyEdit(raw, edit);
9517
+ const next = serializeLike(original, raw);
9518
+ if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
9519
+ const tmp = import_node_path22.default.join(
9520
+ import_node_path22.default.dirname(absolute),
9521
+ `.${import_node_path22.default.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
9522
+ );
9523
+ try {
9524
+ await (0, import_promises18.writeFile)(tmp, next);
9525
+ try {
9526
+ const loaded = await loadManifest(tmp);
9527
+ await resolveSpecs(loaded);
9528
+ } catch (error) {
9529
+ const message6 = error instanceof Error ? error.message : String(error);
9530
+ throw new ManifestEditError(message6.replace(tmp, absolute));
9531
+ }
9532
+ await (0, import_promises18.rename)(tmp, absolute);
9533
+ } finally {
9534
+ await (0, import_promises18.rm)(tmp, { force: true });
9535
+ }
9536
+ return { manifestPath: absolute, sha256: sha256(next), changed: true };
9537
+ }
9538
+ function createGalleryEditHandler(opts) {
9539
+ const log = opts.onProgress ?? (() => {
9540
+ });
9541
+ return async (body) => {
9542
+ const parsed = ManifestEditSchema.safeParse(body);
9543
+ if (!parsed.success) {
9544
+ throw new ManifestEditError(
9545
+ "invalid edit: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
9546
+ );
9547
+ }
9548
+ let manifestPath;
9549
+ try {
9550
+ manifestPath = await opts.manifestFor(parsed.data.project);
9551
+ } catch (error) {
9552
+ throw new ManifestEditError(error instanceof Error ? error.message : String(error));
9553
+ }
9554
+ const result = await applyManifestEdit(manifestPath, parsed.data);
9555
+ if (result.changed) {
9556
+ const relative = import_node_path22.default.relative(process.cwd(), result.manifestPath);
9557
+ const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
9558
+ const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
9559
+ log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
9560
+ }
9561
+ return opts.reload();
9562
+ };
9563
+ }
9564
+
9565
+ // src/gallery/snapshot.ts
9566
+ function galleryMediaId(absolutePath) {
9567
+ return (0, import_node_crypto6.createHash)("sha256").update(import_node_path23.default.resolve(absolutePath)).digest("hex").slice(0, 24);
9568
+ }
9569
+ function galleryMediaRoute(id) {
9570
+ return `/media/${id}`;
9571
+ }
9572
+ function isDeclared(loaded, styleId, assetId) {
9573
+ const asset = loaded.manifest.assets[assetId];
9574
+ if (!asset || !loaded.manifest.styles[styleId]) return false;
9575
+ return asset.styles.length === 0 || asset.styles.includes(styleId);
9576
+ }
9577
+ function matchesFilter(filter, styleId, assetId) {
9578
+ if (filter?.styles?.length && !filter.styles.includes(styleId)) return false;
9579
+ if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
9580
+ return true;
9581
+ }
9582
+ function metadataFps(entry) {
9583
+ if (!entry) return null;
9584
+ const namespace = entry.providerMetadata?.[entry.provider];
9585
+ const frameSet = namespace?.frameSet;
9586
+ if (frameSet && typeof frameSet === "object" && "fps" in frameSet) {
9587
+ const fps = frameSet.fps;
9588
+ if (typeof fps === "number" && Number.isFinite(fps) && fps > 0) return fps;
9589
+ }
9590
+ return null;
9591
+ }
9592
+ async function fileBytes(absolutePath) {
9593
+ try {
9594
+ const info = await (0, import_promises19.stat)(absolutePath);
9595
+ return info.isFile() ? info.size : null;
9596
+ } catch {
9597
+ return null;
9598
+ }
9599
+ }
9600
+ async function describeOutput(media, root, absolutePath, recorded = {}) {
9601
+ const bytes = await fileBytes(absolutePath);
9602
+ const exists = bytes !== null;
9603
+ const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
9604
+ let url = null;
9605
+ if (exists && mediaType) {
9606
+ const id = galleryMediaId(absolutePath);
9607
+ media.set(id, { path: absolutePath, contentType: mediaType });
9608
+ url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
9609
+ }
9610
+ return {
9611
+ path: portableOutputPath(absolutePath, root),
9612
+ absolutePath,
9613
+ sha256: recorded.sha256 ?? null,
9614
+ role: recorded.role ?? null,
9615
+ mediaType,
9616
+ exists,
9617
+ bytes,
9618
+ url
9619
+ };
9620
+ }
9621
+ async function describeQuality(media, root, inspection) {
9622
+ const outputPaths = inspection.outputs ?? [inspection.output];
9623
+ const outputs = await Promise.all(
9624
+ outputPaths.map((file) => describeOutput(media, root, import_node_path23.default.resolve(root, file)))
9625
+ );
9626
+ const recordPath = import_node_path23.default.resolve(root, inspection.record);
9627
+ const recordExists = (0, import_node_fs20.existsSync)(recordPath);
9628
+ let options = null;
9629
+ let check = null;
9630
+ if (recordExists) {
9631
+ try {
9632
+ const result = await checkQualityRecord(recordPath);
9633
+ options = result.options;
9634
+ check = { safe: result.safe, current: result.current, reasons: result.reasons };
9635
+ } catch (error) {
9636
+ check = {
9637
+ safe: false,
9638
+ current: false,
9639
+ reasons: [error instanceof Error ? error.message : String(error)]
9640
+ };
9641
+ }
9642
+ }
9643
+ return {
9644
+ state: inspection.state,
9645
+ reason: inspection.reason,
9646
+ output: portableOutputPath(import_node_path23.default.resolve(root, inspection.output), root),
9647
+ record: portableOutputPath(recordPath, root),
9648
+ recordExists,
9649
+ outputs,
9650
+ palette: options?.palette.colors ?? null,
9651
+ review: options ? options.review.status === "approved" ? {
9652
+ status: "approved",
9653
+ reviewer: options.review.reviewer,
9654
+ approvedAt: options.review.approvedAt,
9655
+ note: options.review.note ?? null
9656
+ } : { status: "pending" } : null,
9657
+ nativeGrid: options ? {
9658
+ confidence: options.nativeGrid.confidence,
9659
+ consensus: options.nativeGrid.consensus,
9660
+ stepX: options.nativeGrid.stepX,
9661
+ stepY: options.nativeGrid.stepY,
9662
+ sourceWidth: options.nativeGrid.sourceWidth,
9663
+ sourceHeight: options.nativeGrid.sourceHeight,
9664
+ nativeWidth: options.nativeGrid.nativeWidth,
9665
+ nativeHeight: options.nativeGrid.nativeHeight
9666
+ } : null,
9667
+ audit: options ? {
9668
+ safe: options.audit.safe,
9669
+ colorCount: options.audit.colorCount,
9670
+ transparency: options.audit.transparency,
9671
+ reasons: options.audit.reasons
9672
+ } : null,
9673
+ frameSet: options?.frameSet ? { fps: options.frameSet.fps, count: options.frameSet.count, roles: options.frameSet.roles } : null,
9674
+ check
9675
+ };
9676
+ }
9677
+ async function buildGallerySnapshot(opts) {
9678
+ const { loaded, specs, lock } = opts;
9679
+ const root = loaded.root;
9680
+ const media = /* @__PURE__ */ new Map();
9681
+ const plan = await buildPlan(specs, lock);
9682
+ const items = [];
9683
+ const manifestText = await (0, import_promises19.readFile)(loaded.path, "utf8");
9684
+ const manifestSha256 = sha256(manifestText);
9685
+ let rawStyles = {};
9686
+ try {
9687
+ const raw = JSON.parse(manifestText);
9688
+ if (raw && typeof raw === "object" && raw.styles && typeof raw.styles === "object") rawStyles = raw.styles;
9689
+ } catch {
9690
+ }
9691
+ for (const planItem of plan.items) {
9692
+ const { spec, key } = planItem;
9693
+ const entry = lock.entries[key];
9694
+ const asset = loaded.manifest.assets[spec.assetId] ?? null;
9695
+ let outputs;
9696
+ if (entry && entry.outputs.length) {
9697
+ outputs = await Promise.all(
9698
+ entry.outputs.map(
9699
+ (output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
9700
+ )
9701
+ );
9702
+ } else if (spec.source) {
9703
+ outputs = [await describeOutput(media, root, import_node_path23.default.resolve(root, spec.source))];
9704
+ } else if (!entry && (0, import_node_fs20.existsSync)(spec.outFile)) {
9705
+ outputs = [await describeOutput(media, root, spec.outFile)];
9706
+ } else {
9707
+ outputs = [];
9708
+ }
9709
+ const quality = planItem.quality ? await describeQuality(media, root, planItem.quality) : null;
9710
+ items.push({
9711
+ id: key,
9712
+ project: null,
9713
+ key,
9714
+ styleId: spec.styleId,
9715
+ assetId: spec.assetId,
9716
+ declared: true,
9717
+ state: planItem.state,
9718
+ reason: planItem.reason,
9719
+ status: entry?.status ?? null,
9720
+ provider: entry?.provider ?? spec.provider,
9721
+ generator: entry?.generator ?? spec.generator,
9722
+ prompt: entry?.prompt ?? spec.prompt,
9723
+ currentPrompt: entry && entry.prompt !== spec.prompt ? spec.prompt : null,
9724
+ width: entry?.width ?? spec.width,
9725
+ height: entry?.height ?? spec.height,
9726
+ tileFeature: entry?.tileFeature ?? spec.tileFeature ?? null,
9727
+ fps: quality?.frameSet?.fps ?? metadataFps(entry) ?? spec.quality?.fps ?? null,
9728
+ cost: entry?.cost ?? 0,
9729
+ costUnit: entry?.costUnit ?? spec.costUnit,
9730
+ estimatedCost: spec.cost,
9731
+ candidates: spec.candidates,
9732
+ submittedAt: entry?.submittedAt ?? null,
9733
+ downloadedAt: entry?.downloadedAt ?? null,
9734
+ jobId: entry?.jobId ?? null,
9735
+ objectId: entry?.objectId ?? null,
9736
+ reviewObjectId: entry?.reviewObjectId ?? null,
9737
+ candidateIndex: entry?.candidateIndex ?? null,
9738
+ error: entry?.error ?? null,
9739
+ recordedSpecHash: entry?.specHash ?? null,
9740
+ currentSpecHash: spec.specHash,
9741
+ revision: entry?.revision ?? null,
9742
+ revisionParentKey: spec.revision ? lockKey(spec.styleId, spec.revision.sourceAssetId) : entry?.revision ? lockKey(spec.styleId, entry.revision.sourceAssetId) : null,
9743
+ outputs,
9744
+ quality,
9745
+ providerMetadata: entry?.providerMetadata ?? {},
9746
+ asset,
9747
+ source: spec.source ?? null,
9748
+ tags: spec.tags,
9749
+ category: asset?.category ?? null
9750
+ });
9751
+ }
9752
+ const shown = new Set(items.map((item) => item.key));
9753
+ for (const [key, entry] of Object.entries(lock.entries)) {
9754
+ if (shown.has(key)) continue;
9755
+ if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
9756
+ if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
9757
+ const outputs = await Promise.all(
9758
+ entry.outputs.map(
9759
+ (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
9760
+ )
9761
+ );
9762
+ items.push({
9763
+ id: key,
9764
+ project: null,
9765
+ key,
9766
+ styleId: entry.styleId,
9767
+ assetId: entry.assetId,
9768
+ declared: false,
9769
+ state: "undeclared",
9770
+ reason: "not declared by the current manifest; `pixelkiln prune` removes the entry",
9771
+ status: entry.status,
9772
+ provider: entry.provider,
9773
+ generator: entry.generator,
9774
+ prompt: entry.prompt,
9775
+ currentPrompt: null,
9776
+ width: entry.width,
9777
+ height: entry.height,
9778
+ tileFeature: entry.tileFeature,
9779
+ fps: metadataFps(entry),
9780
+ cost: entry.cost,
9781
+ costUnit: entry.costUnit,
9782
+ estimatedCost: null,
9783
+ candidates: null,
9784
+ submittedAt: entry.submittedAt,
9785
+ downloadedAt: entry.downloadedAt,
9786
+ jobId: entry.jobId,
9787
+ objectId: entry.objectId,
9788
+ reviewObjectId: entry.reviewObjectId,
9789
+ candidateIndex: entry.candidateIndex,
9790
+ error: entry.error,
9791
+ recordedSpecHash: entry.specHash,
9792
+ currentSpecHash: null,
9793
+ revision: entry.revision,
9794
+ revisionParentKey: entry.revision ? lockKey(entry.styleId, entry.revision.sourceAssetId) : null,
9795
+ outputs,
9796
+ quality: null,
9797
+ providerMetadata: entry.providerMetadata ?? {},
9798
+ asset: null,
9799
+ source: null,
9800
+ tags: [],
9801
+ category: null
9802
+ });
9803
+ }
9804
+ items.sort((a, b) => a.key.localeCompare(b.key));
9805
+ const byState = {};
9806
+ for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
9807
+ const byStatus = {};
9808
+ for (const entry of Object.values(lock.entries)) {
9809
+ byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
9810
+ }
9811
+ const styleIds = /* @__PURE__ */ new Set([
9812
+ ...Object.keys(loaded.manifest.styles),
9813
+ ...items.map((item) => item.styleId)
9814
+ ]);
9815
+ const styles = [...styleIds].filter((id) => !opts.filter?.styles?.length || opts.filter.styles.includes(id) || items.some((item) => item.styleId === id)).sort().map((id) => {
9816
+ const style = loaded.manifest.styles[id];
9817
+ const styleItems = items.filter((item) => item.styleId === id);
9818
+ const spend = {};
9819
+ for (const item of styleItems) {
9820
+ if (item.cost) spend[item.costUnit] = (spend[item.costUnit] ?? 0) + item.cost;
9821
+ }
9822
+ const counts = new Set(styleItems.filter((item) => item.declared && item.candidates !== null).map((item) => item.candidates));
9823
+ const provider = style ? style.provider ?? loaded.manifest.provider : styleItems[0]?.provider ?? loaded.manifest.provider;
9824
+ const actionableItems = styleItems.filter((item) => item.declared && (item.state === "missing" || item.state === "stale" || item.state === "failed"));
9825
+ const declaredItems = styleItems.filter((item) => item.declared);
9826
+ const rawStyle = Object.hasOwn(rawStyles, id) ? rawStyles[id] : void 0;
9827
+ return {
9828
+ id,
9829
+ project: null,
9830
+ // A declared style inherits the manifest default; only a style the
9831
+ // manifest no longer has falls back to what its entries recorded.
9832
+ provider,
9833
+ generator: style?.generator ?? styleItems[0]?.generator ?? "map",
9834
+ outDir: style?.outDir ?? "",
9835
+ promptPrefix: style?.promptPrefix ?? "",
9836
+ promptSuffix: style?.promptSuffix ?? "",
9837
+ palette: style?.palette ?? [],
9838
+ quality: Boolean(style?.quality),
9839
+ tags: style?.tags ?? [],
9840
+ extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
9841
+ ownFields: rawStyle ? Object.keys(rawStyle).filter((key) => key !== "extends") : [],
9842
+ items: styleItems.length,
9843
+ spendByUnit: spend,
9844
+ regenerate: {
9845
+ assets: declaredItems.length,
9846
+ cost: declaredItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
9847
+ costUnit: declaredItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
9848
+ },
9849
+ candidates: counts.size === 1 ? [...counts][0] : null,
9850
+ candidatesEditable: Boolean(style) && Object.hasOwn(CANDIDATE_OPTION, provider),
9851
+ actionable: {
9852
+ keys: actionableItems.map((item) => item.key),
9853
+ cost: actionableItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
9854
+ costUnit: actionableItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
9855
+ }
9856
+ };
9857
+ }).filter((style) => style.items > 0 || !opts.filter?.styles?.length);
9858
+ const snapshot = {
9859
+ version: 1,
9860
+ generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9861
+ project: {
9862
+ id: loaded.manifest.name,
9863
+ name: loaded.manifest.name,
9864
+ manifest: loaded.path,
9865
+ lock: import_node_path23.default.resolve(opts.lockPath),
9866
+ root,
9867
+ provider: loaded.manifest.provider,
9868
+ account: null,
9869
+ manifestSha256,
9870
+ entries: Object.keys(lock.entries).length,
9871
+ items: items.length,
9872
+ spendByUnit: spendByUnit(lock),
9873
+ error: null
9874
+ },
9875
+ workspace: null,
9876
+ filter: { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] },
9877
+ totals: {
9878
+ entries: Object.keys(lock.entries).length,
9879
+ items: items.length,
9880
+ byState,
9881
+ byStatus,
9882
+ spendByUnit: spendByUnit(lock)
9883
+ },
9884
+ styles,
9885
+ items
9886
+ };
9887
+ return { snapshot, media };
9888
+ }
9889
+ async function buildWorkspaceGallerySnapshot(opts) {
9890
+ const dir = import_node_path23.default.dirname(import_node_path23.default.resolve(opts.workspacePath));
9891
+ const media = /* @__PURE__ */ new Map();
9892
+ const projects = [];
9893
+ const items = [];
9894
+ const styles = [];
9895
+ const filter = { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] };
9896
+ for (const project of opts.workspace.projects) {
9897
+ const { manifestPath, lockPath } = resolveProject(dir, project);
9898
+ try {
9899
+ const loaded = await loadManifest(manifestPath);
9900
+ const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
9901
+ const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
9902
+ const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
9903
+ const lock = await loadLock(lockPath);
9904
+ let projectItems = [];
9905
+ let projectStyles = [];
9906
+ let manifestSha256 = null;
9907
+ if (!excluded) {
9908
+ const specs = await resolveSpecs(loaded, { styles: styleFilter, assets: assetFilter });
9909
+ normalizeLockOutputPaths(lock, specs);
9910
+ const build = await buildGallerySnapshot({
9911
+ loaded,
9912
+ specs,
9913
+ lock,
9914
+ lockPath,
9915
+ filter: { styles: styleFilter, assets: assetFilter },
9916
+ now: opts.now
9917
+ });
9918
+ for (const [id, file] of build.media) media.set(id, file);
9919
+ manifestSha256 = build.snapshot.project?.manifestSha256 ?? null;
9920
+ projectItems = build.snapshot.items.map((item) => ({
9921
+ ...item,
9922
+ id: `${project.id}:${item.key}`,
9923
+ project: project.id
9924
+ }));
9925
+ projectStyles = build.snapshot.styles.map((style) => ({ ...style, project: project.id }));
9926
+ }
9927
+ items.push(...projectItems);
9928
+ styles.push(...projectStyles);
9929
+ projects.push({
9930
+ id: project.id,
9931
+ name: loaded.manifest.name,
9932
+ manifest: manifestPath,
9933
+ lock: import_node_path23.default.resolve(lockPath),
9934
+ root: loaded.root,
9935
+ provider: loaded.manifest.provider,
9936
+ account: project.account ?? null,
9937
+ manifestSha256: manifestSha256 ?? sha256(await (0, import_promises19.readFile)(manifestPath, "utf8")),
9938
+ entries: Object.keys(lock.entries).length,
9939
+ items: projectItems.length,
9940
+ spendByUnit: spendByUnit(lock),
9941
+ error: null
9942
+ });
9943
+ } catch (err) {
9944
+ projects.push({
9945
+ id: project.id,
9946
+ name: project.id,
9947
+ manifest: manifestPath,
9948
+ lock: import_node_path23.default.resolve(lockPath),
9949
+ root: import_node_path23.default.dirname(manifestPath),
9950
+ provider: project.provider,
9951
+ account: project.account ?? null,
9952
+ manifestSha256: null,
9953
+ entries: 0,
9954
+ items: 0,
9955
+ spendByUnit: {},
9956
+ error: err instanceof Error ? err.message : String(err)
9957
+ });
9958
+ }
9959
+ }
9960
+ const byState = {};
9961
+ for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
9962
+ const byStatus = {};
9963
+ for (const item of items) if (item.status) byStatus[item.status] = (byStatus[item.status] ?? 0) + 1;
9964
+ const spend = {};
9965
+ for (const project of projects) {
9966
+ for (const [unit, amount] of Object.entries(project.spendByUnit)) {
9967
+ spend[unit] = (spend[unit] ?? 0) + amount;
9968
+ }
9969
+ }
9970
+ return {
9971
+ snapshot: {
9972
+ version: 1,
9973
+ generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9974
+ project: null,
9975
+ workspace: { path: import_node_path23.default.resolve(opts.workspacePath), dir, projects },
9976
+ filter,
9977
+ totals: {
9978
+ entries: projects.reduce((sum, project) => sum + project.entries, 0),
9979
+ items: items.length,
9980
+ byState,
9981
+ byStatus,
9982
+ spendByUnit: spend
9983
+ },
9984
+ styles,
9985
+ items
9986
+ },
9987
+ media
9988
+ };
9989
+ }
9990
+
9991
+ // src/gallery/page.ts
9992
+ function renderGallery(snapshot, opts = {}) {
9993
+ const data = JSON.stringify(snapshot).replace(/<\//g, "<\\/").replace(/<!--/g, "<\\u0021--");
9994
+ const session = JSON.stringify(opts.session ?? null);
9995
+ const editable = JSON.stringify(Boolean(opts.editable && opts.session));
9996
+ const generation = JSON.stringify(Boolean(opts.generation && opts.session));
9997
+ const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
9998
+ return `<!doctype html>
9999
+ <html lang="en">
10000
+ <head>
10001
+ <meta charset="utf-8">
10002
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10003
+ <title>${escapeHtml2(title)}</title>
10004
+ <link rel="icon" href="data:,">
10005
+ <style>
10006
+ :root {
10007
+ --bg: #17150f; --panel: #201d17; --panel-deep: #100f0c;
10008
+ --line: rgba(243,234,214,.16); --line-strong: rgba(243,234,214,.3);
10009
+ --text: #f3ead6; --dim: #9b9384; --accent: #ff6b35; --accent-soft: #ff9c5f;
10010
+ --ok: #b9f27c; --warn: #ff9c5f; --bad: #ff7a6b; --cool: #8fc7ff;
10011
+ --content: 1520px; --drawer: 540px;
10012
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
10013
+ --checker:
10014
+ linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
10015
+ linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
10016
+ }
10017
+ * { box-sizing: border-box; }
10018
+ html { color-scheme: dark; }
10019
+ body { margin:0; background:var(--bg); color:var(--text);
10020
+ font:14px/1.5 ui-sans-serif,-apple-system,"SF Pro Text",Inter,system-ui,sans-serif; }
10021
+ button, input, select { font:inherit; color:inherit; }
10022
+ button { border-radius:0; padding:6px 11px; border:1px solid var(--line);
10023
+ background:transparent; cursor:pointer; }
10024
+ button:hover { border-color:var(--line-strong); }
10025
+ button.primary { background:var(--accent); border-color:var(--accent); color:var(--bg); font-weight:600; }
10026
+ button:disabled { opacity:.45; cursor:not-allowed; }
10027
+ :focus-visible { outline:2px solid var(--accent-soft); outline-offset:2px; }
10028
+ a { color:var(--accent-soft); }
10029
+ code, .mono { font-family:var(--mono); }
10030
+
10031
+ header { position:sticky; top:0; z-index:10; background:var(--panel);
10032
+ border-bottom:1px solid var(--line-strong); }
10033
+ .bar { width:min(100%,var(--content)); margin:0 auto; padding:13px 22px 0;
10034
+ display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
10035
+ h1 { font:700 14px/1 var(--mono); margin:0; letter-spacing:-.02em; white-space:nowrap; }
10036
+ h1::before { content:'\u25C6'; color:var(--accent); margin-right:9px; font-size:11px; }
10037
+ .project { color:var(--dim); font-size:12.5px; min-width:0; overflow:hidden;
10038
+ text-overflow:ellipsis; white-space:nowrap; }
10039
+ .project b { color:var(--text); font-weight:600; }
10040
+ .grow { flex:1 1 auto; }
10041
+ .search { display:flex; align-items:center; gap:8px; border:1px solid var(--line);
10042
+ background:var(--panel-deep); padding:0 10px; min-width:min(320px, 100%); }
10043
+ .search input { flex:1; border:0; background:transparent; padding:7px 0; min-width:0; }
10044
+ .search input:focus { outline:none; }
10045
+ .search kbd { color:var(--dim); border:1px solid var(--line); border-bottom-width:2px;
10046
+ padding:0 5px; font-size:11px; font-family:var(--mono); }
10047
+ select { background:var(--panel-deep); border:1px solid var(--line); padding:6px 8px; border-radius:0; }
10048
+ .totals { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 0;
10049
+ display:flex; gap:14px; flex-wrap:wrap; align-items:baseline;
10050
+ color:var(--dim); font-size:12.5px; font-variant-numeric:tabular-nums; }
10051
+ .totals b { color:var(--text); font-weight:600; }
10052
+ .chips { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 13px;
10053
+ display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
10054
+ .chips .sep { width:1px; height:18px; background:var(--line); margin:0 4px; }
10055
+ .chip { padding:3px 9px; font-size:12px; border:1px solid var(--line); color:var(--dim);
10056
+ display:inline-flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; }
10057
+ .chip.on { color:var(--text); border-color:var(--line-strong); background:var(--panel-deep); }
10058
+ .chip .n { color:var(--dim); }
10059
+ .dot { width:7px; height:7px; display:inline-block; background:var(--dim); flex:none; }
10060
+ .dot.ok { background:var(--ok); } .dot.warn { background:var(--warn); }
10061
+ .dot.bad { background:var(--bad); } .dot.cool { background:var(--cool); }
10062
+ .state-ok { color:var(--ok); } .state-warn { color:var(--warn); }
10063
+ .state-bad { color:var(--bad); } .state-cool { color:var(--cool); } .state-dim { color:var(--dim); }
10064
+
10065
+ main { width:min(100%,var(--content)); margin:0 auto; border-inline:1px solid var(--line);
10066
+ min-height:60vh; }
10067
+ .style { border-bottom:1px solid var(--line); padding:20px 22px 24px; }
10068
+ .project { border-bottom:1px solid var(--line-strong); background:var(--panel); padding:14px 22px; }
10069
+ .phead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; }
10070
+ .phead h2 { margin:0; font:700 15px/1.2 var(--mono); }
10071
+ .phead h2::before { content:'\u25C6'; color:var(--accent); margin-right:8px; font-size:10px; }
10072
+ .phead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap; }
10073
+ .sid.project { color:var(--dim); border-color:var(--line-strong); }
10074
+ .shead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; margin-bottom:14px; }
10075
+ .shead h2 { margin:0; font:650 15px/1.2 var(--mono); }
10076
+ .shead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap;
10077
+ align-items:center; }
10078
+ .swatches { display:inline-flex; gap:2px; vertical-align:middle; }
10079
+ .swatches i { width:12px; height:12px; display:block; border:1px solid var(--line); }
10080
+ .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(176px, 1fr)); gap:10px; }
10081
+ .card { border:1px solid var(--line); background:var(--panel); text-align:left; padding:0;
10082
+ display:flex; flex-direction:column; cursor:pointer; min-width:0;
10083
+ content-visibility:auto; contain-intrinsic-size:auto 176px auto 196px; }
10084
+ .card:hover { border-color:var(--line-strong); }
10085
+ .card.active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); }
10086
+ .card.ghost { border-style:dashed; background:transparent; }
10087
+ .cell { height:132px; display:grid; place-items:center; background:var(--panel-deep);
10088
+ background-image:var(--checker); background-size:12px 12px; background-position:0 0,6px 6px;
10089
+ overflow:hidden; position:relative; padding:8px; }
10090
+ .card.ghost .cell { background-image:none; color:var(--dim); font-size:12px; }
10091
+ .cell img { image-rendering:pixelated; display:block; max-width:100%; max-height:100%; }
10092
+ .cell img.fit { width:auto; height:auto; object-fit:contain; }
10093
+ .cell .multi { display:grid; grid-template-columns:1fr 1fr; gap:4px; width:100%; height:100%; }
10094
+ .cell .multi div { display:grid; place-items:center; overflow:hidden; }
10095
+ .badge { position:absolute; right:6px; bottom:6px; background:var(--panel); color:var(--dim);
10096
+ border:1px solid var(--line); font:11px/1 var(--mono); padding:3px 6px; }
10097
+ .cbody { padding:8px 10px 10px; display:flex; flex-direction:column; gap:3px; min-width:0; }
10098
+ .aid { font:600 13px/1.3 var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
10099
+ .cmeta { display:flex; gap:8px; align-items:center; color:var(--dim); font-size:11.5px;
10100
+ font-variant-numeric:tabular-nums; white-space:nowrap; overflow:hidden; }
10101
+ .cmeta .st { display:inline-flex; align-items:center; gap:5px; }
10102
+ .empty { padding:64px 22px; color:var(--dim); text-align:center; }
10103
+ .empty p { margin:0 0 12px; }
10104
+ footer { width:min(100%,var(--content)); margin:0 auto; padding:22px 22px 60px; color:var(--dim);
10105
+ font-size:12.5px; }
10106
+ kbd { border:1px solid var(--line); border-bottom-width:2px; padding:1px 5px; font-size:11px;
10107
+ font-family:var(--mono); }
10108
+
10109
+ .scrim { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:19; }
10110
+ .drawer { position:fixed; top:0; right:0; bottom:0; width:min(var(--drawer), 100vw); z-index:20;
10111
+ background:var(--panel); border-left:1px solid var(--line-strong); overflow-y:auto;
10112
+ display:flex; flex-direction:column; }
10113
+ .dhead { position:sticky; top:0; background:var(--panel); border-bottom:1px solid var(--line);
10114
+ padding:14px 18px; display:flex; align-items:center; gap:10px; z-index:1; }
10115
+ .dhead .title { flex:1; min-width:0; }
10116
+ .dhead .title .sid { color:var(--accent-soft); font:650 10.5px/1 var(--mono); border:1px solid var(--accent);
10117
+ padding:2px 6px; display:inline-block; margin-bottom:6px; }
10118
+ .dhead .title .aid { font-size:16px; white-space:normal; word-break:break-all; }
10119
+ .dhead button { padding:5px 9px; }
10120
+ .dbody { padding:0 18px 40px; }
10121
+ .preview { margin:16px 0 10px; background:var(--panel-deep); background-image:var(--checker);
10122
+ background-size:12px 12px; background-position:0 0,6px 6px; border:1px solid var(--line);
10123
+ min-height:200px; max-height:60vh; overflow:auto; display:grid; place-items:center; padding:12px; }
10124
+ .preview img { image-rendering:pixelated; display:block; }
10125
+ .preview .none { color:var(--dim); font-size:12.5px; text-align:center; padding:40px 12px; }
10126
+ .zoom { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size:12px; color:var(--dim); }
10127
+ .zoom button { padding:3px 8px; font-size:12px; }
10128
+ .zoom button.on { border-color:var(--accent); color:var(--text); }
10129
+ .zoom .grow { text-align:right; font-variant-numeric:tabular-nums; }
10130
+ .members { display:flex; gap:6px; overflow-x:auto; padding:10px 0 4px; }
10131
+ .members button { flex:none; padding:4px; display:flex; flex-direction:column; align-items:center; gap:3px;
10132
+ background:var(--panel-deep); background-image:var(--checker); background-size:8px 8px;
10133
+ background-position:0 0,4px 4px; }
10134
+ .members button.on { border-color:var(--accent); }
10135
+ .members img { image-rendering:pixelated; display:block; width:40px; height:40px; object-fit:contain; }
10136
+ .members span { font:10.5px/1 var(--mono); color:var(--dim); background:var(--panel); padding:2px 4px; }
10137
+ .status { margin:14px 0 0; padding:10px 12px; border:1px solid var(--line); display:flex; gap:10px;
10138
+ align-items:baseline; font-size:13px; }
10139
+ .status .st { font-weight:600; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
10140
+ .status .why { color:var(--dim); }
10141
+ section.meta { border-top:1px solid var(--line); padding:14px 0 6px; margin-top:14px; }
10142
+ section.meta h3 { margin:0 0 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
10143
+ dl { margin:0; display:grid; grid-template-columns:minmax(96px, 30%) 1fr; gap:6px 12px; font-size:12.5px; }
10144
+ dt { color:var(--dim); }
10145
+ dd { margin:0; min-width:0; word-break:break-word; }
10146
+ dd.mono { font-family:var(--mono); font-size:12px; }
10147
+ dd .copy { margin-left:6px; padding:0 5px; font:11px/1.4 var(--mono); color:var(--dim); border-color:transparent; }
10148
+ dd .copy:hover { border-color:var(--line); color:var(--text); }
10149
+ .prompt { white-space:pre-wrap; }
10150
+ .out { border:1px solid var(--line); padding:8px 10px; margin-bottom:8px; }
10151
+ .out dl { grid-template-columns:minmax(72px, 24%) 1fr; }
10152
+ .out .role { font:600 12px/1 var(--mono); margin-bottom:6px; display:flex; gap:8px; align-items:center; }
10153
+ .miss { color:var(--bad); }
10154
+ .pal { display:flex; flex-wrap:wrap; gap:3px; }
10155
+ .pal i { width:16px; height:16px; display:block; border:1px solid var(--line); }
10156
+ details { border:1px solid var(--line); padding:0; margin-top:8px; }
10157
+ details summary { cursor:pointer; padding:7px 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
10158
+ details pre { margin:0; padding:10px; border-top:1px solid var(--line); background:var(--panel-deep);
10159
+ font:11.5px/1.45 var(--mono); overflow:auto; max-height:320px; }
10160
+ .linkish { background:none; border:0; padding:0; color:var(--accent-soft); cursor:pointer;
10161
+ font-family:var(--mono); font-size:12px; text-decoration:underline; }
10162
+ .nav { display:flex; gap:6px; }
10163
+ #note { color:var(--warn); font-size:12.5px; }
10164
+ .editing { color:var(--accent-soft); border:1px solid var(--accent); padding:2px 7px; font:650 10.5px/1.4 var(--mono); }
10165
+ form.edit { border:1px solid var(--accent); padding:12px 14px 14px; margin-top:14px; display:grid; gap:10px; }
10166
+ form.edit h3 { margin:0; font:650 12.5px/1 var(--mono); color:var(--accent-soft); }
10167
+ form.edit .row { display:grid; grid-template-columns:repeat(auto-fit, minmax(110px, 1fr)); gap:10px; }
10168
+ .field { display:grid; gap:4px; font-size:12px; color:var(--dim); min-width:0; }
10169
+ .field span { font-family:var(--mono); font-size:11px; }
10170
+ .field small { font-size:11px; color:var(--dim); }
10171
+ .field input, .field textarea, .field select { background:var(--panel-deep); border:1px solid var(--line);
10172
+ color:var(--text); padding:6px 8px; border-radius:0; font:13px/1.45 inherit; width:100%; min-width:0; }
10173
+ .field textarea { resize:vertical; min-height:64px; font-family:inherit; }
10174
+ .field input:focus, .field textarea:focus, .field select:focus { outline:none; border-color:var(--accent); }
10175
+ .field.check { display:flex; align-items:center; gap:8px; }
10176
+ .field.check input { width:auto; }
10177
+ form.edit .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
10178
+ form.edit .msg { font-size:12.5px; }
10179
+ form.edit .msg.bad { color:var(--bad); }
10180
+ .notice { margin:14px 0 0; padding:10px 12px; border:1px solid var(--ok); color:var(--ok); font-size:13px; }
10181
+ .shead .tools { margin-left:auto; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
10182
+ .shead .tools button { padding:3px 9px; font-size:12px; }
10183
+ .shead .cand { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--dim); }
10184
+ .shead .cand input { width:56px; background:var(--panel-deep); border:1px solid var(--line); color:var(--text);
10185
+ padding:2px 6px; font:12px/1.4 inherit; border-radius:0; }
10186
+ form.edit.style-form { margin:0 0 14px; }
10187
+ form.edit .blast { padding:8px 10px; border:1px solid var(--line); font-size:12.5px; color:var(--dim); }
10188
+ form.edit .blast.hot { border-color:var(--warn); color:var(--text); }
10189
+ form.edit .blast b { color:var(--warn); font-weight:600; }
10190
+ form.edit .pal { margin-top:4px; }
10191
+ .style .notice { margin:0 0 14px; }
10192
+ #jobs { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; display:grid; gap:6px; }
10193
+ #jobs:empty { display:none; }
10194
+ .job { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
10195
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
10196
+ .job .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
10197
+ .job .last { color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
10198
+ .job .acts { display:flex; gap:6px; }
10199
+ .job .acts button { padding:3px 9px; font-size:12px; }
10200
+ .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
10201
+ font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
10202
+ .budget { color:var(--dim); }
10203
+ .budget b { color:var(--text); }
10204
+ .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
10205
+ .dialog form { background:var(--panel); border:1px solid var(--accent); width:min(560px, calc(100vw - 32px));
10206
+ max-height:calc(100vh - 32px); overflow:auto; padding:16px 18px 18px; display:grid; gap:12px; }
10207
+ .dialog h3 { margin:0; font:650 14px/1.2 var(--mono); }
10208
+ .dialog table { width:100%; border-collapse:collapse; font-size:12.5px; }
10209
+ .dialog td, .dialog th { text-align:left; padding:4px 6px; border-bottom:1px solid var(--line); }
10210
+ .dialog th { color:var(--dim); font-weight:500; }
10211
+ .dialog td.n, .dialog th.n { text-align:right; font-variant-numeric:tabular-nums; }
10212
+ .dialog .sum { display:grid; gap:4px; font-size:13px; }
10213
+ .dialog .sum b { font-weight:600; }
10214
+ .dialog .warn { color:var(--warn); font-size:12.5px; }
10215
+ .dialog .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
10216
+ .dialog .msg { font-size:12.5px; color:var(--bad); }
10217
+ .sheet-scrim { position:fixed; inset:0; z-index:24; background:rgba(0,0,0,.5); }
10218
+ .sheet { position:fixed; top:0; right:0; bottom:0; z-index:25; background:var(--bg);
10219
+ border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
10220
+ .sheet.review-host { width:min(1180px, 94vw); }
10221
+ .sheet.compare-host { width:min(1500px, 96vw); }
10222
+ .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
10223
+ background:var(--panel); font-size:13px; flex-wrap:wrap; }
10224
+ .sheet .rbar span { color:var(--dim); }
10225
+ .sheet .rbar .zoom { margin-left:auto; }
10226
+ .sheet iframe { border:0; width:100%; height:100%; background:var(--bg); }
10227
+ @media (prefers-reduced-motion: no-preference) { .sheet { animation: slide .18s ease-out; } }
10228
+ .compare-body { overflow:auto; padding:16px; }
10229
+ .compare-body table { border-collapse:separate; border-spacing:0; min-width:100%; font-size:12.5px; }
10230
+ .compare-body th, .compare-body td { text-align:left; vertical-align:top; padding:6px 10px; border-bottom:1px solid var(--line);
10231
+ min-width:180px; max-width:420px; }
10232
+ .compare-body th:first-child, .compare-body td:first-child { min-width:96px; max-width:120px; color:var(--dim);
10233
+ position:sticky; left:0; background:var(--bg); z-index:1; }
10234
+ .compare-body thead th { position:sticky; top:0; background:var(--bg); z-index:2; border-bottom:1px solid var(--line-strong); }
10235
+ .compare-body .chead { display:grid; gap:4px; }
10236
+ .compare-body .chead .aid { font-size:14px; }
10237
+ .compare-body .chead .badges { display:flex; gap:6px; }
10238
+ .compare-body .chead .badges .sid { display:inline-block; }
10239
+ .compare-body .cimg { background:var(--panel-deep); background-image:var(--checker); background-size:12px 12px;
10240
+ background-position:0 0,6px 6px; border:1px solid var(--line); display:grid; place-items:center; padding:10px;
10241
+ min-height:120px; max-height:56vh; overflow:auto; }
10242
+ .compare-body .cimg img { image-rendering:pixelated; display:block; }
10243
+ .compare-body .cimg .none { color:var(--dim); font-size:12px; }
10244
+ .compare-body td.diff { background:color-mix(in srgb, var(--warn) 9%, transparent); }
10245
+ .compare-body td.mono { font-family:var(--mono); font-size:12px; word-break:break-all; }
10246
+ .compare-body td .prompt { white-space:pre-wrap; }
10247
+ .compare-body td .rm { float:right; padding:1px 6px; font-size:11px; color:var(--dim); border-color:transparent; }
10248
+ .card.compared { outline:2px dashed var(--accent-soft); outline-offset:-2px; }
10249
+ .card .slot { position:absolute; left:6px; top:6px; background:var(--accent); color:var(--bg); font:700 11px/1 var(--mono);
10250
+ padding:3px 6px; }
10251
+ .tray { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:18; background:var(--panel);
10252
+ border:1px solid var(--accent); padding:8px 10px; display:flex; gap:8px; align-items:center; max-width:calc(100vw - 32px);
10253
+ box-shadow:0 10px 30px rgba(0,0,0,.4); }
10254
+ .tray .chipset { display:flex; gap:6px; overflow-x:auto; }
10255
+ .tray .pick { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); padding:3px 4px 3px 6px;
10256
+ font:12px/1.2 var(--mono); white-space:nowrap; }
10257
+ .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
10258
+ .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
10259
+ .tray > button { padding:5px 11px; font-size:12.5px; }
10260
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10261
+ .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10262
+ .drawer .gen button.primary { padding:7px 14px; }
10263
+ @media (max-width: 720px) {
10264
+ .bar, .totals, .chips, .style, footer { padding-inline:14px; }
10265
+ .grid { grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); }
10266
+ .cell { height:112px; }
10267
+ .drawer { width:100vw; }
10268
+ }
10269
+ @media (prefers-reduced-motion: no-preference) {
10270
+ .drawer { animation: slide .16s ease-out; }
10271
+ @keyframes slide { from { transform: translateX(16px); opacity:.6; } to { transform:none; opacity:1; } }
10272
+ }
10273
+ </style>
10274
+ </head>
10275
+ <body>
10276
+ <header>
10277
+ <div class="bar">
10278
+ <h1>pixelkiln</h1>
10279
+ <span class="project" id="project"></span>
10280
+ <span class="grow"></span>
10281
+ <label class="search"><input id="q" type="search" placeholder="Search assets, prompts, ids, hashes" autocomplete="off"><kbd>/</kbd></label>
10282
+ <select id="sort" aria-label="Sort">
10283
+ <option value="key">Sort: style / asset</option>
10284
+ <option value="newest">Sort: newest first</option>
10285
+ <option value="oldest">Sort: oldest first</option>
10286
+ <option value="cost">Sort: cost</option>
10287
+ <option value="size">Sort: size</option>
10288
+ </select>
10289
+ <select id="group" aria-label="Group">
10290
+ <option value="style">Group: by style</option>
10291
+ <option value="none">Group: none</option>
10292
+ </select>
10293
+ <span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
10294
+ <button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
10295
+ <label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
10296
+ </div>
10297
+ <div class="totals" id="totals"></div>
10298
+ <div class="chips" id="chips"></div>
10299
+ <div id="jobs"></div>
10300
+ </header>
10301
+ <main id="root"></main>
10302
+ <footer>
10303
+ Click a sprite for its full record; <kbd>shift</kbd>-click adds it to a side-by-side comparison. <kbd>\u2190</kbd>/<kbd>\u2192</kbd> step through the visible set while a record
10304
+ is open, <kbd>Esc</kbd> closes it, and <kbd>/</kbd> jumps to search. This page reads the manifest, lockfile,
10305
+ and disk only \u2014 it never contacts a provider<span id="foot-edit"> and never writes anything</span><span id="foot-editing" hidden>.
10306
+ Editing is on: saving rewrites the manifest and nothing else</span><span id="foot-gen" hidden>.
10307
+ Generation is on under the session budget shown above; every run is the same submit, poll, and fetch as <code>pixelkiln gen</code></span>.
10308
+ <span id="note"></span>
10309
+ </footer>
10310
+ <div id="drawer-host"></div>
10311
+ <div id="dialog-host"></div>
10312
+ <script>
10313
+ const INITIAL = ${data};
10314
+ const SESSION = ${session};
10315
+ const EDITABLE = ${editable};
10316
+ const GENERATION = ${generation};
10317
+ let snap = INITIAL;
10318
+ const STATE_TONE = {
10319
+ ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
10320
+ failed: 'bad', 'in-flight': 'cool', recoverable: 'cool', missing: 'dim', undeclared: 'dim',
10321
+ };
10322
+ const STATE_ORDER = ['ok','stale','orphaned','untracked','in-flight','recoverable','blocked','failed','missing','undeclared'];
10323
+ const ui = {
10324
+ q: '', states: new Set(), providers: new Set(), generators: new Set(), projects: new Set(),
10325
+ sort: 'key', group: 'style', open: null, member: 0, zoom: 'auto', playing: null,
10326
+ /** Cards rendered per pass; a project with thousands of assets opts into the rest. */
10327
+ limit: 600,
10328
+ /** Item id whose edit form is open, or 'new:<project>:<style>' for an add form. */
10329
+ editing: null,
10330
+ /** One-shot confirmation shown in the drawer after a save. */
10331
+ notice: null,
10332
+ /** Record ids picked for side-by-side comparison, in pick order (max 4). */
10333
+ compare: [],
10334
+ /** Zoom for the comparison panel. */
10335
+ compareZoom: 'auto',
10336
+ /** Jobs expanded to show their log. */
10337
+ logs: new Set(),
10338
+ /** Job ids already seen finished, so a completion refreshes exactly once. */
10339
+ settled: new Set(),
10340
+ };
10341
+ /** Generation status from /api/jobs: jobs, session budget, spend so far. */
10342
+ let GEN = { jobs: [], budget: { byProvider: {} }, spent: {}, units: {} };
10343
+ const $ = (id) => document.getElementById(id);
10344
+ const el = (tag, cls, text) => {
10345
+ const n = document.createElement(tag);
10346
+ if (cls) n.className = cls;
10347
+ if (text !== undefined && text !== null) n.textContent = String(text);
10348
+ return n;
10349
+ };
10350
+ const fmtCost = (unit, amount) => {
10351
+ if (unit === 'free') return 'free';
10352
+ if (unit === 'usd') return '$' + Number(amount).toFixed(2);
10353
+ if (unit === 'generations') return amount + ' generation' + (amount === 1 ? '' : 's');
10354
+ return amount + ' ' + unit;
10355
+ };
10356
+ const fmtSpend = (spend) => Object.entries(spend).filter(([, n]) => n).sort()
10357
+ .map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + ') || 'no spend recorded';
10358
+ const fmtBytes = (n) => n == null ? '\u2014' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB';
10359
+ const fmtWhen = (iso) => {
10360
+ if (!iso) return null;
10361
+ const d = new Date(iso);
10362
+ return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
10363
+ };
10364
+ const isFrameSet = (item) => item.generator === 'frames' ||
10365
+ (item.outputs.length > 1 && item.outputs.every((o) => o.role && /^frame/i.test(o.role)));
10366
+ const shortPath = (p) => {
10367
+ if (p.length <= 64) return p;
10368
+ const parts = p.split('/');
10369
+ return parts.length > 4 ? '\u2026/' + parts.slice(-3).join('/') : p;
10370
+ };
10371
+ const displayScale = (w, h, boxW, boxH) => {
10372
+ const s = Math.min(boxW / w, boxH / h);
10373
+ return s >= 1 ? Math.floor(s) : s;
10374
+ };
10375
+
10376
+ const projectOf = (item) => snap.workspace
10377
+ ? snap.workspace.projects.find((pr) => pr.id === item.project)
10378
+ : snap.project;
10379
+
10380
+ // The only write this page ever makes: one manifest edit, quoting the
10381
+ // manifest hash it was rendered from so a concurrent hand edit is refused
10382
+ // rather than overwritten. The server answers with the rebuilt gallery.
10383
+ async function postEdit(body) {
10384
+ const res = await fetch('/api/edit', {
10385
+ method: 'POST',
10386
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10387
+ body: JSON.stringify(body),
10388
+ });
10389
+ if (!res.ok) {
10390
+ const err = new Error(await res.text());
10391
+ err.status = res.status;
10392
+ throw err;
10393
+ }
10394
+ return res.json();
10395
+ }
10396
+ const field = (label, input, hint) => {
10397
+ const w = el('label', 'field');
10398
+ w.append(el('span', null, label), input);
10399
+ if (hint) w.append(el('small', null, hint));
10400
+ return w;
10401
+ };
10402
+ const numberInput = (value, placeholder) => {
10403
+ const i = el('input'); i.type = 'number'; i.min = '16'; i.step = '1';
10404
+ i.value = value == null ? '' : String(value); i.placeholder = placeholder || '';
10405
+ return i;
10406
+ };
10407
+ const numberOrNull = (input) => input.value.trim() === '' ? null : Number(input.value);
10408
+ const splitTags = (value) => value.split(',').map((t) => t.trim()).filter(Boolean);
10409
+
10410
+ // ---- generation (only when the server holds a session budget) -------------
10411
+
10412
+ const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'review']);
10413
+ const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
10414
+ const remainingBudget = (provider) => {
10415
+ const keyed = GEN.budget.byProvider[provider];
10416
+ const ceiling = keyed !== undefined ? keyed : GEN.budget.amount;
10417
+ return ceiling === undefined ? null : Math.max(0, ceiling - (GEN.spent[provider] || 0));
10418
+ };
10419
+ async function postGenerate(body) {
10420
+ const res = await fetch('/api/generate', {
10421
+ method: 'POST',
10422
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10423
+ body: JSON.stringify(body),
10424
+ });
10425
+ if (!res.ok) { const err = new Error(await res.text()); err.status = res.status; throw err; }
10426
+ return res.json();
10427
+ }
10428
+ let jobsTimer = null;
10429
+ async function pollJobs() {
10430
+ if (!GENERATION) return;
10431
+ try {
10432
+ const res = await fetch('/api/jobs', { cache: 'no-store' });
10433
+ if (!res.ok) throw new Error(await res.text());
10434
+ GEN = await res.json();
10435
+ } catch (err) {
10436
+ $('note').textContent = ' Job status unavailable: ' + err.message;
10437
+ return;
10438
+ }
10439
+ renderJobs();
10440
+ renderHeader();
10441
+ let changed = false;
10442
+ for (const job of GEN.jobs) {
10443
+ if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) { ui.settled.add(job.id); changed = true; }
10444
+ if (job.phase === 'review' && !ui.settled.has(job.id + ':review')) { ui.settled.add(job.id + ':review'); changed = true; }
10445
+ }
10446
+ if (changed) refresh();
10447
+ const active = GEN.jobs.some((job) => ACTIVE_PHASES.has(job.phase) && job.phase !== 'review');
10448
+ clearTimeout(jobsTimer);
10449
+ if (active) jobsTimer = setTimeout(pollJobs, 2000);
10450
+ }
10451
+ function renderJobs() {
10452
+ const host = $('jobs');
10453
+ host.textContent = '';
10454
+ if (!GENERATION) return;
10455
+ for (const job of GEN.jobs) {
10456
+ if (ui.settled.has(job.id + ':dismissed')) continue;
10457
+ const row = el('div', 'job');
10458
+ const ph = el('span', 'ph');
10459
+ ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
10460
+ const what = (job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10461
+ (job.project ? ' in ' + job.project : '');
10462
+ const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
10463
+ last.title = job.keys.join('\\n');
10464
+ const acts = el('div', 'acts');
10465
+ if (job.phase === 'review' && job.review.length) {
10466
+ const b = el('button', 'primary', 'Review ' + job.review.length);
10467
+ b.type = 'button'; b.onclick = () => openReview(job.id);
10468
+ acts.append(b);
10469
+ }
10470
+ const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log');
10471
+ logBtn.type = 'button';
10472
+ logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderJobs(); };
10473
+ acts.append(logBtn);
10474
+ if (!ACTIVE_PHASES.has(job.phase)) {
10475
+ const d = el('button', null, 'Dismiss');
10476
+ d.type = 'button'; d.onclick = () => { ui.settled.add(job.id + ':dismissed'); renderJobs(); };
10477
+ acts.append(d);
10478
+ }
10479
+ row.append(ph, last, acts);
10480
+ if (ui.logs.has(job.id)) row.append(el('pre', null, job.messages.join('\\n')));
10481
+ host.append(row);
10482
+ }
10483
+ }
10484
+ function budgetLine() {
10485
+ const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
10486
+ const parts = [];
10487
+ for (const provider of [...providers].sort()) {
10488
+ const left = remainingBudget(provider);
10489
+ const unit = GEN.units[provider] || 'generations';
10490
+ if (left !== null) parts.push(provider + ': ' + fmtCost(unit, Math.round(left * 100) / 100) + ' left');
10491
+ }
10492
+ // An unkeyed ceiling belongs to whichever single provider first spends it.
10493
+ if (GEN.budget.amount !== undefined && !Object.keys(GEN.spent).length) parts.push(GEN.budget.amount + ' available');
10494
+ return parts.length ? 'session budget \u2014 ' + parts.join(' \xB7 ') : '';
10495
+ }
10496
+
10497
+ // The confirm step: what will be sent, what it is estimated to cost, and what
10498
+ // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
10499
+ function generateDialog(items, { project = null, force = false, resume = false } = {}) {
10500
+ const host = $('dialog-host');
10501
+ host.textContent = '';
10502
+ const wrap = el('div', 'dialog');
10503
+ const form = el('form');
10504
+ form.append(el('h3', null, resume ? 'Resume ' + items.length + (items.length === 1 ? ' asset' : ' assets')
10505
+ : (force ? 'Regenerate ' : 'Generate ') + items.length + (items.length === 1 ? ' asset' : ' assets')));
10506
+ const table = el('table');
10507
+ const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
10508
+ table.append(thead);
10509
+ const byProvider = new Map();
10510
+ for (const item of items) {
10511
+ const tr = el('tr');
10512
+ tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
10513
+ el('td', 'n', resume ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10514
+ table.append(tr);
10515
+ if (!resume) {
10516
+ const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
10517
+ g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
10518
+ }
10519
+ }
10520
+ form.append(table);
10521
+ const sum = el('div', 'sum');
10522
+ if (resume) {
10523
+ sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
10524
+ } else {
10525
+ for (const [provider, g] of byProvider) {
10526
+ const left = remainingBudget(provider);
10527
+ const line = el('div');
10528
+ line.append(el('b', null, provider + ': ' + fmtCost(g.unit, Math.round(g.cost * 100) / 100)));
10529
+ line.append(document.createTextNode(left === null ? ' \u2014 no session budget for this provider'
10530
+ : ' \xB7 ' + fmtCost(g.unit, Math.round(left * 100) / 100) + ' of the session budget left'));
10531
+ if (left !== null && g.cost > left) line.className = 'warn';
10532
+ sum.append(line);
10533
+ }
10534
+ if (force) sum.append(el('div', 'warn', 'Regenerating replaces the current art. The previous file is replaced when the new result is fetched; the provider objects it came from are not deleted.'));
10535
+ sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
10536
+ }
10537
+ form.append(sum);
10538
+ const actions = el('div', 'actions');
10539
+ const go = el('button', 'primary', resume ? 'Resume' : 'Generate');
10540
+ go.type = 'submit';
10541
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
10542
+ const msg = el('span', 'msg');
10543
+ actions.append(go, cancel, msg);
10544
+ form.append(actions);
10545
+ form.onsubmit = async (e) => {
10546
+ e.preventDefault();
10547
+ go.disabled = true; msg.textContent = '';
10548
+ try {
10549
+ const body = { keys: items.map((i) => i.key) };
10550
+ if (project) body.project = project;
10551
+ if (force) body.force = true;
10552
+ if (resume) body.resume = true;
10553
+ const job = await postGenerate(body);
10554
+ host.textContent = '';
10555
+ GEN.jobs.unshift(job);
10556
+ renderJobs();
10557
+ pollJobs();
10558
+ } catch (err) {
10559
+ go.disabled = false;
10560
+ msg.textContent = err.message;
10561
+ }
10562
+ };
10563
+ wrap.append(form);
10564
+ host.append(wrap);
10565
+ setTimeout(() => go.focus(), 0);
10566
+ }
10567
+
10568
+ function openReview(jobId) {
10569
+ const host = $('dialog-host');
10570
+ host.textContent = '';
10571
+ const scrim = el('div', 'sheet-scrim');
10572
+ scrim.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
10573
+ const panel = el('div', 'sheet review-host');
10574
+ const bar = el('div', 'rbar');
10575
+ const close = el('button', null, 'Back to gallery'); close.type = 'button';
10576
+ close.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
10577
+ bar.append(close, el('span', null, 'Choose from the candidates below. Apply selections writes the lockfile and downloads what you chose; unchosen rows stay in review.'));
10578
+ const frame = el('iframe');
10579
+ frame.src = '/review/' + encodeURIComponent(jobId);
10580
+ frame.title = 'Candidate review';
10581
+ panel.append(bar, frame);
10582
+ host.append(scrim, panel);
10583
+ }
10584
+ window.addEventListener('message', (e) => {
10585
+ if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
10586
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
10587
+ });
10588
+
10589
+ function visibleItems() {
10590
+ const q = ui.q.trim().toLowerCase();
10591
+ let items = snap.items.filter((item) => {
10592
+ if (ui.states.size && !ui.states.has(item.state)) return false;
10593
+ if (ui.providers.size && !ui.providers.has(item.provider)) return false;
10594
+ if (ui.generators.size && !ui.generators.has(item.generator)) return false;
10595
+ if (ui.projects.size && !ui.projects.has(item.project)) return false;
10596
+ if (!q) return true;
10597
+ const hay = [item.id, item.prompt, item.currentPrompt, item.jobId, item.objectId,
10598
+ item.recordedSpecHash, item.currentSpecHash, item.category, ...(item.tags || []),
10599
+ ...item.outputs.flatMap((o) => [o.path, o.sha256])].filter(Boolean).join('\\n').toLowerCase();
10600
+ return hay.includes(q);
10601
+ });
10602
+ const when = (item) => item.downloadedAt || item.submittedAt || '';
10603
+ const sorters = {
10604
+ key: (a, b) => a.id.localeCompare(b.id),
10605
+ newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
10606
+ oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
10607
+ cost: (a, b) => (b.cost - a.cost) || a.id.localeCompare(b.id),
10608
+ size: (a, b) => (b.width * b.height - a.width * a.height) || a.id.localeCompare(b.id),
10609
+ };
10610
+ items.sort(sorters[ui.sort] || sorters.key);
10611
+ return items;
10612
+ }
10613
+
10614
+ function renderHeader() {
10615
+ $('editing').hidden = !EDITABLE;
10616
+ $('foot-edit').hidden = EDITABLE;
10617
+ $('foot-editing').hidden = !EDITABLE;
10618
+ $('foot-gen').hidden = !GENERATION;
10619
+ const p = $('project');
10620
+ p.textContent = '';
10621
+ if (snap.workspace) {
10622
+ p.append(el('b', null, 'workspace'), ' ', document.createTextNode(shortPath(snap.workspace.path)));
10623
+ p.title = snap.workspace.path;
10624
+ } else {
10625
+ p.append(el('b', null, snap.project.name), ' ', document.createTextNode(shortPath(snap.project.manifest)));
10626
+ p.title = snap.project.manifest + '\\nlockfile: ' + snap.project.lock;
10627
+ }
10628
+
10629
+ const t = $('totals');
10630
+ t.textContent = '';
10631
+ if (snap.workspace) {
10632
+ const broken = snap.workspace.projects.filter((pr) => pr.error).length;
10633
+ const w = el('span', null, '');
10634
+ w.append(el('b', null, snap.workspace.projects.length), document.createTextNode(' project' +
10635
+ (snap.workspace.projects.length === 1 ? '' : 's') + (broken ? ' (' + broken + ' unreadable)' : '')));
10636
+ t.append(w);
10637
+ }
10638
+ const n = snap.totals.entries;
10639
+ t.append(el('span', null, ''));
10640
+ t.lastChild.append(el('b', null, n), document.createTextNode(' lock ' + (n === 1 ? 'entry' : 'entries')));
10641
+ const declared = snap.items.filter((i) => i.declared).length;
10642
+ t.append(el('span', null, declared + ' declared by ' + (snap.workspace ? 'a manifest' : 'the manifest')));
10643
+ const recorded = fmtSpend(snap.totals.spendByUnit);
10644
+ t.append(el('span', null, recorded === 'no spend recorded' ? recorded : recorded + ' recorded'));
10645
+ if (snap.filter.styles.length || snap.filter.assets.length) {
10646
+ t.append(el('span', 'state-warn', 'filtered: ' +
10647
+ [snap.filter.styles.length ? '--style ' + snap.filter.styles.join(',') : '',
10648
+ snap.filter.assets.length ? '--only ' + snap.filter.assets.join(',') : ''].filter(Boolean).join(' ')));
10649
+ }
10650
+ t.append(el('span', null, 'snapshot ' + fmtWhen(snap.generatedAt)));
10651
+ if (GENERATION) {
10652
+ const line = budgetLine();
10653
+ if (line) t.append(el('span', 'budget', line));
10654
+ }
10655
+
10656
+ const chips = $('chips');
10657
+ chips.textContent = '';
10658
+ const counts = (pick) => {
10659
+ const m = new Map();
10660
+ for (const item of snap.items) { const k = pick(item); m.set(k, (m.get(k) || 0) + 1); }
10661
+ return m;
10662
+ };
10663
+ const chip = (label, count, set, key, tone) => {
10664
+ const b = el('button', 'chip' + (set.has(key) ? ' on' : ''));
10665
+ b.type = 'button';
10666
+ b.setAttribute('aria-pressed', String(set.has(key)));
10667
+ if (tone) b.append(el('i', 'dot ' + tone));
10668
+ b.append(document.createTextNode(label), el('span', 'n', count));
10669
+ b.onclick = () => { set.has(key) ? set.delete(key) : set.add(key); render(); };
10670
+ return b;
10671
+ };
10672
+ if (snap.workspace) {
10673
+ const byProject = counts((i) => i.project);
10674
+ for (const pr of snap.workspace.projects) {
10675
+ if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
10676
+ }
10677
+ chips.append(el('span', 'sep'));
10678
+ }
10679
+ const byState = counts((i) => i.state);
10680
+ for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
10681
+ const byProvider = counts((i) => i.provider);
10682
+ if (byProvider.size > 1 || ui.providers.size) {
10683
+ chips.append(el('span', 'sep'));
10684
+ for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
10685
+ }
10686
+ const byGen = counts((i) => i.generator);
10687
+ if (byGen.size > 1 || ui.generators.size) {
10688
+ chips.append(el('span', 'sep'));
10689
+ for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
10690
+ }
10691
+ if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
10692
+ const clear = el('button', 'chip', 'clear filters');
10693
+ clear.type = 'button';
10694
+ clear.onclick = () => clearFilters();
10695
+ chips.append(el('span', 'sep'), clear);
10696
+ }
10697
+ }
10698
+
10699
+ function clearFilters() {
10700
+ ui.states.clear(); ui.providers.clear(); ui.generators.clear(); ui.projects.clear();
10701
+ ui.q = ''; $('q').value = '';
10702
+ render();
10703
+ }
10704
+
10705
+ function thumb(item) {
10706
+ const cell = el('div', 'cell');
10707
+ const shown = item.outputs.filter((o) => o.url);
10708
+ if (!shown.length) {
10709
+ cell.classList.add('none');
10710
+ cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
10711
+ : item.state === 'in-flight' ? (item.status === 'review' ? 'awaiting review' : 'awaiting provider')
10712
+ : item.state === 'recoverable' ? 'ready to fetch'
10713
+ : item.state === 'failed' ? 'generation failed'
10714
+ : item.state === 'blocked' ? 'waiting on its parent'
10715
+ : item.outputs.length ? 'file missing on disk' : 'no output recorded'));
10716
+ return cell;
10717
+ }
10718
+ const w = item.width, h = item.height;
10719
+ if (shown.length > 1 && !isFrameSet(item)) {
10720
+ const multi = el('div', 'multi');
10721
+ for (const o of shown.slice(0, 4)) {
10722
+ const box = el('div');
10723
+ const img = el('img');
10724
+ img.src = o.url; img.alt = o.role || item.assetId; img.loading = 'lazy';
10725
+ const s = displayScale(w, h, 66, 52);
10726
+ if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
10727
+ box.append(img); multi.append(box);
10728
+ }
10729
+ cell.append(multi, el('span', 'badge', shown.length + ' outputs'));
10730
+ return cell;
10731
+ }
10732
+ const img = el('img');
10733
+ img.src = shown[0].url; img.alt = item.assetId; img.loading = 'lazy';
10734
+ const s = displayScale(w, h, 156, 116);
10735
+ if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
10736
+ cell.append(img);
10737
+ if (isFrameSet(item)) cell.append(el('span', 'badge', '\u25B8 ' + shown.length + ' frames'));
10738
+ else if (s >= 1 && s !== 1) cell.append(el('span', 'badge', s + '\xD7'));
10739
+ return cell;
10740
+ }
10741
+
10742
+ function card(item) {
10743
+ const slot = ui.compare.indexOf(item.id);
10744
+ const c = el('button', 'card' + (item.outputs.some((o) => o.url) ? '' : ' ghost') + (ui.open === item.id ? ' active' : '') + (slot >= 0 ? ' compared' : ''));
10745
+ c.type = 'button';
10746
+ c.dataset.key = item.id;
10747
+ c.setAttribute('aria-label', item.id + ', ' + item.state + (slot >= 0 ? ', in comparison' : ''));
10748
+ const body = el('div', 'cbody');
10749
+ body.append(el('div', 'aid', item.assetId));
10750
+ const meta = el('div', 'cmeta');
10751
+ const st = el('span', 'st');
10752
+ st.append(el('i', 'dot ' + STATE_TONE[item.state]), document.createTextNode(item.state));
10753
+ meta.append(st, el('span', null, item.width + '\xD7' + item.height));
10754
+ if (item.cost) meta.append(el('span', null, fmtCost(item.costUnit, item.cost)));
10755
+ body.append(meta);
10756
+ const cell = thumb(item);
10757
+ if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
10758
+ c.append(cell, body);
10759
+ c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
10760
+ return c;
10761
+ }
10762
+
10763
+ function renderMain(items) {
10764
+ const root = $('root');
10765
+ root.textContent = '';
10766
+ if (!snap.items.length) {
10767
+ const e = el('div', 'empty');
10768
+ e.append(el('p', null, 'Nothing has been generated for this project yet.'));
10769
+ e.append(el('p', null, 'pixelkiln plan shows what the manifest would make; pixelkiln gen makes it.'));
10770
+ root.append(e);
10771
+ return;
10772
+ }
10773
+ if (!items.length) {
10774
+ const e = el('div', 'empty');
10775
+ e.append(el('p', null, 'No generations match these filters.'));
10776
+ const b = el('button', null, 'Clear filters');
10777
+ b.type = 'button';
10778
+ b.onclick = clearFilters;
10779
+ e.append(b);
10780
+ root.append(e);
10781
+ return;
10782
+ }
10783
+ // Building every card for a very large project on each keystroke is the one
10784
+ // thing that makes this page feel slow, so a render is capped and the rest
10785
+ // is one click away. Filters usually narrow well below the cap anyway.
10786
+ const hidden = Math.max(0, items.length - ui.limit);
10787
+ const shown = hidden ? items.slice(0, ui.limit) : items;
10788
+ // A workspace always breaks on project: lock keys repeat across projects,
10789
+ // so a flat wall of "anvil" cards would be ambiguous. Style grouping stays
10790
+ // optional inside each project, exactly as it is for one project.
10791
+ const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
10792
+ const projects = snap.workspace
10793
+ ? snap.workspace.projects.filter((pr) => ui.projects.size
10794
+ ? ui.projects.has(pr.id)
10795
+ : unfiltered || shown.some((i) => i.project === pr.id))
10796
+ : [null];
10797
+ const sections = [];
10798
+ for (const pr of projects) {
10799
+ const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
10800
+ const styleSections = ui.group === 'style'
10801
+ ? snap.styles.filter((s) => !pr || s.project === pr.id)
10802
+ .map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length)
10803
+ : (inProject.length ? [{ style: null, items: inProject }] : []);
10804
+ if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
10805
+ sections.push(...styleSections);
10806
+ }
10807
+ for (const sec of sections) {
10808
+ if (sec.project) {
10809
+ root.append(projectHeader(sec.project, sec.count));
10810
+ continue;
10811
+ }
10812
+ const wrap = el('section', 'style');
10813
+ if (sec.style) {
10814
+ const s = sec.style;
10815
+ const head = el('div', 'shead');
10816
+ head.append(el('h2', null, s.id));
10817
+ const meta = el('div', 'meta');
10818
+ meta.append(el('span', null, s.provider), el('span', null, s.generator));
10819
+ if (s.outDir) meta.append(el('span', 'mono', s.outDir));
10820
+ if (s.palette.length) {
10821
+ const sw = el('span', 'swatches');
10822
+ for (const c of s.palette) { const i = el('i'); i.style.background = c; i.title = c; sw.append(i); }
10823
+ meta.append(sw);
10824
+ }
10825
+ if (s.quality) meta.append(el('span', null, 'quality profile'));
10826
+ meta.append(el('span', null, sec.items.length + ' of ' + s.items + (s.items === 1 ? ' asset' : ' assets')));
10827
+ if (Object.keys(s.spendByUnit).length) meta.append(el('span', null, fmtSpend(s.spendByUnit)));
10828
+ head.append(meta);
10829
+ const addKey = 'new:' + (s.project || '') + ':' + s.id;
10830
+ const tools = el('div', 'tools');
10831
+ if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
10832
+ if (GENERATION && s.actionable.keys.length) {
10833
+ const g = el('button', 'primary', 'Generate ' + s.actionable.keys.length + ' \xB7 ' + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
10834
+ g.type = 'button';
10835
+ g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
10836
+ tools.append(g);
10837
+ }
10838
+ const styleKey = 'style:' + (s.project || '') + ':' + s.id;
10839
+ if (EDITABLE && s.outDir) {
10840
+ const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
10841
+ es.type = 'button';
10842
+ es.onclick = () => { ui.editing = ui.editing === styleKey ? null : styleKey; ui.notice = null; render(); };
10843
+ tools.append(es);
10844
+ const add = el('button', 'add', ui.editing === addKey ? 'Cancel' : '+ Add asset');
10845
+ add.type = 'button';
10846
+ add.onclick = () => { ui.editing = ui.editing === addKey ? null : addKey; render(); };
10847
+ tools.append(add);
10848
+ }
10849
+ if (tools.childNodes.length) head.append(tools);
10850
+ wrap.append(head);
10851
+ if (ui.notice && ui.notice.id === styleKey) wrap.append(el('div', 'notice', ui.notice.text));
10852
+ if (ui.editing === styleKey) wrap.append(styleForm(s));
10853
+ if (ui.editing === addKey) wrap.append(addAssetForm(s));
10854
+ }
10855
+ const grid = el('div', 'grid');
10856
+ for (const item of sec.items) grid.append(card(item));
10857
+ wrap.append(grid);
10858
+ root.append(wrap);
10859
+ }
10860
+ if (hidden) {
10861
+ const more = el('div', 'empty');
10862
+ more.append(el('p', null, hidden + ' more ' + (hidden === 1 ? 'generation is' : 'generations are') + ' not shown.'));
10863
+ const b = el('button', null, 'Show all ' + items.length);
10864
+ b.type = 'button';
10865
+ b.onclick = () => { ui.limit = Infinity; render(); };
10866
+ more.append(b);
10867
+ root.append(more);
10868
+ }
10869
+ }
10870
+
10871
+ function projectHeader(pr, count) {
10872
+ const head = el('section', 'project');
10873
+ const line = el('div', 'phead');
10874
+ line.append(el('h2', null, pr.id));
10875
+ const meta = el('div', 'meta');
10876
+ if (pr.name !== pr.id) meta.append(el('span', null, pr.name));
10877
+ meta.append(el('span', 'mono', shortPath(pr.manifest)));
10878
+ if (pr.account) meta.append(el('span', null, pr.account));
10879
+ if (pr.error) {
10880
+ meta.append(el('span', 'state-bad', 'unreadable: ' + pr.error));
10881
+ } else {
10882
+ meta.append(el('span', null, count + ' of ' + pr.items + ' shown \xB7 ' + pr.entries + ' lock ' + (pr.entries === 1 ? 'entry' : 'entries')));
10883
+ if (Object.keys(pr.spendByUnit).length) meta.append(el('span', null, fmtSpend(pr.spendByUnit)));
10884
+ }
10885
+ line.append(meta);
10886
+ head.append(line);
10887
+ return head;
10888
+ }
10889
+
10890
+ // How many images one generation returns for this style. A provider option
10891
+ // where the adapter has one; a fact of the generator where it does not.
10892
+ function candidatesControl(style) {
10893
+ const wrap = el('span', 'cand');
10894
+ const editKey = 'cand:' + (style.project || '') + ':' + style.id;
10895
+ const declared = snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
10896
+ if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
10897
+ wrap.append(el('span', null, style.candidates + (style.candidates === 1 ? ' candidate' : ' candidates') + ' per generation'));
10898
+ if (EDITABLE && style.candidatesEditable) {
10899
+ const b = el('button', null, 'change'); b.type = 'button';
10900
+ b.onclick = () => { ui.editing = editKey; render(); };
10901
+ wrap.append(b);
10902
+ } else if (style.provider === 'pixellab') {
10903
+ wrap.title = 'PixelLab: map and pixflux return one image per generation; a 1dir style returns 4\u201364 for its size.';
10904
+ }
10905
+ return wrap;
10906
+ }
10907
+ const input = el('input'); input.type = 'number'; input.min = '1'; input.max = '64'; input.value = String(style.candidates);
10908
+ const save = el('button', 'primary', 'Save'); save.type = 'button';
10909
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
10910
+ const note = el('span', null, 'affects ' + declared + (declared === 1 ? ' asset' : ' assets') + ' \u2014 they become stale');
10911
+ save.onclick = async () => {
10912
+ save.disabled = true;
10913
+ try {
10914
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
10915
+ const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
10916
+ if (style.project) body.project = style.project;
10917
+ snap = await postEdit(body);
10918
+ ui.editing = null;
10919
+ render();
10920
+ } catch (err) {
10921
+ save.disabled = false;
10922
+ note.textContent = err.message;
10923
+ note.className = 'state-bad';
10924
+ }
10925
+ };
10926
+ wrap.append(input, save, cancel, note);
10927
+ setTimeout(() => input.focus(), 0);
10928
+ return wrap;
10929
+ }
10930
+
10931
+ function generateActions(item) {
10932
+ const row = el('div', 'gen');
10933
+ if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
10934
+ const cost = item.estimatedCost === null ? '' : ' \xB7 ' + fmtCost(item.costUnit, item.estimatedCost);
10935
+ const add = (label, cls, opts) => {
10936
+ const b = el('button', cls, label); b.type = 'button';
10937
+ b.onclick = () => generateDialog([item], { project: item.project, ...opts });
10938
+ row.append(b);
10939
+ };
10940
+ if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
10941
+ else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
10942
+ if (item.state === 'in-flight' || item.state === 'recoverable') {
10943
+ // A job already parked this asset in review: go straight to its sheet.
10944
+ const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
10945
+ if (holder) {
10946
+ const b = el('button', 'primary', 'Review candidates'); b.type = 'button';
10947
+ b.onclick = () => openReview(holder.id);
10948
+ row.append(b);
10949
+ } else {
10950
+ add(item.status === 'review' ? 'Resume review' : 'Resume \xB7 no cost', 'primary', { resume: true });
10951
+ }
10952
+ }
10953
+ return row;
10954
+ }
10955
+
10956
+ // ---- compare ---------------------------------------------------------------
10957
+
10958
+ function toggleCompare(id) {
10959
+ const at = ui.compare.indexOf(id);
10960
+ if (at >= 0) ui.compare.splice(at, 1);
10961
+ else if (ui.compare.length >= 4) { $('note').textContent = ' Compare holds four records; remove one first.'; return; }
10962
+ else ui.compare.push(id);
10963
+ $('note').textContent = '';
10964
+ render();
10965
+ }
10966
+ function renderTray() {
10967
+ let tray = $('tray');
10968
+ if (!ui.compare.length) { if (tray) tray.remove(); return; }
10969
+ if (!tray) { tray = el('div', 'tray'); tray.id = 'tray'; document.body.append(tray); }
10970
+ tray.textContent = '';
10971
+ const chips = el('div', 'chipset');
10972
+ for (const id of ui.compare) {
10973
+ const item = snap.items.find((i) => i.id === id);
10974
+ if (!item) continue;
10975
+ const pick = el('span', 'pick');
10976
+ const shown = item.outputs.find((o) => o.url);
10977
+ if (shown) { const t = el('img'); t.src = shown.url; t.alt = ''; pick.append(t); }
10978
+ pick.append(document.createTextNode((item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId));
10979
+ const x = el('button', null, '\xD7'); x.type = 'button'; x.title = 'Remove from comparison';
10980
+ x.onclick = () => toggleCompare(id);
10981
+ pick.append(x);
10982
+ chips.append(pick);
10983
+ }
10984
+ const go = el('button', 'primary', 'Compare ' + ui.compare.length); go.type = 'button';
10985
+ go.disabled = ui.compare.length < 2;
10986
+ go.onclick = () => openCompare();
10987
+ const clear = el('button', null, 'Clear'); clear.type = 'button';
10988
+ clear.onclick = () => { ui.compare = []; render(); };
10989
+ tray.append(chips, go, clear);
10990
+ }
10991
+ function openCompare() {
10992
+ const items = ui.compare.map((id) => snap.items.find((i) => i.id === id)).filter(Boolean);
10993
+ if (items.length < 2) return;
10994
+ const host = $('dialog-host');
10995
+ host.textContent = '';
10996
+ const scrim = el('div', 'sheet-scrim'); scrim.onclick = () => { host.textContent = ''; };
10997
+ const panel = el('div', 'sheet compare-host');
10998
+ const bar = el('div', 'rbar');
10999
+ const close = el('button', null, 'Back to gallery'); close.type = 'button'; close.onclick = () => { host.textContent = ''; };
11000
+ bar.append(close, el('span', null, 'Fields that differ are tinted. Shift-click cards in the gallery to change the set.'));
11001
+ const zoomBar = el('div', 'zoom');
11002
+ zoomBar.append(el('span', null, 'zoom'));
11003
+ const columnWidth = Math.max(160, Math.floor((Math.min(window.innerWidth * 0.96, 1500) - 140) / items.length) - 24);
11004
+ const largest = Math.max(...items.map((i) => Math.max(i.width, i.height)));
11005
+ const auto = displayScale(largest, largest, columnWidth, Math.min(window.innerHeight * 0.5, 520));
11006
+ for (const z of ['fit', 1, 2, 4, 8]) {
11007
+ const b = el('button', ui.compareZoom === String(z) || (ui.compareZoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
11008
+ b.type = 'button'; b.onclick = () => { ui.compareZoom = String(z); openCompare(); };
11009
+ zoomBar.append(b);
11010
+ }
11011
+ bar.append(zoomBar);
11012
+ const zoom = ui.compareZoom === 'auto' ? auto : ui.compareZoom === 'fit' ? Math.min(auto, 1) : Number(ui.compareZoom);
11013
+
11014
+ const body = el('div', 'compare-body');
11015
+ const table = el('table');
11016
+ const thead = el('thead'); const hr = el('tr'); hr.append(el('th', null, ''));
11017
+ for (const item of items) {
11018
+ const th = el('th');
11019
+ const head = el('div', 'chead');
11020
+ const badges = el('div', 'badges');
11021
+ if (item.project) badges.append(el('span', 'sid project', item.project));
11022
+ badges.append(el('span', 'sid', item.styleId));
11023
+ const aid = el('div', 'aid', item.assetId);
11024
+ const openBtn = el('button', 'linkish', 'open record'); openBtn.type = 'button';
11025
+ openBtn.onclick = () => { host.textContent = ''; openItem(item.id); };
11026
+ head.append(badges, aid, openBtn);
11027
+ th.append(head);
11028
+ hr.append(th);
11029
+ }
11030
+ thead.append(hr); table.append(thead);
11031
+ const tbody = el('tbody');
11032
+
11033
+ // Images first, at one shared zoom, so like is compared with like.
11034
+ const imgRow = el('tr'); imgRow.append(el('td', null, 'art'));
11035
+ for (const item of items) {
11036
+ const td = el('td');
11037
+ const box = el('div', 'cimg');
11038
+ const shown = item.outputs.find((o) => o.url);
11039
+ if (shown) {
11040
+ const img = el('img'); img.src = shown.url; img.alt = item.assetId;
11041
+ if (zoom >= 1) { img.width = Math.round(item.width * zoom); img.height = Math.round(item.height * zoom); }
11042
+ else { img.style.maxWidth = '100%'; img.style.height = 'auto'; }
11043
+ box.append(img);
11044
+ } else box.append(el('span', 'none', 'no art on disk'));
11045
+ td.append(box);
11046
+ if (item.outputs.filter((o) => o.url).length > 1) td.append(el('div', 'state-dim', item.outputs.length + ' outputs; first shown'));
11047
+ imgRow.append(td);
11048
+ }
11049
+ tbody.append(imgRow);
11050
+
11051
+ const rows = [
11052
+ ['state', (i) => i.state + (i.reason ? ' \u2014 ' + i.reason : ''), (i) => i.state],
11053
+ ['provider', (i) => i.provider],
11054
+ ['generator', (i) => i.generator + (i.tileFeature ? ' \xB7 ' + i.tileFeature : '')],
11055
+ ['candidates', (i) => i.candidates ?? '\u2014'],
11056
+ ['size', (i) => i.width + ' \xD7 ' + i.height],
11057
+ ['cost', (i) => i.status ? fmtCost(i.costUnit, i.cost) : (i.estimatedCost !== null ? 'est. ' + fmtCost(i.costUnit, i.estimatedCost) : '\u2014')],
11058
+ ['prompt', (i) => i.prompt, null, 'prompt'],
11059
+ ['submitted', (i) => fmtWhen(i.submittedAt) || '\u2014'],
11060
+ ['downloaded', (i) => fmtWhen(i.downloadedAt) || '\u2014'],
11061
+ ['quality', (i) => i.quality ? i.quality.state + (i.quality.review && i.quality.review.status === 'approved' ? ' by ' + i.quality.review.reviewer : '') : '\u2014'],
11062
+ ['spec hash', (i) => i.recordedSpecHash ? i.recordedSpecHash.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
11063
+ ['sha256', (i) => i.outputs[0] && i.outputs[0].sha256 ? i.outputs[0].sha256.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
11064
+ ['tags', (i) => (i.tags || []).filter((t) => !/^(pixelkiln|asset|style):/.test(t)).join(', ') || '\u2014'],
11065
+ ];
11066
+ for (const [label, show, keyOf, cls] of rows) {
11067
+ const tr = el('tr'); tr.append(el('td', null, label));
11068
+ const keys = items.map((i) => String((keyOf || show)(i)));
11069
+ const differs = new Set(keys).size > 1;
11070
+ for (const item of items) {
11071
+ const td = el('td', (differs ? 'diff' : '') + (cls === 'mono' ? ' mono' : ''));
11072
+ if (cls === 'prompt') td.append(el('div', 'prompt', show(item))); else td.textContent = String(show(item));
11073
+ tr.append(td);
11074
+ }
11075
+ tbody.append(tr);
11076
+ }
11077
+ table.append(tbody);
11078
+ body.append(table);
11079
+ panel.append(bar, body);
11080
+ host.append(scrim, panel);
11081
+ close.focus({ preventScroll: true });
11082
+ }
11083
+
11084
+ // ---- editing (only when the server minted a session) ---------------------
11085
+
11086
+ // Styles a parent edit reaches: every descendant (via extends) that does not
11087
+ // declare the field itself. Child-wins means an omitted field is inherited,
11088
+ // so the count follows the manifest's own rule rather than guessing.
11089
+ function inheritorsOf(style, field) {
11090
+ const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
11091
+ const out = [];
11092
+ for (const other of byId.values()) {
11093
+ if (other.id === style.id) continue;
11094
+ let cur = other, blocked = false, hops = 0;
11095
+ while (cur && cur.id !== style.id && hops++ < 32) {
11096
+ if (cur.ownFields.includes(field)) { blocked = true; break; }
11097
+ cur = cur.extends ? byId.get(cur.extends) : null;
11098
+ }
11099
+ if (cur && cur.id === style.id && !blocked) out.push(other.id);
11100
+ }
11101
+ return out;
11102
+ }
11103
+ function blastRadius(style, fields) {
11104
+ const styleIds = new Set([style.id]);
11105
+ for (const field of fields) for (const id of inheritorsOf(style, field)) styleIds.add(id);
11106
+ const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
11107
+ const units = new Map();
11108
+ for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
11109
+ return {
11110
+ styles: [...styleIds],
11111
+ assets: affected.length,
11112
+ cost: [...units].map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + '),
11113
+ };
11114
+ }
11115
+ const parsePalette = (value) => value.split(/[s,]+/).map((c) => c.trim()).filter(Boolean)
11116
+ .map((c) => '#' + c.replace(/^#/, '').toLowerCase());
11117
+ const paletteValid = (colors) => colors.every((c) => /^#[0-9a-f]{6}$/.test(c));
11118
+
11119
+ function styleForm(style) {
11120
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
11121
+ const own = (field) => style.ownFields.includes(field);
11122
+ const provenance = (field) => own(field) ? 'set on this style'
11123
+ : style.extends ? 'inherited from ' + style.extends + ' \u2014 saving sets a value on this style itself; ' + style.extends + ' is unchanged'
11124
+ : 'default \u2014 saving sets a value on this style itself';
11125
+ const form = el('form', 'edit style-form');
11126
+ form.append(el('h3', null, 'Edit style ' + style.id));
11127
+ const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
11128
+ const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
11129
+ const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb \u2014 leave empty for no forced palette';
11130
+ const swatches = el('div', 'pal');
11131
+ const drawSwatches = () => {
11132
+ swatches.textContent = '';
11133
+ for (const c of parsePalette(palette.value)) { const i = el('i'); i.style.background = c; i.title = c; swatches.append(i); }
11134
+ };
11135
+ drawSwatches();
11136
+ form.append(
11137
+ field('prompt prefix', prefix, 'Prepended to every asset prompt in this style. ' + provenance('promptPrefix')),
11138
+ field('prompt suffix', suffix, 'Appended to every asset prompt in this style. ' + provenance('promptSuffix')),
11139
+ field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
11140
+ swatches,
11141
+ );
11142
+ const blast = el('div', 'blast');
11143
+ const changed = () => {
11144
+ const out = [];
11145
+ if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
11146
+ if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
11147
+ if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
11148
+ return out;
11149
+ };
11150
+ const updateBlast = () => {
11151
+ const fields = changed();
11152
+ drawSwatches();
11153
+ if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
11154
+ const r = blastRadius(style, fields);
11155
+ blast.className = 'blast hot';
11156
+ blast.textContent = '';
11157
+ const others = r.styles.filter((id) => id !== style.id);
11158
+ blast.append(el('b', null, 'Affects ' + r.assets + (r.assets === 1 ? ' asset' : ' assets')));
11159
+ blast.append(document.createTextNode(others.length
11160
+ ? ' in ' + style.id + ' and ' + others.length + (others.length === 1 ? ' style that inherits it (' : ' styles that inherit it (') + others.join(', ') + ').'
11161
+ : ' in ' + style.id + '.'));
11162
+ blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
11163
+ };
11164
+ for (const input of [prefix, suffix, palette]) input.addEventListener('input', updateBlast);
11165
+ updateBlast();
11166
+ form.append(blast);
11167
+ const actions = el('div', 'actions');
11168
+ const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
11169
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
11170
+ const msg = el('span', 'msg');
11171
+ actions.append(save, cancel, msg);
11172
+ form.append(actions);
11173
+ form.onsubmit = async (e) => {
11174
+ e.preventDefault();
11175
+ const fields = changed();
11176
+ if (!fields.length) { ui.editing = null; render(); return; }
11177
+ const colors = parsePalette(palette.value);
11178
+ if (fields.includes('palette') && !paletteValid(colors)) { msg.className = 'msg bad'; msg.textContent = 'palette must be six-digit hex colours'; return; }
11179
+ const patch = {};
11180
+ if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
11181
+ if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
11182
+ if (fields.includes('palette')) patch.palette = colors;
11183
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
11184
+ const r = blastRadius(style, fields);
11185
+ try {
11186
+ const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch };
11187
+ if (style.project) body.project = style.project;
11188
+ snap = await postEdit(body);
11189
+ ui.editing = null;
11190
+ ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
11191
+ text: 'Saved. The request for ' + r.assets + (r.assets === 1 ? ' asset' : ' assets') + ' changed' + (r.cost ? ' \u2014 about ' + r.cost + ' to regenerate.' : '.') + ' Nothing is spent until you generate.' };
11192
+ render();
11193
+ } catch (err) {
11194
+ save.disabled = false;
11195
+ msg.className = 'msg bad';
11196
+ msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
11197
+ }
11198
+ };
11199
+ setTimeout(() => prefix.focus(), 0);
11200
+ return form;
11201
+ }
11202
+
11203
+ function addAssetForm(style) {
11204
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
11205
+ const siblings = snap.styles.filter((x) => x.project === style.project);
11206
+ const form = el('form', 'edit');
11207
+ form.append(el('h3', null, 'New asset in ' + style.id));
11208
+ const id = el('input'); id.type = 'text'; id.placeholder = 'asset-id'; id.required = true; id.autocomplete = 'off';
11209
+ id.pattern = '[^/\\\\]+';
11210
+ const prompt = el('textarea'); prompt.placeholder = 'What to generate. The style adds its prefix and suffix.'; prompt.required = true;
11211
+ const width = numberInput(null, 'style default'), height = numberInput(null, 'style default');
11212
+ const category = el('input'); category.type = 'text'; category.placeholder = 'optional subfolder';
11213
+ const only = el('input'); only.type = 'checkbox'; only.checked = siblings.length > 1;
11214
+ const onlyField = el('label', 'field check'); onlyField.append(only, el('span', null, 'only in ' + style.id));
11215
+ const row1 = el('div', 'row'); row1.append(field('id', id), field('category', category));
11216
+ const row2 = el('div', 'row'); row2.append(field('width', width), field('height', height));
11217
+ form.append(row1, field('prompt', prompt), row2);
11218
+ if (siblings.length > 1) form.append(onlyField);
11219
+ const actions = el('div', 'actions');
11220
+ const save = el('button', 'primary', 'Add asset'); save.type = 'submit';
11221
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
11222
+ const msg = el('span', 'msg');
11223
+ actions.append(save, cancel, msg);
11224
+ form.append(actions);
11225
+ form.onsubmit = async (e) => {
11226
+ e.preventDefault();
11227
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
11228
+ const asset = { prompt: prompt.value };
11229
+ if (numberOrNull(width) !== null) asset.width = numberOrNull(width);
11230
+ if (numberOrNull(height) !== null) asset.height = numberOrNull(height);
11231
+ if (category.value.trim()) asset.category = category.value.trim();
11232
+ if (siblings.length > 1 && only.checked) asset.styles = [style.id];
11233
+ try {
11234
+ const body = { action: 'add-asset', assetId: id.value.trim(), expectedSha256: pr.manifestSha256, asset };
11235
+ if (style.project) body.project = style.project;
11236
+ snap = await postEdit(body);
11237
+ const newId = (style.project ? style.project + ':' : '') + style.id + '/' + id.value.trim();
11238
+ ui.editing = null;
11239
+ ui.notice = { id: newId, text: 'Added to the manifest. Nothing is generated until you run pixelkiln gen.' };
11240
+ render();
11241
+ if (snap.items.some((i) => i.id === newId)) openItem(newId);
11242
+ } catch (err) {
11243
+ save.disabled = false;
11244
+ msg.className = 'msg bad';
11245
+ msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
11246
+ }
11247
+ };
11248
+ setTimeout(() => id.focus(), 0);
11249
+ return form;
11250
+ }
11251
+
11252
+ function editForm(item) {
11253
+ const pr = projectOf(item);
11254
+ const a = item.asset;
11255
+ const styled = !!(a.promptByStyle && Object.hasOwn(a.promptByStyle, item.styleId));
11256
+ const form = el('form', 'edit');
11257
+ form.append(el('h3', null, 'Edit intent'));
11258
+ const scope = el('select');
11259
+ scope.append(new Option('Prompt for every style', 'all'), new Option('Prompt only for ' + item.styleId, 'style'));
11260
+ scope.value = styled ? 'style' : 'all';
11261
+ const prompt = el('textarea');
11262
+ prompt.value = styled ? a.promptByStyle[item.styleId] : a.prompt;
11263
+ scope.onchange = () => {
11264
+ prompt.value = scope.value === 'style' ? (a.promptByStyle?.[item.styleId] ?? a.prompt) : a.prompt;
11265
+ };
11266
+ const width = numberInput(a.width, 'style default'), height = numberInput(a.height, 'style default'), size = numberInput(a.size, 'style default');
11267
+ const category = el('input'); category.type = 'text'; category.value = a.category || '';
11268
+ const tags = el('input'); tags.type = 'text'; tags.value = (a.tags || []).join(', '); tags.placeholder = 'comma-separated';
11269
+ form.append(field('prompt', prompt, 'The style adds its prefix and suffix; the sent prompt is shown below.'), field('applies to', scope));
11270
+ const row = el('div', 'row'); row.append(field('width', width), field('height', height), field('size', size));
11271
+ form.append(row);
11272
+ const row2 = el('div', 'row');
11273
+ row2.append(field('category', category, 'Output subfolder. The existing file stays put; restore or gen writes the new path.'), field('tags', tags));
11274
+ form.append(row2);
11275
+ const actions = el('div', 'actions');
11276
+ const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
11277
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; renderDrawer(); };
11278
+ const msg = el('span', 'msg');
11279
+ actions.append(save, cancel, msg);
11280
+ form.append(actions);
11281
+ form.onsubmit = async (e) => {
11282
+ e.preventDefault();
11283
+ const patch = {};
11284
+ if (scope.value === 'style') {
11285
+ if (!styled || prompt.value !== a.promptByStyle[item.styleId]) patch.promptForStyle = { styleId: item.styleId, prompt: prompt.value };
11286
+ } else {
11287
+ if (prompt.value !== a.prompt) patch.prompt = prompt.value;
11288
+ if (styled) patch.promptForStyle = { styleId: item.styleId, prompt: null };
11289
+ }
11290
+ for (const [key, input] of [['width', width], ['height', height], ['size', size]]) {
11291
+ const next = numberOrNull(input);
11292
+ if (next !== (a[key] ?? null)) patch[key] = next;
11293
+ }
11294
+ const nextCategory = category.value.trim() || null;
11295
+ if (nextCategory !== (a.category || null)) patch.category = nextCategory;
11296
+ const nextTags = splitTags(tags.value);
11297
+ if (nextTags.join('\\u0000') !== (a.tags || []).join('\\u0000')) patch.tags = nextTags;
11298
+ if (!Object.keys(patch).length) { ui.editing = null; renderDrawer(); return; }
11299
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
11300
+ try {
11301
+ const body = { action: 'patch-asset', assetId: item.assetId, expectedSha256: pr.manifestSha256, patch };
11302
+ if (item.project) body.project = item.project;
11303
+ snap = await postEdit(body);
11304
+ const after = snap.items.find((i) => i.id === item.id);
11305
+ ui.editing = null;
11306
+ ui.notice = {
11307
+ id: item.id,
11308
+ text: after
11309
+ ? 'Saved. plan now reports ' + after.state +
11310
+ (after.estimatedCost !== null && (after.state === 'stale' || after.state === 'missing')
11311
+ ? ' \u2014 ' + fmtCost(after.costUnit, after.estimatedCost) + ' to generate. Nothing is spent until you run pixelkiln gen.'
11312
+ : '.')
11313
+ : 'Saved.',
11314
+ };
11315
+ render();
11316
+ } catch (err) {
11317
+ save.disabled = false;
11318
+ msg.className = 'msg bad';
11319
+ msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
11320
+ }
11321
+ };
11322
+ setTimeout(() => prompt.focus(), 0);
11323
+ return form;
11324
+ }
11325
+
11326
+ // ---- detail drawer -------------------------------------------------------
11327
+
11328
+ function row(dl, label, value, opts = {}) {
11329
+ if (value === null || value === undefined || value === '') return;
11330
+ dl.append(el('dt', null, label));
11331
+ const dd = el('dd', opts.mono ? 'mono' : null);
11332
+ if (value instanceof Node) dd.append(value); else dd.textContent = String(value);
11333
+ if (opts.copy) {
11334
+ const b = el('button', 'copy', 'copy');
11335
+ b.type = 'button';
11336
+ b.onclick = async () => {
11337
+ try { await navigator.clipboard.writeText(String(opts.copy)); b.textContent = 'copied'; }
11338
+ catch { b.textContent = 'select to copy'; }
11339
+ setTimeout(() => (b.textContent = 'copy'), 1200);
11340
+ };
11341
+ dd.append(b);
11342
+ }
11343
+ dl.append(dd);
11344
+ }
11345
+ function section(title) {
11346
+ const s = el('section', 'meta');
11347
+ s.append(el('h3', null, title));
11348
+ const dl = el('dl');
11349
+ s.append(dl);
11350
+ return { s, dl };
11351
+ }
11352
+ function stateNode(state, reason) {
11353
+ const box = el('div', 'status');
11354
+ const st = el('span', 'st state-' + STATE_TONE[state]);
11355
+ st.append(el('i', 'dot ' + STATE_TONE[state]), document.createTextNode(state));
11356
+ box.append(st, el('span', 'why', reason));
11357
+ return box;
11358
+ }
11359
+ function sibling(item, key) {
11360
+ return snap.items.find((i) => i.key === key && i.project === item.project);
11361
+ }
11362
+ function keyLink(item, key, label) {
11363
+ const b = el('button', 'linkish', label || key);
11364
+ b.type = 'button';
11365
+ const target = sibling(item, key);
11366
+ if (!target) { b.disabled = true; b.title = 'not in this snapshot'; }
11367
+ b.onclick = () => openItem(target.id);
11368
+ return b;
11369
+ }
11370
+ function jsonDetails(title, value) {
11371
+ const d = el('details');
11372
+ d.append(el('summary', null, title), el('pre', null, JSON.stringify(value, null, 2)));
11373
+ return d;
11374
+ }
11375
+
11376
+ function stopPlayback() {
11377
+ if (ui.playing) { clearInterval(ui.playing); ui.playing = null; }
11378
+ }
11379
+
11380
+ function renderPreview(item, host) {
11381
+ host.textContent = '';
11382
+ stopPlayback();
11383
+ const shown = item.outputs.filter((o) => o.url);
11384
+ const preview = el('div', 'preview');
11385
+ if (!shown.length) {
11386
+ preview.append(el('div', 'none', item.outputs.length ? 'The recorded file is not on disk. pixelkiln restore re-downloads it without spending.' : 'No output to show.'));
11387
+ host.append(preview);
11388
+ return;
11389
+ }
11390
+ const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
11391
+ const frameSet = isFrameSet(item);
11392
+ const w = item.width, h = item.height;
11393
+ const box = Math.min(window.innerWidth, 540) - 62;
11394
+ const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
11395
+ const zoom = ui.zoom === 'auto' ? auto : ui.zoom === 'fit' ? Math.min(auto, 1) : Number(ui.zoom);
11396
+ const img = el('img');
11397
+ img.src = member.url; img.alt = member.role || item.assetId;
11398
+ if (zoom >= 1) { img.width = Math.round(w * zoom); img.height = Math.round(h * zoom); }
11399
+ else { img.style.maxWidth = '100%'; img.style.maxHeight = '55vh'; img.style.width = 'auto'; img.style.height = 'auto'; }
11400
+ preview.append(img);
11401
+
11402
+ const zoomBar = el('div', 'zoom');
11403
+ zoomBar.append(el('span', null, 'zoom'));
11404
+ for (const z of ['fit', 1, 2, 4, 8]) {
11405
+ const b = el('button', ui.zoom === String(z) || (ui.zoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
11406
+ b.type = 'button';
11407
+ b.onclick = () => { ui.zoom = String(z); renderPreview(item, host); };
11408
+ zoomBar.append(b);
11409
+ }
11410
+ const label = el('span', 'grow', w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : ' fitted'));
11411
+ zoomBar.append(label);
11412
+ if (frameSet && shown.length > 1) {
11413
+ const fps = item.fps || 12;
11414
+ const play = el('button', null, 'Play ' + fps + ' fps');
11415
+ play.type = 'button';
11416
+ let idx = shown.indexOf(member);
11417
+ play.onclick = () => {
11418
+ if (ui.playing) { stopPlayback(); play.textContent = 'Play ' + fps + ' fps'; return; }
11419
+ play.textContent = 'Pause';
11420
+ ui.playing = setInterval(() => {
11421
+ if (document.hidden) return;
11422
+ idx = (idx + 1) % shown.length;
11423
+ img.src = shown[idx].url;
11424
+ label.textContent = (shown[idx].role || 'frame ' + (idx + 1)) + ' \xB7 ' + w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : '');
11425
+ for (const [i, b] of [...host.querySelectorAll('.members button')].entries()) b.classList.toggle('on', i === idx);
11426
+ }, Math.max(16, Math.round(1000 / fps)));
11427
+ };
11428
+ zoomBar.append(play);
11429
+ }
11430
+ host.append(preview, zoomBar);
11431
+
11432
+ if (shown.length > 1) {
11433
+ const strip = el('div', 'members');
11434
+ shown.forEach((o, i) => {
11435
+ const b = el('button', o === member ? 'on' : null);
11436
+ b.type = 'button';
11437
+ const t = el('img'); t.src = o.url; t.alt = o.role || 'output ' + (i + 1); t.loading = 'lazy';
11438
+ b.append(t, el('span', null, o.role || String(i + 1)));
11439
+ b.onclick = () => { ui.member = i; renderPreview(item, host); };
11440
+ strip.append(b);
11441
+ });
11442
+ host.append(strip);
11443
+ }
11444
+ }
11445
+
11446
+ function renderDrawer() {
11447
+ const host = $('drawer-host');
11448
+ host.textContent = '';
11449
+ stopPlayback();
11450
+ const item = ui.open && snap.items.find((i) => i.id === ui.open);
11451
+ if (!item) { ui.open = null; return; }
11452
+ const items = visibleItems();
11453
+ const pos = items.findIndex((i) => i.id === item.id);
11454
+
11455
+ const scrim = el('div', 'scrim');
11456
+ scrim.onclick = () => closeItem();
11457
+ const drawer = el('aside', 'drawer');
11458
+ drawer.setAttribute('role', 'dialog');
11459
+ drawer.setAttribute('aria-label', item.id);
11460
+
11461
+ const head = el('div', 'dhead');
11462
+ const title = el('div', 'title');
11463
+ if (item.project) title.append(el('span', 'sid project', item.project), ' ');
11464
+ title.append(el('span', 'sid', item.styleId), el('div', 'aid', item.assetId));
11465
+ const nav = el('div', 'nav');
11466
+ const prev = el('button', null, '\u2190'); prev.type = 'button'; prev.title = 'Previous (\u2190)';
11467
+ prev.disabled = pos <= 0; prev.onclick = () => step(-1);
11468
+ const next = el('button', null, '\u2192'); next.type = 'button'; next.title = 'Next (\u2192)';
11469
+ next.disabled = pos < 0 || pos >= items.length - 1; next.onclick = () => step(1);
11470
+ const close = el('button', null, 'Close'); close.type = 'button'; close.onclick = () => closeItem();
11471
+ const cmp = el('button', null, ui.compare.includes(item.id) ? 'Remove from compare' : 'Compare +');
11472
+ cmp.type = 'button'; cmp.onclick = () => toggleCompare(item.id);
11473
+ nav.append(cmp);
11474
+ const canEdit = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256;
11475
+ if (canEdit) {
11476
+ const edit = el('button', null, ui.editing === item.id ? 'Cancel edit' : 'Edit');
11477
+ edit.type = 'button';
11478
+ edit.onclick = () => { ui.editing = ui.editing === item.id ? null : item.id; ui.notice = null; renderDrawer(); };
11479
+ nav.append(edit);
11480
+ }
11481
+ nav.append(prev, next, close);
11482
+ head.append(title, nav);
11483
+
11484
+ const body = el('div', 'dbody');
11485
+ const previewHost = el('div');
11486
+ renderPreview(item, previewHost);
11487
+ body.append(previewHost);
11488
+ if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
11489
+ body.append(stateNode(item.state, item.reason));
11490
+ if (GENERATION) body.append(generateActions(item));
11491
+ if (canEdit && ui.editing === item.id) body.append(editForm(item));
11492
+ // Plan already quotes the error as the reason for a failed entry; only a
11493
+ // stale or superseded failure needs its own line.
11494
+ if (item.error && !item.reason.includes(item.error)) body.append(stateNode('failed', item.error));
11495
+
11496
+ // Generation \u2014 what was sent and what it cost.
11497
+ {
11498
+ const { s, dl } = section('Generation');
11499
+ row(dl, 'provider', item.provider);
11500
+ row(dl, 'generator', item.generator + (item.tileFeature ? ' \xB7 ' + item.tileFeature : ''));
11501
+ row(dl, 'lock status', item.status || 'none \u2014 nothing submitted');
11502
+ const pr = el('div', 'prompt', item.prompt); row(dl, item.status ? 'prompt sent' : 'prompt', pr);
11503
+ if (item.currentPrompt) row(dl, 'prompt now', el('div', 'prompt state-warn', item.currentPrompt));
11504
+ row(dl, 'size', item.width + ' \xD7 ' + item.height + ' px');
11505
+ if (item.status) row(dl, 'cost', fmtCost(item.costUnit, item.cost));
11506
+ if (item.estimatedCost !== null && (item.estimatedCost !== item.cost || !item.status)) {
11507
+ row(dl, 'estimate now', fmtCost(item.costUnit, item.estimatedCost) +
11508
+ (item.candidates && item.candidates > 1 ? ' for ' + item.candidates + ' candidates' : ''));
11509
+ }
11510
+ if (item.candidateIndex !== null) row(dl, 'candidate', '#' + (item.candidateIndex + 1) + ' chosen in review');
11511
+ row(dl, 'submitted', fmtWhen(item.submittedAt));
11512
+ row(dl, 'downloaded', fmtWhen(item.downloadedAt));
11513
+ row(dl, 'job id', item.jobId, { mono: true, copy: item.jobId });
11514
+ row(dl, 'object id', item.objectId, { mono: true, copy: item.objectId });
11515
+ row(dl, 'review object', item.reviewObjectId, { mono: true, copy: item.reviewObjectId });
11516
+ body.append(s);
11517
+ }
11518
+
11519
+ // Outputs \u2014 every file with its hash and whether it is really there.
11520
+ {
11521
+ const s = el('section', 'meta');
11522
+ s.append(el('h3', null, item.outputs.length === 1 ? 'Output' : 'Outputs (' + item.outputs.length + ')'));
11523
+ if (!item.outputs.length) s.append(el('div', 'state-dim', item.source ? 'placed from ' + item.source : 'none recorded'));
11524
+ for (const o of item.outputs) {
11525
+ const box = el('div', 'out');
11526
+ const role = el('div', 'role');
11527
+ role.append(document.createTextNode(o.role || (item.outputs.length > 1 ? 'primary' : 'file')));
11528
+ if (!o.exists) role.append(el('span', 'miss', 'missing on disk'));
11529
+ if (o.url) { const a = el('a', null, 'open'); a.href = o.url.split('?')[0]; a.target = '_blank'; a.rel = 'noopener'; role.append(a); }
11530
+ box.append(role);
11531
+ const dl = el('dl');
11532
+ row(dl, 'path', o.path, { mono: true, copy: o.absolutePath });
11533
+ row(dl, 'sha256', o.sha256 ? o.sha256.slice(0, 16) + '\u2026' : (o.exists ? 'not recorded' : null), { mono: true, copy: o.sha256 || undefined });
11534
+ row(dl, 'bytes', o.exists ? fmtBytes(o.bytes) : null);
11535
+ row(dl, 'type', o.mediaType);
11536
+ box.append(dl);
11537
+ s.append(box);
11538
+ }
11539
+ body.append(s);
11540
+ }
11541
+
11542
+ if (item.revision || item.revisionParentKey) {
11543
+ const { s, dl } = section('Lineage');
11544
+ row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
11545
+ if (item.revisionParentKey) row(dl, 'parent', keyLink(item, item.revisionParentKey));
11546
+ if (item.revision) {
11547
+ row(dl, 'parent sha256', item.revision.sourceSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.sourceSha256 });
11548
+ if (item.revision.maskSha256) row(dl, 'mask sha256', item.revision.maskSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.maskSha256 });
11549
+ if (item.revision.strength !== undefined) row(dl, 'strength', item.revision.strength);
11550
+ }
11551
+ body.append(s);
11552
+ }
11553
+ const children = snap.items.filter((i) => i.revisionParentKey === item.key && i.project === item.project);
11554
+ if (children.length) {
11555
+ const { s, dl } = section('Revisions from this asset');
11556
+ for (const c of children) row(dl, c.revision ? c.revision.mode : 'child', keyLink(item, c.key));
11557
+ body.append(s);
11558
+ }
11559
+
11560
+ if (item.quality) {
11561
+ const q = item.quality;
11562
+ const { s, dl } = section('Quality');
11563
+ const tone = q.state === 'approved' ? 'ok' : q.state === 'blocked' ? 'bad' : 'warn';
11564
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone + ' '), document.createTextNode(' ' + q.state));
11565
+ row(dl, 'state', st);
11566
+ row(dl, 'why', q.reason);
11567
+ if (q.review) {
11568
+ row(dl, 'review', q.review.status === 'approved'
11569
+ ? 'approved by ' + q.review.reviewer + ' \xB7 ' + fmtWhen(q.review.approvedAt)
11570
+ : 'pending human 1\xD7 review');
11571
+ if (q.review.status === 'approved' && q.review.note) row(dl, 'note', q.review.note);
11572
+ }
11573
+ if (q.palette) {
11574
+ const pal = el('div', 'pal');
11575
+ for (const c of q.palette) { const i = el('i'); i.style.background = c; i.title = c; pal.append(i); }
11576
+ row(dl, 'palette', pal);
11577
+ row(dl, 'colors', q.palette.length + ' declared' + (q.audit ? ', ' + q.audit.colorCount + ' used' : ''));
11578
+ }
11579
+ if (q.nativeGrid) {
11580
+ row(dl, 'native grid', q.nativeGrid.sourceWidth + '\xD7' + q.nativeGrid.sourceHeight + ' \u2192 ' +
11581
+ q.nativeGrid.nativeWidth + '\xD7' + q.nativeGrid.nativeHeight + ' (step ' + q.nativeGrid.stepX + '\xD7' + q.nativeGrid.stepY +
11582
+ ', ' + q.nativeGrid.confidence + ' confidence)');
11583
+ }
11584
+ if (q.audit) row(dl, 'audit', (q.audit.safe ? 'passed' : 'failed') + ' \xB7 ' + Math.round(q.audit.transparency * 100) + '% transparent' +
11585
+ (q.audit.reasons.length ? ' \xB7 ' + q.audit.reasons.join('; ') : ''));
11586
+ if (q.frameSet) row(dl, 'frame set', q.frameSet.count + ' frames at ' + q.frameSet.fps + ' fps');
11587
+ row(dl, 'output', q.output, { mono: true });
11588
+ row(dl, 'record', q.record + (q.recordExists ? '' : ' (not written yet)'), { mono: true });
11589
+ if (q.check && !q.check.safe) row(dl, 'check', el('div', 'state-warn', q.check.reasons.join('\\n')));
11590
+ body.append(s);
11591
+ if (q.outputs.some((o) => o.url)) {
11592
+ const strip = el('div', 'members');
11593
+ for (const o of q.outputs.filter((o) => o.url)) {
11594
+ const b = el('button'); b.type = 'button';
11595
+ const t = el('img'); t.src = o.url; t.alt = 'refined ' + (o.role || item.assetId);
11596
+ b.append(t, el('span', null, 'refined')); b.onclick = () => window.open(o.url.split('?')[0], '_blank');
11597
+ strip.append(b);
11598
+ }
11599
+ s.append(strip);
11600
+ }
11601
+ }
11602
+
11603
+ {
11604
+ const { s, dl } = section('Identity');
11605
+ if (item.project) row(dl, 'project', item.project);
11606
+ row(dl, 'lock key', item.key, { mono: true, copy: item.key });
11607
+ if (item.recordedSpecHash) {
11608
+ row(dl, 'spec hash', item.recordedSpecHash.slice(0, 16) + '\u2026', { mono: true, copy: item.recordedSpecHash });
11609
+ }
11610
+ if (item.currentSpecHash && item.currentSpecHash !== item.recordedSpecHash) {
11611
+ row(dl, item.recordedSpecHash ? 'spec hash now' : 'spec hash',
11612
+ el('span', item.recordedSpecHash ? 'state-warn' : null, item.currentSpecHash.slice(0, 16) + '\u2026'), { mono: true, copy: item.currentSpecHash });
11613
+ }
11614
+ row(dl, 'declared', item.declared ? 'yes' : 'no \u2014 the manifest no longer has this asset in this style');
11615
+ row(dl, 'category', item.category);
11616
+ row(dl, 'tags', item.tags && item.tags.length ? item.tags.join(', ') : null);
11617
+ row(dl, 'source art', item.source, { mono: true });
11618
+ body.append(s);
11619
+ }
11620
+
11621
+ if (item.asset) {
11622
+ // Show the asset as the author would write it: schema defaults that are
11623
+ // empty add nothing but noise to a record.
11624
+ const declared = Object.fromEntries(Object.entries(item.asset).filter(([, v]) =>
11625
+ !(Array.isArray(v) && !v.length) && !(v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length)));
11626
+ body.append(jsonDetails('Manifest asset', declared));
11627
+ }
11628
+ if (item.providerMetadata && Object.keys(item.providerMetadata).length) {
11629
+ body.append(jsonDetails('Provider metadata', item.providerMetadata));
11630
+ }
11631
+
11632
+ drawer.append(head, body);
11633
+ host.append(scrim, drawer);
11634
+ close.focus({ preventScroll: true });
11635
+ }
11636
+
11637
+ // View state lives in the URL \u2014 filters and sort in the query, the open
11638
+ // record in the hash \u2014 so a link to "every failed generation" or to one
11639
+ // specific record can be pasted into a review thread and land there.
11640
+ function readUrlState() {
11641
+ const params = new URLSearchParams(location.search);
11642
+ const list = (name) => (params.get(name) || '').split(',').map((v) => v.trim()).filter(Boolean);
11643
+ ui.q = params.get('q') || '';
11644
+ ui.states = new Set(list('state'));
11645
+ ui.providers = new Set(list('provider'));
11646
+ ui.generators = new Set(list('generator'));
11647
+ ui.projects = new Set(list('project'));
11648
+ ui.compare = list('compare').slice(0, 4);
11649
+ if (['key', 'newest', 'oldest', 'cost', 'size'].includes(params.get('sort'))) ui.sort = params.get('sort');
11650
+ if (['style', 'none'].includes(params.get('group'))) ui.group = params.get('group');
11651
+ }
11652
+ function writeUrlState() {
11653
+ const params = new URLSearchParams();
11654
+ if (ui.q) params.set('q', ui.q);
11655
+ if (ui.states.size) params.set('state', [...ui.states].join(','));
11656
+ if (ui.providers.size) params.set('provider', [...ui.providers].join(','));
11657
+ if (ui.generators.size) params.set('generator', [...ui.generators].join(','));
11658
+ if (ui.projects.size) params.set('project', [...ui.projects].join(','));
11659
+ if (ui.compare.length) params.set('compare', ui.compare.join(','));
11660
+ if (ui.sort !== 'key') params.set('sort', ui.sort);
11661
+ if (ui.group !== 'style') params.set('group', ui.group);
11662
+ const query = params.toString();
11663
+ const next = location.pathname + (query ? '?' + query : '') + (ui.open ? '#' + encodeURIComponent(ui.open) : '');
11664
+ if (next !== location.pathname + location.search + location.hash) history.replaceState(null, '', next);
11665
+ }
11666
+ function syncHash() { writeUrlState(); }
11667
+ function keyFromHash() {
11668
+ try { return location.hash.length > 1 ? decodeURIComponent(location.hash.slice(1)) : null; }
11669
+ catch { return null; }
11670
+ }
11671
+ // Opening a record must not rebuild the grid: at a few thousand cards that
11672
+ // is a visible pause, and nothing in the grid changes except one highlight.
11673
+ function markActive(key) {
11674
+ document.querySelector('.card.active')?.classList.remove('active');
11675
+ const card = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
11676
+ if (card) card.classList.add('active');
11677
+ return card;
11678
+ }
11679
+ function openItem(key) {
11680
+ if (ui.open !== key) { ui.editing = null; if (ui.notice && ui.notice.id !== key) ui.notice = null; }
11681
+ ui.open = key; ui.member = 0; ui.zoom = 'auto';
11682
+ syncHash();
11683
+ const card = markActive(key);
11684
+ renderDrawer();
11685
+ if (card) card.scrollIntoView({ block: 'nearest' });
11686
+ }
11687
+ function closeItem() {
11688
+ const key = ui.open;
11689
+ ui.open = null;
11690
+ ui.editing = null;
11691
+ ui.notice = null;
11692
+ syncHash();
11693
+ const card = markActive(null);
11694
+ renderDrawer();
11695
+ const focus = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
11696
+ if (focus) focus.focus({ preventScroll: true });
11697
+ }
11698
+ window.addEventListener('hashchange', () => {
11699
+ const key = keyFromHash();
11700
+ if (key && key !== ui.open && snap.items.some((i) => i.id === key)) openItem(key);
11701
+ else if (!key && ui.open) closeItem();
11702
+ });
11703
+ function step(delta) {
11704
+ const items = visibleItems();
11705
+ const pos = items.findIndex((i) => i.id === ui.open);
11706
+ const next = items[pos + delta];
11707
+ if (next) openItem(next.id);
11708
+ }
11709
+
11710
+ function render() {
11711
+ writeUrlState();
11712
+ renderHeader();
11713
+ renderMain(visibleItems());
11714
+ renderDrawer();
11715
+ renderTray();
11716
+ }
11717
+
11718
+ // ---- refresh --------------------------------------------------------------
11719
+
11720
+ let refreshing = false;
11721
+ async function refresh() {
11722
+ if (refreshing) return;
11723
+ refreshing = true;
11724
+ const b = $('refresh');
11725
+ b.disabled = true;
11726
+ try {
11727
+ const res = await fetch('/api/gallery.json', { cache: 'no-store' });
11728
+ if (!res.ok) throw new Error(await res.text());
11729
+ snap = await res.json();
11730
+ $('note').textContent = '';
11731
+ render();
11732
+ } catch (err) {
11733
+ $('note').textContent = ' Refresh failed: ' + err.message + ' \u2014 is pixelkiln gallery still running?';
11734
+ } finally {
11735
+ refreshing = false;
11736
+ b.disabled = false;
11737
+ }
11738
+ }
11739
+ $('refresh').onclick = refresh;
11740
+ let autoTimer = null;
11741
+ $('auto').onchange = (e) => {
11742
+ if (autoTimer) clearInterval(autoTimer);
11743
+ autoTimer = e.target.checked ? setInterval(() => { if (!document.hidden) refresh(); }, 5000) : null;
11744
+ };
11745
+
11746
+ // ---- input ----------------------------------------------------------------
11747
+
11748
+ let searchTimer = null;
11749
+ $('q').addEventListener('input', (e) => {
11750
+ ui.q = e.target.value;
11751
+ clearTimeout(searchTimer);
11752
+ searchTimer = setTimeout(render, 90);
11753
+ });
11754
+ $('sort').onchange = (e) => { ui.sort = e.target.value; render(); };
11755
+ $('group').onchange = (e) => { ui.group = e.target.value; render(); };
11756
+ document.addEventListener('keydown', (e) => {
11757
+ const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || '');
11758
+ if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
11759
+ if (e.key === 'Escape') {
11760
+ if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
11761
+ if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
11762
+ if (ui.open) { e.preventDefault(); closeItem(); }
11763
+ return;
11764
+ }
11765
+ if (ui.open && !typing) {
11766
+ if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
11767
+ else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
11768
+ }
11769
+ });
11770
+ document.addEventListener('visibilitychange', () => { if (document.hidden) stopPlayback(); });
11771
+
11772
+ readUrlState();
11773
+ $('q').value = ui.q;
11774
+ $('sort').value = ui.sort;
11775
+ $('group').value = ui.group;
11776
+ const initialKey = keyFromHash();
11777
+ if (initialKey && snap.items.some((i) => i.id === initialKey)) ui.open = initialKey;
11778
+ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
11779
+ render();
11780
+ if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
11781
+ if (GENERATION) pollJobs();
11782
+ if (ui.compare.length >= 2 && !ui.open) openCompare();
11783
+ </script>
11784
+ </body>
11785
+ </html>`;
11786
+ }
11787
+ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11788
+
11789
+ // src/gallery/server.ts
11790
+ var import_node_child_process3 = require("child_process");
11791
+ var import_node_crypto7 = require("crypto");
11792
+ var import_node_http2 = require("http");
11793
+ var import_promises20 = require("fs/promises");
11794
+ var MAX_EDIT_BYTES = 64 * 1024;
11795
+ async function readJsonBody(req) {
11796
+ const chunks = [];
11797
+ let bytes = 0;
11798
+ for await (const chunk2 of req) {
11799
+ bytes += chunk2.length;
11800
+ if (bytes > MAX_EDIT_BYTES) throw Object.assign(new Error("edit request is too large"), { status: 413 });
11801
+ chunks.push(chunk2);
11802
+ }
11803
+ try {
11804
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
11805
+ } catch {
11806
+ throw Object.assign(new Error("edit request is not valid JSON"), { status: 400 });
11807
+ }
11808
+ }
11809
+ async function serveGallery(opts) {
11810
+ const log = opts.onProgress ?? (() => {
11811
+ });
11812
+ const session = opts.edit || opts.generate ? (0, import_node_crypto7.randomBytes)(16).toString("hex") : null;
11813
+ let media = /* @__PURE__ */ new Map();
11814
+ let loading = null;
11815
+ const reviewAssets = /* @__PURE__ */ new Map();
11816
+ const load = () => {
11817
+ if (!loading) {
11818
+ loading = opts.load().then((build) => {
11819
+ media = build.media;
11820
+ return build;
11821
+ }).finally(() => {
11822
+ loading = null;
11823
+ });
11824
+ }
11825
+ return loading;
11826
+ };
11827
+ const server = (0, import_node_http2.createServer)(async (req, res) => {
11828
+ const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
11829
+ const fail = (status, message6) => {
11830
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
11831
+ res.end(message6);
11832
+ };
11833
+ const json = (status, value) => {
11834
+ res.writeHead(status, {
11835
+ "Content-Type": "application/json; charset=utf-8",
11836
+ "Cache-Control": "no-store",
11837
+ "X-Content-Type-Options": "nosniff"
11838
+ });
11839
+ res.end(JSON.stringify(value));
11840
+ };
11841
+ const guardedBody = async (what) => {
11842
+ const expectedOrigin = req.headers.host ? `http://${req.headers.host}` : null;
11843
+ if (!req.headers.origin || req.headers.origin !== expectedOrigin) {
11844
+ fail(403, `cross-origin ${what} are not allowed`);
11845
+ return void 0;
11846
+ }
11847
+ if (req.headers["x-pixelkiln-session"] !== session) {
11848
+ fail(403, "missing or stale gallery session");
11849
+ return void 0;
11850
+ }
11851
+ if (!req.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
11852
+ fail(415, "application/json is required");
11853
+ return void 0;
11854
+ }
11855
+ return readJsonBody(req);
11856
+ };
11857
+ const reportError = (what, err) => {
11858
+ const status = typeof err?.status === "number" ? err.status : 500;
11859
+ const message6 = err instanceof Error ? err.message : String(err);
11860
+ if (status >= 500) log(` gallery ${what} failed: ${message6}`);
11861
+ fail(status, message6);
11862
+ };
11863
+ if (req.method === "POST" && url2.pathname === "/api/edit") {
11864
+ if (!opts.edit || !session) return fail(405, "the gallery is read-only; start it with --edit to change the manifest");
11865
+ try {
11866
+ const body = await guardedBody("edits");
11867
+ if (body === void 0) return;
11868
+ const build = await opts.edit(body);
11869
+ media = build.media;
11870
+ json(200, build.snapshot);
11871
+ } catch (err) {
11872
+ reportError("edit", err);
11873
+ }
11874
+ return;
11875
+ }
11876
+ if (req.method === "POST" && url2.pathname === "/api/generate") {
11877
+ if (!opts.generate || !session) return fail(405, "generation is off; start the gallery with --budget to enable it");
11878
+ try {
11879
+ const body = await guardedBody("generation requests");
11880
+ if (body === void 0) return;
11881
+ json(202, await opts.generate.start(body));
11882
+ } catch (err) {
11883
+ reportError("generate", err);
11884
+ }
11885
+ return;
11886
+ }
11887
+ const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
11888
+ if (reviewMatch && opts.generate) {
11889
+ const [, jobId, rest = ""] = reviewMatch;
11890
+ if (req.method === "POST" && rest === "/apply") {
11891
+ try {
11892
+ const body = await guardedBody("review submissions");
11893
+ if (body === void 0) return;
11894
+ const result = await opts.generate.applyReview(jobId, body);
11895
+ reviewAssets.delete(jobId);
11896
+ json(200, result);
11897
+ } catch (err) {
11898
+ reportError("review", err);
11899
+ }
11900
+ return;
11901
+ }
11902
+ if (req.method === "GET" && rest === "") {
11903
+ try {
11904
+ const routePrefix = `/review/${jobId}`;
11905
+ const review = await opts.generate.review(jobId, {
11906
+ routePrefix,
11907
+ applyUrl: `${routePrefix}/apply`,
11908
+ applyHeaders: { "X-Pixelkiln-Session": session },
11909
+ embedded: true
11910
+ });
11911
+ if (!review) return fail(404, "nothing is waiting for review on this job");
11912
+ reviewAssets.set(jobId, review.assets);
11913
+ res.writeHead(200, {
11914
+ "Content-Type": "text/html; charset=utf-8",
11915
+ "Cache-Control": "no-store",
11916
+ "X-Content-Type-Options": "nosniff"
11917
+ });
11918
+ res.end(review.html({
11919
+ applyUrl: `${routePrefix}/apply`,
11920
+ applyHeaders: { "X-Pixelkiln-Session": session },
11921
+ embedded: true
11922
+ }));
11923
+ } catch (err) {
11924
+ reportError("review", err);
11925
+ }
11926
+ return;
11927
+ }
11928
+ if (req.method === "GET") {
11929
+ const asset = reviewAssets.get(jobId)?.get(url2.pathname);
11930
+ if (!asset) return fail(404, "no such review asset");
11931
+ try {
11932
+ const bytes = await (0, import_promises20.readFile)(asset.path);
11933
+ res.writeHead(200, {
11934
+ "Content-Type": asset.contentType,
11935
+ "Cache-Control": "no-store",
11936
+ "X-Content-Type-Options": "nosniff"
11937
+ });
11938
+ res.end(bytes);
11939
+ } catch {
11940
+ fail(404, "review asset is unavailable");
11941
+ }
11942
+ return;
11943
+ }
11944
+ }
11945
+ if (req.method !== "GET" && req.method !== "HEAD") return fail(405, "the gallery is read-only");
11946
+ if (url2.pathname === "/api/jobs") {
11947
+ if (!opts.generate) return fail(404, "generation is off");
11948
+ return json(200, opts.generate.status());
11949
+ }
11950
+ try {
11951
+ if (url2.pathname === "/") {
11952
+ const { snapshot } = await load();
11953
+ res.writeHead(200, {
11954
+ "Content-Type": "text/html; charset=utf-8",
11955
+ "Cache-Control": "no-store",
11956
+ "X-Content-Type-Options": "nosniff"
11957
+ });
11958
+ res.end(renderGallery(snapshot, {
11959
+ ...session ? { session } : {},
11960
+ editable: Boolean(opts.edit),
11961
+ generation: Boolean(opts.generate)
11962
+ }));
11963
+ return;
11964
+ }
11965
+ if (url2.pathname === "/api/gallery.json") {
11966
+ const { snapshot } = await load();
11967
+ res.writeHead(200, {
11968
+ "Content-Type": "application/json; charset=utf-8",
11969
+ "Cache-Control": "no-store",
11970
+ "X-Content-Type-Options": "nosniff"
11971
+ });
11972
+ res.end(JSON.stringify(snapshot));
11973
+ return;
11974
+ }
11975
+ const mediaMatch = /^\/media\/([0-9a-f]{24})$/.exec(url2.pathname);
11976
+ if (mediaMatch) {
11977
+ const asset = media.get(mediaMatch[1]);
11978
+ if (!asset) return fail(404, "no such gallery media");
11979
+ let bytes;
11980
+ try {
11981
+ bytes = await (0, import_promises20.readFile)(asset.path);
11982
+ } catch {
11983
+ return fail(404, "gallery media is no longer on disk; refresh the page");
11984
+ }
11985
+ res.writeHead(200, {
11986
+ "Content-Type": asset.contentType,
11987
+ "Content-Length": bytes.length,
11988
+ // The page cache-busts with the recorded hash, so a regenerated file
11989
+ // is never shown stale; `no-cache` keeps a hard reload honest too.
11990
+ "Cache-Control": "no-cache",
11991
+ "X-Content-Type-Options": "nosniff"
11992
+ });
11993
+ res.end(req.method === "HEAD" ? void 0 : bytes);
11994
+ return;
11995
+ }
11996
+ fail(404, "not found");
11997
+ } catch (err) {
11998
+ const message6 = err instanceof Error ? err.message : String(err);
11999
+ log(` gallery request failed: ${message6}`);
12000
+ fail(500, message6);
12001
+ }
12002
+ });
12003
+ const url = await new Promise((resolve, reject) => {
12004
+ server.once("error", reject);
12005
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
12006
+ const address = server.address();
12007
+ const port = typeof address === "object" && address ? address.port : opts.port;
12008
+ resolve(`http://127.0.0.1:${port}/`);
12009
+ });
12010
+ });
12011
+ opts.onReady?.(url);
12012
+ if (opts.open !== false && process.platform === "darwin") {
12013
+ (0, import_node_child_process3.spawn)("open", [url], { stdio: "ignore", detached: true }).unref();
9090
12014
  }
9091
12015
  return {
9092
- name,
9093
- provider: "pixellab",
9094
- styles: {
9095
- [styleId]: {
9096
- generator,
9097
- size: commonSize,
9098
- // Matches the schema default: a scaffolded style is for sprites, and
9099
- // a sprite wants its background stripped.
9100
- noBackground: true,
9101
- promptPrefix: "",
9102
- promptSuffix: "",
9103
- styleImages: [],
9104
- palette: [],
9105
- outDir,
9106
- tags: [name],
9107
- providerOptions: {}
12016
+ url,
12017
+ session,
12018
+ close: () => new Promise((resolve, reject) => {
12019
+ server.closeAllConnections?.();
12020
+ server.close((err) => err ? reject(err) : resolve());
12021
+ })
12022
+ };
12023
+ }
12024
+
12025
+ // src/gallery/generate.ts
12026
+ var import_node_crypto8 = require("crypto");
12027
+ var import_zod8 = require("zod");
12028
+ var GenerateRequestSchema = import_zod8.z.object({
12029
+ project: import_zod8.z.string().min(1).optional(),
12030
+ keys: import_zod8.z.array(import_zod8.z.string().min(1)).min(1).max(500),
12031
+ /** Regenerate work that is up to date, as `gen --force` does. */
12032
+ force: import_zod8.z.boolean().optional(),
12033
+ /** Advance in-flight, review, or selected work without submitting anything. */
12034
+ resume: import_zod8.z.boolean().optional()
12035
+ }).strict();
12036
+ var GenerateRequestError = class extends Error {
12037
+ constructor(message6, status = 400) {
12038
+ super(message6);
12039
+ this.status = status;
12040
+ this.name = "GenerateRequestError";
12041
+ }
12042
+ status;
12043
+ };
12044
+ function message5(error) {
12045
+ return error instanceof Error ? error.message : String(error);
12046
+ }
12047
+ function remainingFor(group, groupCount, budget, spent) {
12048
+ const keyed = budget.byProvider[group.provider];
12049
+ if (keyed === void 0 && groupCount > 1 && budget.amount !== void 0) {
12050
+ throw new GenerateRequestError(
12051
+ "This run spans several providers; start the gallery with a keyed budget for each, for example --budget pixellab=40 --budget retrodiffusion=1.25."
12052
+ );
12053
+ }
12054
+ const ceiling = keyed ?? (groupCount === 1 ? budget.amount : void 0);
12055
+ if (ceiling === void 0) {
12056
+ throw new GenerateRequestError(
12057
+ `No session budget for ${group.provider}; start the gallery with --budget ${group.provider}=<amount>.`
12058
+ );
12059
+ }
12060
+ return Math.max(0, ceiling - (spent[group.provider] ?? 0));
12061
+ }
12062
+ function createGenerateHandlers(opts) {
12063
+ const log = opts.onProgress ?? (() => {
12064
+ });
12065
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
12066
+ const jobs = /* @__PURE__ */ new Map();
12067
+ const spent = {};
12068
+ const units = {};
12069
+ const contexts = /* @__PURE__ */ new Map();
12070
+ const reviews = /* @__PURE__ */ new Map();
12071
+ const busyKeys = () => {
12072
+ const busy = /* @__PURE__ */ new Map();
12073
+ for (const job of jobs.values()) {
12074
+ if (job.phase === "done" || job.phase === "failed") continue;
12075
+ for (const key of job.keys) busy.set(`${job.project ?? ""}:${key}`, job.id);
12076
+ }
12077
+ return busy;
12078
+ };
12079
+ const say = (job, line) => {
12080
+ job.messages.push(line);
12081
+ if (job.messages.length > 400) job.messages.splice(0, job.messages.length - 400);
12082
+ log(` [${job.id.slice(0, 6)}] ${line.trim()}`);
12083
+ };
12084
+ const groupByRecordedProvider = (specs, lock) => {
12085
+ const groups = /* @__PURE__ */ new Map();
12086
+ for (const spec of specs) {
12087
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
12088
+ const providerId = entry?.provider ?? spec.provider;
12089
+ groups.set(providerId, [...groups.get(providerId) ?? [], spec]);
12090
+ }
12091
+ return groups;
12092
+ };
12093
+ async function settle(job, ctx, specs) {
12094
+ job.phase = "polling";
12095
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
12096
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
12097
+ const res = await poll(provider, ctx.lock, ctx.lockPath, {
12098
+ onProgress: (line) => say(job, line),
12099
+ specs: providerSpecs,
12100
+ intervalMs: opts.pollIntervalMs
12101
+ });
12102
+ say(job, `${providerId}: ${res.completed} completed, ${res.review} awaiting review, ${res.failed} failed, ${res.stillRunning} still running`);
12103
+ }
12104
+ job.review = specs.map((spec) => lockKey(spec.styleId, spec.assetId)).filter((key) => ctx.lock.entries[key]?.status === "review");
12105
+ job.phase = "fetching";
12106
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
12107
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
12108
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
12109
+ onProgress: (line) => say(job, line)
12110
+ });
12111
+ job.counts.downloaded += res.downloaded;
12112
+ if (res.downloaded || res.failed) {
12113
+ say(job, `${providerId}: downloaded ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
12114
+ }
12115
+ }
12116
+ if (job.review.length) {
12117
+ job.phase = "review";
12118
+ say(job, `${job.review.length} asset(s) awaiting selection`);
12119
+ } else {
12120
+ job.phase = "done";
12121
+ job.finishedAt = now().toISOString();
12122
+ }
12123
+ }
12124
+ async function run(job, ctx, specs, groups) {
12125
+ try {
12126
+ if (job.mode === "generate") {
12127
+ job.phase = "submitting";
12128
+ for (const group of groups) {
12129
+ const provider = opts.providerFor(job.project ?? void 0, group.provider);
12130
+ const ceiling = remainingFor(group, groups.length, opts.budget, spent);
12131
+ if (provider.balance) {
12132
+ const balance = await provider.balance();
12133
+ say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
12134
+ if (balance.unit !== group.costUnit) {
12135
+ throw new Error(
12136
+ `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
12137
+ );
12138
+ }
12139
+ if (balance.unit !== "free" && group.cost > balance.remaining) {
12140
+ throw new Error(
12141
+ `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
12142
+ );
12143
+ }
12144
+ }
12145
+ const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
12146
+ budget: ceiling,
12147
+ onProgress: (line) => say(job, line),
12148
+ spacingMs: opts.submitSpacingMs
12149
+ });
12150
+ spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
12151
+ units[group.provider] = res.unit;
12152
+ job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
12153
+ job.counts.submitted += res.submitted;
12154
+ job.counts.failed += res.failed;
12155
+ say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
12156
+ }
12157
+ }
12158
+ await settle(job, ctx, specs);
12159
+ } catch (error) {
12160
+ job.phase = "failed";
12161
+ job.error = message5(error);
12162
+ job.finishedAt = now().toISOString();
12163
+ say(job, `failed: ${job.error}`);
12164
+ }
12165
+ }
12166
+ return {
12167
+ async start(body) {
12168
+ const parsed = GenerateRequestSchema.safeParse(body);
12169
+ if (!parsed.success) {
12170
+ throw new GenerateRequestError(
12171
+ "invalid request: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
12172
+ );
12173
+ }
12174
+ const request = parsed.data;
12175
+ const ctx = await opts.loadProject(request.project);
12176
+ const byKey = new Map(ctx.specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
12177
+ const specs = [];
12178
+ for (const key of request.keys) {
12179
+ const spec = byKey.get(key);
12180
+ if (!spec) throw new GenerateRequestError(`"${key}" is not declared by the manifest`);
12181
+ specs.push(spec);
9108
12182
  }
12183
+ const busy = busyKeys();
12184
+ for (const key of request.keys) {
12185
+ const holder = busy.get(`${request.project ?? ""}:${key}`);
12186
+ if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
12187
+ }
12188
+ let groups = [];
12189
+ if (!request.resume) {
12190
+ const plan = await buildPlan(specs, ctx.lock, { force: request.force });
12191
+ if (!plan.actionable.length) {
12192
+ const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
12193
+ throw new GenerateRequestError(`nothing to generate; ${reasons.join("; ")}`);
12194
+ }
12195
+ groups = plan.groups;
12196
+ for (const group of groups) {
12197
+ const remaining = remainingFor(group, groups.length, opts.budget, spent);
12198
+ if (group.cost > remaining) {
12199
+ throw new GenerateRequestError(
12200
+ `${group.provider} would spend ${formatCost(group.costUnit, group.cost)} but only ${formatCost(group.costUnit, remaining)} of this session's budget remain.`
12201
+ );
12202
+ }
12203
+ }
12204
+ }
12205
+ const job = {
12206
+ id: (0, import_node_crypto8.randomBytes)(8).toString("hex"),
12207
+ project: request.project ?? null,
12208
+ keys: request.keys,
12209
+ mode: request.resume ? "resume" : "generate",
12210
+ phase: "queued",
12211
+ startedAt: now().toISOString(),
12212
+ finishedAt: null,
12213
+ messages: [],
12214
+ error: null,
12215
+ spent: {},
12216
+ review: [],
12217
+ counts: { submitted: 0, failed: 0, downloaded: 0 }
12218
+ };
12219
+ jobs.set(job.id, job);
12220
+ contexts.set(job.id, ctx);
12221
+ 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("; "));
12222
+ void run(job, ctx, specs, groups);
12223
+ return job;
9109
12224
  },
9110
- assets
12225
+ status() {
12226
+ return {
12227
+ jobs: [...jobs.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt)),
12228
+ budget: opts.budget,
12229
+ spent,
12230
+ units
12231
+ };
12232
+ },
12233
+ async review(jobId, options) {
12234
+ const job = jobs.get(jobId);
12235
+ const ctx = contexts.get(jobId);
12236
+ if (!job || !ctx || job.phase !== "review" || !job.review.length) return null;
12237
+ const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
12238
+ const providers = [...groupByRecordedProvider(specs, ctx.lock).keys()];
12239
+ const providerId = providers[0];
12240
+ if (!providerId) return null;
12241
+ const session = await prepareReview(opts.providerFor(job.project ?? void 0, providerId), ctx.lock, {
12242
+ keys: job.review,
12243
+ specs,
12244
+ onProgress: (line) => say(job, line),
12245
+ routePrefix: options.routePrefix
12246
+ });
12247
+ if (session) reviews.set(jobId, session);
12248
+ return session;
12249
+ },
12250
+ async applyReview(jobId, body) {
12251
+ const job = jobs.get(jobId);
12252
+ const ctx = contexts.get(jobId);
12253
+ const session = reviews.get(jobId);
12254
+ if (!job || !ctx || !session) throw new GenerateRequestError("no review is open for this job", 404);
12255
+ const result = await session.apply(body, ctx.lockPath);
12256
+ say(job, `selected ${result.selected}, left in review ${result.skipped}`);
12257
+ reviews.delete(jobId);
12258
+ const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
12259
+ void settle(job, ctx, specs).catch((error) => {
12260
+ job.phase = "failed";
12261
+ job.error = message5(error);
12262
+ job.finishedAt = now().toISOString();
12263
+ say(job, `failed: ${job.error}`);
12264
+ });
12265
+ return result;
12266
+ }
9111
12267
  };
9112
12268
  }
9113
- function mode(values) {
9114
- const counts = /* @__PURE__ */ new Map();
9115
- for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
9116
- return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
9117
- }
9118
12269
 
9119
12270
  // src/pipeline/salvage.ts
9120
- var import_promises16 = require("fs/promises");
9121
- var import_node_fs18 = require("fs");
9122
- var import_node_path21 = __toESM(require("path"), 1);
12271
+ var import_promises21 = require("fs/promises");
12272
+ var import_node_fs21 = require("fs");
12273
+ var import_node_path24 = __toESM(require("path"), 1);
9123
12274
  function providerClaimId(provider, objectId) {
9124
12275
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
9125
12276
  }
9126
12277
  async function loadClaims(lockPaths, opts = {}) {
9127
12278
  const claimed = /* @__PURE__ */ new Set();
9128
12279
  for (const p of lockPaths) {
9129
- if (!(0, import_node_fs18.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
12280
+ if (!(0, import_node_fs21.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
9130
12281
  let parsed;
9131
12282
  try {
9132
- parsed = parseLock(JSON.parse(await (0, import_promises16.readFile)(p, "utf8")));
12283
+ parsed = parseLock(JSON.parse(await (0, import_promises21.readFile)(p, "utf8")));
9133
12284
  } catch {
9134
12285
  throw new Error(`Claim lockfile is malformed: ${p}`);
9135
12286
  }
@@ -9182,20 +12333,20 @@ function matchOrphanStyle(prompt, manifest) {
9182
12333
  return matchStyleByPattern(prompt, manifest);
9183
12334
  }
9184
12335
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
9185
- const own = import_node_path21.default.resolve(ownManifestPath);
12336
+ const own = import_node_path24.default.resolve(ownManifestPath);
9186
12337
  const siblingManifestPaths = [
9187
12338
  .../* @__PURE__ */ new Set([
9188
12339
  ...workspaceManifestPaths,
9189
- ...claimPaths.map((c) => import_node_path21.default.join(import_node_path21.default.dirname(import_node_path21.default.resolve(c)), "pixelkiln.manifest.json"))
12340
+ ...claimPaths.map((c) => import_node_path24.default.join(import_node_path24.default.dirname(import_node_path24.default.resolve(c)), "pixelkiln.manifest.json"))
9190
12341
  ])
9191
12342
  ];
9192
12343
  const siblings = [];
9193
12344
  for (const siblingManifestPath of siblingManifestPaths) {
9194
- if (import_node_path21.default.resolve(siblingManifestPath) === own) continue;
9195
- if (!(0, import_node_fs18.existsSync)(siblingManifestPath)) continue;
12345
+ if (import_node_path24.default.resolve(siblingManifestPath) === own) continue;
12346
+ if (!(0, import_node_fs21.existsSync)(siblingManifestPath)) continue;
9196
12347
  try {
9197
12348
  const { manifest } = await loadManifest(siblingManifestPath);
9198
- siblings.push({ label: import_node_path21.default.basename(import_node_path21.default.dirname(siblingManifestPath)), manifest });
12349
+ siblings.push({ label: import_node_path24.default.basename(import_node_path24.default.dirname(siblingManifestPath)), manifest });
9199
12350
  } catch {
9200
12351
  }
9201
12352
  }
@@ -9311,16 +12462,16 @@ async function applyTags(provider, decisions, existing, opts = {}) {
9311
12462
  }
9312
12463
 
9313
12464
  // src/pick/salvage-server.ts
9314
- var import_promises17 = require("fs/promises");
9315
- var import_node_path22 = __toESM(require("path"), 1);
12465
+ var import_promises22 = require("fs/promises");
12466
+ var import_node_path25 = __toESM(require("path"), 1);
9316
12467
 
9317
12468
  // src/pick/salvage-sheet.ts
9318
- var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
12469
+ var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9319
12470
  function renderSalvageSheet(orphans, ctx) {
9320
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml2(o.prompt) }));
12471
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt) }));
9321
12472
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
9322
- const styleId = ctx ? escapeHtml2(ctx.styleId) : null;
9323
- const importDir = ctx ? escapeHtml2(ctx.importDir) : null;
12473
+ const styleId = ctx ? escapeHtml3(ctx.styleId) : null;
12474
+ const importDir = ctx ? escapeHtml3(ctx.importDir) : null;
9324
12475
  return `<!doctype html>
9325
12476
  <html lang="en">
9326
12477
  <head>
@@ -9477,7 +12628,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9477
12628
  });
9478
12629
  const html = renderSalvageSheet(orphans, {
9479
12630
  styleId: ctx.styleId,
9480
- importDir: import_node_path22.default.relative(process.cwd(), ctx.importDir) || "."
12631
+ importDir: import_node_path25.default.relative(process.cwd(), ctx.importDir) || "."
9481
12632
  });
9482
12633
  const byId = new Map(orphans.map((o) => [o.id, o]));
9483
12634
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -9506,10 +12657,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9506
12657
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
9507
12658
  decodePng(buf);
9508
12659
  const assetId = idFromPrompt(orphan.prompt, taken);
9509
- const rel = import_node_path22.default.join("_salvaged", `${assetId}.png`);
9510
- const outFile = import_node_path22.default.resolve(ctx.importDir, rel);
9511
- await (0, import_promises17.mkdir)(import_node_path22.default.dirname(outFile), { recursive: true });
9512
- await (0, import_promises17.writeFile)(outFile, buf);
12660
+ const rel = import_node_path25.default.join("_salvaged", `${assetId}.png`);
12661
+ const outFile = import_node_path25.default.resolve(ctx.importDir, rel);
12662
+ await (0, import_promises22.mkdir)(import_node_path25.default.dirname(outFile), { recursive: true });
12663
+ await (0, import_promises22.writeFile)(outFile, buf);
9513
12664
  ctx.manifest.assets[assetId] = {
9514
12665
  prompt: orphan.prompt,
9515
12666
  promptByStyle: {},
@@ -9540,7 +12691,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9540
12691
  sourceUrl: durableSource,
9541
12692
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
9542
12693
  outputs: [{
9543
- path: portableOutputPath(outFile, import_node_path22.default.dirname(ctx.manifestPath)),
12694
+ path: portableOutputPath(outFile, import_node_path25.default.dirname(ctx.manifestPath)),
9544
12695
  sha256: sha256(buf)
9545
12696
  }],
9546
12697
  submittedAt: orphan.createdAt,
@@ -9564,148 +12715,15 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9564
12715
  }
9565
12716
  }
9566
12717
  await applyTags(provider, decisions, existingTags, { onProgress: log });
9567
- const raw = JSON.parse(await (0, import_promises17.readFile)(ctx.manifestPath, "utf8"));
12718
+ const raw = JSON.parse(await (0, import_promises22.readFile)(ctx.manifestPath, "utf8"));
9568
12719
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
9569
- await (0, import_promises17.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12720
+ await (0, import_promises22.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
9570
12721
  await saveLock(ctx.lockPath, ctx.lock);
9571
12722
  return result;
9572
12723
  }
9573
12724
  });
9574
12725
  }
9575
12726
 
9576
- // src/workspace.ts
9577
- var import_promises18 = require("fs/promises");
9578
- var import_node_fs19 = require("fs");
9579
- var import_node_path23 = __toESM(require("path"), 1);
9580
- var import_zod6 = require("zod");
9581
- var WorkspaceProjectSchema = import_zod6.z.object({
9582
- id: import_zod6.z.string().min(1),
9583
- /** Manifest path, relative to the catalog file's own directory. */
9584
- manifest: import_zod6.z.string().min(1),
9585
- /** Lockfile path, relative to the catalog file's own directory. */
9586
- lock: import_zod6.z.string().min(1),
9587
- provider: import_zod6.z.string().min(1).default("pixellab"),
9588
- /** Free-form label for a shared account, e.g. distinguishing sandboxes. */
9589
- account: import_zod6.z.string().optional()
9590
- }).strict();
9591
- var WorkspaceSchema = import_zod6.z.object({
9592
- version: import_zod6.z.literal(1),
9593
- projects: import_zod6.z.array(WorkspaceProjectSchema).default([])
9594
- }).strict();
9595
- function parseWorkspace(raw) {
9596
- const parsed = WorkspaceSchema.safeParse(raw);
9597
- if (parsed.success) return parsed.data;
9598
- throw new Error(
9599
- `Workspace catalog is not valid v1:
9600
- ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
9601
- );
9602
- }
9603
- async function loadWorkspace(workspacePath) {
9604
- if (!(0, import_node_fs19.existsSync)(workspacePath)) return { version: 1, projects: [] };
9605
- let raw;
9606
- try {
9607
- raw = JSON.parse(await (0, import_promises18.readFile)(workspacePath, "utf8"));
9608
- } catch (err) {
9609
- throw new Error(
9610
- `Workspace catalog at ${workspacePath} is malformed:
9611
- ${err instanceof Error ? err.message : String(err)}`
9612
- );
9613
- }
9614
- return parseWorkspace(raw);
9615
- }
9616
- async function saveWorkspace(workspacePath, ws) {
9617
- const sorted = {
9618
- version: 1,
9619
- projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
9620
- };
9621
- await (0, import_promises18.mkdir)(import_node_path23.default.dirname(import_node_path23.default.resolve(workspacePath)), { recursive: true });
9622
- const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9623
- try {
9624
- await (0, import_promises18.writeFile)(tmp, JSON.stringify(sorted, null, 2) + "\n");
9625
- await (0, import_promises18.rename)(tmp, workspacePath);
9626
- } finally {
9627
- await (0, import_promises18.rm)(tmp, { force: true });
9628
- }
9629
- }
9630
- function toPortablePath(dir, absolute) {
9631
- return import_node_path23.default.relative(dir, absolute).split(import_node_path23.default.sep).join("/");
9632
- }
9633
- function resolveProject(dir, project) {
9634
- return {
9635
- manifestPath: import_node_path23.default.resolve(dir, project.manifest.split("/").join(import_node_path23.default.sep)),
9636
- lockPath: import_node_path23.default.resolve(dir, project.lock.split("/").join(import_node_path23.default.sep))
9637
- };
9638
- }
9639
- function validateWorkspace(ws, dir) {
9640
- const diagnostics = [];
9641
- const idCounts = /* @__PURE__ */ new Map();
9642
- const lockOwners = /* @__PURE__ */ new Map();
9643
- const manifestOwners = /* @__PURE__ */ new Map();
9644
- for (const project of ws.projects) {
9645
- idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
9646
- const { manifestPath, lockPath } = resolveProject(dir, project);
9647
- lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
9648
- manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
9649
- if (import_node_path23.default.isAbsolute(project.manifest) || import_node_path23.default.isAbsolute(project.lock)) {
9650
- diagnostics.push({
9651
- id: "absolute-path",
9652
- level: "warning",
9653
- message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
9654
- });
9655
- }
9656
- if (!(0, import_node_fs19.existsSync)(manifestPath)) {
9657
- diagnostics.push({
9658
- id: "missing-manifest",
9659
- level: "error",
9660
- message: `project "${project.id}" manifest not found: ${manifestPath}`
9661
- });
9662
- }
9663
- if (!(0, import_node_fs19.existsSync)(lockPath)) {
9664
- diagnostics.push({
9665
- id: "missing-lock",
9666
- level: "error",
9667
- message: `project "${project.id}" lockfile not found: ${lockPath}`
9668
- });
9669
- }
9670
- }
9671
- for (const [id, count] of idCounts) {
9672
- if (count > 1) {
9673
- diagnostics.push({
9674
- id: "duplicate-id",
9675
- level: "error",
9676
- message: `project id "${id}" is registered ${count} times`
9677
- });
9678
- }
9679
- }
9680
- for (const [lockPath, ids] of lockOwners) {
9681
- if (ids.length > 1) {
9682
- diagnostics.push({
9683
- id: "duplicate-lock",
9684
- level: "error",
9685
- message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
9686
- });
9687
- }
9688
- }
9689
- for (const [manifestPath, ids] of manifestOwners) {
9690
- if (ids.length > 1) {
9691
- diagnostics.push({
9692
- id: "duplicate-manifest",
9693
- level: "warning",
9694
- message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
9695
- });
9696
- }
9697
- }
9698
- const providers = new Set(ws.projects.map((p) => p.provider));
9699
- if (providers.size > 1) {
9700
- diagnostics.push({
9701
- id: "mixed-provider",
9702
- level: "warning",
9703
- message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
9704
- });
9705
- }
9706
- return diagnostics;
9707
- }
9708
-
9709
12727
  // src/pipeline/workspace.ts
9710
12728
  async function workspaceClaims(ws, dir) {
9711
12729
  const lockPaths = [];
@@ -9816,6 +12834,8 @@ async function workspaceStatus(ws, dir) {
9816
12834
  DEFAULT_RATE_LIMIT,
9817
12835
  FAKE_PNG,
9818
12836
  FakeProvider,
12837
+ GenerateRequestError,
12838
+ GenerateRequestSchema,
9819
12839
  GeneratorSchema,
9820
12840
  GridConfidenceSchema,
9821
12841
  ImageQualityMetricsSchema,
@@ -9824,6 +12844,9 @@ async function workspaceStatus(ws, dir) {
9824
12844
  LockSchema,
9825
12845
  MAX_DOWNLOAD_BYTES,
9826
12846
  MAX_RETRIES,
12847
+ ManifestDriftError,
12848
+ ManifestEditError,
12849
+ ManifestEditSchema,
9827
12850
  ManifestInputSchema,
9828
12851
  ManifestSchema,
9829
12852
  MediaType,
@@ -9846,13 +12869,16 @@ async function workspaceStatus(ws, dir) {
9846
12869
  WorkspaceProjectSchema,
9847
12870
  WorkspaceSchema,
9848
12871
  adopt,
12872
+ applyManifestEdit,
9849
12873
  applyTags,
9850
12874
  approveQualityRecord,
9851
12875
  auditStyle,
9852
12876
  availableProviders,
9853
12877
  backoffMs,
12878
+ buildGallerySnapshot,
9854
12879
  buildManifest,
9855
12880
  buildPlan,
12881
+ buildWorkspaceGallerySnapshot,
9856
12882
  bundledRecipeRoot,
9857
12883
  cacheFileName,
9858
12884
  candidateCount,
@@ -9862,6 +12888,8 @@ async function workspaceStatus(ws, dir) {
9862
12888
  colorDistance,
9863
12889
  countNumberedDescriptions,
9864
12890
  createArtifactBundleManifest,
12891
+ createGalleryEditHandler,
12892
+ createGenerateHandlers,
9865
12893
  createProvider,
9866
12894
  currentEntryOutputPath,
9867
12895
  currentOutputPath,
@@ -9874,6 +12902,8 @@ async function workspaceStatus(ws, dir) {
9874
12902
  fetchAssets,
9875
12903
  findOrphans,
9876
12904
  formatCost,
12905
+ galleryMediaId,
12906
+ galleryMediaRoute,
9877
12907
  generationCost,
9878
12908
  groupOrphansByStyle,
9879
12909
  hex,
@@ -9913,6 +12943,7 @@ async function workspaceStatus(ws, dir) {
9913
12943
  pngSize,
9914
12944
  poll,
9915
12945
  portableOutputPath,
12946
+ prepareReview,
9916
12947
  primaryOutput,
9917
12948
  providerCredentialEnvs,
9918
12949
  providerFactory,
@@ -9928,6 +12959,7 @@ async function workspaceStatus(ws, dir) {
9928
12959
  refineQualityProfiles,
9929
12960
  registerProvider,
9930
12961
  remove,
12962
+ renderGallery,
9931
12963
  renderSalvageSheet,
9932
12964
  renderSheet,
9933
12965
  requireApprovedQualitySources,
@@ -9956,6 +12988,7 @@ async function workspaceStatus(ws, dir) {
9956
12988
  saveWorkspace,
9957
12989
  scanAssets,
9958
12990
  selectEntryOutput,
12991
+ serveGallery,
9959
12992
  sha256,
9960
12993
  sha256File,
9961
12994
  shouldPersistSourceUrl,