pixelkiln 0.31.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/cli.js CHANGED
@@ -6431,7 +6431,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
6431
6431
  }
6432
6432
 
6433
6433
  // src/pipeline/fetch.ts
6434
- import { existsSync as existsSync10 } from "fs";
6434
+ import { existsSync as existsSync10, readFileSync as readFileSync4 } from "fs";
6435
6435
  import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6436
6436
  import path14 from "path";
6437
6437
 
@@ -6487,7 +6487,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6487
6487
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
6488
6488
  if (!opts.repair) return false;
6489
6489
  const spec = specByKey.get(key);
6490
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync10(resolveOutputPath(output.path, spec.root)));
6490
+ if (e.outputs.length === 0 || !spec) return true;
6491
+ return e.outputs.some((output) => {
6492
+ const file = resolveOutputPath(output.path, spec.root);
6493
+ if (!existsSync10(file)) return true;
6494
+ return Boolean(opts.force) && sha256(readFileSync4(file)) !== output.sha256;
6495
+ });
6491
6496
  });
6492
6497
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
6493
6498
  let cursor = 0;
@@ -9412,6 +9417,8 @@ function renderGallery(snapshot, opts = {}) {
9412
9417
  .tray > button { padding:5px 11px; font-size:12.5px; }
9413
9418
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
9414
9419
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
9420
+ .drawer .readonly-hint { margin-top:10px; font-size:12.5px; line-height:1.5; }
9421
+ .drawer .readonly-hint code { font:12px var(--mono); color:var(--text); }
9415
9422
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
9416
9423
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
9417
9424
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
@@ -9621,7 +9628,7 @@ function renderJobs() {
9621
9628
  const row = el('div', 'job');
9622
9629
  const ph = el('span', 'ph');
9623
9630
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
9624
- const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
9631
+ 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') +
9625
9632
  (job.project ? ' in ' + job.project : '');
9626
9633
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
9627
9634
  last.title = job.keys.join('\\n');
@@ -9746,13 +9753,16 @@ function budgetLine() {
9746
9753
 
9747
9754
  // The confirm step: what will be sent, what it is estimated to cost, and what
9748
9755
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
9749
- function generateDialog(items, { project = null, force = false, resume = false, refresh = false } = {}) {
9756
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false } = {}) {
9750
9757
  const host = $('dialog-host');
9751
9758
  host.textContent = '';
9752
9759
  const wrap = el('div', 'dialog');
9753
9760
  const form = el('form');
9754
9761
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
9762
+ const free = resume || refresh || restore;
9763
+ const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
9755
9764
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
9765
+ : restore ? 'Restore ' + noun
9756
9766
  : resume ? 'Resume ' + noun
9757
9767
  : (force ? 'Regenerate ' : 'Generate ') + noun));
9758
9768
  const table = el('table');
@@ -9762,9 +9772,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
9762
9772
  for (const item of items) {
9763
9773
  const tr = el('tr');
9764
9774
  tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
9765
- el('td', 'n', resume || refresh ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
9775
+ el('td', 'n', free ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
9766
9776
  table.append(tr);
9767
- if (!resume && !refresh) {
9777
+ if (!free) {
9768
9778
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
9769
9779
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
9770
9780
  }
@@ -9773,6 +9783,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
9773
9783
  const sum = el('div', 'sum');
9774
9784
  if (refresh) {
9775
9785
  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.'));
9786
+ } else if (restore) {
9787
+ 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.'));
9788
+ 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.'));
9776
9789
  } else if (resume) {
9777
9790
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
9778
9791
  } else {
@@ -9785,12 +9798,13 @@ function generateDialog(items, { project = null, force = false, resume = false,
9785
9798
  if (left !== null && g.cost > left) line.className = 'warn';
9786
9799
  sum.append(line);
9787
9800
  }
9788
- 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.'));
9801
+ 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.'));
9802
+ 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.'));
9789
9803
  sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
9790
9804
  }
9791
9805
  form.append(sum);
9792
9806
  const actions = el('div', 'actions');
9793
- const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
9807
+ 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');
9794
9808
  go.type = 'submit';
9795
9809
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
9796
9810
  const msg = el('span', 'msg');
@@ -9805,6 +9819,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
9805
9819
  if (force) body.force = true;
9806
9820
  if (resume) body.resume = true;
9807
9821
  if (refresh) body.refresh = true;
9822
+ if (restore) body.restore = true;
9808
9823
  const job = await postGenerate(body);
9809
9824
  host.textContent = '';
9810
9825
  GEN.jobs.unshift(job);
@@ -10463,6 +10478,13 @@ function generateActions(item) {
10463
10478
  };
10464
10479
  if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
10465
10480
  else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
10481
+ else if (item.state === 'orphaned' && item.outputs.every((o) => o.exists)) {
10482
+ // The file changed after download. Putting the recorded bytes back and
10483
+ // regenerating both discard that change, so both say so and ask.
10484
+ add('Restore recorded bytes \xB7 no cost', null, { restore: true, force: true });
10485
+ add('Regenerate' + cost, null, { force: true });
10486
+ } else if (item.state === 'orphaned') add('Restore \xB7 no cost', 'primary', { restore: true });
10487
+ else if (item.state === 'untracked') add('Generate' + cost + ' \xB7 replaces the file', null, { force: true });
10466
10488
  if (item.state === 'in-flight' || item.state === 'recoverable') {
10467
10489
  // A job already parked this asset in review: go straight to its sheet.
10468
10490
  const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
@@ -11153,6 +11175,15 @@ function renderDrawer() {
11153
11175
  if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
11154
11176
  body.append(stateNode(item.state, item.reason));
11155
11177
  if (GENERATION) body.append(generateActions(item));
11178
+ // Writes are opt-in flags on the command line; say so where the buttons would be.
11179
+ if ((!EDITABLE || !GENERATION) && item.declared) {
11180
+ const missing = [];
11181
+ if (!EDITABLE) missing.push('--edit to change its prompt, size, and tags or open it in the browser editor');
11182
+ if (!GENERATION) missing.push('--budget <n> to generate or regenerate it from here');
11183
+ const hint = el('div', 'state-dim readonly-hint');
11184
+ hint.append(document.createTextNode('This gallery is read-only. Restart it with '), el('code', null, 'pixelkiln gallery'), document.createTextNode(' plus ' + missing.join(', and ') + '.'));
11185
+ body.append(hint);
11186
+ }
11156
11187
  if (canEdit && ui.editing === item.id) body.append(editForm(item));
11157
11188
  // Plan already quotes the error as the reason for a failed entry; only a
11158
11189
  // stale or superseded failure needs its own line.
@@ -11743,13 +11774,21 @@ import { z as z8 } from "zod";
11743
11774
  var GenerateRequestSchema = z8.object({
11744
11775
  project: z8.string().min(1).optional(),
11745
11776
  keys: z8.array(z8.string().min(1)).min(1).max(500),
11746
- /** Regenerate work that is up to date, as `gen --force` does. */
11777
+ /**
11778
+ * Regenerate work that is up to date, as `gen --force` does, and let the
11779
+ * fetch replace a locally modified or untracked file. With `restore`, put
11780
+ * the recorded bytes back over a modified file.
11781
+ */
11747
11782
  force: z8.boolean().optional(),
11748
11783
  /** Advance in-flight, review, or selected work without submitting anything. */
11749
11784
  resume: z8.boolean().optional(),
11750
11785
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
11751
- refresh: z8.boolean().optional()
11752
- }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
11786
+ refresh: z8.boolean().optional(),
11787
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
11788
+ restore: z8.boolean().optional()
11789
+ }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
11790
+ message: "resume, refresh, and restore are exclusive"
11791
+ });
11753
11792
  var GenerateRequestError = class extends Error {
11754
11793
  constructor(message6, status = 400) {
11755
11794
  super(message6);
@@ -11837,7 +11876,9 @@ function createGenerateHandlers(opts) {
11837
11876
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11838
11877
  const provider = opts.providerFor(job.project ?? void 0, providerId);
11839
11878
  const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
11840
- onProgress: (line) => say(job, line)
11879
+ onProgress: (line) => say(job, line),
11880
+ // A forced regeneration replaces whatever is on disk, as `gen --force`.
11881
+ force: job.force
11841
11882
  });
11842
11883
  job.counts.downloaded += res.downloaded;
11843
11884
  if (res.downloaded || res.failed) {
@@ -11852,12 +11893,32 @@ function createGenerateHandlers(opts) {
11852
11893
  job.finishedAt = now().toISOString();
11853
11894
  }
11854
11895
  }
11896
+ async function restore(job, ctx, specs) {
11897
+ job.phase = "fetching";
11898
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11899
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
11900
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
11901
+ onProgress: (line) => say(job, line),
11902
+ repair: true,
11903
+ force: job.force
11904
+ });
11905
+ job.counts.downloaded += res.downloaded;
11906
+ say(job, `${providerId}: restored ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
11907
+ if (res.failed) throw new Error(`${res.failed} file(s) could not be restored`);
11908
+ }
11909
+ job.phase = "done";
11910
+ job.finishedAt = now().toISOString();
11911
+ }
11855
11912
  async function run(job, ctx, specs, groups) {
11856
11913
  try {
11857
11914
  if (job.mode === "refresh") {
11858
11915
  await refresh(job, ctx, specs);
11859
11916
  return;
11860
11917
  }
11918
+ if (job.mode === "restore") {
11919
+ await restore(job, ctx, specs);
11920
+ return;
11921
+ }
11861
11922
  if (job.mode === "generate") {
11862
11923
  job.phase = "submitting";
11863
11924
  for (const group of groups) {
@@ -11920,8 +11981,16 @@ function createGenerateHandlers(opts) {
11920
11981
  const holder = busy.get(`${request.project ?? ""}:${key}`);
11921
11982
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
11922
11983
  }
11984
+ if (request.restore) {
11985
+ for (const key of request.keys) {
11986
+ const entry = ctx.lock.entries[key];
11987
+ if (!entry || entry.status !== "downloaded" || !entry.outputs.length) {
11988
+ throw new GenerateRequestError(`"${key}" has no downloaded output recorded to restore`);
11989
+ }
11990
+ }
11991
+ }
11923
11992
  let groups = [];
11924
- if (!request.resume && !request.refresh) {
11993
+ if (!request.resume && !request.refresh && !request.restore) {
11925
11994
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
11926
11995
  if (!plan.actionable.length) {
11927
11996
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -11941,7 +12010,8 @@ function createGenerateHandlers(opts) {
11941
12010
  id: randomBytes2(8).toString("hex"),
11942
12011
  project: request.project ?? null,
11943
12012
  keys: request.keys,
11944
- mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
12013
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : "generate",
12014
+ force: Boolean(request.force),
11945
12015
  phase: "queued",
11946
12016
  startedAt: now().toISOString(),
11947
12017
  finishedAt: null,
@@ -11953,7 +12023,7 @@ function createGenerateHandlers(opts) {
11953
12023
  };
11954
12024
  jobs.set(job.id, job);
11955
12025
  contexts.set(job.id, ctx);
11956
- 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("; "));
12026
+ 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("; "));
11957
12027
  void run(job, ctx, specs, groups);
11958
12028
  return job;
11959
12029
  },
@@ -14016,7 +14086,7 @@ function announceGalleryReady(url, count, stdout = process.stdout, stderr = proc
14016
14086
  gallery of ${count} generation${count === 1 ? "" : "s"}: ${url}
14017
14087
  ` + (notes.length ? ` (${notes.join("; ")}; Ctrl+C to stop)
14018
14088
 
14019
- ` : " (read-only; press Ctrl+C to stop)\n\n")
14089
+ ` : " (read-only \u2014 add --edit to change prompts or edit sprites, --budget <n> to generate; Ctrl+C to stop)\n\n")
14020
14090
  );
14021
14091
  }
14022
14092
  function sessionBudget(args) {