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.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()
9601
+ }).strict(),
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}$/)
9516
9608
  }).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}$/)
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)`);
@@ -9570,6 +9678,7 @@ function createGalleryEditHandler(opts) {
9570
9678
  }
9571
9679
 
9572
9680
  // src/gallery/snapshot.ts
9681
+ var PROJECT_FILE_TYPE = "application/octet-stream";
9573
9682
  function galleryMediaId(absolutePath) {
9574
9683
  return createHash4("sha256").update(path24.resolve(absolutePath)).digest("hex").slice(0, 24);
9575
9684
  }
@@ -9689,7 +9798,7 @@ async function buildGallerySnapshot(opts) {
9689
9798
  const media = /* @__PURE__ */ new Map();
9690
9799
  const plan = await buildPlan(specs, lock);
9691
9800
  const items = [];
9692
- const manifestText = await readFile18(loaded.path, "utf8");
9801
+ const manifestText = await readFile19(loaded.path, "utf8");
9693
9802
  const manifestSha256 = sha256(manifestText);
9694
9803
  let rawStyles = {};
9695
9804
  try {
@@ -9704,6 +9813,7 @@ async function buildGallerySnapshot(opts) {
9704
9813
  let outputs;
9705
9814
  let edit = null;
9706
9815
  let editStatus = null;
9816
+ let editMeta = null;
9707
9817
  if (entry && entry.outputs.length) {
9708
9818
  outputs = await Promise.all(
9709
9819
  entry.outputs.map(
@@ -9715,7 +9825,27 @@ async function buildGallerySnapshot(opts) {
9715
9825
  const editSha = existsSync19(editPath) ? await sha256File(editPath) : null;
9716
9826
  edit = await describeOutput(media, root, editPath, { sha256: editSha });
9717
9827
  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";
9828
+ const companion = await readHandEditCompanion(editPath);
9829
+ if (companion) {
9830
+ const projectPath = handEditProjectPath(editPath);
9831
+ const project = companion.project && existsSync19(projectPath) ? await fileInfo(projectPath) : null;
9832
+ let projectUrl = null;
9833
+ if (project) {
9834
+ const id = galleryMediaId(projectPath);
9835
+ media.set(id, { path: projectPath, contentType: PROJECT_FILE_TYPE });
9836
+ projectUrl = `${galleryMediaRoute(id)}?v=${project.bytes}-${Date.parse(project.modifiedAt).toString(36)}`;
9837
+ }
9838
+ editMeta = {
9839
+ editor: companion.editor,
9840
+ savedAt: companion.savedAt,
9841
+ basedOn: companion.basedOn,
9842
+ changedSince: editSha !== null && editSha !== companion.sha256,
9843
+ project: project ? portableOutputPath(projectPath, root) : null,
9844
+ projectUrl
9845
+ };
9846
+ }
9847
+ 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);
9848
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
9719
9849
  }
9720
9850
  } else if (spec.source) {
9721
9851
  outputs = [await describeOutput(media, root, path24.resolve(root, spec.source))];
@@ -9765,6 +9895,7 @@ async function buildGallerySnapshot(opts) {
9765
9895
  source: spec.source ?? null,
9766
9896
  edit,
9767
9897
  editStatus,
9898
+ editMeta,
9768
9899
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9769
9900
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9770
9901
  tags: spec.tags,
@@ -9821,6 +9952,7 @@ async function buildGallerySnapshot(opts) {
9821
9952
  source: null,
9822
9953
  edit: null,
9823
9954
  editStatus: null,
9955
+ editMeta: null,
9824
9956
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
9825
9957
  refreshable: false,
9826
9958
  tags: [],
@@ -9962,7 +10094,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9962
10094
  root: loaded.root,
9963
10095
  provider: loaded.manifest.provider,
9964
10096
  account: project.account ?? null,
9965
- manifestSha256: manifestSha256 ?? sha256(await readFile18(manifestPath, "utf8")),
10097
+ manifestSha256: manifestSha256 ?? sha256(await readFile19(manifestPath, "utf8")),
9966
10098
  entries: Object.keys(lock.entries).length,
9967
10099
  items: projectItems.length,
9968
10100
  spendByUnit: spendByUnit(lock),
@@ -10022,6 +10154,7 @@ function renderGallery(snapshot, opts = {}) {
10022
10154
  const session = JSON.stringify(opts.session ?? null);
10023
10155
  const editable = JSON.stringify(Boolean(opts.editable && opts.session));
10024
10156
  const generation = JSON.stringify(Boolean(opts.generation && opts.session));
10157
+ const editor = JSON.stringify(Boolean(opts.editor && opts.session));
10025
10158
  const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
10026
10159
  return `<!doctype html>
