pixelkiln 0.26.0 → 0.28.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
@@ -9393,7 +9393,7 @@ function validateWorkspace(ws, dir) {
9393
9393
 
9394
9394
  // src/gallery/edit.ts
9395
9395
  var import_node_path24 = __toESM(require("path"), 1);
9396
- var import_zod8 = require("zod");
9396
+ var import_zod9 = require("zod");
9397
9397
 
9398
9398
  // src/manifest-edit.ts
9399
9399
  var import_promises18 = require("fs/promises");
@@ -9649,6 +9649,7 @@ var import_node_child_process3 = require("child_process");
9649
9649
  var import_node_fs20 = require("fs");
9650
9650
  var import_promises19 = require("fs/promises");
9651
9651
  var import_node_path23 = __toESM(require("path"), 1);
9652
+ var import_zod8 = require("zod");
9652
9653
  var HAND_EDIT_DIR = "edits";
9653
9654
  function handEditPath(loaded, spec) {
9654
9655
  const style = loaded.manifest.styles[spec.styleId];
@@ -9718,6 +9719,89 @@ async function detachHandEdit(loaded, spec, opts = {}) {
9718
9719
  });
9719
9720
  return { manifestSha256: result.sha256, changed: result.changed };
9720
9721
  }
9722
+ var HandEditCompanionSchema = import_zod8.z.object({
9723
+ version: import_zod8.z.literal(1),
9724
+ /** sha256 of the generated art the edit started from; null when it started from untracked art. */
9725
+ basedOn: import_zod8.z.string().regex(/^[0-9a-f]{64}$/).nullable(),
9726
+ /** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
9727
+ sha256: import_zod8.z.string().regex(/^[0-9a-f]{64}$/),
9728
+ /** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
9729
+ editor: import_zod8.z.string().min(1),
9730
+ protocol: import_zod8.z.number().int().positive(),
9731
+ savedAt: import_zod8.z.string().datetime(),
9732
+ /** Basename of the layered project file beside the edit, when one was kept. */
9733
+ project: import_zod8.z.string().min(1).nullable()
9734
+ }).strict();
9735
+ function handEditCompanionPath(editPath) {
9736
+ return editPath.replace(/\.png$/i, "") + ".edit.json";
9737
+ }
9738
+ function handEditProjectPath(editPath) {
9739
+ return editPath.replace(/\.png$/i, "") + ".pxo";
9740
+ }
9741
+ async function readHandEditCompanion(editPath) {
9742
+ let text;
9743
+ try {
9744
+ text = await (0, import_promises19.readFile)(handEditCompanionPath(editPath), "utf8");
9745
+ } catch {
9746
+ return null;
9747
+ }
9748
+ try {
9749
+ return HandEditCompanionSchema.parse(JSON.parse(text));
9750
+ } catch {
9751
+ return null;
9752
+ }
9753
+ }
9754
+ var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
9755
+ async function saveHandEdit(loaded, lock, spec, input) {
9756
+ if (input.png.length > MAX_HAND_EDIT_BYTES) {
9757
+ throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9758
+ }
9759
+ if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
9760
+ throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9761
+ }
9762
+ let decoded;
9763
+ try {
9764
+ validateMedia(input.png, MediaType.PNG);
9765
+ decoded = decodePng(input.png);
9766
+ } catch (error) {
9767
+ throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
9768
+ }
9769
+ const base = handEditBase(spec, lock);
9770
+ const expected = base ? decodePng(await (0, import_promises19.readFile)(base)) : { width: spec.width, height: spec.height };
9771
+ if (decoded.width !== expected.width || decoded.height !== expected.height) {
9772
+ throw new ManifestEditError(
9773
+ `the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
9774
+ );
9775
+ }
9776
+ const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
9777
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9778
+ const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
9779
+ const digest2 = sha256(input.png);
9780
+ const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
9781
+ const companion = {
9782
+ version: 1,
9783
+ basedOn,
9784
+ sha256: digest2,
9785
+ editor: input.editor,
9786
+ protocol: input.protocol,
9787
+ savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
9788
+ project: projectPath ? import_node_path23.default.basename(projectPath) : null
9789
+ };
9790
+ await replaceFile(started.editPath, input.png);
9791
+ if (projectPath && input.project) await replaceFile(projectPath, input.project);
9792
+ const companionPath = handEditCompanionPath(started.editPath);
9793
+ await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
9794
+ return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
9795
+ }
9796
+ async function replaceFile(file, bytes) {
9797
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9798
+ try {
9799
+ await (0, import_promises19.writeFile)(tmp, bytes);
9800
+ await (0, import_promises19.rename)(tmp, file);
9801
+ } finally {
9802
+ await (0, import_promises19.rm)(tmp, { force: true });
9803
+ }
9804
+ }
9721
9805
  function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
9722
9806
  const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
9723
9807
  const [program, ...args] = command;
@@ -9729,22 +9813,37 @@ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
9729
9813
  }
9730
9814
 
9731
9815
  // src/gallery/edit.ts
9732
- var HandEditRequestSchema = import_zod8.z.discriminatedUnion("action", [
9733
- import_zod8.z.object({
9734
- action: import_zod8.z.literal("start-edit"),
9735
- project: import_zod8.z.string().min(1).optional(),
9736
- assetId: import_zod8.z.string().min(1),
9737
- styleId: import_zod8.z.string().min(1),
9738
- expectedSha256: import_zod8.z.string().regex(/^[0-9a-f]{64}$/),
9816
+ var Base64 = import_zod9.z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
9817
+ var HandEditRequestSchema = import_zod9.z.discriminatedUnion("action", [
9818
+ import_zod9.z.object({
9819
+ action: import_zod9.z.literal("start-edit"),
9820
+ project: import_zod9.z.string().min(1).optional(),
9821
+ assetId: import_zod9.z.string().min(1),
9822
+ styleId: import_zod9.z.string().min(1),
9823
+ expectedSha256: import_zod9.z.string().regex(/^[0-9a-f]{64}$/),
9739
9824
  /** Also launch the author's editor on the file. */
9740
- open: import_zod8.z.boolean().optional()
9825
+ open: import_zod9.z.boolean().optional()
9741
9826
  }).strict(),
9742
- import_zod8.z.object({
9743
- action: import_zod8.z.literal("detach-edit"),
9744
- project: import_zod8.z.string().min(1).optional(),
9745
- assetId: import_zod8.z.string().min(1),
9746
- styleId: import_zod8.z.string().min(1),
9747
- expectedSha256: import_zod8.z.string().regex(/^[0-9a-f]{64}$/)
9827
+ import_zod9.z.object({
9828
+ action: import_zod9.z.literal("detach-edit"),
9829
+ project: import_zod9.z.string().min(1).optional(),
9830
+ assetId: import_zod9.z.string().min(1),
9831
+ styleId: import_zod9.z.string().min(1),
9832
+ expectedSha256: import_zod9.z.string().regex(/^[0-9a-f]{64}$/)
9833
+ }).strict(),
9834
+ /** From the in-browser editor: the page holds the bytes, the server validates and writes. */
9835
+ import_zod9.z.object({
9836
+ action: import_zod9.z.literal("save-edit"),
9837
+ project: import_zod9.z.string().min(1).optional(),
9838
+ assetId: import_zod9.z.string().min(1),
9839
+ styleId: import_zod9.z.string().min(1),
9840
+ expectedSha256: import_zod9.z.string().regex(/^[0-9a-f]{64}$/),
9841
+ png: Base64.min(1),
9842
+ /** The editor's layered project file, kept beside the edit. */
9843
+ pxo: Base64.optional(),
9844
+ /** Editor identity from the bridge's ready message. */
9845
+ editor: import_zod9.z.string().min(1).max(200),
9846
+ protocol: import_zod9.z.number().int().positive()
9748
9847
  }).strict()
9749
9848
  ]);
9750
9849
  function createGalleryEditHandler(opts) {
@@ -9765,6 +9864,15 @@ function createGalleryEditHandler(opts) {
9765
9864
  const command = (opts.openEditor ?? openInEditor)(started.editPath);
9766
9865
  log(` opened with: ${command}`);
9767
9866
  }
9867
+ } else if (request.action === "save-edit") {
9868
+ const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
9869
+ png: Buffer.from(request.png, "base64"),
9870
+ ...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
9871
+ editor: request.editor,
9872
+ protocol: request.protocol,
9873
+ expectedSha256: request.expectedSha256
9874
+ });
9875
+ log(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
9768
9876
  } else {
9769
9877
  const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
9770
9878
  if (result2.changed) log(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
@@ -9795,6 +9903,7 @@ function createGalleryEditHandler(opts) {
9795
9903
  }
9796
9904
 
9797
9905
  // src/gallery/snapshot.ts
9906
+ var PROJECT_FILE_TYPE = "application/octet-stream";
9798
9907
  function galleryMediaId(absolutePath) {
9799
9908
  return (0, import_node_crypto6.createHash)("sha256").update(import_node_path25.default.resolve(absolutePath)).digest("hex").slice(0, 24);
9800
9909
  }
@@ -9929,6 +10038,7 @@ async function buildGallerySnapshot(opts) {
9929
10038
  let outputs;
9930
10039
  let edit = null;
9931
10040
  let editStatus = null;
10041
+ let editMeta = null;
9932
10042
  if (entry && entry.outputs.length) {
9933
10043
  outputs = await Promise.all(
9934
10044
  entry.outputs.map(
@@ -9940,7 +10050,27 @@ async function buildGallerySnapshot(opts) {
9940
10050
  const editSha = (0, import_node_fs21.existsSync)(editPath) ? await sha256File(editPath) : null;
9941
10051
  edit = await describeOutput(media, root, editPath, { sha256: editSha });
9942
10052
  const generated = outputs[0];
9943
- editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists ? "regenerated-since" : "edited";
10053
+ const companion = await readHandEditCompanion(editPath);
10054
+ if (companion) {
10055
+ const projectPath = handEditProjectPath(editPath);
10056
+ const project = companion.project && (0, import_node_fs21.existsSync)(projectPath) ? await fileInfo(projectPath) : null;
10057
+ let projectUrl = null;
10058
+ if (project) {
10059
+ const id = galleryMediaId(projectPath);
10060
+ media.set(id, { path: projectPath, contentType: PROJECT_FILE_TYPE });
10061
+ projectUrl = `${galleryMediaRoute(id)}?v=${project.bytes}-${Date.parse(project.modifiedAt).toString(36)}`;
10062
+ }
10063
+ editMeta = {
10064
+ editor: companion.editor,
10065
+ savedAt: companion.savedAt,
10066
+ basedOn: companion.basedOn,
10067
+ changedSince: editSha !== null && editSha !== companion.sha256,
10068
+ project: project ? portableOutputPath(projectPath, root) : null,
10069
+ projectUrl
10070
+ };
10071
+ }
10072
+ const regenerated = companion ? generated?.sha256 !== void 0 && generated.sha256 !== null && companion.basedOn !== generated.sha256 : Boolean(generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists);
10073
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
9944
10074
  }
9945
10075
  } else if (spec.source) {
9946
10076
  outputs = [await describeOutput(media, root, import_node_path25.default.resolve(root, spec.source))];
@@ -9990,6 +10120,7 @@ async function buildGallerySnapshot(opts) {
9990
10120
  source: spec.source ?? null,
9991
10121
  edit,
9992
10122
  editStatus,
10123
+ editMeta,
9993
10124
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9994
10125
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9995
10126
  tags: spec.tags,
@@ -10046,6 +10177,7 @@ async function buildGallerySnapshot(opts) {
10046
10177
  source: null,
10047
10178
  edit: null,
10048
10179
  editStatus: null,
10180
+ editMeta: null,
10049
10181
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
10050
10182
  refreshable: false,
10051
10183
  tags: [],
@@ -10247,6 +10379,7 @@ function renderGallery(snapshot, opts = {}) {
10247
10379
  const session = JSON.stringify(opts.session ?? null);
10248
10380
  const editable = JSON.stringify(Boolean(opts.editable && opts.session));
10249
10381
  const generation = JSON.stringify(Boolean(opts.generation && opts.session));
10382
+ const editor = JSON.stringify(Boolean(opts.editor && opts.session));
10250
10383
  const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
10251
10384
  return `<!doctype html>
10252
10385
  <html lang="en">
@@ -10452,6 +10585,15 @@ function renderGallery(snapshot, opts = {}) {
10452
10585
  .job .acts button { padding:3px 9px; font-size:12px; }
10453
10586
  .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
10454
10587
  font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
10588
+ #tools { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; }
10589
+ #tools:empty { display:none; }
10590
+ .tool { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
10591
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
10592
+ .tool .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
10593
+ .tool .last { color:var(--dim); min-width:0; }
10594
+ .tool .acts { display:flex; gap:6px; }
10595
+ .tool .acts button { padding:3px 9px; font-size:12px; }
10596
+ .tool progress { grid-column:1 / -1; width:100%; height:6px; accent-color:var(--accent); }
10455
10597
  .budget { color:var(--dim); }
10456
10598
  .budget b { color:var(--text); }
10457
10599
  .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
@@ -10472,6 +10614,16 @@ function renderGallery(snapshot, opts = {}) {
10472
10614
  border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
10473
10615
  .sheet.review-host { width:min(1180px, 94vw); }
10474
10616
  .sheet.compare-host { width:min(1500px, 96vw); }
10617
+ .sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
10618
+ .sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
10619
+ .sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
10620
+ .sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
10621
+ .sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
10622
+ .sheet.editor-host .stage { position:relative; min-height:0; }
10623
+ .sheet.editor-host .stage iframe { position:absolute; inset:0; }
10624
+ .sheet.editor-host .loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--bg);
10625
+ color:var(--dim); text-align:center; padding:24px; }
10626
+ .sheet.editor-host .loading b { display:block; color:var(--text); margin-bottom:6px; }
10475
10627
  .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
10476
10628
  background:var(--panel); font-size:13px; flex-wrap:wrap; }
10477
10629
  .sheet .rbar span { color:var(--dim); }
@@ -10510,7 +10662,7 @@ function renderGallery(snapshot, opts = {}) {
10510
10662
  .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
10511
10663
  .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
10512
10664
  .tray > button { padding:5px 11px; font-size:12.5px; }
10513
- @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10665
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10514
10666
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10515
10667
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10516
10668
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
@@ -10558,6 +10710,7 @@ function renderGallery(snapshot, opts = {}) {
10558
10710
  <div class="totals" id="totals"></div>
10559
10711
  <div class="chips" id="chips"></div>
10560
10712
  <div id="jobs"></div>
10713
+ <div id="tools"></div>
10561
10714
  </header>
10562
10715
  <main id="root"></main>
10563
10716
  <footer>
@@ -10576,6 +10729,7 @@ const INITIAL = ${data};
10576
10729
  const SESSION = ${session};
10577
10730
  const EDITABLE = ${editable};
10578
10731
  const GENERATION = ${generation};
10732
+ const EDITOR = ${editor};
10579
10733
  let snap = INITIAL;
10580
10734
  const STATE_TONE = {
10581
10735
  ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
@@ -10743,6 +10897,92 @@ function renderJobs() {
10743
10897
  host.append(row);
10744
10898
  }
10745
10899
  }
10900
+ // ---- the in-browser editor (only when the server serves one) -------------
10901
+ // The build is a 46 MB web export fetched once into a user-level cache and
10902
+ // verified against hashes this PixelKiln release carries; the page only
10903
+ // reports and asks, the server downloads. Nothing is fetched without a click.
10904
+
10905
+ let ED = null;
10906
+ let edTimer = null;
10907
+ const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
10908
+ async function pollEditor() {
10909
+ if (!EDITOR) return;
10910
+ try {
10911
+ const res = await fetch('/api/editor', { cache: 'no-store' });
10912
+ if (!res.ok) throw new Error(await res.text());
10913
+ ED = await res.json();
10914
+ } catch (err) {
10915
+ ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
10916
+ }
10917
+ renderTools();
10918
+ clearTimeout(edTimer);
10919
+ if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
10920
+ else if (ui.open) renderDrawer();
10921
+ }
10922
+ async function installEditor() {
10923
+ try {
10924
+ const res = await fetch('/api/editor/install', {
10925
+ method: 'POST',
10926
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10927
+ body: '{}',
10928
+ });
10929
+ if (!res.ok) throw new Error(await res.text());
10930
+ ED = await res.json();
10931
+ } catch (err) {
10932
+ ED = Object.assign({}, ED, { error: err.message });
10933
+ }
10934
+ renderTools();
10935
+ clearTimeout(edTimer);
10936
+ edTimer = setTimeout(pollEditor, 500);
10937
+ }
10938
+ function renderTools() {
10939
+ const host = $('tools');
10940
+ host.textContent = '';
10941
+ if (!EDITOR || !ED) return;
10942
+ const row = el('div', 'tool');
10943
+ const ph = el('span', 'ph');
10944
+ const last = el('span', 'last');
10945
+ const acts = el('div', 'acts');
10946
+ const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
10947
+ if (ED.installing) {
10948
+ const p = ED.installing;
10949
+ ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
10950
+ last.textContent = name + ': fetching ' + (p.file || 'the build') + ' \u2014 ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
10951
+ row.append(ph, last, acts);
10952
+ const bar = el('progress');
10953
+ if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
10954
+ row.append(bar);
10955
+ } else if (ED.installed) {
10956
+ ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
10957
+ last.textContent = name + ' is installed and verified; a record\u2019s Hand edit section opens sprites in it.';
10958
+ last.title = ED.dir || '';
10959
+ const b = el('button', null, 'Open editor');
10960
+ b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
10961
+ acts.append(b);
10962
+ row.append(ph, last, acts);
10963
+ } else if (ED.error) {
10964
+ ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
10965
+ last.textContent = name + ': ' + ED.error;
10966
+ const b = el('button', 'primary', 'Retry install');
10967
+ b.type = 'button'; b.onclick = installEditor;
10968
+ acts.append(b);
10969
+ row.append(ph, last, acts);
10970
+ } else if (!ED.release) {
10971
+ ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
10972
+ last.textContent = name + ': this PixelKiln version pins no published build.';
10973
+ row.append(ph, last, acts);
10974
+ } else {
10975
+ ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
10976
+ last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
10977
+ ED.release + ', verified against the hashes this version carries, and kept outside the project.';
10978
+ const b = el('button', 'primary', 'Install editor');
10979
+ b.type = 'button'; b.onclick = installEditor;
10980
+ acts.append(b);
10981
+ row.append(ph, last, acts);
10982
+ }
10983
+ host.append(row);
10984
+ }
10985
+
10746
10986
  function budgetLine() {
10747
10987
  const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
10748
10988
  const parts = [];
@@ -10849,8 +11089,183 @@ function openReview(jobId) {
10849
11089
  host.append(scrim, panel);
10850
11090
  }
10851
11091
  window.addEventListener('message', (e) => {
10852
- if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
10853
- setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
11092
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
11093
+ if (e.data.type === 'pixelkiln:review-applied') {
11094
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
11095
+ return;
11096
+ }
11097
+ if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
11098
+ });
11099
+
11100
+ // ---- the in-browser editor sheet -------------------------------------------
11101
+ // The page is the protocol host: it hands the editor the PNG and palette,
11102
+ // asks it for the image back, and does the authenticated write itself. The
11103
+ // iframe never sees the session token, and the server validates, sizes, and
11104
+ // declares the file the same way pixelkiln edit does.
11105
+
11106
+ let SHEET = null;
11107
+ const editorSource = (item) => item.edit && item.edit.exists ? item.edit
11108
+ : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
11109
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
11110
+ !(item.outputs.length > 1);
11111
+ const toBase64 = (buf) => new Promise((resolve, reject) => {
11112
+ const r = new FileReader();
11113
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
11114
+ r.onerror = () => reject(r.error);
11115
+ r.readAsDataURL(new Blob([buf]));
11116
+ });
11117
+ function sheetStatus(tone, text) {
11118
+ if (!SHEET) return;
11119
+ SHEET.status.textContent = '';
11120
+ SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
11121
+ }
11122
+ function sheetButtons() {
11123
+ if (!SHEET) return;
11124
+ const busy = !SHEET.opened || SHEET.pending !== null;
11125
+ SHEET.save.disabled = busy || !SHEET.dirty;
11126
+ SHEET.saveClose.disabled = busy;
11127
+ }
11128
+ function openEditorSheet(item) {
11129
+ if (SHEET) return;
11130
+ const src = editorSource(item);
11131
+ const host = $('dialog-host');
11132
+ host.textContent = '';
11133
+ const scrim = el('div', 'sheet-scrim');
11134
+ scrim.onclick = () => closeEditorSheet();
11135
+ const panel = el('div', 'sheet editor-host');
11136
+ const bar = el('div', 'rbar');
11137
+ const close = el('button', null, 'Close'); close.type = 'button';
11138
+ close.onclick = () => closeEditorSheet();
11139
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
11140
+ const status = el('span', 'st');
11141
+ const acts = el('div', 'acts');
11142
+ const msg = el('span', 'msg');
11143
+ const save = el('button', 'primary', 'Save to project'); save.type = 'button';
11144
+ save.onclick = () => requestEditorSave(false);
11145
+ const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
11146
+ saveClose.onclick = () => requestEditorSave(true);
11147
+ acts.append(msg, save, saveClose);
11148
+ bar.append(close, title, status, acts);
11149
+ const stage = el('div', 'stage');
11150
+ const frame = el('iframe');
11151
+ frame.title = 'Pixelorama';
11152
+ frame.src = ED.url;
11153
+ const loading = el('div', 'loading');
11154
+ const loadText = el('div');
11155
+ loadText.append(el('b', null, 'Loading the editor'), document.createTextNode('The first open compiles a 40 MB build; later opens come from the browser cache.'));
11156
+ loading.append(loadText);
11157
+ stage.append(frame, loading);
11158
+ panel.append(bar, stage);
11159
+ host.append(scrim, panel);
11160
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false };
11161
+ sheetStatus('cool', 'loading');
11162
+ sheetButtons();
11163
+ close.focus({ preventScroll: true });
11164
+ }
11165
+ // The edit file is what the editor gets; when a browser save kept the layered
11166
+ // project beside it, that goes along too and the editor restores the layers,
11167
+ // falling back to the flattened PNG if the file cannot be read.
11168
+ async function sendOpen() {
11169
+ const { item, source } = SHEET;
11170
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
11171
+ const projectUrl = source === item.edit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
11172
+ let png, pxo = null;
11173
+ try {
11174
+ const res = await fetch(source.url, { cache: 'no-store' });
11175
+ if (!res.ok) throw new Error(await res.text());
11176
+ png = await res.arrayBuffer();
11177
+ if (projectUrl) {
11178
+ const p = await fetch(projectUrl, { cache: 'no-store' });
11179
+ if (p.ok) pxo = await p.arrayBuffer();
11180
+ }
11181
+ } catch (err) {
11182
+ if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
11183
+ return;
11184
+ }
11185
+ if (!SHEET) return;
11186
+ const request = 'open-' + (++SHEET.requests);
11187
+ SHEET.sentProject = !!pxo;
11188
+ const message = {
11189
+ type: 'pixelkiln:open', request,
11190
+ asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
11191
+ png, palette: style ? style.palette : [],
11192
+ };
11193
+ const transfer = [png];
11194
+ if (pxo) { message.pxo = pxo; transfer.push(pxo); }
11195
+ SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
11196
+ sheetStatus('cool', 'opening');
11197
+ }
11198
+ function requestEditorSave(close) {
11199
+ if (!SHEET || !SHEET.opened || SHEET.pending) return;
11200
+ const request = 'save-' + (++SHEET.requests);
11201
+ SHEET.pending = { request, close };
11202
+ SHEET.msg.textContent = '';
11203
+ sheetStatus('cool', 'saving');
11204
+ sheetButtons();
11205
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
11206
+ }
11207
+ async function onEditorMessage(m) {
11208
+ switch (m.type) {
11209
+ case 'pixelkiln:ready':
11210
+ SHEET.ready = m;
11211
+ if (m.version !== ED.protocol) {
11212
+ SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
11213
+ sheetStatus('bad', 'mismatch');
11214
+ return;
11215
+ }
11216
+ sendOpen();
11217
+ break;
11218
+ case 'pixelkiln:opened':
11219
+ SHEET.opened = true;
11220
+ SHEET.loading.remove();
11221
+ if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file with its ' + (m.layers === 1 ? 'layer' : m.layers + ' layers') + ' restored');
11222
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file; its layer file could not be opened');
11223
+ else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
11224
+ sheetButtons();
11225
+ break;
11226
+ case 'pixelkiln:dirty':
11227
+ SHEET.dirty = !!m.dirty;
11228
+ if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
11229
+ sheetButtons();
11230
+ break;
11231
+ case 'pixelkiln:save': {
11232
+ const pending = SHEET.pending;
11233
+ if (!pending || pending.request !== m.request) return;
11234
+ try {
11235
+ const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
11236
+ if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
11237
+ await postHandEdit(SHEET.item, 'save-edit', body);
11238
+ const saved = snap.items.find((i) => i.id === SHEET.item.id);
11239
+ if (saved) SHEET.item = saved;
11240
+ SHEET.dirty = false;
11241
+ SHEET.pending = null;
11242
+ ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edit ? saved.edit.path : 'the edit') + '. mount and pack place it in place of the generated art.' };
11243
+ render();
11244
+ if (pending.close) { closeEditorSheet(true); return; }
11245
+ sheetStatus('ok', 'saved');
11246
+ } catch (err) {
11247
+ SHEET.pending = null;
11248
+ SHEET.msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh, then save again.' : '');
11249
+ sheetStatus('bad', 'not saved');
11250
+ }
11251
+ sheetButtons();
11252
+ break;
11253
+ }
11254
+ case 'pixelkiln:error':
11255
+ SHEET.msg.textContent = m.message || 'The editor reported an error.';
11256
+ if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
11257
+ break;
11258
+ }
11259
+ }
11260
+ function closeEditorSheet(force) {
11261
+ if (!SHEET) return;
11262
+ if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
11263
+ SHEET = null;
11264
+ $('dialog-host').textContent = '';
11265
+ refresh();
11266
+ }
11267
+ window.addEventListener('beforeunload', (e) => {
11268
+ if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
10854
11269
  });
10855
11270
 
10856
11271
  function visibleItems() {
@@ -11426,6 +11841,11 @@ function handEditSection(item) {
11426
11841
  row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11427
11842
  if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11428
11843
  row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11844
+ if (item.editMeta) {
11845
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
11846
+ if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
11847
+ if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
11848
+ }
11429
11849
  s.append(dl);
11430
11850
  } else {
11431
11851
  s.append(el('div', 'state-dim', 'Touch it up in your own editor. The generated file stays untouched; the edit is a sibling file the manifest points at, and mount and pack place that instead.'));
@@ -11434,7 +11854,7 @@ function handEditSection(item) {
11434
11854
  const acts = el('div', 'hand-actions');
11435
11855
  const msg = el('span', 'msg');
11436
11856
  const go = async (label, action, extra, done) => {
11437
- const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
11857
+ const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
11438
11858
  b.onclick = async () => {
11439
11859
  b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
11440
11860
  try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
@@ -11442,8 +11862,20 @@ function handEditSection(item) {
11442
11862
  };
11443
11863
  acts.append(b);
11444
11864
  };
11865
+ if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
11866
+ if (ED.installed) {
11867
+ const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
11868
+ b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
11869
+ b.onclick = () => openEditorSheet(item);
11870
+ acts.append(b);
11871
+ } else if (ED.release && !ED.installing) {
11872
+ const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
11873
+ b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
11874
+ acts.append(b);
11875
+ }
11876
+ }
11445
11877
  if (item.edit) {
11446
- go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11878
+ go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11447
11879
  go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11448
11880
  } else if (canStart) {
11449
11881
  go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file and opened it in your editor. Save there, then Refresh to see it here.');
@@ -12170,6 +12602,7 @@ document.addEventListener('keydown', (e) => {
12170
12602
  if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
12171
12603
  if (e.key === 'Escape') {
12172
12604
  if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
12605
+ if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
12173
12606
  if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
12174
12607
  if (ui.open) { e.preventDefault(); closeItem(); }
12175
12608
  return;
@@ -12191,6 +12624,7 @@ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
12191
12624
  render();
12192
12625
  if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
12193
12626
  if (GENERATION) pollJobs();
12627
+ if (EDITOR) pollEditor();
12194
12628
  if (ui.compare.length >= 2 && !ui.open) openCompare();
12195
12629
  </script>
12196
12630
  </body>
@@ -12203,7 +12637,16 @@ var import_node_child_process4 = require("child_process");
12203
12637
  var import_node_crypto7 = require("crypto");
12204
12638
  var import_node_http2 = require("http");
12205
12639
  var import_promises21 = require("fs/promises");
12206
- var MAX_EDIT_BYTES = 64 * 1024;
12640
+ var MAX_EDIT_BYTES = 48 * 1024 * 1024;
12641
+ var EDITOR_CSP = [
12642
+ "default-src 'self'",
12643
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
12644
+ "style-src 'self' 'unsafe-inline'",
12645
+ "img-src 'self' data: blob:",
12646
+ "worker-src 'self' blob:",
12647
+ "connect-src 'self'",
12648
+ "frame-ancestors 'self'"
12649
+ ].join("; ");
12207
12650
  async function readJsonBody(req) {
12208
12651
  const chunks = [];
12209
12652
  let bytes = 0;
@@ -12221,7 +12664,7 @@ async function readJsonBody(req) {
12221
12664
  async function serveGallery(opts) {
12222
12665
  const log = opts.onProgress ?? (() => {
12223
12666
  });
12224
- const session = opts.edit || opts.generate ? (0, import_node_crypto7.randomBytes)(16).toString("hex") : null;
12667
+ const session = opts.edit || opts.generate || opts.editor ? (0, import_node_crypto7.randomBytes)(16).toString("hex") : null;
12225
12668
  let media = /* @__PURE__ */ new Map();
12226
12669
  let loading = null;
12227
12670
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -12296,6 +12739,17 @@ async function serveGallery(opts) {
12296
12739
  }
12297
12740
  return;
12298
12741
  }
12742
+ if (req.method === "POST" && url2.pathname === "/api/editor/install") {
12743
+ if (!opts.editor || !session) return fail(405, "the in-browser editor is off for this gallery");
12744
+ try {
12745
+ const body = await guardedBody("editor installs");
12746
+ if (body === void 0) return;
12747
+ json(202, await opts.editor.install());
12748
+ } catch (err) {
12749
+ reportError("editor install", err);
12750
+ }
12751
+ return;
12752
+ }
12299
12753
  const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
12300
12754
  if (reviewMatch && opts.generate) {
12301
12755
  const [, jobId, rest = ""] = reviewMatch;
@@ -12359,6 +12813,32 @@ async function serveGallery(opts) {
12359
12813
  if (!opts.generate) return fail(404, "generation is off");
12360
12814
  return json(200, opts.generate.status());
12361
12815
  }
12816
+ if (url2.pathname === "/api/editor") {
12817
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12818
+ try {
12819
+ return json(200, await opts.editor.status());
12820
+ } catch (err) {
12821
+ return reportError("editor status", err);
12822
+ }
12823
+ }
12824
+ const editorMatch = /^\/editor\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(url2.pathname);
12825
+ if (editorMatch) {
12826
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12827
+ const file = await opts.editor.file(editorMatch[1], editorMatch[2]);
12828
+ if (!file) return fail(404, "that editor file is not installed; check /api/editor");
12829
+ res.writeHead(200, {
12830
+ "Content-Type": file.contentType,
12831
+ "Content-Length": file.bytes.length,
12832
+ // The release tag is in the path, so these bytes never change under
12833
+ // this URL; the 40 MB wasm should come from the browser cache.
12834
+ "Cache-Control": "public, max-age=31536000, immutable",
12835
+ "X-Content-Type-Options": "nosniff",
12836
+ "Cross-Origin-Resource-Policy": "same-origin",
12837
+ ...editorMatch[2] === "index.html" ? { "Content-Security-Policy": EDITOR_CSP } : {}
12838
+ });
12839
+ res.end(req.method === "HEAD" ? void 0 : file.bytes);
12840
+ return;
12841
+ }
12362
12842
  try {
12363
12843
  if (url2.pathname === "/") {
12364
12844
  const { snapshot } = await load();
@@ -12370,7 +12850,8 @@ async function serveGallery(opts) {
12370
12850
  res.end(renderGallery(snapshot, {
12371
12851
  ...session ? { session } : {},
12372
12852
  editable: Boolean(opts.edit),
12373
- generation: Boolean(opts.generate)
12853
+ generation: Boolean(opts.generate),
12854
+ editor: Boolean(opts.editor)
12374
12855
  }));
12375
12856
  return;
12376
12857
  }
@@ -12436,16 +12917,16 @@ async function serveGallery(opts) {
12436
12917
 
12437
12918
  // src/gallery/generate.ts
12438
12919
  var import_node_crypto8 = require("crypto");
12439
- var import_zod9 = require("zod");
12440
- var GenerateRequestSchema = import_zod9.z.object({
12441
- project: import_zod9.z.string().min(1).optional(),
12442
- keys: import_zod9.z.array(import_zod9.z.string().min(1)).min(1).max(500),
12920
+ var import_zod10 = require("zod");
12921
+ var GenerateRequestSchema = import_zod10.z.object({
12922
+ project: import_zod10.z.string().min(1).optional(),
12923
+ keys: import_zod10.z.array(import_zod10.z.string().min(1)).min(1).max(500),
12443
12924
  /** Regenerate work that is up to date, as `gen --force` does. */
12444
- force: import_zod9.z.boolean().optional(),
12925
+ force: import_zod10.z.boolean().optional(),
12445
12926
  /** Advance in-flight, review, or selected work without submitting anything. */
12446
- resume: import_zod9.z.boolean().optional(),
12927
+ resume: import_zod10.z.boolean().optional(),
12447
12928
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
12448
- refresh: import_zod9.z.boolean().optional()
12929
+ refresh: import_zod10.z.boolean().optional()
12449
12930
  }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
12450
12931
  var GenerateRequestError = class extends Error {
12451
12932
  constructor(message6, status = 400) {