pixelkiln 0.30.0 → 0.32.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.d.cts CHANGED
@@ -8312,8 +8312,14 @@ interface GenerateJob {
8312
8312
  id: string;
8313
8313
  project: string | null;
8314
8314
  keys: string[];
8315
- /** `resume` skips submission: poll, review, and fetch existing work at no cost; `refresh` re-pulls changed objects. */
8316
- mode: "generate" | "resume" | "refresh";
8315
+ /**
8316
+ * `resume` skips submission: poll, review, and fetch existing work at no
8317
+ * cost; `refresh` re-pulls changed objects; `restore` puts the recorded
8318
+ * bytes back on disk from the cache or the provider, as `pixelkiln restore`.
8319
+ */
8320
+ mode: "generate" | "resume" | "refresh" | "restore";
8321
+ /** The job may replace a locally modified or untracked file, as `--force` does. */
8322
+ force: boolean;
8317
8323
  phase: GeneratePhase;
8318
8324
  startedAt: string;
8319
8325
  finishedAt: string | null;
@@ -8377,36 +8383,46 @@ interface GalleryGenerateHandlers {
8377
8383
  declare const GenerateRequestSchema: z.ZodEffects<z.ZodObject<{
8378
8384
  project: z.ZodOptional<z.ZodString>;
8379
8385
  keys: z.ZodArray<z.ZodString, "many">;
8380
- /** Regenerate work that is up to date, as `gen --force` does. */
8386
+ /**
8387
+ * Regenerate work that is up to date, as `gen --force` does, and let the
8388
+ * fetch replace a locally modified or untracked file. With `restore`, put
8389
+ * the recorded bytes back over a modified file.
8390
+ */
8381
8391
  force: z.ZodOptional<z.ZodBoolean>;
8382
8392
  /** Advance in-flight, review, or selected work without submitting anything. */
8383
8393
  resume: z.ZodOptional<z.ZodBoolean>;
8384
8394
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
8385
8395
  refresh: z.ZodOptional<z.ZodBoolean>;
8396
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
8397
+ restore: z.ZodOptional<z.ZodBoolean>;
8386
8398
  }, "strict", z.ZodTypeAny, {
8387
8399
  keys: string[];
8388
8400
  resume?: boolean | undefined;
8389
8401
  force?: boolean | undefined;
8390
8402
  project?: string | undefined;
8391
8403
  refresh?: boolean | undefined;
8404
+ restore?: boolean | undefined;
8392
8405
  }, {
8393
8406
  keys: string[];
8394
8407
  resume?: boolean | undefined;
8395
8408
  force?: boolean | undefined;
8396
8409
  project?: string | undefined;
8397
8410
  refresh?: boolean | undefined;
8411
+ restore?: boolean | undefined;
8398
8412
  }>, {
8399
8413
  keys: string[];
8400
8414
  resume?: boolean | undefined;
8401
8415
  force?: boolean | undefined;
8402
8416
  project?: string | undefined;
8403
8417
  refresh?: boolean | undefined;
8418
+ restore?: boolean | undefined;
8404
8419
  }, {
8405
8420
  keys: string[];
8406
8421
  resume?: boolean | undefined;
8407
8422
  force?: boolean | undefined;
8408
8423
  project?: string | undefined;
8409
8424
  refresh?: boolean | undefined;
8425
+ restore?: boolean | undefined;
8410
8426
  }>;
8411
8427
  declare class GenerateRequestError extends Error {
8412
8428
  readonly status: number;
package/dist/index.d.ts CHANGED
@@ -8312,8 +8312,14 @@ interface GenerateJob {
8312
8312
  id: string;
8313
8313
  project: string | null;
8314
8314
  keys: string[];
8315
- /** `resume` skips submission: poll, review, and fetch existing work at no cost; `refresh` re-pulls changed objects. */
8316
- mode: "generate" | "resume" | "refresh";
8315
+ /**
8316
+ * `resume` skips submission: poll, review, and fetch existing work at no
8317
+ * cost; `refresh` re-pulls changed objects; `restore` puts the recorded
8318
+ * bytes back on disk from the cache or the provider, as `pixelkiln restore`.
8319
+ */
8320
+ mode: "generate" | "resume" | "refresh" | "restore";
8321
+ /** The job may replace a locally modified or untracked file, as `--force` does. */
8322
+ force: boolean;
8317
8323
  phase: GeneratePhase;
8318
8324
  startedAt: string;
8319
8325
  finishedAt: string | null;
@@ -8377,36 +8383,46 @@ interface GalleryGenerateHandlers {
8377
8383
  declare const GenerateRequestSchema: z.ZodEffects<z.ZodObject<{
8378
8384
  project: z.ZodOptional<z.ZodString>;
8379
8385
  keys: z.ZodArray<z.ZodString, "many">;
8380
- /** Regenerate work that is up to date, as `gen --force` does. */
8386
+ /**
8387
+ * Regenerate work that is up to date, as `gen --force` does, and let the
8388
+ * fetch replace a locally modified or untracked file. With `restore`, put
8389
+ * the recorded bytes back over a modified file.
8390
+ */
8381
8391
  force: z.ZodOptional<z.ZodBoolean>;
8382
8392
  /** Advance in-flight, review, or selected work without submitting anything. */
8383
8393
  resume: z.ZodOptional<z.ZodBoolean>;
8384
8394
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
8385
8395
  refresh: z.ZodOptional<z.ZodBoolean>;
8396
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
8397
+ restore: z.ZodOptional<z.ZodBoolean>;
8386
8398
  }, "strict", z.ZodTypeAny, {
8387
8399
  keys: string[];
8388
8400
  resume?: boolean | undefined;
8389
8401
  force?: boolean | undefined;
8390
8402
  project?: string | undefined;
8391
8403
  refresh?: boolean | undefined;
8404
+ restore?: boolean | undefined;
8392
8405
  }, {
8393
8406
  keys: string[];
8394
8407
  resume?: boolean | undefined;
8395
8408
  force?: boolean | undefined;
8396
8409
  project?: string | undefined;
8397
8410
  refresh?: boolean | undefined;
8411
+ restore?: boolean | undefined;
8398
8412
  }>, {
8399
8413
  keys: string[];
8400
8414
  resume?: boolean | undefined;
8401
8415
  force?: boolean | undefined;
8402
8416
  project?: string | undefined;
8403
8417
  refresh?: boolean | undefined;
8418
+ restore?: boolean | undefined;
8404
8419
  }, {
8405
8420
  keys: string[];
8406
8421
  resume?: boolean | undefined;
8407
8422
  force?: boolean | undefined;
8408
8423
  project?: string | undefined;
8409
8424
  refresh?: boolean | undefined;
8425
+ restore?: boolean | undefined;
8410
8426
  }>;
8411
8427
  declare class GenerateRequestError extends Error {
8412
8428
  readonly status: number;
package/dist/index.js CHANGED
@@ -7770,7 +7770,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
7770
7770
  }
7771
7771
 
7772
7772
  // src/pipeline/fetch.ts
7773
- import { existsSync as existsSync13 } from "fs";
7773
+ import { existsSync as existsSync13, readFileSync as readFileSync3 } from "fs";
7774
7774
  import { mkdir as mkdir4, readFile as readFile11, rename as rename4, rm as rm6, writeFile as writeFile4 } from "fs/promises";
7775
7775
  import path16 from "path";
7776
7776
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
@@ -7787,7 +7787,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7787
7787
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7788
7788
  if (!opts.repair) return false;
7789
7789
  const spec = specByKey.get(key);
7790
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync13(resolveOutputPath(output.path, spec.root)));
7790
+ if (e.outputs.length === 0 || !spec) return true;
7791
+ return e.outputs.some((output) => {
7792
+ const file = resolveOutputPath(output.path, spec.root);
7793
+ if (!existsSync13(file)) return true;
7794
+ return Boolean(opts.force) && sha256(readFileSync3(file)) !== output.sha256;
7795
+ });
7791
7796
  });
7792
7797
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
7793
7798
  let cursor = 0;
@@ -10556,6 +10561,7 @@ function renderGallery(snapshot, opts = {}) {
10556
10561
  .sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
10557
10562
  .sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
10558
10563
  .sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
10564
+ .sheet.editor-host .rbar .chip { color:var(--dim); font-size:12.5px; display:inline-flex; align-items:center; gap:5px; margin-right:6px; }
10559
10565
  .sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
10560
10566
  .sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
10561
10567
  .sheet.editor-host .stage { position:relative; min-height:0; }
@@ -10603,6 +10609,8 @@ function renderGallery(snapshot, opts = {}) {
10603
10609
  .tray > button { padding:5px 11px; font-size:12.5px; }
10604
10610
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10605
10611
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10612
+ .drawer .readonly-hint { margin-top:10px; font-size:12.5px; line-height:1.5; }
10613
+ .drawer .readonly-hint code { font:12px var(--mono); color:var(--text); }
10606
10614
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10607
10615
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
10608
10616
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
@@ -10812,7 +10820,7 @@ function renderJobs() {
10812
10820
  const row = el('div', 'job');
10813
10821
  const ph = el('span', 'ph');
10814
10822
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
10815
- const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10823
+ const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'restore' ? 'restore ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
10816
10824
  (job.project ? ' in ' + job.project : '');
10817
10825
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
10818
10826
  last.title = job.keys.join('\\n');
@@ -10937,13 +10945,16 @@ function budgetLine() {
10937
10945
 
10938
10946
  // The confirm step: what will be sent, what it is estimated to cost, and what
10939
10947
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
10940
- function generateDialog(items, { project = null, force = false, resume = false, refresh = false } = {}) {
10948
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false } = {}) {
10941
10949
  const host = $('dialog-host');
10942
10950
  host.textContent = '';
10943
10951
  const wrap = el('div', 'dialog');
10944
10952
  const form = el('form');
10945
10953
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
10954
+ const free = resume || refresh || restore;
10955
+ const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
10946
10956
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
10957
+ : restore ? 'Restore ' + noun
10947
10958
  : resume ? 'Resume ' + noun
10948
10959
  : (force ? 'Regenerate ' : 'Generate ') + noun));
10949
10960
  const table = el('table');
@@ -10953,9 +10964,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
10953
10964
  for (const item of items) {
10954
10965
  const tr = el('tr');
10955
10966
  tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
10956
- el('td', 'n', resume || refresh ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10967
+ el('td', 'n', free ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
10957
10968
  table.append(tr);
10958
- if (!resume && !refresh) {
10969
+ if (!free) {
10959
10970
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
10960
10971
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
10961
10972
  }
@@ -10964,6 +10975,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
10964
10975
  const sum = el('div', 'sum');
10965
10976
  if (refresh) {
10966
10977
  sum.append(el('div', null, 'Re-downloads each object from the provider and replaces the local file only when the object changed upstream \u2014 for example after editing it in the provider\u2019s own editor. Unchanged objects are left alone; a file you changed locally is refused. Nothing is submitted.'));
10978
+ } else if (restore) {
10979
+ sum.append(el('div', null, 'Puts the recorded bytes back on disk from the local cache or the provider object. Nothing is generated or submitted.'));
10980
+ if (force && replacing.length) sum.append(el('div', 'warn', 'The file on disk was changed after PixelKiln wrote it. Restoring discards that change. To keep it instead, cancel and use Edit by hand or Edit in browser first: the edit is copied beside the generated file, and the record stays intact.'));
10967
10981
  } else if (resume) {
10968
10982
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
10969
10983
  } else {
@@ -10976,12 +10990,13 @@ function generateDialog(items, { project = null, force = false, resume = false,
10976
10990
  if (left !== null && g.cost > left) line.className = 'warn';
10977
10991
  sum.append(line);
10978
10992
  }
10979
- 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.'));
10993
+ if (force && replacing.length) sum.append(el('div', 'warn', (replacing.length === 1 ? 'This file' : replacing.length + ' of these files') + ' changed on disk after PixelKiln wrote it (or was never recorded). Generating replaces it and that change is lost. To keep it, cancel and use Edit by hand or Edit in browser first.'));
10994
+ else 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.'));
10980
10995
  sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
10981
10996
  }
10982
10997
  form.append(sum);
10983
10998
  const actions = el('div', 'actions');
10984
- const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
10999
+ const go = el('button', 'primary', refresh ? 'Pull changes' : restore ? (force && replacing.length ? 'Restore and discard my change' : 'Restore') : resume ? 'Resume' : force && replacing.length ? 'Generate and replace' : 'Generate');
10985
11000
  go.type = 'submit';
10986
11001
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
10987
11002
  const msg = el('span', 'msg');
@@ -10996,6 +11011,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
10996
11011
  if (force) body.force = true;
10997
11012
  if (resume) body.resume = true;
10998
11013
  if (refresh) body.refresh = true;
11014
+ if (restore) body.restore = true;
10999
11015
  const job = await postGenerate(body);
11000
11016
  host.textContent = '';
11001
11017
  GEN.jobs.unshift(job);
@@ -11084,11 +11100,16 @@ function openEditorSheet(item) {
11084
11100
  const status = el('span', 'st');
11085
11101
  const acts = el('div', 'acts');
11086
11102
  const msg = el('span', 'msg');
11103
+ const compare = el('label', 'chip'); compare.hidden = true;
11104
+ compare.title = 'The generated art on a locked layer at 50%, for comparison; never part of the saved file';
11105
+ const compareBox = el('input'); compareBox.type = 'checkbox'; compareBox.checked = true;
11106
+ compareBox.onchange = () => showReference(compareBox.checked);
11107
+ compare.append(compareBox, document.createTextNode(' show generated'));
11087
11108
  const save = el('button', 'primary', 'Save to project'); save.type = 'button';
11088
11109
  save.onclick = () => requestEditorSave(false);
11089
11110
  const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
11090
11111
  saveClose.onclick = () => requestEditorSave(true);
11091
- acts.append(msg, save, saveClose);
11112
+ acts.append(msg, compare, save, saveClose);
11092
11113
  bar.append(close, title, status, acts);
11093
11114
  const stage = el('div', 'stage');
11094
11115
  const frame = el('iframe');
@@ -11101,7 +11122,7 @@ function openEditorSheet(item) {
11101
11122
  stage.append(frame, loading);
11102
11123
  panel.append(bar, stage);
11103
11124
  host.append(scrim, panel);
11104
- SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false, roles: null };
11125
+ SHEET = { item, frame, loading, status, msg, save, saveClose, compare, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false, sentReference: false, reference: false, roles: null };
11105
11126
  sheetStatus('cool', 'loading');
11106
11127
  sheetButtons();
11107
11128
  close.focus({ preventScroll: true });
@@ -11121,7 +11142,11 @@ async function sendOpen() {
11121
11142
  sheetStatus('bad', 'unsupported');
11122
11143
  return;
11123
11144
  }
11145
+ // When an edit is being opened, the generated art rides along as a
11146
+ // reference layer: what the editor shows through at 50% for comparison.
11147
+ const referenceFiles = sources.fromEdit && SHEET.ready.version >= 4 && item.outputs.every((o) => o.exists && o.url) ? item.outputs : null;
11124
11148
  const pngs = [];
11149
+ const reference = [];
11125
11150
  let pxo = null;
11126
11151
  try {
11127
11152
  for (const file of sources.files) {
@@ -11133,6 +11158,10 @@ async function sendOpen() {
11133
11158
  const p = await fetch(projectUrl, { cache: 'no-store' });
11134
11159
  if (p.ok) pxo = await p.arrayBuffer();
11135
11160
  }
11161
+ for (const file of referenceFiles || []) {
11162
+ const res = await fetch(file.url, { cache: 'no-store' });
11163
+ if (res.ok) reference.push({ role: file.role || null, png: await res.arrayBuffer() });
11164
+ }
11136
11165
  } catch (err) {
11137
11166
  if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
11138
11167
  return;
@@ -11157,9 +11186,18 @@ async function sendOpen() {
11157
11186
  transfer.push(message.png);
11158
11187
  }
11159
11188
  if (pxo) { message.pxo = pxo; transfer.push(pxo); }
11189
+ if (reference.length === (referenceFiles || []).length && reference.length) {
11190
+ message.reference = reference;
11191
+ for (const r of reference) transfer.push(r.png);
11192
+ }
11193
+ SHEET.sentReference = !!message.reference;
11160
11194
  SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
11161
11195
  sheetStatus('cool', 'opening');
11162
11196
  }
11197
+ function showReference(visible) {
11198
+ if (!SHEET || !SHEET.opened || !SHEET.reference) return;
11199
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:reference', visible }, location.origin);
11200
+ }
11163
11201
  function requestEditorSave(close) {
11164
11202
  if (!SHEET || !SHEET.opened || SHEET.pending) return;
11165
11203
  const request = 'save-' + (++SHEET.requests);
@@ -11183,10 +11221,13 @@ async function onEditorMessage(m) {
11183
11221
  case 'pixelkiln:opened':
11184
11222
  SHEET.opened = true;
11185
11223
  SHEET.loading.remove();
11224
+ SHEET.reference = !!m.reference;
11225
+ SHEET.compare.hidden = !SHEET.reference;
11186
11226
  if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file' + (m.frames > 1 ? 's' : '') + ' with ' + (m.layers === 1 ? 'the layer' : m.layers + ' layers') + (m.frames > 1 ? ' and ' + m.frames + ' ' + memberNoun(SHEET.item, m.frames) : '') + ' restored');
11187
11227
  else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file' + (m.frames > 1 ? 's' : '') + '; the layer file could not be opened');
11188
11228
  else if (m.source === 'frames') sheetStatus('ok', (SHEET.item.edits.length ? 'editing the edit files' : 'editing a copy of the generated ' + memberNoun(SHEET.item, m.frames)) + ' as ' + m.frames + ' frames' + (isFrameSet(SHEET.item) ? '' : ', one per ' + memberNoun(SHEET.item, 1)));
11189
11229
  else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
11230
+ if (SHEET.reference) SHEET.status.append(document.createTextNode(' \xB7 generated art shown through at 50%'));
11190
11231
  sheetButtons();
11191
11232
  break;
11192
11233
  case 'pixelkiln:dirty':
@@ -11629,6 +11670,13 @@ function generateActions(item) {
11629
11670
  };
11630
11671
  if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
11631
11672
  else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
11673
+ else if (item.state === 'orphaned' && item.outputs.every((o) => o.exists)) {
11674
+ // The file changed after download. Putting the recorded bytes back and
11675
+ // regenerating both discard that change, so both say so and ask.
11676
+ add('Restore recorded bytes \xB7 no cost', null, { restore: true, force: true });
11677
+ add('Regenerate' + cost, null, { force: true });
11678
+ } else if (item.state === 'orphaned') add('Restore \xB7 no cost', 'primary', { restore: true });
11679
+ else if (item.state === 'untracked') add('Generate' + cost + ' \xB7 replaces the file', null, { force: true });
11632
11680
  if (item.state === 'in-flight' || item.state === 'recoverable') {
11633
11681
  // A job already parked this asset in review: go straight to its sheet.
11634
11682
  const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
@@ -12319,6 +12367,15 @@ function renderDrawer() {
12319
12367
  if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
12320
12368
  body.append(stateNode(item.state, item.reason));
12321
12369
  if (GENERATION) body.append(generateActions(item));
12370
+ // Writes are opt-in flags on the command line; say so where the buttons would be.
12371
+ if ((!EDITABLE || !GENERATION) && item.declared) {
12372
+ const missing = [];
12373
+ if (!EDITABLE) missing.push('--edit to change its prompt, size, and tags or open it in the browser editor');
12374
+ if (!GENERATION) missing.push('--budget <n> to generate or regenerate it from here');
12375
+ const hint = el('div', 'state-dim readonly-hint');
12376
+ hint.append(document.createTextNode('This gallery is read-only. Restart it with '), el('code', null, 'pixelkiln gallery'), document.createTextNode(' plus ' + missing.join(', and ') + '.'));
12377
+ body.append(hint);
12378
+ }
12322
12379
  if (canEdit && ui.editing === item.id) body.append(editForm(item));
12323
12380
  // Plan already quotes the error as the reason for a failed entry; only a
12324
12381
  // stale or superseded failure needs its own line.
@@ -12629,8 +12686,8 @@ var pin_default = {
12629
12686
  pixelorama: "v1.2.2",
12630
12687
  godot: "4.7.2",
12631
12688
  extensionsApi: 9,
12632
- protocol: 3,
12633
- release: "editor-pixelorama-v1.2.2-pk.14",
12689
+ protocol: 4,
12690
+ release: "editor-pixelorama-v1.2.2-pk.18",
12634
12691
  files: {
12635
12692
  "index.apple-touch-icon.png": {
12636
12693
  sha256: "ee5434873e0fed91e9a888c3ea5a8b79beac25999308be7a6eb41bf4ce640d27",
@@ -12645,7 +12702,7 @@ var pin_default = {
12645
12702
  bytes: 7298
12646
12703
  },
12647
12704
  "index.html": {
12648
- sha256: "2974146c89f4df9f796e840947c334aaf3e4fa3dd18bcd7e08db2eabfd0f086e",
12705
+ sha256: "d9adf06036660c4b76ef5386e872bc03dc0d5b0628fd6ac660d480893ee1f47a",
12649
12706
  bytes: 5442
12650
12707
  },
12651
12708
  "index.icon.png": {
@@ -12657,8 +12714,8 @@ var pin_default = {
12657
12714
  bytes: 279815
12658
12715
  },
12659
12716
  "index.pck": {
12660
- sha256: "736bd383c82fdadf31cc894caf08490b99c5df572aa20db683972c35c7a0465b",
12661
- bytes: 6429628
12717
+ sha256: "a41d9c3a39aa99ea5ee49ddcedfeb8b092006aa75c0de67139f13297c17f42e4",
12718
+ bytes: 6431692
12662
12719
  },
12663
12720
  "index.png": {
12664
12721
  sha256: "6e3ca3eb4a99e4c07bdd89ebc873b440ec0ffd66ce1955160da9e7ed80d538d7",
@@ -13207,13 +13264,21 @@ import { z as z10 } from "zod";
13207
13264
  var GenerateRequestSchema = z10.object({
13208
13265
  project: z10.string().min(1).optional(),
13209
13266
  keys: z10.array(z10.string().min(1)).min(1).max(500),
13210
- /** Regenerate work that is up to date, as `gen --force` does. */
13267
+ /**
13268
+ * Regenerate work that is up to date, as `gen --force` does, and let the
13269
+ * fetch replace a locally modified or untracked file. With `restore`, put
13270
+ * the recorded bytes back over a modified file.
13271
+ */
13211
13272
  force: z10.boolean().optional(),
13212
13273
  /** Advance in-flight, review, or selected work without submitting anything. */
13213
13274
  resume: z10.boolean().optional(),
13214
13275
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
13215
- refresh: z10.boolean().optional()
13216
- }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
13276
+ refresh: z10.boolean().optional(),
13277
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
13278
+ restore: z10.boolean().optional()
13279
+ }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
13280
+ message: "resume, refresh, and restore are exclusive"
13281
+ });
13217
13282
  var GenerateRequestError = class extends Error {
13218
13283
  constructor(message6, status = 400) {
13219
13284
  super(message6);
@@ -13301,7 +13366,9 @@ function createGenerateHandlers(opts) {
13301
13366
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
13302
13367
  const provider = opts.providerFor(job.project ?? void 0, providerId);
13303
13368
  const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
13304
- onProgress: (line) => say(job, line)
13369
+ onProgress: (line) => say(job, line),
13370
+ // A forced regeneration replaces whatever is on disk, as `gen --force`.
13371
+ force: job.force
13305
13372
  });
13306
13373
  job.counts.downloaded += res.downloaded;
13307
13374
  if (res.downloaded || res.failed) {
@@ -13316,12 +13383,32 @@ function createGenerateHandlers(opts) {
13316
13383
  job.finishedAt = now().toISOString();
13317
13384
  }
13318
13385
  }
13386
+ async function restore(job, ctx, specs) {
13387
+ job.phase = "fetching";
13388
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
13389
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
13390
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
13391
+ onProgress: (line) => say(job, line),
13392
+ repair: true,
13393
+ force: job.force
13394
+ });
13395
+ job.counts.downloaded += res.downloaded;
13396
+ say(job, `${providerId}: restored ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
13397
+ if (res.failed) throw new Error(`${res.failed} file(s) could not be restored`);
13398
+ }
13399
+ job.phase = "done";
13400
+ job.finishedAt = now().toISOString();
13401
+ }
13319
13402
  async function run(job, ctx, specs, groups) {
13320
13403
  try {
13321
13404
  if (job.mode === "refresh") {
13322
13405
  await refresh(job, ctx, specs);
13323
13406
  return;
13324
13407
  }
13408
+ if (job.mode === "restore") {
13409
+ await restore(job, ctx, specs);
13410
+ return;
13411
+ }
13325
13412
  if (job.mode === "generate") {
13326
13413
  job.phase = "submitting";
13327
13414
  for (const group of groups) {
@@ -13384,8 +13471,16 @@ function createGenerateHandlers(opts) {
13384
13471
  const holder = busy.get(`${request.project ?? ""}:${key}`);
13385
13472
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
13386
13473
  }
13474
+ if (request.restore) {
13475
+ for (const key of request.keys) {
13476
+ const entry = ctx.lock.entries[key];
13477
+ if (!entry || entry.status !== "downloaded" || !entry.outputs.length) {
13478
+ throw new GenerateRequestError(`"${key}" has no downloaded output recorded to restore`);
13479
+ }
13480
+ }
13481
+ }
13387
13482
  let groups = [];
13388
- if (!request.resume && !request.refresh) {
13483
+ if (!request.resume && !request.refresh && !request.restore) {
13389
13484
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
13390
13485
  if (!plan.actionable.length) {
13391
13486
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -13405,7 +13500,8 @@ function createGenerateHandlers(opts) {
13405
13500
  id: randomBytes2(8).toString("hex"),
13406
13501
  project: request.project ?? null,
13407
13502
  keys: request.keys,
13408
- mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
13503
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : "generate",
13504
+ force: Boolean(request.force),
13409
13505
  phase: "queued",
13410
13506
  startedAt: now().toISOString(),
13411
13507
  finishedAt: null,
@@ -13417,7 +13513,7 @@ function createGenerateHandlers(opts) {
13417
13513
  };
13418
13514
  jobs.set(job.id, job);
13419
13515
  contexts.set(job.id, ctx);
13420
- say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
13516
+ say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.restore ? `restoring ${request.keys.length} asset(s) at no cost${request.force ? " (replacing modified files)" : ""}` : 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("; "));
13421
13517
  void run(job, ctx, specs, groups);
13422
13518
  return job;
13423
13519
  },