10027
10160
  <html lang="en">
@@ -10227,6 +10360,15 @@ function renderGallery(snapshot, opts = {}) {
10227
10360
  .job .acts button { padding:3px 9px; font-size:12px; }
10228
10361
  .job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
10229
10362
  font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
10363
+ #tools { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; }
10364
+ #tools:empty { display:none; }
10365
+ .tool { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
10366
+ grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
10367
+ .tool .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
10368
+ .tool .last { color:var(--dim); min-width:0; }
10369
+ .tool .acts { display:flex; gap:6px; }
10370
+ .tool .acts button { padding:3px 9px; font-size:12px; }
10371
+ .tool progress { grid-column:1 / -1; width:100%; height:6px; accent-color:var(--accent); }
10230
10372
  .budget { color:var(--dim); }
10231
10373
  .budget b { color:var(--text); }
10232
10374
  .dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
@@ -10247,6 +10389,16 @@ function renderGallery(snapshot, opts = {}) {
10247
10389
  border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
10248
10390
  .sheet.review-host { width:min(1180px, 94vw); }
10249
10391
  .sheet.compare-host { width:min(1500px, 96vw); }
10392
+ .sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
10393
+ .sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
10394
+ .sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
10395
+ .sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
10396
+ .sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
10397
+ .sheet.editor-host .stage { position:relative; min-height:0; }
10398
+ .sheet.editor-host .stage iframe { position:absolute; inset:0; }
10399
+ .sheet.editor-host .loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--bg);
10400
+ color:var(--dim); text-align:center; padding:24px; }
10401
+ .sheet.editor-host .loading b { display:block; color:var(--text); margin-bottom:6px; }
10250
10402
  .sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
10251
10403
  background:var(--panel); font-size:13px; flex-wrap:wrap; }
10252
10404
  .sheet .rbar span { color:var(--dim); }
