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.js CHANGED
@@ -258,6 +258,15 @@ var AssetSchema = z.object({
258
258
  * that wasn't. `prompt` still records what was asked for.
259
259
  */
260
260
  source: z.string().optional(),
261
+ /**
262
+ * Per-style replacement for `source`, keyed by style id.
263
+ *
264
+ * A hand edit belongs to one generation: the same asset generated in two
265
+ * styles is two different pictures, so the touch-up of one must not be
266
+ * placed for the other. `pixelkiln edit` writes here when an asset is in
267
+ * more than one style, and to `source` when it is in exactly one.
268
+ */
269
+ sourceByStyle: z.record(z.string().min(1)).default({}),
261
270
  /** Controlled generation derived from another asset in the same style. */
262
271
  revision: RevisionSchema.optional(),
263
272
  /**
@@ -292,7 +301,7 @@ var AssetSchema = z.object({
292
301
  z.array(z.union([z.string(), z.number().finite(), z.boolean()])).min(2).max(64)
293
302
  ])).default({})
294
303
  }).strict().superRefine((asset, context) => {
295
- if (asset.source && asset.revision) {
304
+ if ((asset.source || Object.keys(asset.sourceByStyle).length) && asset.revision) {
296
305
  context.addIssue({
297
306
  code: z.ZodIssueCode.custom,
298
307
  message: "source and revision are mutually exclusive",
@@ -535,11 +544,11 @@ var PixelLabClient = class {
535
544
  * common than the failure mode of retrying (a duplicate object), and a
536
545
  * duplicate is visible and free to delete whereas a silent gap is neither.
537
546
  */
538
- async request(path25, init, attempt = 0) {
547
+ async request(path27, init, attempt = 0) {
539
548
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
540
549
  let res;
541
550
  try {
542
- res = await fetch(`${BASE}${path25}`, {
551
+ res = await fetch(`${BASE}${path27}`, {
543
552
  ...init,
544
553
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
545
554
  headers: {
@@ -551,24 +560,24 @@ var PixelLabClient = class {
551
560
  } catch (err) {
552
561
  if (attempt < MAX_RETRIES) {
553
562
  await sleep(backoffMs(attempt));
554
- return this.request(path25, init, attempt + 1);
563
+ return this.request(path27, init, attempt + 1);
555
564
  }
556
565
  throw err;
557
566
  }
558
567
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
559
568
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
560
569
  await sleep(waitMs);
561
- return this.request(path25, init, attempt + 1);
570
+ return this.request(path27, init, attempt + 1);
562
571
  }
563
572
  const text = await res.text();
564
573
  if (!res.ok) {
565
- throw new PixelLabError(`${init?.method ?? "GET"} ${path25} \u2192 ${res.status}`, res.status, text);
574
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path27} \u2192 ${res.status}`, res.status, text);
566
575
  }
567
576
  if (!text) return {};
568
577
  try {
569
578
  return JSON.parse(text);
570
579
  } catch {
571
- throw new Error(`${init?.method ?? "GET"} ${path25} returned invalid JSON`);
580
+ throw new Error(`${init?.method ?? "GET"} ${path27} returned invalid JSON`);
572
581
  }
573
582
  }
574
583
  async balance() {
@@ -1615,9 +1624,9 @@ var RetroDiffusionClient = class {
1615
1624
  }
1616
1625
  return balance;
1617
1626
  }
1618
- async call(path25, init = {}) {
1627
+ async call(path27, init = {}) {
1619
1628
  if (!this.token) throw new Error("RD_API_KEY is not set");
1620
- const response = await this.request(`${this.baseUrl}${path25}`, {
1629
+ const response = await this.request(`${this.baseUrl}${path27}`, {
1621
1630
  ...init,
1622
1631
  headers: {
1623
1632
  "Content-Type": "application/json",
@@ -2420,10 +2429,10 @@ import { createReadStream } from "fs";
2420
2429
  function sha256(data) {
2421
2430
  return createHash("sha256").update(data).digest("hex");
2422
2431
  }
2423
- async function sha256File(path25) {
2432
+ async function sha256File(path27) {
2424
2433
  return new Promise((resolve, reject) => {
2425
2434
  const hash = createHash("sha256");
2426
- const stream = createReadStream(path25);
2435
+ const stream = createReadStream(path27);
2427
2436
  stream.on("data", (chunk2) => hash.update(chunk2));
2428
2437
  stream.on("error", reject);
2429
2438
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -3878,6 +3887,11 @@ ${issues}`);
3878
3887
  unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
3879
3888
  }
3880
3889
  }
3890
+ for (const styleId of Object.keys(asset.sourceByStyle)) {
3891
+ if (!styleIds.has(styleId)) {
3892
+ unknownReferences.push(`assets.${assetId}.sourceByStyle: unknown style "${styleId}"`);
3893
+ }
3894
+ }
3881
3895
  if (asset.revision) {
3882
3896
  if (!parsed.data.assets[asset.revision.from]) {
3883
3897
  unknownReferences.push(
@@ -4142,7 +4156,7 @@ async function resolveSpecs(loaded, filter) {
4142
4156
  }
4143
4157
  } : {},
4144
4158
  tags,
4145
- source: asset.source,
4159
+ source: asset.sourceByStyle[styleId] ?? asset.source,
4146
4160
  // Revision identity is attached after every dependency in this style
4147
4161
  // has a concrete output target. Finalization below computes the hash.
4148
4162
  specHash: ""
@@ -6746,10 +6760,10 @@ function isSha256Hash(value) {
6746
6760
  function parseCache(value) {
6747
6761
  return HashCacheSchema.parse(value);
6748
6762
  }
6749
- async function loadCache(path25) {
6750
- if (!existsSync10(path25)) return { version: 1, hashes: {} };
6763
+ async function loadCache(path27) {
6764
+ if (!existsSync10(path27)) return { version: 1, hashes: {} };
6751
6765
  try {
6752
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path25, "utf8")));
6766
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path27, "utf8")));
6753
6767
  if (!parsed.success) return { version: 1, hashes: {} };
6754
6768
  return {
6755
6769
  version: 1,
@@ -6761,18 +6775,18 @@ async function loadCache(path25) {
6761
6775
  return { version: 1, hashes: {} };
6762
6776
  }
6763
6777
  }
6764
- async function saveCache(path25, cache) {
6778
+ async function saveCache(path27, cache) {
6765
6779
  const sorted = {};
6766
6780
  for (const key of Object.keys(cache.hashes).sort()) {
6767
6781
  const hash = cache.hashes[key];
6768
6782
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6769
6783
  sorted[key] = hash;
6770
6784
  }
6771
- await mkdir3(pathModule.dirname(pathModule.resolve(path25)), { recursive: true });
6772
- const tmp = `${path25}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6785
+ await mkdir3(pathModule.dirname(pathModule.resolve(path27)), { recursive: true });
6786
+ const tmp = `${path27}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6773
6787
  try {
6774
6788
  await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6775
- await rename3(tmp, path25);
6789
+ await rename3(tmp, path27);
6776
6790
  } finally {
6777
6791
  await rm4(tmp, { force: true });
6778
6792
  }
@@ -7019,16 +7033,24 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
7019
7033
  }
7020
7034
  const prefix = `${styleId}/`;
7021
7035
  const entries = Object.entries(lock.entries).filter(([key]) => key.startsWith(prefix));
7022
- if (!entries.length) {
7036
+ const sources = options.sources ?? {};
7037
+ if (!entries.length && !Object.keys(sources).length) {
7023
7038
  throw new Error(
7024
7039
  `No locked assets for style "${styleId}". Run \`pixelkiln gen --style ${styleId}\` first.`
7025
7040
  );
7026
7041
  }
7027
7042
  const inputs = [];
7028
7043
  const noOutput = [];
7044
+ const locked = /* @__PURE__ */ new Set();
7029
7045
  for (const [key, entry] of entries) {
7030
7046
  const id = key.slice(prefix.length);
7047
+ locked.add(id);
7031
7048
  const outputs = resolveEntryOutputs(entry, id, manifestDir);
7049
+ const source = sources[id];
7050
+ if (source && outputs.length <= 1) {
7051
+ inputs.push({ id, path: path15.resolve(manifestDir, source) });
7052
+ continue;
7053
+ }
7032
7054
  let selected = outputs;
7033
7055
  if (options.outputRoles?.length) {
7034
7056
  selected = outputs.filter(
@@ -7045,6 +7067,9 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
7045
7067
  }
7046
7068
  for (const output of selected) inputs.push({ id: output.id, path: output.absolutePath });
7047
7069
  }
7070
+ for (const [id, source] of Object.entries(sources)) {
7071
+ if (!locked.has(id)) inputs.push({ id, path: path15.resolve(manifestDir, source) });
7072
+ }
7048
7073
  if (!inputs.length) {
7049
7074
  throw new Error(`No readable sprites for style "${styleId}" \u2014 ${noOutput.length} skipped.`);
7050
7075
  }
@@ -8290,6 +8315,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
8290
8315
  assets[asset.id] = {
8291
8316
  prompt: "",
8292
8317
  promptByStyle: {},
8318
+ sourceByStyle: {},
8293
8319
  category: asset.category || void 0,
8294
8320
  file: asset.file,
8295
8321
  tags: [],
@@ -8977,9 +9003,9 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
8977
9003
 
8978
9004
  // src/gallery/snapshot.ts
8979
9005
  import { createHash as createHash4 } from "crypto";
8980
- import { existsSync as existsSync18 } from "fs";
9006
+ import { existsSync as existsSync19 } from "fs";
8981
9007
  import { readFile as readFile18, stat as stat3 } from "fs/promises";
8982
- import path22 from "path";
9008
+ import path24 from "path";
8983
9009
 
8984
9010
  // src/workspace.ts
8985
9011
  import { mkdir as mkdir5, readFile as readFile16, rename as rename5, rm as rm7, writeFile as writeFile7 } from "fs/promises";
@@ -9115,6 +9141,10 @@ function validateWorkspace(ws, dir) {
9115
9141
  }
9116
9142
 
9117
9143
  // src/gallery/edit.ts
9144
+ import path23 from "path";
9145
+ import { z as z8 } from "zod";
9146
+
9147
+ // src/manifest-edit.ts
9118
9148
  import { readFile as readFile17, rename as rename6, rm as rm8, writeFile as writeFile8 } from "fs/promises";
9119
9149
  import path21 from "path";
9120
9150
  import { z as z7 } from "zod";
@@ -9161,6 +9191,22 @@ var ManifestEditSchema = z7.discriminatedUnion("action", [
9161
9191
  expectedSha256: HexSha,
9162
9192
  asset: NewAssetSchema
9163
9193
  }).strict(),
9194
+ z7.object({
9195
+ action: z7.literal("set-source"),
9196
+ project: z7.string().min(1).optional(),
9197
+ assetId: z7.string().min(1),
9198
+ styleId: z7.string().min(1),
9199
+ /** Manifest-relative path of the art that stands in for this asset in this style. */
9200
+ source: z7.string().min(1),
9201
+ expectedSha256: HexSha
9202
+ }).strict(),
9203
+ z7.object({
9204
+ action: z7.literal("clear-source"),
9205
+ project: z7.string().min(1).optional(),
9206
+ assetId: z7.string().min(1),
9207
+ styleId: z7.string().min(1),
9208
+ expectedSha256: HexSha
9209
+ }).strict(),
9164
9210
  z7.object({
9165
9211
  action: z7.literal("patch-style"),
9166
9212
  project: z7.string().min(1).optional(),
@@ -9173,7 +9219,11 @@ var ManifestEditSchema = z7.discriminatedUnion("action", [
9173
9219
  promptPrefix: z7.string().optional(),
9174
9220
  promptSuffix: z7.string().optional(),
9175
9221
  /** `#rrggbb` values; null or empty clears the style's own palette. */
9176
- palette: z7.array(z7.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional()
9222
+ palette: z7.array(z7.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
9223
+ /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
9224
+ view: z7.string().max(64).nullable().optional(),
9225
+ /** pixflux only; null clears the style's own value. */
9226
+ noBackground: z7.boolean().nullable().optional()
9177
9227
  }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
9178
9228
  }).strict()
9179
9229
  ]);
@@ -9207,8 +9257,38 @@ function setOrDelete(target, key, value) {
9207
9257
  if (value === null || value === void 0) delete target[key];
9208
9258
  else target[key] = value;
9209
9259
  }
9260
+ function assetStyles(raw, asset) {
9261
+ const own = Array.isArray(asset.styles) ? asset.styles : [];
9262
+ return own.length ? own : Object.keys(raw.styles ?? {});
9263
+ }
9210
9264
  function applyEdit(raw, edit) {
9211
9265
  raw.assets ??= {};
9266
+ if (edit.action === "set-source" || edit.action === "clear-source") {
9267
+ const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
9268
+ if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
9269
+ if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
9270
+ throw new ManifestEditError(`unknown style "${edit.styleId}"`);
9271
+ }
9272
+ if (!assetStyles(raw, asset2).includes(edit.styleId)) {
9273
+ throw new ManifestEditError(`asset "${edit.assetId}" is not in style "${edit.styleId}"`);
9274
+ }
9275
+ const single = assetStyles(raw, asset2).length === 1;
9276
+ const byStyle = { ...asset2.sourceByStyle ?? {} };
9277
+ if (edit.action === "set-source") {
9278
+ if (single) {
9279
+ asset2.source = edit.source;
9280
+ delete byStyle[edit.styleId];
9281
+ } else {
9282
+ byStyle[edit.styleId] = edit.source;
9283
+ }
9284
+ } else {
9285
+ delete byStyle[edit.styleId];
9286
+ if (single) delete asset2.source;
9287
+ }
9288
+ if (Object.keys(byStyle).length) asset2.sourceByStyle = byStyle;
9289
+ else delete asset2.sourceByStyle;
9290
+ return;
9291
+ }
9212
9292
  if (edit.action === "patch-style") {
9213
9293
  const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
9214
9294
  if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
@@ -9231,6 +9311,8 @@ function applyEdit(raw, edit) {
9231
9311
  const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
9232
9312
  setOrDelete(style, "palette", colors.length ? colors : null);
9233
9313
  }
9314
+ if (patch2.view !== void 0) setOrDelete(style, "view", patch2.view?.trim() || null);
9315
+ if (patch2.noBackground !== void 0) setOrDelete(style, "noBackground", patch2.noBackground);
9234
9316
  return;
9235
9317
  }
9236
9318
  if (edit.action === "add-asset") {
@@ -9310,10 +9392,134 @@ async function applyManifestEdit(manifestPath, edit) {
9310
9392
  }
9311
9393
  return { manifestPath: absolute, sha256: sha256(next), changed: true };
9312
9394
  }
9395
+
9396
+ // src/pipeline/hand-edit.ts
9397
+ import { spawn as spawn2 } from "child_process";
9398
+ import { existsSync as existsSync18 } from "fs";
9399
+ import { copyFile, mkdir as mkdir6 } from "fs/promises";
9400
+ import path22 from "path";
9401
+ var HAND_EDIT_DIR = "edits";
9402
+ function handEditPath(loaded, spec) {
9403
+ const style = loaded.manifest.styles[spec.styleId];
9404
+ const outDir = path22.resolve(loaded.root, style?.outDir ?? ".");
9405
+ const relative = path22.relative(outDir, spec.outFile);
9406
+ const inside = relative && !relative.startsWith("..") && !path22.isAbsolute(relative);
9407
+ const file = inside ? relative : path22.basename(spec.outFile);
9408
+ return path22.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
9409
+ }
9410
+ function handEditBase(spec, lock) {
9411
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9412
+ if (entry) {
9413
+ if (entry.outputs.length !== 1) return null;
9414
+ const primary = primaryOutput(entry);
9415
+ if (!primary || primary.mediaType && primary.mediaType !== "image/png") return null;
9416
+ const file = currentEntryOutputPath(entry, spec, 0);
9417
+ return existsSync18(file) ? file : null;
9418
+ }
9419
+ return existsSync18(spec.outFile) ? spec.outFile : null;
9420
+ }
9421
+ async function startHandEdit(loaded, lock, spec, opts = {}) {
9422
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9423
+ if (entry && entry.outputs.length > 1) {
9424
+ throw new ManifestEditError(
9425
+ `${spec.styleId}/${spec.assetId} has ${entry.outputs.length} outputs; hand edits currently cover single-image assets`
9426
+ );
9427
+ }
9428
+ const existing = spec.source ? path22.resolve(loaded.root, spec.source) : null;
9429
+ const editPath = existing ?? handEditPath(loaded, spec);
9430
+ let created = false;
9431
+ if (!existsSync18(editPath)) {
9432
+ const base = handEditBase(spec, lock);
9433
+ if (!base) {
9434
+ throw new ManifestEditError(
9435
+ `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
9436
+ );
9437
+ }
9438
+ await mkdir6(path22.dirname(editPath), { recursive: true });
9439
+ await copyFile(base, editPath);
9440
+ created = true;
9441
+ }
9442
+ const source = portableOutputPath(editPath, loaded.root);
9443
+ let declared = false;
9444
+ let manifestSha256 = opts.expectedSha256 ?? await sha256File(loaded.path);
9445
+ if (spec.source !== source) {
9446
+ const result = await applyManifestEdit(loaded.path, {
9447
+ action: "set-source",
9448
+ assetId: spec.assetId,
9449
+ styleId: spec.styleId,
9450
+ source,
9451
+ expectedSha256: manifestSha256
9452
+ });
9453
+ declared = result.changed;
9454
+ manifestSha256 = result.sha256;
9455
+ }
9456
+ return { editPath, source, created, declared, manifestSha256 };
9457
+ }
9458
+ async function detachHandEdit(loaded, spec, opts = {}) {
9459
+ if (!spec.source) {
9460
+ return { manifestSha256: opts.expectedSha256 ?? await sha256File(loaded.path), changed: false };
9461
+ }
9462
+ const result = await applyManifestEdit(loaded.path, {
9463
+ action: "clear-source",
9464
+ assetId: spec.assetId,
9465
+ styleId: spec.styleId,
9466
+ expectedSha256: opts.expectedSha256 ?? await sha256File(loaded.path)
9467
+ });
9468
+ return { manifestSha256: result.sha256, changed: result.changed };
9469
+ }
9470
+ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
9471
+ const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
9472
+ const [program, ...args] = command;
9473
+ const child = spawn2(program, [...args, file], { stdio: "ignore", detached: true });
9474
+ child.on("error", () => {
9475
+ });
9476
+ child.unref();
9477
+ return command.join(" ");
9478
+ }
9479
+
9480
+ // src/gallery/edit.ts
9481
+ var HandEditRequestSchema = z8.discriminatedUnion("action", [
9482
+ z8.object({
9483
+ action: z8.literal("start-edit"),
9484
+ project: z8.string().min(1).optional(),
9485
+ assetId: z8.string().min(1),
9486
+ styleId: z8.string().min(1),
9487
+ expectedSha256: z8.string().regex(/^[0-9a-f]{64}$/),
9488
+ /** Also launch the author's editor on the file. */
9489
+ open: z8.boolean().optional()
9490
+ }).strict(),
9491
+ z8.object({
9492
+ action: z8.literal("detach-edit"),
9493
+ project: z8.string().min(1).optional(),
9494
+ assetId: z8.string().min(1),
9495
+ styleId: z8.string().min(1),
9496
+ expectedSha256: z8.string().regex(/^[0-9a-f]{64}$/)
9497
+ }).strict()
9498
+ ]);
9313
9499
  function createGalleryEditHandler(opts) {
9314
9500
  const log = opts.onProgress ?? (() => {
9315
9501
  });
9316
9502
  return async (body) => {
9503
+ const handEdit = HandEditRequestSchema.safeParse(body);
9504
+ if (handEdit.success) {
9505
+ if (!opts.loadProject) throw new ManifestEditError("hand edits are not available in this gallery");
9506
+ const request = handEdit.data;
9507
+ const ctx = await opts.loadProject(request.project);
9508
+ const spec = ctx.specs.find((candidate) => lockKey(candidate.styleId, candidate.assetId) === lockKey(request.styleId, request.assetId));
9509
+ if (!spec) throw new ManifestEditError(`"${request.styleId}/${request.assetId}" is not declared by the manifest`);
9510
+ if (request.action === "start-edit") {
9511
+ const started = await startHandEdit(ctx.loaded, ctx.lock, spec, { expectedSha256: request.expectedSha256 });
9512
+ log(` hand edit ${started.created ? "created" : "found"}: ${started.source}${started.declared ? " (declared in the manifest)" : ""}`);
9513
+ if (request.open) {
9514
+ const command = (opts.openEditor ?? openInEditor)(started.editPath);
9515
+ log(` opened with: ${command}`);
9516
+ }
9517
+ } else {
9518
+ const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
9519
+ if (result2.changed) log(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
9520
+ }
9521
+ return opts.reload();
9522
+ }
9317
9523
  const parsed = ManifestEditSchema.safeParse(body);
9318
9524
  if (!parsed.success) {
9319
9525
  throw new ManifestEditError(
@@ -9328,9 +9534,9 @@ function createGalleryEditHandler(opts) {
9328
9534
  }
9329
9535
  const result = await applyManifestEdit(manifestPath, parsed.data);
9330
9536
  if (result.changed) {
9331
- const relative = path21.relative(process.cwd(), result.manifestPath);
9537
+ const relative = path23.relative(process.cwd(), result.manifestPath);
9332
9538
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
9333
- const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
9539
+ 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;
9334
9540
  log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
9335
9541
  }
9336
9542
  return opts.reload();
@@ -9339,7 +9545,7 @@ function createGalleryEditHandler(opts) {
9339
9545
 
9340
9546
  // src/gallery/snapshot.ts
9341
9547
  function galleryMediaId(absolutePath) {
9342
- return createHash4("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
9548
+ return createHash4("sha256").update(path24.resolve(absolutePath)).digest("hex").slice(0, 24);
9343
9549
  }
9344
9550
  function galleryMediaRoute(id) {
9345
9551
  return `/media/${id}`;
@@ -9364,23 +9570,24 @@ function metadataFps(entry) {
9364
9570
  }
9365
9571
  return null;
9366
9572
  }
9367
- async function fileBytes(absolutePath) {
9573
+ async function fileInfo(absolutePath) {
9368
9574
  try {
9369
9575
  const info = await stat3(absolutePath);
9370
- return info.isFile() ? info.size : null;
9576
+ return info.isFile() ? { bytes: info.size, modifiedAt: info.mtime.toISOString() } : null;
9371
9577
  } catch {
9372
9578
  return null;
9373
9579
  }
9374
9580
  }
9375
9581
  async function describeOutput(media, root, absolutePath, recorded = {}) {
9376
- const bytes = await fileBytes(absolutePath);
9377
- const exists = bytes !== null;
9582
+ const info = await fileInfo(absolutePath);
9583
+ const exists = info !== null;
9378
9584
  const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
9379
9585
  let url = null;
9380
9586
  if (exists && mediaType) {
9381
9587
  const id = galleryMediaId(absolutePath);
9382
9588
  media.set(id, { path: absolutePath, contentType: mediaType });
9383
- url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
9589
+ const version = recorded.sha256 ?? `${info.bytes}-${Date.parse(info.modifiedAt).toString(36)}`;
9590
+ url = `${galleryMediaRoute(id)}?v=${version.slice(0, 24)}`;
9384
9591
  }
9385
9592
  return {
9386
9593
  path: portableOutputPath(absolutePath, root),
@@ -9389,17 +9596,18 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
9389
9596
  role: recorded.role ?? null,
9390
9597
  mediaType,
9391
9598
  exists,
9392
- bytes,
9599
+ bytes: info?.bytes ?? null,
9600
+ modifiedAt: info?.modifiedAt ?? null,
9393
9601
  url
9394
9602
  };
9395
9603
  }
9396
9604
  async function describeQuality(media, root, inspection) {
9397
9605
  const outputPaths = inspection.outputs ?? [inspection.output];
9398
9606
  const outputs = await Promise.all(
9399
- outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
9607
+ outputPaths.map((file) => describeOutput(media, root, path24.resolve(root, file)))
9400
9608
  );
9401
- const recordPath = path22.resolve(root, inspection.record);
9402
- const recordExists = existsSync18(recordPath);
9609
+ const recordPath = path24.resolve(root, inspection.record);
9610
+ const recordExists = existsSync19(recordPath);
9403
9611
  let options = null;
9404
9612
  let check = null;
9405
9613
  if (recordExists) {
@@ -9418,7 +9626,7 @@ async function describeQuality(media, root, inspection) {
9418
9626
  return {
9419
9627
  state: inspection.state,
9420
9628
  reason: inspection.reason,
9421
- output: portableOutputPath(path22.resolve(root, inspection.output), root),
9629
+ output: portableOutputPath(path24.resolve(root, inspection.output), root),
9422
9630
  record: portableOutputPath(recordPath, root),
9423
9631
  recordExists,
9424
9632
  outputs,
@@ -9468,15 +9676,24 @@ async function buildGallerySnapshot(opts) {
9468
9676
  const entry = lock.entries[key];
9469
9677
  const asset = loaded.manifest.assets[spec.assetId] ?? null;
9470
9678
  let outputs;
9679
+ let edit = null;
9680
+ let editStatus = null;
9471
9681
  if (entry && entry.outputs.length) {
9472
9682
  outputs = await Promise.all(
9473
9683
  entry.outputs.map(
9474
9684
  (output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
9475
9685
  )
9476
9686
  );
9687
+ if (spec.source) {
9688
+ const editPath = path24.resolve(root, spec.source);
9689
+ const editSha = existsSync19(editPath) ? await sha256File(editPath) : null;
9690
+ edit = await describeOutput(media, root, editPath, { sha256: editSha });
9691
+ const generated = outputs[0];
9692
+ editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists ? "regenerated-since" : "edited";
9693
+ }
9477
9694
  } else if (spec.source) {
9478
- outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
9479
- } else if (!entry && existsSync18(spec.outFile)) {
9695
+ outputs = [await describeOutput(media, root, path24.resolve(root, spec.source))];
9696
+ } else if (!entry && existsSync19(spec.outFile)) {
9480
9697
  outputs = [await describeOutput(media, root, spec.outFile)];
9481
9698
  } else {
9482
9699
  outputs = [];
@@ -9520,6 +9737,8 @@ async function buildGallerySnapshot(opts) {
9520
9737
  providerMetadata: entry?.providerMetadata ?? {},
9521
9738
  asset,
9522
9739
  source: spec.source ?? null,
9740
+ edit,
9741
+ editStatus,
9523
9742
  tags: spec.tags,
9524
9743
  category: asset?.category ?? null
9525
9744
  });
@@ -9572,6 +9791,8 @@ async function buildGallerySnapshot(opts) {
9572
9791
  providerMetadata: entry.providerMetadata ?? {},
9573
9792
  asset: null,
9574
9793
  source: null,
9794
+ edit: null,
9795
+ editStatus: null,
9575
9796
  tags: [],
9576
9797
  category: null
9577
9798
  });
@@ -9610,6 +9831,8 @@ async function buildGallerySnapshot(opts) {
9610
9831
  promptPrefix: style?.promptPrefix ?? "",
9611
9832
  promptSuffix: style?.promptSuffix ?? "",
9612
9833
  palette: style?.palette ?? [],
9834
+ view: style?.view ?? null,
9835
+ noBackground: style?.noBackground ?? true,
9613
9836
  quality: Boolean(style?.quality),
9614
9837
  tags: style?.tags ?? [],
9615
9838
  extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
@@ -9637,7 +9860,7 @@ async function buildGallerySnapshot(opts) {
9637
9860
  id: loaded.manifest.name,
9638
9861
  name: loaded.manifest.name,
9639
9862
  manifest: loaded.path,
9640
- lock: path22.resolve(opts.lockPath),
9863
+ lock: path24.resolve(opts.lockPath),
9641
9864
  root,
9642
9865
  provider: loaded.manifest.provider,
9643
9866
  account: null,
@@ -9662,7 +9885,7 @@ async function buildGallerySnapshot(opts) {
9662
9885
  return { snapshot, media };
9663
9886
  }
9664
9887
  async function buildWorkspaceGallerySnapshot(opts) {
9665
- const dir = path22.dirname(path22.resolve(opts.workspacePath));
9888
+ const dir = path24.dirname(path24.resolve(opts.workspacePath));
9666
9889
  const media = /* @__PURE__ */ new Map();
9667
9890
  const projects = [];
9668
9891
  const items = [];
@@ -9705,7 +9928,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9705
9928
  id: project.id,
9706
9929
  name: loaded.manifest.name,
9707
9930
  manifest: manifestPath,
9708
- lock: path22.resolve(lockPath),
9931
+ lock: path24.resolve(lockPath),
9709
9932
  root: loaded.root,
9710
9933
  provider: loaded.manifest.provider,
9711
9934
  account: project.account ?? null,
@@ -9720,8 +9943,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
9720
9943
  id: project.id,
9721
9944
  name: project.id,
9722
9945
  manifest: manifestPath,
9723
- lock: path22.resolve(lockPath),
9724
- root: path22.dirname(manifestPath),
9946
+ lock: path24.resolve(lockPath),
9947
+ root: path24.dirname(manifestPath),
9725
9948
  provider: project.provider,
9726
9949
  account: project.account ?? null,
9727
9950
  manifestSha256: null,
@@ -9747,7 +9970,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9747
9970
  version: 1,
9748
9971
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9749
9972
  project: null,
9750
- workspace: { path: path22.resolve(opts.workspacePath), dir, projects },
9973
+ workspace: { path: path24.resolve(opts.workspacePath), dir, projects },
9751
9974
  filter,
9752
9975
  totals: {
9753
9976
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -10034,6 +10257,14 @@ function renderGallery(snapshot, opts = {}) {
10034
10257
  .tray > button { padding:5px 11px; font-size:12.5px; }
10035
10258
  @media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
10036
10259
  .drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
10260
+ .pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
10261
+ .pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
10262
+ background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
10263
+ .pair figure img { image-rendering:pixelated; display:block; max-width:100%; }
10264
+ .pair figcaption { font:11px/1.4 var(--mono); color:var(--dim); background:var(--panel); padding:2px 6px; margin-top:6px; }
10265
+ .card .pen { position:absolute; right:6px; top:6px; background:var(--panel); color:var(--accent-soft); border:1px solid var(--accent);
10266
+ font:700 11px/1 var(--mono); padding:3px 5px; }
10267
+ .hand-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
10037
10268
  .drawer .gen button.primary { padding:7px 14px; }
10038
10269
  @media (max-width: 720px) {
10039
10270
  .bar, .totals, .chips, .style, footer { padding-inline:14px; }
@@ -10084,6 +10315,7 @@ function renderGallery(snapshot, opts = {}) {
10084
10315
  </footer>
10085
10316
  <div id="drawer-host"></div>
10086
10317
  <div id="dialog-host"></div>
10318
+ <datalist id="view-options"><option value="low top-down"><option value="high top-down"><option value="side"><option value="sidescroller"></datalist>
10087
10319
  <script>
10088
10320
  const INITIAL = ${data};
10089
10321
  const SESSION = ${session};
@@ -10479,7 +10711,8 @@ function clearFilters() {
10479
10711
 
10480
10712
  function thumb(item) {
10481
10713
  const cell = el('div', 'cell');
10482
- const shown = item.outputs.filter((o) => o.url);
10714
+ // A hand edit is what ships, so it is what the card shows.
10715
+ const shown = item.edit && item.edit.url && item.editStatus === 'edited' ? [item.edit] : item.outputs.filter((o) => o.url);
10483
10716
  if (!shown.length) {
10484
10717
  cell.classList.add('none');
10485
10718
  cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
@@ -10530,6 +10763,9 @@ function card(item) {
10530
10763
  body.append(meta);
10531
10764
  const cell = thumb(item);
10532
10765
  if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
10766
+ if (item.editStatus === 'edited' || item.editStatus === 'regenerated-since') {
10767
+ const pen = el('span', 'pen', '\u270E'); pen.title = 'hand-edited'; cell.append(pen);
10768
+ }
10533
10769
  c.append(cell, body);
10534
10770
  c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
10535
10771
  return c;
@@ -10856,6 +11092,78 @@ function openCompare() {
10856
11092
  close.focus({ preventScroll: true });
10857
11093
  }
10858
11094
 
11095
+ // ---- hand edits -------------------------------------------------------------
11096
+
11097
+ const EDIT_STATUS_TEXT = {
11098
+ same: 'A copy of the generated art, not changed yet. Open it in your editor and save.',
11099
+ edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
11100
+ 'regenerated-since': 'The generated art changed after this edit was saved \u2014 the edit is based on an older generation.',
11101
+ missing: 'The declared file is not on disk.',
11102
+ };
11103
+ async function postHandEdit(item, action, extra) {
11104
+ const pr = projectOf(item);
11105
+ const body = { action, assetId: item.assetId, styleId: item.styleId, expectedSha256: pr.manifestSha256, ...extra };
11106
+ if (item.project) body.project = item.project;
11107
+ snap = await postEdit(body);
11108
+ }
11109
+ function handEditSection(item) {
11110
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 &&
11111
+ item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png';
11112
+ if (!item.edit && !canStart) return null;
11113
+ const s = el('section', 'meta');
11114
+ s.append(el('h3', null, 'Hand edit'));
11115
+ if (item.edit) {
11116
+ const pair = el('div', 'pair');
11117
+ const shared = displayScale(item.width, item.height, 200, 160);
11118
+ const fig = (out, label) => {
11119
+ const f = el('figure');
11120
+ if (out && out.url) {
11121
+ const img = el('img'); img.src = out.url; img.alt = label;
11122
+ if (shared >= 1) { img.width = item.width * shared; img.height = item.height * shared; }
11123
+ f.append(img);
11124
+ } else f.append(el('span', 'state-dim', 'not on disk'));
11125
+ f.append(el('figcaption', null, label));
11126
+ return f;
11127
+ };
11128
+ pair.append(fig(item.outputs[0], 'generated'), fig(item.edit, 'edit'));
11129
+ s.append(pair);
11130
+ const dl = el('dl');
11131
+ const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
11132
+ const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
11133
+ row(dl, 'status', st);
11134
+ row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
11135
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11136
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11137
+ row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11138
+ s.append(dl);
11139
+ } else {
11140
+ 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.'));
11141
+ }
11142
+ if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
11143
+ const acts = el('div', 'hand-actions');
11144
+ const msg = el('span', 'msg');
11145
+ const go = async (label, action, extra, done) => {
11146
+ const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
11147
+ b.onclick = async () => {
11148
+ b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
11149
+ try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
11150
+ catch (err) { b.disabled = false; msg.className = 'msg bad'; msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : ''); }
11151
+ };
11152
+ acts.append(b);
11153
+ };
11154
+ if (item.edit) {
11155
+ go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11156
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11157
+ } else if (canStart) {
11158
+ 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.');
11159
+ go('Create edit file only', 'start-edit', { open: false }, 'Created the edit file and declared it in the manifest.');
11160
+ }
11161
+ acts.append(msg);
11162
+ s.append(acts);
11163
+ }
11164
+ return s;
11165
+ }
11166
+
10859
11167
  // ---- editing (only when the server minted a session) ---------------------
10860
11168
 
10861
11169
  // Styles a parent edit reaches: every descendant (via extends) that does not
@@ -10875,9 +11183,18 @@ function inheritorsOf(style, field) {
10875
11183
  }
10876
11184
  return out;
10877
11185
  }
11186
+ // noBackground reaches the request only for pixflux and non-PixelLab
11187
+ // providers; elsewhere a change is recorded but alters nothing.
11188
+ const fieldReaches = (field, style) =>
11189
+ field !== 'noBackground' || style.generator === 'pixflux' || style.provider !== 'pixellab';
10878
11190
  function blastRadius(style, fields) {
10879
- const styleIds = new Set([style.id]);
10880
- for (const field of fields) for (const id of inheritorsOf(style, field)) styleIds.add(id);
11191
+ const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
11192
+ const styleIds = new Set();
11193
+ for (const field of fields) {
11194
+ for (const id of [style.id, ...inheritorsOf(style, field)]) {
11195
+ if (fieldReaches(field, byId.get(id) || style)) styleIds.add(id);
11196
+ }
11197
+ }
10881
11198
  const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
10882
11199
  const units = new Map();
10883
11200
  for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
@@ -10902,6 +11219,11 @@ function styleForm(style) {
10902
11219
  const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
10903
11220
  const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
10904
11221
  const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb \u2014 leave empty for no forced palette';
11222
+ const view = el('input'); view.type = 'text'; view.value = style.view || ''; view.placeholder = 'provider default';
11223
+ view.setAttribute('list', 'view-options');
11224
+ const noBg = el('select');
11225
+ 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'));
11226
+ noBg.value = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
10905
11227
  const swatches = el('div', 'pal');
10906
11228
  const drawSwatches = () => {
10907
11229
  swatches.textContent = '';
@@ -10914,12 +11236,21 @@ function styleForm(style) {
10914
11236
  field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
10915
11237
  swatches,
10916
11238
  );
11239
+ const row = el('div', 'row');
11240
+ row.append(
11241
+ field('view', view, 'PixelLab accepts low top-down, high top-down, side; Retro Diffusion reads sidescroller. ' + provenance('view')),
11242
+ field('background', noBg, (fieldReaches('noBackground', style) ? 'Sent for this style. ' : 'Not sent for ' + style.provider + ' ' + style.generator + '; recorded only. ') + provenance('noBackground')),
11243
+ );
11244
+ form.append(row);
10917
11245
  const blast = el('div', 'blast');
10918
11246
  const changed = () => {
10919
11247
  const out = [];
10920
11248
  if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
10921
11249
  if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
10922
11250
  if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
11251
+ if (view.value.trim() !== (style.view || '')) out.push('view');
11252
+ const bgNow = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
11253
+ if (noBg.value !== bgNow) out.push('noBackground');
10923
11254
  return out;
10924
11255
  };
10925
11256
  const updateBlast = () => {
@@ -10927,6 +11258,11 @@ function styleForm(style) {
10927
11258
  drawSwatches();
10928
11259
  if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
10929
11260
  const r = blastRadius(style, fields);
11261
+ if (!r.assets) {
11262
+ blast.className = 'blast';
11263
+ blast.textContent = 'Recorded in the manifest only: nothing in ' + style.id + ' sends this field, so no request changes.';
11264
+ return;
11265
+ }
10930
11266
  blast.className = 'blast hot';
10931
11267
  blast.textContent = '';
10932
11268
  const others = r.styles.filter((id) => id !== style.id);
@@ -10936,7 +11272,8 @@ function styleForm(style) {
10936
11272
  : ' in ' + style.id + '.'));
10937
11273
  blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
10938
11274
  };
10939
- for (const input of [prefix, suffix, palette]) input.addEventListener('input', updateBlast);
11275
+ for (const input of [prefix, suffix, palette, view]) input.addEventListener('input', updateBlast);
11276
+ noBg.addEventListener('change', updateBlast);
10940
11277
  updateBlast();
10941
11278
  form.append(blast);
10942
11279
  const actions = el('div', 'actions');
@@ -10955,6 +11292,8 @@ function styleForm(style) {
10955
11292
  if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
10956
11293
  if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
10957
11294
  if (fields.includes('palette')) patch.palette = colors;
11295
+ if (fields.includes('view')) patch.view = view.value.trim();
11296
+ if (fields.includes('noBackground')) patch.noBackground = noBg.value === 'default' ? null : noBg.value === 'on';
10958
11297
  save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
10959
11298
  const r = blastRadius(style, fields);
10960
11299
  try {
@@ -10963,7 +11302,9 @@ function styleForm(style) {
10963
11302
  snap = await postEdit(body);
10964
11303
  ui.editing = null;
10965
11304
  ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
10966
- 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.' };
11305
+ text: r.assets
11306
+ ? '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.'
11307
+ : 'Saved. Recorded in the manifest; no request changed.' };
10967
11308
  render();
10968
11309
  } catch (err) {
10969
11310
  save.disabled = false;
@@ -11314,6 +11655,9 @@ function renderDrawer() {
11314
11655
  body.append(s);
11315
11656
  }
11316
11657
 
11658
+ const hand = handEditSection(item);
11659
+ if (hand) body.append(hand);
11660
+
11317
11661
  if (item.revision || item.revisionParentKey) {
11318
11662
  const { s, dl } = section('Lineage');
11319
11663
  row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
@@ -11562,7 +11906,7 @@ if (ui.compare.length >= 2 && !ui.open) openCompare();
11562
11906
  var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11563
11907
 
11564
11908
  // src/gallery/server.ts
11565
- import { spawn as spawn2 } from "child_process";
11909
+ import { spawn as spawn3 } from "child_process";
11566
11910
  import { randomBytes } from "crypto";
11567
11911
  import { createServer as createServer2 } from "http";
11568
11912
  import { readFile as readFile19 } from "fs/promises";
@@ -11785,7 +12129,7 @@ async function serveGallery(opts) {
11785
12129
  });
11786
12130
  opts.onReady?.(url);
11787
12131
  if (opts.open !== false && process.platform === "darwin") {
11788
- spawn2("open", [url], { stdio: "ignore", detached: true }).unref();
12132
+ spawn3("open", [url], { stdio: "ignore", detached: true }).unref();
11789
12133
  }
11790
12134
  return {
11791
12135
  url,
@@ -11799,14 +12143,14 @@ async function serveGallery(opts) {
11799
12143
 
11800
12144
  // src/gallery/generate.ts
11801
12145
  import { randomBytes as randomBytes2 } from "crypto";
11802
- import { z as z8 } from "zod";
11803
- var GenerateRequestSchema = z8.object({
11804
- project: z8.string().min(1).optional(),
11805
- keys: z8.array(z8.string().min(1)).min(1).max(500),
12146
+ import { z as z9 } from "zod";
12147
+ var GenerateRequestSchema = z9.object({
12148
+ project: z9.string().min(1).optional(),
12149
+ keys: z9.array(z9.string().min(1)).min(1).max(500),
11806
12150
  /** Regenerate work that is up to date, as `gen --force` does. */
11807
- force: z8.boolean().optional(),
12151
+ force: z9.boolean().optional(),
11808
12152
  /** Advance in-flight, review, or selected work without submitting anything. */
11809
- resume: z8.boolean().optional()
12153
+ resume: z9.boolean().optional()
11810
12154
  }).strict();
11811
12155
  var GenerateRequestError = class extends Error {
11812
12156
  constructor(message6, status = 400) {
@@ -12044,15 +12388,15 @@ function createGenerateHandlers(opts) {
12044
12388
 
12045
12389
  // src/pipeline/salvage.ts
12046
12390
  import { readFile as readFile20 } from "fs/promises";
12047
- import { existsSync as existsSync19 } from "fs";
12048
- import path23 from "path";
12391
+ import { existsSync as existsSync20 } from "fs";
12392
+ import path25 from "path";
12049
12393
  function providerClaimId(provider, objectId) {
12050
12394
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
12051
12395
  }
12052
12396
  async function loadClaims(lockPaths, opts = {}) {
12053
12397
  const claimed = /* @__PURE__ */ new Set();
12054
12398
  for (const p of lockPaths) {
12055
- if (!existsSync19(p)) throw new Error(`Claim lockfile not found: ${p}`);
12399
+ if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
12056
12400
  let parsed;
12057
12401
  try {
12058
12402
  parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
@@ -12108,20 +12452,20 @@ function matchOrphanStyle(prompt, manifest) {
12108
12452
  return matchStyleByPattern(prompt, manifest);
12109
12453
  }
12110
12454
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
12111
- const own = path23.resolve(ownManifestPath);
12455
+ const own = path25.resolve(ownManifestPath);
12112
12456
  const siblingManifestPaths = [
12113
12457
  .../* @__PURE__ */ new Set([
12114
12458
  ...workspaceManifestPaths,
12115
- ...claimPaths.map((c) => path23.join(path23.dirname(path23.resolve(c)), "pixelkiln.manifest.json"))
12459
+ ...claimPaths.map((c) => path25.join(path25.dirname(path25.resolve(c)), "pixelkiln.manifest.json"))
12116
12460
  ])
12117
12461
  ];
12118
12462
  const siblings = [];
12119
12463
  for (const siblingManifestPath of siblingManifestPaths) {
12120
- if (path23.resolve(siblingManifestPath) === own) continue;
12121
- if (!existsSync19(siblingManifestPath)) continue;
12464
+ if (path25.resolve(siblingManifestPath) === own) continue;
12465
+ if (!existsSync20(siblingManifestPath)) continue;
12122
12466
  try {
12123
12467
  const { manifest } = await loadManifest(siblingManifestPath);
12124
- siblings.push({ label: path23.basename(path23.dirname(siblingManifestPath)), manifest });
12468
+ siblings.push({ label: path25.basename(path25.dirname(siblingManifestPath)), manifest });
12125
12469
  } catch {
12126
12470
  }
12127
12471
  }
@@ -12237,8 +12581,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
12237
12581
  }
12238
12582
 
12239
12583
  // src/pick/salvage-server.ts
12240
- import { mkdir as mkdir6, writeFile as writeFile9, readFile as readFile21 } from "fs/promises";
12241
- import path24 from "path";
12584
+ import { mkdir as mkdir7, writeFile as writeFile9, readFile as readFile21 } from "fs/promises";
12585
+ import path26 from "path";
12242
12586
 
12243
12587
  // src/pick/salvage-sheet.ts
12244
12588
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -12403,7 +12747,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12403
12747
  });
12404
12748
  const html = renderSalvageSheet(orphans, {
12405
12749
  styleId: ctx.styleId,
12406
- importDir: path24.relative(process.cwd(), ctx.importDir) || "."
12750
+ importDir: path26.relative(process.cwd(), ctx.importDir) || "."
12407
12751
  });
12408
12752
  const byId = new Map(orphans.map((o) => [o.id, o]));
12409
12753
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -12432,13 +12776,14 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12432
12776
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
12433
12777
  decodePng(buf);
12434
12778
  const assetId = idFromPrompt(orphan.prompt, taken);
12435
- const rel = path24.join("_salvaged", `${assetId}.png`);
12436
- const outFile = path24.resolve(ctx.importDir, rel);
12437
- await mkdir6(path24.dirname(outFile), { recursive: true });
12779
+ const rel = path26.join("_salvaged", `${assetId}.png`);
12780
+ const outFile = path26.resolve(ctx.importDir, rel);
12781
+ await mkdir7(path26.dirname(outFile), { recursive: true });
12438
12782
  await writeFile9(outFile, buf);
12439
12783
  ctx.manifest.assets[assetId] = {
12440
12784
  prompt: orphan.prompt,
12441
12785
  promptByStyle: {},
12786
+ sourceByStyle: {},
12442
12787
  category: "_salvaged",
12443
12788
  file: rel,
12444
12789
  tags: ["salvaged"],
@@ -12466,7 +12811,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12466
12811
  sourceUrl: durableSource,
12467
12812
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
12468
12813
  outputs: [{
12469
- path: portableOutputPath(outFile, path24.dirname(ctx.manifestPath)),
12814
+ path: portableOutputPath(outFile, path26.dirname(ctx.manifestPath)),
12470
12815
  sha256: sha256(buf)
12471
12816
  }],
12472
12817
  submittedAt: orphan.createdAt,