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/index.cjs CHANGED
@@ -8041,7 +8041,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
8041
8041
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
8042
8042
  if (!opts.repair) return false;
8043
8043
  const spec = specByKey.get(key);
8044
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !(0, import_node_fs15.existsSync)(resolveOutputPath(output.path, spec.root)));
8044
+ if (e.outputs.length === 0 || !spec) return true;
8045
+ return e.outputs.some((output) => {
8046
+ const file = resolveOutputPath(output.path, spec.root);
8047
+ if (!(0, import_node_fs15.existsSync)(file)) return true;
8048
+ return Boolean(opts.force) && sha256((0, import_node_fs15.readFileSync)(file)) !== output.sha256;
8049
+ });
8045
8050
  });
8046
8051
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
8047
8052
  let cursor = 0;
@@ -10858,6 +10863,8 @@ function renderGallery(snapshot, opts = {}) {
10858
10863
  .tray > button { padding:5px 11px; font-size:12.5px; }
10859
10864
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10860
10865
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10866
+ .drawer .readonly-hint { margin-top:10px; font-size:12.5px; line-height:1.5; }
10867
+ .drawer .readonly-hint code { font:12px var(--mono); color:var(--text); }
10861
10868
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10862
10869
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
10863
10870
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
@@ -11067,7 +11074,7 @@ function renderJobs() {
11067
11074
  const row = el('div', 'job');
11068
11075
  const ph = el('span', 'ph');
11069
11076
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
11070
- const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
11077
+ 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') +
11071
11078
  (job.project ? ' in ' + job.project : '');
11072
11079
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
11073
11080
  last.title = job.keys.join('\\n');
@@ -11192,13 +11199,16 @@ function budgetLine() {
11192
11199
 
11193
11200
  // The confirm step: what will be sent, what it is estimated to cost, and what
11194
11201
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
11195
- function generateDialog(items, { project = null, force = false, resume = false, refresh = false } = {}) {
11202
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false } = {}) {
11196
11203
  const host = $('dialog-host');
11197
11204
  host.textContent = '';
11198
11205
  const wrap = el('div', 'dialog');
11199
11206
  const form = el('form');
11200
11207
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
11208
+ const free = resume || refresh || restore;
11209
+ const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
11201
11210
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
11211
+ : restore ? 'Restore ' + noun
11202
11212
  : resume ? 'Resume ' + noun
11203
11213
  : (force ? 'Regenerate ' : 'Generate ') + noun));
11204
11214
  const table = el('table');
@@ -11208,9 +11218,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
11208
11218
  for (const item of items) {
11209
11219
  const tr = el('tr');
11210
11220
  tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
11211
- el('td', 'n', resume || refresh ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
11221
+ el('td', 'n', free ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
11212
11222
  table.append(tr);
11213
- if (!resume && !refresh) {
11223
+ if (!free) {
11214
11224
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
11215
11225
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
11216
11226
  }
@@ -11219,6 +11229,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
11219
11229
  const sum = el('div', 'sum');
11220
11230
  if (refresh) {
11221
11231
  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.'));
11232
+ } else if (restore) {
11233
+ 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.'));
11234
+ 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.'));
11222
11235
  } else if (resume) {
11223
11236
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
11224
11237
  } else {
@@ -11231,12 +11244,13 @@ function generateDialog(items, { project = null, force = false, resume = false,
11231
11244
  if (left !== null && g.cost > left) line.className = 'warn';
11232
11245
  sum.append(line);
11233
11246
  }
11234
- 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.'));
11247
+ 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.'));
11248
+ 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.'));
11235
11249
  sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
11236
11250
  }
11237
11251
  form.append(sum);
11238
11252
  const actions = el('div', 'actions');
11239
- const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
11253
+ 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');
11240
11254
  go.type = 'submit';
11241
11255
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
11242
11256
  const msg = el('span', 'msg');
@@ -11251,6 +11265,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
11251
11265
  if (force) body.force = true;
11252
11266
  if (resume) body.resume = true;
11253
11267
  if (refresh) body.refresh = true;
11268
+ if (restore) body.restore = true;
11254
11269
  const job = await postGenerate(body);
11255
11270
  host.textContent = '';
11256
11271
  GEN.jobs.unshift(job);
@@ -11909,6 +11924,13 @@ function generateActions(item) {
11909
11924
  };
11910
11925
  if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
11911
11926
  else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
11927
+ else if (item.state === 'orphaned' && item.outputs.every((o) => o.exists)) {
11928
+ // The file changed after download. Putting the recorded bytes back and
11929
+ // regenerating both discard that change, so both say so and ask.
11930
+ add('Restore recorded bytes \xB7 no cost', null, { restore: true, force: true });
11931
+ add('Regenerate' + cost, null, { force: true });
11932
+ } else if (item.state === 'orphaned') add('Restore \xB7 no cost', 'primary', { restore: true });
11933
+ else if (item.state === 'untracked') add('Generate' + cost + ' \xB7 replaces the file', null, { force: true });
11912
11934
  if (item.state === 'in-flight' || item.state === 'recoverable') {
11913
11935
  // A job already parked this asset in review: go straight to its sheet.
11914
11936
  const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
@@ -12599,6 +12621,15 @@ function renderDrawer() {
12599
12621
  if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
12600
12622
  body.append(stateNode(item.state, item.reason));
12601
12623
  if (GENERATION) body.append(generateActions(item));
12624
+ // Writes are opt-in flags on the command line; say so where the buttons would be.
12625
+ if ((!EDITABLE || !GENERATION) && item.declared) {
12626
+ const missing = [];
12627
+ if (!EDITABLE) missing.push('--edit to change its prompt, size, and tags or open it in the browser editor');
12628
+ if (!GENERATION) missing.push('--budget <n> to generate or regenerate it from here');
12629
+ const hint = el('div', 'state-dim readonly-hint');
12630
+ hint.append(document.createTextNode('This gallery is read-only. Restart it with '), el('code', null, 'pixelkiln gallery'), document.createTextNode(' plus ' + missing.join(', and ') + '.'));
12631
+ body.append(hint);
12632
+ }
12602
12633
  if (canEdit && ui.editing === item.id) body.append(editForm(item));
12603
12634
  // Plan already quotes the error as the reason for a failed entry; only a
12604
12635
  // stale or superseded failure needs its own line.
@@ -13487,13 +13518,21 @@ var import_zod10 = require("zod");
13487
13518
  var GenerateRequestSchema = import_zod10.z.object({
13488
13519
  project: import_zod10.z.string().min(1).optional(),
13489
13520
  keys: import_zod10.z.array(import_zod10.z.string().min(1)).min(1).max(500),
13490
- /** Regenerate work that is up to date, as `gen --force` does. */
13521
+ /**
13522
+ * Regenerate work that is up to date, as `gen --force` does, and let the
13523
+ * fetch replace a locally modified or untracked file. With `restore`, put
13524
+ * the recorded bytes back over a modified file.
13525
+ */
13491
13526
  force: import_zod10.z.boolean().optional(),
13492
13527
  /** Advance in-flight, review, or selected work without submitting anything. */
13493
13528
  resume: import_zod10.z.boolean().optional(),
13494
13529
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
13495
- refresh: import_zod10.z.boolean().optional()
13496
- }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
13530
+ refresh: import_zod10.z.boolean().optional(),
13531
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
13532
+ restore: import_zod10.z.boolean().optional()
13533
+ }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
13534
+ message: "resume, refresh, and restore are exclusive"
13535
+ });
13497
13536
  var GenerateRequestError = class extends Error {
13498
13537
  constructor(message6, status = 400) {
13499
13538
  super(message6);
@@ -13581,7 +13620,9 @@ function createGenerateHandlers(opts) {
13581
13620
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
13582
13621
  const provider = opts.providerFor(job.project ?? void 0, providerId);
13583
13622
  const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
13584
- onProgress: (line) => say(job, line)
13623
+ onProgress: (line) => say(job, line),
13624
+ // A forced regeneration replaces whatever is on disk, as `gen --force`.
13625
+ force: job.force
13585
13626
  });
13586
13627
  job.counts.downloaded += res.downloaded;
13587
13628
  if (res.downloaded || res.failed) {
@@ -13596,12 +13637,32 @@ function createGenerateHandlers(opts) {
13596
13637
  job.finishedAt = now().toISOString();
13597
13638
  }
13598
13639
  }
13640
+ async function restore(job, ctx, specs) {
13641
+ job.phase = "fetching";
13642
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
13643
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
13644
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
13645
+ onProgress: (line) => say(job, line),
13646
+ repair: true,
13647
+ force: job.force
13648
+ });
13649
+ job.counts.downloaded += res.downloaded;
13650
+ say(job, `${providerId}: restored ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
13651
+ if (res.failed) throw new Error(`${res.failed} file(s) could not be restored`);
13652
+ }
13653
+ job.phase = "done";
13654
+ job.finishedAt = now().toISOString();
13655
+ }
13599
13656
  async function run(job, ctx, specs, groups) {
13600
13657
  try {
13601
13658
  if (job.mode === "refresh") {
13602
13659
  await refresh(job, ctx, specs);
13603
13660
  return;
13604
13661
  }
13662
+ if (job.mode === "restore") {
13663
+ await restore(job, ctx, specs);
13664
+ return;
13665
+ }
13605
13666
  if (job.mode === "generate") {
13606
13667
  job.phase = "submitting";
13607
13668
  for (const group of groups) {
@@ -13664,8 +13725,16 @@ function createGenerateHandlers(opts) {
13664
13725
  const holder = busy.get(`${request.project ?? ""}:${key}`);
13665
13726
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
13666
13727
  }
13728
+ if (request.restore) {
13729
+ for (const key of request.keys) {
13730
+ const entry = ctx.lock.entries[key];
13731
+ if (!entry || entry.status !== "downloaded" || !entry.outputs.length) {
13732
+ throw new GenerateRequestError(`"${key}" has no downloaded output recorded to restore`);
13733
+ }
13734
+ }
13735
+ }
13667
13736
  let groups = [];
13668
- if (!request.resume && !request.refresh) {
13737
+ if (!request.resume && !request.refresh && !request.restore) {
13669
13738
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
13670
13739
  if (!plan.actionable.length) {
13671
13740
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -13685,7 +13754,8 @@ function createGenerateHandlers(opts) {
13685
13754
  id: (0, import_node_crypto9.randomBytes)(8).toString("hex"),
13686
13755
  project: request.project ?? null,
13687
13756
  keys: request.keys,
13688
- mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
13757
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : "generate",
13758
+ force: Boolean(request.force),
13689
13759
  phase: "queued",
13690
13760
  startedAt: now().toISOString(),
13691
13761
  finishedAt: null,
@@ -13697,7 +13767,7 @@ function createGenerateHandlers(opts) {
13697
13767
  };
13698
13768
  jobs.set(job.id, job);
13699
13769
  contexts.set(job.id, ctx);
13700
- 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("; "));
13770
+ 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("; "));
13701
13771
  void run(job, ctx, specs, groups);
13702
13772
  return job;
13703
13773
  },