@@ -10285,7 +10437,7 @@ function renderGallery(snapshot, opts = {}) {
10285
10437
  .tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
10286
10438
  .tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
10287
10439
  .tray > button { padding:5px 11px; font-size:12.5px; }
10288
- @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10440
+ @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
10289
10441
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10290
10442
  .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10291
10443
  .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
@@ -10333,6 +10485,7 @@ function renderGallery(snapshot, opts = {}) {
10333
10485
  <div class="totals" id="totals"></div>
10334
10486
  <div class="chips" id="chips"></div>
10335
10487
  <div id="jobs"></div>
10488
+ <div id="tools"></div>
10336
10489
  </header>
10337
10490
  <main id="root"></main>
10338
10491
  <footer>
@@ -10351,6 +10504,7 @@ const INITIAL = ${data};
10351
10504
  const SESSION = ${session};
10352
10505
  const EDITABLE = ${editable};
10353
10506
  const GENERATION = ${generation};
10507
+ const EDITOR = ${editor};
10354
10508
  let snap = INITIAL;
10355
10509
  const STATE_TONE = {
10356
10510
  ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
@@ -10518,6 +10672,92 @@ function renderJobs() {
10518
10672
  host.append(row);
10519
10673
  }
10520
10674
  }
10675
+ // ---- the in-browser editor (only when the server serves one) -------------
10676
+ // The build is a 46 MB web export fetched once into a user-level cache and
10677
+ // verified against hashes this PixelKiln release carries; the page only
10678
+ // reports and asks, the server downloads. Nothing is fetched without a click.
10679
+
10680
+ let ED = null;
10681
+ let edTimer = null;
10682
+ const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
10683
+ async function pollEditor() {
10684
+ if (!EDITOR) return;
10685
+ try {
10686
+ const res = await fetch('/api/editor', { cache: 'no-store' });
10687
+ if (!res.ok) throw new Error(await res.text());
10688
+ ED = await res.json();
10689
+ } catch (err) {
10690
+ ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
10691
+ }
10692
+ renderTools();
10693
+ clearTimeout(edTimer);
10694
+ if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
10695
+ else if (ui.open) renderDrawer();
10696
+ }
10697
+ async function installEditor() {
10698
+ try {
10699
+ const res = await fetch('/api/editor/install', {
10700
+ method: 'POST',
10701
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
10702
+ body: '{}',
10703
+ });
10704
+ if (!res.ok) throw new Error(await res.text());
10705
+ ED = await res.json();
10706
+ } catch (err) {
10707
+ ED = Object.assign({}, ED, { error: err.message });
10708
+ }
10709
+ renderTools();
10710
+ clearTimeout(edTimer);
10711
+ edTimer = setTimeout(pollEditor, 500);
10712
+ }
10713
+ function renderTools() {
10714
+ const host = $('tools');
10715
+ host.textContent = '';
10716
+ if (!EDITOR || !ED) return;
10717
+ const row = el('div', 'tool');
10718
+ const ph = el('span', 'ph');
10719
+ const last = el('span', 'last');
10720
+ const acts = el('div', 'acts');
10721
+ const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
10722
+ if (ED.installing) {
10723
+ const p = ED.installing;
10724
+ ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
10725
+ last.textContent = name + ': fetching ' + (p.file || 'the build') + ' \u2014 ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
10726
+ row.append(ph, last, acts);
10727
+ const bar = el('progress');
10728
+ if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
10729
+ row.append(bar);
10730
+ } else if (ED.installed) {
10731
+ ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
10732
+ last.textContent = name + ' is installed and verified; a record\u2019s Hand edit section opens sprites in it.';
10733
+ last.title = ED.dir || '';
10734
+ const b = el('button', null, 'Open editor');
10735
+ b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
10736
+ acts.append(b);
10737
+ row.append(ph, last, acts);
10738
+ } else if (ED.error) {
10739
+ ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
10740
+ last.textContent = name + ': ' + ED.error;
10741
+ const b = el('button', 'primary', 'Retry install');
10742
+ b.type = 'button'; b.onclick = installEditor;
10743
+ acts.append(b);
10744
+ row.append(ph, last, acts);
10745
+ } else if (!ED.release) {
10746
+ ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
10747
+ last.textContent = name + ': this PixelKiln version pins no published build.';
10748
+ row.append(ph, last, acts);
10749
+ } else {
10750
+ ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
10751
+ last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
10752
+ ED.release + ', verified against the hashes this version carries, and kept outside the project.';
10753
+ const b = el('button', 'primary', 'Install editor');
10754
+ b.type = 'button'; b.onclick = installEditor;
10755
+ acts.append(b);
10756
+ row.append(ph, last, acts);
10757
+ }
10758
+ host.append(row);
10759
+ }
10760
+
10521
10761
  function budgetLine() {
10522
10762
  const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
10523
10763
  const parts = [];
@@ -10624,8 +10864,183 @@ function openReview(jobId) {
10624
10864
  host.append(scrim, panel);
10625
10865
  }
10626
10866
  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);
10867
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
10868
+ if (e.data.type === 'pixelkiln:review-applied') {
10869
+ setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
10870
+ return;
10871
+ }
10872
+ if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
10873
+ });
10874
+
10875
+ // ---- the in-browser editor sheet -------------------------------------------
10876
+ // The page is the protocol host: it hands the editor the PNG and palette,
10877
+ // asks it for the image back, and does the authenticated write itself. The
10878
+ // iframe never sees the session token, and the server validates, sizes, and
10879
+ // declares the file the same way pixelkiln edit does.
10880
+
10881
+ let SHEET = null;
10882
+ const editorSource = (item) => item.edit && item.edit.exists ? item.edit
10883
+ : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
10884
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
10885
+ !(item.outputs.length > 1);
10886
+ const toBase64 = (buf) => new Promise((resolve, reject) => {
10887
+ const r = new FileReader();
10888
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
10889
+ r.onerror = () => reject(r.error);
10890
+ r.readAsDataURL(new Blob([buf]));
10891
+ });
10892
+ function sheetStatus(tone, text) {
10893
+ if (!SHEET) return;
10894
+ SHEET.status.textContent = '';
10895
+ SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
10896
+ }
10897
+ function sheetButtons() {
10898
+ if (!SHEET) return;
10899
+ const busy = !SHEET.opened || SHEET.pending !== null;
10900
+ SHEET.save.disabled = busy || !SHEET.dirty;
10901
+ SHEET.saveClose.disabled = busy;
10902
+ }
10903
+ function openEditorSheet(item) {
10904
+ if (SHEET) return;
10905
+ const src = editorSource(item);
10906
+ const host = $('dialog-host');
10907
+ host.textContent = '';
10908
+ const scrim = el('div', 'sheet-scrim');
10909
+ scrim.onclick = () => closeEditorSheet();
10910
+ const panel = el('div', 'sheet editor-host');
10911
+ const bar = el('div', 'rbar');
10912
+ const close = el('button', null, 'Close'); close.type = 'button';
10913
+ close.onclick = () => closeEditorSheet();
10914
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
10915
+ const status = el('span', 'st');
10916
+ const acts = el('div', 'acts');
10917
+ const msg = el('span', 'msg');
10918
+ const save = el('button', 'primary', 'Save to project'); save.type = 'button';
10919
+ save.onclick = () => requestEditorSave(false);
10920
+ const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
10921
+ saveClose.onclick = () => requestEditorSave(true);
10922
+ acts.append(msg, save, saveClose);
10923
+ bar.append(close, title, status, acts);
10924
+ const stage = el('div', 'stage');
10925
+ const frame = el('iframe');
10926
+ frame.title = 'Pixelorama';
10927
+ frame.src = ED.url;
10928
+ const loading = el('div', 'loading');
10929
+ const loadText = el('div');
10930
+ 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.'));
10931
+ loading.append(loadText);
10932
+ stage.append(frame, loading);
10933
+ panel.append(bar, stage);
10934
+ host.append(scrim, panel);
10935
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false };
10936
+ sheetStatus('cool', 'loading');
10937
+ sheetButtons();
10938
+ close.focus({ preventScroll: true });
10939
+ }
10940
+ // The edit file is what the editor gets; when a browser save kept the layered
10941
+ // project beside it, that goes along too and the editor restores the layers,
10942
+ // falling back to the flattened PNG if the file cannot be read.
10943
+ async function sendOpen() {
10944
+ const { item, source } = SHEET;
10945
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
10946
+ const projectUrl = source === item.edit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
10947
+ let png, pxo = null;
10948
+ try {
10949
+ const res = await fetch(source.url, { cache: 'no-store' });
10950
+ if (!res.ok) throw new Error(await res.text());
10951
+ png = await res.arrayBuffer();
10952
+ if (projectUrl) {
10953
+ const p = await fetch(projectUrl, { cache: 'no-store' });
10954
+ if (p.ok) pxo = await p.arrayBuffer();
10955
+ }
10956
+ } catch (err) {
10957
+ if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
10958
+ return;
10959
+ }
10960
+ if (!SHEET) return;
10961
+ const request = 'open-' + (++SHEET.requests);
10962
+ SHEET.sentProject = !!pxo;
10963
+ const message = {
10964
+ type: 'pixelkiln:open', request,
10965
+ asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
10966
+ png, palette: style ? style.palette : [],
10967
+ };
10968
+ const transfer = [png];
10969
+ if (pxo) { message.pxo = pxo; transfer.push(pxo); }
10970
+ SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
10971
+ sheetStatus('cool', 'opening');
10972
+ }
10973
+ function requestEditorSave(close) {
10974
+ if (!SHEET || !SHEET.opened || SHEET.pending) return;
10975
+ const request = 'save-' + (++SHEET.requests);
10976
+ SHEET.pending = { request, close };
10977
+ SHEET.msg.textContent = '';
10978
+ sheetStatus('cool', 'saving');
10979
+ sheetButtons();
10980
+ SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
10981
+ }
10982
+ async function onEditorMessage(m) {
10983
+ switch (m.type) {
10984
+ case 'pixelkiln:ready':
10985
+ SHEET.ready = m;
10986
+ if (m.version !== ED.protocol) {
10987
+ SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
10988
+ sheetStatus('bad', 'mismatch');
10989
+ return;
10990
+ }
10991
+ sendOpen();
10992
+ break;
10993
+ case 'pixelkiln:opened':
10994
+ SHEET.opened = true;
10995
+ SHEET.loading.remove();
10996
+ if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file with its ' + (m.layers === 1 ? 'layer' : m.layers + ' layers') + ' restored');
10997
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file; its layer file could not be opened');
10998
+ else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
10999
+ sheetButtons();
11000
+ break;
11001
+ case 'pixelkiln:dirty':
11002
+ SHEET.dirty = !!m.dirty;
11003
+ if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
11004
+ sheetButtons();
11005
+ break;
11006
+ case 'pixelkiln:save': {
11007
+ const pending = SHEET.pending;
11008
+ if (!pending || pending.request !== m.request) return;
11009
+ try {
11010
+ const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
11011
+ if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
11012
+ await postHandEdit(SHEET.item, 'save-edit', body);
11013
+ const saved = snap.items.find((i) => i.id === SHEET.item.id);
11014
+ if (saved) SHEET.item = saved;
11015
+ SHEET.dirty = false;
11016
+ SHEET.pending = null;
11017
+ 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.' };
11018
+ render();
11019
+ if (pending.close) { closeEditorSheet(true); return; }
11020
+ sheetStatus('ok', 'saved');
11021
+ } catch (err) {
11022
+ SHEET.pending = null;
11023
+ SHEET.msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh, then save again.' : '');
11024
+ sheetStatus('bad', 'not saved');
11025
+ }
11026
+ sheetButtons();
11027
+ break;
11028
+ }
11029
+ case 'pixelkiln:error':
11030
+ SHEET.msg.textContent = m.message || 'The editor reported an error.';
11031
+ if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
11032
+ break;
11033
+ }
11034
+ }
11035
+ function closeEditorSheet(force) {
11036
+ if (!SHEET) return;
11037
+ if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
11038
+ SHEET = null;
11039
+ $('dialog-host').textContent = '';
11040
+ refresh();
11041
+ }
11042
+ window.addEventListener('beforeunload', (e) => {
11043
+ if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
10629
11044
  });
