pixelkiln 0.81.0 → 0.82.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.
@@ -525,3 +525,16 @@ body[data-size="l"] .cell { height:210px; }
525
525
  .keys-sheet dd { margin:0; color:var(--dim); }
526
526
  .keys-sheet kbd { font:600 11.5px/1 var(--mono); border:1px solid var(--line-strong); border-bottom-width:2px; padding:2px 6px; }
527
527
  @media (max-width: 900px) { .bar { flex-wrap:wrap; } }
528
+
529
+ /* ---- toast, studio template preview ---- */
530
+ .toast { position:fixed; left:22px; bottom:22px; z-index:40; display:flex; align-items:center; gap:10px; max-width:min(560px, calc(100vw - 44px));
531
+ padding:9px 10px 9px 14px; background:var(--panel); border:1px solid var(--line-strong); border-left:3px solid var(--ok);
532
+ box-shadow:0 10px 30px rgba(0,0,0,.45); font-size:13px; }
533
+ .toast[hidden] { display:none; }
534
+ .toast.bad { border-left-color:var(--bad); }
535
+ .toast button { padding:4px 10px; font-size:12.5px; }
536
+ .toast button.x { border:0; color:var(--dim); padding:4px 6px; }
537
+ .studio-tpl-preview { display:flex; align-items:center; gap:10px; }
538
+ .studio-tpl-preview[hidden] { display:none; }
539
+ .studio-tpl-preview img { width:48px; height:48px; image-rendering:pixelated; object-fit:contain; background-color:var(--stage-bg); background-image:var(--stage-pattern); background-size:8px 8px; border:1px solid var(--line); }
540
+ .studio-draft { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
@@ -150,7 +150,15 @@
150
150
  return s >= 1 ? Math.floor(s) : s;
151
151
  };
152
152
  var projectOf = (item) => S.snap.workspace ? S.snap.workspace.projects.find((pr) => pr.id === item.project) : S.snap.project;
