pixelkiln 0.26.0 → 0.27.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)`);
@@ -9929,6 +10037,7 @@ async function buildGallerySnapshot(opts) {
9929
10037
  let outputs;
9930
10038
  let edit = null;
9931
10039
  let editStatus = null;
10040
+ let editMeta = null;
9932
10041
  if (entry && entry.outputs.length) {
9933
10042
  outputs = await Promise.all(
9934
10043
  entry.outputs.map(
@@ -9940,7 +10049,19 @@ async function buildGallerySnapshot(opts) {
9940
10049
  const editSha = (0, import_node_fs21.existsSync)(editPath) ? await sha256File(editPath) : null;
9941
10050
  edit = await describeOutput(media, root, editPath, { sha256: editSha });
9942
10051
  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";
10052
+ const companion = await readHandEditCompanion(editPath);
10053
+ if (companion) {
10054
+ const projectPath = handEditProjectPath(editPath);
10055
+ editMeta = {
10056
+ editor: companion.editor,
10057
+ savedAt: companion.savedAt,
10058
+ basedOn: companion.basedOn,
10059
+ changedSince: editSha !== null && editSha !== companion.sha256,
10060
+ project: companion.project && (0, import_node_fs21.existsSync)(projectPath) ? portableOutputPath(projectPath, root) : null
10061
+ };
10062
+ }
10063
+ 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);
10064
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
9944
10065
  }
9945
10066
  } else if (spec.source) {
9946
10067
  outputs = [await describeOutput(media, root, import_node_path25.default.resolve(root, spec.source))];
@@ -9990,6 +10111,7 @@ async function buildGallerySnapshot(opts) {
9990
10111
  source: spec.source ?? null,
9991
10112
  edit,
9992
10113
  editStatus,
10114
+ editMeta,
9993
10115
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9994
10116
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9995
10117
  tags: spec.tags,
@@ -10046,6 +10168,7 @@ async function buildGallerySnapshot(opts) {
10046
10168
  source: null,
10047
10169
  edit: null,
10048
10170
  editStatus: null,
10171
+ editMeta: null,
10049
10172
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
10050
10173
  refreshable: false,
10051
10174
  tags: [],
@@ -10247,6 +10370,7 @@ function renderGallery(snapshot, opts = {}) {
10247
10370
  const session = JSON.stringify(opts.session ?? null);
10248
10371
  const editable = JSON.stringify(Boolean(opts.editable && opts.session));
10249
10372
  const generation = JSON.stringify(Boolean(opts.generation && opts.session));
10373
+ const editor = JSON.stringify(Boolean(opts.editor && opts.session));
10250
10374
  const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
10251
10375
  return `<!doctype html>
10252
10376
  <html lang="en">
@@ -10452,6 +10576,15 @@ function renderGallery(snapshot, opts = {}) {
10452
10576
  .job .acts button { padding:3px 9px; font-size:12px; }
10453
10577
  .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
10454
10578
  font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
10579
+ #tools { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; }
10580
+ #tools:empty { display:none; }
10581
+ .tool { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
10582
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
10583
+ .tool .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
10584
+ .tool .last { color:var(--dim); min-width:0; }
10585
+ .tool .acts { display:flex; gap:6px; }
10586
+ .tool .acts button { padding:3px 9px; font-size:12px; }
10587
+ .tool progress { grid-column:1 / -1; width:100%; height:6px; accent-color:var(--accent); }
10455
10588
  .budget { color:var(--dim); }
10456
10589
  .budget b { color:var(--text); }
10457
10590
  .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
@@ -10472,6 +10605,16 @@ function renderGallery(snapshot, opts = {}) {
10472
10605
  border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
10473
10606
  .sheet.review-host { width:min(1180px, 94vw); }
10474
10607
  .sheet.compare-host { width:min(1500px, 96vw); }
10608
+ .sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
10609
+ .sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
10610
+ .sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
10611
+ .sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
10612
+ .sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
10613
+ .sheet.editor-host .stage { position:relative; min-height:0; }
10614
+ .sheet.editor-host .stage iframe { position:absolute; inset:0; }
10615
+ .sheet.editor-host .loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--bg);
10616
+ color:var(--dim); text-align:center; padding:24px; }
10617
+ .sheet.editor-host .loading b { display:block; color:var(--text); margin-bottom:6px; }
10475
10618
  .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
10476
10619
  background:var(--panel); font-size:13px; flex-wrap:wrap; }
