pixelkiln 0.22.0 → 0.24.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
@@ -482,6 +482,15 @@ var AssetSchema = import_zod.z.object({
482
482
  * that wasn't. `prompt` still records what was asked for.
483
483
  */
484
484
  source: import_zod.z.string().optional(),
485
+ /**
486
+ * Per-style replacement for `source`, keyed by style id.
487
+ *
488
+ * A hand edit belongs to one generation: the same asset generated in two
489
+ * styles is two different pictures, so the touch-up of one must not be
490
+ * placed for the other. `pixelkiln edit` writes here when an asset is in
491
+ * more than one style, and to `source` when it is in exactly one.
492
+ */
493
+ sourceByStyle: import_zod.z.record(import_zod.z.string().min(1)).default({}),
485
494
  /** Controlled generation derived from another asset in the same style. */
486
495
  revision: RevisionSchema.optional(),
487
496
  /**
@@ -516,7 +525,7 @@ var AssetSchema = import_zod.z.object({
516
525
  import_zod.z.array(import_zod.z.union([import_zod.z.string(), import_zod.z.number().finite(), import_zod.z.boolean()])).min(2).max(64)
517
526
  ])).default({})
518
527
  }).strict().superRefine((asset, context) => {
519
- if (asset.source && asset.revision) {
528
+ if ((asset.source || Object.keys(asset.sourceByStyle).length) && asset.revision) {
520
529
  context.addIssue({
521
530
  code: import_zod.z.ZodIssueCode.custom,
522
531
  message: "source and revision are mutually exclusive",
@@ -759,11 +768,11 @@ var PixelLabClient = class {
759
768
  * common than the failure mode of retrying (a duplicate object), and a
760
769
  * duplicate is visible and free to delete whereas a silent gap is neither.
761
770
  */
762
- async request(path25, init, attempt = 0) {
771
+ async request(path27, init, attempt = 0) {
763
772
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
764
773
  let res;
765
774
  try {
766
- res = await fetch(`${BASE}${path25}`, {
775
+ res = await fetch(`${BASE}${path27}`, {
767
776
  ...init,
768
777
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
769
778
  headers: {
@@ -775,24 +784,24 @@ var PixelLabClient = class {
775
784
  } catch (err) {
776
785
  if (attempt < MAX_RETRIES) {
777
786
  await sleep(backoffMs(attempt));
778
- return this.request(path25, init, attempt + 1);
787
+ return this.request(path27, init, attempt + 1);
779
788
  }
780
789
  throw err;
781
790
  }
782
791
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
783
792
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
784
793
  await sleep(waitMs);
785
- return this.request(path25, init, attempt + 1);
794
+ return this.request(path27, init, attempt + 1);
786
795
  }
787
796
  const text = await res.text();
788
797
  if (!res.ok) {
789
- throw new PixelLabError(`${init?.method ?? "GET"} ${path25} \u2192 ${res.status}`, res.status, text);
798
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path27} \u2192 ${res.status}`, res.status, text);
790
799
  }
791
800
  if (!text) return {};
792
801
  try {
793
802
  return JSON.parse(text);
794
803
  } catch {
795
- throw new Error(`${init?.method ?? "GET"} ${path25} returned invalid JSON`);
804
+ throw new Error(`${init?.method ?? "GET"} ${path27} returned invalid JSON`);
796
805
  }
797
806
  }
798
807
  async balance() {
@@ -1839,9 +1848,9 @@ var RetroDiffusionClient = class {
1839
1848
  }
1840
1849
  return balance;
1841
1850
  }
1842
- async call(path25, init = {}) {
1851
+ async call(path27, init = {}) {
1843
1852
  if (!this.token) throw new Error("RD_API_KEY is not set");
1844
- const response = await this.request(`${this.baseUrl}${path25}`, {
1853
+ const response = await this.request(`${this.baseUrl}${path27}`, {
1845
1854
  ...init,
1846
1855
  headers: {
1847
1856
  "Content-Type": "application/json",
@@ -2644,10 +2653,10 @@ var import_node_fs2 = require("fs");
2644
2653
  function sha256(data) {
2645
2654
  return (0, import_node_crypto2.createHash)("sha256").update(data).digest("hex");
2646
2655
  }
2647
- async function sha256File(path25) {
2656
+ async function sha256File(path27) {
2648
2657
  return new Promise((resolve, reject) => {
2649
2658
  const hash = (0, import_node_crypto2.createHash)("sha256");
2650
- const stream = (0, import_node_fs2.createReadStream)(path25);
2659
+ const stream = (0, import_node_fs2.createReadStream)(path27);
2651
2660
  stream.on("data", (chunk2) => hash.update(chunk2));
2652
2661
  stream.on("error", reject);
2653
2662
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -4102,6 +4111,11 @@ ${issues}`);
4102
4111
  unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
4103
4112
  }
4104
4113
  }
4114
+ for (const styleId of Object.keys(asset.sourceByStyle)) {
4115
+ if (!styleIds.has(styleId)) {
4116
+ unknownReferences.push(`assets.${assetId}.sourceByStyle: unknown style "${styleId}"`);
4117
+ }
4118
+ }
4105
4119
  if (asset.revision) {
4106
4120
  if (!parsed.data.assets[asset.revision.from]) {
4107
4121
  unknownReferences.push(
@@ -4366,7 +4380,7 @@ async function resolveSpecs(loaded, filter) {
4366
4380
  }
4367
4381
  } : {},
4368
4382
  tags,
4369
- source: asset.source,
4383
+ source: asset.sourceByStyle[styleId] ?? asset.source,
4370
4384
  // Revision identity is attached after every dependency in this style
4371
4385
  // has a concrete output target. Finalization below computes the hash.
4372
4386
  specHash: ""
@@ -6971,10 +6985,10 @@ function isSha256Hash(value) {
6971
6985
  function parseCache(value) {
6972
6986
  return HashCacheSchema.parse(value);
6973
6987
  }
6974
- async function loadCache(path25) {
6975
- if (!(0, import_node_fs11.existsSync)(path25)) return { version: 1, hashes: {} };
6988
+ async function loadCache(path27) {
6989
+ if (!(0, import_node_fs11.existsSync)(path27)) return { version: 1, hashes: {} };
6976
6990
  try {
6977
- const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises9.readFile)(path25, "utf8")));
6991
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises9.readFile)(path27, "utf8")));
6978
6992
  if (!parsed.success) return { version: 1, hashes: {} };
6979
6993
  return {
6980
6994
  version: 1,
@@ -6986,18 +7000,18 @@ async function loadCache(path25) {
6986
7000
  return { version: 1, hashes: {} };
6987
7001
  }
6988
7002
  }
6989
- async function saveCache(path25, cache) {
7003
+ async function saveCache(path27, cache) {
6990
7004
  const sorted = {};
6991
7005
  for (const key of Object.keys(cache.hashes).sort()) {
6992
7006
  const hash = cache.hashes[key];
6993
7007
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6994
7008
  sorted[key] = hash;
6995
7009
  }
6996
- await (0, import_promises9.mkdir)(import_node_path14.default.dirname(import_node_path14.default.resolve(path25)), { recursive: true });
6997
- const tmp = `${path25}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7010
+ await (0, import_promises9.mkdir)(import_node_path14.default.dirname(import_node_path14.default.resolve(path27)), { recursive: true });
7011
+ const tmp = `${path27}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6998
7012
  try {
6999
7013
  await (0, import_promises9.writeFile)(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
7000
- await (0, import_promises9.rename)(tmp, path25);
7014
+ await (0, import_promises9.rename)(tmp, path27);
7001
7015
  } finally {
7002
7016
  await (0, import_promises9.rm)(tmp, { force: true });
7003
7017
  }
@@ -7244,16 +7258,24 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
7244
7258
  }
7245
7259
  const prefix = `${styleId}/`;
7246
7260
  const entries = Object.entries(lock.entries).filter(([key]) => key.startsWith(prefix));
7247
- if (!entries.length) {
7261
+ const sources = options.sources ?? {};
7262
+ if (!entries.length && !Object.keys(sources).length) {
7248
7263
  throw new Error(
7249
7264
  `No locked assets for style "${styleId}". Run \`pixelkiln gen --style ${styleId}\` first.`
7250
7265
  );
7251
7266
  }
7252
7267
  const inputs = [];
7253
7268
  const noOutput = [];
7269
+ const locked = /* @__PURE__ */ new Set();
7254
7270
  for (const [key, entry] of entries) {
7255
7271
  const id = key.slice(prefix.length);
7272
+ locked.add(id);
7256
7273
  const outputs = resolveEntryOutputs(entry, id, manifestDir);
7274
+ const source = sources[id];
7275
+ if (source && outputs.length <= 1) {
7276
+ inputs.push({ id, path: import_node_path16.default.resolve(manifestDir, source) });
7277
+ continue;
7278
+ }
7257
7279
  let selected = outputs;
7258
7280
  if (options.outputRoles?.length) {
7259
7281
  selected = outputs.filter(
@@ -7270,6 +7292,9 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
7270
7292
  }
7271
7293
  for (const output of selected) inputs.push({ id: output.id, path: output.absolutePath });
7272
7294
  }
7295
+ for (const [id, source] of Object.entries(sources)) {
7296
+ if (!locked.has(id)) inputs.push({ id, path: import_node_path16.default.resolve(manifestDir, source) });
7297
+ }
7273
7298
  if (!inputs.length) {
7274
7299
  throw new Error(`No readable sprites for style "${styleId}" \u2014 ${noOutput.length} skipped.`);
7275
7300
  }
@@ -8515,6 +8540,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
8515
8540
  assets[asset.id] = {
8516
8541
  prompt: "",
8517
8542
  promptByStyle: {},
8543
+ sourceByStyle: {},
8518
8544
  category: asset.category || void 0,
8519
8545
  file: asset.file,
8520
8546
  tags: [],
@@ -9202,9 +9228,9 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
9202
9228
 
9203
9229
  // src/gallery/snapshot.ts
9204
9230
  var import_node_crypto6 = require("crypto");
9205
- var import_node_fs20 = require("fs");
9206
- var import_promises19 = require("fs/promises");
9207
- var import_node_path23 = __toESM(require("path"), 1);
9231
+ var import_node_fs21 = require("fs");
9232
+ var import_promises20 = require("fs/promises");
9233
+ var import_node_path25 = __toESM(require("path"), 1);
9208
9234
 
9209
9235
  // src/workspace.ts
9210
9236
  var import_promises17 = require("fs/promises");
@@ -9340,6 +9366,10 @@ function validateWorkspace(ws, dir) {
9340
9366
  }
9341
9367
 
9342
9368
  // src/gallery/edit.ts
9369
+ var import_node_path24 = __toESM(require("path"), 1);
9370
+ var import_zod8 = require("zod");
9371
+
9372
+ // src/manifest-edit.ts
9343
9373
  var import_promises18 = require("fs/promises");
9344
9374
  var import_node_path22 = __toESM(require("path"), 1);
9345
9375
  var import_zod7 = require("zod");
@@ -9386,6 +9416,22 @@ var ManifestEditSchema = import_zod7.z.discriminatedUnion("action", [
9386
9416
  expectedSha256: HexSha,
9387
9417
  asset: NewAssetSchema
9388
9418
  }).strict(),
9419
+ import_zod7.z.object({
9420
+ action: import_zod7.z.literal("set-source"),
9421
+ project: import_zod7.z.string().min(1).optional(),
9422
+ assetId: import_zod7.z.string().min(1),
9423
+ styleId: import_zod7.z.string().min(1),
9424
+ /** Manifest-relative path of the art that stands in for this asset in this style. */
9425
+ source: import_zod7.z.string().min(1),
9426
+ expectedSha256: HexSha
9427
+ }).strict(),
9428
+ import_zod7.z.object({
9429
+ action: import_zod7.z.literal("clear-source"),
9430
+ project: import_zod7.z.string().min(1).optional(),
9431
+ assetId: import_zod7.z.string().min(1),
9432
+ styleId: import_zod7.z.string().min(1),
9433
+ expectedSha256: HexSha
9434
+ }).strict(),
9389
9435
  import_zod7.z.object({
9390
9436
  action: import_zod7.z.literal("patch-style"),
9391
9437
  project: import_zod7.z.string().min(1).optional(),
@@ -9398,7 +9444,11 @@ var ManifestEditSchema = import_zod7.z.discriminatedUnion("action", [
9398
9444
  promptPrefix: import_zod7.z.string().optional(),
9399
9445
  promptSuffix: import_zod7.z.string().optional(),
9400
9446
  /** `#rrggbb` values; null or empty clears the style's own palette. */
9401
- palette: import_zod7.z.array(import_zod7.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional()
9447
+ palette: import_zod7.z.array(import_zod7.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
9448
+ /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
9449
+ view: import_zod7.z.string().max(64).nullable().optional(),
9450
+ /** pixflux only; null clears the style's own value. */
9451
+ noBackground: import_zod7.z.boolean().nullable().optional()
9402
9452
  }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
9403
9453
  }).strict()
9404
9454
  ]);
@@ -9432,8 +9482,38 @@ function setOrDelete(target, key, value) {
9432
9482
  if (value === null || value === void 0) delete target[key];
9433
9483
  else target[key] = value;
9434
9484
  }
9485
+ function assetStyles(raw, asset) {
9486
+ const own = Array.isArray(asset.styles) ? asset.styles : [];
9487
+ return own.length ? own : Object.keys(raw.styles ?? {});
9488
+ }
9435
9489
  function applyEdit(raw, edit) {
9436
9490
  raw.assets ??= {};
9491
+ if (edit.action === "set-source" || edit.action === "clear-source") {
9492
+ const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
9493
+ if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
9494
+ if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
9495
+ throw new ManifestEditError(`unknown style "${edit.styleId}"`);
9496
+ }
9497
+ if (!assetStyles(raw, asset2).includes(edit.styleId)) {
9498
+ throw new ManifestEditError(`asset "${edit.assetId}" is not in style "${edit.styleId}"`);
9499
+ }
9500
+ const single = assetStyles(raw, asset2).length === 1;
9501
+ const byStyle = { ...asset2.sourceByStyle ?? {} };
9502
+ if (edit.action === "set-source") {
9503
+ if (single) {
9504
+ asset2.source = edit.source;
9505
+ delete byStyle[edit.styleId];
9506
+ } else {
9507
+ byStyle[edit.styleId] = edit.source;
9508
+ }
9509
+ } else {
9510
+ delete byStyle[edit.styleId];
9511
+ if (single) delete asset2.source;
9512
+ }
9513
+ if (Object.keys(byStyle).length) asset2.sourceByStyle = byStyle;
9514
+ else delete asset2.sourceByStyle;
9515
+ return;
9516
+ }
9437
9517
  if (edit.action === "patch-style") {
9438
9518
  const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
9439
9519
  if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
@@ -9456,6 +9536,8 @@ function applyEdit(raw, edit) {
9456
9536
  const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
9457
9537
  setOrDelete(style, "palette", colors.length ? colors : null);
9458
9538
  }
9539
+ if (patch2.view !== void 0) setOrDelete(style, "view", patch2.view?.trim() || null);
9540
+ if (patch2.noBackground !== void 0) setOrDelete(style, "noBackground", patch2.noBackground);
9459
9541
  return;
9460
9542
  }
9461
9543
  if (edit.action === "add-asset") {
@@ -9535,10 +9617,134 @@ async function applyManifestEdit(manifestPath, edit) {
9535
9617
  }
9536
9618
  return { manifestPath: absolute, sha256: sha256(next), changed: true };
9537
9619
  }
9620
+
9621
+ // src/pipeline/hand-edit.ts
9622
+ var import_node_child_process3 = require("child_process");
9623
+ var import_node_fs20 = require("fs");
9624
+ var import_promises19 = require("fs/promises");
9625
+ var import_node_path23 = __toESM(require("path"), 1);
9626
+ var HAND_EDIT_DIR = "edits";
9627
+ function handEditPath(loaded, spec) {
9628
+ const style = loaded.manifest.styles[spec.styleId];
9629
+ const outDir = import_node_path23.default.resolve(loaded.root, style?.outDir ?? ".");
9630
+ const relative = import_node_path23.default.relative(outDir, spec.outFile);
9631
+ const inside = relative && !relative.startsWith("..") && !import_node_path23.default.isAbsolute(relative);
9632
+ const file = inside ? relative : import_node_path23.default.basename(spec.outFile);
9633
+ return import_node_path23.default.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
9634
+ }
9635
+ function handEditBase(spec, lock) {
9636
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9637
+ if (entry) {
9638
+ if (entry.outputs.length !== 1) return null;
9639
+ const primary = primaryOutput(entry);
9640
+ if (!primary || primary.mediaType && primary.mediaType !== "image/png") return null;
9641
+ const file = currentEntryOutputPath(entry, spec, 0);
9642
+ return (0, import_node_fs20.existsSync)(file) ? file : null;
9643
+ }
9644
+ return (0, import_node_fs20.existsSync)(spec.outFile) ? spec.outFile : null;
9645
+ }
9646
+ async function startHandEdit(loaded, lock, spec, opts = {}) {
9647
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9648
+ if (entry && entry.outputs.length > 1) {
9649
+ throw new ManifestEditError(
9650
+ `${spec.styleId}/${spec.assetId} has ${entry.outputs.length} outputs; hand edits currently cover single-image assets`
9651
+ );
9652
+ }
9653
+ const existing = spec.source ? import_node_path23.default.resolve(loaded.root, spec.source) : null;
9654
+ const editPath = existing ?? handEditPath(loaded, spec);
9655
+ let created = false;
9656
+ if (!(0, import_node_fs20.existsSync)(editPath)) {
9657
+ const base = handEditBase(spec, lock);
9658
+ if (!base) {
9659
+ throw new ManifestEditError(
9660
+ `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
9661
+ );
9662
+ }
9663
+ await (0, import_promises19.mkdir)(import_node_path23.default.dirname(editPath), { recursive: true });
9664
+ await (0, import_promises19.copyFile)(base, editPath);
9665
+ created = true;
9666
+ }
9667
+ const source = portableOutputPath(editPath, loaded.root);
9668
+ let declared = false;
9669
+ let manifestSha256 = opts.expectedSha256 ?? await sha256File(loaded.path);
9670
+ if (spec.source !== source) {
9671
+ const result = await applyManifestEdit(loaded.path, {
9672
+ action: "set-source",
9673
+ assetId: spec.assetId,
9674
+ styleId: spec.styleId,
9675
+ source,
9676
+ expectedSha256: manifestSha256
9677
+ });
9678
+ declared = result.changed;
9679
+ manifestSha256 = result.sha256;
9680
+ }
9681
+ return { editPath, source, created, declared, manifestSha256 };
9682
+ }
9683
+ async function detachHandEdit(loaded, spec, opts = {}) {
9684
+ if (!spec.source) {
9685
+ return { manifestSha256: opts.expectedSha256 ?? await sha256File(loaded.path), changed: false };
9686
+ }
9687
+ const result = await applyManifestEdit(loaded.path, {
9688
+ action: "clear-source",
9689
+ assetId: spec.assetId,
9690
+ styleId: spec.styleId,
9691
+ expectedSha256: opts.expectedSha256 ?? await sha256File(loaded.path)
9692
+ });
9693
+ return { manifestSha256: result.sha256, changed: result.changed };
9694
+ }
9695
+ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
9696
+ const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
9697
+ const [program, ...args] = command;
9698
+ const child = (0, import_node_child_process3.spawn)(program, [...args, file], { stdio: "ignore", detached: true });
9699
+ child.on("error", () => {
9700
+ });
9701
+ child.unref();
9702
+ return command.join(" ");
9703
+ }
9704
+
9705
+ // src/gallery/edit.ts
9706
+ var HandEditRequestSchema = import_zod8.z.discriminatedUnion("action", [
9707
+ import_zod8.z.object({
9708
+ action: import_zod8.z.literal("start-edit"),
9709
+ project: import_zod8.z.string().min(1).optional(),
9710
+ assetId: import_zod8.z.string().min(1),
9711
+ styleId: import_zod8.z.string().min(1),
9712
+ expectedSha256: import_zod8.z.string().regex(/^[0-9a-f]{64}$/),
9713
+ /** Also launch the author's editor on the file. */
9714
+ open: import_zod8.z.boolean().optional()
9715
+ }).strict(),
9716
+ import_zod8.z.object({
9717
+ action: import_zod8.z.literal("detach-edit"),
9718
+ project: import_zod8.z.string().min(1).optional(),
9719
+ assetId: import_zod8.z.string().min(1),
9720
+ styleId: import_zod8.z.string().min(1),
9721
+ expectedSha256: import_zod8.z.string().regex(/^[0-9a-f]{64}$/)
9722
+ }).strict()
9723
+ ]);
9538
9724
  function createGalleryEditHandler(opts) {
9539
9725
  const log = opts.onProgress ?? (() => {
9540
9726
  });
9541
9727
  return async (body) => {
9728
+ const handEdit = HandEditRequestSchema.safeParse(body);
9729
+ if (handEdit.success) {
9730
+ if (!opts.loadProject) throw new ManifestEditError("hand edits are not available in this gallery");
9731
+ const request = handEdit.data;
9732
+ const ctx = await opts.loadProject(request.project);
9733
+ const spec = ctx.specs.find((candidate) => lockKey(candidate.styleId, candidate.assetId) === lockKey(request.styleId, request.assetId));
9734
+ if (!spec) throw new ManifestEditError(`"${request.styleId}/${request.assetId}" is not declared by the manifest`);
9735
+ if (request.action === "start-edit") {
9736
+ const started = await startHandEdit(ctx.loaded, ctx.lock, spec, { expectedSha256: request.expectedSha256 });
9737
+ log(` hand edit ${started.created ? "created" : "found"}: ${started.source}${started.declared ? " (declared in the manifest)" : ""}`);
9738
+ if (request.open) {
9739
+ const command = (opts.openEditor ?? openInEditor)(started.editPath);
9740
+ log(` opened with: ${command}`);
9741
+ }
9742
+ } else {
9743
+ const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
9744
+ if (result2.changed) log(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
9745
+ }
9746
+ return opts.reload();
9747
+ }
9542
9748
  const parsed = ManifestEditSchema.safeParse(body);
9543
9749
  if (!parsed.success) {
9544
9750
  throw new ManifestEditError(
@@ -9553,9 +9759,9 @@ function createGalleryEditHandler(opts) {
9553
9759
  }
9554
9760
  const result = await applyManifestEdit(manifestPath, parsed.data);
9555
9761
  if (result.changed) {
9556
- const relative = import_node_path22.default.relative(process.cwd(), result.manifestPath);
9762
+ const relative = import_node_path24.default.relative(process.cwd(), result.manifestPath);
9557
9763
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
9558
- const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
9764
+ const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.action === "set-source" || parsed.data.action === "clear-source" ? `${parsed.data.styleId}/${parsed.data.assetId} source` : parsed.data.assetId;
9559
9765
  log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
9560
9766
  }
9561
9767
  return opts.reload();
@@ -9564,7 +9770,7 @@ function createGalleryEditHandler(opts) {
9564
9770
 
9565
9771
  // src/gallery/snapshot.ts
9566
9772
  function galleryMediaId(absolutePath) {
9567
- return (0, import_node_crypto6.createHash)("sha256").update(import_node_path23.default.resolve(absolutePath)).digest("hex").slice(0, 24);
9773
+ return (0, import_node_crypto6.createHash)("sha256").update(import_node_path25.default.resolve(absolutePath)).digest("hex").slice(0, 24);
9568
9774
  }
9569
9775
  function galleryMediaRoute(id) {
9570
9776
  return `/media/${id}`;
@@ -9589,23 +9795,24 @@ function metadataFps(entry) {
9589
9795
  }
9590
9796
  return null;
9591
9797
  }
9592
- async function fileBytes(absolutePath) {
9798
+ async function fileInfo(absolutePath) {
9593
9799
  try {
9594
- const info = await (0, import_promises19.stat)(absolutePath);
9595
- return info.isFile() ? info.size : null;
9800
+ const info = await (0, import_promises20.stat)(absolutePath);
9801
+ return info.isFile() ? { bytes: info.size, modifiedAt: info.mtime.toISOString() } : null;
9596
9802
  } catch {
9597
9803
  return null;
9598
9804
  }
9599
9805
  }
9600
9806
  async function describeOutput(media, root, absolutePath, recorded = {}) {
9601
- const bytes = await fileBytes(absolutePath);
9602
- const exists = bytes !== null;
9807
+ const info = await fileInfo(absolutePath);
9808
+ const exists = info !== null;
9603
9809
  const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
9604
9810
  let url = null;
9605
9811
  if (exists && mediaType) {
9606
9812
  const id = galleryMediaId(absolutePath);
9607
9813
  media.set(id, { path: absolutePath, contentType: mediaType });
9608
- url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
9814
+ const version = recorded.sha256 ?? `${info.bytes}-${Date.parse(info.modifiedAt).toString(36)}`;
9815
+ url = `${galleryMediaRoute(id)}?v=${version.slice(0, 24)}`;
9609
9816
  }
9610
9817
  return {
9611
9818
  path: portableOutputPath(absolutePath, root),
@@ -9614,17 +9821,18 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
9614
9821
  role: recorded.role ?? null,
9615
9822
  mediaType,
9616
9823
  exists,
9617
- bytes,
9824
+ bytes: info?.bytes ?? null,
9825
+ modifiedAt: info?.modifiedAt ?? null,
9618
9826
  url
9619
9827
  };
9620
9828
  }
9621
9829
  async function describeQuality(media, root, inspection) {
9622
9830
  const outputPaths = inspection.outputs ?? [inspection.output];
9623
9831
  const outputs = await Promise.all(
9624
- outputPaths.map((file) => describeOutput(media, root, import_node_path23.default.resolve(root, file)))
9832
+ outputPaths.map((file) => describeOutput(media, root, import_node_path25.default.resolve(root, file)))
9625
9833
  );
9626
- const recordPath = import_node_path23.default.resolve(root, inspection.record);
9627
- const recordExists = (0, import_node_fs20.existsSync)(recordPath);
9834
+ const recordPath = import_node_path25.default.resolve(root, inspection.record);
9835
+ const recordExists = (0, import_node_fs21.existsSync)(recordPath);
9628
9836
  let options = null;
9629
9837
  let check = null;
9630
9838
  if (recordExists) {
@@ -9643,7 +9851,7 @@ async function describeQuality(media, root, inspection) {
9643
9851
  return {
9644
9852
  state: inspection.state,
9645
9853
  reason: inspection.reason,
9646
- output: portableOutputPath(import_node_path23.default.resolve(root, inspection.output), root),
9854
+ output: portableOutputPath(import_node_path25.default.resolve(root, inspection.output), root),
9647
9855
  record: portableOutputPath(recordPath, root),
9648
9856
  recordExists,
9649
9857
  outputs,
@@ -9680,7 +9888,7 @@ async function buildGallerySnapshot(opts) {
9680
9888
  const media = /* @__PURE__ */ new Map();
9681
9889
  const plan = await buildPlan(specs, lock);
9682
9890
  const items = [];
9683
- const manifestText = await (0, import_promises19.readFile)(loaded.path, "utf8");
9891
+ const manifestText = await (0, import_promises20.readFile)(loaded.path, "utf8");
9684
9892
  const manifestSha256 = sha256(manifestText);
9685
9893
  let rawStyles = {};
9686
9894
  try {
@@ -9693,15 +9901,24 @@ async function buildGallerySnapshot(opts) {
9693
9901
  const entry = lock.entries[key];
9694
9902
  const asset = loaded.manifest.assets[spec.assetId] ?? null;
9695
9903
  let outputs;
9904
+ let edit = null;
9905
+ let editStatus = null;
9696
9906
  if (entry && entry.outputs.length) {
9697
9907
  outputs = await Promise.all(
9698
9908
  entry.outputs.map(
9699
9909
  (output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
9700
9910
  )
9701
9911
  );
9912
+ if (spec.source) {
9913
+ const editPath = import_node_path25.default.resolve(root, spec.source);
9914
+ const editSha = (0, import_node_fs21.existsSync)(editPath) ? await sha256File(editPath) : null;
9915
+ edit = await describeOutput(media, root, editPath, { sha256: editSha });
9916
+ const generated = outputs[0];
9917
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists ? "regenerated-since" : "edited";
9918
+ }
9702
9919
  } else if (spec.source) {
9703
- outputs = [await describeOutput(media, root, import_node_path23.default.resolve(root, spec.source))];
9704
- } else if (!entry && (0, import_node_fs20.existsSync)(spec.outFile)) {
9920
+ outputs = [await describeOutput(media, root, import_node_path25.default.resolve(root, spec.source))];
9921
+ } else if (!entry && (0, import_node_fs21.existsSync)(spec.outFile)) {
9705
9922
  outputs = [await describeOutput(media, root, spec.outFile)];
9706
9923
  } else {
9707
9924
  outputs = [];
@@ -9745,6 +9962,8 @@ async function buildGallerySnapshot(opts) {
9745
9962
  providerMetadata: entry?.providerMetadata ?? {},
9746
9963
  asset,
9747
9964
  source: spec.source ?? null,
9965
+ edit,
9966
+ editStatus,
9748
9967
  tags: spec.tags,
9749
9968
  category: asset?.category ?? null
9750
9969
  });
@@ -9797,6 +10016,8 @@ async function buildGallerySnapshot(opts) {
9797
10016
  providerMetadata: entry.providerMetadata ?? {},
9798
10017
  asset: null,
9799
10018
  source: null,
10019
+ edit: null,
10020
+ editStatus: null,
9800
10021
  tags: [],
9801
10022
  category: null
9802
10023
  });
@@ -9835,6 +10056,8 @@ async function buildGallerySnapshot(opts) {
9835
10056
  promptPrefix: style?.promptPrefix ?? "",
9836
10057
  promptSuffix: style?.promptSuffix ?? "",
9837
10058
  palette: style?.palette ?? [],
10059
+ view: style?.view ?? null,
10060
+ noBackground: style?.noBackground ?? true,
9838
10061
  quality: Boolean(style?.quality),
9839
10062
  tags: style?.tags ?? [],
9840
10063
  extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
@@ -9862,7 +10085,7 @@ async function buildGallerySnapshot(opts) {
9862
10085
  id: loaded.manifest.name,
9863
10086
  name: loaded.manifest.name,
9864
10087
  manifest: loaded.path,
9865
- lock: import_node_path23.default.resolve(opts.lockPath),
10088
+ lock: import_node_path25.default.resolve(opts.lockPath),
9866
10089
  root,
9867
10090
  provider: loaded.manifest.provider,
9868
10091
  account: null,
@@ -9887,7 +10110,7 @@ async function buildGallerySnapshot(opts) {
9887
10110
  return { snapshot, media };
9888
10111
  }
9889
10112
  async function buildWorkspaceGallerySnapshot(opts) {
9890
- const dir = import_node_path23.default.dirname(import_node_path23.default.resolve(opts.workspacePath));
10113
+ const dir = import_node_path25.default.dirname(import_node_path25.default.resolve(opts.workspacePath));
9891
10114
  const media = /* @__PURE__ */ new Map();
9892
10115
  const projects = [];
9893
10116
  const items = [];
@@ -9930,11 +10153,11 @@ async function buildWorkspaceGallerySnapshot(opts) {
9930
10153
  id: project.id,
9931
10154
  name: loaded.manifest.name,
9932
10155
  manifest: manifestPath,
9933
- lock: import_node_path23.default.resolve(lockPath),
10156
+ lock: import_node_path25.default.resolve(lockPath),
9934
10157
  root: loaded.root,
9935
10158
  provider: loaded.manifest.provider,
9936
10159
  account: project.account ?? null,
9937
- manifestSha256: manifestSha256 ?? sha256(await (0, import_promises19.readFile)(manifestPath, "utf8")),
10160
+ manifestSha256: manifestSha256 ?? sha256(await (0, import_promises20.readFile)(manifestPath, "utf8")),
9938
10161
  entries: Object.keys(lock.entries).length,
9939
10162
  items: projectItems.length,
9940
10163
  spendByUnit: spendByUnit(lock),
@@ -9945,8 +10168,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
9945
10168
  id: project.id,
9946
10169
  name: project.id,
9947
10170
  manifest: manifestPath,
9948
- lock: import_node_path23.default.resolve(lockPath),
9949
- root: import_node_path23.default.dirname(manifestPath),
10171
+ lock: import_node_path25.default.resolve(lockPath),
10172
+ root: import_node_path25.default.dirname(manifestPath),
9950
10173
  provider: project.provider,
9951
10174
  account: project.account ?? null,
9952
10175
  manifestSha256: null,
@@ -9972,7 +10195,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9972
10195
  version: 1,
9973
10196
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9974
10197
  project: null,
9975
- workspace: { path: import_node_path23.default.resolve(opts.workspacePath), dir, projects },
10198
+ workspace: { path: import_node_path25.default.resolve(opts.workspacePath), dir, projects },
9976
10199
  filter,
9977
10200
  totals: {
9978
10201
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -10259,6 +10482,14 @@ function renderGallery(snapshot, opts = {}) {
10259
10482
  .tray > button { padding:5px 11px; font-size:12.5px; }
10260
10483
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10261
10484
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10485
+ .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10486
+ .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
10487
+ background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
10488
+ .pair figure img { image-rendering:pixelated; display:block; max-width:100%; }
10489
+ .pair figcaption { font:11px/1.4 var(--mono); color:var(--dim); background:var(--panel); padding:2px 6px; margin-top:6px; }
10490
+ .card .pen { position:absolute; right:6px; top:6px; background:var(--panel); color:var(--accent-soft); border:1px solid var(--accent);
10491
+ font:700 11px/1 var(--mono); padding:3px 5px; }
10492
+ .hand-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
10262
10493
  .drawer .gen button.primary { padding:7px 14px; }
10263
10494
  @media (max-width: 720px) {
10264
10495
  .bar, .totals, .chips, .style, footer { padding-inline:14px; }
@@ -10309,6 +10540,7 @@ function renderGallery(snapshot, opts = {}) {
10309
10540
  </footer>
10310
10541
  <div id="drawer-host"></div>
10311
10542
  <div id="dialog-host"></div>
10543
+ <datalist id="view-options"><option value="low top-down"><option value="high top-down"><option value="side"><option value="sidescroller"></datalist>
10312
10544
  <script>
10313
10545
  const INITIAL = ${data};
10314
10546
  const SESSION = ${session};
@@ -10704,7 +10936,8 @@ function clearFilters() {
10704
10936
 
10705
10937
  function thumb(item) {
10706
10938
  const cell = el('div', 'cell');
10707
- const shown = item.outputs.filter((o) => o.url);
10939
+ // A hand edit is what ships, so it is what the card shows.
10940
+ const shown = item.edit && item.edit.url && item.editStatus === 'edited' ? [item.edit] : item.outputs.filter((o) => o.url);
10708
10941
  if (!shown.length) {
10709
10942
  cell.classList.add('none');
10710
10943
  cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
@@ -10755,6 +10988,9 @@ function card(item) {
10755
10988
  body.append(meta);
10756
10989
  const cell = thumb(item);
10757
10990
  if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
10991
+ if (item.editStatus === 'edited' || item.editStatus === 'regenerated-since') {
10992
+ const pen = el('span', 'pen', '\u270E'); pen.title = 'hand-edited'; cell.append(pen);
10993
+ }
10758
10994
  c.append(cell, body);
10759
10995
  c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
10760
10996
  return c;
@@ -11081,6 +11317,78 @@ function openCompare() {
11081
11317
  close.focus({ preventScroll: true });
11082
11318
  }
11083
11319
 
11320
+ // ---- hand edits -------------------------------------------------------------
11321
+
11322
+ const EDIT_STATUS_TEXT = {
11323
+ same: 'A copy of the generated art, not changed yet. Open it in your editor and save.',
11324
+ edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
11325
+ 'regenerated-since': 'The generated art changed after this edit was saved \u2014 the edit is based on an older generation.',
11326
+ missing: 'The declared file is not on disk.',
11327
+ };
11328
+ async function postHandEdit(item, action, extra) {
11329
+ const pr = projectOf(item);
11330
+ const body = { action, assetId: item.assetId, styleId: item.styleId, expectedSha256: pr.manifestSha256, ...extra };
11331
+ if (item.project) body.project = item.project;
11332
+ snap = await postEdit(body);
11333
+ }
11334
+ function handEditSection(item) {
11335
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 &&
11336
+ item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png';
11337
+ if (!item.edit && !canStart) return null;
11338
+ const s = el('section', 'meta');
11339
+ s.append(el('h3', null, 'Hand edit'));
11340
+ if (item.edit) {
11341
+ const pair = el('div', 'pair');
11342
+ const shared = displayScale(item.width, item.height, 200, 160);
11343
+ const fig = (out, label) => {
11344
+ const f = el('figure');
11345
+ if (out && out.url) {
11346
+ const img = el('img'); img.src = out.url; img.alt = label;
11347
+ if (shared >= 1) { img.width = item.width * shared; img.height = item.height * shared; }
11348
+ f.append(img);
11349
+ } else f.append(el('span', 'state-dim', 'not on disk'));
11350
+ f.append(el('figcaption', null, label));
11351
+ return f;
11352
+ };
11353
+ pair.append(fig(item.outputs[0], 'generated'), fig(item.edit, 'edit'));
11354
+ s.append(pair);
11355
+ const dl = el('dl');
11356
+ const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
11357
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
11358
+ row(dl, 'status', st);
11359
+ row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
11360
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11361
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11362
+ row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11363
+ s.append(dl);
11364
+ } else {
11365
+ 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.'));
11366
+ }
11367
+ if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
11368
+ const acts = el('div', 'hand-actions');
11369
+ const msg = el('span', 'msg');
11370
+ const go = async (label, action, extra, done) => {
11371
+ const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
11372
+ b.onclick = async () => {
11373
+ b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
11374
+ try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
11375
+ catch (err) { b.disabled = false; msg.className = 'msg bad'; msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : ''); }
11376
+ };
11377
+ acts.append(b);
11378
+ };
11379
+ if (item.edit) {
11380
+ go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11381
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11382
+ } else if (canStart) {
11383
+ 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.');
11384
+ go('Create edit file only', 'start-edit', { open: false }, 'Created the edit file and declared it in the manifest.');
11385
+ }
11386
+ acts.append(msg);
11387
+ s.append(acts);
11388
+ }
11389
+ return s;
11390
+ }
11391
+
11084
11392
  // ---- editing (only when the server minted a session) ---------------------
11085
11393
 
11086
11394
  // Styles a parent edit reaches: every descendant (via extends) that does not
@@ -11100,9 +11408,18 @@ function inheritorsOf(style, field) {
11100
11408
  }
11101
11409
  return out;
11102
11410
  }
11411
+ // noBackground reaches the request only for pixflux and non-PixelLab
11412
+ // providers; elsewhere a change is recorded but alters nothing.
11413
+ const fieldReaches = (field, style) =>
11414
+ field !== 'noBackground' || style.generator === 'pixflux' || style.provider !== 'pixellab';
11103
11415
  function blastRadius(style, fields) {
11104
- const styleIds = new Set([style.id]);
11105
- for (const field of fields) for (const id of inheritorsOf(style, field)) styleIds.add(id);
11416
+ const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
11417
+ const styleIds = new Set();
11418
+ for (const field of fields) {
11419
+ for (const id of [style.id, ...inheritorsOf(style, field)]) {
11420
+ if (fieldReaches(field, byId.get(id) || style)) styleIds.add(id);
11421
+ }
11422
+ }
11106
11423
  const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
11107
11424
  const units = new Map();
11108
11425
  for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
@@ -11127,6 +11444,11 @@ function styleForm(style) {
11127
11444
  const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
11128
11445
  const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
11129
11446
  const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb \u2014 leave empty for no forced palette';
11447
+ const view = el('input'); view.type = 'text'; view.value = style.view || ''; view.placeholder = 'provider default';
11448
+ view.setAttribute('list', 'view-options');
11449
+ const noBg = el('select');
11450
+ noBg.append(new Option(style.extends ? 'inherit from ' + style.extends : 'default (on)', 'default'), new Option('on \u2014 strip the generated background', 'on'), new Option('off \u2014 keep the background (scenes, banners)', 'off'));
11451
+ noBg.value = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
11130
11452
  const swatches = el('div', 'pal');
11131
11453
  const drawSwatches = () => {
11132
11454
  swatches.textContent = '';
@@ -11139,12 +11461,21 @@ function styleForm(style) {
11139
11461
  field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
11140
11462
  swatches,
11141
11463
  );
11464
+ const row = el('div', 'row');
11465
+ row.append(
11466
+ field('view', view, 'PixelLab accepts low top-down, high top-down, side; Retro Diffusion reads sidescroller. ' + provenance('view')),
11467
+ field('background', noBg, (fieldReaches('noBackground', style) ? 'Sent for this style. ' : 'Not sent for ' + style.provider + ' ' + style.generator + '; recorded only. ') + provenance('noBackground')),
11468
+ );
11469
+ form.append(row);
11142
11470
  const blast = el('div', 'blast');
11143
11471
  const changed = () => {
11144
11472
  const out = [];
11145
11473
  if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
11146
11474
  if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
11147
11475
  if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
11476
+ if (view.value.trim() !== (style.view || '')) out.push('view');
11477
+ const bgNow = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
11478
+ if (noBg.value !== bgNow) out.push('noBackground');
11148
11479
  return out;
11149
11480
  };
11150
11481
  const updateBlast = () => {
@@ -11152,6 +11483,11 @@ function styleForm(style) {
11152
11483
  drawSwatches();
11153
11484
  if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
11154
11485
  const r = blastRadius(style, fields);
11486
+ if (!r.assets) {
11487
+ blast.className = 'blast';
11488
+ blast.textContent = 'Recorded in the manifest only: nothing in ' + style.id + ' sends this field, so no request changes.';
11489
+ return;
11490
+ }
11155
11491
  blast.className = 'blast hot';
11156
11492
  blast.textContent = '';
11157
11493
  const others = r.styles.filter((id) => id !== style.id);
@@ -11161,7 +11497,8 @@ function styleForm(style) {
11161
11497
  : ' in ' + style.id + '.'));
11162
11498
  blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
11163
11499
  };
11164
- for (const input of [prefix, suffix, palette]) input.addEventListener('input', updateBlast);
11500
+ for (const input of [prefix, suffix, palette, view]) input.addEventListener('input', updateBlast);
11501
+ noBg.addEventListener('change', updateBlast);
11165
11502
  updateBlast();
11166
11503
  form.append(blast);
11167
11504
  const actions = el('div', 'actions');
@@ -11180,6 +11517,8 @@ function styleForm(style) {
11180
11517
  if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
11181
11518
  if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
11182
11519
  if (fields.includes('palette')) patch.palette = colors;
11520
+ if (fields.includes('view')) patch.view = view.value.trim();
11521
+ if (fields.includes('noBackground')) patch.noBackground = noBg.value === 'default' ? null : noBg.value === 'on';
11183
11522
  save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
11184
11523
  const r = blastRadius(style, fields);
11185
11524
  try {
@@ -11188,7 +11527,9 @@ function styleForm(style) {
11188
11527
  snap = await postEdit(body);
11189
11528
  ui.editing = null;
11190
11529
  ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
11191
- text: 'Saved. The request for ' + r.assets + (r.assets === 1 ? ' asset' : ' assets') + ' changed' + (r.cost ? ' \u2014 about ' + r.cost + ' to regenerate.' : '.') + ' Nothing is spent until you generate.' };
11530
+ text: r.assets
11531
+ ? 'Saved. The request for ' + r.assets + (r.assets === 1 ? ' asset' : ' assets') + ' changed' + (r.cost ? ' \u2014 about ' + r.cost + ' to regenerate.' : '.') + ' Nothing is spent until you generate.'
11532
+ : 'Saved. Recorded in the manifest; no request changed.' };
11192
11533
  render();
11193
11534
  } catch (err) {
11194
11535
  save.disabled = false;
@@ -11539,6 +11880,9 @@ function renderDrawer() {
11539
11880
  body.append(s);
11540
11881
  }
11541
11882
 
11883
+ const hand = handEditSection(item);
11884
+ if (hand) body.append(hand);
11885
+
11542
11886
  if (item.revision || item.revisionParentKey) {
11543
11887
  const { s, dl } = section('Lineage');
11544
11888
  row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
@@ -11787,10 +12131,10 @@ if (ui.compare.length >= 2 && !ui.open) openCompare();
11787
12131
  var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11788
12132
 
11789
12133
  // src/gallery/server.ts
11790
- var import_node_child_process3 = require("child_process");
12134
+ var import_node_child_process4 = require("child_process");
11791
12135
  var import_node_crypto7 = require("crypto");
11792
12136
  var import_node_http2 = require("http");
11793
- var import_promises20 = require("fs/promises");
12137
+ var import_promises21 = require("fs/promises");
11794
12138
  var MAX_EDIT_BYTES = 64 * 1024;
11795
12139
  async function readJsonBody(req) {
11796
12140
  const chunks = [];
@@ -11929,7 +12273,7 @@ async function serveGallery(opts) {
11929
12273
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
11930
12274
  if (!asset) return fail(404, "no such review asset");
11931
12275
  try {
11932
- const bytes = await (0, import_promises20.readFile)(asset.path);
12276
+ const bytes = await (0, import_promises21.readFile)(asset.path);
11933
12277
  res.writeHead(200, {
11934
12278
  "Content-Type": asset.contentType,
11935
12279
  "Cache-Control": "no-store",
@@ -11978,7 +12322,7 @@ async function serveGallery(opts) {
11978
12322
  if (!asset) return fail(404, "no such gallery media");
11979
12323
  let bytes;
11980
12324
  try {
11981
- bytes = await (0, import_promises20.readFile)(asset.path);
12325
+ bytes = await (0, import_promises21.readFile)(asset.path);
11982
12326
  } catch {
11983
12327
  return fail(404, "gallery media is no longer on disk; refresh the page");
11984
12328
  }
@@ -12010,7 +12354,7 @@ async function serveGallery(opts) {
12010
12354
  });
12011
12355
  opts.onReady?.(url);
12012
12356
  if (opts.open !== false && process.platform === "darwin") {
12013
- (0, import_node_child_process3.spawn)("open", [url], { stdio: "ignore", detached: true }).unref();
12357
+ (0, import_node_child_process4.spawn)("open", [url], { stdio: "ignore", detached: true }).unref();
12014
12358
  }
12015
12359
  return {
12016
12360
  url,
@@ -12024,14 +12368,14 @@ async function serveGallery(opts) {
12024
12368
 
12025
12369
  // src/gallery/generate.ts
12026
12370
  var import_node_crypto8 = require("crypto");
12027
- var import_zod8 = require("zod");
12028
- var GenerateRequestSchema = import_zod8.z.object({
12029
- project: import_zod8.z.string().min(1).optional(),
12030
- keys: import_zod8.z.array(import_zod8.z.string().min(1)).min(1).max(500),
12371
+ var import_zod9 = require("zod");
12372
+ var GenerateRequestSchema = import_zod9.z.object({
12373
+ project: import_zod9.z.string().min(1).optional(),
12374
+ keys: import_zod9.z.array(import_zod9.z.string().min(1)).min(1).max(500),
12031
12375
  /** Regenerate work that is up to date, as `gen --force` does. */
12032
- force: import_zod8.z.boolean().optional(),
12376
+ force: import_zod9.z.boolean().optional(),
12033
12377
  /** Advance in-flight, review, or selected work without submitting anything. */
12034
- resume: import_zod8.z.boolean().optional()
12378
+ resume: import_zod9.z.boolean().optional()
12035
12379
  }).strict();
12036
12380
  var GenerateRequestError = class extends Error {
12037
12381
  constructor(message6, status = 400) {
@@ -12268,19 +12612,19 @@ function createGenerateHandlers(opts) {
12268
12612
  }
12269
12613
 
12270
12614
  // src/pipeline/salvage.ts
12271
- var import_promises21 = require("fs/promises");
12272
- var import_node_fs21 = require("fs");
12273
- var import_node_path24 = __toESM(require("path"), 1);
12615
+ var import_promises22 = require("fs/promises");
12616
+ var import_node_fs22 = require("fs");
12617
+ var import_node_path26 = __toESM(require("path"), 1);
12274
12618
  function providerClaimId(provider, objectId) {
12275
12619
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
12276
12620
  }
12277
12621
  async function loadClaims(lockPaths, opts = {}) {
12278
12622
  const claimed = /* @__PURE__ */ new Set();
12279
12623
  for (const p of lockPaths) {
12280
- if (!(0, import_node_fs21.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
12624
+ if (!(0, import_node_fs22.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
12281
12625
  let parsed;
12282
12626
  try {
12283
- parsed = parseLock(JSON.parse(await (0, import_promises21.readFile)(p, "utf8")));
12627
+ parsed = parseLock(JSON.parse(await (0, import_promises22.readFile)(p, "utf8")));
12284
12628
  } catch {
12285
12629
  throw new Error(`Claim lockfile is malformed: ${p}`);
12286
12630
  }
@@ -12333,20 +12677,20 @@ function matchOrphanStyle(prompt, manifest) {
12333
12677
  return matchStyleByPattern(prompt, manifest);
12334
12678
  }
12335
12679
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
12336
- const own = import_node_path24.default.resolve(ownManifestPath);
12680
+ const own = import_node_path26.default.resolve(ownManifestPath);
12337
12681
  const siblingManifestPaths = [
12338
12682
  .../* @__PURE__ */ new Set([
12339
12683
  ...workspaceManifestPaths,
12340
- ...claimPaths.map((c) => import_node_path24.default.join(import_node_path24.default.dirname(import_node_path24.default.resolve(c)), "pixelkiln.manifest.json"))
12684
+ ...claimPaths.map((c) => import_node_path26.default.join(import_node_path26.default.dirname(import_node_path26.default.resolve(c)), "pixelkiln.manifest.json"))
12341
12685
  ])
12342
12686
  ];
12343
12687
  const siblings = [];
12344
12688
  for (const siblingManifestPath of siblingManifestPaths) {
12345
- if (import_node_path24.default.resolve(siblingManifestPath) === own) continue;
12346
- if (!(0, import_node_fs21.existsSync)(siblingManifestPath)) continue;
12689
+ if (import_node_path26.default.resolve(siblingManifestPath) === own) continue;
12690
+ if (!(0, import_node_fs22.existsSync)(siblingManifestPath)) continue;
12347
12691
  try {
12348
12692
  const { manifest } = await loadManifest(siblingManifestPath);
12349
- siblings.push({ label: import_node_path24.default.basename(import_node_path24.default.dirname(siblingManifestPath)), manifest });
12693
+ siblings.push({ label: import_node_path26.default.basename(import_node_path26.default.dirname(siblingManifestPath)), manifest });
12350
12694
  } catch {
12351
12695
  }
12352
12696
  }
@@ -12462,8 +12806,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
12462
12806
  }
12463
12807
 
12464
12808
  // src/pick/salvage-server.ts
12465
- var import_promises22 = require("fs/promises");
12466
- var import_node_path25 = __toESM(require("path"), 1);
12809
+ var import_promises23 = require("fs/promises");
12810
+ var import_node_path27 = __toESM(require("path"), 1);
12467
12811
 
12468
12812
  // src/pick/salvage-sheet.ts
12469
12813
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -12628,7 +12972,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12628
12972
  });
12629
12973
  const html = renderSalvageSheet(orphans, {
12630
12974
  styleId: ctx.styleId,
12631
- importDir: import_node_path25.default.relative(process.cwd(), ctx.importDir) || "."
12975
+ importDir: import_node_path27.default.relative(process.cwd(), ctx.importDir) || "."
12632
12976
  });
12633
12977
  const byId = new Map(orphans.map((o) => [o.id, o]));
12634
12978
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -12657,13 +13001,14 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12657
13001
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
12658
13002
  decodePng(buf);
12659
13003
  const assetId = idFromPrompt(orphan.prompt, taken);
12660
- const rel = import_node_path25.default.join("_salvaged", `${assetId}.png`);
12661
- const outFile = import_node_path25.default.resolve(ctx.importDir, rel);
12662
- await (0, import_promises22.mkdir)(import_node_path25.default.dirname(outFile), { recursive: true });
12663
- await (0, import_promises22.writeFile)(outFile, buf);
13004
+ const rel = import_node_path27.default.join("_salvaged", `${assetId}.png`);
13005
+ const outFile = import_node_path27.default.resolve(ctx.importDir, rel);
13006
+ await (0, import_promises23.mkdir)(import_node_path27.default.dirname(outFile), { recursive: true });
13007
+ await (0, import_promises23.writeFile)(outFile, buf);
12664
13008
  ctx.manifest.assets[assetId] = {
12665
13009
  prompt: orphan.prompt,
12666
13010
  promptByStyle: {},
13011
+ sourceByStyle: {},
12667
13012
  category: "_salvaged",
12668
13013
  file: rel,
12669
13014
  tags: ["salvaged"],
@@ -12691,7 +13036,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12691
13036
  sourceUrl: durableSource,
12692
13037
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
12693
13038
  outputs: [{
12694
- path: portableOutputPath(outFile, import_node_path25.default.dirname(ctx.manifestPath)),
13039
+ path: portableOutputPath(outFile, import_node_path27.default.dirname(ctx.manifestPath)),
12695
13040
  sha256: sha256(buf)
12696
13041
  }],
12697
13042
  submittedAt: orphan.createdAt,
@@ -12715,9 +13060,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12715
13060
  }
12716
13061
  }
12717
13062
  await applyTags(provider, decisions, existingTags, { onProgress: log });
12718
- const raw = JSON.parse(await (0, import_promises22.readFile)(ctx.manifestPath, "utf8"));
13063
+ const raw = JSON.parse(await (0, import_promises23.readFile)(ctx.manifestPath, "utf8"));
12719
13064
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
12720
- await (0, import_promises22.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
13065
+ await (0, import_promises23.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12721
13066
  await saveLock(ctx.lockPath, ctx.lock);
12722
13067
  return result;
12723
13068
  }