pixelkiln 0.61.0 → 0.62.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/cli.js CHANGED
@@ -67,8 +67,8 @@ function exitCodeFor(err) {
67
67
  }
68
68
 
69
69
  // src/cli/main.ts
70
- import { existsSync as existsSync32 } from "fs";
71
- import { readFile as readFile29 } from "fs/promises";
70
+ import { existsSync as existsSync33 } from "fs";
71
+ import { readFile as readFile30 } from "fs/promises";
72
72
 
73
73
  // src/cli/args.ts
74
74
  import path from "path";
@@ -103,7 +103,11 @@ var VALUE_FLAGS = [
103
103
  "--reviewer",
104
104
  "--note",
105
105
  "--model-root",
106
- "--generation"
106
+ "--generation",
107
+ "--quantize",
108
+ "--description",
109
+ "--weight",
110
+ "--glyph-px"
107
111
  ];
108
112
  var BOOL_FLAGS = [
109
113
  "--force",
@@ -153,6 +157,8 @@ var COMMANDS = [
153
157
  "refine",
154
158
  "recipe",
155
159
  "workspace",
160
+ "unzoom",
161
+ "font",
156
162
  "help",
157
163
  "--help",
158
164
  "-h",
@@ -406,7 +412,11 @@ function parseArgs(argv) {
406
412
  workspace: get("--workspace"),
407
413
  target,
408
414
  provider: get("--provider"),
409
- account: get("--account")
415
+ account: get("--account"),
416
+ quantize: numberOption("--quantize", { min: -1, max: 256, integer: true }),
417
+ description: get("--description"),
418
+ weight: get("--weight"),
419
+ glyphPx: numberOption("--glyph-px", { min: 8, max: 64, integer: true })
410
420
  };
411
421
  }
412
422
 
@@ -461,6 +471,10 @@ Commands
461
471
  verified against pinned hashes. Offline once installed.
462
472
  workspace Register sibling projects and derive account-wide claims/status.
463
473
  add/remove/list/status/claims. Offline.
474
+ unzoom PixelLab: shrink an upscaled image (--from) back to its native
475
+ pixel grid before using it as a style or reference image.
476
+ font PixelLab: generate a pixel font (--description, --out); writes a
477
+ .ttf and a glyph atlas PNG. 25 generations; asks first.
464
478
 
465
479
  Options
466
480
  --columns <n> pack/export: sprites or tiles per row (default: near-square)
@@ -482,6 +496,10 @@ Options
482
496
  --note <text> refine approve: optional review note
483
497
  --model-root <dir> recipe verify: also hash required local model files
484
498
  --generation <n|hash> restore: the previous generation to bring back (1 = newest)
499
+ --quantize <n> unzoom: 0 auto palette (default), -1 keep all colors, 2-256 exact
500
+ --description <text> font: the style to draw, e.g. "warm orange arcade font"
501
+ --weight <w> font: Bold or Regular (default)
502
+ --glyph-px <n> font: native glyph size, 8, 16 (default), 32, or 64
485
503
  --prune cache: remove invalid/unreferenced local cache data
486
504
  --manifest <path> Default: pixelkiln.manifest.json
487
505
  --lock <path> Default: pixelkiln.lock.json beside the manifest
@@ -537,6 +555,8 @@ Examples
537
555
  pixelkiln recipe verify pixelkiln-recipes/comfyui/pixel-art-xl-environment/1.0.0 --model-root /path/to/ComfyUI/models
538
556
  pixelkiln quality snapshot --inputs quality-inputs.json --out pixelkiln.quality.json
539
557
  pixelkiln quality check --from pixelkiln.quality.json
558
+ pixelkiln unzoom --from refs/knight-512.png --out refs/knight.png
559
+ pixelkiln font --description "warm orange arcade font" --weight Bold --out fonts/arcade
540
560
  pixelkiln workspace add ../other-game/pixelkiln.manifest.json
541
561
  pixelkiln workspace status --json
542
562
  pixelkiln salvage --workspace pixelkiln.workspace.json
@@ -618,10 +638,10 @@ import { createReadStream } from "fs";
618
638
  function sha256(data) {
619
639
  return createHash("sha256").update(data).digest("hex");
620
640
  }
621
- async function sha256File(path45) {
641
+ async function sha256File(path47) {
622
642
  return new Promise((resolve, reject) => {
623
643
  const hash = createHash("sha256");
624
- const stream = createReadStream(path45);
644
+ const stream = createReadStream(path47);
625
645
  stream.on("data", (chunk2) => hash.update(chunk2));
626
646
  stream.on("error", reject);
627
647
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -649,7 +669,7 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
649
669
  // tile fields are undefined for every other generator. `tileSize` is
650
670
  // intentionally absent; width/height are derived from it, so it is
651
671
  // already covered.
652
- noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
672
+ noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.generator === "uiElement" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
653
673
  tileType: spec.tileType,
654
674
  tileView: spec.tileView,
655
675
  tileFeature: spec.tileFeature,
@@ -744,7 +764,7 @@ import path2 from "path";
744
764
  // src/types.ts
745
765
  import { z } from "zod";
746
766
  var MediaTypeSchema = z.enum(["image/png", "image/gif"]);
747
- var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset"]);
767
+ var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset", "uiElement"]);
748
768
  var GridConfidenceSchema = z.enum(["low", "medium", "high"]);
749
769
  var RevisionModeSchema = z.enum([
750
770
  "image-to-image",
@@ -753,7 +773,9 @@ var RevisionModeSchema = z.enum([
753
773
  "reduce-colors",
754
774
  "correct-pixelart",
755
775
  "animate",
756
- "animate-pixminimax"
776
+ "animate-pixminimax",
777
+ "interpolate",
778
+ "edit-animation"
757
779
  ]);
758
780
  function tileVariationCount(descriptions) {
759
781
  return Math.max(1, descriptions) * 4;
@@ -1022,9 +1044,13 @@ var RevisionSchema = z.object({
1022
1044
  * the provider layer, since it differs by mode).
1023
1045
  */
1024
1046
  frames: z.number().int().min(4).max(40).optional(),
1025
- /** `animate`/`animate-pixminimax` only: playback rate recorded with the frames; PixelLab does not store one. */
1047
+ /** `animate`/`animate-pixminimax`/`interpolate`/`edit-animation` only: playback rate recorded with the frames; PixelLab does not store one. */
1026
1048
  fps: z.number().int().min(1).max(60).optional(),
1027
- /** `animate`/`animate-pixminimax` only: manifest-relative image pinning where the motion ends (interpolation instead of open-ended animation). */
1049
+ /**
1050
+ * `animate`/`animate-pixminimax`: manifest-relative image pinning where
1051
+ * the motion ends (interpolation instead of open-ended animation).
1052
+ * `interpolate`: required, the ending keyframe; the parent is the start.
1053
+ */
1028
1054
  lastFrame: z.string().min(1).optional(),
1029
1055
  /** `animate-pixminimax` only: facing direction, used only alongside `enhancePrompt` to hold the sprite's facing. */
1030
1056
  direction: CharacterDirectionSchema.optional(),
@@ -1068,14 +1094,15 @@ var RevisionSchema = z.object({
1068
1094
  path: ["strength"]
1069
1095
  });
1070
1096
  }
1071
- if (revision.strength !== void 0 && (revision.mode === "animate" || revision.mode === "animate-pixminimax")) {
1097
+ const generative = revision.mode === "animate" || revision.mode === "animate-pixminimax" || revision.mode === "interpolate" || revision.mode === "edit-animation";
1098
+ if (revision.strength !== void 0 && generative) {
1072
1099
  context.addIssue({
1073
1100
  code: z.ZodIssueCode.custom,
1074
- message: `${revision.mode} revisions do not take a strength; use enhancePrompt`,
1101
+ message: revision.mode === "animate" || revision.mode === "animate-pixminimax" ? `${revision.mode} revisions do not take a strength; use enhancePrompt` : `${revision.mode} revisions do not take a strength`,
1075
1102
  path: ["strength"]
1076
1103
  });
1077
1104
  }
1078
- for (const field of ["frames", "fps", "lastFrame", "enhancePrompt"]) {
1105
+ for (const field of ["frames", "enhancePrompt"]) {
1079
1106
  if (revision[field] !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax") {
1080
1107
  context.addIssue({
1081
1108
  code: z.ZodIssueCode.custom,
@@ -1084,6 +1111,27 @@ var RevisionSchema = z.object({
1084
1111
  });
1085
1112
  }
1086
1113
  }
1114
+ if (revision.lastFrame !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax" && revision.mode !== "interpolate") {
1115
+ context.addIssue({
1116
+ code: z.ZodIssueCode.custom,
1117
+ message: "lastFrame applies to animate/animate-pixminimax/interpolate revisions only",
1118
+ path: ["lastFrame"]
1119
+ });
1120
+ }
1121
+ if (revision.mode === "interpolate" && revision.lastFrame === void 0) {
1122
+ context.addIssue({
1123
+ code: z.ZodIssueCode.custom,
1124
+ message: "interpolate revisions require lastFrame: the ending keyframe to interpolate toward",
1125
+ path: ["lastFrame"]
1126
+ });
1127
+ }
1128
+ if (revision.fps !== void 0 && !generative) {
1129
+ context.addIssue({
1130
+ code: z.ZodIssueCode.custom,
1131
+ message: "fps applies to animate/animate-pixminimax/interpolate/edit-animation revisions only",
1132
+ path: ["fps"]
1133
+ });
1134
+ }
1087
1135
  if (revision.direction !== void 0 && revision.mode !== "animate-pixminimax") {
1088
1136
  context.addIssue({
1089
1137
  code: z.ZodIssueCode.custom,
@@ -1095,6 +1143,8 @@ var RevisionSchema = z.object({
1095
1143
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
1096
1144
  }
1097
1145
  });
1146
+ var FRAME_SET_REVISION_MODES = ["animate", "animate-pixminimax", "interpolate", "edit-animation"];
1147
+ var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation"];
1098
1148
  var StyleObjectSchema = z.object({
1099
1149
  /** Generation backend for this style. Omit to inherit the manifest default. */
1100
1150
  provider: z.string().min(1).optional(),
@@ -1213,7 +1263,7 @@ var StyleObjectSchema = z.object({
1213
1263
  */
1214
1264
  terrainMode: z.enum(["standard", "pro"]).optional(),
1215
1265
  /**
1216
- * `uiAsset` generator only. A natural-language palette hint sent as-is
1266
+ * `uiAsset` and `uiElement` generators only. A natural-language palette hint sent as-is
1217
1267
  * (e.g. "brown and gold"), distinct from `palette`'s hex-color array —
1218
1268
  * this is a prompt-level steer, not a local quantization target.
1219
1269
  */
@@ -4087,11 +4137,16 @@ async function inspectRevision(spec, lock, seen) {
4087
4137
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
4088
4138
  };
4089
4139
  }
4090
- if (!revision.sourceSha256 || !existsSync4(revision.sourceFile)) {
4091
- return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
4092
- }
4093
- if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
4094
- return { ready: false, reason: "revision source changed after the manifest was resolved" };
4140
+ if (revision.sourceMembers) {
4141
+ const members = await inspectSourceMembers(spec, lock);
4142
+ if (!members.ready) return members;
4143
+ } else {
4144
+ if (!revision.sourceSha256 || !existsSync4(revision.sourceFile)) {
4145
+ return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
4146
+ }
4147
+ if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
4148
+ return { ready: false, reason: "revision source changed after the manifest was resolved" };
4149
+ }
4095
4150
  }
4096
4151
  if (revision.maskFile) {
4097
4152
  if (!revision.maskSha256 || !existsSync4(revision.maskFile)) {
@@ -4101,8 +4156,39 @@ async function inspectRevision(spec, lock, seen) {
4101
4156
  return { ready: false, reason: "revision mask changed after the manifest was resolved" };
4102
4157
  }
4103
4158
  }
4159
+ if (revision.lastFrameFile) {
4160
+ if (!revision.lastFrameSha256 || !existsSync4(revision.lastFrameFile)) {
4161
+ return { ready: false, reason: `revision last frame is missing: ${revision.lastFrameFile}` };
4162
+ }
4163
+ if (await sha256File(revision.lastFrameFile) !== revision.lastFrameSha256) {
4164
+ return { ready: false, reason: "revision last frame changed after the manifest was resolved" };
4165
+ }
4166
+ }
4104
4167
  return { ready: true, reason: "revision inputs are current" };
4105
4168
  }
4169
+ async function inspectSourceMembers(spec, lock) {
4170
+ const revision = spec.revision;
4171
+ const members = revision.sourceMembers;
4172
+ for (const member of members) {
4173
+ if (!existsSync4(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
4174
+ if (await sha256File(member.file) !== member.sha256) {
4175
+ return { ready: false, reason: `revision source member changed after the manifest was resolved: ${member.file}` };
4176
+ }
4177
+ }
4178
+ const parent = revision.sourceSpec;
4179
+ if (parent.source) return { ready: true, reason: "declared source set is present" };
4180
+ const entry = lock.entries[lockKey(parent.styleId, parent.assetId)];
4181
+ if (!entry) return { ready: false, reason: "revision source is not in the lockfile" };
4182
+ const recorded = entry.outputs.map((_, index) => path8.resolve(currentEntryOutputPath(entry, parent, index))).sort();
4183
+ const found = members.map((member) => path8.resolve(member.file)).sort();
4184
+ if (recorded.length !== found.length || recorded.some((file, index) => file !== found[index])) {
4185
+ return {
4186
+ ready: false,
4187
+ reason: `revision source ${parent.assetId} records ${recorded.length} output(s) but ${found.length} member file(s) were found beside it; remove files left over from an earlier generation, or re-fetch the parent`
4188
+ };
4189
+ }
4190
+ return { ready: true, reason: "revision source set is current" };
4191
+ }
4106
4192
  async function requireRevisionReady(spec, lock) {
4107
4193
  const inspection = await inspectRevisionReadiness(spec, lock);
4108
4194
  if (inspection && !inspection.ready) throw new Error(inspection.reason);
@@ -4808,14 +4894,15 @@ var ComfyUIProvider = class _ComfyUIProvider {
4808
4894
  }
4809
4895
  /**
4810
4896
  * Everything else resolves generically to whatever the user's own
4811
- * workflow does with `bindings.sourceImage`. `animate`/`animate-pixminimax`
4812
- * are the one exception: they produce an ordered frame set, and this
4813
- * adapter's revision path always writes a single output image (see
4814
- * `submit`/`fetch` below) — a real structural gap, not a missing binding,
4815
- * so it is rejected here rather than only failing once `validate` runs.
4897
+ * workflow does with `bindings.sourceImage`. The frame-set modes
4898
+ * (`animate`, `animate-pixminimax`, `interpolate`, `edit-animation`) are
4899
+ * the exception: they produce an ordered frame set, and this adapter's
4900
+ * revision path always writes a single output image (see `submit`/`fetch`
4901
+ * below) — a real structural gap, not a missing binding, so they are
4902
+ * rejected here rather than only failing once `validate` runs.
4816
4903
  */
4817
4904
  supportsRevision(mode2) {
4818
- return mode2 !== "animate" && mode2 !== "animate-pixminimax";
4905
+ return !FRAME_SET_REVISION_MODES.includes(mode2);
4819
4906
  }
4820
4907
  estimate(spec) {
4821
4908
  return {
@@ -4868,6 +4955,11 @@ var ComfyUIProvider = class _ComfyUIProvider {
4868
4955
  throw new Error("ComfyUI providerOptions.frames requires generator: frames");
4869
4956
  }
4870
4957
  if (spec.revision) {
4958
+ if (spec.revision.sourceMembers) {
4959
+ throw new Error(
4960
+ `ComfyUI revisions read one source image; ${spec.revision.sourceAssetId} is a ${spec.revision.sourceMembers.length}-member set (directions or frames)`
4961
+ );
4962
+ }
4871
4963
  if (!options.bindings.sourceImage) {
4872
4964
  throw new Error("ComfyUI revisions require bindings.sourceImage");
4873
4965
  }
@@ -5603,6 +5695,21 @@ var CorrectPixelartResponseSchema = z2.object({
5603
5695
  images: z2.array(z2.object({ base64: z2.string().min(1) }).passthrough()).min(1),
5604
5696
  usage: z2.unknown().optional()
5605
5697
  }).passthrough();
5698
+ var UnzoomResponseSchema = z2.object({
5699
+ image: z2.object({ base64: z2.string().min(1) }).passthrough(),
5700
+ original_size: z2.object({ width: z2.number().int(), height: z2.number().int() }).passthrough(),
5701
+ unzoomed_size: z2.object({ width: z2.number().int(), height: z2.number().int() }).passthrough(),
5702
+ zoom_factor_detected: z2.number(),
5703
+ usage: z2.unknown().optional()
5704
+ }).passthrough();
5705
+ var FontJobSchema = z2.object({
5706
+ job_id: z2.string().min(1),
5707
+ status: z2.string(),
5708
+ glyph_px: z2.number().int().nullable().optional(),
5709
+ download_atlas_url: z2.string().nullable().optional(),
5710
+ download_ttf_url: z2.string().nullable().optional(),
5711
+ usage: z2.unknown().optional()
5712
+ }).passthrough();
5606
5713
  var SelectFramesSchema = z2.object({ created_object_ids: z2.array(z2.string()) }).passthrough();
5607
5714
  var CharacterSubmitSchema = z2.object({
5608
5715
  background_job_id: z2.string().min(1),
@@ -5687,6 +5794,17 @@ function validateResponse(schema, raw, operation) {
5687
5794
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
5688
5795
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
5689
5796
  }
5797
+ function cleanupImages(args, operation) {
5798
+ const images = args.images ?? (args.image ? [args.image] : []);
5799
+ if (!images.length) throw new Error(`${operation} needs at least one image`);
5800
+ return images;
5801
+ }
5802
+ function cleanupResults(images, sent, operation) {
5803
+ if (images.length !== sent) {
5804
+ throw new Error(`Invalid PixelLab response for ${operation}: sent ${sent} image(s), got ${images.length} back`);
5805
+ }
5806
+ return images.map((image) => Buffer.from(image.base64, "base64"));
5807
+ }
5690
5808
  var PixelLabError = class extends ProviderError {
5691
5809
  constructor(message6, status, body) {
5692
5810
  super("pixellab", message6, { status });
@@ -5715,9 +5833,9 @@ var PixelLabClient = class {
5715
5833
  apiKey;
5716
5834
  http;
5717
5835
  /** Retries transport failures, 429, and 5xx; see http.ts for the policy. */
5718
- async request(path45, init) {
5836
+ async request(path47, init) {
5719
5837
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
5720
- const res = await this.http(`${BASE}${path45}`, {
5838
+ const res = await this.http(`${BASE}${path47}`, {
5721
5839
  ...init,
5722
5840
  headers: {
5723
5841
  Authorization: auth,
@@ -5727,13 +5845,13 @@ var PixelLabClient = class {
5727
5845
  });
5728
5846
  const text = await res.text();
5729
5847
  if (!res.ok) {
5730
- throw new PixelLabError(`${init?.method ?? "GET"} ${path45} \u2192 ${res.status}`, res.status, text);
5848
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path47} \u2192 ${res.status}`, res.status, text);
5731
5849
  }
5732
5850
  if (!text) return {};
5733
5851
  try {
5734
5852
  return JSON.parse(text);
5735
5853
  } catch {
5736
- throw new Error(`${init?.method ?? "GET"} ${path45} returned invalid JSON`);
5854
+ throw new Error(`${init?.method ?? "GET"} ${path47} returned invalid JSON`);
5737
5855
  }
5738
5856
  }
5739
5857
  async balance() {
@@ -6119,10 +6237,10 @@ var PixelLabClient = class {
6119
6237
  const palette = args.paletteSwatchBase64 ? { color_image: { type: "base64", base64: args.paletteSwatchBase64, format: "png" }, force_colors: true } : {};
6120
6238
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
6121
6239
  const south = args.reference?.south;
6122
- let path45;
6240
+ let path47;
6123
6241
  let body;
6124
6242
  if (args.mode === "v3") {
6125
- path45 = "/create-character-v3";
6243
+ path47 = "/create-character-v3";
6126
6244
  body = {
6127
6245
  description: args.description,
6128
6246
  image_size: imageSize,
@@ -6136,7 +6254,7 @@ var PixelLabClient = class {
6136
6254
  ...args.enhancePrompt ? { enhance_prompt: true } : {}
6137
6255
  };
6138
6256
  } else if (args.mode === "pro-flash") {
6139
- path45 = "/create-character-pro-flash";
6257
+ path47 = "/create-character-pro-flash";
6140
6258
  const traits = args.styleTraits;
6141
6259
  body = {
6142
6260
  description: args.description,
@@ -6159,7 +6277,7 @@ var PixelLabClient = class {
6159
6277
  } : {}
6160
6278
  };
6161
6279
  } else if (args.mode === "pro") {
6162
- path45 = "/create-character-pro";
6280
+ path47 = "/create-character-pro";
6163
6281
  body = {
6164
6282
  description: args.description,
6165
6283
  image_size: imageSize,
@@ -6173,7 +6291,7 @@ var PixelLabClient = class {
6173
6291
  ...!south && args.styleCharacterId ? { style_character_id: args.styleCharacterId } : {}
6174
6292
  };
6175
6293
  } else {
6176
- path45 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
6294
+ path47 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
6177
6295
  body = {
6178
6296
  description: args.description,
6179
6297
  image_size: imageSize,
@@ -6190,8 +6308,8 @@ var PixelLabClient = class {
6190
6308
  ...palette
6191
6309
  };
6192
6310
  }
6193
- const raw = await this.request(path45, { method: "POST", body: JSON.stringify(body) });
6194
- return validateResponse(CharacterSubmitSchema, raw, path45.slice(1));
6311
+ const raw = await this.request(path47, { method: "POST", body: JSON.stringify(body) });
6312
+ return validateResponse(CharacterSubmitSchema, raw, path47.slice(1));
6195
6313
  }
6196
6314
  /** A text edit of an existing character, applied to every direction and stored as a sibling. */
6197
6315
  async createCharacterState(args) {
@@ -6353,20 +6471,52 @@ var PixelLabClient = class {
6353
6471
  );
6354
6472
  }
6355
6473
  /**
6356
- * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image onto a
6357
- * smaller palette, synchronously — no `background_job_id`, the result
6358
- * comes back in this same response, like `createImagePixflux`. The
6359
- * schema's own response example is dollar-denominated (`usage: {type:
6360
- * "usd", usd: 0.02}`), which — matching this codebase's repeated
6361
- * experience with PixelLab's documented-vs-billed cost mismatches
6362
- * (`isometricTile`, `objectPro`) — did not hold: confirmed live against a
6363
- * Tier 2 account at exactly 0.1 generations for a 32x32 source
6364
- * (docs/REVISIONS.md). `numColors` and `paletteImage` are mutually
6365
- * exclusive upstream; the manifest schema already enforces that before
6366
- * this is ever called.
6474
+ * `/generate-ui-v2` ("Generate UI (Pro)"): one UI element (a button, a
6475
+ * health bar, an inventory slot, a dialogue box) from a description,
6476
+ * guided by an optional concept image and a natural-language color
6477
+ * palette. A plain background job; unlike `/create-ui-asset` there is no
6478
+ * piece/element layout, and sizes start at 16px instead of 192. Its
6479
+ * completed shape has not been exercised live; `pollUiElement` reads it
6480
+ * defensively.
6481
+ */
6482
+ async generateUiV2(args) {
6483
+ const body = {
6484
+ description: args.description,
6485
+ image_size: { width: args.width, height: args.height }
6486
+ };
6487
+ if (args.conceptImage) {
6488
+ body.concept_image = {
6489
+ image: args.conceptImage.image,
6490
+ size: { width: args.conceptImage.width, height: args.conceptImage.height }
6491
+ };
6492
+ }
6493
+ if (args.colorPalette) body.color_palette = args.colorPalette;
6494
+ if (args.noBackground != null) body.no_background = args.noBackground;
6495
+ if (args.seed != null) body.seed = args.seed;
6496
+ return validateResponse(
6497
+ RevisionJobSubmitSchema,
6498
+ await this.request("/generate-ui-v2", { method: "POST", body: JSON.stringify(body) }),
6499
+ "generate-ui-v2"
6500
+ );
6501
+ }
6502
+ /**
6503
+ * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image, or
6504
+ * several same-size frames together onto ONE shared palette, synchronously
6505
+ * — no `background_job_id`, the result comes back in this same response,
6506
+ * like `createImagePixflux`. Passing every frame of an animation (or every
6507
+ * direction of a character) in one call is the endpoint's whole point:
6508
+ * frames quantized separately drift onto different palettes. The schema's
6509
+ * own response example is dollar-denominated (`usage: {type: "usd", usd:
6510
+ * 0.02}`), which — matching this codebase's repeated experience with
6511
+ * PixelLab's documented-vs-billed cost mismatches (`isometricTile`,
6512
+ * `objectPro`) — did not hold: confirmed live against a Tier 2 account at
6513
+ * exactly 0.1 generations for a 32x32 source (docs/REVISIONS.md).
6514
+ * `numColors` and `paletteImage` are mutually exclusive upstream; the
6515
+ * manifest schema already enforces that before this is ever called.
6367
6516
  */
6368
6517
  async reduceColors(args) {
6369
- const body = { images: [args.image] };
6518
+ const images = cleanupImages(args, "reduce-colors");
6519
+ const body = { images };
6370
6520
  if (args.numColors != null) body.num_colors = args.numColors;
6371
6521
  if (args.paletteImage) body.palette_image = args.paletteImage;
6372
6522
  if (args.dithering) body.dithering = args.dithering;
@@ -6376,8 +6526,10 @@ var PixelLabClient = class {
6376
6526
  await this.request("/reduce-colors", { method: "POST", body: JSON.stringify(body) }),
6377
6527
  "reduce-colors"
6378
6528
  );
6529
+ const pngs = cleanupResults(res.images, images.length, "reduce-colors");
6379
6530
  return {
6380
- png: Buffer.from(res.images[0].base64, "base64"),
6531
+ png: pngs[0],
6532
+ pngs,
6381
6533
  paletteStripPng: Buffer.from(res.palette.base64, "base64"),
6382
6534
  nColors: res.n_colors,
6383
6535
  usage: res.usage
@@ -6386,19 +6538,117 @@ var PixelLabClient = class {
6386
6538
  /**
6387
6539
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
6388
6540
  * stray pixels without resizing, synchronously — same shape as
6389
- * `reduceColors` above, no background job. Cost is likewise confirmed
6390
- * live at 0.1 generations for a 32x32 source, not the schema's own
6541
+ * `reduceColors` above, no background job, and likewise corrects several
6542
+ * same-size frames together when given `images`. Cost is confirmed live
6543
+ * at 0.1 generations for a 32x32 source, not the schema's own
6391
6544
  * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
6392
6545
  */
6393
6546
  async correctPixelart(args) {
6394
- const body = { images: [args.image] };
6547
+ const images = cleanupImages(args, "correct-pixelart");
6548
+ const body = { images };
6395
6549
  if (args.strength != null) body.strength = args.strength;
6396
6550
  const res = validateResponse(
6397
6551
  CorrectPixelartResponseSchema,
6398
6552
  await this.request("/correct-pixelart", { method: "POST", body: JSON.stringify(body) }),
6399
6553
  "correct-pixelart"
6400
6554
  );
6401
- return { png: Buffer.from(res.images[0].base64, "base64"), usage: res.usage };
6555
+ const pngs = cleanupResults(res.images, images.length, "correct-pixelart");
6556
+ return { png: pngs[0], pngs, usage: res.usage };
6557
+ }
6558
+ /**
6559
+ * `/unzoom`: recover native-resolution pixel art from an upscaled image (a
6560
+ * 32x32 sprite saved at 512x512). Synchronous like the Cleanup tier.
6561
+ * PixelLab's own API overview names this "the most common cause of
6562
+ * disappointing output": run it on outside artwork before it becomes a
6563
+ * style or reference image. The input must be at least 256x256 and at
6564
+ * most 2048x2048 in area, and the result is opaque — transparency is
6565
+ * composited onto white before the grid is detected. `quantize` is 0 to
6566
+ * auto-detect a palette, -1 to keep every color, or 2-256 for exactly
6567
+ * that many.
6568
+ */
6569
+ async unzoom(args) {
6570
+ const body = { image: args.image };
6571
+ if (args.quantize != null) body.quantize = args.quantize;
6572
+ const res = validateResponse(
6573
+ UnzoomResponseSchema,
6574
+ await this.request("/unzoom", { method: "POST", body: JSON.stringify(body) }),
6575
+ "unzoom"
6576
+ );
6577
+ return {
6578
+ png: Buffer.from(res.image.base64, "base64"),
6579
+ originalSize: { width: res.original_size.width, height: res.original_size.height },
6580
+ unzoomedSize: { width: res.unzoomed_size.width, height: res.unzoomed_size.height },
6581
+ zoomFactor: res.zoom_factor_detected,
6582
+ usage: res.usage
6583
+ };
6584
+ }
6585
+ /**
6586
+ * `/interpolation-v2` ("Interpolate (Pro)"): generate the in-between
6587
+ * frames from one keyframe to another, guided by a short `action`. Unlike
6588
+ * `last_frame` on `/animate-with-text-v3`, both ends are required and the
6589
+ * frame count is the model's own choice (its docs say "typically 4-8").
6590
+ * Output frames are 16 to 128 pixels per side. A plain background job;
6591
+ * its completed shape is read defensively by `pollAnimateRevision`.
6592
+ */
6593
+ async interpolationV2(args) {
6594
+ const size = { width: args.width, height: args.height };
6595
+ const body = {
6596
+ start_image: { image: args.startImage, size },
6597
+ end_image: { image: args.endImage, size },
6598
+ action: args.action,
6599
+ image_size: size
6600
+ };
6601
+ if (args.seed != null) body.seed = args.seed;
6602
+ if (args.noBackground != null) body.no_background = args.noBackground;
6603
+ return validateResponse(
6604
+ RevisionJobSubmitSchema,
6605
+ await this.request("/interpolation-v2", { method: "POST", body: JSON.stringify(body) }),
6606
+ "interpolation-v2"
6607
+ );
6608
+ }
6609
+ /**
6610
+ * `/edit-animation-v2` ("Edit animation (Pro)"): apply one text edit
6611
+ * across every frame of an existing animation at once, so the change
6612
+ * stays consistent frame to frame. 2 to 16 frames, but the real ceiling
6613
+ * shrinks with frame size because every frame is packed into one grid:
6614
+ * 16 at up to 64px, 9 at 65-80px, 4 at 81-256px.
6615
+ */
6616
+ async editAnimationV2(args) {
6617
+ const size = { width: args.width, height: args.height };
6618
+ const body = {
6619
+ description: args.description,
6620
+ frames: args.frames.map((image) => ({ image, size })),
6621
+ image_size: size
6622
+ };
6623
+ if (args.seed != null) body.seed = args.seed;
6624
+ if (args.noBackground != null) body.no_background = args.noBackground;
6625
+ return validateResponse(
6626
+ RevisionJobSubmitSchema,
6627
+ await this.request("/edit-animation-v2", { method: "POST", body: JSON.stringify(body) }),
6628
+ "edit-animation-v2"
6629
+ );
6630
+ }
6631
+ /**
6632
+ * `/generate-font-pro`: an 80-glyph pixel font (A-Z, a-z, 0-9, common
6633
+ * game-UI punctuation) from a style description, delivered as a glyph
6634
+ * atlas PNG plus a ready-to-use `.ttf`. Documented at 25 subscription
6635
+ * generations. Poll with `getFontJob`.
6636
+ */
6637
+ async generateFontPro(args) {
6638
+ const body = { description: args.description, weight: args.weight };
6639
+ if (args.glyphPx != null) body.glyph_px = args.glyphPx;
6640
+ if (args.seed != null) body.seed = args.seed;
6641
+ if (args.fontName) body.font_name = args.fontName;
6642
+ return validateResponse(
6643
+ RevisionJobSubmitSchema,
6644
+ await this.request("/generate-font-pro", { method: "POST", body: JSON.stringify(body) }),
6645
+ "generate-font-pro"
6646
+ );
6647
+ }
6648
+ /** `GET /generate-font-pro/{job_id}`: status, then no-auth atlas and `.ttf` URLs once completed. */
6649
+ async getFontJob(jobId) {
6650
+ const raw = await this.request(`/generate-font-pro/${encodeURIComponent(jobId)}`);
6651
+ return validateResponse(FontJobSchema, raw, "generate-font-pro/{job_id}");
6402
6652
  }
6403
6653
  /**
6404
6654
  * `/animate-with-text-v3`: animate a loose image from a text description,
@@ -6619,7 +6869,7 @@ var PixelLabProvider = class _PixelLabProvider {
6619
6869
  return new _PixelLabProvider(new PixelLabClient("download-only"));
6620
6870
  }
6621
6871
  supports(generator) {
6622
- return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset";
6872
+ return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset" || generator === "uiElement";
6623
6873
  }
6624
6874
  /**
6625
6875
  * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
@@ -6629,12 +6879,15 @@ var PixelLabProvider = class _PixelLabProvider {
6629
6879
  * rather than a described edit — and `animate`/`animate-pixminimax`
6630
6880
  * (`/animate-with-text-v3`, `/animate-pixminimax`), which animate any loose
6631
6881
  * image from a text description with no character/object resource
6632
- * required. `outpaint` does not: there is no canvas-expansion endpoint in
6633
- * the API, matching docs/REVISIONS.md's note that no provider ships a
6634
- * tested outpaint path yet.
6882
+ * required — and `interpolate` (`/interpolation-v2`, the in-betweens
6883
+ * from one keyframe to another) and `edit-animation`
6884
+ * (`/edit-animation-v2`, one text edit applied across a whole frame set).
6885
+ * `outpaint` does not: there is no canvas-expansion endpoint in the API,
6886
+ * matching docs/REVISIONS.md's note that no provider ships a tested
6887
+ * outpaint path yet.
6635
6888
  */
6636
6889
  supportsRevision(mode2) {
6637
- return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax";
6890
+ return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax" || mode2 === "interpolate" || mode2 === "edit-animation";
6638
6891
  }
6639
6892
  /** PixelLab's own constraints: submissions must be >2s apart, and
6640
6893
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -6662,6 +6915,12 @@ var PixelLabProvider = class _PixelLabProvider {
6662
6915
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
6663
6916
  return { unit: "generations", amount: 0.1, candidates: 1 };
6664
6917
  }
6918
+ if (spec.revision?.mode === "interpolate" || spec.revision?.mode === "edit-animation") {
6919
+ const width = spec.revision.sourceWidth ?? spec.width;
6920
+ const height = spec.revision.sourceHeight ?? spec.height;
6921
+ const side = spec.revision.mode === "edit-animation" ? Math.ceil(Math.sqrt(Math.max(1, spec.revision.sourceMembers?.length ?? 1))) : 1;
6922
+ return { unit: "generations", amount: generationCost(width * side, height * side, "1dir"), candidates: 1 };
6923
+ }
6665
6924
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
6666
6925
  const width = spec.revision.sourceWidth ?? spec.width;
6667
6926
  const height = spec.revision.sourceHeight ?? spec.height;
@@ -6690,6 +6949,12 @@ var PixelLabProvider = class _PixelLabProvider {
6690
6949
  };
6691
6950
  }
6692
6951
  validate(spec, styleImages) {
6952
+ const members = spec.revision?.sourceMembers;
6953
+ if (spec.revision && members && !MEMBER_SET_REVISION_MODES.includes(spec.revision.mode)) {
6954
+ throw new Error(
6955
+ `PixelLab ${spec.revision.mode} reads one source image; ${spec.revision.sourceAssetId} is a ${members.length}-member set (${members[0].role}, ...). Revise one member through a declared source, or use reduce-colors, correct-pixelart, or edit-animation, which take the whole set`
6956
+ );
6957
+ }
6693
6958
  if (spec.revision?.mode === "inpaint") {
6694
6959
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
6695
6960
  if (width != null && height != null && (width < 32 || height < 32 || width > 512 || height > 512)) {
@@ -6698,9 +6963,10 @@ var PixelLabProvider = class _PixelLabProvider {
6698
6963
  }
6699
6964
  if (spec.revision?.mode === "reduce-colors") {
6700
6965
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
6701
- if (width != null && height != null && width * height > 512 * 512) {
6966
+ const count = members?.length ?? 1;
6967
+ if (width != null && height != null && width * height * count > 512 * 512) {
6702
6968
  throw new Error(
6703
- `PixelLab reduce-colors source is ${width}x${height} (${width * height}px\xB2); the API takes at most 512x512 worth of pixels per call`
6969
+ `PixelLab reduce-colors source is ${count > 1 ? `${count} frames of ` : ""}${width}x${height} (${width * height * count}px\xB2 in all); the API takes at most 512x512 worth of pixels per call`
6704
6970
  );
6705
6971
  }
6706
6972
  }
@@ -6724,6 +6990,36 @@ var PixelLabProvider = class _PixelLabProvider {
6724
6990
  );
6725
6991
  }
6726
6992
  }
6993
+ if (spec.revision?.mode === "interpolate") {
6994
+ const { sourceWidth: width, sourceHeight: height, lastFrameWidth, lastFrameHeight } = spec.revision;
6995
+ if (width != null && height != null && (width < 16 || height < 16 || width > 128 || height > 128)) {
6996
+ throw new Error(`PixelLab interpolate keyframes are ${width}x${height}; the API takes 16 to 128 pixels per side`);
6997
+ }
6998
+ if (lastFrameWidth != null && lastFrameHeight != null && width != null && height != null && (lastFrameWidth !== width || lastFrameHeight !== height)) {
6999
+ throw new Error(
7000
+ `PixelLab interpolate lastFrame is ${lastFrameWidth}x${lastFrameHeight}; the start keyframe is ${width}x${height} \u2014 they must match`
7001
+ );
7002
+ }
7003
+ }
7004
+ if (spec.revision?.mode === "edit-animation") {
7005
+ const { sourceWidth: width, sourceHeight: height, sourceAssetId } = spec.revision;
7006
+ if (width != null && height != null) {
7007
+ if (!members || members.length < 2) {
7008
+ throw new Error(
7009
+ `PixelLab edit-animation needs a frame set to edit; ${sourceAssetId} is a single image (revise an animation, a character's directions, or another frame-set revision)`
7010
+ );
7011
+ }
7012
+ if (width < 16 || height < 16 || width > 256 || height > 256) {
7013
+ throw new Error(`PixelLab edit-animation frames are ${width}x${height}; the API takes 16 to 256 pixels per side`);
7014
+ }
7015
+ const ceiling = editAnimationFrameCeiling(width, height);
7016
+ if (members.length > ceiling) {
7017
+ throw new Error(
7018
+ `PixelLab edit-animation takes at most ${ceiling} frames at ${width}x${height} (every frame is packed into one grid); ${sourceAssetId} has ${members.length}`
7019
+ );
7020
+ }
7021
+ }
7022
+ }
6727
7023
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
6728
7024
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
6729
7025
  }
@@ -6740,6 +7036,14 @@ var PixelLabProvider = class _PixelLabProvider {
6740
7036
  `PixelLab uiAsset is ${spec.width}x${spec.height}; the API takes 192 to 688 pixels per side (the exact ceiling also depends on aspect ratio \u2014 square tops out at 512, 16:9 at 688x384, 9:16 at 384x688, 4:3 at 600x448, 3:4 at 448x600)`
6741
7037
  );
6742
7038
  }
7039
+ if (spec.generator === "uiElement" && (spec.width < 16 || spec.height < 16 || spec.width > 792 || spec.height > 688)) {
7040
+ throw new Error(
7041
+ `PixelLab uiElement is ${spec.width}x${spec.height}; the API takes 16 to 792 wide and 16 to 688 tall (the exact ceiling depends on aspect ratio \u2014 square tops out at 512, 16:9 at 688x384)`
7042
+ );
7043
+ }
7044
+ if (spec.generator === "uiElement" && styleImages.length > 1) {
7045
+ throw new Error("PixelLab uiElement takes one concept image; list at most one styleImages entry");
7046
+ }
6743
7047
  if (spec.generator === "map") {
6744
7048
  requirePixelLabOption("map", "view", spec.view, ["low top-down", "high top-down", "side"]);
6745
7049
  requirePixelLabOption("map", "outline", spec.outline, [
@@ -6823,8 +7127,8 @@ var PixelLabProvider = class _PixelLabProvider {
6823
7127
  throw new Error(`PixelLab isometricTile: size must be 16 to 64 pixels, got ${spec.width}`);
6824
7128
  }
6825
7129
  }
6826
- if (spec.generator === "character") this.validateCharacter(spec, styleImages);
6827
- if (spec.generator === "objectPro") this.validateObjectPro(spec, styleImages);
7130
+ if (spec.generator === "character" && !spec.revision) this.validateCharacter(spec, styleImages);
7131
+ if (spec.generator === "objectPro" && !spec.revision) this.validateObjectPro(spec, styleImages);
6828
7132
  if ((spec.generator === "map" || spec.generator === "pixflux") && styleImages.length) {
6829
7133
  throw new Error(`PixelLab ${spec.generator} does not support style images`);
6830
7134
  }
@@ -7391,6 +7695,19 @@ var PixelLabProvider = class _PixelLabProvider {
7391
7695
  });
7392
7696
  return { jobId: res2.ui_asset_id, metadata: { backgroundJobId: res2.background_job_id } };
7393
7697
  }
7698
+ if (spec.generator === "uiElement") {
7699
+ const concept = styleImages[0];
7700
+ const res2 = await this.client.generateUiV2({
7701
+ description: spec.prompt,
7702
+ width: spec.width,
7703
+ height: spec.height,
7704
+ conceptImage: concept ? { image: { base64: concept.base64, format: concept.format }, width: concept.width, height: concept.height } : void 0,
7705
+ colorPalette: spec.uiColorPalette,
7706
+ noBackground: spec.noBackground,
7707
+ seed: spec.seed
7708
+ });
7709
+ return { jobId: res2.background_job_id };
7710
+ }
7394
7711
  if (spec.generator === "pixflux") {
7395
7712
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
7396
7713
  const { png } = await this.client.createImagePixflux({
@@ -7519,13 +7836,18 @@ var PixelLabProvider = class _PixelLabProvider {
7519
7836
  if (!revision.sourceSha256 || !revision.sourceFormat || revision.sourceWidth == null || revision.sourceHeight == null) {
7520
7837
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
7521
7838
  }
7522
- const sourceBytes = readFileSync2(revision.sourceFile);
7523
- if (sha256(sourceBytes) !== revision.sourceSha256) {
7524
- throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
7525
- }
7526
- const image = { base64: sourceBytes.toString("base64"), format: revision.sourceFormat };
7839
+ const sourceFiles = revision.sourceMembers ?? [{ file: revision.sourceFile, sha256: revision.sourceSha256, format: revision.sourceFormat }];
7840
+ const images = sourceFiles.map((member) => {
7841
+ const bytes = readFileSync2(member.file);
7842
+ if (sha256(bytes) !== member.sha256) {
7843
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
7844
+ }
7845
+ return { base64: bytes.toString("base64"), format: member.format };
7846
+ });
7847
+ const image = images[0];
7527
7848
  const width = revision.sourceWidth;
7528
7849
  const height = revision.sourceHeight;
7850
+ const roles = revision.sourceMembers?.map((member) => member.role);
7529
7851
  if (revision.mode === "inpaint") {
7530
7852
  if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
7531
7853
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
@@ -7558,34 +7880,45 @@ var PixelLabProvider = class _PixelLabProvider {
7558
7880
  paletteImage = { base64: paletteBytes.toString("base64"), format: revision.paletteImageFormat };
7559
7881
  }
7560
7882
  const res2 = await this.client.reduceColors({
7561
- image,
7883
+ images,
7562
7884
  numColors: revision.numColors,
7563
7885
  paletteImage,
7564
7886
  dithering: revision.dithering,
7565
7887
  ditheringStrength: revision.ditheringStrength
7566
7888
  });
7567
- const jobId = randomUUID3();
7568
- writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
7569
- return { jobId, metadata: { revisionUsage: res2.usage } };
7889
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
7570
7890
  }
7571
7891
  if (revision.mode === "correct-pixelart") {
7572
- const res2 = await this.client.correctPixelart({ image, strength: revision.strength });
7573
- const jobId = randomUUID3();
7574
- writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
7575
- return { jobId, metadata: { revisionUsage: res2.usage } };
7892
+ const res2 = await this.client.correctPixelart({ images, strength: revision.strength });
7893
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
7894
+ }
7895
+ if (revision.mode === "interpolate") {
7896
+ const lastFrame = this.readLastFrame(spec);
7897
+ if (!lastFrame) throw new Error(`${spec.styleId}/${spec.assetId}: interpolate needs lastFrame, the ending keyframe`);
7898
+ const res2 = await this.client.interpolationV2({
7899
+ startImage: image,
7900
+ endImage: lastFrame,
7901
+ width,
7902
+ height,
7903
+ action: spec.prompt,
7904
+ seed: spec.seed,
7905
+ noBackground: spec.noBackground
7906
+ });
7907
+ return { jobId: res2.background_job_id };
7908
+ }
7909
+ if (revision.mode === "edit-animation") {
7910
+ const res2 = await this.client.editAnimationV2({
7911
+ frames: images,
7912
+ width,
7913
+ height,
7914
+ description: spec.prompt,
7915
+ seed: spec.seed,
7916
+ noBackground: spec.noBackground
7917
+ });
7918
+ return { jobId: res2.background_job_id };
7576
7919
  }
7577
7920
  if (revision.mode === "animate" || revision.mode === "animate-pixminimax") {
7578
- let lastFrame;
7579
- if (revision.lastFrameFile) {
7580
- if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
7581
- throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
7582
- }
7583
- const lastFrameBytes = readFileSync2(revision.lastFrameFile);
7584
- if (sha256(lastFrameBytes) !== revision.lastFrameSha256) {
7585
- throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
7586
- }
7587
- lastFrame = { base64: lastFrameBytes.toString("base64"), format: revision.lastFrameFormat };
7588
- }
7921
+ const lastFrame = this.readLastFrame(spec);
7589
7922
  if (revision.mode === "animate") {
7590
7923
  const res3 = await this.client.animateWithTextV3({
7591
7924
  firstFrame: image,
@@ -7625,6 +7958,36 @@ var PixelLabProvider = class _PixelLabProvider {
7625
7958
  });
7626
7959
  return { jobId: res.background_job_id };
7627
7960
  }
7961
+ /** A revision's pinned ending frame, proven to be the bytes the spec hashed; undefined when none is declared. */
7962
+ readLastFrame(spec) {
7963
+ const revision = spec.revision;
7964
+ if (!revision.lastFrameFile) return void 0;
7965
+ if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
7966
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
7967
+ }
7968
+ const bytes = readFileSync2(revision.lastFrameFile);
7969
+ if (sha256(bytes) !== revision.lastFrameSha256) {
7970
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
7971
+ }
7972
+ return { base64: bytes.toString("base64"), format: revision.lastFrameFormat };
7973
+ }
7974
+ /**
7975
+ * Park a synchronous Cleanup-tier result the way pixflux parks its own:
7976
+ * one `<id>.png` for a single image, or `<id>-<n>.png` per member with the
7977
+ * member roles recorded so `pollCachedRevision` hands them back under the
7978
+ * same names the parent's set used.
7979
+ */
7980
+ cacheCleanupResult(pngs, usage, roles, fps) {
7981
+ const jobId = randomUUID3();
7982
+ if (!roles) {
7983
+ writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
7984
+ return { jobId, metadata: { revisionUsage: usage } };
7985
+ }
7986
+ for (const [index, png] of pngs.entries()) {
7987
+ writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
7988
+ }
7989
+ return { jobId, metadata: { revisionUsage: usage, revisionMembers: roles, ...fps != null ? { revisionFps: fps } : {} } };
7990
+ }
7628
7991
  /**
7629
7992
  * What a background job actually billed, once its own record is still
7630
7993
  * around to ask; see `billedFromUsage`. A stale or already-cleaned-up job
@@ -7645,7 +8008,7 @@ var PixelLabProvider = class _PixelLabProvider {
7645
8008
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart") {
7646
8009
  return this.pollCachedRevision(jobId, context);
7647
8010
  }
7648
- if (revisionMode === "animate" || revisionMode === "animate-pixminimax") {
8011
+ if (revisionMode === "animate" || revisionMode === "animate-pixminimax" || revisionMode === "interpolate" || revisionMode === "edit-animation") {
7649
8012
  return this.pollAnimateRevision(jobId, context);
7650
8013
  }
7651
8014
  if (context?.spec?.revision) return this.pollRevision(jobId);
@@ -7668,6 +8031,7 @@ var PixelLabProvider = class _PixelLabProvider {
7668
8031
  if (generator === "character") return this.pollCharacter(jobId, context);
7669
8032
  if (generator === "objectPro") return this.pollObjectPro(jobId, context);
7670
8033
  if (generator === "uiAsset") return this.pollUiAsset(jobId, context);
8034
+ if (generator === "uiElement") return this.pollUiElement(jobId);
7671
8035
  const backgroundJobId = context?.metadata?.backgroundJobId;
7672
8036
  const obj = await this.client.getObject(jobId);
7673
8037
  if (obj.status === "review") {
@@ -7694,15 +8058,32 @@ var PixelLabProvider = class _PixelLabProvider {
7694
8058
  * work.
7695
8059
  */
7696
8060
  async pollCachedRevision(jobId, context) {
7697
- const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7698
- if (!existsSync7(file)) {
8061
+ const usage = context?.metadata?.revisionUsage;
8062
+ const roles = context?.metadata?.revisionMembers;
8063
+ const gone = {
8064
+ status: "failed",
8065
+ error: "revision result is no longer cached locally; re-run submit for this asset"
8066
+ };
8067
+ if (Array.isArray(roles) && roles.length) {
8068
+ const sources = roles.map((role, index) => ({
8069
+ url: `file://${path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
8070
+ role: String(role)
8071
+ }));
8072
+ if (sources.some((source) => !existsSync7(source.url.slice("file://".length)))) return gone;
8073
+ const fps = context?.metadata?.revisionFps;
8074
+ const frames = sources.every((source) => source.role.startsWith("frame-"));
7699
8075
  return {
7700
- status: "failed",
7701
- error: "revision result is no longer cached locally; re-run submit for this asset"
8076
+ status: "ready",
8077
+ objectId: jobId,
8078
+ sourceUrl: sources[0].url,
8079
+ sources,
8080
+ ...frames && typeof fps === "number" ? { metadata: { frameSet: { fps, count: sources.length } } } : {},
8081
+ billed: billedFromUsage(usage)
7702
8082
  };
7703
8083
  }
8084
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
8085
+ if (!existsSync7(file)) return gone;
7704
8086
  const sourceUrl = `file://${file}`;
7705
- const usage = context?.metadata?.revisionUsage;
7706
8087
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
7707
8088
  }
7708
8089
  /**
@@ -7726,16 +8107,21 @@ var PixelLabProvider = class _PixelLabProvider {
7726
8107
  if (job.status !== "completed") return { status: "processing" };
7727
8108
  const billed = billedFromUsage(job.usage);
7728
8109
  const done = job.last_response ?? {};
7729
- const fps = context?.spec?.revision?.fps ?? 8;
7730
- const review = (frameUrls) => ({
7731
- status: "review-set",
7732
- objectId: jobId,
7733
- frameUrls,
7734
- sources: frameUrls.map((url, index) => ({ url, role: `frame-${String(index).padStart(2, "0")}` })),
7735
- fps,
7736
- metadata: { frameSet: { fps, count: frameUrls.length } },
7737
- billed
7738
- });
8110
+ const revision = context?.spec?.revision;
8111
+ const fps = revision?.fps ?? revision?.sourceFps ?? 8;
8112
+ const memberRoles = revision?.mode === "edit-animation" ? revision.sourceMembers?.map((member) => member.role) : void 0;
8113
+ const review = (frameUrls) => {
8114
+ const roles = memberRoles?.length === frameUrls.length ? memberRoles : frameUrls.map((_, index) => `frame-${String(index).padStart(2, "0")}`);
8115
+ return {
8116
+ status: "review-set",
8117
+ objectId: jobId,
8118
+ frameUrls,
8119
+ sources: frameUrls.map((url, index) => ({ url, role: roles[index] })),
8120
+ fps,
8121
+ metadata: roles.every((role) => role.startsWith("frame-")) ? { frameSet: { fps, count: frameUrls.length } } : {},
8122
+ billed
8123
+ };
8124
+ };
7739
8125
  for (const key of ["frame_urls", "frames", "images"]) {
7740
8126
  const list = done[key];
7741
8127
  if (!Array.isArray(list) || !list.length) continue;
@@ -7767,7 +8153,7 @@ var PixelLabProvider = class _PixelLabProvider {
7767
8153
  }
7768
8154
  return {
7769
8155
  status: "failed",
7770
- error: `Invalid PixelLab response for animate job ${jobId}: completed with no recognized frame list (got: ${Object.keys(done).join(", ") || "no keys"}); update pollAnimateRevision in src/providers/pixellab.ts with the real shape`
8156
+ error: `Invalid PixelLab response for ${revision?.mode ?? "animate"} job ${jobId}: completed with no recognized frame list (got: ${Object.keys(done).join(", ") || "no keys"}); update pollAnimateRevision in src/providers/pixellab.ts with the real shape`
7771
8157
  };
7772
8158
  }
7773
8159
  /**
@@ -7857,6 +8243,36 @@ var PixelLabProvider = class _PixelLabProvider {
7857
8243
  error: `Invalid PixelLab response for imagePro job ${jobId}: completed with no recognized images array (got: ${Object.keys(done).join(", ") || "no keys"}); this endpoint's completed shape has not been exercised live yet -- update pollImagePro in src/providers/pixellab.ts with the real shape`
7858
8244
  };
7859
8245
  }
8246
+ /**
8247
+ * `/generate-ui-v2` completes a plain background job whose
8248
+ * `last_response.images` (per its own docs and Python client) holds the
8249
+ * result. One image is the expected case and goes straight to selected;
8250
+ * should the endpoint ever return several, they go to review like
8251
+ * imagePro's candidates, decoded to the same local cache. The shape has
8252
+ * not been observed live, so anything else fails naming the keys received.
8253
+ */
8254
+ async pollUiElement(jobId) {
8255
+ const job = await this.client.getBackgroundJob(jobId);
8256
+ if (job.status === "failed") return { status: "failed", error: "UI element job failed upstream" };
8257
+ if (job.status !== "completed") return { status: "processing" };
8258
+ const billed = billedFromUsage(job.usage);
8259
+ const done = job.last_response ?? {};
8260
+ const images = Array.isArray(done.images) ? done.images : done.image ? [done.image] : [];
8261
+ const urls = [];
8262
+ for (const [index, candidate] of images.entries()) {
8263
+ const base64 = extractBase64(candidate);
8264
+ if (!base64) continue;
8265
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
8266
+ writeFileSync(file, Buffer.from(base64, "base64"));
8267
+ urls.push(`file://${file}`);
8268
+ }
8269
+ if (urls.length === 1) return { status: "ready", objectId: jobId, sourceUrl: urls[0], sources: [{ url: urls[0] }], billed };
8270
+ if (urls.length > 1) return { status: "review", candidateUrls: urls, billed };
8271
+ return {
8272
+ status: "failed",
8273
+ error: `Invalid PixelLab response for uiElement job ${jobId}: completed with no recognized image (got: ${Object.keys(done).join(", ") || "no keys"}); update pollUiElement in src/providers/pixellab.ts with the real shape`
8274
+ };
8275
+ }
7860
8276
  /**
7861
8277
  * Map objects need their own path because the `/map-objects/{id}` record is
7862
8278
  * deleted upstream roughly 8 hours after creation while the image survives in
@@ -8017,9 +8433,9 @@ var PixelLabProvider = class _PixelLabProvider {
8017
8433
  if (!url) throw new Error(`tiles job ${jobId} has no variation at index ${index}`);
8018
8434
  return { objectId: `${jobId}#${index}`, sourceUrl: url };
8019
8435
  }
8020
- if (generator === "imagePro") {
8436
+ if (generator === "imagePro" || generator === "uiElement") {
8021
8437
  const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
8022
- if (!existsSync7(file)) throw new Error(`imagePro job ${jobId} has no cached candidate at index ${index}`);
8438
+ if (!existsSync7(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
8023
8439
  return { objectId: `${jobId}#${index}`, sourceUrl: `file://${file}` };
8024
8440
  }
8025
8441
  const promoted = await this.client.selectFrames(jobId, [index], commonTag);
@@ -8173,6 +8589,12 @@ function readPose(spec, what, image) {
8173
8589
  }
8174
8590
  return { base64: bytes.toString("base64"), format: image.format };
8175
8591
  }
8592
+ function editAnimationFrameCeiling(width, height) {
8593
+ const side = Math.max(width, height);
8594
+ if (side <= 64) return 16;
8595
+ if (side <= 80) return 9;
8596
+ return 4;
8597
+ }
8176
8598
  function rotationSources(character) {
8177
8599
  const order = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
8178
8600
  const sources = [];
@@ -8265,9 +8687,9 @@ var RetroDiffusionClient = class {
8265
8687
  if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
8266
8688
  return Buffer.from(await response.arrayBuffer());
8267
8689
  }
8268
- async call(path45, init = {}) {
8690
+ async call(path47, init = {}) {
8269
8691
  if (!this.token) throw new Error("RD_API_KEY is not set");
8270
- const response = await this.request(`${this.baseUrl}${path45}`, {
8692
+ const response = await this.request(`${this.baseUrl}${path47}`, {
8271
8693
  ...init,
8272
8694
  headers: {
8273
8695
  "Content-Type": "application/json",
@@ -9399,7 +9821,7 @@ async function resolveSpecs(loaded, filter) {
9399
9821
  size = asset.size ?? style.size ?? 64;
9400
9822
  width = size;
9401
9823
  height = size;
9402
- } else if (generator === "uiAsset") {
9824
+ } else if (generator === "uiAsset" || generator === "uiElement") {
9403
9825
  width = asset.width ?? style.size ?? 256;
9404
9826
  height = asset.height ?? style.size ?? 256;
9405
9827
  size = Math.max(width, height);
@@ -9466,7 +9888,7 @@ async function resolveSpecs(loaded, filter) {
9466
9888
  seed: style.seed,
9467
9889
  uiPieces: generator === "uiAsset" ? asset.pieces : void 0,
9468
9890
  uiElements: generator === "uiAsset" ? asset.elements : void 0,
9469
- uiColorPalette: generator === "uiAsset" ? style.uiColorPalette : void 0,
9891
+ uiColorPalette: generator === "uiAsset" || generator === "uiElement" ? style.uiColorPalette : void 0,
9470
9892
  palette: style.palette,
9471
9893
  enforcePalette: style.enforcePalette,
9472
9894
  noBackground: style.noBackground,
@@ -9506,8 +9928,11 @@ async function resolveSpecs(loaded, filter) {
9506
9928
  terrainView: generator === "terrain" ? style.terrainView : void 0,
9507
9929
  isometricTileSize: generator === "isometricTile" ? style.isometricTileSize : void 0,
9508
9930
  isometricTileShape: generator === "isometricTile" ? style.isometricTileShape : void 0,
9509
- ...generator === "character" ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9510
- ...generator === "objectPro" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9931
+ // A revision in a character or objectPro style is a provider edit of
9932
+ // its parent's pixels, not a new character: giving it a base shape
9933
+ // would have packing, the gallery, and adoption treat it as one.
9934
+ ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9935
+ ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9511
9936
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
9512
9937
  costUnit: "generations",
9513
9938
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -9658,8 +10083,11 @@ async function resolveSpecs(loaded, filter) {
9658
10083
  );
9659
10084
  }
9660
10085
  const sourceSpec = await finalize(asset.revision.from);
9661
- const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path13.resolve(root, sourceSpec.source) : sourceSpec.outFile);
9662
- const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
10086
+ const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path13.resolve(root, sourceSpec.source) : sourceSpec.outFile);
10087
+ const sourceMembers = await revisionSourceMembers(sourceStem, `revision source ${asset.revision.from}`);
10088
+ const sourceFile = sourceMembers?.[0]?.file ?? sourceStem;
10089
+ const sourceImage = sourceMembers ? { ...sourceMembers[0], hash: sha256(JSON.stringify(sourceMembers.map((member) => member.sha256))) } : await optionalRevisionImage(sourceFile, "revision source");
10090
+ const sourceFps = sourceSpec.character?.animation?.fps ?? sourceSpec.objectPro?.animation?.fps ?? sourceSpec.revision?.fps ?? sourceSpec.revision?.sourceFps;
9663
10091
  const maskFile = asset.revision.mask ? path13.resolve(root, asset.revision.mask) : void 0;
9664
10092
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
9665
10093
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
@@ -9680,6 +10108,8 @@ async function resolveSpecs(loaded, filter) {
9680
10108
  sourceHeight: sourceImage?.height ?? null,
9681
10109
  sourceFormat: sourceImage?.format ?? null,
9682
10110
  sourceSpec,
10111
+ ...sourceMembers ? { sourceMembers } : {},
10112
+ ...sourceMembers && sourceFps != null ? { sourceFps } : {},
9683
10113
  ...maskFile ? {
9684
10114
  maskFile,
9685
10115
  maskSha256: maskImage?.hash ?? null,
@@ -9829,6 +10259,38 @@ async function optionalRevisionImage(file, label, requiredFormat) {
9829
10259
  }
9830
10260
  return { hash: sha256(bytes), ...metadata };
9831
10261
  }
10262
+ async function revisionSourceMembers(stem, label) {
10263
+ if (existsSync8(stem)) return null;
10264
+ const at = (role) => memberPath(stem, role, 0, 2, MediaType.PNG);
10265
+ let roles = [];
10266
+ for (const set of [CHARACTER_DIRECTIONS_8, CHARACTER_DIRECTIONS_4]) {
10267
+ if (set.every((direction) => existsSync8(at(direction)))) {
10268
+ roles = [...set];
10269
+ break;
10270
+ }
10271
+ }
10272
+ if (!roles.length) {
10273
+ for (let index = 0; existsSync8(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
10274
+ roles.push(`frame-${String(index).padStart(2, "0")}`);
10275
+ }
10276
+ }
10277
+ if (!roles.length) return null;
10278
+ const members = [];
10279
+ for (const role of roles) {
10280
+ const file = at(role);
10281
+ const image = await optionalRevisionImage(file, `${label} (${role})`);
10282
+ if (!image) return null;
10283
+ members.push({ role, file, sha256: image.hash, width: image.width, height: image.height, format: image.format });
10284
+ }
10285
+ const first = members[0];
10286
+ const odd = members.find((member) => member.width !== first.width || member.height !== first.height);
10287
+ if (odd) {
10288
+ throw new Error(
10289
+ `${label} is a set whose members differ in size (${first.role} is ${first.width}x${first.height}, ${odd.role} is ${odd.width}x${odd.height}); a revision reads every member as one same-size batch`
10290
+ );
10291
+ }
10292
+ return members;
10293
+ }
9832
10294
  async function resolveStyleImages(loaded, styleId) {
9833
10295
  const style = loaded.manifest.styles[styleId];
9834
10296
  if (!style) throw new Error(`Unknown style "${styleId}"`);
@@ -10342,10 +10804,10 @@ function isSha256Hash(value) {
10342
10804
  function parseCache(value) {
10343
10805
  return HashCacheSchema.parse(value);
10344
10806
  }
10345
- async function loadCache(path45) {
10346
- if (!existsSync10(path45)) return { version: 1, hashes: {} };
10807
+ async function loadCache(path47) {
10808
+ if (!existsSync10(path47)) return { version: 1, hashes: {} };
10347
10809
  try {
10348
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path45, "utf8")));
10810
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path47, "utf8")));
10349
10811
  if (!parsed.success) return { version: 1, hashes: {} };
10350
10812
  return {
10351
10813
  version: 1,
@@ -10357,18 +10819,18 @@ async function loadCache(path45) {
10357
10819
  return { version: 1, hashes: {} };
10358
10820
  }
10359
10821
  }
10360
- async function saveCache(path45, cache) {
10822
+ async function saveCache(path47, cache) {
10361
10823
  const sorted = {};
10362
10824
  for (const key of Object.keys(cache.hashes).sort()) {
10363
10825
  const hash = cache.hashes[key];
10364
10826
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
10365
10827
  sorted[key] = hash;
10366
10828
  }
10367
- await mkdir5(pathModule.dirname(pathModule.resolve(path45)), { recursive: true });
10368
- const tmp = `${path45}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10829
+ await mkdir5(pathModule.dirname(pathModule.resolve(path47)), { recursive: true });
10830
+ const tmp = `${path47}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10369
10831
  try {
10370
10832
  await writeFile5(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
10371
- await rename5(tmp, path45);
10833
+ await rename5(tmp, path47);
10372
10834
  } finally {
10373
10835
  await rm5(tmp, { force: true });
10374
10836
  }
@@ -13001,6 +13463,7 @@ async function samePixels(a, b) {
13001
13463
  var metadataFps = (entry) => entry ? frameSetFps(entry) : null;
13002
13464
  function describeCharacter(spec, entry) {
13003
13465
  const recordedCharacter = entry?.providerMetadata?.[entry.provider]?.character;
13466
+ if (spec?.revision) return null;
13004
13467
  if (spec?.character) {
13005
13468
  const character = spec.character;
13006
13469
  return {
@@ -17723,20 +18186,173 @@ function sheetFormat(args) {
17723
18186
  return format;
17724
18187
  }
17725
18188
 
17726
- // src/cli/commands/quality.ts
18189
+ // src/cli/commands/pixellab-utils.ts
17727
18190
  import path40 from "path";
17728
- import { readFile as readFile27 } from "fs/promises";
17729
18191
 
17730
- // src/pipeline/quality-regression.ts
17731
- import path39 from "path";
18192
+ // src/pixellab-utils.ts
17732
18193
  import { existsSync as existsSync29 } from "fs";
17733
- import { readFile as readFile26 } from "fs/promises";
18194
+ import { mkdir as mkdir11, readFile as readFile26, writeFile as writeFile14 } from "fs/promises";
18195
+ import path39 from "path";
18196
+ var UNZOOM_MIN_SIDE = 256;
18197
+ var UNZOOM_MAX_AREA = 2048 * 2048;
18198
+ function defaultUnzoomOut(input) {
18199
+ const ext = path39.extname(input);
18200
+ return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
18201
+ }
18202
+ async function unzoomFile(client2, input, opts = {}) {
18203
+ const out = path39.resolve(opts.out ?? defaultUnzoomOut(input));
18204
+ if (path39.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
18205
+ if (existsSync29(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
18206
+ const bytes = await readFile26(input);
18207
+ const metadata = imageMetadata(bytes);
18208
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
18209
+ const { width, height, format } = metadata;
18210
+ if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
18211
+ throw new Error(
18212
+ `${input} is ${width}x${height}; PixelLab's unzoom needs at least ${UNZOOM_MIN_SIDE}x${UNZOOM_MIN_SIDE} to find the pixel grid, so this is probably already at its native size`
18213
+ );
18214
+ }
18215
+ if (width * height > UNZOOM_MAX_AREA) {
18216
+ throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
18217
+ }
18218
+ const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
18219
+ await mkdir11(path39.dirname(out), { recursive: true });
18220
+ await writeFile14(out, res.png);
18221
+ return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
18222
+ }
18223
+ var FONT_WEIGHTS = ["Bold", "Regular"];
18224
+ var FONT_GLYPH_SIZES = [8, 16, 32, 64];
18225
+ var FONT_COST_GENERATIONS = 25;
18226
+ function fontOutputPaths(out) {
18227
+ const base = path39.resolve(out.replace(/\.(ttf|png)$/i, ""));
18228
+ return { ttf: `${base}.ttf`, atlas: `${base}.png` };
18229
+ }
18230
+ async function generateFont(client2, opts) {
18231
+ const { ttf, atlas } = fontOutputPaths(opts.out);
18232
+ if (!opts.force) {
18233
+ for (const file of [ttf, atlas]) {
18234
+ if (existsSync29(file)) throw new Error(`${file} already exists; pass --force to replace it`);
18235
+ }
18236
+ }
18237
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
18238
+ const interval = opts.intervalMs ?? 5e3;
18239
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
18240
+ const submitted = await client2.generateFontPro({
18241
+ description: opts.description,
18242
+ weight: opts.weight,
18243
+ glyphPx: opts.glyphPx,
18244
+ seed: opts.seed,
18245
+ fontName: opts.fontName
18246
+ });
18247
+ const jobId = submitted.background_job_id;
18248
+ const started = Date.now();
18249
+ for (; ; ) {
18250
+ const job = await client2.getFontJob(jobId);
18251
+ opts.onProgress?.(job.status);
18252
+ if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
18253
+ if (job.status === "completed") {
18254
+ if (!job.download_ttf_url || !job.download_atlas_url) {
18255
+ throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
18256
+ }
18257
+ const [ttfBytes, atlasBytes] = await Promise.all([
18258
+ client2.download(job.download_ttf_url),
18259
+ client2.download(job.download_atlas_url)
18260
+ ]);
18261
+ await mkdir11(path39.dirname(ttf), { recursive: true });
18262
+ await writeFile14(ttf, ttfBytes);
18263
+ await writeFile14(atlas, atlasBytes);
18264
+ return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
18265
+ }
18266
+ if (Date.now() - started > timeout) {
18267
+ throw new Error(
18268
+ `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
18269
+ );
18270
+ }
18271
+ await sleep3(interval);
18272
+ }
18273
+ }
18274
+
18275
+ // src/cli/commands/pixellab-utils.ts
18276
+ function loadKeys(args) {
18277
+ loadEnvFiles(path40.dirname(path40.resolve(args.manifest)));
18278
+ loadEnvFiles(process.cwd());
18279
+ }
18280
+ async function runUnzoom(args) {
18281
+ if (!args.from) throw new UsageError("unzoom needs --from <image>: the upscaled PNG or JPEG to shrink back to its native grid");
18282
+ const out = args.out ?? defaultUnzoomOut(args.from);
18283
+ if (args.dryRun) {
18284
+ log(` would unzoom ${args.from} \u2192 ${out}${args.quantize === void 0 ? "" : ` (quantize ${args.quantize})`}; nothing sent`);
18285
+ return;
18286
+ }
18287
+ loadKeys(args);
18288
+ const res = await unzoomFile(clientFromEnv(), args.from, { out, quantize: args.quantize, force: args.force });
18289
+ if (args.json) {
18290
+ log(JSON.stringify({ version: 1, ...res }, null, 2));
18291
+ return;
18292
+ }
18293
+ log(
18294
+ ` ${res.original.width}x${res.original.height} \u2192 ${res.unzoomed.width}x${res.unzoomed.height} (each art pixel was ${res.zoomFactor}x${res.zoomFactor}); wrote ${res.out}`
18295
+ );
18296
+ log(" the result is opaque: PixelLab composites transparency onto white first");
18297
+ }
18298
+ async function runFont(args) {
18299
+ if (!args.description) throw new UsageError('font needs --description "<style>", e.g. "warm orange arcade font"');
18300
+ if (!args.out) throw new UsageError("font needs --out <base>: <base>.ttf and <base>.png are written");
18301
+ const weight = args.weight ?? "Regular";
18302
+ if (!FONT_WEIGHTS.includes(weight)) {
18303
+ throw new UsageError(`--weight must be ${FONT_WEIGHTS.join(" or ")}, got "${args.weight}"`);
18304
+ }
18305
+ const glyphPx = args.glyphPx ?? 16;
18306
+ if (!FONT_GLYPH_SIZES.includes(glyphPx)) {
18307
+ throw new UsageError(`--glyph-px must be ${FONT_GLYPH_SIZES.join(", ")}, got "${args.glyphPx}"`);
18308
+ }
18309
+ const { ttf, atlas } = fontOutputPaths(args.out);
18310
+ log(` font: "${args.description}", ${weight}, ${glyphPx}px glyphs \u2192 ${ttf} + ${atlas}`);
18311
+ log(` cost: ${FONT_COST_GENERATIONS} generations (PixelLab's documented price)`);
18312
+ if (args.budget !== void 0 && args.budget < FONT_COST_GENERATIONS) {
18313
+ throw new Error(`--budget ${args.budget} is below this font's ${FONT_COST_GENERATIONS}-generation cost`);
18314
+ }
18315
+ if (args.dryRun) {
18316
+ log(" dry run: nothing sent");
18317
+ return;
18318
+ }
18319
+ if (!await confirm(` Spend ${FONT_COST_GENERATIONS} generations on this font?`, args.yes)) return;
18320
+ loadKeys(args);
18321
+ let last = "";
18322
+ const res = await generateFont(clientFromEnv(), {
18323
+ description: args.description,
18324
+ weight,
18325
+ glyphPx,
18326
+ fontName: args.name,
18327
+ out: args.out,
18328
+ force: args.force,
18329
+ onProgress: (status) => {
18330
+ if (status !== last) log(` ${status}\u2026`);
18331
+ last = status;
18332
+ }
18333
+ });
18334
+ if (args.json) {
18335
+ log(JSON.stringify({ version: 1, ...res }, null, 2));
18336
+ return;
18337
+ }
18338
+ log(` wrote ${res.ttf}`);
18339
+ log(` wrote ${res.atlas}`);
18340
+ }
18341
+
18342
+ // src/cli/commands/quality.ts
18343
+ import path42 from "path";
18344
+ import { readFile as readFile28 } from "fs/promises";
18345
+
18346
+ // src/pipeline/quality-regression.ts
18347
+ import path41 from "path";
18348
+ import { existsSync as existsSync30 } from "fs";
18349
+ import { readFile as readFile27 } from "fs/promises";
17734
18350
  import { z as z9 } from "zod";
17735
18351
  var SHA256_RE = /^[0-9a-f]{64}$/;
17736
18352
  var HEX_RE = /^#[0-9a-f]{6}$/;
17737
18353
  var MAX_REDMEAN_DISTANCE = 765;
17738
18354
  var QualityPathSchema = z9.string().min(1).refine(
17739
- (value) => !path39.posix.isAbsolute(value) && !value.includes("\\"),
18355
+ (value) => !path41.posix.isAbsolute(value) && !value.includes("\\"),
17740
18356
  "expected a portable relative path"
17741
18357
  );
17742
18358
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -17883,10 +18499,10 @@ function measureDecodedImageQuality(png, digest2) {
17883
18499
  };
17884
18500
  }
17885
18501
  async function measureImageQuality(file) {
17886
- const absolute = path39.resolve(file);
18502
+ const absolute = path41.resolve(file);
17887
18503
  let bytes;
17888
18504
  try {
17889
- bytes = await readFile26(absolute);
18505
+ bytes = await readFile27(absolute);
17890
18506
  } catch (error) {
17891
18507
  throw new Error(`Cannot read quality image ${absolute}: ${message4(error)}`, { cause: error });
17892
18508
  }
@@ -17905,11 +18521,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
17905
18521
  }
17906
18522
  const ids = parsed.data.map((entry) => entry.id);
17907
18523
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
17908
- const root = path39.dirname(path39.resolve(inputsFilePath));
18524
+ const root = path41.dirname(path41.resolve(inputsFilePath));
17909
18525
  return parsed.data.map((entry) => ({
17910
18526
  id: entry.id,
17911
- file: path39.resolve(root, entry.path),
17912
- ...entry.record ? { record: path39.resolve(root, entry.record) } : {},
18527
+ file: path41.resolve(root, entry.path),
18528
+ ...entry.record ? { record: path41.resolve(root, entry.record) } : {},
17913
18529
  tolerances: QualityToleranceSchema.parse({
17914
18530
  ...DEFAULT_QUALITY_TOLERANCES,
17915
18531
  ...entry.tolerances
@@ -17917,7 +18533,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
17917
18533
  }));
17918
18534
  }
17919
18535
  function portableRelative2(from, to) {
17920
- return path39.relative(from, path39.resolve(to)).split(path39.sep).join("/") || ".";
18536
+ return path41.relative(from, path41.resolve(to)).split(path41.sep).join("/") || ".";
17921
18537
  }
17922
18538
  async function verifyRecordForImage(recordPath, imagePath) {
17923
18539
  const verification = await verifyArtifactBundle(recordPath);
@@ -17932,20 +18548,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
17932
18548
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
17933
18549
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
17934
18550
  }
17935
- const outputs = record.outputs.map((output) => path39.resolve(path39.dirname(recordPath), output.path));
17936
- if (!outputs.includes(path39.resolve(imagePath))) {
18551
+ const outputs = record.outputs.map((output) => path41.resolve(path41.dirname(recordPath), output.path));
18552
+ if (!outputs.includes(path41.resolve(imagePath))) {
17937
18553
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
17938
18554
  }
17939
18555
  }
17940
18556
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
17941
18557
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
17942
- const absolute = path39.resolve(baselinePath);
18558
+ const absolute = path41.resolve(baselinePath);
17943
18559
  for (const input of inputs) {
17944
- if (path39.resolve(input.file) === absolute || input.record && path39.resolve(input.record) === absolute) {
18560
+ if (path41.resolve(input.file) === absolute || input.record && path41.resolve(input.record) === absolute) {
17945
18561
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
17946
18562
  }
17947
18563
  }
17948
- const root = path39.dirname(absolute);
18564
+ const root = path41.dirname(absolute);
17949
18565
  const cases = [];
17950
18566
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
17951
18567
  const expected = await measureImageQuality(input.file);
@@ -17972,8 +18588,8 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
17972
18588
  cases
17973
18589
  });
17974
18590
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
17975
- if (existsSync29(absolute) && !options.force) {
17976
- const current = await readFile26(absolute);
18591
+ if (existsSync30(absolute) && !options.force) {
18592
+ const current = await readFile27(absolute);
17977
18593
  if (!current.equals(data)) {
17978
18594
  throw new OverwriteRefusedError(
17979
18595
  `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
@@ -17985,10 +18601,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
17985
18601
  return { baseline, path: absolute, changed: result.changed.length > 0 };
17986
18602
  }
17987
18603
  async function readQualityBaseline(baselinePath) {
17988
- const absolute = path39.resolve(baselinePath);
18604
+ const absolute = path41.resolve(baselinePath);
17989
18605
  let raw;
17990
18606
  try {
17991
- raw = JSON.parse(await readFile26(absolute, "utf8"));
18607
+ raw = JSON.parse(await readFile27(absolute, "utf8"));
17992
18608
  } catch (error) {
17993
18609
  throw new Error(`Could not read quality baseline ${absolute}: ${message4(error)}`, { cause: error });
17994
18610
  }
@@ -18058,12 +18674,12 @@ function regressionViolations(expected, actual, tolerances) {
18058
18674
  return { violations, warnings };
18059
18675
  }
18060
18676
  async function checkQualityBaseline(baselinePath) {
18061
- const absolute = path39.resolve(baselinePath);
18677
+ const absolute = path41.resolve(baselinePath);
18062
18678
  const baseline = await readQualityBaseline(absolute);
18063
- const root = path39.dirname(absolute);
18679
+ const root = path41.dirname(absolute);
18064
18680
  const cases = [];
18065
18681
  for (const entry of baseline.cases) {
18066
- const file = path39.resolve(root, entry.file);
18682
+ const file = path41.resolve(root, entry.file);
18067
18683
  let actual = null;
18068
18684
  const violations = [];
18069
18685
  const warnings = [];
@@ -18076,7 +18692,7 @@ async function checkQualityBaseline(baselinePath) {
18076
18692
  violations.push(message4(error));
18077
18693
  }
18078
18694
  if (entry.record) {
18079
- const recordPath = path39.resolve(root, entry.record.path);
18695
+ const recordPath = path41.resolve(root, entry.record.path);
18080
18696
  try {
18081
18697
  const recordHash = await sha256File(recordPath);
18082
18698
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -18118,15 +18734,15 @@ async function runQuality(args) {
18118
18734
  if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
18119
18735
  let raw;
18120
18736
  try {
18121
- raw = JSON.parse(await readFile27(path40.resolve(args.inputs), "utf8"));
18737
+ raw = JSON.parse(await readFile28(path42.resolve(args.inputs), "utf8"));
18122
18738
  } catch (error) {
18123
18739
  throw new Error(
18124
- `Could not read quality inputs ${path40.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
18740
+ `Could not read quality inputs ${path42.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
18125
18741
  { cause: error }
18126
18742
  );
18127
18743
  }
18128
18744
  const inputs = resolveQualityInputs(raw, args.inputs);
18129
- if (path40.resolve(args.inputs) === path40.resolve(args.out)) {
18745
+ if (path42.resolve(args.inputs) === path42.resolve(args.out)) {
18130
18746
  throw new Error("quality snapshot --out must not overwrite its --inputs file.");
18131
18747
  }
18132
18748
  const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
@@ -18139,7 +18755,7 @@ async function runQuality(args) {
18139
18755
  }, null, 2));
18140
18756
  } else {
18141
18757
  log(
18142
- ` ${result.changed ? "wrote" : "unchanged"} ${path40.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
18758
+ ` ${result.changed ? "wrote" : "unchanged"} ${path42.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
18143
18759
  );
18144
18760
  log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
18145
18761
  }
@@ -18165,12 +18781,12 @@ async function runQuality(args) {
18165
18781
  }
18166
18782
 
18167
18783
  // src/cli/commands/recipe.ts
18168
- import path42 from "path";
18784
+ import path44 from "path";
18169
18785
 
18170
18786
  // src/recipes.ts
18171
- import path41 from "path";
18172
- import { existsSync as existsSync30 } from "fs";
18173
- import { readdir as readdir3, readFile as readFile28 } from "fs/promises";
18787
+ import path43 from "path";
18788
+ import { existsSync as existsSync31 } from "fs";
18789
+ import { readdir as readdir3, readFile as readFile29 } from "fs/promises";
18174
18790
  import { fileURLToPath } from "url";
18175
18791
  import { z as z10 } from "zod";
18176
18792
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
@@ -18179,7 +18795,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
18179
18795
  var RECIPE_FILE = "pixelkiln.recipe.json";
18180
18796
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
18181
18797
  var PortablePathSchema = z10.string().min(1).refine((value) => {
18182
- if (value.includes("\\") || path41.posix.isAbsolute(value)) return false;
18798
+ if (value.includes("\\") || path43.posix.isAbsolute(value)) return false;
18183
18799
  const parts = value.split("/");
18184
18800
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
18185
18801
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -18355,10 +18971,10 @@ function message5(error) {
18355
18971
  return error instanceof Error ? error.message : String(error);
18356
18972
  }
18357
18973
  async function readRecipeFile(recipePath, bundled) {
18358
- const absolute = path41.resolve(recipePath);
18974
+ const absolute = path43.resolve(recipePath);
18359
18975
  let raw;
18360
18976
  try {
18361
- raw = JSON.parse(await readFile28(absolute, "utf8"));
18977
+ raw = JSON.parse(await readFile29(absolute, "utf8"));
18362
18978
  } catch (error) {
18363
18979
  throw new Error(`Could not read recipe ${absolute}: ${message5(error)}`, { cause: error });
18364
18980
  }
@@ -18369,13 +18985,13 @@ async function readRecipeFile(recipePath, bundled) {
18369
18985
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
18370
18986
  );
18371
18987
  }
18372
- return { recipe: parsed.data, authored: raw, path: absolute, dir: path41.dirname(absolute), bundled };
18988
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: path43.dirname(absolute), bundled };
18373
18989
  }
18374
18990
  async function walkRecipeFiles(root) {
18375
- if (!existsSync30(root)) return [];
18991
+ if (!existsSync31(root)) return [];
18376
18992
  const out = [];
18377
18993
  for (const entry of await readdir3(root, { withFileTypes: true })) {
18378
- const resolved = path41.join(root, entry.name);
18994
+ const resolved = path43.join(root, entry.name);
18379
18995
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
18380
18996
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
18381
18997
  }
@@ -18386,7 +19002,7 @@ function bundledRecipeRoot() {
18386
19002
  }
18387
19003
  async function listBundledRecipes(root = bundledRecipeRoot()) {
18388
19004
  const recipes = await Promise.all(
18389
- (await walkRecipeFiles(path41.resolve(root))).map((file) => readRecipeFile(file, true))
19005
+ (await walkRecipeFiles(path43.resolve(root))).map((file) => readRecipeFile(file, true))
18390
19006
  );
18391
19007
  return recipes.sort(
18392
19008
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -18409,9 +19025,9 @@ function parseSelector(selector) {
18409
19025
  return { id: match[1], version: match[2] };
18410
19026
  }
18411
19027
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
18412
- const local = path41.resolve(target);
18413
- if (existsSync30(local)) {
18414
- const recipePath = path41.basename(local) === RECIPE_FILE ? local : path41.join(local, RECIPE_FILE);
19028
+ const local = path43.resolve(target);
19029
+ if (existsSync31(local)) {
19030
+ const recipePath = path43.basename(local) === RECIPE_FILE ? local : path43.join(local, RECIPE_FILE);
18415
19031
  return readRecipeFile(recipePath, false);
18416
19032
  }
18417
19033
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -18425,8 +19041,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
18425
19041
  return matches[0];
18426
19042
  }
18427
19043
  async function verifyFile(root, file) {
18428
- const absolute = path41.resolve(root, ...file.path.split("/"));
18429
- if (!existsSync30(absolute)) {
19044
+ const absolute = path43.resolve(root, ...file.path.split("/"));
19045
+ if (!existsSync31(absolute)) {
18430
19046
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
18431
19047
  }
18432
19048
  const actualSha256 = await sha256File(absolute);
@@ -18441,7 +19057,7 @@ async function verifyRecipe(target, options = {}) {
18441
19057
  const loaded = await resolveRecipe(target, options.bundledRoot);
18442
19058
  const actualIntegrity = recipeDigest(loaded.authored);
18443
19059
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
18444
- const modelRoot = options.modelRoot ? path41.resolve(options.modelRoot) : null;
19060
+ const modelRoot = options.modelRoot ? path43.resolve(options.modelRoot) : null;
18445
19061
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
18446
19062
  ...await verifyFile(modelRoot, model),
18447
19063
  source: model.source,
@@ -18475,7 +19091,7 @@ async function verifyRecipe(target, options = {}) {
18475
19091
  };
18476
19092
  }
18477
19093
  function renderStyle(style, destination, cwd) {
18478
- const portableDir = path41.relative(cwd, destination).split(path41.sep).join("/") || ".";
19094
+ const portableDir = path43.relative(cwd, destination).split(path43.sep).join("/") || ".";
18479
19095
  const replace = (value) => {
18480
19096
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
18481
19097
  if (Array.isArray(value)) return value.map(replace);
@@ -18487,25 +19103,25 @@ function renderStyle(style, destination, cwd) {
18487
19103
  return replace(style);
18488
19104
  }
18489
19105
  async function installRecipe(target, options = {}) {
18490
- const cwd = path41.resolve(options.cwd ?? process.cwd());
19106
+ const cwd = path43.resolve(options.cwd ?? process.cwd());
18491
19107
  const loaded = await resolveRecipe(target, options.bundledRoot);
18492
19108
  const verification = await verifyRecipe(loaded.path);
18493
19109
  if (!verification.ok) {
18494
19110
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
18495
19111
  }
18496
- const destination = options.out ? path41.resolve(cwd, options.out) : path41.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
19112
+ const destination = options.out ? path43.resolve(cwd, options.out) : path43.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
18497
19113
  const sources = [
18498
19114
  { path: RECIPE_FILE, source: loaded.path },
18499
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path41.join(loaded.dir, ...file.path.split("/")) }))
19115
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path43.join(loaded.dir, ...file.path.split("/")) }))
18500
19116
  ];
18501
19117
  const files = await Promise.all(sources.map(async (file) => ({
18502
- path: path41.join(destination, ...file.path.split("/")),
18503
- data: await readFile28(file.source)
19118
+ path: path43.join(destination, ...file.path.split("/")),
19119
+ data: await readFile29(file.source)
18504
19120
  })));
18505
19121
  if (!options.force) {
18506
19122
  for (const file of files) {
18507
- if (!existsSync30(file.path)) continue;
18508
- const current = await readFile28(file.path);
19123
+ if (!existsSync31(file.path)) continue;
19124
+ const current = await readFile29(file.path);
18509
19125
  if (!current.equals(file.data)) {
18510
19126
  throw new OverwriteRefusedError(
18511
19127
  `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
@@ -18599,14 +19215,14 @@ async function runRecipe(args) {
18599
19215
  }, null, 2));
18600
19216
  } else {
18601
19217
  log(` installed ${result.recipe.id}@${result.recipe.version}`);
18602
- log(` ${path42.relative(process.cwd(), result.destination) || "."}`);
19218
+ log(` ${path44.relative(process.cwd(), result.destination) || "."}`);
18603
19219
  log(`
18604
19220
  Add this entry under your manifest's styles object:`);
18605
19221
  log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
18606
19222
  if (result.recipe.models.length) {
18607
19223
  log(`
18608
19224
  Models are not downloaded automatically. Verify them with:`);
18609
- log(` pixelkiln recipe verify ${path42.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
19225
+ log(` pixelkiln recipe verify ${path44.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
18610
19226
  }
18611
19227
  }
18612
19228
  return;
@@ -18614,7 +19230,7 @@ async function runRecipe(args) {
18614
19230
  }
18615
19231
 
18616
19232
  // src/cli/commands/refine.ts
18617
- import path43 from "path";
19233
+ import path45 from "path";
18618
19234
  async function runRefine(args) {
18619
19235
  if (args.from || args.subcommand === "approve") {
18620
19236
  if (!args.from) {
@@ -18644,12 +19260,12 @@ async function runRefine(args) {
18644
19260
  ` recovered ${result3.detection.columns}x${result3.detection.rows} native grid (${result3.detection.confidence}: ${result3.detection.consensus})`
18645
19261
  );
18646
19262
  log(` enforced ${result3.palette.length}-color palette without dithering`);
18647
- log(` wrote ${path43.relative(process.cwd(), result3.output)}`);
18648
- log(` quality record: ${path43.relative(process.cwd(), result3.record)}`);
19263
+ log(` wrote ${path45.relative(process.cwd(), result3.output)}`);
19264
+ log(` quality record: ${path45.relative(process.cwd(), result3.record)}`);
18649
19265
  log(`
18650
19266
  Automated checks passed. Human 1\xD7 review is still required:`);
18651
19267
  log(
18652
- ` pixelkiln refine approve --from ${path43.relative(process.cwd(), result3.record)} --reviewer "Your Name"`
19268
+ ` pixelkiln refine approve --from ${path45.relative(process.cwd(), result3.record)} --reviewer "Your Name"`
18653
19269
  );
18654
19270
  }
18655
19271
  return;
@@ -18677,7 +19293,7 @@ async function runRefine(args) {
18677
19293
  if (args.json) log(JSON.stringify(result3, null, 2));
18678
19294
  else {
18679
19295
  log(
18680
- result3.options.frameSet ? ` approved ${result3.outputs.length} frames in ${path43.relative(process.cwd(), result3.record)} by ${args.reviewer.trim()}` : ` approved ${path43.relative(process.cwd(), result3.output)} by ${args.reviewer.trim()}`
19296
+ result3.options.frameSet ? ` approved ${result3.outputs.length} frames in ${path45.relative(process.cwd(), result3.record)} by ${args.reviewer.trim()}` : ` approved ${path45.relative(process.cwd(), result3.output)} by ${args.reviewer.trim()}`
18681
19297
  );
18682
19298
  }
18683
19299
  return;
@@ -18686,7 +19302,7 @@ async function runRefine(args) {
18686
19302
  if (args.json) {
18687
19303
  log(JSON.stringify(result2, null, 2));
18688
19304
  } else {
18689
- log(` ${result2.safe ? "release-ready" : "not release-ready"}: ${path43.relative(process.cwd(), result2.output || result2.record)}`);
19305
+ log(` ${result2.safe ? "release-ready" : "not release-ready"}: ${path45.relative(process.cwd(), result2.output || result2.record)}`);
18690
19306
  for (const reason of result2.reasons) log(` ${reason}`);
18691
19307
  }
18692
19308
  if (!result2.safe) process.exitCode = 1;
@@ -18732,7 +19348,7 @@ async function runRefine(args) {
18732
19348
  log(` ${item.state.padEnd(16)} ${item.key} ${item.reason}`);
18733
19349
  if (item.state === "needs-approval") {
18734
19350
  log(
18735
- ` pixelkiln refine approve --from ${path43.relative(process.cwd(), item.record)} --reviewer "Your Name"`
19351
+ ` pixelkiln refine approve --from ${path45.relative(process.cwd(), item.record)} --reviewer "Your Name"`
18736
19352
  );
18737
19353
  }
18738
19354
  }
@@ -18784,15 +19400,15 @@ async function runTools(args) {
18784
19400
  }
18785
19401
 
18786
19402
  // src/cli/commands/workspace.ts
18787
- import path44 from "path";
18788
- import { existsSync as existsSync31 } from "fs";
19403
+ import path46 from "path";
19404
+ import { existsSync as existsSync32 } from "fs";
18789
19405
  async function runWorkspace(args) {
18790
- const workspacePath = path44.resolve(args.workspace ?? "pixelkiln.workspace.json");
18791
- const dir = path44.dirname(workspacePath);
19406
+ const workspacePath = path46.resolve(args.workspace ?? "pixelkiln.workspace.json");
19407
+ const dir = path46.dirname(workspacePath);
18792
19408
  if (args.subcommand === "add") {
18793
- const manifestPath = path44.resolve(args.target);
19409
+ const manifestPath = path46.resolve(args.target);
18794
19410
  const loadedTarget = await loadManifest(manifestPath);
18795
- const lockPath = args.explicitLock ? path44.resolve(args.explicitLock) : path44.join(path44.dirname(manifestPath), "pixelkiln.lock.json");
19411
+ const lockPath = args.explicitLock ? path46.resolve(args.explicitLock) : path46.join(path46.dirname(manifestPath), "pixelkiln.lock.json");
18796
19412
  const ws = await loadWorkspace(workspacePath);
18797
19413
  const id = args.name ?? loadedTarget.manifest.name;
18798
19414
  if (ws.projects.some((p) => p.id === id)) {
@@ -18812,10 +19428,10 @@ async function runWorkspace(args) {
18812
19428
  ...args.account ? { account: args.account } : {}
18813
19429
  };
18814
19430
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
18815
- log(` registered "${id}" in ${path44.relative(process.cwd(), workspacePath)}`);
19431
+ log(` registered "${id}" in ${path46.relative(process.cwd(), workspacePath)}`);
18816
19432
  log(` manifest: ${project.manifest}`);
18817
19433
  log(` lock: ${project.lock}`);
18818
- if (!existsSync31(lockPath)) {
19434
+ if (!existsSync32(lockPath)) {
18819
19435
  log(
18820
19436
  ` warning: no lockfile there yet; this project contributes no claims until one is generated`
18821
19437
  );
@@ -18824,7 +19440,7 @@ async function runWorkspace(args) {
18824
19440
  }
18825
19441
  if (args.subcommand === "remove") {
18826
19442
  const ws = await loadWorkspace(workspacePath);
18827
- const resolvedTarget = path44.resolve(args.target);
19443
+ const resolvedTarget = path46.resolve(args.target);
18828
19444
  const match = ws.projects.find(
18829
19445
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
18830
19446
  );
@@ -18835,11 +19451,11 @@ async function runWorkspace(args) {
18835
19451
  version: 1,
18836
19452
  projects: ws.projects.filter((p) => p !== match)
18837
19453
  });
18838
- log(` removed "${match.id}" from ${path44.relative(process.cwd(), workspacePath)}`);
19454
+ log(` removed "${match.id}" from ${path46.relative(process.cwd(), workspacePath)}`);
18839
19455
  return;
18840
19456
  }
18841
19457
  if (args.subcommand === "list") {
18842
- if (!existsSync31(workspacePath)) {
19458
+ if (!existsSync32(workspacePath)) {
18843
19459
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
18844
19460
  }
18845
19461
  const ws = await loadWorkspace(workspacePath);
@@ -18847,9 +19463,9 @@ async function runWorkspace(args) {
18847
19463
  if (args.json) {
18848
19464
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
18849
19465
  } else if (!ws.projects.length) {
18850
- log(` no projects registered in ${path44.relative(process.cwd(), workspacePath)}`);
19466
+ log(` no projects registered in ${path46.relative(process.cwd(), workspacePath)}`);
18851
19467
  } else {
18852
- log(` ${ws.projects.length} project(s) in ${path44.relative(process.cwd(), workspacePath)}:`);
19468
+ log(` ${ws.projects.length} project(s) in ${path46.relative(process.cwd(), workspacePath)}:`);
18853
19469
  for (const p of ws.projects) {
18854
19470
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
18855
19471
  }
@@ -18859,7 +19475,7 @@ async function runWorkspace(args) {
18859
19475
  return;
18860
19476
  }
18861
19477
  if (args.subcommand === "status") {
18862
- if (!existsSync31(workspacePath)) {
19478
+ if (!existsSync32(workspacePath)) {
18863
19479
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
18864
19480
  }
18865
19481
  const ws = await loadWorkspace(workspacePath);
@@ -18867,7 +19483,7 @@ async function runWorkspace(args) {
18867
19483
  if (args.json) {
18868
19484
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
18869
19485
  } else {
18870
- log(` workspace: ${path44.relative(process.cwd(), workspacePath)}`);
19486
+ log(` workspace: ${path46.relative(process.cwd(), workspacePath)}`);
18871
19487
  for (const p of report.projects) {
18872
19488
  if (p.error) {
18873
19489
  log(`
@@ -18921,8 +19537,8 @@ async function runHelp() {
18921
19537
  async function runVersion() {
18922
19538
  for (const up of ["../package.json", "../../package.json"]) {
18923
19539
  const url = new URL(up, import.meta.url);
18924
- if (!existsSync32(url)) continue;
18925
- const pkg = JSON.parse(await readFile29(url, "utf8"));
19540
+ if (!existsSync33(url)) continue;
19541
+ const pkg = JSON.parse(await readFile30(url, "utf8"));
18926
19542
  if (pkg.name === "pixelkiln") {
18927
19543
  log(`${pkg.name} ${pkg.version}`);
18928
19544
  return;
@@ -18965,7 +19581,9 @@ var RUNNERS = {
18965
19581
  quality: runQuality,
18966
19582
  refine: runRefine,
18967
19583
  recipe: runRecipe,
18968
- workspace: runWorkspace
19584
+ workspace: runWorkspace,
19585
+ unzoom: runUnzoom,
19586
+ font: runFont
18969
19587
  };
18970
19588
  async function main(argv = process.argv.slice(2)) {
18971
19589
  const args = parseArgs(argv);