pixelkiln 0.72.0 → 0.74.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
@@ -1325,7 +1325,7 @@ var AssetSchema = import_zod.z.object({
1325
1325
  path: ["mirror"]
1326
1326
  });
1327
1327
  }
1328
- if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit) {
1328
+ if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit && !asset.animation?.template) {
1329
1329
  context.addIssue({
1330
1330
  code: import_zod.z.ZodIssueCode.custom,
1331
1331
  message: "Required",
@@ -2060,9 +2060,9 @@ var PixelLabClient = class {
2060
2060
  apiKey;
2061
2061
  http;
2062
2062
  /** Retries transport failures, 429, and 5xx; see http.ts for the policy. */
2063
- async request(path35, init) {
2063
+ async request(path36, init) {
2064
2064
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
2065
- const res = await this.http(`${BASE}${path35}`, {
2065
+ const res = await this.http(`${BASE}${path36}`, {
2066
2066
  ...init,
2067
2067
  headers: {
2068
2068
  Authorization: auth,
@@ -2072,13 +2072,13 @@ var PixelLabClient = class {
2072
2072
  });
2073
2073
  const text = await res.text();
2074
2074
  if (!res.ok) {
2075
- throw new PixelLabError(`${init?.method ?? "GET"} ${path35} \u2192 ${res.status}`, res.status, text);
2075
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path36} \u2192 ${res.status}`, res.status, text);
2076
2076
  }
2077
2077
  if (!text) return {};
2078
2078
  try {
2079
2079
  return JSON.parse(text);
2080
2080
  } catch {
2081
- throw new Error(`${init?.method ?? "GET"} ${path35} returned invalid JSON`);
2081
+ throw new Error(`${init?.method ?? "GET"} ${path36} returned invalid JSON`);
2082
2082
  }
2083
2083
  }
2084
2084
  async balance() {
@@ -2470,10 +2470,10 @@ var PixelLabClient = class {
2470
2470
  const palette = args.paletteSwatchBase64 ? { color_image: { type: "base64", base64: args.paletteSwatchBase64, format: "png" }, force_colors: true } : {};
2471
2471
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
2472
2472
  const south = args.reference?.south;
2473
- let path35;
2473
+ let path36;
2474
2474
  let body;
2475
2475
  if (args.mode === "v3") {
2476
- path35 = "/create-character-v3";
2476
+ path36 = "/create-character-v3";
2477
2477
  body = {
2478
2478
  description: args.description,
2479
2479
  image_size: imageSize,
@@ -2487,7 +2487,7 @@ var PixelLabClient = class {
2487
2487
  ...args.enhancePrompt ? { enhance_prompt: true } : {}
2488
2488
  };
2489
2489
  } else if (args.mode === "pro-flash") {
2490
- path35 = "/create-character-pro-flash";
2490
+ path36 = "/create-character-pro-flash";
2491
2491
  const traits = args.styleTraits;
2492
2492
  body = {
2493
2493
  description: args.description,
@@ -2511,7 +2511,7 @@ var PixelLabClient = class {
2511
2511
  } : {}
2512
2512
  };
2513
2513
  } else if (args.mode === "pro") {
2514
- path35 = "/create-character-pro";
2514
+ path36 = "/create-character-pro";
2515
2515
  body = {
2516
2516
  description: args.description,
2517
2517
  image_size: imageSize,
@@ -2525,7 +2525,7 @@ var PixelLabClient = class {
2525
2525
  ...!south && args.styleCharacterId ? { style_character_id: args.styleCharacterId } : {}
2526
2526
  };
2527
2527
  } else {
2528
- path35 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
2528
+ path36 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
2529
2529
  body = {
2530
2530
  description: args.description,
2531
2531
  image_size: imageSize,
@@ -2542,8 +2542,8 @@ var PixelLabClient = class {
2542
2542
  ...palette
2543
2543
  };
2544
2544
  }
2545
- const raw = await this.request(path35, { method: "POST", body: JSON.stringify(body) });
2546
- return validateResponse(CharacterSubmitSchema, raw, path35.slice(1));
2545
+ const raw = await this.request(path36, { method: "POST", body: JSON.stringify(body) });
2546
+ return validateResponse(CharacterSubmitSchema, raw, path36.slice(1));
2547
2547
  }
2548
2548
  /** A text edit of an existing character, applied to every direction and stored as a sibling. */
2549
2549
  async createCharacterState(args) {
@@ -3729,10 +3729,10 @@ var import_node_fs = require("fs");
3729
3729
  function sha256(data) {
3730
3730
  return (0, import_node_crypto.createHash)("sha256").update(data).digest("hex");
3731
3731
  }
3732
- async function sha256File(path35) {
3732
+ async function sha256File(path36) {
3733
3733
  return new Promise((resolve, reject) => {
3734
3734
  const hash = (0, import_node_crypto.createHash)("sha256");
3735
- const stream = (0, import_node_fs.createReadStream)(path35);
3735
+ const stream = (0, import_node_fs.createReadStream)(path36);
3736
3736
  stream.on("data", (chunk2) => hash.update(chunk2));
3737
3737
  stream.on("error", reject);
3738
3738
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -3902,7 +3902,11 @@ function characterCost(spec) {
3902
3902
  return 20;
3903
3903
  }
3904
3904
  if (character.mode === "standard") return 1;
3905
- if (character.mode === "v3") return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
3905
+ if (character.mode === "v3") {
3906
+ const south = character.reference?.south;
3907
+ if (south) return Math.max(1, Math.ceil(south.width * south.height * 8 / 65536));
3908
+ return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
3909
+ }
3906
3910
  if (character.mode === "pro-flash") {
3907
3911
  const south = character.reference?.south;
3908
3912
  return south ? proFlashCharacterCost(south.width, south.height, true) : proFlashCharacterCost(spec.width, spec.height, false);
@@ -6096,9 +6100,9 @@ var RetroDiffusionClient = class {
6096
6100
  if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
6097
6101
  return Buffer.from(await response.arrayBuffer());
6098
6102
  }
6099
- async call(path35, init = {}) {
6103
+ async call(path36, init = {}) {
6100
6104
  if (!this.token) throw new Error("RD_API_KEY is not set");
6101
- const response = await this.request(`${this.baseUrl}${path35}`, {
6105
+ const response = await this.request(`${this.baseUrl}${path36}`, {
6102
6106
  ...init,
6103
6107
  headers: {
6104
6108
  "Content-Type": "application/json",
@@ -12387,10 +12391,10 @@ function isSha256Hash(value) {
12387
12391
  function parseCache(value) {
12388
12392
  return HashCacheSchema.parse(value);
12389
12393
  }
12390
- async function loadCache(path35) {
12391
- if (!(0, import_node_fs14.existsSync)(path35)) return { version: 1, hashes: {} };
12394
+ async function loadCache(path36) {
12395
+ if (!(0, import_node_fs14.existsSync)(path36)) return { version: 1, hashes: {} };
12392
12396
  try {
12393
- const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises11.readFile)(path35, "utf8")));
12397
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises11.readFile)(path36, "utf8")));
12394
12398
  if (!parsed.success) return { version: 1, hashes: {} };
12395
12399
  return {
12396
12400
  version: 1,
@@ -12402,18 +12406,18 @@ async function loadCache(path35) {
12402
12406
  return { version: 1, hashes: {} };
12403
12407
  }
12404
12408
  }
12405
- async function saveCache(path35, cache) {
12409
+ async function saveCache(path36, cache) {
12406
12410
  const sorted = {};
12407
12411
  for (const key of Object.keys(cache.hashes).sort()) {
12408
12412
  const hash = cache.hashes[key];
12409
12413
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
12410
12414
  sorted[key] = hash;
12411
12415
  }
12412
- await (0, import_promises11.mkdir)(import_node_path18.default.dirname(import_node_path18.default.resolve(path35)), { recursive: true });
12413
- const tmp = `${path35}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
12416
+ await (0, import_promises11.mkdir)(import_node_path18.default.dirname(import_node_path18.default.resolve(path36)), { recursive: true });
12417
+ const tmp = `${path36}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
12414
12418
  try {
12415
12419
  await (0, import_promises11.writeFile)(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
12416
- await (0, import_promises11.rename)(tmp, path35);
12420
+ await (0, import_promises11.rename)(tmp, path36);
12417
12421
  } finally {
12418
12422
  await (0, import_promises11.rm)(tmp, { force: true });
12419
12423
  }
@@ -15218,9 +15222,9 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
15218
15222
 
15219
15223
  // src/gallery/snapshot.ts
15220
15224
  var import_node_crypto6 = require("crypto");
15221
- var import_node_fs26 = require("fs");
15222
- var import_promises23 = require("fs/promises");
15223
- var import_node_path31 = __toESM(require("path"), 1);
15225
+ var import_node_fs27 = require("fs");
15226
+ var import_promises24 = require("fs/promises");
15227
+ var import_node_path32 = __toESM(require("path"), 1);
15224
15228
 
15225
15229
  // src/workspace.ts
15226
15230
  var import_promises19 = require("fs/promises");
@@ -15356,8 +15360,8 @@ function validateWorkspace(ws, dir) {
15356
15360
  }
15357
15361
 
15358
15362
  // src/gallery/edit.ts
15359
- var import_node_path30 = __toESM(require("path"), 1);
15360
- var import_zod11 = require("zod");
15363
+ var import_node_path31 = __toESM(require("path"), 1);
15364
+ var import_zod12 = require("zod");
15361
15365
 
15362
15366
  // src/manifest-edit.ts
15363
15367
  var import_promises20 = require("fs/promises");
@@ -15388,13 +15392,16 @@ var NewAnimationSchema = import_zod8.z.object({
15388
15392
  direction: CharacterDirectionSchema.optional(),
15389
15393
  frames: import_zod8.z.number().int().min(4).max(16).optional(),
15390
15394
  fps: import_zod8.z.number().int().min(1).max(60).optional(),
15395
+ /** One loop per named direction, plus free mirrors; expanded when the manifest loads. */
15396
+ directions: import_zod8.z.array(CharacterDirectionSchema).min(1).optional(),
15391
15397
  mode: CharacterAnimationModeSchema.optional(),
15392
15398
  /** What is being animated, when the parent's own description would mislead the model (`character` only). */
15393
15399
  subject: import_zod8.z.string().min(1).optional(),
15394
15400
  enhancePrompt: import_zod8.z.boolean().optional()
15395
15401
  }).strict();
15396
15402
  var NewAssetSchema = import_zod8.z.object({
15397
- prompt: import_zod8.z.string(),
15403
+ /** A portrait, an outfit, or a mirror has none of its own; the loader says where one is needed. */
15404
+ prompt: import_zod8.z.string().optional(),
15398
15405
  category: import_zod8.z.string().optional(),
15399
15406
  width: import_zod8.z.number().int().optional(),
15400
15407
  height: import_zod8.z.number().int().optional(),
@@ -15429,7 +15436,24 @@ var NewAssetSchema = import_zod8.z.object({
15429
15436
  /** A pose or outfit of an existing `character`/`objectPro` base or state. */
15430
15437
  state: NewStateSchema.optional(),
15431
15438
  /** A loop of an existing `character`/`objectPro` base or state. */
15432
- animation: NewAnimationSchema.optional()
15439
+ animation: NewAnimationSchema.optional(),
15440
+ /** A bust of an existing character. */
15441
+ portrait: CharacterPortraitSchema.optional(),
15442
+ /** An existing loop re-clothed from a reference image (uploaded first). */
15443
+ outfit: CharacterOutfitSchema.optional(),
15444
+ /** Another asset of the same style, flipped; free. */
15445
+ mirror: import_zod8.z.string().min(1).optional(),
15446
+ /**
15447
+ * `character`/`objectPro` bases: the subject's own sprite (uploaded
15448
+ * first), or one per direction, which PixelLab rotates instead of drawing.
15449
+ */
15450
+ reference: import_zod8.z.union([import_zod8.z.string().min(1), import_zod8.z.record(CharacterDirectionSchema, import_zod8.z.string().min(1))]).optional(),
15451
+ /** `character` pro bases: a concept image that seeds the design. */
15452
+ concept: import_zod8.z.string().min(1).optional(),
15453
+ /** `character` pro bases: another character in the style whose look this one follows. */
15454
+ styleCharacter: import_zod8.z.string().min(1).optional(),
15455
+ /** `character` standard humanoid bases: this character's proportions, over the style's. */
15456
+ proportions: CharacterProportionsSchema.optional()
15433
15457
  }).strict().refine((asset) => !(asset.revision && (asset.state || asset.animation)), {
15434
15458
  message: "an asset is a revision, a state, or an animation \u2014 not more than one"
15435
15459
  }).refine((asset) => !(asset.state && asset.animation), {
@@ -15440,59 +15464,95 @@ var CANDIDATE_OPTION = {
15440
15464
  comfyui: "numImages",
15441
15465
  scenario: "numOutputs"
15442
15466
  };
15467
+ var NewStyleSchema = import_zod8.z.object({
15468
+ generator: import_zod8.z.enum(["character", "objectPro"]),
15469
+ /** Where the style's art is written, inside the project. */
15470
+ outDir: import_zod8.z.string().min(1).refine((dir) => !dir.split(/[\\/]/).includes("..") && !/^([a-z]:)?[\\/]/i.test(dir), {
15471
+ message: "outDir must be a path inside the project"
15472
+ }),
15473
+ size: import_zod8.z.number().int().min(16).max(256).optional(),
15474
+ mode: CharacterModeSchema.optional(),
15475
+ directions: import_zod8.z.union([import_zod8.z.literal(4), import_zod8.z.literal(8)]).optional(),
15476
+ template: import_zod8.z.string().min(1).optional(),
15477
+ view: import_zod8.z.string().min(1).max(64).optional(),
15478
+ proportions: CharacterProportionsSchema.optional(),
15479
+ isometric: import_zod8.z.boolean().optional(),
15480
+ textGuidanceScale: import_zod8.z.number().min(1).max(20).optional(),
15481
+ enhancePrompt: import_zod8.z.boolean().optional(),
15482
+ promptPrefix: import_zod8.z.string().optional(),
15483
+ promptSuffix: import_zod8.z.string().optional(),
15484
+ palette: import_zod8.z.array(import_zod8.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).optional()
15485
+ }).strict();
15486
+ var PatchAssetEdit = import_zod8.z.object({
15487
+ action: import_zod8.z.literal("patch-asset"),
15488
+ assetId: import_zod8.z.string().min(1),
15489
+ patch: AssetPatchSchema
15490
+ }).strict();
15491
+ var AddAssetEdit = import_zod8.z.object({
15492
+ action: import_zod8.z.literal("add-asset"),
15493
+ assetId: import_zod8.z.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15494
+ asset: NewAssetSchema
15495
+ }).strict();
15496
+ var SetSourceEdit = import_zod8.z.object({
15497
+ action: import_zod8.z.literal("set-source"),
15498
+ assetId: import_zod8.z.string().min(1),
15499
+ styleId: import_zod8.z.string().min(1),
15500
+ /** Manifest-relative path of the art that stands in for this asset in this style. */
15501
+ source: import_zod8.z.string().min(1)
15502
+ }).strict();
15503
+ var ClearSourceEdit = import_zod8.z.object({
15504
+ action: import_zod8.z.literal("clear-source"),
15505
+ assetId: import_zod8.z.string().min(1),
15506
+ styleId: import_zod8.z.string().min(1)
15507
+ }).strict();
15508
+ var PatchStyleEdit = import_zod8.z.object({
15509
+ action: import_zod8.z.literal("patch-style"),
15510
+ styleId: import_zod8.z.string().min(1),
15511
+ patch: import_zod8.z.object({
15512
+ /** Candidates per generation, written to the provider's own option. */
15513
+ candidates: import_zod8.z.number().int().min(1).max(64).optional(),
15514
+ /** Empty clears the style's own value (inherit, or the default). */
15515
+ promptPrefix: import_zod8.z.string().optional(),
15516
+ promptSuffix: import_zod8.z.string().optional(),
15517
+ /** `#rrggbb` values; null or empty clears the style's own palette. */
15518
+ palette: import_zod8.z.array(import_zod8.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
15519
+ /** Snap downloaded art to the palette; false clears the style's own value. */
15520
+ enforcePalette: import_zod8.z.boolean().optional(),
15521
+ /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
15522
+ view: import_zod8.z.string().max(64).nullable().optional(),
15523
+ /** pixflux only; null clears the style's own value. */
15524
+ noBackground: import_zod8.z.boolean().nullable().optional()
15525
+ }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
15526
+ }).strict();
15527
+ var AddStyleEdit = import_zod8.z.object({
15528
+ action: import_zod8.z.literal("add-style"),
15529
+ styleId: import_zod8.z.string().min(1).regex(/^[^/\\:]+$/, "style ids cannot contain slashes or colons"),
15530
+ style: NewStyleSchema
15531
+ }).strict();
15532
+ var Envelope = {
15533
+ /** Workspace project id; omitted for a single-project gallery. */
15534
+ project: import_zod8.z.string().min(1).optional(),
15535
+ /** Manifest bytes the page last saw; a mismatch refuses the write. */
15536
+ expectedSha256: HexSha
15537
+ };
15538
+ var SingleEdit = import_zod8.z.discriminatedUnion("action", [PatchAssetEdit, AddAssetEdit, SetSourceEdit, ClearSourceEdit, PatchStyleEdit, AddStyleEdit]);
15443
15539
  var ManifestEditSchema = import_zod8.z.discriminatedUnion("action", [
15540
+ PatchAssetEdit.extend(Envelope),
15541
+ AddAssetEdit.extend(Envelope),
15542
+ SetSourceEdit.extend(Envelope),
15543
+ ClearSourceEdit.extend(Envelope),
15544
+ PatchStyleEdit.extend(Envelope),
15545
+ AddStyleEdit.extend(Envelope),
15546
+ /**
15547
+ * Several edits as one write: a new character's style, base, and loops
15548
+ * land together or not at all, validated once as the finished manifest.
15549
+ * Each edit sees the ones before it, so a base can name a style the same
15550
+ * batch adds.
15551
+ */
15444
15552
  import_zod8.z.object({
15445
- action: import_zod8.z.literal("patch-asset"),
15446
- /** Workspace project id; omitted for a single-project gallery. */
15447
- project: import_zod8.z.string().min(1).optional(),
15448
- assetId: import_zod8.z.string().min(1),
15449
- /** Manifest bytes the page last saw; a mismatch refuses the write. */
15450
- expectedSha256: HexSha,
15451
- patch: AssetPatchSchema
15452
- }).strict(),
15453
- import_zod8.z.object({
15454
- action: import_zod8.z.literal("add-asset"),
15455
- project: import_zod8.z.string().min(1).optional(),
15456
- assetId: import_zod8.z.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15457
- expectedSha256: HexSha,
15458
- asset: NewAssetSchema
15459
- }).strict(),
15460
- import_zod8.z.object({
15461
- action: import_zod8.z.literal("set-source"),
15462
- project: import_zod8.z.string().min(1).optional(),
15463
- assetId: import_zod8.z.string().min(1),
15464
- styleId: import_zod8.z.string().min(1),
15465
- /** Manifest-relative path of the art that stands in for this asset in this style. */
15466
- source: import_zod8.z.string().min(1),
15467
- expectedSha256: HexSha
15468
- }).strict(),
15469
- import_zod8.z.object({
15470
- action: import_zod8.z.literal("clear-source"),
15471
- project: import_zod8.z.string().min(1).optional(),
15472
- assetId: import_zod8.z.string().min(1),
15473
- styleId: import_zod8.z.string().min(1),
15474
- expectedSha256: HexSha
15475
- }).strict(),
15476
- import_zod8.z.object({
15477
- action: import_zod8.z.literal("patch-style"),
15478
- project: import_zod8.z.string().min(1).optional(),
15479
- styleId: import_zod8.z.string().min(1),
15480
- expectedSha256: HexSha,
15481
- patch: import_zod8.z.object({
15482
- /** Candidates per generation, written to the provider's own option. */
15483
- candidates: import_zod8.z.number().int().min(1).max(64).optional(),
15484
- /** Empty clears the style's own value (inherit, or the default). */
15485
- promptPrefix: import_zod8.z.string().optional(),
15486
- promptSuffix: import_zod8.z.string().optional(),
15487
- /** `#rrggbb` values; null or empty clears the style's own palette. */
15488
- palette: import_zod8.z.array(import_zod8.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
15489
- /** Snap downloaded art to the palette; false clears the style's own value. */
15490
- enforcePalette: import_zod8.z.boolean().optional(),
15491
- /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
15492
- view: import_zod8.z.string().max(64).nullable().optional(),
15493
- /** pixflux only; null clears the style's own value. */
15494
- noBackground: import_zod8.z.boolean().nullable().optional()
15495
- }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
15553
+ action: import_zod8.z.literal("batch"),
15554
+ ...Envelope,
15555
+ edits: import_zod8.z.array(SingleEdit).min(1).max(64)
15496
15556
  }).strict()
15497
15557
  ]);
15498
15558
  var ManifestDriftError = class extends Error {
@@ -15541,6 +15601,18 @@ function undeclaredAsset(raw, assetId) {
15541
15601
  }
15542
15602
  function applyEdit(raw, edit) {
15543
15603
  raw.assets ??= {};
15604
+ if (edit.action === "batch") {
15605
+ for (const each of edit.edits) applyEdit(raw, each);
15606
+ return;
15607
+ }
15608
+ if (edit.action === "add-style") {
15609
+ raw.styles ??= {};
15610
+ if (Object.hasOwn(raw.styles, edit.styleId)) throw new ManifestEditError(`style "${edit.styleId}" already exists`);
15611
+ const style = { ...edit.style };
15612
+ if (edit.style.palette) style.palette = edit.style.palette.map((color) => "#" + color.replace(/^#/, "").toLowerCase());
15613
+ raw.styles[edit.styleId] = style;
15614
+ return;
15615
+ }
15544
15616
  if (edit.action === "set-source" || edit.action === "clear-source") {
15545
15617
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
15546
15618
  if (!asset2) throw undeclaredAsset(raw, edit.assetId);
@@ -15618,6 +15690,15 @@ function applyEdit(raw, edit) {
15618
15690
  if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
15619
15691
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
15620
15692
  }
15693
+ for (const [what, parent] of [
15694
+ ["portrait", edit.asset.portrait?.of],
15695
+ ["outfit", edit.asset.outfit?.of],
15696
+ ["mirror", edit.asset.mirror]
15697
+ ]) {
15698
+ if (parent && !declaredAssets(raw).has(parent)) {
15699
+ throw new ManifestEditError(`${what} source "${parent}" is not declared by the manifest`);
15700
+ }
15701
+ }
15621
15702
  raw.assets[edit.assetId] = asset2;
15622
15703
  return;
15623
15704
  }
@@ -15645,11 +15726,10 @@ function applyEdit(raw, edit) {
15645
15726
  else delete asset.tags;
15646
15727
  }
15647
15728
  }
15648
- async function applyManifestEdit(manifestPath, edit) {
15729
+ async function withCandidate(manifestPath, edit, use, unchanged) {
15649
15730
  const absolute = import_node_path27.default.resolve(manifestPath);
15650
15731
  const original = await (0, import_promises20.readFile)(absolute, "utf8");
15651
- const before = sha256(original);
15652
- if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
15732
+ if (sha256(original) !== edit.expectedSha256) throw new ManifestDriftError(absolute);
15653
15733
  let raw;
15654
15734
  try {
15655
15735
  raw = JSON.parse(original);
@@ -15660,25 +15740,35 @@ async function applyManifestEdit(manifestPath, edit) {
15660
15740
  }
15661
15741
  applyEdit(raw, edit);
15662
15742
  const next = serializeLike(original, raw);
15663
- if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
15743
+ if (next === original) return unchanged(absolute, original);
15664
15744
  const tmp = import_node_path27.default.join(
15665
15745
  import_node_path27.default.dirname(absolute),
15666
15746
  `.${import_node_path27.default.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
15667
15747
  );
15668
15748
  try {
15669
15749
  await (0, import_promises20.writeFile)(tmp, next);
15750
+ let specs;
15670
15751
  try {
15671
- const loaded = await loadManifest(tmp);
15672
- await resolveSpecs(loaded);
15752
+ specs = await resolveSpecs(await loadManifest(tmp));
15673
15753
  } catch (error) {
15674
15754
  const message6 = error instanceof Error ? error.message : String(error);
15675
15755
  throw new ManifestEditError(message6.replace(tmp, absolute));
15676
15756
  }
15677
- await (0, import_promises20.rename)(tmp, absolute);
15757
+ return await use({ absolute, original, next, tmp, specs });
15678
15758
  } finally {
15679
15759
  await (0, import_promises20.rm)(tmp, { force: true });
15680
15760
  }
15681
- return { manifestPath: absolute, sha256: sha256(next), changed: true };
15761
+ }
15762
+ async function applyManifestEdit(manifestPath, edit) {
15763
+ return withCandidate(
15764
+ manifestPath,
15765
+ edit,
15766
+ async ({ absolute, next, tmp }) => {
15767
+ await (0, import_promises20.rename)(tmp, absolute);
15768
+ return { manifestPath: absolute, sha256: sha256(next), changed: true };
15769
+ },
15770
+ (absolute, original) => ({ manifestPath: absolute, sha256: sha256(original), changed: false })
15771
+ );
15682
15772
  }
15683
15773
 
15684
15774
  // src/pipeline/hand-edit.ts
@@ -15929,62 +16019,103 @@ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
15929
16019
  return openExternal(file, command);
15930
16020
  }
15931
16021
 
15932
- // src/gallery/skeleton.ts
16022
+ // src/gallery/upload.ts
15933
16023
  var import_node_fs25 = require("fs");
15934
16024
  var import_promises22 = require("fs/promises");
15935
16025
  var import_node_path29 = __toESM(require("path"), 1);
15936
16026
  var import_zod10 = require("zod");
15937
- var SkeletonEstimateRequestSchema = import_zod10.z.object({
16027
+ var MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
16028
+ var UploadImageSchema = import_zod10.z.object({
16029
+ action: import_zod10.z.literal("upload-image"),
15938
16030
  project: import_zod10.z.string().min(1).optional(),
15939
- styleId: import_zod10.z.string().min(1),
15940
- assetId: import_zod10.z.string().min(1),
15941
- frames: import_zod10.z.number().int().min(3).max(15).optional()
16031
+ /** Manifest-relative destination, ending .png, .jpg, or .jpeg. */
16032
+ path: import_zod10.z.string().min(1),
16033
+ base64: import_zod10.z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4),
16034
+ /** Replace a file already at `path`. */
16035
+ overwrite: import_zod10.z.boolean().optional()
15942
16036
  }).strict();
15943
- var SkeletonAnimationRequestSchema = import_zod10.z.object({
15944
- action: import_zod10.z.literal("create-skeleton-animation"),
15945
- project: import_zod10.z.string().min(1).optional(),
15946
- expectedSha256: import_zod10.z.string().regex(/^[0-9a-f]{64}$/),
15947
- styleId: import_zod10.z.string().min(1),
16037
+ async function saveProjectImage(root, request) {
16038
+ const file = import_node_path29.default.resolve(root, request.path);
16039
+ const relative = import_node_path29.default.relative(root, file);
16040
+ if (relative.startsWith("..") || import_node_path29.default.isAbsolute(relative)) {
16041
+ throw new ManifestEditError("an upload must stay inside the project");
16042
+ }
16043
+ const extension = import_node_path29.default.extname(file).toLowerCase();
16044
+ const expected = extension === ".png" ? "png" : extension === ".jpg" || extension === ".jpeg" ? "jpeg" : null;
16045
+ if (!expected) throw new ManifestEditError("an upload must be a .png, .jpg, or .jpeg path");
16046
+ const bytes = Buffer.from(request.base64, "base64");
16047
+ if (!bytes.length || bytes.length > MAX_UPLOAD_BYTES) {
16048
+ throw new ManifestEditError(`an upload must be between 1 byte and ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`);
16049
+ }
16050
+ const metadata = imageMetadata(bytes);
16051
+ if (!metadata) throw new ManifestEditError("the upload is not a readable PNG or JPEG");
16052
+ if (metadata.format !== expected) {
16053
+ throw new ManifestEditError(`the upload is a ${metadata.format.toUpperCase()}; name it ${metadata.format === "png" ? ".png" : ".jpg"}`);
16054
+ }
16055
+ if ((0, import_node_fs25.existsSync)(file) && !request.overwrite) {
16056
+ throw new ManifestEditError(`${relative.split(import_node_path29.default.sep).join("/")} already exists; choose another name, or replace it`);
16057
+ }
16058
+ await (0, import_promises22.mkdir)(import_node_path29.default.dirname(file), { recursive: true });
16059
+ await (0, import_promises22.writeFile)(file, bytes);
16060
+ return relative.split(import_node_path29.default.sep).join("/");
16061
+ }
16062
+
16063
+ // src/gallery/skeleton.ts
16064
+ var import_node_fs26 = require("fs");
16065
+ var import_promises23 = require("fs/promises");
16066
+ var import_node_path30 = __toESM(require("path"), 1);
16067
+ var import_zod11 = require("zod");
16068
+ var SkeletonEstimateRequestSchema = import_zod11.z.object({
16069
+ project: import_zod11.z.string().min(1).optional(),
16070
+ styleId: import_zod11.z.string().min(1),
16071
+ assetId: import_zod11.z.string().min(1),
16072
+ frames: import_zod11.z.number().int().min(3).max(15).optional()
16073
+ }).strict();
16074
+ var SkeletonAnimationRequestSchema = import_zod11.z.object({
16075
+ action: import_zod11.z.literal("create-skeleton-animation"),
16076
+ project: import_zod11.z.string().min(1).optional(),
16077
+ expectedSha256: import_zod11.z.string().regex(/^[0-9a-f]{64}$/),
16078
+ styleId: import_zod11.z.string().min(1),
15948
16079
  /** The sprite the animation poses. */
15949
- from: import_zod10.z.string().min(1),
15950
- assetId: import_zod10.z.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
16080
+ from: import_zod11.z.string().min(1),
16081
+ assetId: import_zod11.z.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15951
16082
  /** The motion, sent as PixelLab's `action`. */
15952
- prompt: import_zod10.z.string().min(1),
16083
+ prompt: import_zod11.z.string().min(1),
15953
16084
  direction: CharacterDirectionSchema,
15954
- description: import_zod10.z.string().min(1).optional(),
15955
- skeletonTemplate: import_zod10.z.string().min(1).optional(),
16085
+ description: import_zod11.z.string().min(1).optional(),
16086
+ skeletonTemplate: import_zod11.z.string().min(1).optional(),
15956
16087
  /** Manifest-relative JSON path inside the project. */
15957
- keypointsFile: import_zod10.z.string().min(1),
16088
+ keypointsFile: import_zod11.z.string().min(1),
15958
16089
  /** Poses to write to `keypointsFile`; omitted to use a file that already exists. */
15959
16090
  set: SkeletonSetSchema.optional(),
15960
16091
  /** Replace an existing `keypointsFile` with `set`. */
15961
- overwrite: import_zod10.z.boolean().optional()
16092
+ overwrite: import_zod11.z.boolean().optional()
15962
16093
  }).strict();
15963
- var SkeletonKeypointsUpdateSchema = import_zod10.z.object({
15964
- action: import_zod10.z.literal("update-skeleton-keypoints"),
15965
- project: import_zod10.z.string().min(1).optional(),
15966
- styleId: import_zod10.z.string().min(1),
15967
- assetId: import_zod10.z.string().min(1),
16094
+ var SkeletonKeypointsUpdateSchema = import_zod11.z.object({
16095
+ action: import_zod11.z.literal("update-skeleton-keypoints"),
16096
+ project: import_zod11.z.string().min(1).optional(),
16097
+ styleId: import_zod11.z.string().min(1),
16098
+ assetId: import_zod11.z.string().min(1),
15968
16099
  /** The file's SHA-256 when the page loaded it; anything else on disk is a concurrent edit. */
15969
- expectedSha256: import_zod10.z.string().regex(/^[0-9a-f]{64}$/),
16100
+ expectedSha256: import_zod11.z.string().regex(/^[0-9a-f]{64}$/),
15970
16101
  set: SkeletonSetSchema
15971
16102
  }).strict();
15972
16103
  async function createSkeletonAnimation(manifestPath, request) {
15973
- const root = import_node_path29.default.dirname(import_node_path29.default.resolve(manifestPath));
15974
- const file = import_node_path29.default.resolve(root, request.keypointsFile);
15975
- const relative = import_node_path29.default.relative(root, file);
15976
- if (!request.keypointsFile.endsWith(".json") || relative.startsWith("..") || import_node_path29.default.isAbsolute(relative)) {
16104
+ const root = import_node_path30.default.dirname(import_node_path30.default.resolve(manifestPath));
16105
+ const file = import_node_path30.default.resolve(root, request.keypointsFile);
16106
+ const relative = import_node_path30.default.relative(root, file);
16107
+ if (!request.keypointsFile.endsWith(".json") || relative.startsWith("..") || import_node_path30.default.isAbsolute(relative)) {
15977
16108
  throw new ManifestEditError("the keypoints file must be a .json path inside the project");
15978
16109
  }
15979
- const existed = (0, import_node_fs25.existsSync)(file);
16110
+ const existed = (0, import_node_fs26.existsSync)(file);
15980
16111
  let previous = null;
15981
16112
  if (request.set) {
15982
16113
  if (existed && !request.overwrite) {
15983
16114
  throw new ManifestEditError(`${request.keypointsFile} already exists; choose another path, or replace it`);
15984
16115
  }
15985
- if (existed) previous = await (0, import_promises22.readFile)(file);
15986
- await (0, import_promises22.mkdir)(import_node_path29.default.dirname(file), { recursive: true });
15987
- await (0, import_promises22.writeFile)(file, JSON.stringify(request.set, null, 2) + "\n");
16116
+ if (existed) previous = await (0, import_promises23.readFile)(file);
16117
+ await (0, import_promises23.mkdir)(import_node_path30.default.dirname(file), { recursive: true });
16118
+ await (0, import_promises23.writeFile)(file, JSON.stringify(request.set, null, 2) + "\n");
15988
16119
  } else if (!existed) {
15989
16120
  throw new ManifestEditError(`${request.keypointsFile} does not exist; paste poses or estimate them from the sprite`);
15990
16121
  }
@@ -15999,7 +16130,7 @@ async function createSkeletonAnimation(manifestPath, request) {
15999
16130
  revision: {
16000
16131
  mode: "animate-skeleton",
16001
16132
  from: request.from,
16002
- keypointsFile: relative.split(import_node_path29.default.sep).join("/"),
16133
+ keypointsFile: relative.split(import_node_path30.default.sep).join("/"),
16003
16134
  direction: request.direction,
16004
16135
  ...request.description ? { description: request.description } : {},
16005
16136
  ...request.skeletonTemplate ? { skeletonTemplate: request.skeletonTemplate } : {}
@@ -16008,8 +16139,8 @@ async function createSkeletonAnimation(manifestPath, request) {
16008
16139
  });
16009
16140
  } catch (error) {
16010
16141
  if (request.set) {
16011
- if (previous) await (0, import_promises22.writeFile)(file, previous);
16012
- else await (0, import_promises22.rm)(file, { force: true });
16142
+ if (previous) await (0, import_promises23.writeFile)(file, previous);
16143
+ else await (0, import_promises23.rm)(file, { force: true });
16013
16144
  }
16014
16145
  throw error;
16015
16146
  }
@@ -16020,56 +16151,56 @@ async function updateSkeletonKeypoints(ctx, request) {
16020
16151
  if (!spec) throw new ManifestEditError(`"${key}" is not declared by the manifest`);
16021
16152
  const file = spec.revision?.mode === "animate-skeleton" ? spec.revision.keypointsFile : void 0;
16022
16153
  if (!file) throw new ManifestEditError(`${key} is not an animate-skeleton revision`);
16023
- const relative = import_node_path29.default.relative(ctx.loaded.root, file);
16024
- if (relative.startsWith("..") || import_node_path29.default.isAbsolute(relative)) {
16154
+ const relative = import_node_path30.default.relative(ctx.loaded.root, file);
16155
+ if (relative.startsWith("..") || import_node_path30.default.isAbsolute(relative)) {
16025
16156
  throw new ManifestEditError(`${key}'s keypoints file is outside the project; edit it by hand`);
16026
16157
  }
16027
- const current = (0, import_node_fs25.existsSync)(file) ? sha256(await (0, import_promises22.readFile)(file)) : null;
16158
+ const current = (0, import_node_fs26.existsSync)(file) ? sha256(await (0, import_promises23.readFile)(file)) : null;
16028
16159
  if (current !== request.expectedSha256) {
16029
16160
  throw Object.assign(
16030
- new Error(`${relative.split(import_node_path29.default.sep).join("/")} changed on disk since this page loaded it; press Refresh and edit again`),
16161
+ new Error(`${relative.split(import_node_path30.default.sep).join("/")} changed on disk since this page loaded it; press Refresh and edit again`),
16031
16162
  { status: 409 }
16032
16163
  );
16033
16164
  }
16034
- await (0, import_promises22.writeFile)(file, JSON.stringify(request.set, null, 2) + "\n");
16035
- return relative.split(import_node_path29.default.sep).join("/");
16165
+ await (0, import_promises23.writeFile)(file, JSON.stringify(request.set, null, 2) + "\n");
16166
+ return relative.split(import_node_path30.default.sep).join("/");
16036
16167
  }
16037
16168
 
16038
16169
  // src/gallery/edit.ts
16039
- var Base64 = import_zod11.z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
16040
- var HandEditRequestSchema = import_zod11.z.discriminatedUnion("action", [
16041
- import_zod11.z.object({
16042
- action: import_zod11.z.literal("start-edit"),
16043
- project: import_zod11.z.string().min(1).optional(),
16044
- assetId: import_zod11.z.string().min(1),
16045
- styleId: import_zod11.z.string().min(1),
16046
- expectedSha256: import_zod11.z.string().regex(/^[0-9a-f]{64}$/),
16170
+ var Base64 = import_zod12.z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
16171
+ var HandEditRequestSchema = import_zod12.z.discriminatedUnion("action", [
16172
+ import_zod12.z.object({
16173
+ action: import_zod12.z.literal("start-edit"),
16174
+ project: import_zod12.z.string().min(1).optional(),
16175
+ assetId: import_zod12.z.string().min(1),
16176
+ styleId: import_zod12.z.string().min(1),
16177
+ expectedSha256: import_zod12.z.string().regex(/^[0-9a-f]{64}$/),
16047
16178
  /** Also launch the author's editor on the file. */
16048
- open: import_zod11.z.boolean().optional()
16179
+ open: import_zod12.z.boolean().optional()
16049
16180
  }).strict(),
16050
- import_zod11.z.object({
16051
- action: import_zod11.z.literal("detach-edit"),
16052
- project: import_zod11.z.string().min(1).optional(),
16053
- assetId: import_zod11.z.string().min(1),
16054
- styleId: import_zod11.z.string().min(1),
16055
- expectedSha256: import_zod11.z.string().regex(/^[0-9a-f]{64}$/)
16181
+ import_zod12.z.object({
16182
+ action: import_zod12.z.literal("detach-edit"),
16183
+ project: import_zod12.z.string().min(1).optional(),
16184
+ assetId: import_zod12.z.string().min(1),
16185
+ styleId: import_zod12.z.string().min(1),
16186
+ expectedSha256: import_zod12.z.string().regex(/^[0-9a-f]{64}$/)
16056
16187
  }).strict(),
16057
16188
  /** From the in-browser editor: the page holds the bytes, the server validates and writes. */
16058
- import_zod11.z.object({
16059
- action: import_zod11.z.literal("save-edit"),
16060
- project: import_zod11.z.string().min(1).optional(),
16061
- assetId: import_zod11.z.string().min(1),
16062
- styleId: import_zod11.z.string().min(1),
16063
- expectedSha256: import_zod11.z.string().regex(/^[0-9a-f]{64}$/),
16189
+ import_zod12.z.object({
16190
+ action: import_zod12.z.literal("save-edit"),
16191
+ project: import_zod12.z.string().min(1).optional(),
16192
+ assetId: import_zod12.z.string().min(1),
16193
+ styleId: import_zod12.z.string().min(1),
16194
+ expectedSha256: import_zod12.z.string().regex(/^[0-9a-f]{64}$/),
16064
16195
  /** The flattened image; for a frame set, `frames` instead. */
16065
16196
  png: Base64.min(1).optional(),
16066
16197
  /** Every frame of a set, tagged with the role it was opened under (null for one added in the editor). */
16067
- frames: import_zod11.z.array(import_zod11.z.object({ role: import_zod11.z.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
16198
+ frames: import_zod12.z.array(import_zod12.z.object({ role: import_zod12.z.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
16068
16199
  /** The editor's layered project file, kept beside the edit. */
16069
16200
  pxo: Base64.optional(),
16070
16201
  /** Editor identity from the bridge's ready message. */
16071
- editor: import_zod11.z.string().min(1).max(200),
16072
- protocol: import_zod11.z.number().int().positive()
16202
+ editor: import_zod12.z.string().min(1).max(200),
16203
+ protocol: import_zod12.z.number().int().positive()
16073
16204
  }).strict()
16074
16205
  ]);
16075
16206
  function createGalleryEditHandler(opts) {
@@ -16110,6 +16241,21 @@ function createGalleryEditHandler(opts) {
16110
16241
  }
16111
16242
  return opts.reload();
16112
16243
  }
16244
+ const upload = UploadImageSchema.safeParse(body);
16245
+ if (upload.success) {
16246
+ let manifestPath2;
16247
+ try {
16248
+ manifestPath2 = await opts.manifestFor(upload.data.project);
16249
+ } catch (error) {
16250
+ throw new ManifestEditError(error instanceof Error ? error.message : String(error));
16251
+ }
16252
+ const written = await saveProjectImage(import_node_path31.default.dirname(import_node_path31.default.resolve(manifestPath2)), upload.data);
16253
+ log(` image added: ${written}`);
16254
+ return opts.reload();
16255
+ }
16256
+ if (body?.action === "upload-image") {
16257
+ throw new ManifestEditError("invalid upload: " + upload.error.issues.slice(0, 2).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
16258
+ }
16113
16259
  const poses = SkeletonKeypointsUpdateSchema.safeParse(body);
16114
16260
  if (poses.success) {
16115
16261
  if (!opts.loadProject) throw new ManifestEditError("pose edits are not available in this gallery");
@@ -16153,19 +16299,35 @@ function createGalleryEditHandler(opts) {
16153
16299
  }
16154
16300
  const result = await applyManifestEdit(manifestPath, parsed.data);
16155
16301
  if (result.changed) {
16156
- const relative = import_node_path30.default.relative(process.cwd(), result.manifestPath);
16302
+ const relative = import_node_path31.default.relative(process.cwd(), result.manifestPath);
16157
16303
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
16158
- 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;
16159
- log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
16304
+ log(` manifest edited: ${describeEdit(parsed.data)} in ${shown}`);
16160
16305
  }
16161
16306
  return opts.reload();
16162
16307
  };
16163
16308
  }
16309
+ function describeEdit(edit) {
16310
+ switch (edit.action) {
16311
+ case "batch":
16312
+ return `${edit.edits.length} changes (${edit.edits.map(describeEdit).join("; ")})`;
16313
+ case "add-style":
16314
+ return `added style ${edit.styleId}`;
16315
+ case "patch-style":
16316
+ return `changed style ${edit.styleId}`;
16317
+ case "set-source":
16318
+ case "clear-source":
16319
+ return `changed ${edit.styleId}/${edit.assetId} source`;
16320
+ case "add-asset":
16321
+ return `added ${edit.assetId}`;
16322
+ case "patch-asset":
16323
+ return `changed ${edit.assetId}`;
16324
+ }
16325
+ }
16164
16326
 
16165
16327
  // src/gallery/snapshot.ts
16166
16328
  var PROJECT_FILE_TYPE = "application/octet-stream";
16167
16329
  function galleryMediaId(absolutePath) {
16168
- return (0, import_node_crypto6.createHash)("sha256").update(import_node_path31.default.resolve(absolutePath)).digest("hex").slice(0, 24);
16330
+ return (0, import_node_crypto6.createHash)("sha256").update(import_node_path32.default.resolve(absolutePath)).digest("hex").slice(0, 24);
16169
16331
  }
16170
16332
  function galleryMediaRoute(id) {
16171
16333
  return `/media/${id}`;
@@ -16184,9 +16346,9 @@ function describeHistory(media, entry, cacheDir) {
16184
16346
  return (entry.history ?? []).map((generation, i) => {
16185
16347
  const outputs = generation.outputs.map((output) => {
16186
16348
  const mediaType = output.mediaType ?? MediaType.PNG;
16187
- const file = import_node_path31.default.join(cacheDir, cacheFileName(output.sha256, mediaType));
16349
+ const file = import_node_path32.default.join(cacheDir, cacheFileName(output.sha256, mediaType));
16188
16350
  let url = null;
16189
- if ((0, import_node_fs26.existsSync)(file)) {
16351
+ if ((0, import_node_fs27.existsSync)(file)) {
16190
16352
  const id = galleryMediaId(file);
16191
16353
  media.set(id, { path: file, contentType: mediaType });
16192
16354
  url = `${galleryMediaRoute(id)}?v=${output.sha256.slice(0, 24)}`;
@@ -16218,7 +16380,7 @@ function describeHistory(media, entry, cacheDir) {
16218
16380
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
16219
16381
  async function samePixels(a, b) {
16220
16382
  try {
16221
- const [left, right] = await Promise.all([(0, import_promises23.readFile)(a), (0, import_promises23.readFile)(b)]);
16383
+ const [left, right] = await Promise.all([(0, import_promises24.readFile)(a), (0, import_promises24.readFile)(b)]);
16222
16384
  if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
16223
16385
  const x = decodePng(left);
16224
16386
  const y = decodePng(right);
@@ -16284,7 +16446,7 @@ function describeCharacter(spec, entry) {
16284
16446
  }
16285
16447
  async function fileInfo(absolutePath) {
16286
16448
  try {
16287
- const info = await (0, import_promises23.stat)(absolutePath);
16449
+ const info = await (0, import_promises24.stat)(absolutePath);
16288
16450
  return info.isFile() ? { bytes: info.size, modifiedAt: info.mtime.toISOString() } : null;
16289
16451
  } catch {
16290
16452
  return null;
@@ -16316,10 +16478,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
16316
16478
  async function describeQuality(media, root, inspection) {
16317
16479
  const outputPaths = inspection.outputs ?? [inspection.output];
16318
16480
  const outputs = await Promise.all(
16319
- outputPaths.map((file) => describeOutput(media, root, import_node_path31.default.resolve(root, file)))
16481
+ outputPaths.map((file) => describeOutput(media, root, import_node_path32.default.resolve(root, file)))
16320
16482
  );
16321
- const recordPath = import_node_path31.default.resolve(root, inspection.record);
16322
- const recordExists = (0, import_node_fs26.existsSync)(recordPath);
16483
+ const recordPath = import_node_path32.default.resolve(root, inspection.record);
16484
+ const recordExists = (0, import_node_fs27.existsSync)(recordPath);
16323
16485
  let options = null;
16324
16486
  let check = null;
16325
16487
  if (recordExists) {
@@ -16338,7 +16500,7 @@ async function describeQuality(media, root, inspection) {
16338
16500
  return {
16339
16501
  state: inspection.state,
16340
16502
  reason: inspection.reason,
16341
- output: portableOutputPath(import_node_path31.default.resolve(root, inspection.output), root),
16503
+ output: portableOutputPath(import_node_path32.default.resolve(root, inspection.output), root),
16342
16504
  record: portableOutputPath(recordPath, root),
16343
16505
  recordExists,
16344
16506
  outputs,
@@ -16376,9 +16538,9 @@ async function buildGallerySnapshot(opts) {
16376
16538
  const media = /* @__PURE__ */ new Map();
16377
16539
  const plan = await buildPlan(specs, lock);
16378
16540
  const items = [];
16379
- const manifestText = await (0, import_promises23.readFile)(loaded.path, "utf8");
16541
+ const manifestText = await (0, import_promises24.readFile)(loaded.path, "utf8");
16380
16542
  const manifestSha256 = sha256(manifestText);
16381
- const cacheDir = import_node_path31.default.resolve(import_node_path31.default.dirname(opts.lockPath), ".pixelkiln", "cache");
16543
+ const cacheDir = import_node_path32.default.resolve(import_node_path32.default.dirname(opts.lockPath), ".pixelkiln", "cache");
16382
16544
  let rawStyles = {};
16383
16545
  try {
16384
16546
  const raw = JSON.parse(manifestText);
@@ -16402,12 +16564,12 @@ async function buildGallerySnapshot(opts) {
16402
16564
  )
16403
16565
  );
16404
16566
  if (spec.source) {
16405
- const editPath = import_node_path31.default.resolve(root, spec.source);
16567
+ const editPath = import_node_path32.default.resolve(root, spec.source);
16406
16568
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
16407
16569
  const editShas = await Promise.all(
16408
16570
  Array.from({ length: memberCount }, async (_, index) => {
16409
16571
  const file = sourceOutputPath(spec.source, entry, index, root);
16410
- return { file, sha256: (0, import_node_fs26.existsSync)(file) ? await sha256File(file) : null };
16572
+ return { file, sha256: (0, import_node_fs27.existsSync)(file) ? await sha256File(file) : null };
16411
16573
  })
16412
16574
  );
16413
16575
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -16415,7 +16577,7 @@ async function buildGallerySnapshot(opts) {
16415
16577
  const companion = await readHandEditCompanion(editPath);
16416
16578
  if (companion) {
16417
16579
  const projectPath = handEditProjectPath(editPath);
16418
- const project = companion.project && (0, import_node_fs26.existsSync)(projectPath) ? await fileInfo(projectPath) : null;
16580
+ const project = companion.project && (0, import_node_fs27.existsSync)(projectPath) ? await fileInfo(projectPath) : null;
16419
16581
  let projectUrl = null;
16420
16582
  if (project) {
16421
16583
  const id = galleryMediaId(projectPath);
@@ -16451,8 +16613,8 @@ async function buildGallerySnapshot(opts) {
16451
16613
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
16452
16614
  }
16453
16615
  } else if (spec.source) {
16454
- outputs = [await describeOutput(media, root, import_node_path31.default.resolve(root, spec.source))];
16455
- } else if (!entry && (0, import_node_fs26.existsSync)(spec.outFile)) {
16616
+ outputs = [await describeOutput(media, root, import_node_path32.default.resolve(root, spec.source))];
16617
+ } else if (!entry && (0, import_node_fs27.existsSync)(spec.outFile)) {
16456
16618
  outputs = [await describeOutput(media, root, spec.outFile)];
16457
16619
  } else {
16458
16620
  outputs = [];
@@ -16653,7 +16815,7 @@ async function buildGallerySnapshot(opts) {
16653
16815
  id: loaded.manifest.name,
16654
16816
  name: loaded.manifest.name,
16655
16817
  manifest: loaded.path,
16656
- lock: import_node_path31.default.resolve(opts.lockPath),
16818
+ lock: import_node_path32.default.resolve(opts.lockPath),
16657
16819
  root,
16658
16820
  provider: loaded.manifest.provider,
16659
16821
  account: null,
@@ -16679,7 +16841,7 @@ async function buildGallerySnapshot(opts) {
16679
16841
  return { snapshot, media };
16680
16842
  }
16681
16843
  async function buildWorkspaceGallerySnapshot(opts) {
16682
- const dir = import_node_path31.default.dirname(import_node_path31.default.resolve(opts.workspacePath));
16844
+ const dir = import_node_path32.default.dirname(import_node_path32.default.resolve(opts.workspacePath));
16683
16845
  const media = /* @__PURE__ */ new Map();
16684
16846
  const projects = [];
16685
16847
  const items = [];
@@ -16722,11 +16884,11 @@ async function buildWorkspaceGallerySnapshot(opts) {
16722
16884
  id: project.id,
16723
16885
  name: loaded.manifest.name,
16724
16886
  manifest: manifestPath,
16725
- lock: import_node_path31.default.resolve(lockPath),
16887
+ lock: import_node_path32.default.resolve(lockPath),
16726
16888
  root: loaded.root,
16727
16889
  provider: loaded.manifest.provider,
16728
16890
  account: project.account ?? null,
16729
- manifestSha256: manifestSha256 ?? sha256(await (0, import_promises23.readFile)(manifestPath, "utf8")),
16891
+ manifestSha256: manifestSha256 ?? sha256(await (0, import_promises24.readFile)(manifestPath, "utf8")),
16730
16892
  historyLimit: historyLimit(loaded.manifest),
16731
16893
  entries: Object.keys(lock.entries).length,
16732
16894
  items: projectItems.length,
@@ -16738,8 +16900,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
16738
16900
  id: project.id,
16739
16901
  name: project.id,
16740
16902
  manifest: manifestPath,
16741
- lock: import_node_path31.default.resolve(lockPath),
16742
- root: import_node_path31.default.dirname(manifestPath),
16903
+ lock: import_node_path32.default.resolve(lockPath),
16904
+ root: import_node_path32.default.dirname(manifestPath),
16743
16905
  provider: project.provider,
16744
16906
  account: project.account ?? null,
16745
16907
  manifestSha256: null,
@@ -16766,7 +16928,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
16766
16928
  version: 1,
16767
16929
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
16768
16930
  project: null,
16769
- workspace: { path: import_node_path31.default.resolve(opts.workspacePath), dir, projects },
16931
+ workspace: { path: import_node_path32.default.resolve(opts.workspacePath), dir, projects },
16770
16932
  filter,
16771
16933
  totals: {
16772
16934
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -16783,7 +16945,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
16783
16945
  }
16784
16946
 
16785
16947
  // src/gallery/page.ts
16786
- var import_node_fs27 = require("fs");
16948
+ var import_node_fs28 = require("fs");
16787
16949
 
16788
16950
  // src/gallery/client-bundle.ts
16789
16951
  var import_node_module = require("module");
@@ -16823,8 +16985,8 @@ var client;
16823
16985
  function clientAssets() {
16824
16986
  const prebuilt = new URL("gallery.js", CLIENT_DIR);
16825
16987
  client ??= {
16826
- css: (0, import_node_fs27.readFileSync)(new URL("gallery.css", CLIENT_DIR), "utf8"),
16827
- js: (0, import_node_fs27.existsSync)(prebuilt) ? (0, import_node_fs27.readFileSync)(prebuilt, "utf8") : bundleGalleryClient()
16988
+ css: (0, import_node_fs28.readFileSync)(new URL("gallery.css", CLIENT_DIR), "utf8"),
16989
+ js: (0, import_node_fs28.existsSync)(prebuilt) ? (0, import_node_fs28.readFileSync)(prebuilt, "utf8") : bundleGalleryClient()
16828
16990
  };
16829
16991
  return client;
16830
16992
  }
@@ -16866,6 +17028,7 @@ ${css}</style>
16866
17028
  <option value="none">Group: none</option>
16867
17029
  </select>
16868
17030
  <span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
17031
+ <button id="studio" type="button" hidden title="Draft a new character: its style, base, loops, and portrait, priced as you go">+ New character</button>
16869
17032
  <button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
16870
17033
  <label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
16871
17034
  </div>
@@ -16951,10 +17114,10 @@ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download
16951
17114
 
16952
17115
  // src/editor/install.ts
16953
17116
  var import_node_crypto7 = require("crypto");
16954
- var import_node_fs28 = require("fs");
16955
- var import_promises24 = require("fs/promises");
17117
+ var import_node_fs29 = require("fs");
17118
+ var import_promises25 = require("fs/promises");
16956
17119
  var import_node_os3 = __toESM(require("os"), 1);
16957
- var import_node_path32 = __toESM(require("path"), 1);
17120
+ var import_node_path33 = __toESM(require("path"), 1);
16958
17121
  var EditorInstallError = class extends Error {
16959
17122
  constructor(message6, file) {
16960
17123
  super(message6);
@@ -16965,15 +17128,15 @@ var EditorInstallError = class extends Error {
16965
17128
  };
16966
17129
  function editorToolsRoot() {
16967
17130
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
16968
- if (override) return import_node_path32.default.resolve(override);
16969
- if (process.platform === "darwin") return import_node_path32.default.join(import_node_os3.default.homedir(), "Library", "Caches", "pixelkiln", "tools");
17131
+ if (override) return import_node_path33.default.resolve(override);
17132
+ if (process.platform === "darwin") return import_node_path33.default.join(import_node_os3.default.homedir(), "Library", "Caches", "pixelkiln", "tools");
16970
17133
  if (process.platform === "win32") {
16971
- return import_node_path32.default.join(process.env.LOCALAPPDATA ?? import_node_path32.default.join(import_node_os3.default.homedir(), "AppData", "Local"), "pixelkiln", "tools");
17134
+ return import_node_path33.default.join(process.env.LOCALAPPDATA ?? import_node_path33.default.join(import_node_os3.default.homedir(), "AppData", "Local"), "pixelkiln", "tools");
16972
17135
  }
16973
- return import_node_path32.default.join(process.env.XDG_CACHE_HOME ?? import_node_path32.default.join(import_node_os3.default.homedir(), ".cache"), "pixelkiln", "tools");
17136
+ return import_node_path33.default.join(process.env.XDG_CACHE_HOME ?? import_node_path33.default.join(import_node_os3.default.homedir(), ".cache"), "pixelkiln", "tools");
16974
17137
  }
16975
17138
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
16976
- return import_node_path32.default.join(root, "pixelorama", pin.release ?? "unpublished");
17139
+ return import_node_path33.default.join(root, "pixelorama", pin.release ?? "unpublished");
16977
17140
  }
16978
17141
  function editorBaseUrl(pin = EDITOR_PIN) {
16979
17142
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -16983,7 +17146,7 @@ function editorBaseUrl(pin = EDITOR_PIN) {
16983
17146
  }
16984
17147
  async function sha256Of(file) {
16985
17148
  try {
16986
- return (0, import_node_crypto7.createHash)("sha256").update(await (0, import_promises24.readFile)(file)).digest("hex");
17149
+ return (0, import_node_crypto7.createHash)("sha256").update(await (0, import_promises25.readFile)(file)).digest("hex");
16987
17150
  } catch {
16988
17151
  return null;
16989
17152
  }
@@ -16996,8 +17159,8 @@ async function editorStatus(opts = {}) {
16996
17159
  let totalBytes = 0;
16997
17160
  for (const [name, expected] of Object.entries(pin.files)) {
16998
17161
  totalBytes += expected.bytes;
16999
- const file = import_node_path32.default.join(dir, name);
17000
- const info = (0, import_node_fs28.existsSync)(file) ? await (0, import_promises24.stat)(file).catch(() => null) : null;
17162
+ const file = import_node_path33.default.join(dir, name);
17163
+ const info = (0, import_node_fs29.existsSync)(file) ? await (0, import_promises25.stat)(file).catch(() => null) : null;
17001
17164
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
17002
17165
  installedBytes += expected.bytes;
17003
17166
  } else {
@@ -17026,7 +17189,7 @@ async function installEditor(opts = {}) {
17026
17189
  );
17027
17190
  }
17028
17191
  if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
17029
- await (0, import_promises24.mkdir)(dir, { recursive: true });
17192
+ await (0, import_promises25.mkdir)(dir, { recursive: true });
17030
17193
  const downloaded = [];
17031
17194
  const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
17032
17195
  let fetchedBytes = before.installedBytes;
@@ -17054,13 +17217,13 @@ async function installEditor(opts = {}) {
17054
17217
  if (digest2 !== expected.sha256) {
17055
17218
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
17056
17219
  }
17057
- const final = import_node_path32.default.join(dir, name);
17220
+ const final = import_node_path33.default.join(dir, name);
17058
17221
  const tmp = `${final}.${process.pid}.part`;
17059
17222
  try {
17060
- await (0, import_promises24.writeFile)(tmp, bytes);
17061
- await (0, import_promises24.rename)(tmp, final);
17223
+ await (0, import_promises25.writeFile)(tmp, bytes);
17224
+ await (0, import_promises25.rename)(tmp, final);
17062
17225
  } finally {
17063
- await (0, import_promises24.rm)(tmp, { force: true });
17226
+ await (0, import_promises25.rm)(tmp, { force: true });
17064
17227
  }
17065
17228
  fetchedBytes += bytes.length;
17066
17229
  downloaded.push(name);
@@ -17100,12 +17263,12 @@ var EDITOR_CONTENT_TYPES = {
17100
17263
  ".json": "application/json; charset=utf-8"
17101
17264
  };
17102
17265
  function editorContentType(name) {
17103
- return EDITOR_CONTENT_TYPES[import_node_path32.default.extname(name).toLowerCase()] ?? null;
17266
+ return EDITOR_CONTENT_TYPES[import_node_path33.default.extname(name).toLowerCase()] ?? null;
17104
17267
  }
17105
17268
 
17106
17269
  // src/gallery/editor.ts
17107
- var import_promises25 = require("fs/promises");
17108
- var import_node_path33 = __toESM(require("path"), 1);
17270
+ var import_promises26 = require("fs/promises");
17271
+ var import_node_path34 = __toESM(require("path"), 1);
17109
17272
  function editorRoute(release, name = "index.html") {
17110
17273
  return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
17111
17274
  }
@@ -17178,7 +17341,7 @@ function createGalleryEditorHandlers(opts = {}) {
17178
17341
  if (!status.installed) return null;
17179
17342
  let bytes;
17180
17343
  try {
17181
- bytes = await (0, import_promises25.readFile)(import_node_path33.default.join(dir, name));
17344
+ bytes = await (0, import_promises26.readFile)(import_node_path34.default.join(dir, name));
17182
17345
  } catch {
17183
17346
  verified = null;
17184
17347
  return null;
@@ -17195,7 +17358,7 @@ function createGalleryEditorHandlers(opts = {}) {
17195
17358
  // src/gallery/server.ts
17196
17359
  var import_node_crypto8 = require("crypto");
17197
17360
  var import_node_http2 = require("http");
17198
- var import_promises26 = require("fs/promises");
17361
+ var import_promises27 = require("fs/promises");
17199
17362
  var MAX_EDIT_BYTES = 48 * 1024 * 1024;
17200
17363
  var EDITOR_CSP = [
17201
17364
  "default-src 'self'",
@@ -17223,7 +17386,7 @@ async function readJsonBody(req) {
17223
17386
  async function serveGallery(opts) {
17224
17387
  const log = opts.onProgress ?? (() => {
17225
17388
  });
17226
- const session = opts.edit || opts.generate || opts.editor || opts.skeleton ? (0, import_node_crypto8.randomBytes)(16).toString("hex") : null;
17389
+ const session = opts.edit || opts.generate || opts.editor || opts.skeleton || opts.price ? (0, import_node_crypto8.randomBytes)(16).toString("hex") : null;
17227
17390
  let media = /* @__PURE__ */ new Map();
17228
17391
  let loading = null;
17229
17392
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -17298,6 +17461,17 @@ async function serveGallery(opts) {
17298
17461
  }
17299
17462
  return;
17300
17463
  }
17464
+ if (req.method === "POST" && url2.pathname === "/api/price") {
17465
+ if (!opts.price || !session) return fail(405, "pricing an edit needs the gallery started with --edit");
17466
+ try {
17467
+ const body = await guardedBody("price requests");
17468
+ if (body === void 0) return;
17469
+ json(200, await opts.price(body));
17470
+ } catch (err) {
17471
+ reportError("price", err);
17472
+ }
17473
+ return;
17474
+ }
17301
17475
  if (req.method === "POST" && url2.pathname === "/api/skeleton/estimate") {
17302
17476
  if (!opts.skeleton || !session) return fail(405, "skeleton estimates need the gallery started with --edit");
17303
17477
  try {
@@ -17365,7 +17539,7 @@ async function serveGallery(opts) {
17365
17539
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
17366
17540
  if (!asset) return fail(404, "no such review asset");
17367
17541
  try {
17368
- const bytes = await (0, import_promises26.readFile)(asset.path);
17542
+ const bytes = await (0, import_promises27.readFile)(asset.path);
17369
17543
  res.writeHead(200, {
17370
17544
  "Content-Type": asset.contentType,
17371
17545
  "Cache-Control": "no-store",
@@ -17448,7 +17622,7 @@ async function serveGallery(opts) {
17448
17622
  if (!asset) return fail(404, "no such gallery media");
17449
17623
  let bytes;
17450
17624
  try {
17451
- bytes = await (0, import_promises26.readFile)(asset.path);
17625
+ bytes = await (0, import_promises27.readFile)(asset.path);
17452
17626
  } catch {
17453
17627
  return fail(404, "gallery media is no longer on disk; refresh the page");
17454
17628
  }
@@ -17492,27 +17666,27 @@ async function serveGallery(opts) {
17492
17666
 
17493
17667
  // src/gallery/generate.ts
17494
17668
  var import_node_crypto9 = require("crypto");
17495
- var import_zod12 = require("zod");
17496
- var GenerateRequestSchema = import_zod12.z.object({
17497
- project: import_zod12.z.string().min(1).optional(),
17498
- keys: import_zod12.z.array(import_zod12.z.string().min(1)).min(1).max(500),
17669
+ var import_zod13 = require("zod");
17670
+ var GenerateRequestSchema = import_zod13.z.object({
17671
+ project: import_zod13.z.string().min(1).optional(),
17672
+ keys: import_zod13.z.array(import_zod13.z.string().min(1)).min(1).max(500),
17499
17673
  /**
17500
17674
  * Regenerate work that is up to date, as `gen --force` does, and let the
17501
17675
  * fetch replace a locally modified or untracked file. With `restore`, put
17502
17676
  * the recorded bytes back over a modified file.
17503
17677
  */
17504
- force: import_zod12.z.boolean().optional(),
17678
+ force: import_zod13.z.boolean().optional(),
17505
17679
  /** Advance in-flight, review, or selected work without submitting anything. */
17506
- resume: import_zod12.z.boolean().optional(),
17680
+ resume: import_zod13.z.boolean().optional(),
17507
17681
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
17508
- refresh: import_zod12.z.boolean().optional(),
17682
+ refresh: import_zod13.z.boolean().optional(),
17509
17683
  /** Put recorded outputs back on disk from the cache or the provider, without generating. */
17510
- restore: import_zod12.z.boolean().optional(),
17684
+ restore: import_zod13.z.boolean().optional(),
17511
17685
  /**
17512
17686
  * Bring a replaced generation back: 1-based position in the record's
17513
17687
  * history (newest first) or an output hash prefix. One key at a time.
17514
17688
  */
17515
- revert: import_zod12.z.string().min(1).max(64).optional()
17689
+ revert: import_zod13.z.string().min(1).max(64).optional()
17516
17690
  }).strict().refine((request) => [request.resume, request.refresh, request.restore, request.revert !== void 0].filter(Boolean).length <= 1, {
17517
17691
  message: "resume, refresh, restore, and revert are exclusive"
17518
17692
  }).refine((request) => request.revert === void 0 || request.keys.length === 1, { message: "revert takes one key" });
@@ -17636,6 +17810,70 @@ function createGenerateHandlers(opts) {
17636
17810
  job.phase = "done";
17637
17811
  job.finishedAt = now().toISOString();
17638
17812
  }
17813
+ async function submitGroups(job, ctx, groups) {
17814
+ job.phase = "submitting";
17815
+ for (const group of groups) {
17816
+ const provider = opts.providerFor(job.project ?? void 0, group.provider);
17817
+ const ceiling = remainingFor(group, groups.length, opts.budget, spent);
17818
+ if (provider.balance) {
17819
+ const balance = await provider.balance();
17820
+ say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
17821
+ if (balance.unit !== group.costUnit) {
17822
+ throw new Error(
17823
+ `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
17824
+ );
17825
+ }
17826
+ if (balance.unit !== "free" && group.cost > balance.remaining) {
17827
+ throw new Error(
17828
+ `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
17829
+ );
17830
+ }
17831
+ }
17832
+ const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
17833
+ budget: ceiling,
17834
+ onProgress: (line) => say(job, line),
17835
+ spacingMs: opts.submitSpacingMs
17836
+ });
17837
+ spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
17838
+ units[group.provider] = res.unit;
17839
+ job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
17840
+ job.counts.submitted += res.submitted;
17841
+ job.counts.failed += res.failed;
17842
+ say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
17843
+ }
17844
+ }
17845
+ async function generateInWaves(job, ctx, specs, groups) {
17846
+ for (let wave = 1; ; wave++) {
17847
+ const submittedBefore = job.counts.submitted;
17848
+ await submitGroups(job, ctx, groups);
17849
+ await settle(job, ctx, specs);
17850
+ if (job.phase !== "done" || job.counts.submitted === submittedBefore) return;
17851
+ const finishedAt = job.finishedAt;
17852
+ const finish = () => {
17853
+ job.phase = "done";
17854
+ job.finishedAt = finishedAt;
17855
+ };
17856
+ job.phase = "queued";
17857
+ job.finishedAt = null;
17858
+ const next = await opts.loadProject(job.project ?? void 0);
17859
+ const byKey = new Map(next.specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
17860
+ const nextSpecs = job.keys.map((key) => byKey.get(key)).filter((spec) => Boolean(spec));
17861
+ const plan = await buildPlan(nextSpecs, next.lock);
17862
+ if (!plan.actionable.length) return finish();
17863
+ for (const group of plan.groups) {
17864
+ const remaining = remainingFor(group, plan.groups.length, opts.budget, spent);
17865
+ if (group.cost > remaining) {
17866
+ say(job, `wave ${wave + 1} would spend ${formatCost(group.costUnit, group.cost)} on ${group.provider} but only ${formatCost(group.costUnit, remaining)} of this session's budget remain; stopping here`);
17867
+ return finish();
17868
+ }
17869
+ }
17870
+ say(job, `wave ${wave + 1}: ${plan.actionable.length} asset(s) whose parents are now on disk: ` + plan.groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
17871
+ contexts.set(job.id, next);
17872
+ ctx = next;
17873
+ specs = nextSpecs;
17874
+ groups = plan.groups;
17875
+ }
17876
+ }
17639
17877
  async function run(job, ctx, specs, groups) {
17640
17878
  try {
17641
17879
  if (job.mode === "refresh") {
@@ -17663,36 +17901,8 @@ function createGenerateHandlers(opts) {
17663
17901
  return;
17664
17902
  }
17665
17903
  if (job.mode === "generate") {
17666
- job.phase = "submitting";
17667
- for (const group of groups) {
17668
- const provider = opts.providerFor(job.project ?? void 0, group.provider);
17669
- const ceiling = remainingFor(group, groups.length, opts.budget, spent);
17670
- if (provider.balance) {
17671
- const balance = await provider.balance();
17672
- say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
17673
- if (balance.unit !== group.costUnit) {
17674
- throw new Error(
17675
- `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
17676
- );
17677
- }
17678
- if (balance.unit !== "free" && group.cost > balance.remaining) {
17679
- throw new Error(
17680
- `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
17681
- );
17682
- }
17683
- }
17684
- const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
17685
- budget: ceiling,
17686
- onProgress: (line) => say(job, line),
17687
- spacingMs: opts.submitSpacingMs
17688
- });
17689
- spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
17690
- units[group.provider] = res.unit;
17691
- job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
17692
- job.counts.submitted += res.submitted;
17693
- job.counts.failed += res.failed;
17694
- say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
17695
- }
17904
+ await generateInWaves(job, ctx, specs, groups);
17905
+ return;
17696
17906
  }
17697
17907
  await settle(job, ctx, specs);
17698
17908
  } catch (error) {
@@ -17822,19 +18032,19 @@ function createGenerateHandlers(opts) {
17822
18032
  }
17823
18033
 
17824
18034
  // src/pipeline/salvage.ts
17825
- var import_promises27 = require("fs/promises");
17826
- var import_node_fs29 = require("fs");
17827
- var import_node_path34 = __toESM(require("path"), 1);
18035
+ var import_promises28 = require("fs/promises");
18036
+ var import_node_fs30 = require("fs");
18037
+ var import_node_path35 = __toESM(require("path"), 1);
17828
18038
  function providerClaimId(provider, objectId) {
17829
18039
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
17830
18040
  }
17831
18041
  async function loadClaims(lockPaths, opts = {}) {
17832
18042
  const claimed = /* @__PURE__ */ new Set();
17833
18043
  for (const p of lockPaths) {
17834
- if (!(0, import_node_fs29.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
18044
+ if (!(0, import_node_fs30.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
17835
18045
  let parsed;
17836
18046
  try {
17837
- parsed = parseLock(JSON.parse(await (0, import_promises27.readFile)(p, "utf8")));
18047
+ parsed = parseLock(JSON.parse(await (0, import_promises28.readFile)(p, "utf8")));
17838
18048
  } catch {
17839
18049
  throw new Error(`Claim lockfile is malformed: ${p}`);
17840
18050
  }
@@ -17938,20 +18148,20 @@ function matchOrphanStyle(prompt, manifest) {
17938
18148
  return matchStyleByPattern(prompt, manifest);
17939
18149
  }
17940
18150
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
17941
- const own = import_node_path34.default.resolve(ownManifestPath);
18151
+ const own = import_node_path35.default.resolve(ownManifestPath);
17942
18152
  const siblingManifestPaths = [
17943
18153
  .../* @__PURE__ */ new Set([
17944
18154
  ...workspaceManifestPaths,
17945
- ...claimPaths.map((c) => import_node_path34.default.join(import_node_path34.default.dirname(import_node_path34.default.resolve(c)), "pixelkiln.manifest.json"))
18155
+ ...claimPaths.map((c) => import_node_path35.default.join(import_node_path35.default.dirname(import_node_path35.default.resolve(c)), "pixelkiln.manifest.json"))
17946
18156
  ])
17947
18157
  ];
17948
18158
  const siblings = [];
17949
18159
  for (const siblingManifestPath of siblingManifestPaths) {
17950
- if (import_node_path34.default.resolve(siblingManifestPath) === own) continue;
17951
- if (!(0, import_node_fs29.existsSync)(siblingManifestPath)) continue;
18160
+ if (import_node_path35.default.resolve(siblingManifestPath) === own) continue;
18161
+ if (!(0, import_node_fs30.existsSync)(siblingManifestPath)) continue;
17952
18162
  try {
17953
18163
  const { manifest } = await loadManifest(siblingManifestPath);
17954
- siblings.push({ label: import_node_path34.default.basename(import_node_path34.default.dirname(siblingManifestPath)), manifest });
18164
+ siblings.push({ label: import_node_path35.default.basename(import_node_path35.default.dirname(siblingManifestPath)), manifest });
17955
18165
  } catch {
17956
18166
  }
17957
18167
  }
@@ -18071,8 +18281,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
18071
18281
  }
18072
18282
 
18073
18283
  // src/pick/salvage-server.ts
18074
- var import_promises28 = require("fs/promises");
18075
- var import_node_path35 = __toESM(require("path"), 1);
18284
+ var import_promises29 = require("fs/promises");
18285
+ var import_node_path36 = __toESM(require("path"), 1);
18076
18286
 
18077
18287
  // src/pick/salvage-sheet.ts
18078
18288
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -18238,7 +18448,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18238
18448
  });
18239
18449
  const html = renderSalvageSheet(orphans, {
18240
18450
  styleId: ctx.styleId,
18241
- importDir: import_node_path35.default.relative(process.cwd(), ctx.importDir) || "."
18451
+ importDir: import_node_path36.default.relative(process.cwd(), ctx.importDir) || "."
18242
18452
  });
18243
18453
  const byId = new Map(orphans.map((o) => [o.id, o]));
18244
18454
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -18294,10 +18504,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18294
18504
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
18295
18505
  decodePng(buf);
18296
18506
  const assetId = idFromPrompt(orphan.prompt, taken);
18297
- const rel = import_node_path35.default.join("_salvaged", `${assetId}.png`);
18298
- const outFile = import_node_path35.default.resolve(ctx.importDir, rel);
18299
- await (0, import_promises28.mkdir)(import_node_path35.default.dirname(outFile), { recursive: true });
18300
- await (0, import_promises28.writeFile)(outFile, buf);
18507
+ const rel = import_node_path36.default.join("_salvaged", `${assetId}.png`);
18508
+ const outFile = import_node_path36.default.resolve(ctx.importDir, rel);
18509
+ await (0, import_promises29.mkdir)(import_node_path36.default.dirname(outFile), { recursive: true });
18510
+ await (0, import_promises29.writeFile)(outFile, buf);
18301
18511
  ctx.manifest.assets[assetId] = {
18302
18512
  prompt: orphan.prompt,
18303
18513
  promptByStyle: {},
@@ -18329,7 +18539,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18329
18539
  sourceUrl: durableSource,
18330
18540
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
18331
18541
  outputs: [{
18332
- path: portableOutputPath(outFile, import_node_path35.default.dirname(ctx.manifestPath)),
18542
+ path: portableOutputPath(outFile, import_node_path36.default.dirname(ctx.manifestPath)),
18333
18543
  sha256: sha256(buf)
18334
18544
  }],
18335
18545
  submittedAt: orphan.createdAt,
@@ -18353,9 +18563,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18353
18563
  }
18354
18564
  }
18355
18565
  await applyTags(provider, decisions, existingTags, { onProgress: log, characters: characterMembers });
18356
- const raw = JSON.parse(await (0, import_promises28.readFile)(ctx.manifestPath, "utf8"));
18566
+ const raw = JSON.parse(await (0, import_promises29.readFile)(ctx.manifestPath, "utf8"));
18357
18567
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
18358
- await (0, import_promises28.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
18568
+ await (0, import_promises29.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
18359
18569
  await saveLock(ctx.lockPath, ctx.lock);
18360
18570
  if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
18361
18571
  return result;