pixelkiln 0.31.0 → 0.32.1

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
@@ -2285,8 +2285,15 @@ function lockKey(styleId, assetId) {
2285
2285
 
2286
2286
  // src/providers/pixellab.ts
2287
2287
  function pixelLabObjectUrl(generator, objectId) {
2288
- if (!objectId || generator !== "map" && generator !== "1dir") return null;
2289
- return `https://www.pixellab.ai/objects/${encodeURIComponent(objectId)}`;
2288
+ if (!objectId) return null;
2289
+ if (generator === "map" || generator === "1dir") {
2290
+ return `https://www.pixellab.ai/create-object/${encodeURIComponent(objectId)}`;
2291
+ }
2292
+ if (generator === "tiles") {
2293
+ const setId = objectId.split("#")[0];
2294
+ return setId ? `https://www.pixellab.ai/maps/tiles/${encodeURIComponent(setId)}` : null;
2295
+ }
2296
+ return null;
2290
2297
  }
2291
2298
  var PixelLabProvider = class _PixelLabProvider {
2292
2299
  constructor(client) {
@@ -6431,7 +6438,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
6431
6438
  }
6432
6439
 
6433
6440
  // src/pipeline/fetch.ts
6434
- import { existsSync as existsSync10 } from "fs";
6441
+ import { existsSync as existsSync10, readFileSync as readFileSync4 } from "fs";
6435
6442
  import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6436
6443
  import path14 from "path";
6437
6444
 
@@ -6487,7 +6494,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6487
6494
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
6488
6495
  if (!opts.repair) return false;
6489
6496
  const spec = specByKey.get(key);
6490
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync10(resolveOutputPath(output.path, spec.root)));
6497
+ if (e.outputs.length === 0 || !spec) return true;
6498
+ return e.outputs.some((output) => {
6499
+ const file = resolveOutputPath(output.path, spec.root);
6500
+ if (!existsSync10(file)) return true;
6501
+ return Boolean(opts.force) && sha256(readFileSync4(file)) !== output.sha256;
6502
+ });
6491
6503
  });
6492
6504
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
6493
6505
  let cursor = 0;
@@ -9412,6 +9424,8 @@ function renderGallery(snapshot, opts = {}) {
9412
9424
  .tray > button { padding:5px 11px; font-size:12.5px; }
9413
9425
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
9414
9426
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
9427
+ .drawer .readonly-hint { margin-top:10px; font-size:12.5px; line-height:1.5; }
9428
+ .drawer .readonly-hint code { font:12px var(--mono); color:var(--text); }
9415
9429
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
9416
9430
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
9417
9431
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
@@ -9621,7 +9635,7 @@ function renderJobs() {
9621
9635
  const row = el('div', 'job');
9622
9636
  const ph = el('span', 'ph');
9623
9637
  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') +
9638
+ 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
9639
  (job.project ? ' in ' + job.project : '');
9626
9640
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
9627
9641
  last.title = job.keys.join('\\n');
@@ -9746,13 +9760,16 @@ function budgetLine() {
9746
9760
 
9747
9761
  // The confirm step: what will be sent, what it is estimated to cost, and what
9748
9762
  // 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 } = {}) {
9763
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false } = {}) {
9750
9764
  const host = $('dialog-host');
9751
9765
  host.textContent = '';
9752
9766
  const wrap = el('div', 'dialog');
9753
9767
  const form = el('form');
9754
9768
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
9769
+ const free = resume || refresh || restore;
9770
+ const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
9755
9771
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
9772
+ : restore ? 'Restore ' + noun
9756
9773
  : resume ? 'Resume ' + noun
9757
9774
  : (force ? 'Regenerate ' : 'Generate ') + noun));
9758
9775
  const table = el('table');
@@ -9762,9 +9779,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
9762
9779
  for (const item of items) {
9763
9780
  const tr = el('tr');
9764
9781
  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)));