153
+ var editHooks = { saved: null };
154
+ var manifestShaOf = (snap, project) => snap.workspace ? snap.workspace.projects.find((pr) => pr.id === project)?.manifestSha256 ?? null : snap.project?.manifestSha256 ?? null;
153
155
  async function postEdit(body) {
156
+ const before = manifestShaOf(S.snap, body && body.project);
157
+ const snap = await sendEdit(body);
158
+ editHooks.saved?.(body, manifestShaOf(snap, body && body.project) !== before);
159
+ return snap;
160
+ }
161
+ async function sendEdit(body) {
154
162
  const res = await fetch("/api/edit", {
155
163
  method: "POST",
156
164
  headers: { "Content-Type": "application/json", "X-Pixelkiln-Session": SESSION ?? "" },
@@ -4645,6 +4653,48 @@
4645
4653
  { id: "pro", label: "pro: described in words, sequential (20-40 per direction)" }
4646
4654
  ];
4647
4655
  var templated = (mode) => mode === "skeleton-v3" || mode === "template";
4656
+ var KNOWN_TEMPLATES = [
4657
+ "walk",
4658
+ "walking-8-frames",
4659
+ "running-8-frames",
4660
+ "breathing-idle",
4661
+ "crouched-walking",
4662
+ "attack",
4663
+ "attack-back",
4664
+ "attack-left",
4665
+ "attack-right",
4666
+ "cross-punch",
4667
+ "backflip",
4668
+ "angry",
4669
+ "bark"
4670
+ ];
4671
+ var draftKey = () => "pixelkiln.studio.draft:" + (S.snap.workspace ? S.snap.workspace.path : S.snap.project ? S.snap.project.manifest : "");
4672
+ function readDraft() {
4673
+ try {
4674
+ const d = JSON.parse(localStorage.getItem(draftKey()) || "null");
4675
+ return d && typeof d === "object" ? d : null;
4676
+ } catch {
4677
+ return null;
4678
+ }
4679
+ }
4680
+ function writeDraft(draft) {
4681
+ try {
4682
+ if (draft) localStorage.setItem(draftKey(), JSON.stringify(draft));
4683
+ else localStorage.removeItem(draftKey());
4684
+ } catch {
4685
+ }
4686
+ }
4687
+ function usedTemplates(projectId) {
4688
+ const out = /* @__PURE__ */ new Map();
4689
+ for (const i of S.snap.items) {
4690
+ const t = i.asset && i.asset.animation && i.asset.animation.template;
4691
+ if (!t || i.project !== projectId) continue;
4692
+ const frames = i.outputs.filter((o) => o.url);
4693
+ if (frames.length > 1 && !out.get(t)) out.set(t, i);
4694
+ else if (!out.has(t)) out.set(t, null);
4695
+ }
4696
+ return out;
4697
+ }
4648
4698
  var idPattern = "[^\\/\\\\:]+";
4649
4699
  function openStudio() {
4650
4700
  const host = $("dialog-host");
@@ -4657,10 +4707,12 @@
4657
4707
  const scrim = el("div", "sheet-scrim");
4658
4708
  const panel = el("div", "sheet studio");
4659
4709
  let closed = false;
4710
+ const previewTimers = /* @__PURE__ */ new Set();
4660
4711
  const close = () => {
4661
4712
  closed = true;
4662
4713
  if (priceTimer) clearTimeout(priceTimer);
4663
4714
  priceSeq++;
4715
+ for (const t of previewTimers) clearInterval(t);
4664
4716
  host.textContent = "";
4665
4717
  };
4666
4718
  const bar = el("div", "rbar");
@@ -4734,7 +4786,9 @@
4734
4786
  const loopList = el("div", "studio-loops");
4735
4787
  const addLoop = el("button", "add", "+ Add a loop");
4736
4788
  addLoop.type = "button";
4737
- loopsSection.append(el("h3", null, "Loops"), el("small", "state-dim", "Each loop is drawn once per ticked direction; a direction whose mirror is ticked is flipped from it for free."), loopList, addLoop);
4789
+ const templateList = el("datalist");
4790
+ templateList.id = "studio-templates";
4791
+ loopsSection.append(el("h3", null, "Loops"), el("small", "state-dim", "Each loop is drawn once per ticked direction; a direction whose mirror is ticked is flipped from it for free."), loopList, addLoop, templateList);
4738
4792
  const portrait = el("input");
4739
4793
  portrait.type = "checkbox";
4740
4794
  const portraitSize = el("select");
@@ -4805,7 +4859,54 @@
4805
4859
  function syncLoopMode(row2) {
4806
4860
  const t = templated(row2.mode.value);
4807
4861
  row2.what.placeholder = t ? "template, e.g. walk, breathing-idle, running-8-frames" : 'the motion, e.g. "swings a sword overhead"';
4808
- row2.what.title = t ? "A PixelLab template id" : "What the loop does";
4862
+ row2.what.title = t ? "A PixelLab template id: pick one, or type any id PixelLab has" : "What the loop does";
4863
+ if (t) row2.what.setAttribute("list", templateList.id);
4864
+ else row2.what.removeAttribute("list");
4865
+ syncPreview(row2);
4866
+ }
4867
+ function syncPreview(row2) {
4868
+ const old = row2.preview.__timer;
4869
+ if (old) {
4870
+ clearInterval(old);
4871
+ previewTimers.delete(old);
4872
+ }
4873
+ row2.preview.textContent = "";
4874
+ const name = row2.what.value.trim();
4875
+ const sample = templated(row2.mode.value) && name ? usedTemplates(projectId()).get(name) : null;
4876
+ if (!sample) {
4877
+ row2.preview.hidden = true;
4878
+ return;
4879
+ }
4880
+ const urls = sample.outputs.filter((o) => o.url).map((o) => o.url);
4881
+ const img = el("img");
4882
+ img.src = urls[0];
4883
+ img.alt = name;
4884
+ let i = 0;
4885
+ const timer = setInterval(() => {
4886
+ if (!img.isConnected) {
4887
+ clearInterval(timer);
4888
+ previewTimers.delete(timer);
4889
+ return;
4890
+ }
4891
+ if (!document.hidden) {
4892
+ i = (i + 1) % urls.length;
4893
+ img.src = urls[i];
4894
+ }
4895
+ }, Math.round(1e3 / (sample.fps || 12)));
4896
+ row2.preview.__timer = timer;
4897
+ previewTimers.add(timer);
4898
+ row2.preview.append(img, el("small", "state-dim", name + ", as drawn for " + sample.assetId));
4899
+ row2.preview.hidden = false;
4900
+ }
4901
+ function fillTemplateList() {
4902
+ templateList.textContent = "";
4903
+ const used = usedTemplates(projectId());
4904
+ for (const t of [.../* @__PURE__ */ new Set([...used.keys(), ...KNOWN_TEMPLATES])]) {
4905
+ const o = el("option");
4906
+ o.value = t;
4907
+ if (used.has(t)) o.label = "used in this project";
4908
+ templateList.append(o);
4909
+ }
4809
4910
  }
4810
4911
  function addLoopRow(defaults = {}) {
4811
4912
  const wrap = el("div", "studio-loop");
@@ -4835,11 +4936,15 @@
4835
4936
  }
4836
4937
  const remove = el("button", null, "Remove");
4837
4938
  remove.type = "button";
4838
- const row2 = { id, mode, what, directions: boxes, wrap };
4939
+ const preview = el("div", "studio-tpl-preview");
4940
+ preview.hidden = true;
4941
+ const row2 = { id, mode, what, directions: boxes, wrap, preview };
4942
+ what.addEventListener("input", () => syncPreview(row2));
4839
4943
  remove.onclick = () => {
4840
4944
  loops2.splice(loops2.indexOf(row2), 1);
4841
4945
  wrap.remove();
4842
4946
  schedulePrice();
4947
+ keepDraft();
4843
4948
  };
4844
4949
  mode.onchange = () => {
4845
4950
  syncLoopMode(row2);
@@ -4847,7 +4952,7 @@
4847
4952
  };
4848
4953
  const line = el("div", "row");
4849
4954
  line.append(field("loop id", id), field("mode", mode), field("template or motion", what));
4850
- wrap.append(line, dirs, remove);
4955
+ wrap.append(line, preview, dirs, remove);
4851
4956
  loopList.append(wrap);
4852
4957
  loops2.push(row2);
4853
4958
  syncLoopMode(row2);
@@ -4985,6 +5090,7 @@
4985
5090
  const keys = price ? price.items.filter((i) => ours(i.key)).map((i) => i.key) : [];
4986
5091
  try {
4987
5092
  S.snap = await postEdit(request());
5093
+ writeDraft(null);
4988
5094
  const newId = (pid ? pid + ":" : "") + sid + "/" + base;
4989
5095
  if (generate && keys.length) {
4990
5096
  await postGenerate({ keys, ...pid ? { project: pid } : {} });
@@ -5078,11 +5184,84 @@
5078
5184
  };
5079
5185
  form.addEventListener("input", schedulePrice);
5080
5186
  form.addEventListener("change", schedulePrice);
5187
+ const capture = () => ({
5188
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
5189
+ project: project.value,
5190
+ style: style.value,
5191
+ styleId: styleId.value,
5192
+ engine: engine.value,
5193
+ size: size.value,
5194
+ directions: directions.value,
5195
+ body: body.value,
5196
+ view: view.value,
5197
+ outDir: outDir.value,
5198
+ outDirTouched,
5199
+ baseId: baseId.value,
5200
+ prompt: prompt.value,
5201
+ reference,
5202
+ loops: loops2.map((r) => ({ id: r.id.value, mode: r.mode.value, what: r.what.value, directions: [...r.directions].filter(([, b]) => b.checked).map(([d]) => d) })),
5203
+ portrait: portrait.checked,
5204
+ portraitSize: portraitSize.value
5205
+ });
5206
+ let draftTimer = null;
5207
+ const keepDraft = () => {
5208
+ if (closed) return;
5209
+ if (draftTimer) clearTimeout(draftTimer);
5210
+ draftTimer = setTimeout(() => {
5211
+ if (!closed) writeDraft(baseId.value.trim() || prompt.value.trim() ? capture() : null);
5212
+ }, 300);
5213
+ };
5214
+ form.addEventListener("input", keepDraft);
5215
+ form.addEventListener("change", keepDraft);
5216
+ addLoop.addEventListener("click", keepDraft);
5081
5217
  fillStyles();
5218
+ fillTemplateList();
5082
5219
  syncEngine();
5083
5220
  syncStyle();
5084
- addLoopRow({ id: "walk", mode: "skeleton-v3", what: "walk", directions: ["south", "west", "north"] });
5085
- addLoopRow({ id: "idle", mode: "skeleton-v3", what: "breathing-idle", directions: ["south"] });
5221
+ const draft = readDraft();
5222
+ if (draft && (draft.baseId || draft.prompt)) {
5223
+ const set = (input, value) => {
5224
+ if (typeof value !== "string") return;
5225
+ if (input instanceof HTMLSelectElement && ![...input.options].some((o) => o.value === value)) return;
5226
+ input.value = value;
5227
+ };
5228
+ set(project, draft.project);
5229
+ fillStyles();
5230
+ set(style, draft.style);
5231
+ set(engine, draft.engine);
5232
+ syncEngine();
5233
+ set(styleId, draft.styleId);
5234
+ set(size, draft.size);
5235
+ set(directions, draft.directions);
5236
+ set(body, draft.body);
5237
+ set(view, draft.view);
5238
+ outDirTouched = Boolean(draft.outDirTouched);
5239
+ set(outDir, draft.outDir);
5240
+ set(baseId, draft.baseId);
5241
+ set(prompt, draft.prompt);
5242
+ if (typeof draft.reference === "string") {
5243
+ reference = draft.reference;
5244
+ refField.append(el("small", "state-dim studio-ref-kept", "Using " + draft.reference + ", uploaded with this draft. Choose a file to replace it."));
5245
+ }
5246
+ portrait.checked = Boolean(draft.portrait);
5247
+ set(portraitSize, draft.portraitSize);
5248
+ syncStyle();
5249
+ for (const l of Array.isArray(draft.loops) ? draft.loops : []) addLoopRow({ id: l.id, mode: l.mode, what: l.what, directions: l.directions });
5250
+ const note = el("div", "notice studio-draft");
5251
+ note.append(document.createTextNode("Picked up the draft you left " + (fmtWhen(draft.savedAt) || "earlier") + ". "));
5252
+ const discard = el("button", "linkish", "Start over");
5253
+ discard.type = "button";
5254
+ discard.onclick = () => {
5255
+ writeDraft(null);
5256
+ close();
5257
+ openStudio();
5258
+ };
5259
+ note.append(discard);
5260
+ form.prepend(note);
5261
+ } else {
5262
+ addLoopRow({ id: "walk", mode: "skeleton-v3", what: "walk", directions: ["south", "west", "north"] });
5263
+ addLoopRow({ id: "idle", mode: "skeleton-v3", what: "breathing-idle", directions: ["south"] });
5264
+ }
5086
5265
  updatePrice();
5087
5266
  setTimeout(() => baseId.focus(), 0);
5088
5267
  }
@@ -5094,7 +5273,8 @@
5094
5273
  keys: [
5095
5274
  [["/"], "jump to search"],
5096
5275
  [["?"], "this list"],
5097
- [["Esc"], "close whatever is open: a sheet, the record, the View menu, or select mode"]
5276
+ [["Esc"], "close whatever is open: a sheet, the record, the View menu, or select mode"],
5277
+ [["⌘/ctrl", "Z"], "undo the gallery's last manifest edit (with --edit)"]
5098
5278
  ]
5099
5279
  },
5100
5280
  {
@@ -5162,8 +5342,86 @@
5162
5342
  close.focus();
5163
5343
  }
5164
5344
 
5345
+ // src/gallery/client/src/undo.ts
5346
+ var lastProject = null;
5347
+ var hideTimer = null;
5348
+ function describe(body) {
5349
+ switch (body.action) {
5350
+ case "batch":
5351
+ return body.edits.length + " changes to the manifest";
5352
+ case "add-style":
5353
+ return "added style " + body.styleId;
5354
+ case "patch-style":
5355
+ return "changed style " + body.styleId;
5356
+ case "add-asset":
5357
+ return "added " + body.assetId;
5358
+ case "patch-asset":
5359
+ return "changed " + body.assetId;
5360
+ case "set-source":
5361
+ case "clear-source":
5362
+ return "changed the source of " + body.assetId;
5363
+ case "create-skeleton-animation":
5364
+ return "added " + body.assetId;
5365
+ case "start-edit":
5366
+ return "started a hand edit of " + body.assetId;
5367
+ case "save-edit":
5368
+ return "saved a hand edit of " + body.assetId;
5369
+ case "detach-edit":
5370
+ return "detached the hand edit of " + body.assetId;
5371
+ default:
5372
+ return "the manifest changed";
5373
+ }
5374
+ }
5375
+ function toast(text, canUndo, tone = "") {
5376
+ const host = $("toast");
5377
+ host.textContent = "";
5378
+ host.className = "toast" + (tone ? " " + tone : "");
5379
+ host.append(el("span", null, text));
5380
+ if (canUndo) {
5381
+ const b = el("button", null, "Undo");
5382
+ b.type = "button";
5383
+ b.title = "Put the manifest back as it was (⌘Z / ctrl+Z)";
5384
+ b.onclick = () => void undoLast();
5385
+ host.append(b);
5386
+ }
5387
+ const x = el("button", "x", "×");
5388
+ x.type = "button";
5389
+ x.setAttribute("aria-label", "Dismiss");
5390
+ x.onclick = () => {
5391
+ host.hidden = true;
5392
+ };
5393
+ host.append(x);
5394
+ host.hidden = false;
5395
+ if (hideTimer) clearTimeout(hideTimer);
5396
+ hideTimer = setTimeout(() => {
5397
+ host.hidden = true;
5398
+ }, 12e3);
5399
+ }
5400
+ function installUndo() {
5401
+ editHooks.saved = (body, changed) => {
5402
+ if (!body || body.action === "undo-edit" || !changed) return;
5403
+ lastProject = body.project ?? null;
5404
+ toast("Saved: " + describe(body) + ".", true);
5405
+ };
5406
+ }
5407
+ async function undoLast() {
5408
+ if (!EDITABLE) return;
5409
+ const expected = manifestShaOf(S.snap, lastProject);
5410
+ if (!expected) return;
5411
+ const body = { action: "undo-edit", expectedSha256: expected };
5412
+ if (lastProject) body.project = lastProject;
5413
+ try {
5414
+ S.snap = await postEdit(body);
5415
+ render();
5416
+ toast("Undone. The manifest is back as it was before that edit.", true);
5417
+ } catch (err) {
5418
+ toast(/nothing to undo/.test(err.message) ? "Nothing left to undo." : "Could not undo: " + err.message, false, "bad");
5419
+ }
5420
+ }
5421
+
5165
5422
  // src/gallery/client/src/main.ts
5166
5423
  loadPrefs();
5424
+ installUndo();
5167
5425
  $("refresh").onclick = refresh;
5168
5426
  $("select").onclick = () => setSelecting(!ui.selecting);
5169
5427
  $("keys").onclick = openShortcuts;
@@ -5234,6 +5492,11 @@
5234
5492
  openShortcuts();
5235
5493
  return;
5236
5494
  }
5495
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z" && !typing && EDITABLE && !$("dialog-host").childNodes.length) {
5496
+ e.preventDefault();
5497
+ void undoLast();
5498
+ return;
5499
+ }
5237
5500
  if (e.key === "Escape") {
5238
5501
  if ($("view-menu").open) {
5239
5502
  e.preventDefault();
package/dist/index.cjs CHANGED
@@ -15256,7 +15256,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
15256
15256
  // src/gallery/snapshot.ts
15257
15257
  var import_node_crypto6 = require("crypto");
15258
15258
  var import_node_fs27 = require("fs");
15259
- var import_promises24 = require("fs/promises");
15259
+ var import_promises25 = require("fs/promises");
15260
15260
  var import_node_path32 = __toESM(require("path"), 1);
15261
15261
 
15262
15262
  // src/workspace.ts
@@ -15393,6 +15393,7 @@ function validateWorkspace(ws, dir) {
15393
15393
  }
15394
15394
 
15395
15395
  // src/gallery/edit.ts
15396
+ var import_promises24 = require("fs/promises");
15396
15397
  var import_node_path31 = __toESM(require("path"), 1);
15397
15398
  var import_zod12 = require("zod");
15398
15399
 
@@ -16248,9 +16249,62 @@ var HandEditRequestSchema = import_zod12.z.discriminatedUnion("action", [
16248
16249
  protocol: import_zod12.z.number().int().positive()
16249
16250
  }).strict()
16250
16251
  ]);
