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.js CHANGED
@@ -509,8 +509,8 @@ function validateResponse(schema, raw, operation) {
509
509
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
510
510
  }
511
511
  var PixelLabError = class extends Error {
512
- constructor(message5, status, body) {
513
- super(message5);
512
+ constructor(message6, status, body) {
513
+ super(message6);
514
514
  this.status = status;
515
515
  this.body = body;
516
516
  this.name = "PixelLabError";
@@ -535,11 +535,11 @@ var PixelLabClient = class {
535
535
  * common than the failure mode of retrying (a duplicate object), and a
536
536
  * duplicate is visible and free to delete whereas a silent gap is neither.
537
537
  */
538
- async request(path23, init, attempt = 0) {
538
+ async request(path25, init, attempt = 0) {
539
539
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
540
540
  let res;
541
541
  try {
542
- res = await fetch(`${BASE}${path23}`, {
542
+ res = await fetch(`${BASE}${path25}`, {
543
543
  ...init,
544
544
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
545
545
  headers: {
@@ -551,24 +551,24 @@ var PixelLabClient = class {
551
551
  } catch (err) {
552
552
  if (attempt < MAX_RETRIES) {
553
553
  await sleep(backoffMs(attempt));
554
- return this.request(path23, init, attempt + 1);
554
+ return this.request(path25, init, attempt + 1);
555
555
  }
556
556
  throw err;
557
557
  }
558
558
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
559
559
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
560
560
  await sleep(waitMs);
561
- return this.request(path23, init, attempt + 1);
561
+ return this.request(path25, init, attempt + 1);
562
562
  }
563
563
  const text = await res.text();
564
564
  if (!res.ok) {
565
- throw new PixelLabError(`${init?.method ?? "GET"} ${path23} \u2192 ${res.status}`, res.status, text);
565
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path25} \u2192 ${res.status}`, res.status, text);
566
566
  }
567
567
  if (!text) return {};
568
568
  try {
569
569
  return JSON.parse(text);
570
570
  } catch {
571
- throw new Error(`${init?.method ?? "GET"} ${path23} returned invalid JSON`);
571
+ throw new Error(`${init?.method ?? "GET"} ${path25} returned invalid JSON`);
572
572
  }
573
573
  }
574
574
  async balance() {
@@ -1615,9 +1615,9 @@ var RetroDiffusionClient = class {
1615
1615
  }
1616
1616
  return balance;
1617
1617
  }
1618
- async call(path23, init = {}) {
1618
+ async call(path25, init = {}) {
1619
1619
  if (!this.token) throw new Error("RD_API_KEY is not set");
1620
- const response = await this.request(`${this.baseUrl}${path23}`, {
1620
+ const response = await this.request(`${this.baseUrl}${path25}`, {
1621
1621
  ...init,
1622
1622
  headers: {
1623
1623
  "Content-Type": "application/json",
@@ -1919,8 +1919,8 @@ function retroError(value) {
1919
1919
  if (details.length) return details.join("; ");
1920
1920
  }
1921
1921
  if (record.detail && typeof record.detail === "object") {
1922
- const message5 = record.detail.message;
1923
- if (typeof message5 === "string") return message5;
1922
+ const message6 = record.detail.message;
1923
+ if (typeof message6 === "string") return message6;
1924
1924
  }
1925
1925
  return JSON.stringify(value);
1926
1926
  }
@@ -2376,13 +2376,13 @@ function validProgress(progress) {
2376
2376
  }
2377
2377
  return Math.round(progress * 100);
2378
2378
  }
2379
- function finiteNonnegative(value, message5) {
2380
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(message5);
2379
+ function finiteNonnegative(value, message6) {
2380
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(message6);
2381
2381
  return value;
2382
2382
  }
2383
- function optionalFiniteNonnegative(value, message5) {
2383
+ function optionalFiniteNonnegative(value, message6) {
2384
2384
  if (value == null) return 0;
2385
- return finiteNonnegative(value, message5);
2385
+ return finiteNonnegative(value, message6);
2386
2386
  }
2387
2387
  function numericRecord(value) {
2388
2388
  if (value == null) return {};
@@ -2420,10 +2420,10 @@ import { createReadStream } from "fs";
2420
2420
  function sha256(data) {
2421
2421
  return createHash("sha256").update(data).digest("hex");
2422
2422
  }
2423
- async function sha256File(path23) {
2423
+ async function sha256File(path25) {
2424
2424
  return new Promise((resolve, reject) => {
2425
2425
  const hash = createHash("sha256");
2426
- const stream = createReadStream(path23);
2426
+ const stream = createReadStream(path25);
2427
2427
  stream.on("data", (chunk2) => hash.update(chunk2));
2428
2428
  stream.on("error", reject);
2429
2429
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -3312,9 +3312,9 @@ function normalizeBaseUrl(value) {
3312
3312
  }
3313
3313
  function historyError(status) {
3314
3314
  const messages = Array.isArray(status.messages) ? status.messages : [];
3315
- for (const message5 of messages) {
3316
- if (!Array.isArray(message5) || message5[0] !== "execution_error" || !isObject(message5[1])) continue;
3317
- const detail = message5[1].exception_message;
3315
+ for (const message6 of messages) {
3316
+ if (!Array.isArray(message6) || message6[0] !== "execution_error" || !isObject(message6[1])) continue;
3317
+ const detail = message6[1].exception_message;
3318
3318
  if (typeof detail === "string" && detail.trim()) {
3319
3319
  return `ComfyUI execution failed: ${detail.trim().slice(0, 300)}`;
3320
3320
  }
@@ -6746,10 +6746,10 @@ function isSha256Hash(value) {
6746
6746
  function parseCache(value) {
6747
6747
  return HashCacheSchema.parse(value);
6748
6748
  }
6749
- async function loadCache(path23) {
6750
- if (!existsSync10(path23)) return { version: 1, hashes: {} };
6749
+ async function loadCache(path25) {
6750
+ if (!existsSync10(path25)) return { version: 1, hashes: {} };
6751
6751
  try {
6752
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path23, "utf8")));
6752
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path25, "utf8")));
6753
6753
  if (!parsed.success) return { version: 1, hashes: {} };
6754
6754
  return {
6755
6755
  version: 1,
@@ -6761,18 +6761,18 @@ async function loadCache(path23) {
6761
6761
  return { version: 1, hashes: {} };
6762
6762
  }
6763
6763
  }
6764
- async function saveCache(path23, cache) {
6764
+ async function saveCache(path25, cache) {
6765
6765
  const sorted = {};
6766
6766
  for (const key of Object.keys(cache.hashes).sort()) {
6767
6767
  const hash = cache.hashes[key];
6768
6768
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6769
6769
  sorted[key] = hash;
6770
6770
  }
6771
- await mkdir3(pathModule.dirname(pathModule.resolve(path23)), { recursive: true });
6772
- const tmp = `${path23}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6771
+ await mkdir3(pathModule.dirname(pathModule.resolve(path25)), { recursive: true });
6772
+ const tmp = `${path25}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6773
6773
  try {
6774
6774
  await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6775
- await rename3(tmp, path23);
6775
+ await rename3(tmp, path25);
6776
6776
  } finally {
6777
6777
  await rm4(tmp, { force: true });
6778
6778
  }
@@ -7603,7 +7603,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7603
7603
  ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
7604
7604
  );
7605
7605
  } catch (err) {
7606
- const message5 = err instanceof Error ? err.message : String(err);
7606
+ const message6 = err instanceof Error ? err.message : String(err);
7607
7607
  const entry = lock.entries[key];
7608
7608
  if (entry.jobId && entry.submissionComplete === true) {
7609
7609
  upsert(lock, key, { status: "processing", error: null });
@@ -7615,10 +7615,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7615
7615
  failed++;
7616
7616
  upsert(lock, key, {
7617
7617
  status: "failed",
7618
- error: message5,
7618
+ error: message6,
7619
7619
  cost: entry.jobId ? estimate.amount : 0
7620
7620
  });
7621
- log(` FAILED ${key}: ${message5}`);
7621
+ log(` FAILED ${key}: ${message6}`);
7622
7622
  }
7623
7623
  }
7624
7624
  await saveLock(lockPath, lock);
@@ -7847,14 +7847,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7847
7847
  });
7848
7848
  result.downloaded++;
7849
7849
  } catch (err) {
7850
- const message5 = err instanceof Error ? err.message : String(err);
7850
+ const message6 = err instanceof Error ? err.message : String(err);
7851
7851
  upsert(lock, key, {
7852
7852
  status: "download-failed",
7853
7853
  outputs: mergeOutputs(entry.outputs, outputs),
7854
- error: `download failed: ${message5}`
7854
+ error: `download failed: ${message6}`
7855
7855
  });
7856
7856
  result.failed++;
7857
- log(` FAILED ${key}: ${message5}`);
7857
+ log(` FAILED ${key}: ${message6}`);
7858
7858
  }
7859
7859
  await saveLock(lockPath, lock);
7860
7860
  }
@@ -7924,7 +7924,7 @@ import { access, stat as stat2 } from "fs/promises";
7924
7924
  import path17 from "path";
7925
7925
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
7926
7926
  const checks = [];
7927
- const add = (id, level, message5) => checks.push({ id, level, message: message5 });
7927
+ const add = (id, level, message6) => checks.push({ id, level, message: message6 });
7928
7928
  add(
7929
7929
  "manifest",
7930
7930
  "ok",
@@ -8228,9 +8228,107 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
8228
8228
  return count;
8229
8229
  }
8230
8230
 
8231
+ // src/pick/server.ts
8232
+ import { existsSync as existsSync16 } from "fs";
8233
+ import { readFile as readFile15 } from "fs/promises";
8234
+
8235
+ // src/pipeline/init.ts
8236
+ import { readdir as readdir3, readFile as readFile13, writeFile as writeFile6 } from "fs/promises";
8237
+ import { existsSync as existsSync15 } from "fs";
8238
+ import path19 from "path";
8239
+ function pngSize(buf) {
8240
+ if (buf.length < 24) return null;
8241
+ const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
8242
+ if (!isPng) return null;
8243
+ if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
8244
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
8245
+ }
8246
+ function slugify(value) {
8247
+ return value.replace(/\.png$/i, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
8248
+ }
8249
+ async function walk(dir, exclude) {
8250
+ const out = [];
8251
+ for (const entry of await readdir3(dir, { withFileTypes: true })) {
8252
+ const full = path19.join(dir, entry.name);
8253
+ if (exclude.some((x) => full.includes(x))) continue;
8254
+ if (entry.isDirectory()) out.push(...await walk(full, exclude));
8255
+ else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
8256
+ }
8257
+ return out;
8258
+ }
8259
+ async function scanAssets(root, opts = {}) {
8260
+ const exclude = opts.exclude ?? [];
8261
+ const files = await walk(root, exclude);
8262
+ const assets = [];
8263
+ const skipped = [];
8264
+ const seen = /* @__PURE__ */ new Map();
8265
+ for (const file of files.sort()) {
8266
+ const size = pngSize(await readFile13(file));
8267
+ if (!size) {
8268
+ skipped.push(`${file} (not a readable PNG)`);
8269
+ continue;
8270
+ }
8271
+ const rel = path19.relative(root, file);
8272
+ const category = path19.dirname(rel) === "." ? "" : path19.dirname(rel);
8273
+ let id = slugify(path19.basename(rel));
8274
+ if (seen.has(id)) {
8275
+ const n = seen.get(id) + 1;
8276
+ seen.set(id, n);
8277
+ id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
8278
+ } else {
8279
+ seen.set(id, 1);
8280
+ }
8281
+ assets.push({ id, category, file: rel, width: size.width, height: size.height });
8282
+ }
8283
+ return { assets, skipped };
8284
+ }
8285
+ function buildManifest(name, styleId, generator, outDir, scanned) {
8286
+ const squares = scanned.filter((a) => a.width === a.height);
8287
+ const commonSize = squares.length ? mode(squares.map((a) => a.width)) : 64;
8288
+ const assets = {};
8289
+ for (const asset of scanned) {
8290
+ assets[asset.id] = {
8291
+ prompt: "",
8292
+ promptByStyle: {},
8293
+ category: asset.category || void 0,
8294
+ file: asset.file,
8295
+ tags: [],
8296
+ styles: [],
8297
+ providerInputs: {},
8298
+ ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
8299
+ };
8300
+ }
8301
+ return {
8302
+ name,
8303
+ provider: "pixellab",
8304
+ styles: {
8305
+ [styleId]: {
8306
+ generator,
8307
+ size: commonSize,
8308
+ // Matches the schema default: a scaffolded style is for sprites, and
8309
+ // a sprite wants its background stripped.
8310
+ noBackground: true,
8311
+ promptPrefix: "",
8312
+ promptSuffix: "",
8313
+ styleImages: [],
8314
+ palette: [],
8315
+ outDir,
8316
+ tags: [name],
8317
+ providerOptions: {}
8318
+ }
8319
+ },
8320
+ assets
8321
+ };
8322
+ }
8323
+ function mode(values) {
8324
+ const counts = /* @__PURE__ */ new Map();
8325
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
8326
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
8327
+ }
8328
+
8231
8329
  // src/pick/sheet.ts
8232
8330
  var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8233
- function renderSheet(groups) {
8331
+ function renderSheet(groups, options = {}) {
8234
8332
  const safe = groups.map((g) => ({
8235
8333
  ...g,
8236
8334
  styleId: escapeHtml(g.styleId),
@@ -8244,6 +8342,11 @@ function renderSheet(groups) {
8244
8342
  } : {}
8245
8343
  }));
8246
8344
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
8345
+ const apply = JSON.stringify({
8346
+ url: options.applyUrl ?? "/apply",
8347
+ headers: options.applyHeaders ?? {},
8348
+ embedded: options.embedded === true
8349
+ }).replace(/<\//g, "<\\/");
8247
8350
  return `<!doctype html>
8248
8351
  <html lang="en">
8249
8352
  <head>
@@ -8286,7 +8389,7 @@ function renderSheet(groups) {
8286
8389
  align-items:start; }
8287
8390
  .review.no-context { display:block; }
8288
8391
  .context { border:1px solid var(--line); padding:8px; background:var(--panel-deep);
8289
- min-width:0; }
8392
+ min-width:0; display:grid; gap:12px; align-content:start; }
8290
8393
  .context-label { display:flex; justify-content:space-between; gap:8px; margin-bottom:7px;
8291
8394
  color:var(--dim); font-size:10.5px; font-variant-numeric:tabular-nums; }
8292
8395
  .context img { display:block; width:100%; height:auto; max-height:420px; object-fit:contain;
@@ -8355,6 +8458,7 @@ function renderSheet(groups) {
8355
8458
  </footer>
8356
8459
  <script>
8357
8460
  const GROUPS = ${data};
8461
+ const APPLY = ${apply};
8358
8462
  const picks = new Map();
8359
8463
  const root = document.getElementById('root');
8360
8464
  document.getElementById('total').textContent = GROUPS.length;
@@ -8375,24 +8479,40 @@ GROUPS.forEach((g, gi) => {
8375
8479
  '<div class="ghead"><span class="style">' + g.styleId + '</span>' +
8376
8480
  '<span class="aid">' + g.assetId + '</span>' +
8377
8481
  '<span class="prompt">' + g.prompt + '</span></div>' +
8378
- '<div class="review' + (g.revision ? '' : ' no-context') + '">' +
8482
+ '<div class="review' + (g.revision || g.current ? '' : ' no-context') + '">' +
8379
8483
  '<div class="frames"></div></div>';
8380
8484
  const review = el.querySelector('.review');
8381
8485
  const frames = el.querySelector('.frames');
8382
- if (g.revision) {
8383
- const context = document.createElement('aside');
8384
- context.className = 'context';
8486
+ const contextPanel = (identityText, dimensionsText, url, alt) => {
8487
+ const block = document.createElement('div');
8385
8488
  const label = document.createElement('div');
8386
8489
  label.className = 'context-label';
8387
8490
  const identity = document.createElement('span');
8388
- identity.textContent = g.revision.mode.toUpperCase() + ' \xB7 SOURCE ' + g.revision.sourceAssetId;
8491
+ identity.textContent = identityText;
8389
8492
  const dimensions = document.createElement('span');
8390
- dimensions.textContent = g.revision.width + '\xD7' + g.revision.height;
8493
+ dimensions.textContent = dimensionsText;
8391
8494
  label.append(identity, dimensions);
8392
- const source = document.createElement('img');
8393
- source.src = g.revision.sourceUrl;
8394
- source.alt = 'Revision source ' + g.revision.sourceAssetId;
8395
- context.append(label, source);
8495
+ const img = document.createElement('img');
8496
+ img.src = url;
8497
+ img.alt = alt;
8498
+ block.append(label, img);
8499
+ return block;
8500
+ };
8501
+ if (g.revision || g.current) {
8502
+ const context = document.createElement('aside');
8503
+ context.className = 'context';
8504
+ if (g.revision) {
8505
+ context.append(contextPanel(
8506
+ g.revision.mode.toUpperCase() + ' \xB7 SOURCE ' + g.revision.sourceAssetId,
8507
+ g.revision.width + '\xD7' + g.revision.height,
8508
+ g.revision.sourceUrl, 'Revision source ' + g.revision.sourceAssetId));
8509
+ }
8510
+ if (g.current) {
8511
+ context.append(contextPanel(
8512
+ 'CURRENT ART \xB7 replaced if you choose',
8513
+ g.current.width + '\xD7' + g.current.height,
8514
+ g.current.url, 'Current ' + g.assetId));
8515
+ }
8396
8516
  review.insertBefore(context, frames);
8397
8517
  }
8398
8518
  if (g.mode === 'frame-set') {
@@ -8576,13 +8696,17 @@ document.getElementById('submit').onclick = async () => {
8576
8696
  status.textContent = 'applying\u2026';
8577
8697
  const selections = [...picks.entries()].map(([gi, index]) => ({ key: GROUPS[gi].key, index }));
8578
8698
  try {
8579
- const res = await fetch('/apply', {
8580
- method: 'POST', headers: { 'Content-Type': 'application/json' },
8699
+ const res = await fetch(APPLY.url, {
8700
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...APPLY.headers },
8581
8701
  body: JSON.stringify({ selections }),
8582
8702
  });
8583
8703
  if (!res.ok) throw new Error(await res.text());
8584
- status.textContent = 'done \u2014 you can close this tab';
8704
+ const result = await res.json();
8705
+ status.textContent = APPLY.embedded ? 'applied' : 'done \u2014 you can close this tab';
8585
8706
  document.body.style.opacity = '.6';
8707
+ if (APPLY.embedded && window.parent !== window) {
8708
+ window.parent.postMessage({ type: 'pixelkiln:review-applied', result }, location.origin);
8709
+ }
8586
8710
  } catch (err) {
8587
8711
  status.textContent = 'failed: ' + err.message;
8588
8712
  btn.disabled = false;
@@ -8596,7 +8720,7 @@ document.getElementById('submit').onclick = async () => {
8596
8720
  // src/pick/review-server.ts
8597
8721
  import { createServer } from "http";
8598
8722
  import { spawn } from "child_process";
8599
- import { readFile as readFile13 } from "fs/promises";
8723
+ import { readFile as readFile14 } from "fs/promises";
8600
8724
  function serveReviewPage(opts) {
8601
8725
  const log = opts.onProgress ?? (() => {
8602
8726
  });
@@ -8605,7 +8729,7 @@ function serveReviewPage(opts) {
8605
8729
  if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
8606
8730
  try {
8607
8731
  const asset = opts.assets.get(req.url);
8608
- const bytes = await readFile13(asset.path);
8732
+ const bytes = await readFile14(asset.path);
8609
8733
  res.writeHead(200, {
8610
8734
  "Content-Type": asset.contentType,
8611
8735
  "Cache-Control": "no-store",
@@ -8654,10 +8778,10 @@ function serveReviewPage(opts) {
8654
8778
  res.end(JSON.stringify(result));
8655
8779
  server.close(() => resolve(result));
8656
8780
  } catch (err) {
8657
- const message5 = err instanceof Error ? err.message : String(err);
8658
- log(` apply failed: ${message5}`);
8781
+ const message6 = err instanceof Error ? err.message : String(err);
8782
+ log(` apply failed: ${message6}`);
8659
8783
  res.writeHead(500, { "Content-Type": "text/plain" });
8660
- res.end(message5);
8784
+ res.end(message6);
8661
8785
  }
8662
8786
  return;
8663
8787
  }
@@ -8677,7 +8801,23 @@ function serveReviewPage(opts) {
8677
8801
  }
8678
8802
 
8679
8803
  // src/pick/server.ts
8680
- async function runPicker(provider, lock, lockPath, opts = {}) {
8804
+ async function currentArt(entry, spec) {
8805
+ if (!spec || !entry.supersededOutputs?.length) return null;
8806
+ const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
8807
+ if (!previous) return null;
8808
+ const file = resolveOutputPath(previous.path, spec.root);
8809
+ if (!existsSync16(file)) return null;
8810
+ const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
8811
+ if (!contentType) return null;
8812
+ let width = entry.width;
8813
+ let height = entry.height;
8814
+ if (contentType === "image/png") {
8815
+ const size = pngSize(await readFile15(file));
8816
+ if (size) ({ width, height } = size);
8817
+ }
8818
+ return { path: file, contentType, width, height };
8819
+ }
8820
+ async function prepareReview(provider, lock, opts = {}) {
8681
8821
  const log = opts.onProgress ?? (() => {
8682
8822
  });
8683
8823
  const selectedKeys = opts.keys ? new Set(opts.keys) : null;
@@ -8685,6 +8825,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8685
8825
  (opts.specs ?? []).map((spec) => [lockKey(spec.styleId, spec.assetId), spec])
8686
8826
  );
8687
8827
  const reviewAssets = /* @__PURE__ */ new Map();
8828
+ const prefix = opts.routePrefix ?? "";
8688
8829
  const groups = [];
8689
8830
  for (const [key, entry] of Object.entries(lock.entries)) {
8690
8831
  if (entry.provider !== provider.id || selectedKeys && !selectedKeys.has(key)) continue;
@@ -8694,13 +8835,18 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8694
8835
  const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
8695
8836
  if (state.status !== "review" && state.status !== "review-set" || (state.status === "review" ? !state.candidateUrls.length : !state.frameUrls.length)) continue;
8696
8837
  const frameUrls = state.status === "review" ? state.candidateUrls : state.frameUrls;
8697
- const sourceRoute = spec?.revision ? `/revision-source/${encodeURIComponent(String(groups.length))}` : null;
8838
+ const sourceRoute = spec?.revision ? `${prefix}/revision-source/${encodeURIComponent(String(groups.length))}` : null;
8698
8839
  if (sourceRoute && spec?.revision) {
8699
8840
  reviewAssets.set(sourceRoute, {
8700
8841
  path: spec.revision.sourceFile,
8701
8842
  contentType: spec.revision.sourceFormat === "jpeg" ? "image/jpeg" : "image/png"
8702
8843
  });
8703
8844
  }
8845
+ const current = await currentArt(entry, spec);
8846
+ const currentRoute = current ? `${prefix}/current-art/${encodeURIComponent(String(groups.length))}` : null;
8847
+ if (current && currentRoute) {
8848
+ reviewAssets.set(currentRoute, { path: current.path, contentType: current.contentType });
8849
+ }
8704
8850
  groups.push({
8705
8851
  key,
8706
8852
  assetId: entry.assetId,
@@ -8723,33 +8869,20 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8723
8869
  width: spec.revision.sourceWidth,
8724
8870
  height: spec.revision.sourceHeight
8725
8871
  }
8726
- } : {}
8872
+ } : {},
8873
+ ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
8727
8874
  });
8728
8875
  } catch (err) {
8729
8876
  log(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);
8730
8877
  }
8731
8878
  }
8732
- if (groups.length === 0) return { selected: 0, skipped: 0 };
8733
- const html = renderSheet(groups);
8879
+ if (groups.length === 0) return null;
8734
8880
  const byKey = new Map(groups.map((g) => [g.key, g]));
8735
- return serveReviewPage({
8736
- html,
8737
- port: opts.port,
8738
- open: opts.open,
8739
- onProgress: log,
8881
+ return {
8882
+ groups,
8740
8883
  assets: reviewAssets,
8741
- onReady: (url) => {
8742
- const info = { url, keys: groups.map((group) => group.key) };
8743
- if (opts.onReady) {
8744
- opts.onReady(info);
8745
- } else {
8746
- log(`
8747
- ${groups.length} asset(s) awaiting selection: ${url}`);
8748
- log(` (leave this running; it exits once you apply)
8749
- `);
8750
- }
8751
- },
8752
- handleApply: async (body) => {
8884
+ html: (options) => renderSheet(groups, options),
8885
+ apply: async (body, lockPath) => {
8753
8886
  const { selections } = body;
8754
8887
  let selected = 0;
8755
8888
  for (const { key, index } of selections) {
@@ -8809,117 +8942,3120 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8809
8942
  await saveLock(lockPath, lock);
8810
8943
  return { selected, skipped: groups.length - selected };
8811
8944
  }
8945
+ };
8946
+ }
8947
+ async function runPicker(provider, lock, lockPath, opts = {}) {
8948
+ const log = opts.onProgress ?? (() => {
8949
+ });
8950
+ const session = await prepareReview(provider, lock, {
8951
+ onProgress: log,
8952
+ keys: opts.keys,
8953
+ specs: opts.specs
8954
+ });
8955
+ if (!session) return { selected: 0, skipped: 0 };
8956
+ const groups = session.groups;
8957
+ return serveReviewPage({
8958
+ html: session.html(),
8959
+ port: opts.port,
8960
+ open: opts.open,
8961
+ onProgress: log,
8962
+ assets: session.assets,
8963
+ onReady: (url) => {
8964
+ const info = { url, keys: groups.map((group) => group.key) };
8965
+ if (opts.onReady) {
8966
+ opts.onReady(info);
8967
+ } else {
8968
+ log(`
8969
+ ${groups.length} asset(s) awaiting selection: ${url}`);
8970
+ log(` (leave this running; it exits once you apply)
8971
+ `);
8972
+ }
8973
+ },
8974
+ handleApply: (body) => session.apply(body, lockPath)
8812
8975
  });
8813
8976
  }
8814
8977
 
8815
- // src/pipeline/init.ts
8816
- import { readdir as readdir3, readFile as readFile14, writeFile as writeFile6 } from "fs/promises";
8817
- import { existsSync as existsSync15 } from "fs";
8818
- import path19 from "path";
8819
- function pngSize(buf) {
8820
- if (buf.length < 24) return null;
8821
- const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
8822
- if (!isPng) return null;
8823
- if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
8824
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
8825
- }
8826
- function slugify(value) {
8827
- return value.replace(/\.png$/i, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
8978
+ // src/gallery/snapshot.ts
8979
+ import { createHash as createHash4 } from "crypto";
8980
+ import { existsSync as existsSync18 } from "fs";
8981
+ import { readFile as readFile18, stat as stat3 } from "fs/promises";
8982
+ import path22 from "path";
8983
+
8984
+ // src/workspace.ts
8985
+ import { mkdir as mkdir5, readFile as readFile16, rename as rename5, rm as rm7, writeFile as writeFile7 } from "fs/promises";
8986
+ import { existsSync as existsSync17 } from "fs";
8987
+ import path20 from "path";
8988
+ import { z as z6 } from "zod";
8989
+ var WorkspaceProjectSchema = z6.object({
8990
+ id: z6.string().min(1),
8991
+ /** Manifest path, relative to the catalog file's own directory. */
8992
+ manifest: z6.string().min(1),
8993
+ /** Lockfile path, relative to the catalog file's own directory. */
8994
+ lock: z6.string().min(1),
8995
+ provider: z6.string().min(1).default("pixellab"),
8996
+ /** Free-form label for a shared account, e.g. distinguishing sandboxes. */
8997
+ account: z6.string().optional()
8998
+ }).strict();
8999
+ var WorkspaceSchema = z6.object({
9000
+ version: z6.literal(1),
9001
+ projects: z6.array(WorkspaceProjectSchema).default([])
9002
+ }).strict();
9003
+ function parseWorkspace(raw) {
9004
+ const parsed = WorkspaceSchema.safeParse(raw);
9005
+ if (parsed.success) return parsed.data;
9006
+ throw new Error(
9007
+ `Workspace catalog is not valid v1:
9008
+ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
9009
+ );
8828
9010
  }
8829
- async function walk(dir, exclude) {
8830
- const out = [];
8831
- for (const entry of await readdir3(dir, { withFileTypes: true })) {
8832
- const full = path19.join(dir, entry.name);
8833
- if (exclude.some((x) => full.includes(x))) continue;
8834
- if (entry.isDirectory()) out.push(...await walk(full, exclude));
8835
- else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
9011
+ async function loadWorkspace(workspacePath) {
9012
+ if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
9013
+ let raw;
9014
+ try {
9015
+ raw = JSON.parse(await readFile16(workspacePath, "utf8"));
9016
+ } catch (err) {
9017
+ throw new Error(
9018
+ `Workspace catalog at ${workspacePath} is malformed:
9019
+ ${err instanceof Error ? err.message : String(err)}`
9020
+ );
8836
9021
  }
8837
- return out;
9022
+ return parseWorkspace(raw);
8838
9023
  }
8839
- async function scanAssets(root, opts = {}) {
8840
- const exclude = opts.exclude ?? [];
8841
- const files = await walk(root, exclude);
8842
- const assets = [];
8843
- const skipped = [];
8844
- const seen = /* @__PURE__ */ new Map();
8845
- for (const file of files.sort()) {
8846
- const size = pngSize(await readFile14(file));
8847
- if (!size) {
8848
- skipped.push(`${file} (not a readable PNG)`);
8849
- continue;
8850
- }
8851
- const rel = path19.relative(root, file);
8852
- const category = path19.dirname(rel) === "." ? "" : path19.dirname(rel);
8853
- let id = slugify(path19.basename(rel));
8854
- if (seen.has(id)) {
8855
- const n = seen.get(id) + 1;
8856
- seen.set(id, n);
8857
- id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
8858
- } else {
8859
- seen.set(id, 1);
8860
- }
8861
- assets.push({ id, category, file: rel, width: size.width, height: size.height });
9024
+ async function saveWorkspace(workspacePath, ws) {
9025
+ const sorted = {
9026
+ version: 1,
9027
+ projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
9028
+ };
9029
+ await mkdir5(path20.dirname(path20.resolve(workspacePath)), { recursive: true });
9030
+ const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9031
+ try {
9032
+ await writeFile7(tmp, JSON.stringify(sorted, null, 2) + "\n");
9033
+ await rename5(tmp, workspacePath);
9034
+ } finally {
9035
+ await rm7(tmp, { force: true });
8862
9036
  }
8863
- return { assets, skipped };
8864
9037
  }
8865
- function buildManifest(name, styleId, generator, outDir, scanned) {
8866
- const squares = scanned.filter((a) => a.width === a.height);
8867
- const commonSize = squares.length ? mode(squares.map((a) => a.width)) : 64;
8868
- const assets = {};
8869
- for (const asset of scanned) {
8870
- assets[asset.id] = {
8871
- prompt: "",
8872
- promptByStyle: {},
8873
- category: asset.category || void 0,
8874
- file: asset.file,
8875
- tags: [],
8876
- styles: [],
8877
- providerInputs: {},
8878
- ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
8879
- };
8880
- }
9038
+ function toPortablePath(dir, absolute) {
9039
+ return path20.relative(dir, absolute).split(path20.sep).join("/");
9040
+ }
9041
+ function resolveProject(dir, project) {
9042
+ return {
9043
+ manifestPath: path20.resolve(dir, project.manifest.split("/").join(path20.sep)),
9044
+ lockPath: path20.resolve(dir, project.lock.split("/").join(path20.sep))
9045
+ };
9046
+ }
9047
+ function validateWorkspace(ws, dir) {
9048
+ const diagnostics = [];
9049
+ const idCounts = /* @__PURE__ */ new Map();
9050
+ const lockOwners = /* @__PURE__ */ new Map();
9051
+ const manifestOwners = /* @__PURE__ */ new Map();
9052
+ for (const project of ws.projects) {
9053
+ idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
9054
+ const { manifestPath, lockPath } = resolveProject(dir, project);
9055
+ lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
9056
+ manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
9057
+ if (path20.isAbsolute(project.manifest) || path20.isAbsolute(project.lock)) {
9058
+ diagnostics.push({
9059
+ id: "absolute-path",
9060
+ level: "warning",
9061
+ message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
9062
+ });
9063
+ }
9064
+ if (!existsSync17(manifestPath)) {
9065
+ diagnostics.push({
9066
+ id: "missing-manifest",
9067
+ level: "error",
9068
+ message: `project "${project.id}" manifest not found: ${manifestPath}`
9069
+ });
9070
+ }
9071
+ if (!existsSync17(lockPath)) {
9072
+ diagnostics.push({
9073
+ id: "missing-lock",
9074
+ level: "error",
9075
+ message: `project "${project.id}" lockfile not found: ${lockPath}`
9076
+ });
9077
+ }
9078
+ }
9079
+ for (const [id, count] of idCounts) {
9080
+ if (count > 1) {
9081
+ diagnostics.push({
9082
+ id: "duplicate-id",
9083
+ level: "error",
9084
+ message: `project id "${id}" is registered ${count} times`
9085
+ });
9086
+ }
9087
+ }
9088
+ for (const [lockPath, ids] of lockOwners) {
9089
+ if (ids.length > 1) {
9090
+ diagnostics.push({
9091
+ id: "duplicate-lock",
9092
+ level: "error",
9093
+ message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
9094
+ });
9095
+ }
9096
+ }
9097
+ for (const [manifestPath, ids] of manifestOwners) {
9098
+ if (ids.length > 1) {
9099
+ diagnostics.push({
9100
+ id: "duplicate-manifest",
9101
+ level: "warning",
9102
+ message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
9103
+ });
9104
+ }
9105
+ }
9106
+ const providers = new Set(ws.projects.map((p) => p.provider));
9107
+ if (providers.size > 1) {
9108
+ diagnostics.push({
9109
+ id: "mixed-provider",
9110
+ level: "warning",
9111
+ message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
9112
+ });
9113
+ }
9114
+ return diagnostics;
9115
+ }
9116
+
9117
+ // src/gallery/edit.ts
9118
+ import { readFile as readFile17, rename as rename6, rm as rm8, writeFile as writeFile8 } from "fs/promises";
9119
+ import path21 from "path";
9120
+ import { z as z7 } from "zod";
9121
+ var HexSha = z7.string().regex(/^[0-9a-f]{64}$/);
9122
+ var AssetPatchSchema = z7.object({
9123
+ prompt: z7.string().optional(),
9124
+ /** Per-style override for the style being viewed; null removes it. */
9125
+ promptForStyle: z7.object({ styleId: z7.string().min(1), prompt: z7.string().nullable() }).optional(),
9126
+ category: z7.string().nullable().optional(),
9127
+ width: z7.number().int().nullable().optional(),
9128
+ height: z7.number().int().nullable().optional(),
9129
+ size: z7.number().int().nullable().optional(),
9130
+ tags: z7.array(z7.string()).optional()
9131
+ }).strict();
9132
+ var NewAssetSchema = z7.object({
9133
+ prompt: z7.string(),
9134
+ category: z7.string().optional(),
9135
+ width: z7.number().int().optional(),
9136
+ height: z7.number().int().optional(),
9137
+ size: z7.number().int().optional(),
9138
+ tags: z7.array(z7.string()).optional(),
9139
+ /** Restrict to these styles; empty or omitted means every style. */
9140
+ styles: z7.array(z7.string().min(1)).optional()
9141
+ }).strict();
9142
+ var CANDIDATE_OPTION = {
9143
+ retrodiffusion: "numImages",
9144
+ comfyui: "numImages",
9145
+ scenario: "numOutputs"
9146
+ };
9147
+ var ManifestEditSchema = z7.discriminatedUnion("action", [
9148
+ z7.object({
9149
+ action: z7.literal("patch-asset"),
9150
+ /** Workspace project id; omitted for a single-project gallery. */
9151
+ project: z7.string().min(1).optional(),
9152
+ assetId: z7.string().min(1),
9153
+ /** Manifest bytes the page last saw; a mismatch refuses the write. */
9154
+ expectedSha256: HexSha,
9155
+ patch: AssetPatchSchema
9156
+ }).strict(),
9157
+ z7.object({
9158
+ action: z7.literal("add-asset"),
9159
+ project: z7.string().min(1).optional(),
9160
+ assetId: z7.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
9161
+ expectedSha256: HexSha,
9162
+ asset: NewAssetSchema
9163
+ }).strict(),
9164
+ z7.object({
9165
+ action: z7.literal("patch-style"),
9166
+ project: z7.string().min(1).optional(),
9167
+ styleId: z7.string().min(1),
9168
+ expectedSha256: HexSha,
9169
+ patch: z7.object({
9170
+ /** Candidates per generation, written to the provider's own option. */
9171
+ candidates: z7.number().int().min(1).max(64).optional(),
9172
+ /** Empty clears the style's own value (inherit, or the default). */
9173
+ promptPrefix: z7.string().optional(),
9174
+ promptSuffix: z7.string().optional(),
9175
+ /** `#rrggbb` values; null or empty clears the style's own palette. */
9176
+ palette: z7.array(z7.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional()
9177
+ }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
9178
+ }).strict()
9179
+ ]);
9180
+ var ManifestDriftError = class extends Error {
9181
+ status = 409;
9182
+ constructor(manifestPath) {
9183
+ super(`${manifestPath} changed on disk since this page loaded; refresh and try again`);
9184
+ this.name = "ManifestDriftError";
9185
+ }
9186
+ };
9187
+ var ManifestEditError = class extends Error {
9188
+ status = 400;
9189
+ constructor(message6) {
9190
+ super(message6);
9191
+ this.name = "ManifestEditError";
9192
+ }
9193
+ };
9194
+ function serializeLike(original, value) {
9195
+ const indentMatch = /\n([ \t]+)"/.exec(original);
9196
+ const indent = indentMatch ? indentMatch[1].startsWith(" ") ? " " : indentMatch[1].length : 2;
9197
+ return JSON.stringify(value, null, indent) + (original.endsWith("\n") ? "\n" : "");
9198
+ }
9199
+ function styleProvider(raw, styleId, seen = /* @__PURE__ */ new Set()) {
9200
+ const style = raw.styles?.[styleId];
9201
+ if (!style || seen.has(styleId)) return raw.provider ?? "pixellab";
9202
+ if (style.provider) return style.provider;
9203
+ seen.add(styleId);
9204
+ return style.extends ? styleProvider(raw, style.extends, seen) : raw.provider ?? "pixellab";
9205
+ }
9206
+ function setOrDelete(target, key, value) {
9207
+ if (value === null || value === void 0) delete target[key];
9208
+ else target[key] = value;
9209
+ }
9210
+ function applyEdit(raw, edit) {
9211
+ raw.assets ??= {};
9212
+ if (edit.action === "patch-style") {
9213
+ const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
9214
+ if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
9215
+ const { patch: patch2 } = edit;
9216
+ if (patch2.candidates !== void 0) {
9217
+ const provider = styleProvider(raw, edit.styleId);
9218
+ const option = CANDIDATE_OPTION[provider];
9219
+ if (!option) {
9220
+ throw new ManifestEditError(
9221
+ 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`
9222
+ );
9223
+ }
9224
+ const options = { ...style.providerOptions ?? {} };
9225
+ options[provider] = { ...options[provider] ?? {}, [option]: patch2.candidates };
9226
+ style.providerOptions = options;
9227
+ }
9228
+ if (patch2.promptPrefix !== void 0) setOrDelete(style, "promptPrefix", patch2.promptPrefix || null);
9229
+ if (patch2.promptSuffix !== void 0) setOrDelete(style, "promptSuffix", patch2.promptSuffix || null);
9230
+ if (patch2.palette !== void 0) {
9231
+ const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
9232
+ setOrDelete(style, "palette", colors.length ? colors : null);
9233
+ }
9234
+ return;
9235
+ }
9236
+ if (edit.action === "add-asset") {
9237
+ if (Object.hasOwn(raw.assets, edit.assetId)) {
9238
+ throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
9239
+ }
9240
+ const { styles, tags, ...rest } = edit.asset;
9241
+ const asset2 = { ...rest };
9242
+ if (tags?.length) asset2.tags = tags;
9243
+ if (styles?.length) {
9244
+ for (const styleId of styles) {
9245
+ if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
9246
+ throw new ManifestEditError(`unknown style "${styleId}"`);
9247
+ }
9248
+ }
9249
+ asset2.styles = styles;
9250
+ }
9251
+ raw.assets[edit.assetId] = asset2;
9252
+ return;
9253
+ }
9254
+ const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
9255
+ if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
9256
+ const { patch } = edit;
9257
+ if (patch.prompt !== void 0) asset.prompt = patch.prompt;
9258
+ if (patch.promptForStyle) {
9259
+ const { styleId, prompt } = patch.promptForStyle;
9260
+ if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
9261
+ throw new ManifestEditError(`unknown style "${styleId}"`);
9262
+ }
9263
+ const byStyle = { ...asset.promptByStyle ?? {} };
9264
+ if (prompt === null) delete byStyle[styleId];
9265
+ else byStyle[styleId] = prompt;
9266
+ if (Object.keys(byStyle).length) asset.promptByStyle = byStyle;
9267
+ else delete asset.promptByStyle;
9268
+ }
9269
+ if ("category" in patch) setOrDelete(asset, "category", patch.category);
9270
+ if ("width" in patch) setOrDelete(asset, "width", patch.width);
9271
+ if ("height" in patch) setOrDelete(asset, "height", patch.height);
9272
+ if ("size" in patch) setOrDelete(asset, "size", patch.size);
9273
+ if (patch.tags !== void 0) {
9274
+ if (patch.tags.length) asset.tags = patch.tags;
9275
+ else delete asset.tags;
9276
+ }
9277
+ }
9278
+ async function applyManifestEdit(manifestPath, edit) {
9279
+ const absolute = path21.resolve(manifestPath);
9280
+ const original = await readFile17(absolute, "utf8");
9281
+ const before = sha256(original);
9282
+ if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
9283
+ let raw;
9284
+ try {
9285
+ raw = JSON.parse(original);
9286
+ } catch (error) {
9287
+ throw new ManifestEditError(
9288
+ `manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
9289
+ );
9290
+ }
9291
+ applyEdit(raw, edit);
9292
+ const next = serializeLike(original, raw);
9293
+ if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
9294
+ const tmp = path21.join(
9295
+ path21.dirname(absolute),
9296
+ `.${path21.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
9297
+ );
9298
+ try {
9299
+ await writeFile8(tmp, next);
9300
+ try {
9301
+ const loaded = await loadManifest(tmp);
9302
+ await resolveSpecs(loaded);
9303
+ } catch (error) {
9304
+ const message6 = error instanceof Error ? error.message : String(error);
9305
+ throw new ManifestEditError(message6.replace(tmp, absolute));
9306
+ }
9307
+ await rename6(tmp, absolute);
9308
+ } finally {
9309
+ await rm8(tmp, { force: true });
9310
+ }
9311
+ return { manifestPath: absolute, sha256: sha256(next), changed: true };
9312
+ }
9313
+ function createGalleryEditHandler(opts) {
9314
+ const log = opts.onProgress ?? (() => {
9315
+ });
9316
+ return async (body) => {
9317
+ const parsed = ManifestEditSchema.safeParse(body);
9318
+ if (!parsed.success) {
9319
+ throw new ManifestEditError(
9320
+ "invalid edit: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
9321
+ );
9322
+ }
9323
+ let manifestPath;
9324
+ try {
9325
+ manifestPath = await opts.manifestFor(parsed.data.project);
9326
+ } catch (error) {
9327
+ throw new ManifestEditError(error instanceof Error ? error.message : String(error));
9328
+ }
9329
+ const result = await applyManifestEdit(manifestPath, parsed.data);
9330
+ if (result.changed) {
9331
+ const relative = path21.relative(process.cwd(), result.manifestPath);
9332
+ const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
9333
+ const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
9334
+ log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
9335
+ }
9336
+ return opts.reload();
9337
+ };
9338
+ }
9339
+
9340
+ // src/gallery/snapshot.ts
9341
+ function galleryMediaId(absolutePath) {
9342
+ return createHash4("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
9343
+ }
9344
+ function galleryMediaRoute(id) {
9345
+ return `/media/${id}`;
9346
+ }
9347
+ function isDeclared(loaded, styleId, assetId) {
9348
+ const asset = loaded.manifest.assets[assetId];
9349
+ if (!asset || !loaded.manifest.styles[styleId]) return false;
9350
+ return asset.styles.length === 0 || asset.styles.includes(styleId);
9351
+ }
9352
+ function matchesFilter(filter, styleId, assetId) {
9353
+ if (filter?.styles?.length && !filter.styles.includes(styleId)) return false;
9354
+ if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
9355
+ return true;
9356
+ }
9357
+ function metadataFps(entry) {
9358
+ if (!entry) return null;
9359
+ const namespace = entry.providerMetadata?.[entry.provider];
9360
+ const frameSet = namespace?.frameSet;
9361
+ if (frameSet && typeof frameSet === "object" && "fps" in frameSet) {
9362
+ const fps = frameSet.fps;
9363
+ if (typeof fps === "number" && Number.isFinite(fps) && fps > 0) return fps;
9364
+ }
9365
+ return null;
9366
+ }
9367
+ async function fileBytes(absolutePath) {
9368
+ try {
9369
+ const info = await stat3(absolutePath);
9370
+ return info.isFile() ? info.size : null;
9371
+ } catch {
9372
+ return null;
9373
+ }
9374
+ }
9375
+ async function describeOutput(media, root, absolutePath, recorded = {}) {
9376
+ const bytes = await fileBytes(absolutePath);
9377
+ const exists = bytes !== null;
9378
+ const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
9379
+ let url = null;
9380
+ if (exists && mediaType) {
9381
+ const id = galleryMediaId(absolutePath);
9382
+ media.set(id, { path: absolutePath, contentType: mediaType });
9383
+ url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
9384
+ }
9385
+ return {
9386
+ path: portableOutputPath(absolutePath, root),
9387
+ absolutePath,
9388
+ sha256: recorded.sha256 ?? null,
9389
+ role: recorded.role ?? null,
9390
+ mediaType,
9391
+ exists,
9392
+ bytes,
9393
+ url
9394
+ };
9395
+ }
9396
+ async function describeQuality(media, root, inspection) {
9397
+ const outputPaths = inspection.outputs ?? [inspection.output];
9398
+ const outputs = await Promise.all(
9399
+ outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
9400
+ );
9401
+ const recordPath = path22.resolve(root, inspection.record);
9402
+ const recordExists = existsSync18(recordPath);
9403
+ let options = null;
9404
+ let check = null;
9405
+ if (recordExists) {
9406
+ try {
9407
+ const result = await checkQualityRecord(recordPath);
9408
+ options = result.options;
9409
+ check = { safe: result.safe, current: result.current, reasons: result.reasons };
9410
+ } catch (error) {
9411
+ check = {
9412
+ safe: false,
9413
+ current: false,
9414
+ reasons: [error instanceof Error ? error.message : String(error)]
9415
+ };
9416
+ }
9417
+ }
9418
+ return {
9419
+ state: inspection.state,
9420
+ reason: inspection.reason,
9421
+ output: portableOutputPath(path22.resolve(root, inspection.output), root),
9422
+ record: portableOutputPath(recordPath, root),
9423
+ recordExists,
9424
+ outputs,
9425
+ palette: options?.palette.colors ?? null,
9426
+ review: options ? options.review.status === "approved" ? {
9427
+ status: "approved",
9428
+ reviewer: options.review.reviewer,
9429
+ approvedAt: options.review.approvedAt,
9430
+ note: options.review.note ?? null
9431
+ } : { status: "pending" } : null,
9432
+ nativeGrid: options ? {
9433
+ confidence: options.nativeGrid.confidence,
9434
+ consensus: options.nativeGrid.consensus,
9435
+ stepX: options.nativeGrid.stepX,
9436
+ stepY: options.nativeGrid.stepY,
9437
+ sourceWidth: options.nativeGrid.sourceWidth,
9438
+ sourceHeight: options.nativeGrid.sourceHeight,
9439
+ nativeWidth: options.nativeGrid.nativeWidth,
9440
+ nativeHeight: options.nativeGrid.nativeHeight
9441
+ } : null,
9442
+ audit: options ? {
9443
+ safe: options.audit.safe,
9444
+ colorCount: options.audit.colorCount,
9445
+ transparency: options.audit.transparency,
9446
+ reasons: options.audit.reasons
9447
+ } : null,
9448
+ frameSet: options?.frameSet ? { fps: options.frameSet.fps, count: options.frameSet.count, roles: options.frameSet.roles } : null,
9449
+ check
9450
+ };
9451
+ }
9452
+ async function buildGallerySnapshot(opts) {
9453
+ const { loaded, specs, lock } = opts;
9454
+ const root = loaded.root;
9455
+ const media = /* @__PURE__ */ new Map();
9456
+ const plan = await buildPlan(specs, lock);
9457
+ const items = [];
9458
+ const manifestText = await readFile18(loaded.path, "utf8");
9459
+ const manifestSha256 = sha256(manifestText);
9460
+ let rawStyles = {};
9461
+ try {
9462
+ const raw = JSON.parse(manifestText);
9463
+ if (raw && typeof raw === "object" && raw.styles && typeof raw.styles === "object") rawStyles = raw.styles;
9464
+ } catch {
9465
+ }
9466
+ for (const planItem of plan.items) {
9467
+ const { spec, key } = planItem;
9468
+ const entry = lock.entries[key];
9469
+ const asset = loaded.manifest.assets[spec.assetId] ?? null;
9470
+ let outputs;
9471
+ if (entry && entry.outputs.length) {
9472
+ outputs = await Promise.all(
9473
+ entry.outputs.map(
9474
+ (output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
9475
+ )
9476
+ );
9477
+ } else if (spec.source) {
9478
+ outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
9479
+ } else if (!entry && existsSync18(spec.outFile)) {
9480
+ outputs = [await describeOutput(media, root, spec.outFile)];
9481
+ } else {
9482
+ outputs = [];
9483
+ }
9484
+ const quality = planItem.quality ? await describeQuality(media, root, planItem.quality) : null;
9485
+ items.push({
9486
+ id: key,
9487
+ project: null,
9488
+ key,
9489
+ styleId: spec.styleId,
9490
+ assetId: spec.assetId,
9491
+ declared: true,
9492
+ state: planItem.state,
9493
+ reason: planItem.reason,
9494
+ status: entry?.status ?? null,
9495
+ provider: entry?.provider ?? spec.provider,
9496
+ generator: entry?.generator ?? spec.generator,
9497
+ prompt: entry?.prompt ?? spec.prompt,
9498
+ currentPrompt: entry && entry.prompt !== spec.prompt ? spec.prompt : null,
9499
+ width: entry?.width ?? spec.width,
9500
+ height: entry?.height ?? spec.height,
9501
+ tileFeature: entry?.tileFeature ?? spec.tileFeature ?? null,
9502
+ fps: quality?.frameSet?.fps ?? metadataFps(entry) ?? spec.quality?.fps ?? null,
9503
+ cost: entry?.cost ?? 0,
9504
+ costUnit: entry?.costUnit ?? spec.costUnit,
9505
+ estimatedCost: spec.cost,
9506
+ candidates: spec.candidates,
9507
+ submittedAt: entry?.submittedAt ?? null,
9508
+ downloadedAt: entry?.downloadedAt ?? null,
9509
+ jobId: entry?.jobId ?? null,
9510
+ objectId: entry?.objectId ?? null,
9511
+ reviewObjectId: entry?.reviewObjectId ?? null,
9512
+ candidateIndex: entry?.candidateIndex ?? null,
9513
+ error: entry?.error ?? null,
9514
+ recordedSpecHash: entry?.specHash ?? null,
9515
+ currentSpecHash: spec.specHash,
9516
+ revision: entry?.revision ?? null,
9517
+ revisionParentKey: spec.revision ? lockKey(spec.styleId, spec.revision.sourceAssetId) : entry?.revision ? lockKey(spec.styleId, entry.revision.sourceAssetId) : null,
9518
+ outputs,
9519
+ quality,
9520
+ providerMetadata: entry?.providerMetadata ?? {},
9521
+ asset,
9522
+ source: spec.source ?? null,
9523
+ tags: spec.tags,
9524
+ category: asset?.category ?? null
9525
+ });
9526
+ }
9527
+ const shown = new Set(items.map((item) => item.key));
9528
+ for (const [key, entry] of Object.entries(lock.entries)) {
9529
+ if (shown.has(key)) continue;
9530
+ if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
9531
+ if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
9532
+ const outputs = await Promise.all(
9533
+ entry.outputs.map(
9534
+ (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
9535
+ )
9536
+ );
9537
+ items.push({
9538
+ id: key,
9539
+ project: null,
9540
+ key,
9541
+ styleId: entry.styleId,
9542
+ assetId: entry.assetId,
9543
+ declared: false,
9544
+ state: "undeclared",
9545
+ reason: "not declared by the current manifest; `pixelkiln prune` removes the entry",
9546
+ status: entry.status,
9547
+ provider: entry.provider,
9548
+ generator: entry.generator,
9549
+ prompt: entry.prompt,
9550
+ currentPrompt: null,
9551
+ width: entry.width,
9552
+ height: entry.height,
9553
+ tileFeature: entry.tileFeature,
9554
+ fps: metadataFps(entry),
9555
+ cost: entry.cost,
9556
+ costUnit: entry.costUnit,
9557
+ estimatedCost: null,
9558
+ candidates: null,
9559
+ submittedAt: entry.submittedAt,
9560
+ downloadedAt: entry.downloadedAt,
9561
+ jobId: entry.jobId,
9562
+ objectId: entry.objectId,
9563
+ reviewObjectId: entry.reviewObjectId,
9564
+ candidateIndex: entry.candidateIndex,
9565
+ error: entry.error,
9566
+ recordedSpecHash: entry.specHash,
9567
+ currentSpecHash: null,
9568
+ revision: entry.revision,
9569
+ revisionParentKey: entry.revision ? lockKey(entry.styleId, entry.revision.sourceAssetId) : null,
9570
+ outputs,
9571
+ quality: null,
9572
+ providerMetadata: entry.providerMetadata ?? {},
9573
+ asset: null,
9574
+ source: null,
9575
+ tags: [],
9576
+ category: null
9577
+ });
9578
+ }
9579
+ items.sort((a, b) => a.key.localeCompare(b.key));
9580
+ const byState = {};
9581
+ for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
9582
+ const byStatus = {};
9583
+ for (const entry of Object.values(lock.entries)) {
9584
+ byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
9585
+ }
9586
+ const styleIds = /* @__PURE__ */ new Set([
9587
+ ...Object.keys(loaded.manifest.styles),
9588
+ ...items.map((item) => item.styleId)
9589
+ ]);
9590
+ const styles = [...styleIds].filter((id) => !opts.filter?.styles?.length || opts.filter.styles.includes(id) || items.some((item) => item.styleId === id)).sort().map((id) => {
9591
+ const style = loaded.manifest.styles[id];
9592
+ const styleItems = items.filter((item) => item.styleId === id);
9593
+ const spend = {};
9594
+ for (const item of styleItems) {
9595
+ if (item.cost) spend[item.costUnit] = (spend[item.costUnit] ?? 0) + item.cost;
9596
+ }
9597
+ const counts = new Set(styleItems.filter((item) => item.declared && item.candidates !== null).map((item) => item.candidates));
9598
+ const provider = style ? style.provider ?? loaded.manifest.provider : styleItems[0]?.provider ?? loaded.manifest.provider;
9599
+ const actionableItems = styleItems.filter((item) => item.declared && (item.state === "missing" || item.state === "stale" || item.state === "failed"));
9600
+ const declaredItems = styleItems.filter((item) => item.declared);
9601
+ const rawStyle = Object.hasOwn(rawStyles, id) ? rawStyles[id] : void 0;
9602
+ return {
9603
+ id,
9604
+ project: null,
9605
+ // A declared style inherits the manifest default; only a style the
9606
+ // manifest no longer has falls back to what its entries recorded.
9607
+ provider,
9608
+ generator: style?.generator ?? styleItems[0]?.generator ?? "map",
9609
+ outDir: style?.outDir ?? "",
9610
+ promptPrefix: style?.promptPrefix ?? "",
9611
+ promptSuffix: style?.promptSuffix ?? "",
9612
+ palette: style?.palette ?? [],
9613
+ quality: Boolean(style?.quality),
9614
+ tags: style?.tags ?? [],
9615
+ extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
9616
+ ownFields: rawStyle ? Object.keys(rawStyle).filter((key) => key !== "extends") : [],
9617
+ items: styleItems.length,
9618
+ spendByUnit: spend,
9619
+ regenerate: {
9620
+ assets: declaredItems.length,
9621
+ cost: declaredItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
9622
+ costUnit: declaredItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
9623
+ },
9624
+ candidates: counts.size === 1 ? [...counts][0] : null,
9625
+ candidatesEditable: Boolean(style) && Object.hasOwn(CANDIDATE_OPTION, provider),
9626
+ actionable: {
9627
+ keys: actionableItems.map((item) => item.key),
9628
+ cost: actionableItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
9629
+ costUnit: actionableItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
9630
+ }
9631
+ };
9632
+ }).filter((style) => style.items > 0 || !opts.filter?.styles?.length);
9633
+ const snapshot = {
9634
+ version: 1,
9635
+ generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9636
+ project: {
9637
+ id: loaded.manifest.name,
9638
+ name: loaded.manifest.name,
9639
+ manifest: loaded.path,
9640
+ lock: path22.resolve(opts.lockPath),
9641
+ root,
9642
+ provider: loaded.manifest.provider,
9643
+ account: null,
9644
+ manifestSha256,
9645
+ entries: Object.keys(lock.entries).length,
9646
+ items: items.length,
9647
+ spendByUnit: spendByUnit(lock),
9648
+ error: null
9649
+ },
9650
+ workspace: null,
9651
+ filter: { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] },
9652
+ totals: {
9653
+ entries: Object.keys(lock.entries).length,
9654
+ items: items.length,
9655
+ byState,
9656
+ byStatus,
9657
+ spendByUnit: spendByUnit(lock)
9658
+ },
9659
+ styles,
9660
+ items
9661
+ };
9662
+ return { snapshot, media };
9663
+ }
9664
+ async function buildWorkspaceGallerySnapshot(opts) {
9665
+ const dir = path22.dirname(path22.resolve(opts.workspacePath));
9666
+ const media = /* @__PURE__ */ new Map();
9667
+ const projects = [];
9668
+ const items = [];
9669
+ const styles = [];
9670
+ const filter = { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] };
9671
+ for (const project of opts.workspace.projects) {
9672
+ const { manifestPath, lockPath } = resolveProject(dir, project);
9673
+ try {
9674
+ const loaded = await loadManifest(manifestPath);
9675
+ const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
9676
+ const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
9677
+ const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
9678
+ const lock = await loadLock(lockPath);
9679
+ let projectItems = [];
9680
+ let projectStyles = [];
9681
+ let manifestSha256 = null;
9682
+ if (!excluded) {
9683
+ const specs = await resolveSpecs(loaded, { styles: styleFilter, assets: assetFilter });
9684
+ normalizeLockOutputPaths(lock, specs);
9685
+ const build = await buildGallerySnapshot({
9686
+ loaded,
9687
+ specs,
9688
+ lock,
9689
+ lockPath,
9690
+ filter: { styles: styleFilter, assets: assetFilter },
9691
+ now: opts.now
9692
+ });
9693
+ for (const [id, file] of build.media) media.set(id, file);
9694
+ manifestSha256 = build.snapshot.project?.manifestSha256 ?? null;
9695
+ projectItems = build.snapshot.items.map((item) => ({
9696
+ ...item,
9697
+ id: `${project.id}:${item.key}`,
9698
+ project: project.id
9699
+ }));
9700
+ projectStyles = build.snapshot.styles.map((style) => ({ ...style, project: project.id }));
9701
+ }
9702
+ items.push(...projectItems);
9703
+ styles.push(...projectStyles);
9704
+ projects.push({
9705
+ id: project.id,
9706
+ name: loaded.manifest.name,
9707
+ manifest: manifestPath,
9708
+ lock: path22.resolve(lockPath),
9709
+ root: loaded.root,
9710
+ provider: loaded.manifest.provider,
9711
+ account: project.account ?? null,
9712
+ manifestSha256: manifestSha256 ?? sha256(await readFile18(manifestPath, "utf8")),
9713
+ entries: Object.keys(lock.entries).length,
9714
+ items: projectItems.length,
9715
+ spendByUnit: spendByUnit(lock),
9716
+ error: null
9717
+ });
9718
+ } catch (err) {
9719
+ projects.push({
9720
+ id: project.id,
9721
+ name: project.id,
9722
+ manifest: manifestPath,
9723
+ lock: path22.resolve(lockPath),
9724
+ root: path22.dirname(manifestPath),
9725
+ provider: project.provider,
9726
+ account: project.account ?? null,
9727
+ manifestSha256: null,
9728
+ entries: 0,
9729
+ items: 0,
9730
+ spendByUnit: {},
9731
+ error: err instanceof Error ? err.message : String(err)
9732
+ });
9733
+ }
9734
+ }
9735
+ const byState = {};
9736
+ for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
9737
+ const byStatus = {};
9738
+ for (const item of items) if (item.status) byStatus[item.status] = (byStatus[item.status] ?? 0) + 1;
9739
+ const spend = {};
9740
+ for (const project of projects) {
9741
+ for (const [unit, amount] of Object.entries(project.spendByUnit)) {
9742
+ spend[unit] = (spend[unit] ?? 0) + amount;
9743
+ }
9744
+ }
9745
+ return {
9746
+ snapshot: {
9747
+ version: 1,
9748
+ generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9749
+ project: null,
9750
+ workspace: { path: path22.resolve(opts.workspacePath), dir, projects },
9751
+ filter,
9752
+ totals: {
9753
+ entries: projects.reduce((sum, project) => sum + project.entries, 0),
9754
+ items: items.length,
9755
+ byState,
9756
+ byStatus,
9757
+ spendByUnit: spend
9758
+ },
9759
+ styles,
9760
+ items
9761
+ },
9762
+ media
9763
+ };
9764
+ }
9765
+
9766
+ // src/gallery/page.ts
9767
+ function renderGallery(snapshot, opts = {}) {
9768
+ const data = JSON.stringify(snapshot).replace(/<\//g, "<\\/").replace(/<!--/g, "<\\u0021--");
9769
+ const session = JSON.stringify(opts.session ?? null);
9770
+ const editable = JSON.stringify(Boolean(opts.editable && opts.session));
9771
+ const generation = JSON.stringify(Boolean(opts.generation && opts.session));
9772
+ const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
9773
+ return `<!doctype html>
9774
+ <html lang="en">
9775
+ <head>
9776
+ <meta charset="utf-8">
9777
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9778
+ <title>${escapeHtml2(title)}</title>
9779
+ <link rel="icon" href="data:,">
9780
+ <style>
9781
+ :root {
9782
+ --bg: #17150f; --panel: #201d17; --panel-deep: #100f0c;
9783
+ --line: rgba(243,234,214,.16); --line-strong: rgba(243,234,214,.3);
9784
+ --text: #f3ead6; --dim: #9b9384; --accent: #ff6b35; --accent-soft: #ff9c5f;
9785
+ --ok: #b9f27c; --warn: #ff9c5f; --bad: #ff7a6b; --cool: #8fc7ff;
9786
+ --content: 1520px; --drawer: 540px;
9787
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
9788
+ --checker:
9789
+ linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
9790
+ linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
9791
+ }
9792
+ * { box-sizing: border-box; }
9793
+ html { color-scheme: dark; }
9794
+ body { margin:0; background:var(--bg); color:var(--text);
9795
+ font:14px/1.5 ui-sans-serif,-apple-system,"SF Pro Text",Inter,system-ui,sans-serif; }
9796
+ button, input, select { font:inherit; color:inherit; }
9797
+ button { border-radius:0; padding:6px 11px; border:1px solid var(--line);
9798
+ background:transparent; cursor:pointer; }
9799
+ button:hover { border-color:var(--line-strong); }
9800
+ button.primary { background:var(--accent); border-color:var(--accent); color:var(--bg); font-weight:600; }
9801
+ button:disabled { opacity:.45; cursor:not-allowed; }
9802
+ :focus-visible { outline:2px solid var(--accent-soft); outline-offset:2px; }
9803
+ a { color:var(--accent-soft); }
9804
+ code, .mono { font-family:var(--mono); }
9805
+
9806
+ header { position:sticky; top:0; z-index:10; background:var(--panel);
9807
+ border-bottom:1px solid var(--line-strong); }
9808
+ .bar { width:min(100%,var(--content)); margin:0 auto; padding:13px 22px 0;
9809
+ display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
9810
+ h1 { font:700 14px/1 var(--mono); margin:0; letter-spacing:-.02em; white-space:nowrap; }
9811
+ h1::before { content:'\u25C6'; color:var(--accent); margin-right:9px; font-size:11px; }
9812
+ .project { color:var(--dim); font-size:12.5px; min-width:0; overflow:hidden;
9813
+ text-overflow:ellipsis; white-space:nowrap; }
9814
+ .project b { color:var(--text); font-weight:600; }
9815
+ .grow { flex:1 1 auto; }
9816
+ .search { display:flex; align-items:center; gap:8px; border:1px solid var(--line);
9817
+ background:var(--panel-deep); padding:0 10px; min-width:min(320px, 100%); }
9818
+ .search input { flex:1; border:0; background:transparent; padding:7px 0; min-width:0; }
9819
+ .search input:focus { outline:none; }
9820
+ .search kbd { color:var(--dim); border:1px solid var(--line); border-bottom-width:2px;
9821
+ padding:0 5px; font-size:11px; font-family:var(--mono); }
9822
+ select { background:var(--panel-deep); border:1px solid var(--line); padding:6px 8px; border-radius:0; }
9823
+ .totals { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 0;
9824
+ display:flex; gap:14px; flex-wrap:wrap; align-items:baseline;
9825
+ color:var(--dim); font-size:12.5px; font-variant-numeric:tabular-nums; }
9826
+ .totals b { color:var(--text); font-weight:600; }
9827
+ .chips { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 13px;
9828
+ display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
9829
+ .chips .sep { width:1px; height:18px; background:var(--line); margin:0 4px; }
9830
+ .chip { padding:3px 9px; font-size:12px; border:1px solid var(--line); color:var(--dim);
9831
+ display:inline-flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; }
9832
+ .chip.on { color:var(--text); border-color:var(--line-strong); background:var(--panel-deep); }
9833
+ .chip .n { color:var(--dim); }
9834
+ .dot { width:7px; height:7px; display:inline-block; background:var(--dim); flex:none; }
9835
+ .dot.ok { background:var(--ok); } .dot.warn { background:var(--warn); }
9836
+ .dot.bad { background:var(--bad); } .dot.cool { background:var(--cool); }
9837
+ .state-ok { color:var(--ok); } .state-warn { color:var(--warn); }
9838
+ .state-bad { color:var(--bad); } .state-cool { color:var(--cool); } .state-dim { color:var(--dim); }
9839
+
9840
+ main { width:min(100%,var(--content)); margin:0 auto; border-inline:1px solid var(--line);
9841
+ min-height:60vh; }
9842
+ .style { border-bottom:1px solid var(--line); padding:20px 22px 24px; }
9843
+ .project { border-bottom:1px solid var(--line-strong); background:var(--panel); padding:14px 22px; }
9844
+ .phead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; }
9845
+ .phead h2 { margin:0; font:700 15px/1.2 var(--mono); }
9846
+ .phead h2::before { content:'\u25C6'; color:var(--accent); margin-right:8px; font-size:10px; }
9847
+ .phead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap; }
9848
+ .sid.project { color:var(--dim); border-color:var(--line-strong); }
9849
+ .shead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; margin-bottom:14px; }
9850
+ .shead h2 { margin:0; font:650 15px/1.2 var(--mono); }
9851
+ .shead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap;
9852
+ align-items:center; }
9853
+ .swatches { display:inline-flex; gap:2px; vertical-align:middle; }
9854
+ .swatches i { width:12px; height:12px; display:block; border:1px solid var(--line); }
9855
+ .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(176px, 1fr)); gap:10px; }
9856
+ .card { border:1px solid var(--line); background:var(--panel); text-align:left; padding:0;
9857
+ display:flex; flex-direction:column; cursor:pointer; min-width:0;
9858
+ content-visibility:auto; contain-intrinsic-size:auto 176px auto 196px; }
9859
+ .card:hover { border-color:var(--line-strong); }
9860
+ .card.active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); }
9861
+ .card.ghost { border-style:dashed; background:transparent; }
9862
+ .cell { height:132px; display:grid; place-items:center; background:var(--panel-deep);
9863
+ background-image:var(--checker); background-size:12px 12px; background-position:0 0,6px 6px;
9864
+ overflow:hidden; position:relative; padding:8px; }
9865
+ .card.ghost .cell { background-image:none; color:var(--dim); font-size:12px; }
9866
+ .cell img { image-rendering:pixelated; display:block; max-width:100%; max-height:100%; }
9867
+ .cell img.fit { width:auto; height:auto; object-fit:contain; }
9868
+ .cell .multi { display:grid; grid-template-columns:1fr 1fr; gap:4px; width:100%; height:100%; }
9869
+ .cell .multi div { display:grid; place-items:center; overflow:hidden; }
9870
+ .badge { position:absolute; right:6px; bottom:6px; background:var(--panel); color:var(--dim);
9871
+ border:1px solid var(--line); font:11px/1 var(--mono); padding:3px 6px; }
9872
+ .cbody { padding:8px 10px 10px; display:flex; flex-direction:column; gap:3px; min-width:0; }
9873
+ .aid { font:600 13px/1.3 var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9874
+ .cmeta { display:flex; gap:8px; align-items:center; color:var(--dim); font-size:11.5px;
9875
+ font-variant-numeric:tabular-nums; white-space:nowrap; overflow:hidden; }
9876
+ .cmeta .st { display:inline-flex; align-items:center; gap:5px; }
9877
+ .empty { padding:64px 22px; color:var(--dim); text-align:center; }
9878
+ .empty p { margin:0 0 12px; }
9879
+ footer { width:min(100%,var(--content)); margin:0 auto; padding:22px 22px 60px; color:var(--dim);
9880
+ font-size:12.5px; }
9881
+ kbd { border:1px solid var(--line); border-bottom-width:2px; padding:1px 5px; font-size:11px;
9882
+ font-family:var(--mono); }
9883
+
9884
+ .scrim { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:19; }
9885
+ .drawer { position:fixed; top:0; right:0; bottom:0; width:min(var(--drawer), 100vw); z-index:20;
9886
+ background:var(--panel); border-left:1px solid var(--line-strong); overflow-y:auto;
9887
+ display:flex; flex-direction:column; }
9888
+ .dhead { position:sticky; top:0; background:var(--panel); border-bottom:1px solid var(--line);
9889
+ padding:14px 18px; display:flex; align-items:center; gap:10px; z-index:1; }
9890
+ .dhead .title { flex:1; min-width:0; }
9891
+ .dhead .title .sid { color:var(--accent-soft); font:650 10.5px/1 var(--mono); border:1px solid var(--accent);
9892
+ padding:2px 6px; display:inline-block; margin-bottom:6px; }
9893
+ .dhead .title .aid { font-size:16px; white-space:normal; word-break:break-all; }
9894
+ .dhead button { padding:5px 9px; }
9895
+ .dbody { padding:0 18px 40px; }
9896
+ .preview { margin:16px 0 10px; background:var(--panel-deep); background-image:var(--checker);
9897
+ background-size:12px 12px; background-position:0 0,6px 6px; border:1px solid var(--line);
9898
+ min-height:200px; max-height:60vh; overflow:auto; display:grid; place-items:center; padding:12px; }
9899
+ .preview img { image-rendering:pixelated; display:block; }
9900
+ .preview .none { color:var(--dim); font-size:12.5px; text-align:center; padding:40px 12px; }
9901
+ .zoom { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size:12px; color:var(--dim); }
9902
+ .zoom button { padding:3px 8px; font-size:12px; }
9903
+ .zoom button.on { border-color:var(--accent); color:var(--text); }
9904
+ .zoom .grow { text-align:right; font-variant-numeric:tabular-nums; }
9905
+ .members { display:flex; gap:6px; overflow-x:auto; padding:10px 0 4px; }
9906
+ .members button { flex:none; padding:4px; display:flex; flex-direction:column; align-items:center; gap:3px;
9907
+ background:var(--panel-deep); background-image:var(--checker); background-size:8px 8px;
9908
+ background-position:0 0,4px 4px; }
9909
+ .members button.on { border-color:var(--accent); }
9910
+ .members img { image-rendering:pixelated; display:block; width:40px; height:40px; object-fit:contain; }
9911
+ .members span { font:10.5px/1 var(--mono); color:var(--dim); background:var(--panel); padding:2px 4px; }
9912
+ .status { margin:14px 0 0; padding:10px 12px; border:1px solid var(--line); display:flex; gap:10px;
9913
+ align-items:baseline; font-size:13px; }
9914
+ .status .st { font-weight:600; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
9915
+ .status .why { color:var(--dim); }
9916
+ section.meta { border-top:1px solid var(--line); padding:14px 0 6px; margin-top:14px; }
9917
+ section.meta h3 { margin:0 0 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
9918
+ dl { margin:0; display:grid; grid-template-columns:minmax(96px, 30%) 1fr; gap:6px 12px; font-size:12.5px; }
9919
+ dt { color:var(--dim); }
9920
+ dd { margin:0; min-width:0; word-break:break-word; }
9921
+ dd.mono { font-family:var(--mono); font-size:12px; }
9922
+ dd .copy { margin-left:6px; padding:0 5px; font:11px/1.4 var(--mono); color:var(--dim); border-color:transparent; }
9923
+ dd .copy:hover { border-color:var(--line); color:var(--text); }
9924
+ .prompt { white-space:pre-wrap; }
9925
+ .out { border:1px solid var(--line); padding:8px 10px; margin-bottom:8px; }
9926
+ .out dl { grid-template-columns:minmax(72px, 24%) 1fr; }
9927
+ .out .role { font:600 12px/1 var(--mono); margin-bottom:6px; display:flex; gap:8px; align-items:center; }
9928
+ .miss { color:var(--bad); }
9929
+ .pal { display:flex; flex-wrap:wrap; gap:3px; }
9930
+ .pal i { width:16px; height:16px; display:block; border:1px solid var(--line); }
9931
+ details { border:1px solid var(--line); padding:0; margin-top:8px; }
9932
+ details summary { cursor:pointer; padding:7px 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
9933
+ details pre { margin:0; padding:10px; border-top:1px solid var(--line); background:var(--panel-deep);
9934
+ font:11.5px/1.45 var(--mono); overflow:auto; max-height:320px; }
9935
+ .linkish { background:none; border:0; padding:0; color:var(--accent-soft); cursor:pointer;
9936
+ font-family:var(--mono); font-size:12px; text-decoration:underline; }
9937
+ .nav { display:flex; gap:6px; }
9938
+ #note { color:var(--warn); font-size:12.5px; }
9939
+ .editing { color:var(--accent-soft); border:1px solid var(--accent); padding:2px 7px; font:650 10.5px/1.4 var(--mono); }
9940
+ form.edit { border:1px solid var(--accent); padding:12px 14px 14px; margin-top:14px; display:grid; gap:10px; }
9941
+ form.edit h3 { margin:0; font:650 12.5px/1 var(--mono); color:var(--accent-soft); }
9942
+ form.edit .row { display:grid; grid-template-columns:repeat(auto-fit, minmax(110px, 1fr)); gap:10px; }
9943
+ .field { display:grid; gap:4px; font-size:12px; color:var(--dim); min-width:0; }
9944
+ .field span { font-family:var(--mono); font-size:11px; }
9945
+ .field small { font-size:11px; color:var(--dim); }
9946
+ .field input, .field textarea, .field select { background:var(--panel-deep); border:1px solid var(--line);
9947
+ color:var(--text); padding:6px 8px; border-radius:0; font:13px/1.45 inherit; width:100%; min-width:0; }
9948
+ .field textarea { resize:vertical; min-height:64px; font-family:inherit; }
9949
+ .field input:focus, .field textarea:focus, .field select:focus { outline:none; border-color:var(--accent); }
9950
+ .field.check { display:flex; align-items:center; gap:8px; }
9951
+ .field.check input { width:auto; }
9952
+ form.edit .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
9953
+ form.edit .msg { font-size:12.5px; }
9954
+ form.edit .msg.bad { color:var(--bad); }
9955
+ .notice { margin:14px 0 0; padding:10px 12px; border:1px solid var(--ok); color:var(--ok); font-size:13px; }
9956
+ .shead .tools { margin-left:auto; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
9957
+ .shead .tools button { padding:3px 9px; font-size:12px; }
9958
+ .shead .cand { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--dim); }
9959
+ .shead .cand input { width:56px; background:var(--panel-deep); border:1px solid var(--line); color:var(--text);
9960
+ padding:2px 6px; font:12px/1.4 inherit; border-radius:0; }
9961
+ form.edit.style-form { margin:0 0 14px; }
9962
+ form.edit .blast { padding:8px 10px; border:1px solid var(--line); font-size:12.5px; color:var(--dim); }
9963
+ form.edit .blast.hot { border-color:var(--warn); color:var(--text); }
9964
+ form.edit .blast b { color:var(--warn); font-weight:600; }
9965
+ form.edit .pal { margin-top:4px; }
9966
+ .style .notice { margin:0 0 14px; }
9967
+ #jobs { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; display:grid; gap:6px; }
9968
+ #jobs:empty { display:none; }
9969
+ .job { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
9970
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
9971
+ .job .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
9972
+ .job .last { color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
9973
+ .job .acts { display:flex; gap:6px; }
9974
+ .job .acts button { padding:3px 9px; font-size:12px; }
9975
+ .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
9976
+ font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
9977
+ .budget { color:var(--dim); }
9978
+ .budget b { color:var(--text); }
9979
+ .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
9980
+ .dialog form { background:var(--panel); border:1px solid var(--accent); width:min(560px, calc(100vw - 32px));
9981
+ max-height:calc(100vh - 32px); overflow:auto; padding:16px 18px 18px; display:grid; gap:12px; }
9982
+ .dialog h3 { margin:0; font:650 14px/1.2 var(--mono); }
9983
+ .dialog table { width:100%; border-collapse:collapse; font-size:12.5px; }
9984
+ .dialog td, .dialog th { text-align:left; padding:4px 6px; border-bottom:1px solid var(--line); }
9985
+ .dialog th { color:var(--dim); font-weight:500; }
9986
+ .dialog td.n, .dialog th.n { text-align:right; font-variant-numeric:tabular-nums; }
9987
+ .dialog .sum { display:grid; gap:4px; font-size:13px; }
9988
+ .dialog .sum b { font-weight:600; }
9989
+ .dialog .warn { color:var(--warn); font-size:12.5px; }
9990
+ .dialog .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
9991
+ .dialog .msg { font-size:12.5px; color:var(--bad); }
9992
+ .sheet-scrim { position:fixed; inset:0; z-index:24; background:rgba(0,0,0,.5); }
9993
+ .sheet { position:fixed; top:0; right:0; bottom:0; z-index:25; background:var(--bg);
9994
+ border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
9995
+ .sheet.review-host { width:min(1180px, 94vw); }
9996
+ .sheet.compare-host { width:min(1500px, 96vw); }
9997
+ .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
9998
+ background:var(--panel); font-size:13px; flex-wrap:wrap; }
9999
+ .sheet .rbar span { color:var(--dim); }
10000
+ .sheet .rbar .zoom { margin-left:auto; }
10001
+ .sheet iframe { border:0; width:100%; height:100%; background:var(--bg); }
10002
+ @media (prefers-reduced-motion: no-preference) { .sheet { animation: slide .18s ease-out; } }
10003
+ .compare-body { overflow:auto; padding:16px; }
10004
+ .compare-body table { border-collapse:separate; border-spacing:0; min-width:100%; font-size:12.5px; }
10005
+ .compare-body th, .compare-body td { text-align:left; vertical-align:top; padding:6px 10px; border-bottom:1px solid var(--line);
10006
+ min-width:180px; max-width:420px; }
10007
+ .compare-body th:first-child, .compare-body td:first-child { min-width:96px; max-width:120px; color:var(--dim);
10008
+ position:sticky; left:0; background:var(--bg); z-index:1; }
10009
+ .compare-body thead th { position:sticky; top:0; background:var(--bg); z-index:2; border-bottom:1px solid var(--line-strong); }
10010
+ .compare-body .chead { display:grid; gap:4px; }
10011
+ .compare-body .chead .aid { font-size:14px; }
10012
+ .compare-body .chead .badges { display:flex; gap:6px; }
10013
+ .compare-body .chead .badges .sid { display:inline-block; }
10014
+ .compare-body .cimg { background:var(--panel-deep); background-image:var(--checker); background-size:12px 12px;
10015
+ background-position:0 0,6px 6px; border:1px solid var(--line); display:grid; place-items:center; padding:10px;
10016
+ min-height:120px; max-height:56vh; overflow:auto; }
10017
+ .compare-body .cimg img { image-rendering:pixelated; display:block; }
10018
+ .compare-body .cimg .none { color:var(--dim); font-size:12px; }
10019
+ .compare-body td.diff { background:color-mix(in srgb, var(--warn) 9%, transparent); }
10020
+ .compare-body td.mono { font-family:var(--mono); font-size:12px; word-break:break-all; }
10021
+ .compare-body td .prompt { white-space:pre-wrap; }
10022
+ .compare-body td .rm { float:right; padding:1px 6px; font-size:11px; color:var(--dim); border-color:transparent; }
10023
+ .card.compared { outline:2px dashed var(--accent-soft); outline-offset:-2px; }
10024
+ .card .slot { position:absolute; left:6px; top:6px; background:var(--accent); color:var(--bg); font:700 11px/1 var(--mono);
10025
+ padding:3px 6px; }
10026
+ .tray { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:18; background:var(--panel);
10027
+ border:1px solid var(--accent); padding:8px 10px; display:flex; gap:8px; align-items:center; max-width:calc(100vw - 32px);
10028
+ box-shadow:0 10px 30px rgba(0,0,0,.4); }
10029
+ .tray .chipset { display:flex; gap:6px; overflow-x:auto; }
10030
+ .tray .pick { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); padding:3px 4px 3px 6px;
10031
+ font:12px/1.2 var(--mono); white-space:nowrap; }
10032
+ .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
10033
+ .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
10034
+ .tray > button { padding:5px 11px; font-size:12.5px; }
10035
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10036
+ .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10037
+ .drawer .gen button.primary { padding:7px 14px; }
10038
+ @media (max-width: 720px) {
10039
+ .bar, .totals, .chips, .style, footer { padding-inline:14px; }
10040
+ .grid { grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); }
10041
+ .cell { height:112px; }
10042
+ .drawer { width:100vw; }
10043
+ }
10044
+ @media (prefers-reduced-motion: no-preference) {
10045
+ .drawer { animation: slide .16s ease-out; }
10046
+ @keyframes slide { from { transform: translateX(16px); opacity:.6; } to { transform:none; opacity:1; } }
10047
+ }
10048
+ </style>
10049
+ </head>
10050
+ <body>
10051
+ <header>
10052
+ <div class="bar">
10053
+ <h1>pixelkiln</h1>
10054
+ <span class="project" id="project"></span>
10055
+ <span class="grow"></span>
10056
+ <label class="search"><input id="q" type="search" placeholder="Search assets, prompts, ids, hashes" autocomplete="off"><kbd>/</kbd></label>
10057
+ <select id="sort" aria-label="Sort">
10058
+ <option value="key">Sort: style / asset</option>
10059
+ <option value="newest">Sort: newest first</option>
10060
+ <option value="oldest">Sort: oldest first</option>
10061
+ <option value="cost">Sort: cost</option>
10062
+ <option value="size">Sort: size</option>
10063
+ </select>
10064
+ <select id="group" aria-label="Group">
10065
+ <option value="style">Group: by style</option>
10066
+ <option value="none">Group: none</option>
10067
+ </select>
10068
+ <span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
10069
+ <button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
10070
+ <label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
10071
+ </div>
10072
+ <div class="totals" id="totals"></div>
10073
+ <div class="chips" id="chips"></div>
10074
+ <div id="jobs"></div>
10075
+ </header>
10076
+ <main id="root"></main>
10077
+ <footer>
10078
+ 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
10079
+ is open, <kbd>Esc</kbd> closes it, and <kbd>/</kbd> jumps to search. This page reads the manifest, lockfile,
10080
+ and disk only \u2014 it never contacts a provider<span id="foot-edit"> and never writes anything</span><span id="foot-editing" hidden>.
10081
+ Editing is on: saving rewrites the manifest and nothing else</span><span id="foot-gen" hidden>.
10082
+ Generation is on under the session budget shown above; every run is the same submit, poll, and fetch as <code>pixelkiln gen</code></span>.
10083
+ <span id="note"></span>
10084
+ </footer>
10085
+ <div id="drawer-host"></div>
10086
+ <div id="dialog-host"></div>
10087
+ <script>
10088
+ const INITIAL = ${data};
10089
+ const SESSION = ${session};
10090
+ const EDITABLE = ${editable};
10091
+ const GENERATION = ${generation};
10092
+ let snap = INITIAL;
10093
+ const STATE_TONE = {
10094
+ ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
10095
+ failed: 'bad', 'in-flight': 'cool', recoverable: 'cool', missing: 'dim', undeclared: 'dim',
10096
+ };
10097
+ const STATE_ORDER = ['ok','stale','orphaned','untracked','in-flight','recoverable','blocked','failed','missing','undeclared'];
10098
+ const ui = {
10099
+ q: '', states: new Set(), providers: new Set(), generators: new Set(), projects: new Set(),
10100
+ sort: 'key', group: 'style', open: null, member: 0, zoom: 'auto', playing: null,
10101
+ /** Cards rendered per pass; a project with thousands of assets opts into the rest. */
10102
+ limit: 600,
10103
+ /** Item id whose edit form is open, or 'new:<project>:<style>' for an add form. */
10104
+ editing: null,
10105
+ /** One-shot confirmation shown in the drawer after a save. */
10106
+ notice: null,
10107
+ /** Record ids picked for side-by-side comparison, in pick order (max 4). */
10108
+ compare: [],
10109
+ /** Zoom for the comparison panel. */
10110
+ compareZoom: 'auto',
10111
+ /** Jobs expanded to show their log. */
10112
+ logs: new Set(),
10113
+ /** Job ids already seen finished, so a completion refreshes exactly once. */
10114
+ settled: new Set(),
10115
+ };
10116
+ /** Generation status from /api/jobs: jobs, session budget, spend so far. */
10117
+ let GEN = { jobs: [], budget: { byProvider: {} }, spent: {}, units: {} };
10118
+ const $ = (id) => document.getElementById(id);
10119
+ const el = (tag, cls, text) => {
10120
+ const n = document.createElement(tag);
10121
+ if (cls) n.className = cls;
10122
+ if (text !== undefined && text !== null) n.textContent = String(text);
10123
+ return n;
10124
+ };
10125
+ const fmtCost = (unit, amount) => {
10126
+ if (unit === 'free') return 'free';
10127
+ if (unit === 'usd') return '$' + Number(amount).toFixed(2);
10128
+ if (unit === 'generations') return amount + ' generation' + (amount === 1 ? '' : 's');
10129
+ return amount + ' ' + unit;
10130
+ };
10131
+ const fmtSpend = (spend) => Object.entries(spend).filter(([, n]) => n).sort()
10132
+ .map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + ') || 'no spend recorded';
10133
+ const fmtBytes = (n) => n == null ? '\u2014' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB';
10134
+ const fmtWhen = (iso) => {
10135
+ if (!iso) return null;
10136
+ const d = new Date(iso);
10137
+ return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
10138
+ };
10139
+ const isFrameSet = (item) => item.generator === 'frames' ||
10140
+ (item.outputs.length > 1 && item.outputs.every((o) => o.role && /^frame/i.test(o.role)));
10141
+ const shortPath = (p) => {
10142
+ if (p.length <= 64) return p;
10143
+ const parts = p.split('/');
10144
+ return parts.length > 4 ? '\u2026/' + parts.slice(-3).join('/') : p;
10145
+ };
10146
+ const displayScale = (w, h, boxW, boxH) => {
10147
+ const s = Math.min(boxW / w, boxH / h);
10148
+ return s >= 1 ? Math.floor(s) : s;
10149
+ };
10150
+
10151
+ const projectOf = (item) => snap.workspace
10152
+ ? snap.workspace.projects.find((pr) => pr.id === item.project)
10153
+ : snap.project;
10154
+
10155
+ // The only write this page ever makes: one manifest edit, quoting the
10156
+ // manifest hash it was rendered from so a concurrent hand edit is refused
10157
+ // rather than overwritten. The server answers with the rebuilt gallery.
10158
+ async function postEdit(body) {
10159
+ const res = await fetch('/api/edit', {
10160
+ method: 'POST',
10161
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10162
+ body: JSON.stringify(body),
10163
+ });
10164
+ if (!res.ok) {
10165
+ const err = new Error(await res.text());
10166
+ err.status = res.status;
10167
+ throw err;
10168
+ }
10169
+ return res.json();
10170
+ }
10171
+ const field = (label, input, hint) => {
10172
+ const w = el('label', 'field');
10173
+ w.append(el('span', null, label), input);
10174
+ if (hint) w.append(el('small', null, hint));
10175
+ return w;
10176
+ };
10177
+ const numberInput = (value, placeholder) => {
10178
+ const i = el('input'); i.type = 'number'; i.min = '16'; i.step = '1';
10179
+ i.value = value == null ? '' : String(value); i.placeholder = placeholder || '';
10180
+ return i;
10181
+ };
10182
+ const numberOrNull = (input) => input.value.trim() === '' ? null : Number(input.value);
10183
+ const splitTags = (value) => value.split(',').map((t) => t.trim()).filter(Boolean);
10184
+
10185
+ // ---- generation (only when the server holds a session budget) -------------
10186
+
10187
+ const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'review']);
10188
+ const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
10189
+ const remainingBudget = (provider) => {
10190
+ const keyed = GEN.budget.byProvider[provider];
10191
+ const ceiling = keyed !== undefined ? keyed : GEN.budget.amount;
10192
+ return ceiling === undefined ? null : Math.max(0, ceiling - (GEN.spent[provider] || 0));
10193
+ };
10194
+ async function postGenerate(body) {
10195
+ const res = await fetch('/api/generate', {
10196
+ method: 'POST',
10197
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10198
+ body: JSON.stringify(body),
10199
+ });
10200
+ if (!res.ok) { const err = new Error(await res.text()); err.status = res.status; throw err; }
10201
+ return res.json();
10202
+ }
10203
+ let jobsTimer = null;
10204
+ async function pollJobs() {
10205
+ if (!GENERATION) return;
10206
+ try {
10207
+ const res = await fetch('/api/jobs', { cache: 'no-store' });
10208
+ if (!res.ok) throw new Error(await res.text());
10209
+ GEN = await res.json();
10210
+ } catch (err) {
10211
+ $('note').textContent = ' Job status unavailable: ' + err.message;
10212
+ return;
10213
+ }
10214
+ renderJobs();
10215
+ renderHeader();
10216
+ let changed = false;
10217
+ for (const job of GEN.jobs) {
10218
+ if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) { ui.settled.add(job.id); changed = true; }
10219
+ if (job.phase === 'review' && !ui.settled.has(job.id + ':review')) { ui.settled.add(job.id + ':review'); changed = true; }
10220
+ }
10221
+ if (changed) refresh();
10222
+ const active = GEN.jobs.some((job) => ACTIVE_PHASES.has(job.phase) && job.phase !== 'review');
10223
+ clearTimeout(jobsTimer);
10224
+ if (active) jobsTimer = setTimeout(pollJobs, 2000);
10225
+ }
10226
+ function renderJobs() {
10227
+ const host = $('jobs');
10228
+ host.textContent = '';
10229
+ if (!GENERATION) return;
10230
+ for (const job of GEN.jobs) {
10231
+ if (ui.settled.has(job.id + ':dismissed')) continue;
10232
+ const row = el('div', 'job');
10233
+ const ph = el('span', 'ph');
10234
+ ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
10235
+ const what = (job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10236
+ (job.project ? ' in ' + job.project : '');
10237
+ const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
10238
+ last.title = job.keys.join('\\n');
10239
+ const acts = el('div', 'acts');
10240
+ if (job.phase === 'review' && job.review.length) {
10241
+ const b = el('button', 'primary', 'Review ' + job.review.length);
10242
+ b.type = 'button'; b.onclick = () => openReview(job.id);
10243
+ acts.append(b);
10244
+ }
10245
+ const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log');
10246
+ logBtn.type = 'button';
10247
+ logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderJobs(); };
10248
+ acts.append(logBtn);
10249
+ if (!ACTIVE_PHASES.has(job.phase)) {
10250
+ const d = el('button', null, 'Dismiss');
10251
+ d.type = 'button'; d.onclick = () => { ui.settled.add(job.id + ':dismissed'); renderJobs(); };
10252
+ acts.append(d);
10253
+ }
10254
+ row.append(ph, last, acts);
10255
+ if (ui.logs.has(job.id)) row.append(el('pre', null, job.messages.join('\\n')));
10256
+ host.append(row);
10257
+ }
10258
+ }
10259
+ function budgetLine() {
10260
+ const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
10261
+ const parts = [];
10262
+ for (const provider of [...providers].sort()) {
10263
+ const left = remainingBudget(provider);
10264
+ const unit = GEN.units[provider] || 'generations';
10265
+ if (left !== null) parts.push(provider + ': ' + fmtCost(unit, Math.round(left * 100) / 100) + ' left');
10266
+ }
10267
+ // An unkeyed ceiling belongs to whichever single provider first spends it.
10268
+ if (GEN.budget.amount !== undefined && !Object.keys(GEN.spent).length) parts.push(GEN.budget.amount + ' available');
10269
+ return parts.length ? 'session budget \u2014 ' + parts.join(' \xB7 ') : '';
10270
+ }
10271
+
10272
+ // The confirm step: what will be sent, what it is estimated to cost, and what
10273
+ // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
10274
+ function generateDialog(items, { project = null, force = false, resume = false } = {}) {
10275
+ const host = $('dialog-host');
10276
+ host.textContent = '';
10277
+ const wrap = el('div', 'dialog');
10278
+ const form = el('form');
10279
+ form.append(el('h3', null, resume ? 'Resume ' + items.length + (items.length === 1 ? ' asset' : ' assets')
10280
+ : (force ? 'Regenerate ' : 'Generate ') + items.length + (items.length === 1 ? ' asset' : ' assets')));
10281
+ const table = el('table');
10282
+ const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
10283
+ table.append(thead);
10284
+ const byProvider = new Map();
10285
+ for (const item of items) {
10286
+ const tr = el('tr');
10287
+ tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
10288
+ el('td', 'n', resume ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10289
+ table.append(tr);
10290
+ if (!resume) {
10291
+ const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
10292
+ g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
10293
+ }
10294
+ }
10295
+ form.append(table);
10296
+ const sum = el('div', 'sum');
10297
+ if (resume) {
10298
+ sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
10299
+ } else {
10300
+ for (const [provider, g] of byProvider) {
10301
+ const left = remainingBudget(provider);
10302
+ const line = el('div');
10303
+ line.append(el('b', null, provider + ': ' + fmtCost(g.unit, Math.round(g.cost * 100) / 100)));
10304
+ line.append(document.createTextNode(left === null ? ' \u2014 no session budget for this provider'
10305
+ : ' \xB7 ' + fmtCost(g.unit, Math.round(left * 100) / 100) + ' of the session budget left'));
10306
+ if (left !== null && g.cost > left) line.className = 'warn';
10307
+ sum.append(line);
10308
+ }
10309
+ 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.'));
10310
+ sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
10311
+ }
10312
+ form.append(sum);
10313
+ const actions = el('div', 'actions');
10314
+ const go = el('button', 'primary', resume ? 'Resume' : 'Generate');
10315
+ go.type = 'submit';
10316
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
10317
+ const msg = el('span', 'msg');
10318
+ actions.append(go, cancel, msg);
10319
+ form.append(actions);
10320
+ form.onsubmit = async (e) => {
10321
+ e.preventDefault();
10322
+ go.disabled = true; msg.textContent = '';
10323
+ try {
10324
+ const body = { keys: items.map((i) => i.key) };
10325
+ if (project) body.project = project;
10326
+ if (force) body.force = true;
10327
+ if (resume) body.resume = true;
10328
+ const job = await postGenerate(body);
10329
+ host.textContent = '';
10330
+ GEN.jobs.unshift(job);
10331
+ renderJobs();
10332
+ pollJobs();
10333
+ } catch (err) {
10334
+ go.disabled = false;
10335
+ msg.textContent = err.message;
10336
+ }
10337
+ };
10338
+ wrap.append(form);
10339
+ host.append(wrap);
10340
+ setTimeout(() => go.focus(), 0);
10341
+ }
10342
+
10343
+ function openReview(jobId) {
10344
+ const host = $('dialog-host');
10345
+ host.textContent = '';
10346
+ const scrim = el('div', 'sheet-scrim');
10347
+ scrim.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
10348
+ const panel = el('div', 'sheet review-host');
10349
+ const bar = el('div', 'rbar');
10350
+ const close = el('button', null, 'Back to gallery'); close.type = 'button';
10351
+ close.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
10352
+ 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.'));
10353
+ const frame = el('iframe');
10354
+ frame.src = '/review/' + encodeURIComponent(jobId);
10355
+ frame.title = 'Candidate review';
10356
+ panel.append(bar, frame);
10357
+ host.append(scrim, panel);
10358
+ }
10359
+ window.addEventListener('message', (e) => {
10360
+ if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
10361
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
10362
+ });
10363
+
10364
+ function visibleItems() {
10365
+ const q = ui.q.trim().toLowerCase();
10366
+ let items = snap.items.filter((item) => {
10367
+ if (ui.states.size && !ui.states.has(item.state)) return false;
10368
+ if (ui.providers.size && !ui.providers.has(item.provider)) return false;
10369
+ if (ui.generators.size && !ui.generators.has(item.generator)) return false;
10370
+ if (ui.projects.size && !ui.projects.has(item.project)) return false;
10371
+ if (!q) return true;
10372
+ const hay = [item.id, item.prompt, item.currentPrompt, item.jobId, item.objectId,
10373
+ item.recordedSpecHash, item.currentSpecHash, item.category, ...(item.tags || []),
10374
+ ...item.outputs.flatMap((o) => [o.path, o.sha256])].filter(Boolean).join('\\n').toLowerCase();
10375
+ return hay.includes(q);
10376
+ });
10377
+ const when = (item) => item.downloadedAt || item.submittedAt || '';
10378
+ const sorters = {
10379
+ key: (a, b) => a.id.localeCompare(b.id),
10380
+ newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
10381
+ oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
10382
+ cost: (a, b) => (b.cost - a.cost) || a.id.localeCompare(b.id),
10383
+ size: (a, b) => (b.width * b.height - a.width * a.height) || a.id.localeCompare(b.id),
10384
+ };
10385
+ items.sort(sorters[ui.sort] || sorters.key);
10386
+ return items;
10387
+ }
10388
+
10389
+ function renderHeader() {
10390
+ $('editing').hidden = !EDITABLE;
10391
+ $('foot-edit').hidden = EDITABLE;
10392
+ $('foot-editing').hidden = !EDITABLE;
10393
+ $('foot-gen').hidden = !GENERATION;
10394
+ const p = $('project');
10395
+ p.textContent = '';
10396
+ if (snap.workspace) {
10397
+ p.append(el('b', null, 'workspace'), ' ', document.createTextNode(shortPath(snap.workspace.path)));
10398
+ p.title = snap.workspace.path;
10399
+ } else {
10400
+ p.append(el('b', null, snap.project.name), ' ', document.createTextNode(shortPath(snap.project.manifest)));
10401
+ p.title = snap.project.manifest + '\\nlockfile: ' + snap.project.lock;
10402
+ }
10403
+
10404
+ const t = $('totals');
10405
+ t.textContent = '';
10406
+ if (snap.workspace) {
10407
+ const broken = snap.workspace.projects.filter((pr) => pr.error).length;
10408
+ const w = el('span', null, '');
10409
+ w.append(el('b', null, snap.workspace.projects.length), document.createTextNode(' project' +
10410
+ (snap.workspace.projects.length === 1 ? '' : 's') + (broken ? ' (' + broken + ' unreadable)' : '')));
10411
+ t.append(w);
10412
+ }
10413
+ const n = snap.totals.entries;
10414
+ t.append(el('span', null, ''));
10415
+ t.lastChild.append(el('b', null, n), document.createTextNode(' lock ' + (n === 1 ? 'entry' : 'entries')));
10416
+ const declared = snap.items.filter((i) => i.declared).length;
10417
+ t.append(el('span', null, declared + ' declared by ' + (snap.workspace ? 'a manifest' : 'the manifest')));
10418
+ const recorded = fmtSpend(snap.totals.spendByUnit);
10419
+ t.append(el('span', null, recorded === 'no spend recorded' ? recorded : recorded + ' recorded'));
10420
+ if (snap.filter.styles.length || snap.filter.assets.length) {
10421
+ t.append(el('span', 'state-warn', 'filtered: ' +
10422
+ [snap.filter.styles.length ? '--style ' + snap.filter.styles.join(',') : '',
10423
+ snap.filter.assets.length ? '--only ' + snap.filter.assets.join(',') : ''].filter(Boolean).join(' ')));
10424
+ }
10425
+ t.append(el('span', null, 'snapshot ' + fmtWhen(snap.generatedAt)));
10426
+ if (GENERATION) {
10427
+ const line = budgetLine();
10428
+ if (line) t.append(el('span', 'budget', line));
10429
+ }
10430
+
10431
+ const chips = $('chips');
10432
+ chips.textContent = '';
10433
+ const counts = (pick) => {
10434
+ const m = new Map();
10435
+ for (const item of snap.items) { const k = pick(item); m.set(k, (m.get(k) || 0) + 1); }
10436
+ return m;
10437
+ };
10438
+ const chip = (label, count, set, key, tone) => {
10439
+ const b = el('button', 'chip' + (set.has(key) ? ' on' : ''));
10440
+ b.type = 'button';
10441
+ b.setAttribute('aria-pressed', String(set.has(key)));
10442
+ if (tone) b.append(el('i', 'dot ' + tone));
10443
+ b.append(document.createTextNode(label), el('span', 'n', count));
10444
+ b.onclick = () => { set.has(key) ? set.delete(key) : set.add(key); render(); };
10445
+ return b;
10446
+ };
10447
+ if (snap.workspace) {
10448
+ const byProject = counts((i) => i.project);
10449
+ for (const pr of snap.workspace.projects) {
10450
+ if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
10451
+ }
10452
+ chips.append(el('span', 'sep'));
10453
+ }
10454
+ const byState = counts((i) => i.state);
10455
+ for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
10456
+ const byProvider = counts((i) => i.provider);
10457
+ if (byProvider.size > 1 || ui.providers.size) {
10458
+ chips.append(el('span', 'sep'));
10459
+ for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
10460
+ }
10461
+ const byGen = counts((i) => i.generator);
10462
+ if (byGen.size > 1 || ui.generators.size) {
10463
+ chips.append(el('span', 'sep'));
10464
+ for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
10465
+ }
10466
+ if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
10467
+ const clear = el('button', 'chip', 'clear filters');
10468
+ clear.type = 'button';
10469
+ clear.onclick = () => clearFilters();
10470
+ chips.append(el('span', 'sep'), clear);
10471
+ }
10472
+ }
10473
+
10474
+ function clearFilters() {
10475
+ ui.states.clear(); ui.providers.clear(); ui.generators.clear(); ui.projects.clear();
10476
+ ui.q = ''; $('q').value = '';
10477
+ render();
10478
+ }
10479
+
10480
+ function thumb(item) {
10481
+ const cell = el('div', 'cell');
10482
+ const shown = item.outputs.filter((o) => o.url);
10483
+ if (!shown.length) {
10484
+ cell.classList.add('none');
10485
+ cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
10486
+ : item.state === 'in-flight' ? (item.status === 'review' ? 'awaiting review' : 'awaiting provider')
10487
+ : item.state === 'recoverable' ? 'ready to fetch'
10488
+ : item.state === 'failed' ? 'generation failed'
10489
+ : item.state === 'blocked' ? 'waiting on its parent'
10490
+ : item.outputs.length ? 'file missing on disk' : 'no output recorded'));
10491
+ return cell;
10492
+ }
10493
+ const w = item.width, h = item.height;
10494
+ if (shown.length > 1 && !isFrameSet(item)) {
10495
+ const multi = el('div', 'multi');
10496
+ for (const o of shown.slice(0, 4)) {
10497
+ const box = el('div');
10498
+ const img = el('img');
10499
+ img.src = o.url; img.alt = o.role || item.assetId; img.loading = 'lazy';
10500
+ const s = displayScale(w, h, 66, 52);
10501
+ if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
10502
+ box.append(img); multi.append(box);
10503
+ }
10504
+ cell.append(multi, el('span', 'badge', shown.length + ' outputs'));
10505
+ return cell;
10506
+ }
10507
+ const img = el('img');
10508
+ img.src = shown[0].url; img.alt = item.assetId; img.loading = 'lazy';
10509
+ const s = displayScale(w, h, 156, 116);
10510
+ if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
10511
+ cell.append(img);
10512
+ if (isFrameSet(item)) cell.append(el('span', 'badge', '\u25B8 ' + shown.length + ' frames'));
10513
+ else if (s >= 1 && s !== 1) cell.append(el('span', 'badge', s + '\xD7'));
10514
+ return cell;
10515
+ }
10516
+
10517
+ function card(item) {
10518
+ const slot = ui.compare.indexOf(item.id);
10519
+ const c = el('button', 'card' + (item.outputs.some((o) => o.url) ? '' : ' ghost') + (ui.open === item.id ? ' active' : '') + (slot >= 0 ? ' compared' : ''));
10520
+ c.type = 'button';
10521
+ c.dataset.key = item.id;
10522
+ c.setAttribute('aria-label', item.id + ', ' + item.state + (slot >= 0 ? ', in comparison' : ''));
10523
+ const body = el('div', 'cbody');
10524
+ body.append(el('div', 'aid', item.assetId));
10525
+ const meta = el('div', 'cmeta');
10526
+ const st = el('span', 'st');
10527
+ st.append(el('i', 'dot ' + STATE_TONE[item.state]), document.createTextNode(item.state));
10528
+ meta.append(st, el('span', null, item.width + '\xD7' + item.height));
10529
+ if (item.cost) meta.append(el('span', null, fmtCost(item.costUnit, item.cost)));
10530
+ body.append(meta);
10531
+ const cell = thumb(item);
10532
+ if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
10533
+ c.append(cell, body);
10534
+ c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
10535
+ return c;
10536
+ }
10537
+
10538
+ function renderMain(items) {
10539
+ const root = $('root');
10540
+ root.textContent = '';
10541
+ if (!snap.items.length) {
10542
+ const e = el('div', 'empty');
10543
+ e.append(el('p', null, 'Nothing has been generated for this project yet.'));
10544
+ e.append(el('p', null, 'pixelkiln plan shows what the manifest would make; pixelkiln gen makes it.'));
10545
+ root.append(e);
10546
+ return;
10547
+ }
10548
+ if (!items.length) {
10549
+ const e = el('div', 'empty');
10550
+ e.append(el('p', null, 'No generations match these filters.'));
10551
+ const b = el('button', null, 'Clear filters');
10552
+ b.type = 'button';
10553
+ b.onclick = clearFilters;
10554
+ e.append(b);
10555
+ root.append(e);
10556
+ return;
10557
+ }
10558
+ // Building every card for a very large project on each keystroke is the one
10559
+ // thing that makes this page feel slow, so a render is capped and the rest
10560
+ // is one click away. Filters usually narrow well below the cap anyway.
10561
+ const hidden = Math.max(0, items.length - ui.limit);
10562
+ const shown = hidden ? items.slice(0, ui.limit) : items;
10563
+ // A workspace always breaks on project: lock keys repeat across projects,
10564
+ // so a flat wall of "anvil" cards would be ambiguous. Style grouping stays
10565
+ // optional inside each project, exactly as it is for one project.
10566
+ const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
10567
+ const projects = snap.workspace
10568
+ ? snap.workspace.projects.filter((pr) => ui.projects.size
10569
+ ? ui.projects.has(pr.id)
10570
+ : unfiltered || shown.some((i) => i.project === pr.id))
10571
+ : [null];
10572
+ const sections = [];
10573
+ for (const pr of projects) {
10574
+ const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
10575
+ const styleSections = ui.group === 'style'
10576
+ ? snap.styles.filter((s) => !pr || s.project === pr.id)
10577
+ .map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length)
10578
+ : (inProject.length ? [{ style: null, items: inProject }] : []);
10579
+ if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
10580
+ sections.push(...styleSections);
10581
+ }
10582
+ for (const sec of sections) {
10583
+ if (sec.project) {
10584
+ root.append(projectHeader(sec.project, sec.count));
10585
+ continue;
10586
+ }
10587
+ const wrap = el('section', 'style');
10588
+ if (sec.style) {
10589
+ const s = sec.style;
10590
+ const head = el('div', 'shead');
10591
+ head.append(el('h2', null, s.id));
10592
+ const meta = el('div', 'meta');
10593
+ meta.append(el('span', null, s.provider), el('span', null, s.generator));
10594
+ if (s.outDir) meta.append(el('span', 'mono', s.outDir));
10595
+ if (s.palette.length) {
10596
+ const sw = el('span', 'swatches');
10597
+ for (const c of s.palette) { const i = el('i'); i.style.background = c; i.title = c; sw.append(i); }
10598
+ meta.append(sw);
10599
+ }
10600
+ if (s.quality) meta.append(el('span', null, 'quality profile'));
10601
+ meta.append(el('span', null, sec.items.length + ' of ' + s.items + (s.items === 1 ? ' asset' : ' assets')));
10602
+ if (Object.keys(s.spendByUnit).length) meta.append(el('span', null, fmtSpend(s.spendByUnit)));
10603
+ head.append(meta);
10604
+ const addKey = 'new:' + (s.project || '') + ':' + s.id;
10605
+ const tools = el('div', 'tools');
10606
+ if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
10607
+ if (GENERATION && s.actionable.keys.length) {
10608
+ const g = el('button', 'primary', 'Generate ' + s.actionable.keys.length + ' \xB7 ' + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
10609
+ g.type = 'button';
10610
+ g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
10611
+ tools.append(g);
10612
+ }
10613
+ const styleKey = 'style:' + (s.project || '') + ':' + s.id;
10614
+ if (EDITABLE && s.outDir) {
10615
+ const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
10616
+ es.type = 'button';
10617
+ es.onclick = () => { ui.editing = ui.editing === styleKey ? null : styleKey; ui.notice = null; render(); };
10618
+ tools.append(es);
10619
+ const add = el('button', 'add', ui.editing === addKey ? 'Cancel' : '+ Add asset');
10620
+ add.type = 'button';
10621
+ add.onclick = () => { ui.editing = ui.editing === addKey ? null : addKey; render(); };
10622
+ tools.append(add);
10623
+ }
10624
+ if (tools.childNodes.length) head.append(tools);
10625
+ wrap.append(head);
10626
+ if (ui.notice && ui.notice.id === styleKey) wrap.append(el('div', 'notice', ui.notice.text));
10627
+ if (ui.editing === styleKey) wrap.append(styleForm(s));
10628
+ if (ui.editing === addKey) wrap.append(addAssetForm(s));
10629
+ }
10630
+ const grid = el('div', 'grid');
10631
+ for (const item of sec.items) grid.append(card(item));
10632
+ wrap.append(grid);
10633
+ root.append(wrap);
10634
+ }
10635
+ if (hidden) {
10636
+ const more = el('div', 'empty');
10637
+ more.append(el('p', null, hidden + ' more ' + (hidden === 1 ? 'generation is' : 'generations are') + ' not shown.'));
10638
+ const b = el('button', null, 'Show all ' + items.length);
10639
+ b.type = 'button';
10640
+ b.onclick = () => { ui.limit = Infinity; render(); };
10641
+ more.append(b);
10642
+ root.append(more);
10643
+ }
10644
+ }
10645
+
10646
+ function projectHeader(pr, count) {
10647
+ const head = el('section', 'project');
10648
+ const line = el('div', 'phead');
10649
+ line.append(el('h2', null, pr.id));
10650
+ const meta = el('div', 'meta');
10651
+ if (pr.name !== pr.id) meta.append(el('span', null, pr.name));
10652
+ meta.append(el('span', 'mono', shortPath(pr.manifest)));
10653
+ if (pr.account) meta.append(el('span', null, pr.account));
10654
+ if (pr.error) {
10655
+ meta.append(el('span', 'state-bad', 'unreadable: ' + pr.error));
10656
+ } else {
10657
+ meta.append(el('span', null, count + ' of ' + pr.items + ' shown \xB7 ' + pr.entries + ' lock ' + (pr.entries === 1 ? 'entry' : 'entries')));
10658
+ if (Object.keys(pr.spendByUnit).length) meta.append(el('span', null, fmtSpend(pr.spendByUnit)));
10659
+ }
10660
+ line.append(meta);
10661
+ head.append(line);
10662
+ return head;
10663
+ }
10664
+
10665
+ // How many images one generation returns for this style. A provider option
10666
+ // where the adapter has one; a fact of the generator where it does not.
10667
+ function candidatesControl(style) {
10668
+ const wrap = el('span', 'cand');
10669
+ const editKey = 'cand:' + (style.project || '') + ':' + style.id;
10670
+ const declared = snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
10671
+ if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
10672
+ wrap.append(el('span', null, style.candidates + (style.candidates === 1 ? ' candidate' : ' candidates') + ' per generation'));
10673
+ if (EDITABLE && style.candidatesEditable) {
10674
+ const b = el('button', null, 'change'); b.type = 'button';
10675
+ b.onclick = () => { ui.editing = editKey; render(); };
10676
+ wrap.append(b);
10677
+ } else if (style.provider === 'pixellab') {
10678
+ wrap.title = 'PixelLab: map and pixflux return one image per generation; a 1dir style returns 4\u201364 for its size.';
10679
+ }
10680
+ return wrap;
10681
+ }
10682
+ const input = el('input'); input.type = 'number'; input.min = '1'; input.max = '64'; input.value = String(style.candidates);
10683
+ const save = el('button', 'primary', 'Save'); save.type = 'button';
10684
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
10685
+ const note = el('span', null, 'affects ' + declared + (declared === 1 ? ' asset' : ' assets') + ' \u2014 they become stale');
10686
+ save.onclick = async () => {
10687
+ save.disabled = true;
10688
+ try {
10689
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
10690
+ const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
10691
+ if (style.project) body.project = style.project;
10692
+ snap = await postEdit(body);
10693
+ ui.editing = null;
10694
+ render();
10695
+ } catch (err) {
10696
+ save.disabled = false;
10697
+ note.textContent = err.message;
10698
+ note.className = 'state-bad';
10699
+ }
10700
+ };
10701
+ wrap.append(input, save, cancel, note);
10702
+ setTimeout(() => input.focus(), 0);
10703
+ return wrap;
10704
+ }
10705
+
10706
+ function generateActions(item) {
10707
+ const row = el('div', 'gen');
10708
+ if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
10709
+ const cost = item.estimatedCost === null ? '' : ' \xB7 ' + fmtCost(item.costUnit, item.estimatedCost);
10710
+ const add = (label, cls, opts) => {
10711
+ const b = el('button', cls, label); b.type = 'button';
10712
+ b.onclick = () => generateDialog([item], { project: item.project, ...opts });
10713
+ row.append(b);
10714
+ };
10715
+ if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
10716
+ else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
10717
+ if (item.state === 'in-flight' || item.state === 'recoverable') {
10718
+ // A job already parked this asset in review: go straight to its sheet.
10719
+ const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
10720
+ if (holder) {
10721
+ const b = el('button', 'primary', 'Review candidates'); b.type = 'button';
10722
+ b.onclick = () => openReview(holder.id);
10723
+ row.append(b);
10724
+ } else {
10725
+ add(item.status === 'review' ? 'Resume review' : 'Resume \xB7 no cost', 'primary', { resume: true });
10726
+ }
10727
+ }
10728
+ return row;
10729
+ }
10730
+
10731
+ // ---- compare ---------------------------------------------------------------
10732
+
10733
+ function toggleCompare(id) {
10734
+ const at = ui.compare.indexOf(id);
10735
+ if (at >= 0) ui.compare.splice(at, 1);
10736
+ else if (ui.compare.length >= 4) { $('note').textContent = ' Compare holds four records; remove one first.'; return; }
10737
+ else ui.compare.push(id);
10738
+ $('note').textContent = '';
10739
+ render();
10740
+ }
10741
+ function renderTray() {
10742
+ let tray = $('tray');
10743
+ if (!ui.compare.length) { if (tray) tray.remove(); return; }
10744
+ if (!tray) { tray = el('div', 'tray'); tray.id = 'tray'; document.body.append(tray); }
10745
+ tray.textContent = '';
10746
+ const chips = el('div', 'chipset');
10747
+ for (const id of ui.compare) {
10748
+ const item = snap.items.find((i) => i.id === id);
10749
+ if (!item) continue;
10750
+ const pick = el('span', 'pick');
10751
+ const shown = item.outputs.find((o) => o.url);
10752
+ if (shown) { const t = el('img'); t.src = shown.url; t.alt = ''; pick.append(t); }
10753
+ pick.append(document.createTextNode((item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId));
10754
+ const x = el('button', null, '\xD7'); x.type = 'button'; x.title = 'Remove from comparison';
10755
+ x.onclick = () => toggleCompare(id);
10756
+ pick.append(x);
10757
+ chips.append(pick);
10758
+ }
10759
+ const go = el('button', 'primary', 'Compare ' + ui.compare.length); go.type = 'button';
10760
+ go.disabled = ui.compare.length < 2;
10761
+ go.onclick = () => openCompare();
10762
+ const clear = el('button', null, 'Clear'); clear.type = 'button';
10763
+ clear.onclick = () => { ui.compare = []; render(); };
10764
+ tray.append(chips, go, clear);
10765
+ }
10766
+ function openCompare() {
10767
+ const items = ui.compare.map((id) => snap.items.find((i) => i.id === id)).filter(Boolean);
10768
+ if (items.length < 2) return;
10769
+ const host = $('dialog-host');
10770
+ host.textContent = '';
10771
+ const scrim = el('div', 'sheet-scrim'); scrim.onclick = () => { host.textContent = ''; };
10772
+ const panel = el('div', 'sheet compare-host');
10773
+ const bar = el('div', 'rbar');
10774
+ const close = el('button', null, 'Back to gallery'); close.type = 'button'; close.onclick = () => { host.textContent = ''; };
10775
+ bar.append(close, el('span', null, 'Fields that differ are tinted. Shift-click cards in the gallery to change the set.'));
10776
+ const zoomBar = el('div', 'zoom');
10777
+ zoomBar.append(el('span', null, 'zoom'));
10778
+ const columnWidth = Math.max(160, Math.floor((Math.min(window.innerWidth * 0.96, 1500) - 140) / items.length) - 24);
10779
+ const largest = Math.max(...items.map((i) => Math.max(i.width, i.height)));
10780
+ const auto = displayScale(largest, largest, columnWidth, Math.min(window.innerHeight * 0.5, 520));
10781
+ for (const z of ['fit', 1, 2, 4, 8]) {
10782
+ const b = el('button', ui.compareZoom === String(z) || (ui.compareZoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
10783
+ b.type = 'button'; b.onclick = () => { ui.compareZoom = String(z); openCompare(); };
10784
+ zoomBar.append(b);
10785
+ }
10786
+ bar.append(zoomBar);
10787
+ const zoom = ui.compareZoom === 'auto' ? auto : ui.compareZoom === 'fit' ? Math.min(auto, 1) : Number(ui.compareZoom);
10788
+
10789
+ const body = el('div', 'compare-body');
10790
+ const table = el('table');
10791
+ const thead = el('thead'); const hr = el('tr'); hr.append(el('th', null, ''));
10792
+ for (const item of items) {
10793
+ const th = el('th');
10794
+ const head = el('div', 'chead');
10795
+ const badges = el('div', 'badges');
10796
+ if (item.project) badges.append(el('span', 'sid project', item.project));
10797
+ badges.append(el('span', 'sid', item.styleId));
10798
+ const aid = el('div', 'aid', item.assetId);
10799
+ const openBtn = el('button', 'linkish', 'open record'); openBtn.type = 'button';
10800
+ openBtn.onclick = () => { host.textContent = ''; openItem(item.id); };
10801
+ head.append(badges, aid, openBtn);
10802
+ th.append(head);
10803
+ hr.append(th);
10804
+ }
10805
+ thead.append(hr); table.append(thead);
10806
+ const tbody = el('tbody');
10807
+
10808
+ // Images first, at one shared zoom, so like is compared with like.
10809
+ const imgRow = el('tr'); imgRow.append(el('td', null, 'art'));
10810
+ for (const item of items) {
10811
+ const td = el('td');
10812
+ const box = el('div', 'cimg');
10813
+ const shown = item.outputs.find((o) => o.url);
10814
+ if (shown) {
10815
+ const img = el('img'); img.src = shown.url; img.alt = item.assetId;
10816
+ if (zoom >= 1) { img.width = Math.round(item.width * zoom); img.height = Math.round(item.height * zoom); }
10817
+ else { img.style.maxWidth = '100%'; img.style.height = 'auto'; }
10818
+ box.append(img);
10819
+ } else box.append(el('span', 'none', 'no art on disk'));
10820
+ td.append(box);
10821
+ if (item.outputs.filter((o) => o.url).length > 1) td.append(el('div', 'state-dim', item.outputs.length + ' outputs; first shown'));
10822
+ imgRow.append(td);
10823
+ }
10824
+ tbody.append(imgRow);
10825
+
10826
+ const rows = [
10827
+ ['state', (i) => i.state + (i.reason ? ' \u2014 ' + i.reason : ''), (i) => i.state],
10828
+ ['provider', (i) => i.provider],
10829
+ ['generator', (i) => i.generator + (i.tileFeature ? ' \xB7 ' + i.tileFeature : '')],
10830
+ ['candidates', (i) => i.candidates ?? '\u2014'],
10831
+ ['size', (i) => i.width + ' \xD7 ' + i.height],
10832
+ ['cost', (i) => i.status ? fmtCost(i.costUnit, i.cost) : (i.estimatedCost !== null ? 'est. ' + fmtCost(i.costUnit, i.estimatedCost) : '\u2014')],
10833
+ ['prompt', (i) => i.prompt, null, 'prompt'],
10834
+ ['submitted', (i) => fmtWhen(i.submittedAt) || '\u2014'],
10835
+ ['downloaded', (i) => fmtWhen(i.downloadedAt) || '\u2014'],
10836
+ ['quality', (i) => i.quality ? i.quality.state + (i.quality.review && i.quality.review.status === 'approved' ? ' by ' + i.quality.review.reviewer : '') : '\u2014'],
10837
+ ['spec hash', (i) => i.recordedSpecHash ? i.recordedSpecHash.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
10838
+ ['sha256', (i) => i.outputs[0] && i.outputs[0].sha256 ? i.outputs[0].sha256.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
10839
+ ['tags', (i) => (i.tags || []).filter((t) => !/^(pixelkiln|asset|style):/.test(t)).join(', ') || '\u2014'],
10840
+ ];
10841
+ for (const [label, show, keyOf, cls] of rows) {
10842
+ const tr = el('tr'); tr.append(el('td', null, label));
10843
+ const keys = items.map((i) => String((keyOf || show)(i)));
10844
+ const differs = new Set(keys).size > 1;
10845
+ for (const item of items) {
10846
+ const td = el('td', (differs ? 'diff' : '') + (cls === 'mono' ? ' mono' : ''));
10847
+ if (cls === 'prompt') td.append(el('div', 'prompt', show(item))); else td.textContent = String(show(item));
10848
+ tr.append(td);
10849
+ }
10850
+ tbody.append(tr);
10851
+ }
10852
+ table.append(tbody);
10853
+ body.append(table);
10854
+ panel.append(bar, body);
10855
+ host.append(scrim, panel);
10856
+ close.focus({ preventScroll: true });
10857
+ }
10858
+
10859
+ // ---- editing (only when the server minted a session) ---------------------
10860
+
10861
+ // Styles a parent edit reaches: every descendant (via extends) that does not
10862
+ // declare the field itself. Child-wins means an omitted field is inherited,
10863
+ // so the count follows the manifest's own rule rather than guessing.
10864
+ function inheritorsOf(style, field) {
10865
+ const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
10866
+ const out = [];
10867
+ for (const other of byId.values()) {
10868
+ if (other.id === style.id) continue;
10869
+ let cur = other, blocked = false, hops = 0;
10870
+ while (cur && cur.id !== style.id && hops++ < 32) {
10871
+ if (cur.ownFields.includes(field)) { blocked = true; break; }
10872
+ cur = cur.extends ? byId.get(cur.extends) : null;
10873
+ }
10874
+ if (cur && cur.id === style.id && !blocked) out.push(other.id);
10875
+ }
10876
+ return out;
10877
+ }
10878
+ function blastRadius(style, fields) {
10879
+ const styleIds = new Set([style.id]);
10880
+ for (const field of fields) for (const id of inheritorsOf(style, field)) styleIds.add(id);
10881
+ const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
10882
+ const units = new Map();
10883
+ for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
10884
+ return {
10885
+ styles: [...styleIds],
10886
+ assets: affected.length,
10887
+ cost: [...units].map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + '),
10888
+ };
10889
+ }
10890
+ const parsePalette = (value) => value.split(/[s,]+/).map((c) => c.trim()).filter(Boolean)
10891
+ .map((c) => '#' + c.replace(/^#/, '').toLowerCase());
10892
+ const paletteValid = (colors) => colors.every((c) => /^#[0-9a-f]{6}$/.test(c));
10893
+
10894
+ function styleForm(style) {
10895
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
10896
+ const own = (field) => style.ownFields.includes(field);
10897
+ const provenance = (field) => own(field) ? 'set on this style'
10898
+ : style.extends ? 'inherited from ' + style.extends + ' \u2014 saving sets a value on this style itself; ' + style.extends + ' is unchanged'
10899
+ : 'default \u2014 saving sets a value on this style itself';
10900
+ const form = el('form', 'edit style-form');
10901
+ form.append(el('h3', null, 'Edit style ' + style.id));
10902
+ const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
10903
+ const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
10904
+ const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb \u2014 leave empty for no forced palette';
10905
+ const swatches = el('div', 'pal');
10906
+ const drawSwatches = () => {
10907
+ swatches.textContent = '';
10908
+ for (const c of parsePalette(palette.value)) { const i = el('i'); i.style.background = c; i.title = c; swatches.append(i); }
10909
+ };
10910
+ drawSwatches();
10911
+ form.append(
10912
+ field('prompt prefix', prefix, 'Prepended to every asset prompt in this style. ' + provenance('promptPrefix')),
10913
+ field('prompt suffix', suffix, 'Appended to every asset prompt in this style. ' + provenance('promptSuffix')),
10914
+ field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
10915
+ swatches,
10916
+ );
10917
+ const blast = el('div', 'blast');
10918
+ const changed = () => {
10919
+ const out = [];
10920
+ if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
10921
+ if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
10922
+ if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
10923
+ return out;
10924
+ };
10925
+ const updateBlast = () => {
10926
+ const fields = changed();
10927
+ drawSwatches();
10928
+ if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
10929
+ const r = blastRadius(style, fields);
10930
+ blast.className = 'blast hot';
10931
+ blast.textContent = '';
10932
+ const others = r.styles.filter((id) => id !== style.id);
10933
+ blast.append(el('b', null, 'Affects ' + r.assets + (r.assets === 1 ? ' asset' : ' assets')));
10934
+ blast.append(document.createTextNode(others.length
10935
+ ? ' in ' + style.id + ' and ' + others.length + (others.length === 1 ? ' style that inherits it (' : ' styles that inherit it (') + others.join(', ') + ').'
10936
+ : ' in ' + style.id + '.'));
10937
+ blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
10938
+ };
10939
+ for (const input of [prefix, suffix, palette]) input.addEventListener('input', updateBlast);
10940
+ updateBlast();
10941
+ form.append(blast);
10942
+ const actions = el('div', 'actions');
10943
+ const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
10944
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
10945
+ const msg = el('span', 'msg');
10946
+ actions.append(save, cancel, msg);
10947
+ form.append(actions);
10948
+ form.onsubmit = async (e) => {
10949
+ e.preventDefault();
10950
+ const fields = changed();
10951
+ if (!fields.length) { ui.editing = null; render(); return; }
10952
+ const colors = parsePalette(palette.value);
10953
+ if (fields.includes('palette') && !paletteValid(colors)) { msg.className = 'msg bad'; msg.textContent = 'palette must be six-digit hex colours'; return; }
10954
+ const patch = {};
10955
+ if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
10956
+ if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
10957
+ if (fields.includes('palette')) patch.palette = colors;
10958
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
10959
+ const r = blastRadius(style, fields);
10960
+ try {
10961
+ const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch };
10962
+ if (style.project) body.project = style.project;
10963
+ snap = await postEdit(body);
10964
+ ui.editing = null;
10965
+ ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
10966
+ 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.' };
10967
+ render();
10968
+ } catch (err) {
10969
+ save.disabled = false;
10970
+ msg.className = 'msg bad';
10971
+ msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
10972
+ }
10973
+ };
10974
+ setTimeout(() => prefix.focus(), 0);
10975
+ return form;
10976
+ }
10977
+
10978
+ function addAssetForm(style) {
10979
+ const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
10980
+ const siblings = snap.styles.filter((x) => x.project === style.project);
10981
+ const form = el('form', 'edit');
10982
+ form.append(el('h3', null, 'New asset in ' + style.id));
10983
+ const id = el('input'); id.type = 'text'; id.placeholder = 'asset-id'; id.required = true; id.autocomplete = 'off';
10984
+ id.pattern = '[^/\\\\]+';
10985
+ const prompt = el('textarea'); prompt.placeholder = 'What to generate. The style adds its prefix and suffix.'; prompt.required = true;
10986
+ const width = numberInput(null, 'style default'), height = numberInput(null, 'style default');
10987
+ const category = el('input'); category.type = 'text'; category.placeholder = 'optional subfolder';
10988
+ const only = el('input'); only.type = 'checkbox'; only.checked = siblings.length > 1;
10989
+ const onlyField = el('label', 'field check'); onlyField.append(only, el('span', null, 'only in ' + style.id));
10990
+ const row1 = el('div', 'row'); row1.append(field('id', id), field('category', category));
10991
+ const row2 = el('div', 'row'); row2.append(field('width', width), field('height', height));
10992
+ form.append(row1, field('prompt', prompt), row2);
10993
+ if (siblings.length > 1) form.append(onlyField);
10994
+ const actions = el('div', 'actions');
10995
+ const save = el('button', 'primary', 'Add asset'); save.type = 'submit';
10996
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
10997
+ const msg = el('span', 'msg');
10998
+ actions.append(save, cancel, msg);
10999
+ form.append(actions);
11000
+ form.onsubmit = async (e) => {
11001
+ e.preventDefault();
11002
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
11003
+ const asset = { prompt: prompt.value };
11004
+ if (numberOrNull(width) !== null) asset.width = numberOrNull(width);
11005
+ if (numberOrNull(height) !== null) asset.height = numberOrNull(height);
11006
+ if (category.value.trim()) asset.category = category.value.trim();
11007
+ if (siblings.length > 1 && only.checked) asset.styles = [style.id];
11008
+ try {
11009
+ const body = { action: 'add-asset', assetId: id.value.trim(), expectedSha256: pr.manifestSha256, asset };
11010
+ if (style.project) body.project = style.project;
11011
+ snap = await postEdit(body);
11012
+ const newId = (style.project ? style.project + ':' : '') + style.id + '/' + id.value.trim();
11013
+ ui.editing = null;
11014
+ ui.notice = { id: newId, text: 'Added to the manifest. Nothing is generated until you run pixelkiln gen.' };
11015
+ render();
11016
+ if (snap.items.some((i) => i.id === newId)) openItem(newId);
11017
+ } catch (err) {
11018
+ save.disabled = false;
11019
+ msg.className = 'msg bad';
11020
+ msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
11021
+ }
11022
+ };
11023
+ setTimeout(() => id.focus(), 0);
11024
+ return form;
11025
+ }
11026
+
11027
+ function editForm(item) {
11028
+ const pr = projectOf(item);
11029
+ const a = item.asset;
11030
+ const styled = !!(a.promptByStyle && Object.hasOwn(a.promptByStyle, item.styleId));
11031
+ const form = el('form', 'edit');
11032
+ form.append(el('h3', null, 'Edit intent'));
11033
+ const scope = el('select');
11034
+ scope.append(new Option('Prompt for every style', 'all'), new Option('Prompt only for ' + item.styleId, 'style'));
11035
+ scope.value = styled ? 'style' : 'all';
11036
+ const prompt = el('textarea');
11037
+ prompt.value = styled ? a.promptByStyle[item.styleId] : a.prompt;
11038
+ scope.onchange = () => {
11039
+ prompt.value = scope.value === 'style' ? (a.promptByStyle?.[item.styleId] ?? a.prompt) : a.prompt;
11040
+ };
11041
+ const width = numberInput(a.width, 'style default'), height = numberInput(a.height, 'style default'), size = numberInput(a.size, 'style default');
11042
+ const category = el('input'); category.type = 'text'; category.value = a.category || '';
11043
+ const tags = el('input'); tags.type = 'text'; tags.value = (a.tags || []).join(', '); tags.placeholder = 'comma-separated';
11044
+ form.append(field('prompt', prompt, 'The style adds its prefix and suffix; the sent prompt is shown below.'), field('applies to', scope));
11045
+ const row = el('div', 'row'); row.append(field('width', width), field('height', height), field('size', size));
11046
+ form.append(row);
11047
+ const row2 = el('div', 'row');
11048
+ row2.append(field('category', category, 'Output subfolder. The existing file stays put; restore or gen writes the new path.'), field('tags', tags));
11049
+ form.append(row2);
11050
+ const actions = el('div', 'actions');
11051
+ const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
11052
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; renderDrawer(); };
11053
+ const msg = el('span', 'msg');
11054
+ actions.append(save, cancel, msg);
11055
+ form.append(actions);
11056
+ form.onsubmit = async (e) => {
11057
+ e.preventDefault();
11058
+ const patch = {};
11059
+ if (scope.value === 'style') {
11060
+ if (!styled || prompt.value !== a.promptByStyle[item.styleId]) patch.promptForStyle = { styleId: item.styleId, prompt: prompt.value };
11061
+ } else {
11062
+ if (prompt.value !== a.prompt) patch.prompt = prompt.value;
11063
+ if (styled) patch.promptForStyle = { styleId: item.styleId, prompt: null };
11064
+ }
11065
+ for (const [key, input] of [['width', width], ['height', height], ['size', size]]) {
11066
+ const next = numberOrNull(input);
11067
+ if (next !== (a[key] ?? null)) patch[key] = next;
11068
+ }
11069
+ const nextCategory = category.value.trim() || null;
11070
+ if (nextCategory !== (a.category || null)) patch.category = nextCategory;
11071
+ const nextTags = splitTags(tags.value);
11072
+ if (nextTags.join('\\u0000') !== (a.tags || []).join('\\u0000')) patch.tags = nextTags;
11073
+ if (!Object.keys(patch).length) { ui.editing = null; renderDrawer(); return; }
11074
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
11075
+ try {
11076
+ const body = { action: 'patch-asset', assetId: item.assetId, expectedSha256: pr.manifestSha256, patch };
11077
+ if (item.project) body.project = item.project;
11078
+ snap = await postEdit(body);
11079
+ const after = snap.items.find((i) => i.id === item.id);
11080
+ ui.editing = null;
11081
+ ui.notice = {
11082
+ id: item.id,
11083
+ text: after
11084
+ ? 'Saved. plan now reports ' + after.state +
11085
+ (after.estimatedCost !== null && (after.state === 'stale' || after.state === 'missing')
11086
+ ? ' \u2014 ' + fmtCost(after.costUnit, after.estimatedCost) + ' to generate. Nothing is spent until you run pixelkiln gen.'
11087
+ : '.')
11088
+ : 'Saved.',
11089
+ };
11090
+ render();
11091
+ } catch (err) {
11092
+ save.disabled = false;
11093
+ msg.className = 'msg bad';
11094
+ msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
11095
+ }
11096
+ };
11097
+ setTimeout(() => prompt.focus(), 0);
11098
+ return form;
11099
+ }
11100
+
11101
+ // ---- detail drawer -------------------------------------------------------
11102
+
11103
+ function row(dl, label, value, opts = {}) {
11104
+ if (value === null || value === undefined || value === '') return;
11105
+ dl.append(el('dt', null, label));
11106
+ const dd = el('dd', opts.mono ? 'mono' : null);
11107
+ if (value instanceof Node) dd.append(value); else dd.textContent = String(value);
11108
+ if (opts.copy) {
11109
+ const b = el('button', 'copy', 'copy');
11110
+ b.type = 'button';
11111
+ b.onclick = async () => {
11112
+ try { await navigator.clipboard.writeText(String(opts.copy)); b.textContent = 'copied'; }
11113
+ catch { b.textContent = 'select to copy'; }
11114
+ setTimeout(() => (b.textContent = 'copy'), 1200);
11115
+ };
11116
+ dd.append(b);
11117
+ }
11118
+ dl.append(dd);
11119
+ }
11120
+ function section(title) {
11121
+ const s = el('section', 'meta');
11122
+ s.append(el('h3', null, title));
11123
+ const dl = el('dl');
11124
+ s.append(dl);
11125
+ return { s, dl };
11126
+ }
11127
+ function stateNode(state, reason) {
11128
+ const box = el('div', 'status');
11129
+ const st = el('span', 'st state-' + STATE_TONE[state]);
11130
+ st.append(el('i', 'dot ' + STATE_TONE[state]), document.createTextNode(state));
11131
+ box.append(st, el('span', 'why', reason));
11132
+ return box;
11133
+ }
11134
+ function sibling(item, key) {
11135
+ return snap.items.find((i) => i.key === key && i.project === item.project);
11136
+ }
11137
+ function keyLink(item, key, label) {
11138
+ const b = el('button', 'linkish', label || key);
11139
+ b.type = 'button';
11140
+ const target = sibling(item, key);
11141
+ if (!target) { b.disabled = true; b.title = 'not in this snapshot'; }
11142
+ b.onclick = () => openItem(target.id);
11143
+ return b;
11144
+ }
11145
+ function jsonDetails(title, value) {
11146
+ const d = el('details');
11147
+ d.append(el('summary', null, title), el('pre', null, JSON.stringify(value, null, 2)));
11148
+ return d;
11149
+ }
11150
+
11151
+ function stopPlayback() {
11152
+ if (ui.playing) { clearInterval(ui.playing); ui.playing = null; }
11153
+ }
11154
+
11155
+ function renderPreview(item, host) {
11156
+ host.textContent = '';
11157
+ stopPlayback();
11158
+ const shown = item.outputs.filter((o) => o.url);
11159
+ const preview = el('div', 'preview');
11160
+ if (!shown.length) {
11161
+ 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.'));
11162
+ host.append(preview);
11163
+ return;
11164
+ }
11165
+ const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
11166
+ const frameSet = isFrameSet(item);
11167
+ const w = item.width, h = item.height;
11168
+ const box = Math.min(window.innerWidth, 540) - 62;
11169
+ const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
11170
+ const zoom = ui.zoom === 'auto' ? auto : ui.zoom === 'fit' ? Math.min(auto, 1) : Number(ui.zoom);
11171
+ const img = el('img');
11172
+ img.src = member.url; img.alt = member.role || item.assetId;
11173
+ if (zoom >= 1) { img.width = Math.round(w * zoom); img.height = Math.round(h * zoom); }
11174
+ else { img.style.maxWidth = '100%'; img.style.maxHeight = '55vh'; img.style.width = 'auto'; img.style.height = 'auto'; }
11175
+ preview.append(img);
11176
+
11177
+ const zoomBar = el('div', 'zoom');
11178
+ zoomBar.append(el('span', null, 'zoom'));
11179
+ for (const z of ['fit', 1, 2, 4, 8]) {
11180
+ const b = el('button', ui.zoom === String(z) || (ui.zoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
11181
+ b.type = 'button';
11182
+ b.onclick = () => { ui.zoom = String(z); renderPreview(item, host); };
11183
+ zoomBar.append(b);
11184
+ }
11185
+ const label = el('span', 'grow', w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : ' fitted'));
11186
+ zoomBar.append(label);
11187
+ if (frameSet && shown.length > 1) {
11188
+ const fps = item.fps || 12;
11189
+ const play = el('button', null, 'Play ' + fps + ' fps');
11190
+ play.type = 'button';
11191
+ let idx = shown.indexOf(member);
11192
+ play.onclick = () => {
11193
+ if (ui.playing) { stopPlayback(); play.textContent = 'Play ' + fps + ' fps'; return; }
11194
+ play.textContent = 'Pause';
11195
+ ui.playing = setInterval(() => {
11196
+ if (document.hidden) return;
11197
+ idx = (idx + 1) % shown.length;
11198
+ img.src = shown[idx].url;
11199
+ label.textContent = (shown[idx].role || 'frame ' + (idx + 1)) + ' \xB7 ' + w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : '');
11200
+ for (const [i, b] of [...host.querySelectorAll('.members button')].entries()) b.classList.toggle('on', i === idx);
11201
+ }, Math.max(16, Math.round(1000 / fps)));
11202
+ };
11203
+ zoomBar.append(play);
11204
+ }
11205
+ host.append(preview, zoomBar);
11206
+
11207
+ if (shown.length > 1) {
11208
+ const strip = el('div', 'members');
11209
+ shown.forEach((o, i) => {
11210
+ const b = el('button', o === member ? 'on' : null);
11211
+ b.type = 'button';
11212
+ const t = el('img'); t.src = o.url; t.alt = o.role || 'output ' + (i + 1); t.loading = 'lazy';
11213
+ b.append(t, el('span', null, o.role || String(i + 1)));
11214
+ b.onclick = () => { ui.member = i; renderPreview(item, host); };
11215
+ strip.append(b);
11216
+ });
11217
+ host.append(strip);
11218
+ }
11219
+ }
11220
+
11221
+ function renderDrawer() {
11222
+ const host = $('drawer-host');
11223
+ host.textContent = '';
11224
+ stopPlayback();
11225
+ const item = ui.open && snap.items.find((i) => i.id === ui.open);
11226
+ if (!item) { ui.open = null; return; }
11227
+ const items = visibleItems();
11228
+ const pos = items.findIndex((i) => i.id === item.id);
11229
+
11230
+ const scrim = el('div', 'scrim');
11231
+ scrim.onclick = () => closeItem();
11232
+ const drawer = el('aside', 'drawer');
11233
+ drawer.setAttribute('role', 'dialog');
11234
+ drawer.setAttribute('aria-label', item.id);
11235
+
11236
+ const head = el('div', 'dhead');
11237
+ const title = el('div', 'title');
11238
+ if (item.project) title.append(el('span', 'sid project', item.project), ' ');
11239
+ title.append(el('span', 'sid', item.styleId), el('div', 'aid', item.assetId));
11240
+ const nav = el('div', 'nav');
11241
+ const prev = el('button', null, '\u2190'); prev.type = 'button'; prev.title = 'Previous (\u2190)';
11242
+ prev.disabled = pos <= 0; prev.onclick = () => step(-1);
11243
+ const next = el('button', null, '\u2192'); next.type = 'button'; next.title = 'Next (\u2192)';
11244
+ next.disabled = pos < 0 || pos >= items.length - 1; next.onclick = () => step(1);
11245
+ const close = el('button', null, 'Close'); close.type = 'button'; close.onclick = () => closeItem();
11246
+ const cmp = el('button', null, ui.compare.includes(item.id) ? 'Remove from compare' : 'Compare +');
11247
+ cmp.type = 'button'; cmp.onclick = () => toggleCompare(item.id);
11248
+ nav.append(cmp);
11249
+ const canEdit = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256;
11250
+ if (canEdit) {
11251
+ const edit = el('button', null, ui.editing === item.id ? 'Cancel edit' : 'Edit');
11252
+ edit.type = 'button';
11253
+ edit.onclick = () => { ui.editing = ui.editing === item.id ? null : item.id; ui.notice = null; renderDrawer(); };
11254
+ nav.append(edit);
11255
+ }
11256
+ nav.append(prev, next, close);
11257
+ head.append(title, nav);
11258
+
11259
+ const body = el('div', 'dbody');
11260
+ const previewHost = el('div');
11261
+ renderPreview(item, previewHost);
11262
+ body.append(previewHost);
11263
+ if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
11264
+ body.append(stateNode(item.state, item.reason));
11265
+ if (GENERATION) body.append(generateActions(item));
11266
+ if (canEdit && ui.editing === item.id) body.append(editForm(item));
11267
+ // Plan already quotes the error as the reason for a failed entry; only a
11268
+ // stale or superseded failure needs its own line.
11269
+ if (item.error && !item.reason.includes(item.error)) body.append(stateNode('failed', item.error));
11270
+
11271
+ // Generation \u2014 what was sent and what it cost.
11272
+ {
11273
+ const { s, dl } = section('Generation');
11274
+ row(dl, 'provider', item.provider);
11275
+ row(dl, 'generator', item.generator + (item.tileFeature ? ' \xB7 ' + item.tileFeature : ''));
11276
+ row(dl, 'lock status', item.status || 'none \u2014 nothing submitted');
11277
+ const pr = el('div', 'prompt', item.prompt); row(dl, item.status ? 'prompt sent' : 'prompt', pr);
11278
+ if (item.currentPrompt) row(dl, 'prompt now', el('div', 'prompt state-warn', item.currentPrompt));
11279
+ row(dl, 'size', item.width + ' \xD7 ' + item.height + ' px');
11280
+ if (item.status) row(dl, 'cost', fmtCost(item.costUnit, item.cost));
11281
+ if (item.estimatedCost !== null && (item.estimatedCost !== item.cost || !item.status)) {
11282
+ row(dl, 'estimate now', fmtCost(item.costUnit, item.estimatedCost) +
11283
+ (item.candidates && item.candidates > 1 ? ' for ' + item.candidates + ' candidates' : ''));
11284
+ }
11285
+ if (item.candidateIndex !== null) row(dl, 'candidate', '#' + (item.candidateIndex + 1) + ' chosen in review');
11286
+ row(dl, 'submitted', fmtWhen(item.submittedAt));
11287
+ row(dl, 'downloaded', fmtWhen(item.downloadedAt));
11288
+ row(dl, 'job id', item.jobId, { mono: true, copy: item.jobId });
11289
+ row(dl, 'object id', item.objectId, { mono: true, copy: item.objectId });
11290
+ row(dl, 'review object', item.reviewObjectId, { mono: true, copy: item.reviewObjectId });
11291
+ body.append(s);
11292
+ }
11293
+
11294
+ // Outputs \u2014 every file with its hash and whether it is really there.
11295
+ {
11296
+ const s = el('section', 'meta');
11297
+ s.append(el('h3', null, item.outputs.length === 1 ? 'Output' : 'Outputs (' + item.outputs.length + ')'));
11298
+ if (!item.outputs.length) s.append(el('div', 'state-dim', item.source ? 'placed from ' + item.source : 'none recorded'));
11299
+ for (const o of item.outputs) {
11300
+ const box = el('div', 'out');
11301
+ const role = el('div', 'role');
11302
+ role.append(document.createTextNode(o.role || (item.outputs.length > 1 ? 'primary' : 'file')));
11303
+ if (!o.exists) role.append(el('span', 'miss', 'missing on disk'));
11304
+ if (o.url) { const a = el('a', null, 'open'); a.href = o.url.split('?')[0]; a.target = '_blank'; a.rel = 'noopener'; role.append(a); }
11305
+ box.append(role);
11306
+ const dl = el('dl');
11307
+ row(dl, 'path', o.path, { mono: true, copy: o.absolutePath });
11308
+ row(dl, 'sha256', o.sha256 ? o.sha256.slice(0, 16) + '\u2026' : (o.exists ? 'not recorded' : null), { mono: true, copy: o.sha256 || undefined });
11309
+ row(dl, 'bytes', o.exists ? fmtBytes(o.bytes) : null);
11310
+ row(dl, 'type', o.mediaType);
11311
+ box.append(dl);
11312
+ s.append(box);
11313
+ }
11314
+ body.append(s);
11315
+ }
11316
+
11317
+ if (item.revision || item.revisionParentKey) {
11318
+ const { s, dl } = section('Lineage');
11319
+ row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
11320
+ if (item.revisionParentKey) row(dl, 'parent', keyLink(item, item.revisionParentKey));
11321
+ if (item.revision) {
11322
+ row(dl, 'parent sha256', item.revision.sourceSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.sourceSha256 });
11323
+ if (item.revision.maskSha256) row(dl, 'mask sha256', item.revision.maskSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.maskSha256 });
11324
+ if (item.revision.strength !== undefined) row(dl, 'strength', item.revision.strength);
11325
+ }
11326
+ body.append(s);
11327
+ }
11328
+ const children = snap.items.filter((i) => i.revisionParentKey === item.key && i.project === item.project);
11329
+ if (children.length) {
11330
+ const { s, dl } = section('Revisions from this asset');
11331
+ for (const c of children) row(dl, c.revision ? c.revision.mode : 'child', keyLink(item, c.key));
11332
+ body.append(s);
11333
+ }
11334
+
11335
+ if (item.quality) {
11336
+ const q = item.quality;
11337
+ const { s, dl } = section('Quality');
11338
+ const tone = q.state === 'approved' ? 'ok' : q.state === 'blocked' ? 'bad' : 'warn';
11339
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone + ' '), document.createTextNode(' ' + q.state));
11340
+ row(dl, 'state', st);
11341
+ row(dl, 'why', q.reason);
11342
+ if (q.review) {
11343
+ row(dl, 'review', q.review.status === 'approved'
11344
+ ? 'approved by ' + q.review.reviewer + ' \xB7 ' + fmtWhen(q.review.approvedAt)
11345
+ : 'pending human 1\xD7 review');
11346
+ if (q.review.status === 'approved' && q.review.note) row(dl, 'note', q.review.note);
11347
+ }
11348
+ if (q.palette) {
11349
+ const pal = el('div', 'pal');
11350
+ for (const c of q.palette) { const i = el('i'); i.style.background = c; i.title = c; pal.append(i); }
11351
+ row(dl, 'palette', pal);
11352
+ row(dl, 'colors', q.palette.length + ' declared' + (q.audit ? ', ' + q.audit.colorCount + ' used' : ''));
11353
+ }
11354
+ if (q.nativeGrid) {
11355
+ row(dl, 'native grid', q.nativeGrid.sourceWidth + '\xD7' + q.nativeGrid.sourceHeight + ' \u2192 ' +
11356
+ q.nativeGrid.nativeWidth + '\xD7' + q.nativeGrid.nativeHeight + ' (step ' + q.nativeGrid.stepX + '\xD7' + q.nativeGrid.stepY +
11357
+ ', ' + q.nativeGrid.confidence + ' confidence)');
11358
+ }
11359
+ if (q.audit) row(dl, 'audit', (q.audit.safe ? 'passed' : 'failed') + ' \xB7 ' + Math.round(q.audit.transparency * 100) + '% transparent' +
11360
+ (q.audit.reasons.length ? ' \xB7 ' + q.audit.reasons.join('; ') : ''));
11361
+ if (q.frameSet) row(dl, 'frame set', q.frameSet.count + ' frames at ' + q.frameSet.fps + ' fps');
11362
+ row(dl, 'output', q.output, { mono: true });
11363
+ row(dl, 'record', q.record + (q.recordExists ? '' : ' (not written yet)'), { mono: true });
11364
+ if (q.check && !q.check.safe) row(dl, 'check', el('div', 'state-warn', q.check.reasons.join('\\n')));
11365
+ body.append(s);
11366
+ if (q.outputs.some((o) => o.url)) {
11367
+ const strip = el('div', 'members');
11368
+ for (const o of q.outputs.filter((o) => o.url)) {
11369
+ const b = el('button'); b.type = 'button';
11370
+ const t = el('img'); t.src = o.url; t.alt = 'refined ' + (o.role || item.assetId);
11371
+ b.append(t, el('span', null, 'refined')); b.onclick = () => window.open(o.url.split('?')[0], '_blank');
11372
+ strip.append(b);
11373
+ }
11374
+ s.append(strip);
11375
+ }
11376
+ }
11377
+
11378
+ {
11379
+ const { s, dl } = section('Identity');
11380
+ if (item.project) row(dl, 'project', item.project);
11381
+ row(dl, 'lock key', item.key, { mono: true, copy: item.key });
11382
+ if (item.recordedSpecHash) {
11383
+ row(dl, 'spec hash', item.recordedSpecHash.slice(0, 16) + '\u2026', { mono: true, copy: item.recordedSpecHash });
11384
+ }
11385
+ if (item.currentSpecHash && item.currentSpecHash !== item.recordedSpecHash) {
11386
+ row(dl, item.recordedSpecHash ? 'spec hash now' : 'spec hash',
11387
+ el('span', item.recordedSpecHash ? 'state-warn' : null, item.currentSpecHash.slice(0, 16) + '\u2026'), { mono: true, copy: item.currentSpecHash });
11388
+ }
11389
+ row(dl, 'declared', item.declared ? 'yes' : 'no \u2014 the manifest no longer has this asset in this style');
11390
+ row(dl, 'category', item.category);
11391
+ row(dl, 'tags', item.tags && item.tags.length ? item.tags.join(', ') : null);
11392
+ row(dl, 'source art', item.source, { mono: true });
11393
+ body.append(s);
11394
+ }
11395
+
11396
+ if (item.asset) {
11397
+ // Show the asset as the author would write it: schema defaults that are
11398
+ // empty add nothing but noise to a record.
11399
+ const declared = Object.fromEntries(Object.entries(item.asset).filter(([, v]) =>
11400
+ !(Array.isArray(v) && !v.length) && !(v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length)));
11401
+ body.append(jsonDetails('Manifest asset', declared));
11402
+ }
11403
+ if (item.providerMetadata && Object.keys(item.providerMetadata).length) {
11404
+ body.append(jsonDetails('Provider metadata', item.providerMetadata));
11405
+ }
11406
+
11407
+ drawer.append(head, body);
11408
+ host.append(scrim, drawer);
11409
+ close.focus({ preventScroll: true });
11410
+ }
11411
+
11412
+ // View state lives in the URL \u2014 filters and sort in the query, the open
11413
+ // record in the hash \u2014 so a link to "every failed generation" or to one
11414
+ // specific record can be pasted into a review thread and land there.
11415
+ function readUrlState() {
11416
+ const params = new URLSearchParams(location.search);
11417
+ const list = (name) => (params.get(name) || '').split(',').map((v) => v.trim()).filter(Boolean);
11418
+ ui.q = params.get('q') || '';
11419
+ ui.states = new Set(list('state'));
11420
+ ui.providers = new Set(list('provider'));
11421
+ ui.generators = new Set(list('generator'));
11422
+ ui.projects = new Set(list('project'));
11423
+ ui.compare = list('compare').slice(0, 4);
11424
+ if (['key', 'newest', 'oldest', 'cost', 'size'].includes(params.get('sort'))) ui.sort = params.get('sort');
11425
+ if (['style', 'none'].includes(params.get('group'))) ui.group = params.get('group');
11426
+ }
11427
+ function writeUrlState() {
11428
+ const params = new URLSearchParams();
11429
+ if (ui.q) params.set('q', ui.q);
11430
+ if (ui.states.size) params.set('state', [...ui.states].join(','));
11431
+ if (ui.providers.size) params.set('provider', [...ui.providers].join(','));
11432
+ if (ui.generators.size) params.set('generator', [...ui.generators].join(','));
11433
+ if (ui.projects.size) params.set('project', [...ui.projects].join(','));
11434
+ if (ui.compare.length) params.set('compare', ui.compare.join(','));
11435
+ if (ui.sort !== 'key') params.set('sort', ui.sort);
11436
+ if (ui.group !== 'style') params.set('group', ui.group);
11437
+ const query = params.toString();
11438
+ const next = location.pathname + (query ? '?' + query : '') + (ui.open ? '#' + encodeURIComponent(ui.open) : '');
11439
+ if (next !== location.pathname + location.search + location.hash) history.replaceState(null, '', next);
11440
+ }
11441
+ function syncHash() { writeUrlState(); }
11442
+ function keyFromHash() {
11443
+ try { return location.hash.length > 1 ? decodeURIComponent(location.hash.slice(1)) : null; }
11444
+ catch { return null; }
11445
+ }
11446
+ // Opening a record must not rebuild the grid: at a few thousand cards that
11447
+ // is a visible pause, and nothing in the grid changes except one highlight.
11448
+ function markActive(key) {
11449
+ document.querySelector('.card.active')?.classList.remove('active');
11450
+ const card = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
11451
+ if (card) card.classList.add('active');
11452
+ return card;
11453
+ }
11454
+ function openItem(key) {
11455
+ if (ui.open !== key) { ui.editing = null; if (ui.notice && ui.notice.id !== key) ui.notice = null; }
11456
+ ui.open = key; ui.member = 0; ui.zoom = 'auto';
11457
+ syncHash();
11458
+ const card = markActive(key);
11459
+ renderDrawer();
11460
+ if (card) card.scrollIntoView({ block: 'nearest' });
11461
+ }
11462
+ function closeItem() {
11463
+ const key = ui.open;
11464
+ ui.open = null;
11465
+ ui.editing = null;
11466
+ ui.notice = null;
11467
+ syncHash();
11468
+ const card = markActive(null);
11469
+ renderDrawer();
11470
+ const focus = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
11471
+ if (focus) focus.focus({ preventScroll: true });
11472
+ }
11473
+ window.addEventListener('hashchange', () => {
11474
+ const key = keyFromHash();
11475
+ if (key && key !== ui.open && snap.items.some((i) => i.id === key)) openItem(key);
11476
+ else if (!key && ui.open) closeItem();
11477
+ });
11478
+ function step(delta) {
11479
+ const items = visibleItems();
11480
+ const pos = items.findIndex((i) => i.id === ui.open);
11481
+ const next = items[pos + delta];
11482
+ if (next) openItem(next.id);
11483
+ }
11484
+
11485
+ function render() {
11486
+ writeUrlState();
11487
+ renderHeader();
11488
+ renderMain(visibleItems());
11489
+ renderDrawer();
11490
+ renderTray();
11491
+ }
11492
+
11493
+ // ---- refresh --------------------------------------------------------------
11494
+
11495
+ let refreshing = false;
11496
+ async function refresh() {
11497
+ if (refreshing) return;
11498
+ refreshing = true;
11499
+ const b = $('refresh');
11500
+ b.disabled = true;
11501
+ try {
11502
+ const res = await fetch('/api/gallery.json', { cache: 'no-store' });
11503
+ if (!res.ok) throw new Error(await res.text());
11504
+ snap = await res.json();
11505
+ $('note').textContent = '';
11506
+ render();
11507
+ } catch (err) {
11508
+ $('note').textContent = ' Refresh failed: ' + err.message + ' \u2014 is pixelkiln gallery still running?';
11509
+ } finally {
11510
+ refreshing = false;
11511
+ b.disabled = false;
11512
+ }
11513
+ }
11514
+ $('refresh').onclick = refresh;
11515
+ let autoTimer = null;
11516
+ $('auto').onchange = (e) => {
11517
+ if (autoTimer) clearInterval(autoTimer);
11518
+ autoTimer = e.target.checked ? setInterval(() => { if (!document.hidden) refresh(); }, 5000) : null;
11519
+ };
11520
+
11521
+ // ---- input ----------------------------------------------------------------
11522
+
11523
+ let searchTimer = null;
11524
+ $('q').addEventListener('input', (e) => {
11525
+ ui.q = e.target.value;
11526
+ clearTimeout(searchTimer);
11527
+ searchTimer = setTimeout(render, 90);
11528
+ });
11529
+ $('sort').onchange = (e) => { ui.sort = e.target.value; render(); };
11530
+ $('group').onchange = (e) => { ui.group = e.target.value; render(); };
11531
+ document.addEventListener('keydown', (e) => {
11532
+ const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || '');
11533
+ if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
11534
+ if (e.key === 'Escape') {
11535
+ if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
11536
+ if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
11537
+ if (ui.open) { e.preventDefault(); closeItem(); }
11538
+ return;
11539
+ }
11540
+ if (ui.open && !typing) {
11541
+ if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
11542
+ else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
11543
+ }
11544
+ });
11545
+ document.addEventListener('visibilitychange', () => { if (document.hidden) stopPlayback(); });
11546
+
11547
+ readUrlState();
11548
+ $('q').value = ui.q;
11549
+ $('sort').value = ui.sort;
11550
+ $('group').value = ui.group;
11551
+ const initialKey = keyFromHash();
11552
+ if (initialKey && snap.items.some((i) => i.id === initialKey)) ui.open = initialKey;
11553
+ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
11554
+ render();
11555
+ if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
11556
+ if (GENERATION) pollJobs();
11557
+ if (ui.compare.length >= 2 && !ui.open) openCompare();
11558
+ </script>
11559
+ </body>
11560
+ </html>`;
11561
+ }
11562
+ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11563
+
11564
+ // src/gallery/server.ts
11565
+ import { spawn as spawn2 } from "child_process";
11566
+ import { randomBytes } from "crypto";
11567
+ import { createServer as createServer2 } from "http";
11568
+ import { readFile as readFile19 } from "fs/promises";
11569
+ var MAX_EDIT_BYTES = 64 * 1024;
11570
+ async function readJsonBody(req) {
11571
+ const chunks = [];
11572
+ let bytes = 0;
11573
+ for await (const chunk2 of req) {
11574
+ bytes += chunk2.length;
11575
+ if (bytes > MAX_EDIT_BYTES) throw Object.assign(new Error("edit request is too large"), { status: 413 });
11576
+ chunks.push(chunk2);
11577
+ }
11578
+ try {
11579
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
11580
+ } catch {
11581
+ throw Object.assign(new Error("edit request is not valid JSON"), { status: 400 });
11582
+ }
11583
+ }
11584
+ async function serveGallery(opts) {
11585
+ const log = opts.onProgress ?? (() => {
11586
+ });
11587
+ const session = opts.edit || opts.generate ? randomBytes(16).toString("hex") : null;
11588
+ let media = /* @__PURE__ */ new Map();
11589
+ let loading = null;
11590
+ const reviewAssets = /* @__PURE__ */ new Map();
11591
+ const load = () => {
11592
+ if (!loading) {
11593
+ loading = opts.load().then((build) => {
11594
+ media = build.media;
11595
+ return build;
11596
+ }).finally(() => {
11597
+ loading = null;
11598
+ });
11599
+ }
11600
+ return loading;
11601
+ };
11602
+ const server = createServer2(async (req, res) => {
11603
+ const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
11604
+ const fail = (status, message6) => {
11605
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
11606
+ res.end(message6);
11607
+ };
11608
+ const json = (status, value) => {
11609
+ res.writeHead(status, {
11610
+ "Content-Type": "application/json; charset=utf-8",
11611
+ "Cache-Control": "no-store",
11612
+ "X-Content-Type-Options": "nosniff"
11613
+ });
11614
+ res.end(JSON.stringify(value));
11615
+ };
11616
+ const guardedBody = async (what) => {
11617
+ const expectedOrigin = req.headers.host ? `http://${req.headers.host}` : null;
11618
+ if (!req.headers.origin || req.headers.origin !== expectedOrigin) {
11619
+ fail(403, `cross-origin ${what} are not allowed`);
11620
+ return void 0;
11621
+ }
11622
+ if (req.headers["x-pixelkiln-session"] !== session) {
11623
+ fail(403, "missing or stale gallery session");
11624
+ return void 0;
11625
+ }
11626
+ if (!req.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
11627
+ fail(415, "application/json is required");
11628
+ return void 0;
11629
+ }
11630
+ return readJsonBody(req);
11631
+ };
11632
+ const reportError = (what, err) => {
11633
+ const status = typeof err?.status === "number" ? err.status : 500;
11634
+ const message6 = err instanceof Error ? err.message : String(err);
11635
+ if (status >= 500) log(` gallery ${what} failed: ${message6}`);
11636
+ fail(status, message6);
11637
+ };
11638
+ if (req.method === "POST" && url2.pathname === "/api/edit") {
11639
+ if (!opts.edit || !session) return fail(405, "the gallery is read-only; start it with --edit to change the manifest");
11640
+ try {
11641
+ const body = await guardedBody("edits");
11642
+ if (body === void 0) return;
11643
+ const build = await opts.edit(body);
11644
+ media = build.media;
11645
+ json(200, build.snapshot);
11646
+ } catch (err) {
11647
+ reportError("edit", err);
11648
+ }
11649
+ return;
11650
+ }
11651
+ if (req.method === "POST" && url2.pathname === "/api/generate") {
11652
+ if (!opts.generate || !session) return fail(405, "generation is off; start the gallery with --budget to enable it");
11653
+ try {
11654
+ const body = await guardedBody("generation requests");
11655
+ if (body === void 0) return;
11656
+ json(202, await opts.generate.start(body));
11657
+ } catch (err) {
11658
+ reportError("generate", err);
11659
+ }
11660
+ return;
11661
+ }
11662
+ const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
11663
+ if (reviewMatch && opts.generate) {
11664
+ const [, jobId, rest = ""] = reviewMatch;
11665
+ if (req.method === "POST" && rest === "/apply") {
11666
+ try {
11667
+ const body = await guardedBody("review submissions");
11668
+ if (body === void 0) return;
11669
+ const result = await opts.generate.applyReview(jobId, body);
11670
+ reviewAssets.delete(jobId);
11671
+ json(200, result);
11672
+ } catch (err) {
11673
+ reportError("review", err);
11674
+ }
11675
+ return;
11676
+ }
11677
+ if (req.method === "GET" && rest === "") {
11678
+ try {
11679
+ const routePrefix = `/review/${jobId}`;
11680
+ const review = await opts.generate.review(jobId, {
11681
+ routePrefix,
11682
+ applyUrl: `${routePrefix}/apply`,
11683
+ applyHeaders: { "X-Pixelkiln-Session": session },
11684
+ embedded: true
11685
+ });
11686
+ if (!review) return fail(404, "nothing is waiting for review on this job");
11687
+ reviewAssets.set(jobId, review.assets);
11688
+ res.writeHead(200, {
11689
+ "Content-Type": "text/html; charset=utf-8",
11690
+ "Cache-Control": "no-store",
11691
+ "X-Content-Type-Options": "nosniff"
11692
+ });
11693
+ res.end(review.html({
11694
+ applyUrl: `${routePrefix}/apply`,
11695
+ applyHeaders: { "X-Pixelkiln-Session": session },
11696
+ embedded: true
11697
+ }));
11698
+ } catch (err) {
11699
+ reportError("review", err);
11700
+ }
11701
+ return;
11702
+ }
11703
+ if (req.method === "GET") {
11704
+ const asset = reviewAssets.get(jobId)?.get(url2.pathname);
11705
+ if (!asset) return fail(404, "no such review asset");
11706
+ try {
11707
+ const bytes = await readFile19(asset.path);
11708
+ res.writeHead(200, {
11709
+ "Content-Type": asset.contentType,
11710
+ "Cache-Control": "no-store",
11711
+ "X-Content-Type-Options": "nosniff"
11712
+ });
11713
+ res.end(bytes);
11714
+ } catch {
11715
+ fail(404, "review asset is unavailable");
11716
+ }
11717
+ return;
11718
+ }
11719
+ }
11720
+ if (req.method !== "GET" && req.method !== "HEAD") return fail(405, "the gallery is read-only");
11721
+ if (url2.pathname === "/api/jobs") {
11722
+ if (!opts.generate) return fail(404, "generation is off");
11723
+ return json(200, opts.generate.status());
11724
+ }
11725
+ try {
11726
+ if (url2.pathname === "/") {
11727
+ const { snapshot } = await load();
11728
+ res.writeHead(200, {
11729
+ "Content-Type": "text/html; charset=utf-8",
11730
+ "Cache-Control": "no-store",
11731
+ "X-Content-Type-Options": "nosniff"
11732
+ });
11733
+ res.end(renderGallery(snapshot, {
11734
+ ...session ? { session } : {},
11735
+ editable: Boolean(opts.edit),
11736
+ generation: Boolean(opts.generate)
11737
+ }));
11738
+ return;
11739
+ }
11740
+ if (url2.pathname === "/api/gallery.json") {
11741
+ const { snapshot } = await load();
11742
+ res.writeHead(200, {
11743
+ "Content-Type": "application/json; charset=utf-8",
11744
+ "Cache-Control": "no-store",
11745
+ "X-Content-Type-Options": "nosniff"
11746
+ });
11747
+ res.end(JSON.stringify(snapshot));
11748
+ return;
11749
+ }
11750
+ const mediaMatch = /^\/media\/([0-9a-f]{24})$/.exec(url2.pathname);
11751
+ if (mediaMatch) {
11752
+ const asset = media.get(mediaMatch[1]);
11753
+ if (!asset) return fail(404, "no such gallery media");
11754
+ let bytes;
11755
+ try {
11756
+ bytes = await readFile19(asset.path);
11757
+ } catch {
11758
+ return fail(404, "gallery media is no longer on disk; refresh the page");
11759
+ }
11760
+ res.writeHead(200, {
11761
+ "Content-Type": asset.contentType,
11762
+ "Content-Length": bytes.length,
11763
+ // The page cache-busts with the recorded hash, so a regenerated file
11764
+ // is never shown stale; `no-cache` keeps a hard reload honest too.
11765
+ "Cache-Control": "no-cache",
11766
+ "X-Content-Type-Options": "nosniff"
11767
+ });
11768
+ res.end(req.method === "HEAD" ? void 0 : bytes);
11769
+ return;
11770
+ }
11771
+ fail(404, "not found");
11772
+ } catch (err) {
11773
+ const message6 = err instanceof Error ? err.message : String(err);
11774
+ log(` gallery request failed: ${message6}`);
11775
+ fail(500, message6);
11776
+ }
11777
+ });
11778
+ const url = await new Promise((resolve, reject) => {
11779
+ server.once("error", reject);
11780
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
11781
+ const address = server.address();
11782
+ const port = typeof address === "object" && address ? address.port : opts.port;
11783
+ resolve(`http://127.0.0.1:${port}/`);
11784
+ });
11785
+ });
11786
+ opts.onReady?.(url);
11787
+ if (opts.open !== false && process.platform === "darwin") {
11788
+ spawn2("open", [url], { stdio: "ignore", detached: true }).unref();
11789
+ }
8881
11790
  return {
8882
- name,
8883
- provider: "pixellab",
8884
- styles: {
8885
- [styleId]: {
8886
- generator,
8887
- size: commonSize,
8888
- // Matches the schema default: a scaffolded style is for sprites, and
8889
- // a sprite wants its background stripped.
8890
- noBackground: true,
8891
- promptPrefix: "",
8892
- promptSuffix: "",
8893
- styleImages: [],
8894
- palette: [],
8895
- outDir,
8896
- tags: [name],
8897
- providerOptions: {}
11791
+ url,
11792
+ session,
11793
+ close: () => new Promise((resolve, reject) => {
11794
+ server.closeAllConnections?.();
11795
+ server.close((err) => err ? reject(err) : resolve());
11796
+ })
11797
+ };
11798
+ }
11799
+
11800
+ // src/gallery/generate.ts
11801
+ import { randomBytes as randomBytes2 } from "crypto";
11802
+ import { z as z8 } from "zod";
11803
+ var GenerateRequestSchema = z8.object({
11804
+ project: z8.string().min(1).optional(),
11805
+ keys: z8.array(z8.string().min(1)).min(1).max(500),
11806
+ /** Regenerate work that is up to date, as `gen --force` does. */
11807
+ force: z8.boolean().optional(),
11808
+ /** Advance in-flight, review, or selected work without submitting anything. */
11809
+ resume: z8.boolean().optional()
11810
+ }).strict();
11811
+ var GenerateRequestError = class extends Error {
11812
+ constructor(message6, status = 400) {
11813
+ super(message6);
11814
+ this.status = status;
11815
+ this.name = "GenerateRequestError";
11816
+ }
11817
+ status;
11818
+ };
11819
+ function message5(error) {
11820
+ return error instanceof Error ? error.message : String(error);
11821
+ }
11822
+ function remainingFor(group, groupCount, budget, spent) {
11823
+ const keyed = budget.byProvider[group.provider];
11824
+ if (keyed === void 0 && groupCount > 1 && budget.amount !== void 0) {
11825
+ throw new GenerateRequestError(
11826
+ "This run spans several providers; start the gallery with a keyed budget for each, for example --budget pixellab=40 --budget retrodiffusion=1.25."
11827
+ );
11828
+ }
11829
+ const ceiling = keyed ?? (groupCount === 1 ? budget.amount : void 0);
11830
+ if (ceiling === void 0) {
11831
+ throw new GenerateRequestError(
11832
+ `No session budget for ${group.provider}; start the gallery with --budget ${group.provider}=<amount>.`
11833
+ );
11834
+ }
11835
+ return Math.max(0, ceiling - (spent[group.provider] ?? 0));
11836
+ }
11837
+ function createGenerateHandlers(opts) {
11838
+ const log = opts.onProgress ?? (() => {
11839
+ });
11840
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
11841
+ const jobs = /* @__PURE__ */ new Map();
11842
+ const spent = {};
11843
+ const units = {};
11844
+ const contexts = /* @__PURE__ */ new Map();
11845
+ const reviews = /* @__PURE__ */ new Map();
11846
+ const busyKeys = () => {
11847
+ const busy = /* @__PURE__ */ new Map();
11848
+ for (const job of jobs.values()) {
11849
+ if (job.phase === "done" || job.phase === "failed") continue;
11850
+ for (const key of job.keys) busy.set(`${job.project ?? ""}:${key}`, job.id);
11851
+ }
11852
+ return busy;
11853
+ };
11854
+ const say = (job, line) => {
11855
+ job.messages.push(line);
11856
+ if (job.messages.length > 400) job.messages.splice(0, job.messages.length - 400);
11857
+ log(` [${job.id.slice(0, 6)}] ${line.trim()}`);
11858
+ };
11859
+ const groupByRecordedProvider = (specs, lock) => {
11860
+ const groups = /* @__PURE__ */ new Map();
11861
+ for (const spec of specs) {
11862
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
11863
+ const providerId = entry?.provider ?? spec.provider;
11864
+ groups.set(providerId, [...groups.get(providerId) ?? [], spec]);
11865
+ }
11866
+ return groups;
11867
+ };
11868
+ async function settle(job, ctx, specs) {
11869
+ job.phase = "polling";
11870
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11871
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
11872
+ const res = await poll(provider, ctx.lock, ctx.lockPath, {
11873
+ onProgress: (line) => say(job, line),
11874
+ specs: providerSpecs,
11875
+ intervalMs: opts.pollIntervalMs
11876
+ });
11877
+ say(job, `${providerId}: ${res.completed} completed, ${res.review} awaiting review, ${res.failed} failed, ${res.stillRunning} still running`);
11878
+ }
11879
+ job.review = specs.map((spec) => lockKey(spec.styleId, spec.assetId)).filter((key) => ctx.lock.entries[key]?.status === "review");
11880
+ job.phase = "fetching";
11881
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11882
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
11883
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
11884
+ onProgress: (line) => say(job, line)
11885
+ });
11886
+ job.counts.downloaded += res.downloaded;
11887
+ if (res.downloaded || res.failed) {
11888
+ say(job, `${providerId}: downloaded ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
11889
+ }
11890
+ }
11891
+ if (job.review.length) {
11892
+ job.phase = "review";
11893
+ say(job, `${job.review.length} asset(s) awaiting selection`);
11894
+ } else {
11895
+ job.phase = "done";
11896
+ job.finishedAt = now().toISOString();
11897
+ }
11898
+ }
11899
+ async function run(job, ctx, specs, groups) {
11900
+ try {
11901
+ if (job.mode === "generate") {
11902
+ job.phase = "submitting";
11903
+ for (const group of groups) {
11904
+ const provider = opts.providerFor(job.project ?? void 0, group.provider);
11905
+ const ceiling = remainingFor(group, groups.length, opts.budget, spent);
11906
+ if (provider.balance) {
11907
+ const balance = await provider.balance();
11908
+ say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
11909
+ if (balance.unit !== group.costUnit) {
11910
+ throw new Error(
11911
+ `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
11912
+ );
11913
+ }
11914
+ if (balance.unit !== "free" && group.cost > balance.remaining) {
11915
+ throw new Error(
11916
+ `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
11917
+ );
11918
+ }
11919
+ }
11920
+ const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
11921
+ budget: ceiling,
11922
+ onProgress: (line) => say(job, line),
11923
+ spacingMs: opts.submitSpacingMs
11924
+ });
11925
+ spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
11926
+ units[group.provider] = res.unit;
11927
+ job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
11928
+ job.counts.submitted += res.submitted;
11929
+ job.counts.failed += res.failed;
11930
+ say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
11931
+ }
11932
+ }
11933
+ await settle(job, ctx, specs);
11934
+ } catch (error) {
11935
+ job.phase = "failed";
11936
+ job.error = message5(error);
11937
+ job.finishedAt = now().toISOString();
11938
+ say(job, `failed: ${job.error}`);
11939
+ }
11940
+ }
11941
+ return {
11942
+ async start(body) {
11943
+ const parsed = GenerateRequestSchema.safeParse(body);
11944
+ if (!parsed.success) {
11945
+ throw new GenerateRequestError(
11946
+ "invalid request: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
11947
+ );
8898
11948
  }
11949
+ const request = parsed.data;
11950
+ const ctx = await opts.loadProject(request.project);
11951
+ const byKey = new Map(ctx.specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
11952
+ const specs = [];
11953
+ for (const key of request.keys) {
11954
+ const spec = byKey.get(key);
11955
+ if (!spec) throw new GenerateRequestError(`"${key}" is not declared by the manifest`);
11956
+ specs.push(spec);
11957
+ }
11958
+ const busy = busyKeys();
11959
+ for (const key of request.keys) {
11960
+ const holder = busy.get(`${request.project ?? ""}:${key}`);
11961
+ if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
11962
+ }
11963
+ let groups = [];
11964
+ if (!request.resume) {
11965
+ const plan = await buildPlan(specs, ctx.lock, { force: request.force });
11966
+ if (!plan.actionable.length) {
11967
+ const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
11968
+ throw new GenerateRequestError(`nothing to generate; ${reasons.join("; ")}`);
11969
+ }
11970
+ groups = plan.groups;
11971
+ for (const group of groups) {
11972
+ const remaining = remainingFor(group, groups.length, opts.budget, spent);
11973
+ if (group.cost > remaining) {
11974
+ throw new GenerateRequestError(
11975
+ `${group.provider} would spend ${formatCost(group.costUnit, group.cost)} but only ${formatCost(group.costUnit, remaining)} of this session's budget remain.`
11976
+ );
11977
+ }
11978
+ }
11979
+ }
11980
+ const job = {
11981
+ id: randomBytes2(8).toString("hex"),
11982
+ project: request.project ?? null,
11983
+ keys: request.keys,
11984
+ mode: request.resume ? "resume" : "generate",
11985
+ phase: "queued",
11986
+ startedAt: now().toISOString(),
11987
+ finishedAt: null,
11988
+ messages: [],
11989
+ error: null,
11990
+ spent: {},
11991
+ review: [],
11992
+ counts: { submitted: 0, failed: 0, downloaded: 0 }
11993
+ };
11994
+ jobs.set(job.id, job);
11995
+ contexts.set(job.id, ctx);
11996
+ 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("; "));
11997
+ void run(job, ctx, specs, groups);
11998
+ return job;
8899
11999
  },
8900
- assets
12000
+ status() {
12001
+ return {
12002
+ jobs: [...jobs.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt)),
12003
+ budget: opts.budget,
12004
+ spent,
12005
+ units
12006
+ };
12007
+ },
12008
+ async review(jobId, options) {
12009
+ const job = jobs.get(jobId);
12010
+ const ctx = contexts.get(jobId);
12011
+ if (!job || !ctx || job.phase !== "review" || !job.review.length) return null;
12012
+ const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
12013
+ const providers = [...groupByRecordedProvider(specs, ctx.lock).keys()];
12014
+ const providerId = providers[0];
12015
+ if (!providerId) return null;
12016
+ const session = await prepareReview(opts.providerFor(job.project ?? void 0, providerId), ctx.lock, {
12017
+ keys: job.review,
12018
+ specs,
12019
+ onProgress: (line) => say(job, line),
12020
+ routePrefix: options.routePrefix
12021
+ });
12022
+ if (session) reviews.set(jobId, session);
12023
+ return session;
12024
+ },
12025
+ async applyReview(jobId, body) {
12026
+ const job = jobs.get(jobId);
12027
+ const ctx = contexts.get(jobId);
12028
+ const session = reviews.get(jobId);
12029
+ if (!job || !ctx || !session) throw new GenerateRequestError("no review is open for this job", 404);
12030
+ const result = await session.apply(body, ctx.lockPath);
12031
+ say(job, `selected ${result.selected}, left in review ${result.skipped}`);
12032
+ reviews.delete(jobId);
12033
+ const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
12034
+ void settle(job, ctx, specs).catch((error) => {
12035
+ job.phase = "failed";
12036
+ job.error = message5(error);
12037
+ job.finishedAt = now().toISOString();
12038
+ say(job, `failed: ${job.error}`);
12039
+ });
12040
+ return result;
12041
+ }
8901
12042
  };
8902
12043
  }
8903
- function mode(values) {
8904
- const counts = /* @__PURE__ */ new Map();
8905
- for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
8906
- return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
8907
- }
8908
12044
 
8909
12045
  // src/pipeline/salvage.ts
8910
- import { readFile as readFile15 } from "fs/promises";
8911
- import { existsSync as existsSync16 } from "fs";
8912
- import path20 from "path";
12046
+ import { readFile as readFile20 } from "fs/promises";
12047
+ import { existsSync as existsSync19 } from "fs";
12048
+ import path23 from "path";
8913
12049
  function providerClaimId(provider, objectId) {
8914
12050
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
8915
12051
  }
8916
12052
  async function loadClaims(lockPaths, opts = {}) {
8917
12053
  const claimed = /* @__PURE__ */ new Set();
8918
12054
  for (const p of lockPaths) {
8919
- if (!existsSync16(p)) throw new Error(`Claim lockfile not found: ${p}`);
12055
+ if (!existsSync19(p)) throw new Error(`Claim lockfile not found: ${p}`);
8920
12056
  let parsed;
8921
12057
  try {
8922
- parsed = parseLock(JSON.parse(await readFile15(p, "utf8")));
12058
+ parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
8923
12059
  } catch {
8924
12060
  throw new Error(`Claim lockfile is malformed: ${p}`);
8925
12061
  }
@@ -8972,20 +12108,20 @@ function matchOrphanStyle(prompt, manifest) {
8972
12108
  return matchStyleByPattern(prompt, manifest);
8973
12109
  }
8974
12110
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
8975
- const own = path20.resolve(ownManifestPath);
12111
+ const own = path23.resolve(ownManifestPath);
8976
12112
  const siblingManifestPaths = [
8977
12113
  .../* @__PURE__ */ new Set([
8978
12114
  ...workspaceManifestPaths,
8979
- ...claimPaths.map((c) => path20.join(path20.dirname(path20.resolve(c)), "pixelkiln.manifest.json"))
12115
+ ...claimPaths.map((c) => path23.join(path23.dirname(path23.resolve(c)), "pixelkiln.manifest.json"))
8980
12116
  ])
8981
12117
  ];
8982
12118
  const siblings = [];
8983
12119
  for (const siblingManifestPath of siblingManifestPaths) {
8984
- if (path20.resolve(siblingManifestPath) === own) continue;
8985
- if (!existsSync16(siblingManifestPath)) continue;
12120
+ if (path23.resolve(siblingManifestPath) === own) continue;
12121
+ if (!existsSync19(siblingManifestPath)) continue;
8986
12122
  try {
8987
12123
  const { manifest } = await loadManifest(siblingManifestPath);
8988
- siblings.push({ label: path20.basename(path20.dirname(siblingManifestPath)), manifest });
12124
+ siblings.push({ label: path23.basename(path23.dirname(siblingManifestPath)), manifest });
8989
12125
  } catch {
8990
12126
  }
8991
12127
  }
@@ -9101,16 +12237,16 @@ async function applyTags(provider, decisions, existing, opts = {}) {
9101
12237
  }
9102
12238
 
9103
12239
  // src/pick/salvage-server.ts
9104
- import { mkdir as mkdir5, writeFile as writeFile7, readFile as readFile16 } from "fs/promises";
9105
- import path21 from "path";
12240
+ import { mkdir as mkdir6, writeFile as writeFile9, readFile as readFile21 } from "fs/promises";
12241
+ import path24 from "path";
9106
12242
 
9107
12243
  // src/pick/salvage-sheet.ts
9108
- var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
12244
+ var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9109
12245
  function renderSalvageSheet(orphans, ctx) {
9110
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml2(o.prompt) }));
12246
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt) }));
9111
12247
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
9112
- const styleId = ctx ? escapeHtml2(ctx.styleId) : null;
9113
- const importDir = ctx ? escapeHtml2(ctx.importDir) : null;
12248
+ const styleId = ctx ? escapeHtml3(ctx.styleId) : null;
12249
+ const importDir = ctx ? escapeHtml3(ctx.importDir) : null;
9114
12250
  return `<!doctype html>
9115
12251
  <html lang="en">
9116
12252
  <head>
@@ -9267,7 +12403,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9267
12403
  });
9268
12404
  const html = renderSalvageSheet(orphans, {
9269
12405
  styleId: ctx.styleId,
9270
- importDir: path21.relative(process.cwd(), ctx.importDir) || "."
12406
+ importDir: path24.relative(process.cwd(), ctx.importDir) || "."
9271
12407
  });
9272
12408
  const byId = new Map(orphans.map((o) => [o.id, o]));
9273
12409
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -9296,10 +12432,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9296
12432
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
9297
12433
  decodePng(buf);
9298
12434
  const assetId = idFromPrompt(orphan.prompt, taken);
9299
- const rel = path21.join("_salvaged", `${assetId}.png`);
9300
- const outFile = path21.resolve(ctx.importDir, rel);
9301
- await mkdir5(path21.dirname(outFile), { recursive: true });
9302
- await writeFile7(outFile, buf);
12435
+ const rel = path24.join("_salvaged", `${assetId}.png`);
12436
+ const outFile = path24.resolve(ctx.importDir, rel);
12437
+ await mkdir6(path24.dirname(outFile), { recursive: true });
12438
+ await writeFile9(outFile, buf);
9303
12439
  ctx.manifest.assets[assetId] = {
9304
12440
  prompt: orphan.prompt,
9305
12441
  promptByStyle: {},
@@ -9330,7 +12466,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9330
12466
  sourceUrl: durableSource,
9331
12467
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
9332
12468
  outputs: [{
9333
- path: portableOutputPath(outFile, path21.dirname(ctx.manifestPath)),
12469
+ path: portableOutputPath(outFile, path24.dirname(ctx.manifestPath)),
9334
12470
  sha256: sha256(buf)
9335
12471
  }],
9336
12472
  submittedAt: orphan.createdAt,
@@ -9354,148 +12490,15 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
9354
12490
  }
9355
12491
  }
9356
12492
  await applyTags(provider, decisions, existingTags, { onProgress: log });
9357
- const raw = JSON.parse(await readFile16(ctx.manifestPath, "utf8"));
12493
+ const raw = JSON.parse(await readFile21(ctx.manifestPath, "utf8"));
9358
12494
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
9359
- await writeFile7(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12495
+ await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
9360
12496
  await saveLock(ctx.lockPath, ctx.lock);
9361
12497
  return result;
9362
12498
  }
9363
12499
  });
9364
12500
  }
9365
12501
 
9366
- // src/workspace.ts
9367
- import { mkdir as mkdir6, readFile as readFile17, rename as rename5, rm as rm7, writeFile as writeFile8 } from "fs/promises";
9368
- import { existsSync as existsSync17 } from "fs";
9369
- import path22 from "path";
9370
- import { z as z6 } from "zod";
9371
- var WorkspaceProjectSchema = z6.object({
9372
- id: z6.string().min(1),
9373
- /** Manifest path, relative to the catalog file's own directory. */
9374
- manifest: z6.string().min(1),
9375
- /** Lockfile path, relative to the catalog file's own directory. */
9376
- lock: z6.string().min(1),
9377
- provider: z6.string().min(1).default("pixellab"),
9378
- /** Free-form label for a shared account, e.g. distinguishing sandboxes. */
9379
- account: z6.string().optional()
9380
- }).strict();
9381
- var WorkspaceSchema = z6.object({
9382
- version: z6.literal(1),
9383
- projects: z6.array(WorkspaceProjectSchema).default([])
9384
- }).strict();
9385
- function parseWorkspace(raw) {
9386
- const parsed = WorkspaceSchema.safeParse(raw);
9387
- if (parsed.success) return parsed.data;
9388
- throw new Error(
9389
- `Workspace catalog is not valid v1:
9390
- ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
9391
- );
9392
- }
9393
- async function loadWorkspace(workspacePath) {
9394
- if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
9395
- let raw;
9396
- try {
9397
- raw = JSON.parse(await readFile17(workspacePath, "utf8"));
9398
- } catch (err) {
9399
- throw new Error(
9400
- `Workspace catalog at ${workspacePath} is malformed:
9401
- ${err instanceof Error ? err.message : String(err)}`
9402
- );
9403
- }
9404
- return parseWorkspace(raw);
9405
- }
9406
- async function saveWorkspace(workspacePath, ws) {
9407
- const sorted = {
9408
- version: 1,
9409
- projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
9410
- };
9411
- await mkdir6(path22.dirname(path22.resolve(workspacePath)), { recursive: true });
9412
- const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9413
- try {
9414
- await writeFile8(tmp, JSON.stringify(sorted, null, 2) + "\n");
9415
- await rename5(tmp, workspacePath);
9416
- } finally {
9417
- await rm7(tmp, { force: true });
9418
- }
9419
- }
9420
- function toPortablePath(dir, absolute) {
9421
- return path22.relative(dir, absolute).split(path22.sep).join("/");
9422
- }
9423
- function resolveProject(dir, project) {
9424
- return {
9425
- manifestPath: path22.resolve(dir, project.manifest.split("/").join(path22.sep)),
9426
- lockPath: path22.resolve(dir, project.lock.split("/").join(path22.sep))
9427
- };
9428
- }
9429
- function validateWorkspace(ws, dir) {
9430
- const diagnostics = [];
9431
- const idCounts = /* @__PURE__ */ new Map();
9432
- const lockOwners = /* @__PURE__ */ new Map();
9433
- const manifestOwners = /* @__PURE__ */ new Map();
9434
- for (const project of ws.projects) {
9435
- idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
9436
- const { manifestPath, lockPath } = resolveProject(dir, project);
9437
- lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
9438
- manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
9439
- if (path22.isAbsolute(project.manifest) || path22.isAbsolute(project.lock)) {
9440
- diagnostics.push({
9441
- id: "absolute-path",
9442
- level: "warning",
9443
- message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
9444
- });
9445
- }
9446
- if (!existsSync17(manifestPath)) {
9447
- diagnostics.push({
9448
- id: "missing-manifest",
9449
- level: "error",
9450
- message: `project "${project.id}" manifest not found: ${manifestPath}`
9451
- });
9452
- }
9453
- if (!existsSync17(lockPath)) {
9454
- diagnostics.push({
9455
- id: "missing-lock",
9456
- level: "error",
9457
- message: `project "${project.id}" lockfile not found: ${lockPath}`
9458
- });
9459
- }
9460
- }
9461
- for (const [id, count] of idCounts) {
9462
- if (count > 1) {
9463
- diagnostics.push({
9464
- id: "duplicate-id",
9465
- level: "error",
9466
- message: `project id "${id}" is registered ${count} times`
9467
- });
9468
- }
9469
- }
9470
- for (const [lockPath, ids] of lockOwners) {
9471
- if (ids.length > 1) {
9472
- diagnostics.push({
9473
- id: "duplicate-lock",
9474
- level: "error",
9475
- message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
9476
- });
9477
- }
9478
- }
9479
- for (const [manifestPath, ids] of manifestOwners) {
9480
- if (ids.length > 1) {
9481
- diagnostics.push({
9482
- id: "duplicate-manifest",
9483
- level: "warning",
9484
- message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
9485
- });
9486
- }
9487
- }
9488
- const providers = new Set(ws.projects.map((p) => p.provider));
9489
- if (providers.size > 1) {
9490
- diagnostics.push({
9491
- id: "mixed-provider",
9492
- level: "warning",
9493
- message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
9494
- });
9495
- }
9496
- return diagnostics;
9497
- }
9498
-
9499
12502
  // src/pipeline/workspace.ts
9500
12503
  async function workspaceClaims(ws, dir) {
9501
12504
  const lockPaths = [];
@@ -9605,6 +12608,8 @@ export {
9605
12608
  DEFAULT_RATE_LIMIT,
9606
12609
  FAKE_PNG,
9607
12610
  FakeProvider,
12611
+ GenerateRequestError,
12612
+ GenerateRequestSchema,
9608
12613
  GeneratorSchema,
9609
12614
  GridConfidenceSchema,
9610
12615
  ImageQualityMetricsSchema,
@@ -9613,6 +12618,9 @@ export {
9613
12618
  LockSchema,
9614
12619
  MAX_DOWNLOAD_BYTES,
9615
12620
  MAX_RETRIES,
12621
+ ManifestDriftError,
12622
+ ManifestEditError,
12623
+ ManifestEditSchema,
9616
12624
  ManifestInputSchema,
9617
12625
  ManifestSchema,
9618
12626
  MediaType,
@@ -9635,13 +12643,16 @@ export {
9635
12643
  WorkspaceProjectSchema,
9636
12644
  WorkspaceSchema,
9637
12645
  adopt,
12646
+ applyManifestEdit,
9638
12647
  applyTags,
9639
12648
  approveQualityRecord,
9640
12649
  auditStyle,
9641
12650
  availableProviders,
9642
12651
  backoffMs,
12652
+ buildGallerySnapshot,
9643
12653
  buildManifest,
9644
12654
  buildPlan,
12655
+ buildWorkspaceGallerySnapshot,
9645
12656
  bundledRecipeRoot,
9646
12657
  cacheFileName,
9647
12658
  candidateCount,
@@ -9651,6 +12662,8 @@ export {
9651
12662
  colorDistance,
9652
12663
  countNumberedDescriptions,
9653
12664
  createArtifactBundleManifest,
12665
+ createGalleryEditHandler,
12666
+ createGenerateHandlers,
9654
12667
  createProvider,
9655
12668
  currentEntryOutputPath,
9656
12669
  currentOutputPath,
@@ -9663,6 +12676,8 @@ export {
9663
12676
  fetchAssets,
9664
12677
  findOrphans,
9665
12678
  formatCost,
12679
+ galleryMediaId,
12680
+ galleryMediaRoute,
9666
12681
  generationCost,
9667
12682
  groupOrphansByStyle,
9668
12683
  hex,
@@ -9702,6 +12717,7 @@ export {
9702
12717
  pngSize,
9703
12718
  poll,
9704
12719
  portableOutputPath,
12720
+ prepareReview,
9705
12721
  primaryOutput,
9706
12722
  providerCredentialEnvs,
9707
12723
  providerFactory,
@@ -9717,6 +12733,7 @@ export {
9717
12733
  refineQualityProfiles,
9718
12734
  registerProvider,
9719
12735
  remove,
12736
+ renderGallery,
9720
12737
  renderSalvageSheet,
9721
12738
  renderSheet,
9722
12739
  requireApprovedQualitySources,
@@ -9745,6 +12762,7 @@ export {
9745
12762
  saveWorkspace,
9746
12763
  scanAssets,
9747
12764
  selectEntryOutput,
12765
+ serveGallery,
9748
12766
  sha256,
9749
12767
  sha256File,
9750
12768
  shouldPersistSourceUrl,