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.js CHANGED
@@ -1001,7 +1001,7 @@ var AssetSchema = z.object({
1001
1001
  path: ["mirror"]
1002
1002
  });
1003
1003
  }
1004
- if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit) {
1004
+ if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit && !asset.animation?.template) {
1005
1005
  context.addIssue({
1006
1006
  code: z.ZodIssueCode.custom,
1007
1007
  message: "Required",
@@ -1736,9 +1736,9 @@ var PixelLabClient = class {
1736
1736
  apiKey;
1737
1737
  http;
1738
1738
  /** Retries transport failures, 429, and 5xx; see http.ts for the policy. */
1739
- async request(path35, init) {
1739
+ async request(path36, init) {
1740
1740
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1741
- const res = await this.http(`${BASE}${path35}`, {
1741
+ const res = await this.http(`${BASE}${path36}`, {
1742
1742
  ...init,
1743
1743
  headers: {
1744
1744
  Authorization: auth,
@@ -1748,13 +1748,13 @@ var PixelLabClient = class {
1748
1748
  });
1749
1749
  const text = await res.text();
1750
1750
  if (!res.ok) {
1751
- throw new PixelLabError(`${init?.method ?? "GET"} ${path35} \u2192 ${res.status}`, res.status, text);
1751
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path36} \u2192 ${res.status}`, res.status, text);
1752
1752
  }
1753
1753
  if (!text) return {};
1754
1754
  try {
1755
1755
  return JSON.parse(text);
1756
1756
  } catch {
1757
- throw new Error(`${init?.method ?? "GET"} ${path35} returned invalid JSON`);
1757
+ throw new Error(`${init?.method ?? "GET"} ${path36} returned invalid JSON`);
1758
1758
  }
1759
1759
  }
1760
1760
  async balance() {
@@ -2146,10 +2146,10 @@ var PixelLabClient = class {
2146
2146
  const palette = args.paletteSwatchBase64 ? { color_image: { type: "base64", base64: args.paletteSwatchBase64, format: "png" }, force_colors: true } : {};
2147
2147
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
2148
2148
  const south = args.reference?.south;
2149
- let path35;
2149
+ let path36;
2150
2150
  let body;
2151
2151
  if (args.mode === "v3") {
2152
- path35 = "/create-character-v3";
2152
+ path36 = "/create-character-v3";
2153
2153
  body = {
2154
2154
  description: args.description,
2155
2155
  image_size: imageSize,
@@ -2163,7 +2163,7 @@ var PixelLabClient = class {
2163
2163
  ...args.enhancePrompt ? { enhance_prompt: true } : {}
2164
2164
  };
2165
2165
  } else if (args.mode === "pro-flash") {
2166
- path35 = "/create-character-pro-flash";
2166
+ path36 = "/create-character-pro-flash";
2167
2167
  const traits = args.styleTraits;
2168
2168
  body = {
2169
2169
  description: args.description,
@@ -2187,7 +2187,7 @@ var PixelLabClient = class {
2187
2187
  } : {}
2188
2188
  };
2189
2189
  } else if (args.mode === "pro") {
2190
- path35 = "/create-character-pro";
2190
+ path36 = "/create-character-pro";
2191
2191
  body = {
2192
2192
  description: args.description,
2193
2193
  image_size: imageSize,
@@ -2201,7 +2201,7 @@ var PixelLabClient = class {
2201
2201
  ...!south && args.styleCharacterId ? { style_character_id: args.styleCharacterId } : {}
2202
2202
  };
2203
2203
  } else {
2204
- path35 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
2204
+ path36 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
2205
2205
  body = {
2206
2206
  description: args.description,
2207
2207
  image_size: imageSize,
@@ -2218,8 +2218,8 @@ var PixelLabClient = class {
2218
2218
  ...palette
2219
2219
  };
2220
2220
  }
2221
- const raw = await this.request(path35, { method: "POST", body: JSON.stringify(body) });
2222
- return validateResponse(CharacterSubmitSchema, raw, path35.slice(1));
2221
+ const raw = await this.request(path36, { method: "POST", body: JSON.stringify(body) });
2222
+ return validateResponse(CharacterSubmitSchema, raw, path36.slice(1));
2223
2223
  }
2224
2224
  /** A text edit of an existing character, applied to every direction and stored as a sibling. */
2225
2225
  async createCharacterState(args) {
@@ -3405,10 +3405,10 @@ import { createReadStream } from "fs";
3405
3405
  function sha256(data) {
3406
3406
  return createHash("sha256").update(data).digest("hex");
3407
3407
  }
3408
- async function sha256File(path35) {
3408
+ async function sha256File(path36) {
3409
3409
  return new Promise((resolve, reject) => {
3410
3410
  const hash = createHash("sha256");
3411
- const stream = createReadStream(path35);
3411
+ const stream = createReadStream(path36);
3412
3412
  stream.on("data", (chunk2) => hash.update(chunk2));
3413
3413
  stream.on("error", reject);
3414
3414
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -3578,7 +3578,11 @@ function characterCost(spec) {
3578
3578
  return 20;
3579
3579
  }
3580
3580
  if (character.mode === "standard") return 1;
3581
- if (character.mode === "v3") return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
3581
+ if (character.mode === "v3") {
3582
+ const south = character.reference?.south;
3583
+ if (south) return Math.max(1, Math.ceil(south.width * south.height * 8 / 65536));
3584
+ return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
3585
+ }
3582
3586
  if (character.mode === "pro-flash") {
3583
3587
  const south = character.reference?.south;
3584
3588
  return south ? proFlashCharacterCost(south.width, south.height, true) : proFlashCharacterCost(spec.width, spec.height, false);
@@ -5772,9 +5776,9 @@ var RetroDiffusionClient = class {
5772
5776
  if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
5773
5777
  return Buffer.from(await response.arrayBuffer());
5774
5778
  }
5775
- async call(path35, init = {}) {
5779
+ async call(path36, init = {}) {
5776
5780
  if (!this.token) throw new Error("RD_API_KEY is not set");
5777
- const response = await this.request(`${this.baseUrl}${path35}`, {
5781
+ const response = await this.request(`${this.baseUrl}${path36}`, {
5778
5782
  ...init,
5779
5783
  headers: {
5780
5784
  "Content-Type": "application/json",
@@ -12063,10 +12067,10 @@ function isSha256Hash(value) {
12063
12067
  function parseCache(value) {
12064
12068
  return HashCacheSchema.parse(value);
12065
12069
  }
12066
- async function loadCache(path35) {
12067
- if (!existsSync13(path35)) return { version: 1, hashes: {} };
12070
+ async function loadCache(path36) {
12071
+ if (!existsSync13(path36)) return { version: 1, hashes: {} };
12068
12072
  try {
12069
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile11(path35, "utf8")));
12073
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile11(path36, "utf8")));
12070
12074
  if (!parsed.success) return { version: 1, hashes: {} };
12071
12075
  return {
12072
12076
  version: 1,
@@ -12078,18 +12082,18 @@ async function loadCache(path35) {
12078
12082
  return { version: 1, hashes: {} };
12079
12083
  }
12080
12084
  }
12081
- async function saveCache(path35, cache) {
12085
+ async function saveCache(path36, cache) {
12082
12086
  const sorted = {};
12083
12087
  for (const key of Object.keys(cache.hashes).sort()) {
12084
12088
  const hash = cache.hashes[key];
12085
12089
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
12086
12090
  sorted[key] = hash;
12087
12091
  }
12088
- await mkdir5(pathModule.dirname(pathModule.resolve(path35)), { recursive: true });
12089
- const tmp = `${path35}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
12092
+ await mkdir5(pathModule.dirname(pathModule.resolve(path36)), { recursive: true });
12093
+ const tmp = `${path36}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
12090
12094
  try {
12091
12095
  await writeFile5(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
12092
- await rename4(tmp, path35);
12096
+ await rename4(tmp, path36);
12093
12097
  } finally {
12094
12098
  await rm4(tmp, { force: true });
12095
12099
  }
@@ -14894,9 +14898,9 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
14894
14898
 
14895
14899
  // src/gallery/snapshot.ts
14896
14900
  import { createHash as createHash4 } from "crypto";
14897
- import { existsSync as existsSync24 } from "fs";
14901
+ import { existsSync as existsSync25 } from "fs";
14898
14902
  import { readFile as readFile22, stat as stat3 } from "fs/promises";
14899
- import path30 from "path";
14903
+ import path31 from "path";
14900
14904
 
14901
14905
  // src/workspace.ts
14902
14906
  import { mkdir as mkdir7, readFile as readFile18, rename as rename6, rm as rm7, writeFile as writeFile9 } from "fs/promises";
@@ -15032,8 +15036,8 @@ function validateWorkspace(ws, dir) {
15032
15036
  }
15033
15037
 
15034
15038
  // src/gallery/edit.ts
15035
- import path29 from "path";
15036
- import { z as z11 } from "zod";
15039
+ import path30 from "path";
15040
+ import { z as z12 } from "zod";
15037
15041
 
15038
15042
  // src/manifest-edit.ts
15039
15043
  import { readFile as readFile19, rename as rename7, rm as rm8, writeFile as writeFile10 } from "fs/promises";
@@ -15064,13 +15068,16 @@ var NewAnimationSchema = z8.object({
15064
15068
  direction: CharacterDirectionSchema.optional(),
15065
15069
  frames: z8.number().int().min(4).max(16).optional(),
15066
15070
  fps: z8.number().int().min(1).max(60).optional(),
15071
+ /** One loop per named direction, plus free mirrors; expanded when the manifest loads. */
15072
+ directions: z8.array(CharacterDirectionSchema).min(1).optional(),
15067
15073
  mode: CharacterAnimationModeSchema.optional(),
15068
15074
  /** What is being animated, when the parent's own description would mislead the model (`character` only). */
15069
15075
  subject: z8.string().min(1).optional(),
15070
15076
  enhancePrompt: z8.boolean().optional()
15071
15077
  }).strict();
15072
15078
  var NewAssetSchema = z8.object({
15073
- prompt: z8.string(),
15079
+ /** A portrait, an outfit, or a mirror has none of its own; the loader says where one is needed. */
15080
+ prompt: z8.string().optional(),
15074
15081
  category: z8.string().optional(),
15075
15082
  width: z8.number().int().optional(),
15076
15083
  height: z8.number().int().optional(),
@@ -15105,7 +15112,24 @@ var NewAssetSchema = z8.object({
15105
15112
  /** A pose or outfit of an existing `character`/`objectPro` base or state. */
15106
15113
  state: NewStateSchema.optional(),
15107
15114
  /** A loop of an existing `character`/`objectPro` base or state. */
15108
- animation: NewAnimationSchema.optional()
15115
+ animation: NewAnimationSchema.optional(),
15116
+ /** A bust of an existing character. */
15117
+ portrait: CharacterPortraitSchema.optional(),
15118
+ /** An existing loop re-clothed from a reference image (uploaded first). */
15119
+ outfit: CharacterOutfitSchema.optional(),
15120
+ /** Another asset of the same style, flipped; free. */
15121
+ mirror: z8.string().min(1).optional(),
15122
+ /**
15123
+ * `character`/`objectPro` bases: the subject's own sprite (uploaded
15124
+ * first), or one per direction, which PixelLab rotates instead of drawing.
15125
+ */
15126
+ reference: z8.union([z8.string().min(1), z8.record(CharacterDirectionSchema, z8.string().min(1))]).optional(),
15127
+ /** `character` pro bases: a concept image that seeds the design. */
15128
+ concept: z8.string().min(1).optional(),
15129
+ /** `character` pro bases: another character in the style whose look this one follows. */
15130
+ styleCharacter: z8.string().min(1).optional(),
15131
+ /** `character` standard humanoid bases: this character's proportions, over the style's. */
15132
+ proportions: CharacterProportionsSchema.optional()
15109
15133
  }).strict().refine((asset) => !(asset.revision && (asset.state || asset.animation)), {
15110
15134
  message: "an asset is a revision, a state, or an animation \u2014 not more than one"
15111
15135
  }).refine((asset) => !(asset.state && asset.animation), {
@@ -15116,59 +15140,95 @@ var CANDIDATE_OPTION = {
15116
15140
  comfyui: "numImages",
15117
15141
  scenario: "numOutputs"
15118
15142
  };
15143
+ var NewStyleSchema = z8.object({
15144
+ generator: z8.enum(["character", "objectPro"]),
15145
+ /** Where the style's art is written, inside the project. */
15146
+ outDir: z8.string().min(1).refine((dir) => !dir.split(/[\\/]/).includes("..") && !/^([a-z]:)?[\\/]/i.test(dir), {
15147
+ message: "outDir must be a path inside the project"
15148
+ }),
15149
+ size: z8.number().int().min(16).max(256).optional(),
15150
+ mode: CharacterModeSchema.optional(),
15151
+ directions: z8.union([z8.literal(4), z8.literal(8)]).optional(),
15152
+ template: z8.string().min(1).optional(),
15153
+ view: z8.string().min(1).max(64).optional(),
15154
+ proportions: CharacterProportionsSchema.optional(),
15155
+ isometric: z8.boolean().optional(),
15156
+ textGuidanceScale: z8.number().min(1).max(20).optional(),
15157
+ enhancePrompt: z8.boolean().optional(),
15158
+ promptPrefix: z8.string().optional(),
15159
+ promptSuffix: z8.string().optional(),
15160
+ palette: z8.array(z8.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).optional()
15161
+ }).strict();
15162
+ var PatchAssetEdit = z8.object({
15163
+ action: z8.literal("patch-asset"),
15164
+ assetId: z8.string().min(1),
15165
+ patch: AssetPatchSchema
15166
+ }).strict();
15167
+ var AddAssetEdit = z8.object({
15168
+ action: z8.literal("add-asset"),
15169
+ assetId: z8.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15170
+ asset: NewAssetSchema
15171
+ }).strict();
15172
+ var SetSourceEdit = z8.object({
15173
+ action: z8.literal("set-source"),
15174
+ assetId: z8.string().min(1),
15175
+ styleId: z8.string().min(1),
15176
+ /** Manifest-relative path of the art that stands in for this asset in this style. */
15177
+ source: z8.string().min(1)
15178
+ }).strict();
15179
+ var ClearSourceEdit = z8.object({
15180
+ action: z8.literal("clear-source"),
15181
+ assetId: z8.string().min(1),
15182
+ styleId: z8.string().min(1)
15183
+ }).strict();
15184
+ var PatchStyleEdit = z8.object({
15185
+ action: z8.literal("patch-style"),
15186
+ styleId: z8.string().min(1),
15187
+ patch: z8.object({
15188
+ /** Candidates per generation, written to the provider's own option. */
15189
+ candidates: z8.number().int().min(1).max(64).optional(),
15190
+ /** Empty clears the style's own value (inherit, or the default). */
15191
+ promptPrefix: z8.string().optional(),
15192
+ promptSuffix: z8.string().optional(),
15193
+ /** `#rrggbb` values; null or empty clears the style's own palette. */
15194
+ palette: z8.array(z8.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
15195
+ /** Snap downloaded art to the palette; false clears the style's own value. */
15196
+ enforcePalette: z8.boolean().optional(),
15197
+ /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
15198
+ view: z8.string().max(64).nullable().optional(),
15199
+ /** pixflux only; null clears the style's own value. */
15200
+ noBackground: z8.boolean().nullable().optional()
15201
+ }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
15202
+ }).strict();
15203
+ var AddStyleEdit = z8.object({
15204
+ action: z8.literal("add-style"),
15205
+ styleId: z8.string().min(1).regex(/^[^/\\:]+$/, "style ids cannot contain slashes or colons"),
15206
+ style: NewStyleSchema
15207
+ }).strict();
15208
+ var Envelope = {
15209
+ /** Workspace project id; omitted for a single-project gallery. */
15210
+ project: z8.string().min(1).optional(),
15211
+ /** Manifest bytes the page last saw; a mismatch refuses the write. */
15212
+ expectedSha256: HexSha
15213
+ };
15214
+ var SingleEdit = z8.discriminatedUnion("action", [PatchAssetEdit, AddAssetEdit, SetSourceEdit, ClearSourceEdit, PatchStyleEdit, AddStyleEdit]);
15119
15215
  var ManifestEditSchema = z8.discriminatedUnion("action", [
15216
+ PatchAssetEdit.extend(Envelope),
15217
+ AddAssetEdit.extend(Envelope),
15218
+ SetSourceEdit.extend(Envelope),
15219
+ ClearSourceEdit.extend(Envelope),
15220
+ PatchStyleEdit.extend(Envelope),
15221
+ AddStyleEdit.extend(Envelope),
15222
+ /**
15223
+ * Several edits as one write: a new character's style, base, and loops
15224
+ * land together or not at all, validated once as the finished manifest.
15225
+ * Each edit sees the ones before it, so a base can name a style the same
15226
+ * batch adds.
15227
+ */
15120
15228
  z8.object({
15121
- action: z8.literal("patch-asset"),
15122
- /** Workspace project id; omitted for a single-project gallery. */
15123
- project: z8.string().min(1).optional(),
15124
- assetId: z8.string().min(1),
15125
- /** Manifest bytes the page last saw; a mismatch refuses the write. */
15126
- expectedSha256: HexSha,
15127
- patch: AssetPatchSchema
15128
- }).strict(),
15129
- z8.object({
15130
- action: z8.literal("add-asset"),
15131
- project: z8.string().min(1).optional(),
15132
- assetId: z8.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15133
- expectedSha256: HexSha,
15134
- asset: NewAssetSchema
15135
- }).strict(),
15136
- z8.object({
15137
- action: z8.literal("set-source"),
15138
- project: z8.string().min(1).optional(),
15139
- assetId: z8.string().min(1),
15140
- styleId: z8.string().min(1),
15141
- /** Manifest-relative path of the art that stands in for this asset in this style. */
15142
- source: z8.string().min(1),
15143
- expectedSha256: HexSha
15144
- }).strict(),
15145
- z8.object({
15146
- action: z8.literal("clear-source"),
15147
- project: z8.string().min(1).optional(),
15148
- assetId: z8.string().min(1),
15149
- styleId: z8.string().min(1),
15150
- expectedSha256: HexSha
15151
- }).strict(),
15152
- z8.object({
15153
- action: z8.literal("patch-style"),
15154
- project: z8.string().min(1).optional(),
15155
- styleId: z8.string().min(1),
15156
- expectedSha256: HexSha,
15157
- patch: z8.object({
15158
- /** Candidates per generation, written to the provider's own option. */
15159
- candidates: z8.number().int().min(1).max(64).optional(),
15160
- /** Empty clears the style's own value (inherit, or the default). */
15161
- promptPrefix: z8.string().optional(),
15162
- promptSuffix: z8.string().optional(),
15163
- /** `#rrggbb` values; null or empty clears the style's own palette. */
15164
- palette: z8.array(z8.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
15165
- /** Snap downloaded art to the palette; false clears the style's own value. */
15166
- enforcePalette: z8.boolean().optional(),
15167
- /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
15168
- view: z8.string().max(64).nullable().optional(),
15169
- /** pixflux only; null clears the style's own value. */
15170
- noBackground: z8.boolean().nullable().optional()
15171
- }).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
15229
+ action: z8.literal("batch"),
15230
+ ...Envelope,
15231
+ edits: z8.array(SingleEdit).min(1).max(64)
15172
15232
  }).strict()
15173
15233
  ]);
15174
15234
  var ManifestDriftError = class extends Error {
@@ -15217,6 +15277,18 @@ function undeclaredAsset(raw, assetId) {
15217
15277
  }
15218
15278
  function applyEdit(raw, edit) {
15219
15279
  raw.assets ??= {};
15280
+ if (edit.action === "batch") {
15281
+ for (const each of edit.edits) applyEdit(raw, each);
15282
+ return;
15283
+ }
15284
+ if (edit.action === "add-style") {
15285
+ raw.styles ??= {};
15286
+ if (Object.hasOwn(raw.styles, edit.styleId)) throw new ManifestEditError(`style "${edit.styleId}" already exists`);
15287
+ const style = { ...edit.style };
15288
+ if (edit.style.palette) style.palette = edit.style.palette.map((color) => "#" + color.replace(/^#/, "").toLowerCase());
15289
+ raw.styles[edit.styleId] = style;
15290
+ return;
15291
+ }
15220
15292
  if (edit.action === "set-source" || edit.action === "clear-source") {
15221
15293
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
15222
15294
  if (!asset2) throw undeclaredAsset(raw, edit.assetId);
@@ -15294,6 +15366,15 @@ function applyEdit(raw, edit) {
15294
15366
  if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
15295
15367
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
15296
15368
  }
15369
+ for (const [what, parent] of [
15370
+ ["portrait", edit.asset.portrait?.of],
15371
+ ["outfit", edit.asset.outfit?.of],
15372
+ ["mirror", edit.asset.mirror]
15373
+ ]) {
15374
+ if (parent && !declaredAssets(raw).has(parent)) {
15375
+ throw new ManifestEditError(`${what} source "${parent}" is not declared by the manifest`);
15376
+ }
15377
+ }
15297
15378
  raw.assets[edit.assetId] = asset2;
15298
15379
  return;
15299
15380
  }
@@ -15321,11 +15402,10 @@ function applyEdit(raw, edit) {
15321
15402
  else delete asset.tags;
15322
15403
  }
15323
15404
  }
15324
- async function applyManifestEdit(manifestPath, edit) {
15405
+ async function withCandidate(manifestPath, edit, use, unchanged) {
15325
15406
  const absolute = path26.resolve(manifestPath);
15326
15407
  const original = await readFile19(absolute, "utf8");
15327
- const before = sha256(original);
15328
- if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
15408
+ if (sha256(original) !== edit.expectedSha256) throw new ManifestDriftError(absolute);
15329
15409
  let raw;
15330
15410
  try {
15331
15411
  raw = JSON.parse(original);
@@ -15336,25 +15416,35 @@ async function applyManifestEdit(manifestPath, edit) {
15336
15416
  }
15337
15417
  applyEdit(raw, edit);
15338
15418
  const next = serializeLike(original, raw);
15339
- if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
15419
+ if (next === original) return unchanged(absolute, original);
15340
15420
  const tmp = path26.join(
15341
15421
  path26.dirname(absolute),
15342
15422
  `.${path26.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
15343
15423
  );
15344
15424
  try {
15345
15425
  await writeFile10(tmp, next);
15426
+ let specs;
15346
15427
  try {
15347
- const loaded = await loadManifest(tmp);
15348
- await resolveSpecs(loaded);
15428
+ specs = await resolveSpecs(await loadManifest(tmp));
15349
15429
  } catch (error) {
15350
15430
  const message6 = error instanceof Error ? error.message : String(error);
15351
15431
  throw new ManifestEditError(message6.replace(tmp, absolute));
15352
15432
  }
15353
- await rename7(tmp, absolute);
15433
+ return await use({ absolute, original, next, tmp, specs });
15354
15434
  } finally {
15355
15435
  await rm8(tmp, { force: true });
15356
15436
  }
15357
- return { manifestPath: absolute, sha256: sha256(next), changed: true };
15437
+ }
15438
+ async function applyManifestEdit(manifestPath, edit) {
15439
+ return withCandidate(
15440
+ manifestPath,
15441
+ edit,
15442
+ async ({ absolute, next, tmp }) => {
15443
+ await rename7(tmp, absolute);
15444
+ return { manifestPath: absolute, sha256: sha256(next), changed: true };
15445
+ },
15446
+ (absolute, original) => ({ manifestPath: absolute, sha256: sha256(original), changed: false })
15447
+ );
15358
15448
  }
15359
15449
 
15360
15450
  // src/pipeline/hand-edit.ts
@@ -15605,62 +15695,103 @@ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
15605
15695
  return openExternal(file, command);
15606
15696
  }
15607
15697
 
15608
- // src/gallery/skeleton.ts
15698
+ // src/gallery/upload.ts
15609
15699
  import { existsSync as existsSync23 } from "fs";
15610
- import { mkdir as mkdir9, readFile as readFile21, rm as rm10, writeFile as writeFile12 } from "fs/promises";
15700
+ import { mkdir as mkdir9, writeFile as writeFile12 } from "fs/promises";
15611
15701
  import path28 from "path";
15612
15702
  import { z as z10 } from "zod";
15613
- var SkeletonEstimateRequestSchema = z10.object({
15703
+ var MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
15704
+ var UploadImageSchema = z10.object({
15705
+ action: z10.literal("upload-image"),
15614
15706
  project: z10.string().min(1).optional(),
15615
- styleId: z10.string().min(1),
15616
- assetId: z10.string().min(1),
15617
- frames: z10.number().int().min(3).max(15).optional()
15707
+ /** Manifest-relative destination, ending .png, .jpg, or .jpeg. */
15708
+ path: z10.string().min(1),
15709
+ base64: z10.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4),
15710
+ /** Replace a file already at `path`. */
15711
+ overwrite: z10.boolean().optional()
15618
15712
  }).strict();
15619
- var SkeletonAnimationRequestSchema = z10.object({
15620
- action: z10.literal("create-skeleton-animation"),
15621
- project: z10.string().min(1).optional(),
15622
- expectedSha256: z10.string().regex(/^[0-9a-f]{64}$/),
15623
- styleId: z10.string().min(1),
15713
+ async function saveProjectImage(root, request) {
15714
+ const file = path28.resolve(root, request.path);
15715
+ const relative = path28.relative(root, file);
15716
+ if (relative.startsWith("..") || path28.isAbsolute(relative)) {
15717
+ throw new ManifestEditError("an upload must stay inside the project");
15718
+ }
15719
+ const extension = path28.extname(file).toLowerCase();
15720
+ const expected = extension === ".png" ? "png" : extension === ".jpg" || extension === ".jpeg" ? "jpeg" : null;
15721
+ if (!expected) throw new ManifestEditError("an upload must be a .png, .jpg, or .jpeg path");
15722
+ const bytes = Buffer.from(request.base64, "base64");
15723
+ if (!bytes.length || bytes.length > MAX_UPLOAD_BYTES) {
15724
+ throw new ManifestEditError(`an upload must be between 1 byte and ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`);
15725
+ }
15726
+ const metadata = imageMetadata(bytes);
15727
+ if (!metadata) throw new ManifestEditError("the upload is not a readable PNG or JPEG");
15728
+ if (metadata.format !== expected) {
15729
+ throw new ManifestEditError(`the upload is a ${metadata.format.toUpperCase()}; name it ${metadata.format === "png" ? ".png" : ".jpg"}`);
15730
+ }
15731
+ if (existsSync23(file) && !request.overwrite) {
15732
+ throw new ManifestEditError(`${relative.split(path28.sep).join("/")} already exists; choose another name, or replace it`);
15733
+ }
15734
+ await mkdir9(path28.dirname(file), { recursive: true });
15735
+ await writeFile12(file, bytes);
15736
+ return relative.split(path28.sep).join("/");
15737
+ }
15738
+
15739
+ // src/gallery/skeleton.ts
15740
+ import { existsSync as existsSync24 } from "fs";
15741
+ import { mkdir as mkdir10, readFile as readFile21, rm as rm10, writeFile as writeFile13 } from "fs/promises";
15742
+ import path29 from "path";
15743
+ import { z as z11 } from "zod";
15744
+ var SkeletonEstimateRequestSchema = z11.object({
15745
+ project: z11.string().min(1).optional(),
15746
+ styleId: z11.string().min(1),
15747
+ assetId: z11.string().min(1),
15748
+ frames: z11.number().int().min(3).max(15).optional()
15749
+ }).strict();
15750
+ var SkeletonAnimationRequestSchema = z11.object({
15751
+ action: z11.literal("create-skeleton-animation"),
15752
+ project: z11.string().min(1).optional(),
15753
+ expectedSha256: z11.string().regex(/^[0-9a-f]{64}$/),
15754
+ styleId: z11.string().min(1),
15624
15755
  /** The sprite the animation poses. */
15625
- from: z10.string().min(1),
15626
- assetId: z10.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15756
+ from: z11.string().min(1),
15757
+ assetId: z11.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
15627
15758
  /** The motion, sent as PixelLab's `action`. */
15628
- prompt: z10.string().min(1),
15759
+ prompt: z11.string().min(1),
15629
15760
  direction: CharacterDirectionSchema,
15630
- description: z10.string().min(1).optional(),
15631
- skeletonTemplate: z10.string().min(1).optional(),
15761
+ description: z11.string().min(1).optional(),
15762
+ skeletonTemplate: z11.string().min(1).optional(),
15632
15763
  /** Manifest-relative JSON path inside the project. */
15633
- keypointsFile: z10.string().min(1),
15764
+ keypointsFile: z11.string().min(1),
15634
15765
  /** Poses to write to `keypointsFile`; omitted to use a file that already exists. */
15635
15766
  set: SkeletonSetSchema.optional(),
15636
15767
  /** Replace an existing `keypointsFile` with `set`. */
15637
- overwrite: z10.boolean().optional()
15768
+ overwrite: z11.boolean().optional()
15638
15769
  }).strict();
15639
- var SkeletonKeypointsUpdateSchema = z10.object({
15640
- action: z10.literal("update-skeleton-keypoints"),
15641
- project: z10.string().min(1).optional(),
15642
- styleId: z10.string().min(1),
15643
- assetId: z10.string().min(1),
15770
+ var SkeletonKeypointsUpdateSchema = z11.object({
15771
+ action: z11.literal("update-skeleton-keypoints"),
15772
+ project: z11.string().min(1).optional(),
15773
+ styleId: z11.string().min(1),
15774
+ assetId: z11.string().min(1),
15644
15775
  /** The file's SHA-256 when the page loaded it; anything else on disk is a concurrent edit. */
15645
- expectedSha256: z10.string().regex(/^[0-9a-f]{64}$/),
15776
+ expectedSha256: z11.string().regex(/^[0-9a-f]{64}$/),
15646
15777
  set: SkeletonSetSchema
15647
15778
  }).strict();
15648
15779
  async function createSkeletonAnimation(manifestPath, request) {
15649
- const root = path28.dirname(path28.resolve(manifestPath));
15650
- const file = path28.resolve(root, request.keypointsFile);
15651
- const relative = path28.relative(root, file);
15652
- if (!request.keypointsFile.endsWith(".json") || relative.startsWith("..") || path28.isAbsolute(relative)) {
15780
+ const root = path29.dirname(path29.resolve(manifestPath));
15781
+ const file = path29.resolve(root, request.keypointsFile);
15782
+ const relative = path29.relative(root, file);
15783
+ if (!request.keypointsFile.endsWith(".json") || relative.startsWith("..") || path29.isAbsolute(relative)) {
15653
15784
  throw new ManifestEditError("the keypoints file must be a .json path inside the project");
15654
15785
  }
15655
- const existed = existsSync23(file);
15786
+ const existed = existsSync24(file);
15656
15787
  let previous = null;
15657
15788
  if (request.set) {
15658
15789
  if (existed && !request.overwrite) {
15659
15790
  throw new ManifestEditError(`${request.keypointsFile} already exists; choose another path, or replace it`);
15660
15791
  }
15661
15792
  if (existed) previous = await readFile21(file);
15662
- await mkdir9(path28.dirname(file), { recursive: true });
15663
- await writeFile12(file, JSON.stringify(request.set, null, 2) + "\n");
15793
+ await mkdir10(path29.dirname(file), { recursive: true });
15794
+ await writeFile13(file, JSON.stringify(request.set, null, 2) + "\n");
15664
15795
  } else if (!existed) {
15665
15796
  throw new ManifestEditError(`${request.keypointsFile} does not exist; paste poses or estimate them from the sprite`);
15666
15797
  }
@@ -15675,7 +15806,7 @@ async function createSkeletonAnimation(manifestPath, request) {
15675
15806
  revision: {
15676
15807
  mode: "animate-skeleton",
15677
15808
  from: request.from,
15678
- keypointsFile: relative.split(path28.sep).join("/"),
15809
+ keypointsFile: relative.split(path29.sep).join("/"),
15679
15810
  direction: request.direction,
15680
15811
  ...request.description ? { description: request.description } : {},
15681
15812
  ...request.skeletonTemplate ? { skeletonTemplate: request.skeletonTemplate } : {}
@@ -15684,7 +15815,7 @@ async function createSkeletonAnimation(manifestPath, request) {
15684
15815
  });
15685
15816
  } catch (error) {
15686
15817
  if (request.set) {
15687
- if (previous) await writeFile12(file, previous);
15818
+ if (previous) await writeFile13(file, previous);
15688
15819
  else await rm10(file, { force: true });
15689
15820
  }
15690
15821
  throw error;
@@ -15696,56 +15827,56 @@ async function updateSkeletonKeypoints(ctx, request) {
15696
15827
  if (!spec) throw new ManifestEditError(`"${key}" is not declared by the manifest`);
15697
15828
  const file = spec.revision?.mode === "animate-skeleton" ? spec.revision.keypointsFile : void 0;
15698
15829
  if (!file) throw new ManifestEditError(`${key} is not an animate-skeleton revision`);
15699
- const relative = path28.relative(ctx.loaded.root, file);
15700
- if (relative.startsWith("..") || path28.isAbsolute(relative)) {
15830
+ const relative = path29.relative(ctx.loaded.root, file);
15831
+ if (relative.startsWith("..") || path29.isAbsolute(relative)) {
15701
15832
  throw new ManifestEditError(`${key}'s keypoints file is outside the project; edit it by hand`);
15702
15833
  }
15703
- const current = existsSync23(file) ? sha256(await readFile21(file)) : null;
15834
+ const current = existsSync24(file) ? sha256(await readFile21(file)) : null;
15704
15835
  if (current !== request.expectedSha256) {
15705
15836
  throw Object.assign(
15706
- new Error(`${relative.split(path28.sep).join("/")} changed on disk since this page loaded it; press Refresh and edit again`),
15837
+ new Error(`${relative.split(path29.sep).join("/")} changed on disk since this page loaded it; press Refresh and edit again`),
15707
15838
  { status: 409 }
15708
15839
  );
15709
15840
  }
15710
- await writeFile12(file, JSON.stringify(request.set, null, 2) + "\n");
15711
- return relative.split(path28.sep).join("/");
15841
+ await writeFile13(file, JSON.stringify(request.set, null, 2) + "\n");
15842
+ return relative.split(path29.sep).join("/");
15712
15843
  }
15713
15844
 
15714
15845
  // src/gallery/edit.ts
15715
- var Base64 = z11.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
15716
- var HandEditRequestSchema = z11.discriminatedUnion("action", [
15717
- z11.object({
15718
- action: z11.literal("start-edit"),
15719
- project: z11.string().min(1).optional(),
15720
- assetId: z11.string().min(1),
15721
- styleId: z11.string().min(1),
15722
- expectedSha256: z11.string().regex(/^[0-9a-f]{64}$/),
15846
+ var Base64 = z12.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
15847
+ var HandEditRequestSchema = z12.discriminatedUnion("action", [
15848
+ z12.object({
15849
+ action: z12.literal("start-edit"),
15850
+ project: z12.string().min(1).optional(),
15851
+ assetId: z12.string().min(1),
15852
+ styleId: z12.string().min(1),
15853
+ expectedSha256: z12.string().regex(/^[0-9a-f]{64}$/),
15723
15854
  /** Also launch the author's editor on the file. */
15724
- open: z11.boolean().optional()
15855
+ open: z12.boolean().optional()
15725
15856
  }).strict(),
15726
- z11.object({
15727
- action: z11.literal("detach-edit"),
15728
- project: z11.string().min(1).optional(),
15729
- assetId: z11.string().min(1),
15730
- styleId: z11.string().min(1),
15731
- expectedSha256: z11.string().regex(/^[0-9a-f]{64}$/)
15857
+ z12.object({
15858
+ action: z12.literal("detach-edit"),
15859
+ project: z12.string().min(1).optional(),
15860
+ assetId: z12.string().min(1),
15861
+ styleId: z12.string().min(1),
15862
+ expectedSha256: z12.string().regex(/^[0-9a-f]{64}$/)
15732
15863
  }).strict(),
15733
15864
  /** From the in-browser editor: the page holds the bytes, the server validates and writes. */
15734
- z11.object({
15735
- action: z11.literal("save-edit"),
15736
- project: z11.string().min(1).optional(),
15737
- assetId: z11.string().min(1),
15738
- styleId: z11.string().min(1),
15739
- expectedSha256: z11.string().regex(/^[0-9a-f]{64}$/),
15865
+ z12.object({
15866
+ action: z12.literal("save-edit"),
15867
+ project: z12.string().min(1).optional(),
15868
+ assetId: z12.string().min(1),
15869
+ styleId: z12.string().min(1),
15870
+ expectedSha256: z12.string().regex(/^[0-9a-f]{64}$/),
15740
15871
  /** The flattened image; for a frame set, `frames` instead. */
15741
15872
  png: Base64.min(1).optional(),
15742
15873
  /** Every frame of a set, tagged with the role it was opened under (null for one added in the editor). */
15743
- frames: z11.array(z11.object({ role: z11.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
15874
+ frames: z12.array(z12.object({ role: z12.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
15744
15875
  /** The editor's layered project file, kept beside the edit. */
15745
15876
  pxo: Base64.optional(),
15746
15877
  /** Editor identity from the bridge's ready message. */
15747
- editor: z11.string().min(1).max(200),
15748
- protocol: z11.number().int().positive()
15878
+ editor: z12.string().min(1).max(200),
15879
+ protocol: z12.number().int().positive()
15749
15880
  }).strict()
15750
15881
  ]);
15751
15882
  function createGalleryEditHandler(opts) {
@@ -15786,6 +15917,21 @@ function createGalleryEditHandler(opts) {
15786
15917
  }
15787
15918
  return opts.reload();
15788
15919
  }
15920
+ const upload = UploadImageSchema.safeParse(body);
15921
+ if (upload.success) {
15922
+ let manifestPath2;
15923
+ try {
15924
+ manifestPath2 = await opts.manifestFor(upload.data.project);
15925
+ } catch (error) {
15926
+ throw new ManifestEditError(error instanceof Error ? error.message : String(error));
15927
+ }
15928
+ const written = await saveProjectImage(path30.dirname(path30.resolve(manifestPath2)), upload.data);
15929
+ log(` image added: ${written}`);
15930
+ return opts.reload();
15931
+ }
15932
+ if (body?.action === "upload-image") {
15933
+ throw new ManifestEditError("invalid upload: " + upload.error.issues.slice(0, 2).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
15934
+ }
15789
15935
  const poses = SkeletonKeypointsUpdateSchema.safeParse(body);
15790
15936
  if (poses.success) {
15791
15937
  if (!opts.loadProject) throw new ManifestEditError("pose edits are not available in this gallery");
@@ -15829,19 +15975,35 @@ function createGalleryEditHandler(opts) {
15829
15975
  }
15830
15976
  const result = await applyManifestEdit(manifestPath, parsed.data);
15831
15977
  if (result.changed) {
15832
- const relative = path29.relative(process.cwd(), result.manifestPath);
15978
+ const relative = path30.relative(process.cwd(), result.manifestPath);
15833
15979
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
15834
- 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;
15835
- log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
15980
+ log(` manifest edited: ${describeEdit(parsed.data)} in ${shown}`);
15836
15981
  }
15837
15982
  return opts.reload();
15838
15983
  };
15839
15984
  }
15985
+ function describeEdit(edit) {
15986
+ switch (edit.action) {
15987
+ case "batch":
15988
+ return `${edit.edits.length} changes (${edit.edits.map(describeEdit).join("; ")})`;
15989
+ case "add-style":
15990
+ return `added style ${edit.styleId}`;
15991
+ case "patch-style":
15992
+ return `changed style ${edit.styleId}`;
15993
+ case "set-source":
15994
+ case "clear-source":
15995
+ return `changed ${edit.styleId}/${edit.assetId} source`;
15996
+ case "add-asset":
15997
+ return `added ${edit.assetId}`;
15998
+ case "patch-asset":
15999
+ return `changed ${edit.assetId}`;
16000
+ }
16001
+ }
15840
16002
 
15841
16003
  // src/gallery/snapshot.ts
15842
16004
  var PROJECT_FILE_TYPE = "application/octet-stream";
15843
16005
  function galleryMediaId(absolutePath) {
15844
- return createHash4("sha256").update(path30.resolve(absolutePath)).digest("hex").slice(0, 24);
16006
+ return createHash4("sha256").update(path31.resolve(absolutePath)).digest("hex").slice(0, 24);
15845
16007
  }
15846
16008
  function galleryMediaRoute(id) {
15847
16009
  return `/media/${id}`;
@@ -15860,9 +16022,9 @@ function describeHistory(media, entry, cacheDir) {
15860
16022
  return (entry.history ?? []).map((generation, i) => {
15861
16023
  const outputs = generation.outputs.map((output) => {
15862
16024
  const mediaType = output.mediaType ?? MediaType.PNG;
15863
- const file = path30.join(cacheDir, cacheFileName(output.sha256, mediaType));
16025
+ const file = path31.join(cacheDir, cacheFileName(output.sha256, mediaType));
15864
16026
  let url = null;
15865
- if (existsSync24(file)) {
16027
+ if (existsSync25(file)) {
15866
16028
  const id = galleryMediaId(file);
15867
16029
  media.set(id, { path: file, contentType: mediaType });
15868
16030
  url = `${galleryMediaRoute(id)}?v=${output.sha256.slice(0, 24)}`;
@@ -15992,10 +16154,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
15992
16154
  async function describeQuality(media, root, inspection) {
15993
16155
  const outputPaths = inspection.outputs ?? [inspection.output];
15994
16156
  const outputs = await Promise.all(
15995
- outputPaths.map((file) => describeOutput(media, root, path30.resolve(root, file)))
16157
+ outputPaths.map((file) => describeOutput(media, root, path31.resolve(root, file)))
15996
16158
  );
15997
- const recordPath = path30.resolve(root, inspection.record);
15998
- const recordExists = existsSync24(recordPath);
16159
+ const recordPath = path31.resolve(root, inspection.record);
16160
+ const recordExists = existsSync25(recordPath);
15999
16161
  let options = null;
16000
16162
  let check = null;
16001
16163
  if (recordExists) {
@@ -16014,7 +16176,7 @@ async function describeQuality(media, root, inspection) {
16014
16176
  return {
16015
16177
  state: inspection.state,
16016
16178
  reason: inspection.reason,
16017
- output: portableOutputPath(path30.resolve(root, inspection.output), root),
16179
+ output: portableOutputPath(path31.resolve(root, inspection.output), root),
16018
16180
  record: portableOutputPath(recordPath, root),
16019
16181
  recordExists,
16020
16182
  outputs,
@@ -16054,7 +16216,7 @@ async function buildGallerySnapshot(opts) {
16054
16216
  const items = [];
16055
16217
  const manifestText = await readFile22(loaded.path, "utf8");
16056
16218
  const manifestSha256 = sha256(manifestText);
16057
- const cacheDir = path30.resolve(path30.dirname(opts.lockPath), ".pixelkiln", "cache");
16219
+ const cacheDir = path31.resolve(path31.dirname(opts.lockPath), ".pixelkiln", "cache");
16058
16220
  let rawStyles = {};
16059
16221
  try {
16060
16222
  const raw = JSON.parse(manifestText);
@@ -16078,12 +16240,12 @@ async function buildGallerySnapshot(opts) {
16078
16240
  )
16079
16241
  );
16080
16242
  if (spec.source) {
16081
- const editPath = path30.resolve(root, spec.source);
16243
+ const editPath = path31.resolve(root, spec.source);
16082
16244
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
16083
16245
  const editShas = await Promise.all(
16084
16246
  Array.from({ length: memberCount }, async (_, index) => {
16085
16247
  const file = sourceOutputPath(spec.source, entry, index, root);
16086
- return { file, sha256: existsSync24(file) ? await sha256File(file) : null };
16248
+ return { file, sha256: existsSync25(file) ? await sha256File(file) : null };
16087
16249
  })
16088
16250
  );
16089
16251
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -16091,7 +16253,7 @@ async function buildGallerySnapshot(opts) {
16091
16253
  const companion = await readHandEditCompanion(editPath);
16092
16254
  if (companion) {
16093
16255
  const projectPath = handEditProjectPath(editPath);
16094
- const project = companion.project && existsSync24(projectPath) ? await fileInfo(projectPath) : null;
16256
+ const project = companion.project && existsSync25(projectPath) ? await fileInfo(projectPath) : null;
16095
16257
  let projectUrl = null;
16096
16258
  if (project) {
16097
16259
  const id = galleryMediaId(projectPath);
@@ -16127,8 +16289,8 @@ async function buildGallerySnapshot(opts) {
16127
16289
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
16128
16290
  }
16129
16291
  } else if (spec.source) {
16130
- outputs = [await describeOutput(media, root, path30.resolve(root, spec.source))];
16131
- } else if (!entry && existsSync24(spec.outFile)) {
16292
+ outputs = [await describeOutput(media, root, path31.resolve(root, spec.source))];
16293
+ } else if (!entry && existsSync25(spec.outFile)) {
16132
16294
  outputs = [await describeOutput(media, root, spec.outFile)];
16133
16295
  } else {
16134
16296
  outputs = [];
@@ -16329,7 +16491,7 @@ async function buildGallerySnapshot(opts) {
16329
16491
  id: loaded.manifest.name,
16330
16492
  name: loaded.manifest.name,
16331
16493
  manifest: loaded.path,
16332
- lock: path30.resolve(opts.lockPath),
16494
+ lock: path31.resolve(opts.lockPath),
16333
16495
  root,
16334
16496
  provider: loaded.manifest.provider,
16335
16497
  account: null,
@@ -16355,7 +16517,7 @@ async function buildGallerySnapshot(opts) {
16355
16517
  return { snapshot, media };
16356
16518
  }
16357
16519
  async function buildWorkspaceGallerySnapshot(opts) {
16358
- const dir = path30.dirname(path30.resolve(opts.workspacePath));
16520
+ const dir = path31.dirname(path31.resolve(opts.workspacePath));
16359
16521
  const media = /* @__PURE__ */ new Map();
16360
16522
  const projects = [];
16361
16523
  const items = [];
@@ -16398,7 +16560,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
16398
16560
  id: project.id,
16399
16561
  name: loaded.manifest.name,
16400
16562
  manifest: manifestPath,
16401
- lock: path30.resolve(lockPath),
16563
+ lock: path31.resolve(lockPath),
16402
16564
  root: loaded.root,
16403
16565
  provider: loaded.manifest.provider,
16404
16566
  account: project.account ?? null,
@@ -16414,8 +16576,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
16414
16576
  id: project.id,
16415
16577
  name: project.id,
16416
16578
  manifest: manifestPath,
16417
- lock: path30.resolve(lockPath),
16418
- root: path30.dirname(manifestPath),
16579
+ lock: path31.resolve(lockPath),
16580
+ root: path31.dirname(manifestPath),
16419
16581
  provider: project.provider,
16420
16582
  account: project.account ?? null,
16421
16583
  manifestSha256: null,
@@ -16442,7 +16604,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
16442
16604
  version: 1,
16443
16605
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
16444
16606
  project: null,
16445
- workspace: { path: path30.resolve(opts.workspacePath), dir, projects },
16607
+ workspace: { path: path31.resolve(opts.workspacePath), dir, projects },
16446
16608
  filter,
16447
16609
  totals: {
16448
16610
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -16459,7 +16621,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
16459
16621
  }
16460
16622
 
16461
16623
  // src/gallery/page.ts
16462
- import { existsSync as existsSync25, readFileSync as readFileSync5 } from "fs";
16624
+ import { existsSync as existsSync26, readFileSync as readFileSync5 } from "fs";
16463
16625
 
16464
16626
  // src/gallery/client-bundle.ts
16465
16627
  import { createRequire } from "module";
@@ -16500,7 +16662,7 @@ function clientAssets() {
16500
16662
  const prebuilt = new URL("gallery.js", CLIENT_DIR);
16501
16663
  client ??= {
16502
16664
  css: readFileSync5(new URL("gallery.css", CLIENT_DIR), "utf8"),
16503
- js: existsSync25(prebuilt) ? readFileSync5(prebuilt, "utf8") : bundleGalleryClient()
16665
+ js: existsSync26(prebuilt) ? readFileSync5(prebuilt, "utf8") : bundleGalleryClient()
16504
16666
  };
16505
16667
  return client;
16506
16668
  }
@@ -16542,6 +16704,7 @@ ${css}</style>
16542
16704
  <option value="none">Group: none</option>
16543
16705
  </select>
16544
16706
  <span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
16707
+ <button id="studio" type="button" hidden title="Draft a new character: its style, base, loops, and portrait, priced as you go">+ New character</button>
16545
16708
  <button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
16546
16709
  <label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
16547
16710
  </div>
@@ -16627,10 +16790,10 @@ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download
16627
16790
 
16628
16791
  // src/editor/install.ts
16629
16792
  import { createHash as createHash5 } from "crypto";
16630
- import { existsSync as existsSync26 } from "fs";
16631
- import { mkdir as mkdir10, readFile as readFile23, rename as rename9, rm as rm11, stat as stat4, writeFile as writeFile13 } from "fs/promises";
16793
+ import { existsSync as existsSync27 } from "fs";
16794
+ import { mkdir as mkdir11, readFile as readFile23, rename as rename9, rm as rm11, stat as stat4, writeFile as writeFile14 } from "fs/promises";
16632
16795
  import os2 from "os";
16633
- import path31 from "path";
16796
+ import path32 from "path";
16634
16797
  var EditorInstallError = class extends Error {
16635
16798
  constructor(message6, file) {
16636
16799
  super(message6);
@@ -16641,15 +16804,15 @@ var EditorInstallError = class extends Error {
16641
16804
  };
16642
16805
  function editorToolsRoot() {
16643
16806
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
16644
- if (override) return path31.resolve(override);
16645
- if (process.platform === "darwin") return path31.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
16807
+ if (override) return path32.resolve(override);
16808
+ if (process.platform === "darwin") return path32.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
16646
16809
  if (process.platform === "win32") {
16647
- return path31.join(process.env.LOCALAPPDATA ?? path31.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
16810
+ return path32.join(process.env.LOCALAPPDATA ?? path32.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
16648
16811
  }
16649
- return path31.join(process.env.XDG_CACHE_HOME ?? path31.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
16812
+ return path32.join(process.env.XDG_CACHE_HOME ?? path32.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
16650
16813
  }
16651
16814
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
16652
- return path31.join(root, "pixelorama", pin.release ?? "unpublished");
16815
+ return path32.join(root, "pixelorama", pin.release ?? "unpublished");
16653
16816
  }
16654
16817
  function editorBaseUrl(pin = EDITOR_PIN) {
16655
16818
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -16672,8 +16835,8 @@ async function editorStatus(opts = {}) {
16672
16835
  let totalBytes = 0;
16673
16836
  for (const [name, expected] of Object.entries(pin.files)) {
16674
16837
  totalBytes += expected.bytes;
16675
- const file = path31.join(dir, name);
16676
- const info = existsSync26(file) ? await stat4(file).catch(() => null) : null;
16838
+ const file = path32.join(dir, name);
16839
+ const info = existsSync27(file) ? await stat4(file).catch(() => null) : null;
16677
16840
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
16678
16841
  installedBytes += expected.bytes;
16679
16842
  } else {
@@ -16702,7 +16865,7 @@ async function installEditor(opts = {}) {
16702
16865
  );
16703
16866
  }
16704
16867
  if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
16705
- await mkdir10(dir, { recursive: true });
16868
+ await mkdir11(dir, { recursive: true });
16706
16869
  const downloaded = [];
16707
16870
  const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
16708
16871
  let fetchedBytes = before.installedBytes;
@@ -16730,10 +16893,10 @@ async function installEditor(opts = {}) {
16730
16893
  if (digest2 !== expected.sha256) {
16731
16894
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
16732
16895
  }
16733
- const final = path31.join(dir, name);
16896
+ const final = path32.join(dir, name);
16734
16897
  const tmp = `${final}.${process.pid}.part`;
16735
16898
  try {
16736
- await writeFile13(tmp, bytes);
16899
+ await writeFile14(tmp, bytes);
16737
16900
  await rename9(tmp, final);
16738
16901
  } finally {
16739
16902
  await rm11(tmp, { force: true });
@@ -16776,12 +16939,12 @@ var EDITOR_CONTENT_TYPES = {
16776
16939
  ".json": "application/json; charset=utf-8"
16777
16940
  };
16778
16941
  function editorContentType(name) {
16779
- return EDITOR_CONTENT_TYPES[path31.extname(name).toLowerCase()] ?? null;
16942
+ return EDITOR_CONTENT_TYPES[path32.extname(name).toLowerCase()] ?? null;
16780
16943
  }
16781
16944
 
16782
16945
  // src/gallery/editor.ts
16783
16946
  import { readFile as readFile24 } from "fs/promises";
16784
- import path32 from "path";
16947
+ import path33 from "path";
16785
16948
  function editorRoute(release, name = "index.html") {
16786
16949
  return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
16787
16950
  }
@@ -16854,7 +17017,7 @@ function createGalleryEditorHandlers(opts = {}) {
16854
17017
  if (!status.installed) return null;
16855
17018
  let bytes;
16856
17019
  try {
16857
- bytes = await readFile24(path32.join(dir, name));
17020
+ bytes = await readFile24(path33.join(dir, name));
16858
17021
  } catch {
16859
17022
  verified = null;
16860
17023
  return null;
@@ -16899,7 +17062,7 @@ async function readJsonBody(req) {
16899
17062
  async function serveGallery(opts) {
16900
17063
  const log = opts.onProgress ?? (() => {
16901
17064
  });
16902
- const session = opts.edit || opts.generate || opts.editor || opts.skeleton ? randomBytes(16).toString("hex") : null;
17065
+ const session = opts.edit || opts.generate || opts.editor || opts.skeleton || opts.price ? randomBytes(16).toString("hex") : null;
16903
17066
  let media = /* @__PURE__ */ new Map();
16904
17067
  let loading = null;
16905
17068
  const reviewAssets = /* @__PURE__ */ new Map();
@@ -16974,6 +17137,17 @@ async function serveGallery(opts) {
16974
17137
  }
16975
17138
  return;
16976
17139
  }
17140
+ if (req.method === "POST" && url2.pathname === "/api/price") {
17141
+ if (!opts.price || !session) return fail(405, "pricing an edit needs the gallery started with --edit");
17142
+ try {
17143
+ const body = await guardedBody("price requests");
17144
+ if (body === void 0) return;
17145
+ json(200, await opts.price(body));
17146
+ } catch (err) {
17147
+ reportError("price", err);
17148
+ }
17149
+ return;
17150
+ }
16977
17151
  if (req.method === "POST" && url2.pathname === "/api/skeleton/estimate") {
16978
17152
  if (!opts.skeleton || !session) return fail(405, "skeleton estimates need the gallery started with --edit");
16979
17153
  try {
@@ -17168,27 +17342,27 @@ async function serveGallery(opts) {
17168
17342
 
17169
17343
  // src/gallery/generate.ts
17170
17344
  import { randomBytes as randomBytes2 } from "crypto";
17171
- import { z as z12 } from "zod";
17172
- var GenerateRequestSchema = z12.object({
17173
- project: z12.string().min(1).optional(),
17174
- keys: z12.array(z12.string().min(1)).min(1).max(500),
17345
+ import { z as z13 } from "zod";
17346
+ var GenerateRequestSchema = z13.object({
17347
+ project: z13.string().min(1).optional(),
17348
+ keys: z13.array(z13.string().min(1)).min(1).max(500),
17175
17349
  /**
17176
17350
  * Regenerate work that is up to date, as `gen --force` does, and let the
17177
17351
  * fetch replace a locally modified or untracked file. With `restore`, put
17178
17352
  * the recorded bytes back over a modified file.
17179
17353
  */
17180
- force: z12.boolean().optional(),
17354
+ force: z13.boolean().optional(),
17181
17355
  /** Advance in-flight, review, or selected work without submitting anything. */
17182
- resume: z12.boolean().optional(),
17356
+ resume: z13.boolean().optional(),
17183
17357
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
17184
- refresh: z12.boolean().optional(),
17358
+ refresh: z13.boolean().optional(),
17185
17359
  /** Put recorded outputs back on disk from the cache or the provider, without generating. */
17186
- restore: z12.boolean().optional(),
17360
+ restore: z13.boolean().optional(),
17187
17361
  /**
17188
17362
  * Bring a replaced generation back: 1-based position in the record's
17189
17363
  * history (newest first) or an output hash prefix. One key at a time.
17190
17364
  */
17191
- revert: z12.string().min(1).max(64).optional()
17365
+ revert: z13.string().min(1).max(64).optional()
17192
17366
  }).strict().refine((request) => [request.resume, request.refresh, request.restore, request.revert !== void 0].filter(Boolean).length <= 1, {
17193
17367
  message: "resume, refresh, restore, and revert are exclusive"
17194
17368
  }).refine((request) => request.revert === void 0 || request.keys.length === 1, { message: "revert takes one key" });
@@ -17312,6 +17486,70 @@ function createGenerateHandlers(opts) {
17312
17486
  job.phase = "done";
17313
17487
  job.finishedAt = now().toISOString();
17314
17488
  }
17489
+ async function submitGroups(job, ctx, groups) {
17490
+ job.phase = "submitting";
17491
+ for (const group of groups) {
17492
+ const provider = opts.providerFor(job.project ?? void 0, group.provider);
17493
+ const ceiling = remainingFor(group, groups.length, opts.budget, spent);
17494
+ if (provider.balance) {
17495
+ const balance = await provider.balance();
17496
+ say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
17497
+ if (balance.unit !== group.costUnit) {
17498
+ throw new Error(
17499
+ `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
17500
+ );
17501
+ }
17502
+ if (balance.unit !== "free" && group.cost > balance.remaining) {
17503
+ throw new Error(
17504
+ `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
17505
+ );
17506
+ }
17507
+ }
17508
+ const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
17509
+ budget: ceiling,
17510
+ onProgress: (line) => say(job, line),
17511
+ spacingMs: opts.submitSpacingMs
17512
+ });
17513
+ spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
17514
+ units[group.provider] = res.unit;
17515
+ job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
17516
+ job.counts.submitted += res.submitted;
17517
+ job.counts.failed += res.failed;
17518
+ say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
17519
+ }
17520
+ }
17521
+ async function generateInWaves(job, ctx, specs, groups) {
17522
+ for (let wave = 1; ; wave++) {
17523
+ const submittedBefore = job.counts.submitted;
17524
+ await submitGroups(job, ctx, groups);
17525
+ await settle(job, ctx, specs);
17526
+ if (job.phase !== "done" || job.counts.submitted === submittedBefore) return;
17527
+ const finishedAt = job.finishedAt;
17528
+ const finish = () => {
17529
+ job.phase = "done";
17530
+ job.finishedAt = finishedAt;
17531
+ };
17532
+ job.phase = "queued";
17533
+ job.finishedAt = null;
17534
+ const next = await opts.loadProject(job.project ?? void 0);
17535
+ const byKey = new Map(next.specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
17536
+ const nextSpecs = job.keys.map((key) => byKey.get(key)).filter((spec) => Boolean(spec));
17537
+ const plan = await buildPlan(nextSpecs, next.lock);
17538
+ if (!plan.actionable.length) return finish();
17539
+ for (const group of plan.groups) {
17540
+ const remaining = remainingFor(group, plan.groups.length, opts.budget, spent);
17541
+ if (group.cost > remaining) {
17542
+ 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`);
17543
+ return finish();
17544
+ }
17545
+ }
17546
+ 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("; "));
17547
+ contexts.set(job.id, next);
17548
+ ctx = next;
17549
+ specs = nextSpecs;
17550
+ groups = plan.groups;
17551
+ }
17552
+ }
17315
17553
  async function run(job, ctx, specs, groups) {
17316
17554
  try {
17317
17555
  if (job.mode === "refresh") {
@@ -17339,36 +17577,8 @@ function createGenerateHandlers(opts) {
17339
17577
  return;
17340
17578
  }
17341
17579
  if (job.mode === "generate") {
17342
- job.phase = "submitting";
17343
- for (const group of groups) {
17344
- const provider = opts.providerFor(job.project ?? void 0, group.provider);
17345
- const ceiling = remainingFor(group, groups.length, opts.budget, spent);
17346
- if (provider.balance) {
17347
- const balance = await provider.balance();
17348
- say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
17349
- if (balance.unit !== group.costUnit) {
17350
- throw new Error(
17351
- `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
17352
- );
17353
- }
17354
- if (balance.unit !== "free" && group.cost > balance.remaining) {
17355
- throw new Error(
17356
- `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
17357
- );
17358
- }
17359
- }
17360
- const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
17361
- budget: ceiling,
17362
- onProgress: (line) => say(job, line),
17363
- spacingMs: opts.submitSpacingMs
17364
- });
17365
- spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
17366
- units[group.provider] = res.unit;
17367
- job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
17368
- job.counts.submitted += res.submitted;
17369
- job.counts.failed += res.failed;
17370
- say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
17371
- }
17580
+ await generateInWaves(job, ctx, specs, groups);
17581
+ return;
17372
17582
  }
17373
17583
  await settle(job, ctx, specs);
17374
17584
  } catch (error) {
@@ -17499,15 +17709,15 @@ function createGenerateHandlers(opts) {
17499
17709
 
17500
17710
  // src/pipeline/salvage.ts
17501
17711
  import { readFile as readFile26 } from "fs/promises";
17502
- import { existsSync as existsSync27 } from "fs";
17503
- import path33 from "path";
17712
+ import { existsSync as existsSync28 } from "fs";
17713
+ import path34 from "path";
17504
17714
  function providerClaimId(provider, objectId) {
17505
17715
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
17506
17716
  }
17507
17717
  async function loadClaims(lockPaths, opts = {}) {
17508
17718
  const claimed = /* @__PURE__ */ new Set();
17509
17719
  for (const p of lockPaths) {
17510
- if (!existsSync27(p)) throw new Error(`Claim lockfile not found: ${p}`);
17720
+ if (!existsSync28(p)) throw new Error(`Claim lockfile not found: ${p}`);
17511
17721
  let parsed;
17512
17722
  try {
17513
17723
  parsed = parseLock(JSON.parse(await readFile26(p, "utf8")));
@@ -17614,20 +17824,20 @@ function matchOrphanStyle(prompt, manifest) {
17614
17824
  return matchStyleByPattern(prompt, manifest);
17615
17825
  }
17616
17826
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
17617
- const own = path33.resolve(ownManifestPath);
17827
+ const own = path34.resolve(ownManifestPath);
17618
17828
  const siblingManifestPaths = [
17619
17829
  .../* @__PURE__ */ new Set([
17620
17830
  ...workspaceManifestPaths,
17621
- ...claimPaths.map((c) => path33.join(path33.dirname(path33.resolve(c)), "pixelkiln.manifest.json"))
17831
+ ...claimPaths.map((c) => path34.join(path34.dirname(path34.resolve(c)), "pixelkiln.manifest.json"))
17622
17832
  ])
17623
17833
  ];
17624
17834
  const siblings = [];
17625
17835
  for (const siblingManifestPath of siblingManifestPaths) {
17626
- if (path33.resolve(siblingManifestPath) === own) continue;
17627
- if (!existsSync27(siblingManifestPath)) continue;
17836
+ if (path34.resolve(siblingManifestPath) === own) continue;
17837
+ if (!existsSync28(siblingManifestPath)) continue;
17628
17838
  try {
17629
17839
  const { manifest } = await loadManifest(siblingManifestPath);
17630
- siblings.push({ label: path33.basename(path33.dirname(siblingManifestPath)), manifest });
17840
+ siblings.push({ label: path34.basename(path34.dirname(siblingManifestPath)), manifest });
17631
17841
  } catch {
17632
17842
  }
17633
17843
  }
@@ -17747,8 +17957,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
17747
17957
  }
17748
17958
 
17749
17959
  // src/pick/salvage-server.ts
17750
- import { mkdir as mkdir11, writeFile as writeFile14, readFile as readFile27 } from "fs/promises";
17751
- import path34 from "path";
17960
+ import { mkdir as mkdir12, writeFile as writeFile15, readFile as readFile27 } from "fs/promises";
17961
+ import path35 from "path";
17752
17962
 
17753
17963
  // src/pick/salvage-sheet.ts
17754
17964
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -17914,7 +18124,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
17914
18124
  });
17915
18125
  const html = renderSalvageSheet(orphans, {
17916
18126
  styleId: ctx.styleId,
17917
- importDir: path34.relative(process.cwd(), ctx.importDir) || "."
18127
+ importDir: path35.relative(process.cwd(), ctx.importDir) || "."
17918
18128
  });
17919
18129
  const byId = new Map(orphans.map((o) => [o.id, o]));
17920
18130
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -17970,10 +18180,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
17970
18180
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
17971
18181
  decodePng(buf);
17972
18182
  const assetId = idFromPrompt(orphan.prompt, taken);
17973
- const rel = path34.join("_salvaged", `${assetId}.png`);
17974
- const outFile = path34.resolve(ctx.importDir, rel);
17975
- await mkdir11(path34.dirname(outFile), { recursive: true });
17976
- await writeFile14(outFile, buf);
18183
+ const rel = path35.join("_salvaged", `${assetId}.png`);
18184
+ const outFile = path35.resolve(ctx.importDir, rel);
18185
+ await mkdir12(path35.dirname(outFile), { recursive: true });
18186
+ await writeFile15(outFile, buf);
17977
18187
  ctx.manifest.assets[assetId] = {
17978
18188
  prompt: orphan.prompt,
17979
18189
  promptByStyle: {},
@@ -18005,7 +18215,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18005
18215
  sourceUrl: durableSource,
18006
18216
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
18007
18217
  outputs: [{
18008
- path: portableOutputPath(outFile, path34.dirname(ctx.manifestPath)),
18218
+ path: portableOutputPath(outFile, path35.dirname(ctx.manifestPath)),
18009
18219
  sha256: sha256(buf)
18010
18220
  }],
18011
18221
  submittedAt: orphan.createdAt,
@@ -18031,7 +18241,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
18031
18241
  await applyTags(provider, decisions, existingTags, { onProgress: log, characters: characterMembers });
18032
18242
  const raw = JSON.parse(await readFile27(ctx.manifestPath, "utf8"));
18033
18243
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
18034
- await writeFile14(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
18244
+ await writeFile15(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
18035
18245
  await saveLock(ctx.lockPath, ctx.lock);
18036
18246
  if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
18037
18247
  return result;