16252
+ var UNDO_DEPTH = 20;
16253
+ var UndoRequestSchema = import_zod12.z.object({
16254
+ action: import_zod12.z.literal("undo-edit"),
16255
+ project: import_zod12.z.string().min(1).optional(),
16256
+ /** The manifest as the page last saw it; undo refuses a manifest someone else has changed since. */
16257
+ expectedSha256: import_zod12.z.string().regex(/^[0-9a-f]{64}$/)
16258
+ }).strict();
16251
16259
  function createGalleryEditHandler(opts) {
16252
16260
  const log = opts.onProgress ?? (() => {
16253
16261
  });
16262
+ const apply = createEditApplier(opts, log);
16263
+ const history = /* @__PURE__ */ new Map();
16264
+ const manifestOf = async (project) => {
16265
+ try {
16266
+ return import_node_path31.default.resolve(await opts.manifestFor(typeof project === "string" ? project : void 0));
16267
+ } catch {
16268
+ return null;
16269
+ }
16270
+ };
16271
+ return async (body) => {
16272
+ const undo = UndoRequestSchema.safeParse(body);
16273
+ if (undo.success) {
16274
+ const manifestPath2 = await manifestOf(undo.data.project);
16275
+ if (!manifestPath2) throw new ManifestEditError("no manifest to undo an edit in");
16276
+ const current = await (0, import_promises24.readFile)(manifestPath2, "utf8");
16277
+ if (sha256(current) !== undo.data.expectedSha256) throw new ManifestDriftError(manifestPath2);
16278
+ const stack = history.get(manifestPath2) ?? [];
16279
+ const last = stack[stack.length - 1];
16280
+ if (!last) throw new ManifestEditError("nothing to undo: this gallery has not changed the manifest");
16281
+ if (last.afterSha !== sha256(current)) {
16282
+ history.delete(manifestPath2);
16283
+ throw Object.assign(new Error("the manifest changed outside the gallery since its last edit; undo would discard that change"), { status: 409 });
16284
+ }
16285
+ const tmp = manifestPath2 + ".pixelkiln-undo";
16286
+ await (0, import_promises24.writeFile)(tmp, last.before);
16287
+ await (0, import_promises24.rename)(tmp, manifestPath2);
16288
+ stack.pop();
16289
+ log(` manifest edit undone in ${import_node_path31.default.relative(process.cwd(), manifestPath2) || manifestPath2}`);
16290
+ return opts.reload();
16291
+ }
16292
+ const manifestPath = await manifestOf(body?.project);
16293
+ const before = manifestPath ? await (0, import_promises24.readFile)(manifestPath, "utf8").catch(() => null) : null;
16294
+ const build = await apply(body);
16295
+ if (manifestPath && before !== null) {
16296
+ const after = await (0, import_promises24.readFile)(manifestPath, "utf8").catch(() => null);
16297
+ if (after !== null && after !== before) {
16298
+ const stack = history.get(manifestPath) ?? [];
16299
+ stack.push({ before, afterSha: sha256(after) });
16300
+ if (stack.length > UNDO_DEPTH) stack.shift();
16301
+ history.set(manifestPath, stack);
16302
+ }
16303
+ }
16304
+ return build;
16305
+ };
16306
+ }
16307
+ function createEditApplier(opts, log) {
16254
16308
  return async (body) => {
16255
16309
  const handEdit = HandEditRequestSchema.safeParse(body);
16256
16310
  if (handEdit.success) {
@@ -16425,7 +16479,7 @@ function describeHistory(media, entry, cacheDir) {
16425
16479
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
16426
16480
  async function samePixels(a, b) {
16427
16481
  try {
16428
- const [left, right] = await Promise.all([(0, import_promises24.readFile)(a), (0, import_promises24.readFile)(b)]);
16482
+ const [left, right] = await Promise.all([(0, import_promises25.readFile)(a), (0, import_promises25.readFile)(b)]);
16429
16483
  if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
16430
16484
  const x = decodePng(left);
16431
16485
  const y = decodePng(right);
@@ -16491,7 +16545,7 @@ function describeCharacter(spec, entry) {
16491
16545
  }
16492
16546
  async function fileInfo(absolutePath) {
16493
16547
  try {
16494
- const info = await (0, import_promises24.stat)(absolutePath);
16548
+ const info = await (0, import_promises25.stat)(absolutePath);
16495
16549
  return info.isFile() ? { bytes: info.size, modifiedAt: info.mtime.toISOString() } : null;
16496
16550
  } catch {
16497
16551
  return null;
@@ -16583,7 +16637,7 @@ async function buildGallerySnapshot(opts) {
16583
16637
  const media = /* @__PURE__ */ new Map();
16584
16638
  const plan = await buildPlan(specs, lock);
16585
16639
  const items = [];
16586
- const manifestText = await (0, import_promises24.readFile)(loaded.path, "utf8");
16640
+ const manifestText = await (0, import_promises25.readFile)(loaded.path, "utf8");
16587
16641
  const manifestSha256 = sha256(manifestText);
16588
16642
  const cacheDir = import_node_path32.default.resolve(import_node_path32.default.dirname(opts.lockPath), ".pixelkiln", "cache");
16589
16643
  let rawStyles = {};
@@ -16935,7 +16989,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
16935
16989
  root: loaded.root,
16936
16990
  provider: loaded.manifest.provider,
16937
16991
  account: project.account ?? null,
16938
- manifestSha256: manifestSha256 ?? sha256(await (0, import_promises24.readFile)(manifestPath, "utf8")),
16992
+ manifestSha256: manifestSha256 ?? sha256(await (0, import_promises25.readFile)(manifestPath, "utf8")),
16939
16993
  historyLimit: historyLimit(loaded.manifest),
16940
16994
  entries: Object.keys(lock.entries).length,
16941
16995
  items: projectItems.length,
@@ -17100,6 +17154,7 @@ ${css}</style>
17100
17154
  </header>
17101
17155
  <main id="root"></main>
17102
17156
  <div id="selbar" class="selbar" hidden></div>
17157
+ <div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
17103
17158
  <footer>
17104
17159
  Click a sprite for its full record; press <kbd>?</kbd> for every keyboard shortcut. This page reads the manifest, lockfile,
17105
17160
  and disk only. It never contacts a provider<span id="foot-edit"> and never writes anything</span><span id="foot-editing" hidden>.
@@ -17176,7 +17231,7 @@ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download
17176
17231
  // src/editor/install.ts
17177
17232
  var import_node_crypto7 = require("crypto");
17178
17233
  var import_node_fs29 = require("fs");
17179
- var import_promises25 = require("fs/promises");
17234
+ var import_promises26 = require("fs/promises");
17180
17235
  var import_node_os3 = __toESM(require("os"), 1);
17181
17236
  var import_node_path33 = __toESM(require("path"), 1);
17182
17237
  var EditorInstallError = class extends Error {
@@ -17207,7 +17262,7 @@ function editorBaseUrl(pin = EDITOR_PIN) {
17207
17262
  }
17208
17263
  async function sha256Of(file) {
17209
17264
  try {
17210
- return (0, import_node_crypto7.createHash)("sha256").update(await (0, import_promises25.readFile)(file)).digest("hex");
17265
+ return (0, import_node_crypto7.createHash)("sha256").update(await (0, import_promises26.readFile)(file)).digest("hex");
17211
17266
  } catch {
17212
17267
  return null;
17213
17268
  }
@@ -17221,7 +17276,7 @@ async function editorStatus(opts = {}) {
17221
17276
  for (const [name, expected] of Object.entries(pin.files)) {
17222
17277
  totalBytes += expected.bytes;
17223
17278
  const file = import_node_path33.default.join(dir, name);
17224
- const info = (0, import_node_fs29.existsSync)(file) ? await (0, import_promises25.stat)(file).catch(() => null) : null;
17279
+ const info = (0, import_node_fs29.existsSync)(file) ? await (0, import_promises26.stat)(file).catch(() => null) : null;
17225
17280
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
17226
17281
  installedBytes += expected.bytes;
17227
17282
  } else {
@@ -17250,7 +17305,7 @@ async function installEditor(opts = {}) {
17250
17305
  );
17251
17306
  }
17252
17307
  if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
17253
- await (0, import_promises25.mkdir)(dir, { recursive: true });
17308
+ await (0, import_promises26.mkdir)(dir, { recursive: true });
17254
17309
  const downloaded = [];
17255
17310
  const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
17256
17311
  let fetchedBytes = before.installedBytes;
@@ -17281,10 +17336,10 @@ async function installEditor(opts = {}) {
17281
17336
  const final = import_node_path33.default.join(dir, name);
17282
17337
  const tmp = `${final}.${process.pid}.part`;
17283
17338
  try {
17284
- await (0, import_promises25.writeFile)(tmp, bytes);
17285
- await (0, import_promises25.rename)(tmp, final);
17339
+ await (0, import_promises26.writeFile)(tmp, bytes);
17340
+ await (0, import_promises26.rename)(tmp, final);
17286
17341
  } finally {
17287
- await (0, import_promises25.rm)(tmp, { force: true });
17342
+ await (0, import_promises26.rm)(tmp, { force: true });
17288
17343
  }
17289
17344
  fetchedBytes += bytes.length;
17290
17345
  downloaded.push(name);
@@ -17328,7 +17383,7 @@ function editorContentType(name) {
17328
17383
  }
17329
17384
 
17330
17385
  // src/gallery/editor.ts
17331
- var import_promises26 = require("fs/promises");
17386
+ var import_promises27 = require("fs/promises");
17332
17387
  var import_node_path34 = __toESM(require("path"), 1);
17333
17388
  function editorRoute(release, name = "index.html") {
17334
17389
  return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
@@ -17402,7 +17457,7 @@ function createGalleryEditorHandlers(opts = {}) {
17402
17457
  if (!status.installed) return null;
17403
17458
  let bytes;
17404
17459
  try {
17405
- bytes = await (0, import_promises26.readFile)(import_node_path34.default.join(dir, name));
17460
+ bytes = await (0, import_promises27.readFile)(import_node_path34.default.join(dir, name));
17406
17461
  } catch {
17407
17462
  verified = null;
17408
17463
  return null;
@@ -17419,7 +17474,7 @@ function createGalleryEditorHandlers(opts = {}) {
17419
17474
  // src/gallery/server.ts
17420
17475
  var import_node_crypto8 = require("crypto");
17421
17476
  var import_node_http2 = require("http");
17422
- var import_promises27 = require("fs/promises");
17477
+ var import_promises28 = require("fs/promises");
17423
17478
  var MAX_EDIT_BYTES = 48 * 1024 * 1024;
17424
17479
  var EDITOR_CSP = [
17425
17480
  "default-src 'self'",
@@ -17600,7 +17655,7 @@ async function serveGallery(opts) {
17600
17655
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
17601
17656
  if (!asset) return fail(404, "no such review asset");
17602
17657
  try {
17603
- const bytes = await (0, import_promises27.readFile)(asset.path);
17658
+ const bytes = await (0, import_promises28.readFile)(asset.path);
17604
17659
  res.writeHead(200, {
17605
17660
  "Content-Type": asset.contentType,
17606
17661
  "Cache-Control": "no-store",
@@ -17683,7 +17738,7 @@ async function serveGallery(opts) {
17683
17738
  if (!asset) return fail(404, "no such gallery media");
17684
17739
  let bytes;
17685
17740
  try {
17686
- bytes = await (0, import_promises27.readFile)(asset.path);
17741
+ bytes = await (0, import_promises28.readFile)(asset.path);
17687
17742
  } catch {
17688
17743
  return fail(404, "gallery media is no longer on disk; refresh the page");
17689
17744
  }
@@ -18093,7 +18148,7 @@ function createGenerateHandlers(opts) {
18093
18148
  }
18094
18149
 
18095
18150
  // src/pipeline/salvage.ts
18096
- var import_promises28 = require("fs/promises");
18151
+ var import_promises29 = require("fs/promises");
18097
18152
  var import_node_fs30 = require("fs");
18098
18153
  var import_node_path35 = __toESM(require("path"), 1);
18099
18154
  function providerClaimId(provider, objectId) {
@@ -18105,7 +18160,7 @@ async function loadClaims(lockPaths, opts = {}) {
18105
18160
  if (!(0, import_node_fs30.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
18106
18161
  let parsed;
18107
18162
  try {
18108
- parsed = parseLock(JSON.parse(await (0, import_promises28.readFile)(p, "utf8")));
18163
+ parsed = parseLock(JSON.parse(await (0, import_promises29.readFile)(p, "utf8")));
18109
18164
  } catch {
18110
18165
  throw new Error(`Claim lockfile is malformed: ${p}`);
18111
18166
  }
@@ -18342,7 +18397,7 @@ async function applyTags(provider, decisions, existing, opts = {}) {
18342
18397
  }
18343
18398
 
18344
18399
  // src/pick/salvage-server.ts
18345
- var import_promises29 = require("fs/promises");
18400
+ var import_promises30 = require("fs/promises");
18346
18401
  var import_node_path36 = __toESM(require("path"), 1);
18347
18402
 
18348
18403
  // src/pick/salvage-sheet.ts
@@ -18567,8 +18622,8 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18567
18622
  const assetId = idFromPrompt(orphan.prompt, taken);
18568
18623
  const rel = import_node_path36.default.join("_salvaged", `${assetId}.png`);
18569
18624
  const outFile = import_node_path36.default.resolve(ctx.importDir, rel);
18570
- await (0, import_promises29.mkdir)(import_node_path36.default.dirname(outFile), { recursive: true });
18571
- await (0, import_promises29.writeFile)(outFile, buf);
18625
+ await (0, import_promises30.mkdir)(import_node_path36.default.dirname(outFile), { recursive: true });
18626
+ await (0, import_promises30.writeFile)(outFile, buf);
18572
18627
  ctx.manifest.assets[assetId] = {
18573
18628
  prompt: orphan.prompt,
18574
18629
  promptByStyle: {},
@@ -18624,9 +18679,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18624
18679
  }
18625
18680
  }
18626
18681
  await applyTags(provider, decisions, existingTags, { onProgress: log, characters: characterMembers });
18627
- const raw = JSON.parse(await (0, import_promises29.readFile)(ctx.manifestPath, "utf8"));
18682
+ const raw = JSON.parse(await (0, import_promises30.readFile)(ctx.manifestPath, "utf8"));
18628
18683
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
18629
- await (0, import_promises29.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
18684
+ await (0, import_promises30.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
18630
18685
  await saveLock(ctx.lockPath, ctx.lock);
18631
18686
  if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
18632
18687
  return result;