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.js CHANGED
@@ -9030,7 +9030,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
9030
9030
  // src/gallery/snapshot.ts
9031
9031
  import { createHash as createHash4 } from "crypto";
9032
9032
  import { existsSync as existsSync19 } from "fs";
9033
- import { readFile as readFile18, stat as stat3 } from "fs/promises";
9033
+ import { readFile as readFile19, stat as stat3 } from "fs/promises";
9034
9034
  import path24 from "path";
9035
9035
 
9036
9036
  // src/workspace.ts
@@ -9168,7 +9168,7 @@ function validateWorkspace(ws, dir) {
9168
9168
 
9169
9169
  // src/gallery/edit.ts
9170
9170
  import path23 from "path";
9171
- import { z as z8 } from "zod";
9171
+ import { z as z9 } from "zod";
9172
9172
 
9173
9173
  // src/manifest-edit.ts
9174
9174
  import { readFile as readFile17, rename as rename6, rm as rm8, writeFile as writeFile8 } from "fs/promises";
@@ -9422,8 +9422,9 @@ async function applyManifestEdit(manifestPath, edit) {
9422
9422
  // src/pipeline/hand-edit.ts
9423
9423
  import { spawn as spawn2 } from "child_process";
9424
9424
  import { existsSync as existsSync18 } from "fs";
9425
- import { copyFile, mkdir as mkdir6 } from "fs/promises";
9425
+ import { copyFile, mkdir as mkdir6, readFile as readFile18, rename as rename7, rm as rm9, writeFile as writeFile9 } from "fs/promises";
9426
9426
  import path22 from "path";
9427
+ import { z as z8 } from "zod";
9427
9428
  var HAND_EDIT_DIR = "edits";
9428
9429
  function handEditPath(loaded, spec) {
9429
9430
  const style = loaded.manifest.styles[spec.styleId];
@@ -9493,6 +9494,89 @@ async function detachHandEdit(loaded, spec, opts = {}) {
9493
9494
  });
9494
9495
  return { manifestSha256: result.sha256, changed: result.changed };
9495
9496
  }
9497
+ var HandEditCompanionSchema = z8.object({
9498
+ version: z8.literal(1),
9499
+ /** sha256 of the generated art the edit started from; null when it started from untracked art. */
9500
+ basedOn: z8.string().regex(/^[0-9a-f]{64}$/).nullable(),
9501
+ /** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
9502
+ sha256: z8.string().regex(/^[0-9a-f]{64}$/),
9503
+ /** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
9504
+ editor: z8.string().min(1),
9505
+ protocol: z8.number().int().positive(),
9506
+ savedAt: z8.string().datetime(),
9507
+ /** Basename of the layered project file beside the edit, when one was kept. */
9508
+ project: z8.string().min(1).nullable()
9509
+ }).strict();
9510
+ function handEditCompanionPath(editPath) {
9511
+ return editPath.replace(/\.png$/i, "") + ".edit.json";
9512
+ }
9513
+ function handEditProjectPath(editPath) {
9514
+ return editPath.replace(/\.png$/i, "") + ".pxo";
9515
+ }
9516
+ async function readHandEditCompanion(editPath) {
9517
+ let text;
9518
+ try {
9519
+ text = await readFile18(handEditCompanionPath(editPath), "utf8");
9520
+ } catch {
9521
+ return null;
9522
+ }
9523
+ try {
9524
+ return HandEditCompanionSchema.parse(JSON.parse(text));
9525
+ } catch {
9526
+ return null;
9527
+ }
9528
+ }
9529
+ var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
9530
+ async function saveHandEdit(loaded, lock, spec, input) {
9531
+ if (input.png.length > MAX_HAND_EDIT_BYTES) {
9532
+ throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9533
+ }
9534
+ if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
9535
+ throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9536
+ }
9537
+ let decoded;
9538
+ try {
9539
+ validateMedia(input.png, MediaType.PNG);
9540
+ decoded = decodePng(input.png);
9541
+ } catch (error) {
9542
+ throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
9543
+ }
9544
+ const base = handEditBase(spec, lock);
9545
+ const expected = base ? decodePng(await readFile18(base)) : { width: spec.width, height: spec.height };
9546
+ if (decoded.width !== expected.width || decoded.height !== expected.height) {
9547
+ throw new ManifestEditError(
9548
+ `the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
9549
+ );
9550
+ }
9551
+ const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
9552
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9553
+ const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
9554
+ const digest2 = sha256(input.png);
9555
+ const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
9556
+ const companion = {
9557
+ version: 1,
9558
+ basedOn,
9559
+ sha256: digest2,
9560
+ editor: input.editor,
9561
+ protocol: input.protocol,
9562
+ savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
9563
+ project: projectPath ? path22.basename(projectPath) : null
9564
+ };
9565
+ await replaceFile(started.editPath, input.png);
9566
+ if (projectPath && input.project) await replaceFile(projectPath, input.project);
9567
+ const companionPath = handEditCompanionPath(started.editPath);
9568
+ await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
9569
+ return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
9570
+ }
9571
+ async function replaceFile(file, bytes) {
9572
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9573
+ try {
9574
+ await writeFile9(tmp, bytes);
9575
+ await rename7(tmp, file);
9576
+ } finally {
9577
+ await rm9(tmp, { force: true });
9578
+ }
9579
+ }
9496
9580
  function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
9497
9581
  const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
9498
9582
  const [program, ...args] = command;
@@ -9504,22 +9588,37 @@ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
9504
9588
  }
9505
9589
 
9506
9590
  // src/gallery/edit.ts
9507
- var HandEditRequestSchema = z8.discriminatedUnion("action", [
9508
- z8.object({
9509
- action: z8.literal("start-edit"),
9510
- project: z8.string().min(1).optional(),
9511
- assetId: z8.string().min(1),
9512
- styleId: z8.string().min(1),
9513
- expectedSha256: z8.string().regex(/^[0-9a-f]{64}$/),
9591
+ var Base64 = z9.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
9592
+ var HandEditRequestSchema = z9.discriminatedUnion("action", [
9593
+ z9.object({
9594
+ action: z9.literal("start-edit"),
9595
+ project: z9.string().min(1).optional(),
9596
+ assetId: z9.string().min(1),
9597
+ styleId: z9.string().min(1),
9598
+ expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/),
9514
9599
  /** Also launch the author's editor on the file. */
9515
- open: z8.boolean().optional()
9600
+ open: z9.boolean().optional()
9516
9601
  }).strict(),
9517
- z8.object({
9518
- action: z8.literal("detach-edit"),
9519
- project: z8.string().min(1).optional(),
9520
- assetId: z8.string().min(1),
9521
- styleId: z8.string().min(1),
9522
- expectedSha256: z8.string().regex(/^[0-9a-f]{64}$/)
9602
+ z9.object({
9603
+ action: z9.literal("detach-edit"),
9604
+ project: z9.string().min(1).optional(),
9605
+ assetId: z9.string().min(1),
9606
+ styleId: z9.string().min(1),
9607
+ expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/)
9608
+ }).strict(),
9609
+ /** From the in-browser editor: the page holds the bytes, the server validates and writes. */
9610
+ z9.object({
9611
+ action: z9.literal("save-edit"),
9612
+ project: z9.string().min(1).optional(),
9613
+ assetId: z9.string().min(1),
9614
+ styleId: z9.string().min(1),
9615
+ expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/),
9616
+ png: Base64.min(1),
9617
+ /** The editor's layered project file, kept beside the edit. */
9618
+ pxo: Base64.optional(),
9619
+ /** Editor identity from the bridge's ready message. */
9620
+ editor: z9.string().min(1).max(200),
9621
+ protocol: z9.number().int().positive()
9523
9622
  }).strict()
9524
9623
  ]);
9525
9624
  function createGalleryEditHandler(opts) {
@@ -9540,6 +9639,15 @@ function createGalleryEditHandler(opts) {
9540
9639
  const command = (opts.openEditor ?? openInEditor)(started.editPath);
9541
9640
  log(` opened with: ${command}`);
9542
9641
  }
9642
+ } else if (request.action === "save-edit") {
9643
+ const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
9644
+ png: Buffer.from(request.png, "base64"),
9645
+ ...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
9646
+ editor: request.editor,
9647
+ protocol: request.protocol,
9648
+ expectedSha256: request.expectedSha256
9649
+ });
9650
+ log(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
9543
9651
  } else {
9544
9652
  const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
9545
9653
  if (result2.changed) log(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
@@ -9689,7 +9797,7 @@ async function buildGallerySnapshot(opts) {
9689
9797
  const media = /* @__PURE__ */ new Map();
9690
9798
  const plan = await buildPlan(specs, lock);
9691
9799
  const items = [];
9692
- const manifestText = await readFile18(loaded.path, "utf8");
9800
+ const manifestText = await readFile19(loaded.path, "utf8");
9693
9801
  const manifestSha256 = sha256(manifestText);
9694
9802
  let rawStyles = {};
9695
9803
  try {
@@ -9704,6 +9812,7 @@ async function buildGallerySnapshot(opts) {
9704
9812
  let outputs;
9705
9813
  let edit = null;
9706
9814
  let editStatus = null;
9815
+ let editMeta = null;
9707
9816
  if (entry && entry.outputs.length) {
9708
9817
  outputs = await Promise.all(
9709
9818
  entry.outputs.map(
@@ -9715,7 +9824,19 @@ async function buildGallerySnapshot(opts) {
9715
9824
  const editSha = existsSync19(editPath) ? await sha256File(editPath) : null;
9716
9825
  edit = await describeOutput(media, root, editPath, { sha256: editSha });
9717
9826
  const generated = outputs[0];
9718
- editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists ? "regenerated-since" : "edited";
9827
+ const companion = await readHandEditCompanion(editPath);
9828
+ if (companion) {
9829
+ const projectPath = handEditProjectPath(editPath);
9830
+ editMeta = {
9831
+ editor: companion.editor,
9832
+ savedAt: companion.savedAt,
9833
+ basedOn: companion.basedOn,
9834
+ changedSince: editSha !== null && editSha !== companion.sha256,
9835
+ project: companion.project && existsSync19(projectPath) ? portableOutputPath(projectPath, root) : null
9836
+ };
9837
+ }
9838
+ 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);
9839
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
9719
9840
  }
9720
9841
  } else if (spec.source) {
9721
9842
  outputs = [await describeOutput(media, root, path24.resolve(root, spec.source))];
@@ -9765,6 +9886,7 @@ async function buildGallerySnapshot(opts) {
9765
9886
  source: spec.source ?? null,
9766
9887
  edit,
9767
9888
  editStatus,
9889
+ editMeta,
9768
9890
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9769
9891
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9770
9892
  tags: spec.tags,
@@ -9821,6 +9943,7 @@ async function buildGallerySnapshot(opts) {
9821
9943
  source: null,
9822
9944
  edit: null,
9823
9945
  editStatus: null,
9946
+ editMeta: null,
9824
9947
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9825
9948
  refreshable: false,
9826
9949
  tags: [],
@@ -9962,7 +10085,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9962
10085
  root: loaded.root,
9963
10086
  provider: loaded.manifest.provider,
9964
10087
  account: project.account ?? null,
9965
- manifestSha256: manifestSha256 ?? sha256(await readFile18(manifestPath, "utf8")),
10088
+ manifestSha256: manifestSha256 ?? sha256(await readFile19(manifestPath, "utf8")),
9966
10089
  entries: Object.keys(lock.entries).length,
9967
10090
  items: projectItems.length,
9968
10091
  spendByUnit: spendByUnit(lock),
@@ -10022,6 +10145,7 @@ function renderGallery(snapshot, opts = {}) {
10022
10145
  const session = JSON.stringify(opts.session ?? null);
10023
10146
  const editable = JSON.stringify(Boolean(opts.editable && opts.session));
10024
10147
  const generation = JSON.stringify(Boolean(opts.generation && opts.session));
10148
+ const editor = JSON.stringify(Boolean(opts.editor && opts.session));
10025
10149
  const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
10026
10150
  return `<!doctype html>
10027
10151
  <html lang="en">
@@ -10227,6 +10351,15 @@ function renderGallery(snapshot, opts = {}) {
10227
10351
  .job .acts button { padding:3px 9px; font-size:12px; }
10228
10352
  .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
10229
10353
  font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
10354
+ #tools { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; }
10355
+ #tools:empty { display:none; }
10356
+ .tool { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
10357
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
10358
+ .tool .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
10359
+ .tool .last { color:var(--dim); min-width:0; }
10360
+ .tool .acts { display:flex; gap:6px; }
10361
+ .tool .acts button { padding:3px 9px; font-size:12px; }
10362
+ .tool progress { grid-column:1 / -1; width:100%; height:6px; accent-color:var(--accent); }
10230
10363
  .budget { color:var(--dim); }
10231
10364
  .budget b { color:var(--text); }
10232
10365
  .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
@@ -10247,6 +10380,16 @@ function renderGallery(snapshot, opts = {}) {
10247
10380
  border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
10248
10381
  .sheet.review-host { width:min(1180px, 94vw); }
10249
10382
  .sheet.compare-host { width:min(1500px, 96vw); }
10383
+ .sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
10384
+ .sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
10385
+ .sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
10386
+ .sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
10387
+ .sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
10388
+ .sheet.editor-host .stage { position:relative; min-height:0; }
10389
+ .sheet.editor-host .stage iframe { position:absolute; inset:0; }
10390
+ .sheet.editor-host .loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--bg);
10391
+ color:var(--dim); text-align:center; padding:24px; }
10392
+ .sheet.editor-host .loading b { display:block; color:var(--text); margin-bottom:6px; }
10250
10393
  .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
10251
10394
  background:var(--panel); font-size:13px; flex-wrap:wrap; }
10252
10395
  .sheet .rbar span { color:var(--dim); }
@@ -10285,7 +10428,7 @@ function renderGallery(snapshot, opts = {}) {
10285
10428
  .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
10286
10429
  .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
10287
10430
  .tray > button { padding:5px 11px; font-size:12.5px; }
10288
- @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10431
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10289
10432
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10290
10433
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10291
10434
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
@@ -10333,6 +10476,7 @@ function renderGallery(snapshot, opts = {}) {
10333
10476
  <div class="totals" id="totals"></div>
10334
10477
  <div class="chips" id="chips"></div>
10335
10478
  <div id="jobs"></div>
10479
+ <div id="tools"></div>
10336
10480
  </header>
10337
10481
  <main id="root"></main>
10338
10482
  <footer>
@@ -10351,6 +10495,7 @@ const INITIAL = ${data};
10351
10495
  const SESSION = ${session};
10352
10496
  const EDITABLE = ${editable};
10353
10497
  const GENERATION = ${generation};
10498
+ const EDITOR = ${editor};
10354
10499
  let snap = INITIAL;
10355
10500
  const STATE_TONE = {
10356
10501
  ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
@@ -10518,6 +10663,92 @@ function renderJobs() {
10518
10663
  host.append(row);
10519
10664
  }
10520
10665
  }
10666
+ // ---- the in-browser editor (only when the server serves one) -------------
10667
+ // The build is a 46 MB web export fetched once into a user-level cache and
10668
+ // verified against hashes this PixelKiln release carries; the page only
10669
+ // reports and asks, the server downloads. Nothing is fetched without a click.
10670
+
10671
+ let ED = null;
10672
+ let edTimer = null;
10673
+ const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
10674
+ async function pollEditor() {
10675
+ if (!EDITOR) return;
10676
+ try {
10677
+ const res = await fetch('/api/editor', { cache: 'no-store' });
10678
+ if (!res.ok) throw new Error(await res.text());
10679
+ ED = await res.json();
10680
+ } catch (err) {
10681
+ ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
10682
+ }
10683
+ renderTools();
10684
+ clearTimeout(edTimer);
10685
+ if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
10686
+ else if (ui.open) renderDrawer();
10687
+ }
10688
+ async function installEditor() {
10689
+ try {
10690
+ const res = await fetch('/api/editor/install', {
10691
+ method: 'POST',
10692
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10693
+ body: '{}',
10694
+ });
10695
+ if (!res.ok) throw new Error(await res.text());
10696
+ ED = await res.json();
10697
+ } catch (err) {
10698
+ ED = Object.assign({}, ED, { error: err.message });
10699
+ }
10700
+ renderTools();
10701
+ clearTimeout(edTimer);
10702
+ edTimer = setTimeout(pollEditor, 500);
10703
+ }
10704
+ function renderTools() {
10705
+ const host = $('tools');
10706
+ host.textContent = '';
10707
+ if (!EDITOR || !ED) return;
10708
+ const row = el('div', 'tool');
10709
+ const ph = el('span', 'ph');
10710
+ const last = el('span', 'last');
10711
+ const acts = el('div', 'acts');
10712
+ const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
10713
+ if (ED.installing) {
10714
+ const p = ED.installing;
10715
+ ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
10716
+ last.textContent = name + ': fetching ' + (p.file || 'the build') + ' \u2014 ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
10717
+ row.append(ph, last, acts);
10718
+ const bar = el('progress');
10719
+ if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
10720
+ row.append(bar);
10721
+ } else if (ED.installed) {
10722
+ ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
10723
+ last.textContent = name + ' is installed and verified; a record\u2019s Hand edit section opens sprites in it.';
10724
+ last.title = ED.dir || '';
10725
+ const b = el('button', null, 'Open editor');
10726
+ b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
10727
+ acts.append(b);
10728
+ row.append(ph, last, acts);
10729
+ } else if (ED.error) {
10730
+ ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
10731
+ last.textContent = name + ': ' + ED.error;
10732
+ const b = el('button', 'primary', 'Retry install');
10733
+ b.type = 'button'; b.onclick = installEditor;
10734
+ acts.append(b);
10735
+ row.append(ph, last, acts);
10736
+ } else if (!ED.release) {
10737
+ ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
10738
+ last.textContent = name + ': this PixelKiln version pins no published build.';
10739
+ row.append(ph, last, acts);
10740
+ } else {
10741
+ ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
10742
+ last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
10743
+ ED.release + ', verified against the hashes this version carries, and kept outside the project.';
10744
+ const b = el('button', 'primary', 'Install editor');
10745
+ b.type = 'button'; b.onclick = installEditor;
10746
+ acts.append(b);
10747
+ row.append(ph, last, acts);
10748
+ }
10749
+ host.append(row);
10750
+ }
10751
+
10521
10752
  function budgetLine() {
10522
10753
  const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
10523
10754
  const parts = [];
@@ -10624,8 +10855,169 @@ function openReview(jobId) {
10624
10855
  host.append(scrim, panel);
10625
10856
  }
10626
10857
  window.addEventListener('message', (e) => {
10627
- if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
10628
- setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
10858
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
10859
+ if (e.data.type === 'pixelkiln:review-applied') {
10860
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
10861
+ return;
10862
+ }
10863
+ if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
10864
+ });
10865
+
10866
+ // ---- the in-browser editor sheet -------------------------------------------
10867
+ // The page is the protocol host: it hands the editor the PNG and palette,
10868
+ // asks it for the image back, and does the authenticated write itself. The
10869
+ // iframe never sees the session token, and the server validates, sizes, and
10870
+ // declares the file the same way pixelkiln edit does.
10871
+
10872
+ let SHEET = null;
10873
+ const editorSource = (item) => item.edit && item.edit.exists ? item.edit
10874
+ : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
10875
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
10876
+ !(item.outputs.length > 1);
10877
+ const toBase64 = (buf) => new Promise((resolve, reject) => {
10878
+ const r = new FileReader();
10879
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
10880
+ r.onerror = () => reject(r.error);
10881
+ r.readAsDataURL(new Blob([buf]));
10882
+ });
10883
+ function sheetStatus(tone, text) {
10884
+ if (!SHEET) return;
10885
+ SHEET.status.textContent = '';
10886
+ SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
10887
+ }
10888
+ function sheetButtons() {
10889
+ if (!SHEET) return;
10890
+ const busy = !SHEET.opened || SHEET.pending !== null;
10891
+ SHEET.save.disabled = busy || !SHEET.dirty;
10892
+ SHEET.saveClose.disabled = busy;
10893
+ }
10894
+ function openEditorSheet(item) {
10895
+ if (SHEET) return;
10896
+ const src = editorSource(item);
10897
+ const host = $('dialog-host');
10898
+ host.textContent = '';
10899
+ const scrim = el('div', 'sheet-scrim');
10900
+ scrim.onclick = () => closeEditorSheet();
10901
+ const panel = el('div', 'sheet editor-host');
10902
+ const bar = el('div', 'rbar');
10903
+ const close = el('button', null, 'Close'); close.type = 'button';
10904
+ close.onclick = () => closeEditorSheet();
10905
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
10906
+ const status = el('span', 'st');
10907
+ const acts = el('div', 'acts');
10908
+ const msg = el('span', 'msg');
10909
+ const save = el('button', 'primary', 'Save to project'); save.type = 'button';
10910
+ save.onclick = () => requestEditorSave(false);
10911
+ const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
10912
+ saveClose.onclick = () => requestEditorSave(true);
10913
+ acts.append(msg, save, saveClose);
10914
+ bar.append(close, title, status, acts);
10915
+ const stage = el('div', 'stage');
10916
+ const frame = el('iframe');
10917
+ frame.title = 'Pixelorama';
10918
+ frame.src = ED.url;
10919
+ const loading = el('div', 'loading');
10920
+ const loadText = el('div');
10921
+ 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.'));
10922
+ loading.append(loadText);
10923
+ stage.append(frame, loading);
10924
+ panel.append(bar, stage);
10925
+ host.append(scrim, panel);
10926
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src };
10927
+ sheetStatus('cool', 'loading');
10928
+ sheetButtons();
10929
+ close.focus({ preventScroll: true });
10930
+ }
10931
+ async function sendOpen() {
10932
+ const { item, source } = SHEET;
10933
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
10934
+ let png;
10935
+ try {
10936
+ const res = await fetch(source.url, { cache: 'no-store' });
10937
+ if (!res.ok) throw new Error(await res.text());
10938
+ png = await res.arrayBuffer();
10939
+ } catch (err) {
10940
+ if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
10941
+ return;
10942
+ }
10943
+ if (!SHEET) return;
10944
+ const request = 'open-' + (++SHEET.requests);
10945
+ SHEET.frame.contentWindow.postMessage({
10946
+ type: 'pixelkiln:open', request,
10947
+ asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
10948
+ png, palette: style ? style.palette : [],
10949
+ }, location.origin, [png]);
10950
+ sheetStatus('cool', 'opening');
10951
+ }
10952
+ function requestEditorSave(close) {
10953
+ if (!SHEET || !SHEET.opened || SHEET.pending) return;
10954
+ const request = 'save-' + (++SHEET.requests);
10955
+ SHEET.pending = { request, close };
10956
+ SHEET.msg.textContent = '';
10957
+ sheetStatus('cool', 'saving');
10958
+ sheetButtons();
10959
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
10960
+ }
10961
+ async function onEditorMessage(m) {
10962
+ switch (m.type) {
10963
+ case 'pixelkiln:ready':
10964
+ SHEET.ready = m;
10965
+ if (m.version !== ED.protocol) {
10966
+ SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
10967
+ sheetStatus('bad', 'mismatch');
10968
+ return;
10969
+ }
10970
+ sendOpen();
10971
+ break;
10972
+ case 'pixelkiln:opened':
10973
+ SHEET.opened = true;
10974
+ SHEET.loading.remove();
10975
+ sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
10976
+ sheetButtons();
10977
+ break;
10978
+ case 'pixelkiln:dirty':
10979
+ SHEET.dirty = !!m.dirty;
10980
+ if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
10981
+ sheetButtons();
10982
+ break;
10983
+ case 'pixelkiln:save': {
10984
+ const pending = SHEET.pending;
10985
+ if (!pending || pending.request !== m.request) return;
10986
+ try {
10987
+ const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
10988
+ if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
10989
+ await postHandEdit(SHEET.item, 'save-edit', body);
10990
+ const saved = snap.items.find((i) => i.id === SHEET.item.id);
10991
+ if (saved) SHEET.item = saved;
10992
+ SHEET.dirty = false;
10993
+ SHEET.pending = null;
10994
+ 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.' };
10995
+ render();
10996
+ if (pending.close) { closeEditorSheet(true); return; }
10997
+ sheetStatus('ok', 'saved');
10998
+ } catch (err) {
10999
+ SHEET.pending = null;
11000
+ SHEET.msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh, then save again.' : '');
11001
+ sheetStatus('bad', 'not saved');
11002
+ }
11003
+ sheetButtons();
11004
+ break;
11005
+ }
11006
+ case 'pixelkiln:error':
11007
+ SHEET.msg.textContent = m.message || 'The editor reported an error.';
11008
+ if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
11009
+ break;
11010
+ }
11011
+ }
11012
+ function closeEditorSheet(force) {
11013
+ if (!SHEET) return;
11014
+ if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
11015
+ SHEET = null;
11016
+ $('dialog-host').textContent = '';
11017
+ refresh();
11018
+ }
11019
+ window.addEventListener('beforeunload', (e) => {
11020
+ if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
10629
11021
  });
10630
11022
 
10631
11023
  function visibleItems() {
@@ -11201,6 +11593,11 @@ function handEditSection(item) {
11201
11593
  row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11202
11594
  if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11203
11595
  row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11596
+ if (item.editMeta) {
11597
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
11598
+ if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
11599
+ if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
11600
+ }
11204
11601
  s.append(dl);
11205
11602
  } else {
11206
11603
  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.'));
@@ -11209,7 +11606,7 @@ function handEditSection(item) {
11209
11606
  const acts = el('div', 'hand-actions');
11210
11607
  const msg = el('span', 'msg');
11211
11608
  const go = async (label, action, extra, done) => {
11212
- const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
11609
+ const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
11213
11610
  b.onclick = async () => {
11214
11611
  b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
11215
11612
  try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
@@ -11217,8 +11614,20 @@ function handEditSection(item) {
11217
11614
  };
11218
11615
  acts.append(b);
11219
11616
  };
11617
+ if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
11618
+ if (ED.installed) {
11619
+ const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
11620
+ b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
11621
+ b.onclick = () => openEditorSheet(item);
11622
+ acts.append(b);
11623
+ } else if (ED.release && !ED.installing) {
11624
+ const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
11625
+ b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
11626
+ acts.append(b);
11627
+ }
11628
+ }
11220
11629
  if (item.edit) {
11221
- go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11630
+ go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11222
11631
  go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11223
11632
  } else if (canStart) {
11224
11633
  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.');
@@ -11945,6 +12354,7 @@ document.addEventListener('keydown', (e) => {
11945
12354
  if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
11946
12355
  if (e.key === 'Escape') {
11947
12356
  if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
12357
+ if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
11948
12358
  if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
11949
12359
  if (ui.open) { e.preventDefault(); closeItem(); }
11950
12360
  return;
@@ -11966,6 +12376,7 @@ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
11966
12376
  render();
11967
12377
  if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
11968
12378
  if (GENERATION) pollJobs();
12379
+ if (EDITOR) pollEditor();
11969
12380
  if (ui.compare.length >= 2 && !ui.open) openCompare();
11970
12381
  </script>
11971
12382
  </body>
@@ -11977,8 +12388,17 @@ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt
11977
12388
  import { spawn as spawn3 } from "child_process";
11978
12389
  import { randomBytes } from "crypto";
11979
12390
  import { createServer as createServer2 } from "http";
11980
- import { readFile as readFile19 } from "fs/promises";
11981
- var MAX_EDIT_BYTES = 64 * 1024;
12391
+ import { readFile as readFile20 } from "fs/promises";
12392
+ var MAX_EDIT_BYTES = 48 * 1024 * 1024;
12393
+ var EDITOR_CSP = [
12394
+ "default-src 'self'",
12395
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
12396
+ "style-src 'self' 'unsafe-inline'",
12397
+ "img-src 'self' data: blob:",
12398
+ "worker-src 'self' blob:",
12399
+ "connect-src 'self'",
12400
+ "frame-ancestors 'self'"
12401
+ ].join("; ");
11982
12402
  async function readJsonBody(req) {
11983
12403
  const chunks = [];
11984
12404
  let bytes = 0;
@@ -11996,7 +12416,7 @@ async function readJsonBody(req) {
11996
12416
  async function serveGallery(opts) {
11997
12417
  const log = opts.onProgress ?? (() => {
11998
12418
  });
11999
- const session = opts.edit || opts.generate ? randomBytes(16).toString("hex") : null;
12419
+ const session = opts.edit || opts.generate || opts.editor ? randomBytes(16).toString("hex") : null;
12000
12420
  let media = /* @__PURE__ */ new Map();
12001
12421
  let loading = null;
12002
12422
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -12071,6 +12491,17 @@ async function serveGallery(opts) {
12071
12491
  }
12072
12492
  return;
12073
12493
  }
12494
+ if (req.method === "POST" && url2.pathname === "/api/editor/install") {
12495
+ if (!opts.editor || !session) return fail(405, "the in-browser editor is off for this gallery");
12496
+ try {
12497
+ const body = await guardedBody("editor installs");
12498
+ if (body === void 0) return;
12499
+ json(202, await opts.editor.install());
12500
+ } catch (err) {
12501
+ reportError("editor install", err);
12502
+ }
12503
+ return;
12504
+ }
12074
12505
  const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
12075
12506
  if (reviewMatch && opts.generate) {
12076
12507
  const [, jobId, rest = ""] = reviewMatch;
@@ -12116,7 +12547,7 @@ async function serveGallery(opts) {
12116
12547
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
12117
12548
  if (!asset) return fail(404, "no such review asset");
12118
12549
  try {
12119
- const bytes = await readFile19(asset.path);
12550
+ const bytes = await readFile20(asset.path);
12120
12551
  res.writeHead(200, {
12121
12552
  "Content-Type": asset.contentType,
12122
12553
  "Cache-Control": "no-store",
@@ -12134,6 +12565,32 @@ async function serveGallery(opts) {
12134
12565
  if (!opts.generate) return fail(404, "generation is off");
12135
12566
  return json(200, opts.generate.status());
12136
12567
  }
12568
+ if (url2.pathname === "/api/editor") {
12569
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12570
+ try {
12571
+ return json(200, await opts.editor.status());
12572
+ } catch (err) {
12573
+ return reportError("editor status", err);
12574
+ }
12575
+ }
12576
+ const editorMatch = /^\/editor\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(url2.pathname);
12577
+ if (editorMatch) {
12578
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12579
+ const file = await opts.editor.file(editorMatch[1], editorMatch[2]);
12580
+ if (!file) return fail(404, "that editor file is not installed; check /api/editor");
12581
+ res.writeHead(200, {
12582
+ "Content-Type": file.contentType,
12583
+ "Content-Length": file.bytes.length,
12584
+ // The release tag is in the path, so these bytes never change under
12585
+ // this URL; the 40 MB wasm should come from the browser cache.
12586
+ "Cache-Control": "public, max-age=31536000, immutable",
12587
+ "X-Content-Type-Options": "nosniff",
12588
+ "Cross-Origin-Resource-Policy": "same-origin",
12589
+ ...editorMatch[2] === "index.html" ? { "Content-Security-Policy": EDITOR_CSP } : {}
12590
+ });
12591
+ res.end(req.method === "HEAD" ? void 0 : file.bytes);
12592
+ return;
12593
+ }
12137
12594
  try {
12138
12595
  if (url2.pathname === "/") {
12139
12596
  const { snapshot } = await load();
@@ -12145,7 +12602,8 @@ async function serveGallery(opts) {
12145
12602
  res.end(renderGallery(snapshot, {
12146
12603
  ...session ? { session } : {},
12147
12604
  editable: Boolean(opts.edit),
12148
- generation: Boolean(opts.generate)
12605
+ generation: Boolean(opts.generate),
12606
+ editor: Boolean(opts.editor)
12149
12607
  }));
12150
12608
  return;
12151
12609
  }
@@ -12165,7 +12623,7 @@ async function serveGallery(opts) {
12165
12623
  if (!asset) return fail(404, "no such gallery media");
12166
12624
  let bytes;
12167
12625
  try {
12168
- bytes = await readFile19(asset.path);
12626
+ bytes = await readFile20(asset.path);
12169
12627
  } catch {
12170
12628
  return fail(404, "gallery media is no longer on disk; refresh the page");
12171
12629
  }
@@ -12211,16 +12669,16 @@ async function serveGallery(opts) {
12211
12669
 
12212
12670
  // src/gallery/generate.ts
12213
12671
  import { randomBytes as randomBytes2 } from "crypto";
12214
- import { z as z9 } from "zod";
12215
- var GenerateRequestSchema = z9.object({
12216
- project: z9.string().min(1).optional(),
12217
- keys: z9.array(z9.string().min(1)).min(1).max(500),
12672
+ import { z as z10 } from "zod";
12673
+ var GenerateRequestSchema = z10.object({
12674
+ project: z10.string().min(1).optional(),
12675
+ keys: z10.array(z10.string().min(1)).min(1).max(500),
12218
12676
  /** Regenerate work that is up to date, as `gen --force` does. */
12219
- force: z9.boolean().optional(),
12677
+ force: z10.boolean().optional(),
12220
12678
  /** Advance in-flight, review, or selected work without submitting anything. */
12221
- resume: z9.boolean().optional(),
12679
+ resume: z10.boolean().optional(),
12222
12680
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
12223
- refresh: z9.boolean().optional()
12681
+ refresh: z10.boolean().optional()
12224
12682
  }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
12225
12683
  var GenerateRequestError = class extends Error {
12226
12684
  constructor(message6, status = 400) {
@@ -12475,7 +12933,7 @@ function createGenerateHandlers(opts) {
12475
12933
  }
12476
12934
 
12477
12935
  // src/pipeline/salvage.ts
12478
- import { readFile as readFile20 } from "fs/promises";
12936
+ import { readFile as readFile21 } from "fs/promises";
12479
12937
  import { existsSync as existsSync20 } from "fs";
12480
12938
  import path25 from "path";
12481
12939
  function providerClaimId(provider, objectId) {
@@ -12487,7 +12945,7 @@ async function loadClaims(lockPaths, opts = {}) {
12487
12945
  if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
12488
12946
  let parsed;
12489
12947
  try {
12490
- parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
12948
+ parsed = parseLock(JSON.parse(await readFile21(p, "utf8")));
12491
12949
  } catch {
12492
12950
  throw new Error(`Claim lockfile is malformed: ${p}`);
12493
12951
  }
@@ -12669,7 +13127,7 @@ async function applyTags(provider, decisions, existing, opts = {}) {
12669
13127
  }
12670
13128
 
12671
13129
  // src/pick/salvage-server.ts
12672
- import { mkdir as mkdir7, writeFile as writeFile9, readFile as readFile21 } from "fs/promises";
13130
+ import { mkdir as mkdir7, writeFile as writeFile10, readFile as readFile22 } from "fs/promises";
12673
13131
  import path26 from "path";
12674
13132
 
12675
13133
  // src/pick/salvage-sheet.ts
@@ -12867,7 +13325,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12867
13325
  const rel = path26.join("_salvaged", `${assetId}.png`);
12868
13326
  const outFile = path26.resolve(ctx.importDir, rel);
12869
13327
  await mkdir7(path26.dirname(outFile), { recursive: true });
12870
- await writeFile9(outFile, buf);
13328
+ await writeFile10(outFile, buf);
12871
13329
  ctx.manifest.assets[assetId] = {
12872
13330
  prompt: orphan.prompt,
12873
13331
  promptByStyle: {},
@@ -12923,9 +13381,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12923
13381
  }
12924
13382
  }
12925
13383
  await applyTags(provider, decisions, existingTags, { onProgress: log });
12926
- const raw = JSON.parse(await readFile21(ctx.manifestPath, "utf8"));
13384
+ const raw = JSON.parse(await readFile22(ctx.manifestPath, "utf8"));
12927
13385
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
12928
- await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
13386
+ await writeFile10(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12929
13387
  await saveLock(ctx.lockPath, ctx.lock);
12930
13388
  return result;
12931
13389
  }