10477
10620
  .sheet .rbar span { color:var(--dim); }
@@ -10510,7 +10653,7 @@ function renderGallery(snapshot, opts = {}) {
10510
10653
  .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
10511
10654
  .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
10512
10655
  .tray > button { padding:5px 11px; font-size:12.5px; }
10513
- @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10656
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10514
10657
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10515
10658
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10516
10659
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
@@ -10558,6 +10701,7 @@ function renderGallery(snapshot, opts = {}) {
10558
10701
  <div class="totals" id="totals"></div>
10559
10702
  <div class="chips" id="chips"></div>
10560
10703
  <div id="jobs"></div>
10704
+ <div id="tools"></div>
10561
10705
  </header>
10562
10706
  <main id="root"></main>
10563
10707
  <footer>
@@ -10576,6 +10720,7 @@ const INITIAL = ${data};
10576
10720
  const SESSION = ${session};
10577
10721
  const EDITABLE = ${editable};
10578
10722
  const GENERATION = ${generation};
10723
+ const EDITOR = ${editor};
10579
10724
  let snap = INITIAL;
10580
10725
  const STATE_TONE = {
10581
10726
  ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
@@ -10743,6 +10888,92 @@ function renderJobs() {
10743
10888
  host.append(row);
10744
10889
  }
10745
10890
  }
10891
+ // ---- the in-browser editor (only when the server serves one) -------------
10892
+ // The build is a 46 MB web export fetched once into a user-level cache and
10893
+ // verified against hashes this PixelKiln release carries; the page only
10894
+ // reports and asks, the server downloads. Nothing is fetched without a click.
10895
+
10896
+ let ED = null;
10897
+ let edTimer = null;
10898
+ const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
10899
+ async function pollEditor() {
10900
+ if (!EDITOR) return;
10901
+ try {
10902
+ const res = await fetch('/api/editor', { cache: 'no-store' });
10903
+ if (!res.ok) throw new Error(await res.text());
10904
+ ED = await res.json();
10905
+ } catch (err) {
10906
+ ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
10907
+ }
10908
+ renderTools();
10909
+ clearTimeout(edTimer);
10910
+ if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
10911
+ else if (ui.open) renderDrawer();
10912
+ }
10913
+ async function installEditor() {
10914
+ try {
10915
+ const res = await fetch('/api/editor/install', {
10916
+ method: 'POST',
10917
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10918
+ body: '{}',
10919
+ });
10920
+ if (!res.ok) throw new Error(await res.text());
10921
+ ED = await res.json();
10922
+ } catch (err) {
10923
+ ED = Object.assign({}, ED, { error: err.message });
10924
+ }
10925
+ renderTools();
10926
+ clearTimeout(edTimer);
10927
+ edTimer = setTimeout(pollEditor, 500);
10928
+ }
10929
+ function renderTools() {
10930
+ const host = $('tools');
10931
+ host.textContent = '';
10932
+ if (!EDITOR || !ED) return;
10933
+ const row = el('div', 'tool');
10934
+ const ph = el('span', 'ph');
10935
+ const last = el('span', 'last');
10936
+ const acts = el('div', 'acts');
10937
+ const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
10938
+ if (ED.installing) {
10939
+ const p = ED.installing;
10940
+ ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
10941
+ last.textContent = name + ': fetching ' + (p.file || 'the build') + ' \u2014 ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
10942
+ row.append(ph, last, acts);
10943
+ const bar = el('progress');
10944
+ if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
10945
+ row.append(bar);
10946
+ } else if (ED.installed) {
10947
+ ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
10948
+ last.textContent = name + ' is installed and verified; a record\u2019s Hand edit section opens sprites in it.';
10949
+ last.title = ED.dir || '';
10950
+ const b = el('button', null, 'Open editor');
10951
+ b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
10952
+ acts.append(b);
10953
+ row.append(ph, last, acts);
10954
+ } else if (ED.error) {
10955
+ ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
10956
+ last.textContent = name + ': ' + ED.error;
10957
+ const b = el('button', 'primary', 'Retry install');
10958
+ b.type = 'button'; b.onclick = installEditor;
10959
+ acts.append(b);
10960
+ row.append(ph, last, acts);
10961
+ } else if (!ED.release) {
10962
+ ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
10963
+ last.textContent = name + ': this PixelKiln version pins no published build.';
10964
+ row.append(ph, last, acts);
10965
+ } else {
10966
+ ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
10967
+ last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
10968
+ ED.release + ', verified against the hashes this version carries, and kept outside the project.';
10969
+ const b = el('button', 'primary', 'Install editor');
10970
+ b.type = 'button'; b.onclick = installEditor;
10971
+ acts.append(b);
10972
+ row.append(ph, last, acts);
10973
+ }
10974
+ host.append(row);
10975
+ }
10976
+
10746
10977
  function budgetLine() {
10747
10978
  const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
10748
10979
  const parts = [];
@@ -10849,8 +11080,169 @@ function openReview(jobId) {
10849
11080
  host.append(scrim, panel);
10850
11081
  }
10851
11082
  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);
11083
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
11084
+ if (e.data.type === 'pixelkiln:review-applied') {
11085
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
11086
+ return;
11087
+ }
11088
+ if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
11089
+ });
11090
+
11091
+ // ---- the in-browser editor sheet -------------------------------------------
11092
+ // The page is the protocol host: it hands the editor the PNG and palette,
11093
+ // asks it for the image back, and does the authenticated write itself. The
11094
+ // iframe never sees the session token, and the server validates, sizes, and
11095
+ // declares the file the same way pixelkiln edit does.
11096
+
11097
+ let SHEET = null;
11098
+ const editorSource = (item) => item.edit && item.edit.exists ? item.edit
11099
+ : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
11100
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
11101
+ !(item.outputs.length > 1);
11102
+ const toBase64 = (buf) => new Promise((resolve, reject) => {
11103
+ const r = new FileReader();
11104
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
11105
+ r.onerror = () => reject(r.error);
11106
+ r.readAsDataURL(new Blob([buf]));
11107
+ });
11108
+ function sheetStatus(tone, text) {
11109
+ if (!SHEET) return;
11110
+ SHEET.status.textContent = '';
11111
+ SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
11112
+ }
11113
+ function sheetButtons() {
11114
+ if (!SHEET) return;
11115
+ const busy = !SHEET.opened || SHEET.pending !== null;
11116
+ SHEET.save.disabled = busy || !SHEET.dirty;
11117
+ SHEET.saveClose.disabled = busy;
11118
+ }
11119
+ function openEditorSheet(item) {
11120
+ if (SHEET) return;
11121
+ const src = editorSource(item);
11122
+ const host = $('dialog-host');
11123
+ host.textContent = '';
11124
+ const scrim = el('div', 'sheet-scrim');
11125
+ scrim.onclick = () => closeEditorSheet();
11126
+ const panel = el('div', 'sheet editor-host');
11127
+ const bar = el('div', 'rbar');
11128
+ const close = el('button', null, 'Close'); close.type = 'button';
11129
+ close.onclick = () => closeEditorSheet();
11130
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
11131
+ const status = el('span', 'st');
11132
+ const acts = el('div', 'acts');
11133
+ const msg = el('span', 'msg');
11134
+ const save = el('button', 'primary', 'Save to project'); save.type = 'button';
11135
+ save.onclick = () => requestEditorSave(false);
11136
+ const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
11137
+ saveClose.onclick = () => requestEditorSave(true);
11138
+ acts.append(msg, save, saveClose);
11139
+ bar.append(close, title, status, acts);
11140
+ const stage = el('div', 'stage');
11141
+ const frame = el('iframe');
11142
+ frame.title = 'Pixelorama';
11143
+ frame.src = ED.url;
11144
+ const loading = el('div', 'loading');
11145
+ const loadText = el('div');
11146
+ 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.'));
11147
+ loading.append(loadText);
11148
+ stage.append(frame, loading);
11149
+ panel.append(bar, stage);
11150
+ host.append(scrim, panel);
11151
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src };
11152
+ sheetStatus('cool', 'loading');
11153
+ sheetButtons();
11154
+ close.focus({ preventScroll: true });
11155
+ }
11156
+ async function sendOpen() {
11157
+ const { item, source } = SHEET;
11158
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
11159
+ let png;
11160
+ try {
11161
+ const res = await fetch(source.url, { cache: 'no-store' });
11162
+ if (!res.ok) throw new Error(await res.text());
11163
+ png = await res.arrayBuffer();
11164
+ } catch (err) {
11165
+ if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
11166
+ return;
11167
+ }
11168
+ if (!SHEET) return;
11169
+ const request = 'open-' + (++SHEET.requests);
11170
+ SHEET.frame.contentWindow.postMessage({
11171
+ type: 'pixelkiln:open', request,
11172
+ asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
11173
+ png, palette: style ? style.palette : [],
11174
+ }, location.origin, [png]);
11175
+ sheetStatus('cool', 'opening');
11176
+ }
11177
+ function requestEditorSave(close) {
11178
+ if (!SHEET || !SHEET.opened || SHEET.pending) return;
11179
+ const request = 'save-' + (++SHEET.requests);
11180
+ SHEET.pending = { request, close };
11181
+ SHEET.msg.textContent = '';
11182
+ sheetStatus('cool', 'saving');
11183
+ sheetButtons();
11184
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
11185
+ }
11186
+ async function onEditorMessage(m) {
11187
+ switch (m.type) {
11188
+ case 'pixelkiln:ready':
11189
+ SHEET.ready = m;
11190
+ if (m.version !== ED.protocol) {
11191
+ SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
11192
+ sheetStatus('bad', 'mismatch');
11193
+ return;
11194
+ }
11195
+ sendOpen();
11196
+ break;
11197
+ case 'pixelkiln:opened':
11198
+ SHEET.opened = true;
11199
+ SHEET.loading.remove();
11200
+ sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
11201
+ sheetButtons();
11202
+ break;
11203
+ case 'pixelkiln:dirty':
11204
+ SHEET.dirty = !!m.dirty;
11205
+ if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
11206
+ sheetButtons();
11207
+ break;
11208
+ case 'pixelkiln:save': {
11209
+ const pending = SHEET.pending;
11210
+ if (!pending || pending.request !== m.request) return;
11211
+ try {
11212
+ const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
11213
+ if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
11214
+ await postHandEdit(SHEET.item, 'save-edit', body);
11215
+ const saved = snap.items.find((i) => i.id === SHEET.item.id);
11216
+ if (saved) SHEET.item = saved;
11217
+ SHEET.dirty = false;
11218
+ SHEET.pending = null;
11219
+ 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.' };
11220
+ render();
11221
+ if (pending.close) { closeEditorSheet(true); return; }
11222
+ sheetStatus('ok', 'saved');
11223
+ } catch (err) {
11224
+ SHEET.pending = null;
11225
+ SHEET.msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh, then save again.' : '');
11226
+ sheetStatus('bad', 'not saved');
11227
+ }
11228
+ sheetButtons();
11229
+ break;
11230
+ }
11231
+ case 'pixelkiln:error':
11232
+ SHEET.msg.textContent = m.message || 'The editor reported an error.';
11233
+ if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
11234
+ break;
11235
+ }
11236
+ }
11237
+ function closeEditorSheet(force) {
11238
+ if (!SHEET) return;
11239
+ if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
11240
+ SHEET = null;
11241
+ $('dialog-host').textContent = '';
11242
+ refresh();
11243
+ }
11244
+ window.addEventListener('beforeunload', (e) => {
11245
+ if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
10854
11246
  });