10630
11045
 
10631
11046
  function visibleItems() {
@@ -11201,6 +11616,11 @@ function handEditSection(item) {
11201
11616
  row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11202
11617
  if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11203
11618
  row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11619
+ if (item.editMeta) {
11620
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
11621
+ if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
11622
+ if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
11623
+ }
11204
11624
  s.append(dl);
11205
11625
  } else {
11206
11626
  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 +11629,7 @@ function handEditSection(item) {
11209
11629
  const acts = el('div', 'hand-actions');
11210
11630
  const msg = el('span', 'msg');
11211
11631
  const go = async (label, action, extra, done) => {
11212
- const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
11632
+ const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
11213
11633
  b.onclick = async () => {
11214
11634
  b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
11215
11635
  try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
@@ -11217,8 +11637,20 @@ function handEditSection(item) {
11217
11637
  };
11218
11638
  acts.append(b);
11219
11639
  };
11640
+ if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
11641
+ if (ED.installed) {
11642
+ const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
11643
+ b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
11644
+ b.onclick = () => openEditorSheet(item);
11645
+ acts.append(b);
11646
+ } else if (ED.release && !ED.installing) {
11647
+ const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
11648
+ b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
11649
+ acts.append(b);
11650
+ }
11651
+ }
11220
11652
  if (item.edit) {
11221
- go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11653
+ go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11222
11654
  go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11223
11655
  } else if (canStart) {
11224
11656
  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 +12377,7 @@ document.addEventListener('keydown', (e) => {
11945
12377
  if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
11946
12378
  if (e.key === 'Escape') {
11947
12379
  if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
12380
+ if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
11948
12381
  if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
11949
12382
  if (ui.open) { e.preventDefault(); closeItem(); }
11950
12383
  return;
@@ -11966,6 +12399,7 @@ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
11966
12399
  render();
11967
12400
  if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
11968
12401
  if (GENERATION) pollJobs();
12402
+ if (EDITOR) pollEditor();
11969
12403
  if (ui.compare.length >= 2 && !ui.open) openCompare();
11970
12404
  </script>
11971
12405
  </body>
@@ -11977,8 +12411,17 @@ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt
11977
12411
  import { spawn as spawn3 } from "child_process";
11978
12412
  import { randomBytes } from "crypto";
11979
12413
  import { createServer as createServer2 } from "http";
11980
- import { readFile as readFile19 } from "fs/promises";
11981
- var MAX_EDIT_BYTES = 64 * 1024;
12414
+ import { readFile as readFile20 } from "fs/promises";
12415
+ var MAX_EDIT_BYTES = 48 * 1024 * 1024;
12416
+ var EDITOR_CSP = [
12417
+ "default-src 'self'",
12418
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
12419
+ "style-src 'self' 'unsafe-inline'",
12420
+ "img-src 'self' data: blob:",
12421
+ "worker-src 'self' blob:",
12422
+ "connect-src 'self'",
12423
+ "frame-ancestors 'self'"
12424
+ ].join("; ");
11982
12425
  async function readJsonBody(req) {
11983
12426
  const chunks = [];
11984
12427
  let bytes = 0;
@@ -11996,7 +12439,7 @@ async function readJsonBody(req) {
11996
12439
  async function serveGallery(opts) {
11997
12440
  const log = opts.onProgress ?? (() => {
11998
12441
  });
11999
- const session = opts.edit || opts.generate ? randomBytes(16).toString("hex") : null;
12442
+ const session = opts.edit || opts.generate || opts.editor ? randomBytes(16).toString("hex") : null;
12000
12443
  let media = /* @__PURE__ */ new Map();
12001
12444
  let loading = null;
12002
12445
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -12071,6 +12514,17 @@ async function serveGallery(opts) {
12071
12514
  }
12072
12515
  return;
12073
12516
  }
12517
+ if (req.method === "POST" && url2.pathname === "/api/editor/install") {
12518
+ if (!opts.editor || !session) return fail(405, "the in-browser editor is off for this gallery");
12519
+ try {
12520
+ const body = await guardedBody("editor installs");
12521
+ if (body === void 0) return;
12522
+ json(202, await opts.editor.install());
12523
+ } catch (err) {
12524
+ reportError("editor install", err);
12525
+ }
12526
+ return;
12527
+ }
12074
12528
  const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
12075
12529
  if (reviewMatch && opts.generate) {
12076
12530
  const [, jobId, rest = ""] = reviewMatch;
@@ -12116,7 +12570,7 @@ async function serveGallery(opts) {
12116
12570
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
12117
12571
  if (!asset) return fail(404, "no such review asset");
12118
12572
  try {
12119
- const bytes = await readFile19(asset.path);
12573
+ const bytes = await readFile20(asset.path);
12120
12574
  res.writeHead(200, {
12121
12575
  "Content-Type": asset.contentType,
12122
12576
  "Cache-Control": "no-store",
@@ -12134,6 +12588,32 @@ async function serveGallery(opts) {
12134
12588
  if (!opts.generate) return fail(404, "generation is off");
12135
12589
  return json(200, opts.generate.status());
12136
12590
  }
12591
+ if (url2.pathname === "/api/editor") {
12592
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12593
+ try {
12594
+ return json(200, await opts.editor.status());
12595
+ } catch (err) {
12596
+ return reportError("editor status", err);
12597
+ }
12598
+ }
12599
+ const editorMatch = /^\/editor\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(url2.pathname);
12600
+ if (editorMatch) {
12601
+ if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
12602
+ const file = await opts.editor.file(editorMatch[1], editorMatch[2]);
12603
+ if (!file) return fail(404, "that editor file is not installed; check /api/editor");
12604
+ res.writeHead(200, {
12605
+ "Content-Type": file.contentType,
12606
+ "Content-Length": file.bytes.length,
12607
+ // The release tag is in the path, so these bytes never change under
12608
+ // this URL; the 40 MB wasm should come from the browser cache.
12609
+ "Cache-Control": "public, max-age=31536000, immutable",
12610
+ "X-Content-Type-Options": "nosniff",
12611
+ "Cross-Origin-Resource-Policy": "same-origin",
12612
+ ...editorMatch[2] === "index.html" ? { "Content-Security-Policy": EDITOR_CSP } : {}
12613
+ });
12614
+ res.end(req.method === "HEAD" ? void 0 : file.bytes);
12615
+ return;
12616
+ }
12137
12617
  try {
12138
12618
  if (url2.pathname === "/") {
12139
12619
  const { snapshot } = await load();
@@ -12145,7 +12625,8 @@ async function serveGallery(opts) {
12145
12625
  res.end(renderGallery(snapshot, {
12146
12626
  ...session ? { session } : {},
12147
12627
  editable: Boolean(opts.edit),
12148
- generation: Boolean(opts.generate)
12628
+ generation: Boolean(opts.generate),
12629
+ editor: Boolean(opts.editor)
12149
12630
  }));
12150
12631
  return;
12151
12632
  }
@@ -12165,7 +12646,7 @@ async function serveGallery(opts) {
12165
12646
  if (!asset) return fail(404, "no such gallery media");
12166
12647
  let bytes;
12167
12648
  try {
12168
- bytes = await readFile19(asset.path);
12649
+ bytes = await readFile20(asset.path);
12169
12650
  } catch {
12170
12651
  return fail(404, "gallery media is no longer on disk; refresh the page");
12171
12652
  }
@@ -12211,16 +12692,16 @@ async function serveGallery(opts) {
12211
12692
 
12212
12693
  // src/gallery/generate.ts
12213
12694
  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),
12695
+ import { z as z10 } from "zod";
12696
+ var GenerateRequestSchema = z10.object({
12697
+ project: z10.string().min(1).optional(),
12698
+ keys: z10.array(z10.string().min(1)).min(1).max(500),
12218
12699
  /** Regenerate work that is up to date, as `gen --force` does. */
12219
- force: z9.boolean().optional(),
12700
+ force: z10.boolean().optional(),
12220
12701
  /** Advance in-flight, review, or selected work without submitting anything. */
12221
- resume: z9.boolean().optional(),
12702
+ resume: z10.boolean().optional(),
12222
12703
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
12223
- refresh: z9.boolean().optional()
12704
+ refresh: z10.boolean().optional()
12224
12705
  }).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
12225
12706
  var GenerateRequestError = class extends Error {
12226
12707
  constructor(message6, status = 400) {
@@ -12475,7 +12956,7 @@ function createGenerateHandlers(opts) {
12475
12956
  }
12476
12957
 
12477
12958
  // src/pipeline/salvage.ts
12478
- import { readFile as readFile20 } from "fs/promises";
12959
+ import { readFile as readFile21 } from "fs/promises";
12479
12960
  import { existsSync as existsSync20 } from "fs";
12480
12961
  import path25 from "path";
12481
12962
  function providerClaimId(provider, objectId) {
@@ -12487,7 +12968,7 @@ async function loadClaims(lockPaths, opts = {}) {
12487
12968
  if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
12488
12969
  let parsed;
12489
12970
  try {
12490
- parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
12971
+ parsed = parseLock(JSON.parse(await readFile21(p, "utf8")));
12491
12972
  } catch {
12492
12973
  throw new Error(`Claim lockfile is malformed: ${p}`);
12493
12974
  }
@@ -12669,7 +13150,7 @@ async function applyTags(provider, decisions, existing, opts = {}) {
12669
13150
  }
12670
13151
 
12671
13152
  // src/pick/salvage-server.ts
12672
- import { mkdir as mkdir7, writeFile as writeFile9, readFile as readFile21 } from "fs/promises";
13153
+ import { mkdir as mkdir7, writeFile as writeFile10, readFile as readFile22 } from "fs/promises";
12673
13154
  import path26 from "path";
12674
13155
 
12675
13156
  // src/pick/salvage-sheet.ts
@@ -12867,7 +13348,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12867
13348
  const rel = path26.join("_salvaged", `${assetId}.png`);
12868
13349
  const outFile = path26.resolve(ctx.importDir, rel);
12869
13350
  await mkdir7(path26.dirname(outFile), { recursive: true });
12870
- await writeFile9(outFile, buf);
13351
+ await writeFile10(outFile, buf);
12871
13352
  ctx.manifest.assets[assetId] = {
12872
13353
  prompt: orphan.prompt,
12873
13354
  promptByStyle: {},
@@ -12923,9 +13404,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12923
13404
  }
12924
13405
  }
12925
13406
  await applyTags(provider, decisions, existingTags, { onProgress: log });
12926
- const raw = JSON.parse(await readFile21(ctx.manifestPath, "utf8"));
13407
+ const raw = JSON.parse(await readFile22(ctx.manifestPath, "utf8"));
12927
13408
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
12928
- await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
13409
+ await writeFile10(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12929
13410
  await saveLock(ctx.lockPath, ctx.lock);
12930
13411
  return result;
12931
13412
  }