9782
+ el('td', 'n', free ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
9766
9783
  table.append(tr);
9767
- if (!resume && !refresh) {
9784
+ if (!free) {
9768
9785
  const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
9769
9786
  g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
9770
9787
  }
@@ -9773,6 +9790,9 @@ function generateDialog(items, { project = null, force = false, resume = false,
9773
9790
  const sum = el('div', 'sum');
9774
9791
  if (refresh) {
9775
9792
  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.'));
9793
+ } else if (restore) {
9794
+ 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.'));
9795
+ 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
9796
  } else if (resume) {
9777
9797
  sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
9778
9798
  } else {
@@ -9785,12 +9805,13 @@ function generateDialog(items, { project = null, force = false, resume = false,
9785
9805
  if (left !== null && g.cost > left) line.className = 'warn';
9786
9806
  sum.append(line);
9787
9807
  }
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.'));
9808
+ 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.'));
9809
+ 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
9810
  sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
9790
9811
  }
9791
9812
  form.append(sum);
9792
9813
  const actions = el('div', 'actions');
9793
- const go = el('button', 'primary', refresh ? 'Pull changes' : resume ? 'Resume' : 'Generate');
9814
+ 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
9815
  go.type = 'submit';
9795
9816
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
9796
9817
  const msg = el('span', 'msg');
@@ -9805,6 +9826,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
9805
9826
  if (force) body.force = true;
9806
9827
  if (resume) body.resume = true;
9807
9828
  if (refresh) body.refresh = true;
9829
+ if (restore) body.restore = true;
9808
9830
  const job = await postGenerate(body);
9809
9831
  host.textContent = '';
9810
9832
  GEN.jobs.unshift(job);
@@ -10436,9 +10458,11 @@ function upstreamSection(item) {
10436
10458
  if (item.upstreamUrl) {
10437
10459
  const a = el('a', null, 'Open in ' + item.provider + ' \u2197');
10438
10460
  a.href = item.upstreamUrl; a.target = '_blank'; a.rel = 'noopener';
10439
- row(dl, 'object', a);
10461
+ const tiles = item.generator === 'tiles';
10462
+ row(dl, tiles ? 'tile set' : 'object', a);
10440
10463
  row(dl, 'note', item.provider === 'pixellab'
10441
- ? 'The account object this generation came from. Edit it there with PixelLab\u2019s editor, then pull the changes back here; the lockfile records the new bytes as this generation.'
10464
+ ? (tiles ? 'The tile set this generation was chosen from. Edit it there with PixelLab\u2019s editor, then pull the changes back here; the lockfile records the new bytes as this generation.'
10465
+ : 'The account object this generation came from. Edit it there with PixelLab\u2019s editor, then pull the changes back here; the lockfile records the new bytes as this generation.')
10442
10466
  : 'The provider\u2019s page for this object.');
10443
10467
  }
10444
10468
  s.append(dl);
@@ -10463,6 +10487,13 @@ function generateActions(item) {
10463
10487
  };
10464
10488
  if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
10465
10489
  else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
10490
+ else if (item.state === 'orphaned' && item.outputs.every((o) => o.exists)) {
10491
+ // The file changed after download. Putting the recorded bytes back and
10492
+ // regenerating both discard that change, so both say so and ask.
10493
+ add('Restore recorded bytes \xB7 no cost', null, { restore: true, force: true });
10494
+ add('Regenerate' + cost, null, { force: true });
10495
+ } else if (item.state === 'orphaned') add('Restore \xB7 no cost', 'primary', { restore: true });
10496
+ else if (item.state === 'untracked') add('Generate' + cost + ' \xB7 replaces the file', null, { force: true });
10466
10497
  if (item.state === 'in-flight' || item.state === 'recoverable') {
10467
10498
  // A job already parked this asset in review: go straight to its sheet.
10468
10499
  const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
@@ -11153,6 +11184,15 @@ function renderDrawer() {
11153
11184
  if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
11154
11185
  body.append(stateNode(item.state, item.reason));
11155
11186
  if (GENERATION) body.append(generateActions(item));
11187
+ // Writes are opt-in flags on the command line; say so where the buttons would be.
11188
+ if ((!EDITABLE || !GENERATION) && item.declared) {
11189
+ const missing = [];
11190
+ if (!EDITABLE) missing.push('--edit to change its prompt, size, and tags or open it in the browser editor');
11191
+ if (!GENERATION) missing.push('--budget <n> to generate or regenerate it from here');
11192
+ const hint = el('div', 'state-dim readonly-hint');
11193
+ hint.append(document.createTextNode('This gallery is read-only. Restart it with '), el('code', null, 'pixelkiln gallery'), document.createTextNode(' plus ' + missing.join(', and ') + '.'));
11194
+ body.append(hint);
11195
+ }
11156
11196
  if (canEdit && ui.editing === item.id) body.append(editForm(item));
11157
11197
  // Plan already quotes the error as the reason for a failed entry; only a
11158
11198
  // stale or superseded failure needs its own line.
@@ -11743,13 +11783,21 @@ import { z as z8 } from "zod";
11743
11783
  var GenerateRequestSchema = z8.object({
11744
11784
  project: z8.string().min(1).optional(),
11745
11785
  keys: z8.array(z8.string().min(1)).min(1).max(500),
11746
- /** Regenerate work that is up to date, as `gen --force` does. */
11786
+ /**
11787
+ * Regenerate work that is up to date, as `gen --force` does, and let the
11788
+ * fetch replace a locally modified or untracked file. With `restore`, put
11789
+ * the recorded bytes back over a modified file.
11790
+ */
11747
11791
  force: z8.boolean().optional(),
11748
11792
  /** Advance in-flight, review, or selected work without submitting anything. */
11749
11793
  resume: z8.boolean().optional(),
11750
11794
  /** 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" });
11795
+ refresh: z8.boolean().optional(),
11796
+ /** Put recorded outputs back on disk from the cache or the provider, without generating. */
11797
+ restore: z8.boolean().optional()
11798
+ }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
11799
+ message: "resume, refresh, and restore are exclusive"
11800
+ });
11753
11801
  var GenerateRequestError = class extends Error {
11754
11802
  constructor(message6, status = 400) {
11755
11803
  super(message6);
@@ -11837,7 +11885,9 @@ function createGenerateHandlers(opts) {
11837
11885
  for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11838
11886
  const provider = opts.providerFor(job.project ?? void 0, providerId);
11839
11887
  const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
11840
- onProgress: (line) => say(job, line)
11888
+ onProgress: (line) => say(job, line),
11889
+ // A forced regeneration replaces whatever is on disk, as `gen --force`.
11890
+ force: job.force
11841
11891
  });
11842
11892
  job.counts.downloaded += res.downloaded;
11843
11893
  if (res.downloaded || res.failed) {
@@ -11852,12 +11902,32 @@ function createGenerateHandlers(opts) {
11852
11902
  job.finishedAt = now().toISOString();
11853
11903
  }
11854
11904
  }
11905
+ async function restore(job, ctx, specs) {
11906
+ job.phase = "fetching";
11907
+ for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
11908
+ const provider = opts.providerFor(job.project ?? void 0, providerId);
11909
+ const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
11910
+ onProgress: (line) => say(job, line),
11911
+ repair: true,
11912
+ force: job.force
11913
+ });
11914
+ job.counts.downloaded += res.downloaded;
11915
+ say(job, `${providerId}: restored ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
11916
+ if (res.failed) throw new Error(`${res.failed} file(s) could not be restored`);
11917
+ }
11918
+ job.phase = "done";
11919
+ job.finishedAt = now().toISOString();
11920
+ }
11855
11921
  async function run(job, ctx, specs, groups) {
11856
11922
  try {
11857
11923
  if (job.mode === "refresh") {
11858
11924
  await refresh(job, ctx, specs);
11859
11925
  return;
11860
11926
  }
11927
+ if (job.mode === "restore") {
11928
+ await restore(job, ctx, specs);
11929
+ return;
11930
+ }
11861
11931
  if (job.mode === "generate") {
11862
11932
  job.phase = "submitting";
11863
11933
  for (const group of groups) {
@@ -11920,8 +11990,16 @@ function createGenerateHandlers(opts) {
11920
11990
  const holder = busy.get(`${request.project ?? ""}:${key}`);
11921
11991
  if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
11922
11992
  }
11993
+ if (request.restore) {
11994
+ for (const key of request.keys) {
11995
+ const entry = ctx.lock.entries[key];
11996
+ if (!entry || entry.status !== "downloaded" || !entry.outputs.length) {
11997
+ throw new GenerateRequestError(`"${key}" has no downloaded output recorded to restore`);
11998
+ }
11999
+ }
12000
+ }
11923
12001
  let groups = [];
11924
- if (!request.resume && !request.refresh) {
12002
+ if (!request.resume && !request.refresh && !request.restore) {
11925
12003
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
11926
12004
  if (!plan.actionable.length) {
11927
12005
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -11941,7 +12019,8 @@ function createGenerateHandlers(opts) {
11941
12019
  id: randomBytes2(8).toString("hex"),
11942
12020
  project: request.project ?? null,
11943
12021
  keys: request.keys,
11944
- mode: request.refresh ? "refresh" : request.resume ? "resume" : "generate",
12022
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : "generate",
12023
+ force: Boolean(request.force),
11945
12024
  phase: "queued",
11946
12025
  startedAt: now().toISOString(),
11947
12026
  finishedAt: null,
@@ -11953,7 +12032,7 @@ function createGenerateHandlers(opts) {
11953
12032
  };
11954
12033
  jobs.set(job.id, job);
11955
12034
  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("; "));
12035
+ 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
12036
  void run(job, ctx, specs, groups);
11958
12037
  return job;
11959
12038
  },
@@ -14016,7 +14095,7 @@ function announceGalleryReady(url, count, stdout = process.stdout, stderr = proc
14016
14095
  gallery of ${count} generation${count === 1 ? "" : "s"}: ${url}
14017
14096
  ` + (notes.length ? ` (${notes.join("; ")}; Ctrl+C to stop)
14018
14097
 
14019
- ` : " (read-only; press Ctrl+C to stop)\n\n")
14098
+ ` : " (read-only \u2014 add --edit to change prompts or edit sprites, --budget <n> to generate; Ctrl+C to stop)\n\n")
14020
14099
  );
14021
14100
  }
14022
14101
  function sessionBudget(args) {