10855
11247
 
10856
11248
  function visibleItems() {
@@ -11426,6 +11818,11 @@ function handEditSection(item) {
11426
11818
  row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11427
11819
  if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11428
11820
  row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11821
+ if (item.editMeta) {
11822
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
11823
+ if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
11824
+ if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
11825
+ }
11429
11826
  s.append(dl);
11430
11827
  } else {
11431
11828
  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 +11831,7 @@ function handEditSection(item) {
11434
11831
  const acts = el('div', 'hand-actions');
11435
11832
  const msg = el('span', 'msg');
11436
11833
  const go = async (label, action, extra, done) => {
11437
- const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
11834
+ const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
11438
11835
  b.onclick = async () => {
11439
11836
  b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
11440
11837
  try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
@@ -11442,8 +11839,20 @@ function handEditSection(item) {
11442
11839
  };
11443
11840
  acts.append(b);
11444
11841
  };
11842
+ if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
11843
+ if (ED.installed) {
11844
+ const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
11845
+ b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
11846
+ b.onclick = () => openEditorSheet(item);
11847
+ acts.append(b);
11848
+ } else if (ED.release && !ED.installing) {
11849
+ const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
11850
+ b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
11851
+ acts.append(b);
11852
+ }
11853
+ }
11445
11854
  if (item.edit) {
11446
- go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11855
+ go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11447
11856
  go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11448
11857
  } else if (canStart) {
11449
11858
  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 +12579,7 @@ document.addEventListener('keydown', (e) => {
12170
12579
  if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
12171
12580
  if (e.key === 'Escape') {
12172
12581
  if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
12582
+ if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
12173
12583
  if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
12174
12584
  if (ui.open) { e.preventDefault(); closeItem(); }
12175
12585
  return;
@@ -12191,6 +12601,7 @@ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
12191
12601
  render();
12192
12602
  if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
12193
12603
  if (GENERATION) pollJobs();
12604
+ if (EDITOR) pollEditor();
12194
12605
  if (ui.compare.length >= 2 && !ui.open) openCompare();
12195
12606
  </script>
12196
12607
  </body>
@@ -12203,7 +12614,16 @@ var import_node_child_process4 = require("child_process");
12203
12614
  var import_node_crypto7 = require("crypto");
12204
12615
  var import_node_http2 = require("http");
12205
12616
  var import_promises21 = require("fs/promises");
12206
- var MAX_EDIT_BYTES = 64 * 1024;
12617
+ var MAX_EDIT_BYTES = 48 * 1024 * 1024;
12618
+ var EDITOR_CSP = [
12619
+ "default-src 'self'",
12620
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
12621
+ "style-src 'self' 'unsafe-inline'",
12622
+ "img-src 'self' data: blob:",
12623
+ "worker-src 'self' blob:",
12624
+ "connect-src 'self'",
12625
+ "frame-ancestors 'self'"
12626
+ ].join("; ");
12207
12627
  async function readJsonBody(req) {
12208
12628
  const chunks = [];
12209
12629
  let bytes = 0;
@@ -12221,7 +12641,7 @@ async function readJsonBody(req) {
12221
12641
  async function serveGallery(opts) {
12222
12642
  const log = opts.onProgress ?? (() => {
12223
12643
  });
12224
- const session = opts.edit || opts.generate ? (0, import_node_crypto7.randomBytes)(16).toString("hex") : null;
12644
+ const session = opts.edit || opts.generate || opts.editor ? (0, import_node_crypto7.randomBytes)(16).toString("hex") : null;
12225
12645
  let media = /* @__PURE__ */ new Map();
12226
12646
  let loading = null;
12227
12647
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -12296,6 +12716,17 @@ async function serveGallery(opts) {
12296
12716
  }
12297
12717
  return;
12298
12718
  }
12719
+ if (req.method === "POST" && url2.pathname === "/api/editor/install") {
12720
+ if (!opts.editor || !session) return fail(405, "the in-browser editor is off for this gallery");
12721
+ try {
12722
+ const body = await guardedBody("editor installs");
12723
+ if (body === void 0) return;
12724
+ json(202, await opts.editor.install());
12725
+ } catch (err) {
12726
+ reportError("editor install", err);
12727
+ }
12728
+ return;
12729
+ }
12299
12730
  const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
12300
12731
  if (reviewMatch && opts.generate) {
12301
12732
  const [, jobId, rest = ""] = reviewMatch;
@@ -12359,6 +12790,32 @@ async function serveGallery(opts) {
12359
12790
  if (!opts.generate) return fail(404, "generation is off");
12360
12791
  return json(200, opts.generate.status());
12361
12792
  }
12793
+ if (url2.pathname === "/api/editor") {
12794
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12795
+ try {
12796
+ return json(200, await opts.editor.status());
12797
+ } catch (err) {
12798
+ return reportError("editor status", err);
12799
+ }
12800
+ }
12801
+ const editorMatch = /^\/editor\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(url2.pathname);
12802
+ if (editorMatch) {
12803
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12804
+ const file = await opts.editor.file(editorMatch[1], editorMatch[2]);
12805
+ if (!file) return fail(404, "that editor file is not installed; check /api/editor");
12806
+ res.writeHead(200, {
12807
+ "Content-Type": file.contentType,
12808
+ "Content-Length": file.bytes.length,
12809
+ // The release tag is in the path, so these bytes never change under
12810
+ // this URL; the 40 MB wasm should come from the browser cache.
12811
+ "Cache-Control": "public, max-age=31536000, immutable",
12812
+ "X-Content-Type-Options": "nosniff",
12813
+ "Cross-Origin-Resource-Policy": "same-origin",
12814
+ ...editorMatch[2] === "index.html" ? { "Content-Security-Policy": EDITOR_CSP } : {}
12815
+ });
12816
+ res.end(req.method === "HEAD" ? void 0 : file.bytes);
12817
+ return;
12818
+ }
12362
12819
  try {
12363
12820
  if (url2.pathname === "/") {
12364
12821
  const { snapshot } = await load();
@@ -12370,7 +12827,8 @@ async function serveGallery(opts) {
12370
12827
  res.end(renderGallery(snapshot, {
12371
12828
  ...session ? { session } : {},
12372
12829
  editable: Boolean(opts.edit),
12373
- generation: Boolean(opts.generate)
12830
+ generation: Boolean(opts.generate),
12831
+ editor: Boolean(opts.editor)
12374
12832
  }));
12375
12833
  return;
12376
12834
  }
@@ -12436,16 +12894,16 @@ async function serveGallery(opts) {
12436
12894
 
12437
12895
  // src/gallery/generate.ts
12438
12896
  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),
12897
+ var import_zod10 = require("zod");
12898
+ var GenerateRequestSchema = import_zod10.z.object({
12899
+ project: import_zod10.z.string().min(1).optional(),
12900
+ keys: import_zod10.z.array(import_zod10.z.string().min(1)).min(1).max(500),
12443
12901
  /** Regenerate work that is up to date, as `gen --force` does. */
12444
- force: import_zod9.z.boolean().optional(),
12902
+ force: import_zod10.z.boolean().optional(),
12445
12903
  /** Advance in-flight, review, or selected work without submitting anything. */
12446
- resume: import_zod9.z.boolean().optional(),
12904
+ resume: import_zod10.z.boolean().optional(),
12447
12905
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
12448
- refresh: import_zod9.z.boolean().optional()
12906
+ refresh: import_zod10.z.boolean().optional()
12449
12907
  }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
12450
12908
  var GenerateRequestError = class extends Error {
12451
12909
  constructor(message6, status = 400) {