pixelkiln 0.61.0 → 0.63.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;
@@ -934,6 +956,14 @@ var CharacterAnimationSchema = z.object({
934
956
  template: z.string().min(1).optional(),
935
957
  /** Direction to animate. */
936
958
  direction: CharacterDirectionSchema.default("south"),
959
+ /**
960
+ * Shorthand for one loop in several directions, instead of `direction`.
961
+ * Expanded when the manifest loads into one loop per named direction
962
+ * (`<id>.<direction>`) plus a mirror for each direction whose flip is
963
+ * not named, exactly as if each had been written out. See
964
+ * `src/loop-directions.ts`.
965
+ */
966
+ directions: z.array(CharacterDirectionSchema).min(1).optional(),
937
967
  /** Frames to generate without a template (v3). Even, 4 to 16. */
938
968
  frames: z.number().int().min(4).max(16).optional(),
939
969
  /** Playback rate recorded with the frames; PixelLab does not store one. */
@@ -962,6 +992,13 @@ var CharacterAnimationSchema = z.object({
962
992
  /** v3 text loops only: let PixelLab expand the action into a fuller motion description first. */
963
993
  enhancePrompt: z.boolean().optional()
964
994
  }).strict().superRefine((animation, context) => {
995
+ if (animation.directions !== void 0) {
996
+ context.addIssue({
997
+ code: z.ZodIssueCode.custom,
998
+ message: "directions is expanded when the manifest loads; validate the loaded manifest, not the raw file",
999
+ path: ["directions"]
1000
+ });
1001
+ }
965
1002
  if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
966
1003
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
967
1004
  }
@@ -1022,9 +1059,13 @@ var RevisionSchema = z.object({
1022
1059
  * the provider layer, since it differs by mode).
1023
1060
  */
1024
1061
  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. */
1062
+ /** `animate`/`animate-pixminimax`/`interpolate`/`edit-animation` only: playback rate recorded with the frames; PixelLab does not store one. */
1026
1063
  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). */
1064
+ /**
1065
+ * `animate`/`animate-pixminimax`: manifest-relative image pinning where
1066
+ * the motion ends (interpolation instead of open-ended animation).
1067
+ * `interpolate`: required, the ending keyframe; the parent is the start.
1068
+ */
1028
1069
  lastFrame: z.string().min(1).optional(),
1029
1070
  /** `animate-pixminimax` only: facing direction, used only alongside `enhancePrompt` to hold the sprite's facing. */
1030
1071
  direction: CharacterDirectionSchema.optional(),
@@ -1068,14 +1109,15 @@ var RevisionSchema = z.object({
1068
1109
  path: ["strength"]
1069
1110
  });
1070
1111
  }
1071
- if (revision.strength !== void 0 && (revision.mode === "animate" || revision.mode === "animate-pixminimax")) {
1112
+ const generative = revision.mode === "animate" || revision.mode === "animate-pixminimax" || revision.mode === "interpolate" || revision.mode === "edit-animation";
1113
+ if (revision.strength !== void 0 && generative) {
1072
1114
  context.addIssue({
1073
1115
  code: z.ZodIssueCode.custom,
1074
- message: `${revision.mode} revisions do not take a strength; use enhancePrompt`,
1116
+ 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
1117
  path: ["strength"]
1076
1118
  });
1077
1119
  }
1078
- for (const field of ["frames", "fps", "lastFrame", "enhancePrompt"]) {
1120
+ for (const field of ["frames", "enhancePrompt"]) {
1079
1121
  if (revision[field] !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax") {
1080
1122
  context.addIssue({
1081
1123
  code: z.ZodIssueCode.custom,
@@ -1084,6 +1126,27 @@ var RevisionSchema = z.object({
1084
1126
  });
1085
1127
  }
1086
1128
  }
1129
+ if (revision.lastFrame !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax" && revision.mode !== "interpolate") {
1130
+ context.addIssue({
1131
+ code: z.ZodIssueCode.custom,
1132
+ message: "lastFrame applies to animate/animate-pixminimax/interpolate revisions only",
1133
+ path: ["lastFrame"]
1134
+ });
1135
+ }
1136
+ if (revision.mode === "interpolate" && revision.lastFrame === void 0) {
1137
+ context.addIssue({
1138
+ code: z.ZodIssueCode.custom,
1139
+ message: "interpolate revisions require lastFrame: the ending keyframe to interpolate toward",
1140
+ path: ["lastFrame"]
1141
+ });
1142
+ }
1143
+ if (revision.fps !== void 0 && !generative) {
1144
+ context.addIssue({
1145
+ code: z.ZodIssueCode.custom,
1146
+ message: "fps applies to animate/animate-pixminimax/interpolate/edit-animation revisions only",
1147
+ path: ["fps"]
1148
+ });
1149
+ }
1087
1150
  if (revision.direction !== void 0 && revision.mode !== "animate-pixminimax") {
1088
1151
  context.addIssue({
1089
1152
  code: z.ZodIssueCode.custom,
@@ -1095,6 +1158,8 @@ var RevisionSchema = z.object({
1095
1158
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
1096
1159
  }
1097
1160
  });
1161
+ var FRAME_SET_REVISION_MODES = ["animate", "animate-pixminimax", "interpolate", "edit-animation"];
1162
+ var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation"];
1098
1163
  var StyleObjectSchema = z.object({
1099
1164
  /** Generation backend for this style. Omit to inherit the manifest default. */
1100
1165
  provider: z.string().min(1).optional(),
@@ -1213,7 +1278,7 @@ var StyleObjectSchema = z.object({
1213
1278
  */
1214
1279
  terrainMode: z.enum(["standard", "pro"]).optional(),
1215
1280
  /**
1216
- * `uiAsset` generator only. A natural-language palette hint sent as-is
1281
+ * `uiAsset` and `uiElement` generators only. A natural-language palette hint sent as-is
1217
1282
  * (e.g. "brown and gold"), distinct from `palette`'s hex-color array —
1218
1283
  * this is a prompt-level steer, not a local quantization target.
1219
1284
  */
@@ -4087,11 +4152,16 @@ async function inspectRevision(spec, lock, seen) {
4087
4152
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
4088
4153
  };
4089
4154
  }
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" };
4155
+ if (revision.sourceMembers) {
4156
+ const members = await inspectSourceMembers(spec, lock);
4157
+ if (!members.ready) return members;
4158
+ } else {
4159
+ if (!revision.sourceSha256 || !existsSync4(revision.sourceFile)) {
4160
+ return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
4161
+ }
4162
+ if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
4163
+ return { ready: false, reason: "revision source changed after the manifest was resolved" };
4164
+ }
4095
4165
  }
4096
4166
  if (revision.maskFile) {
4097
4167
  if (!revision.maskSha256 || !existsSync4(revision.maskFile)) {
@@ -4101,8 +4171,39 @@ async function inspectRevision(spec, lock, seen) {
4101
4171
  return { ready: false, reason: "revision mask changed after the manifest was resolved" };
4102
4172
  }
4103
4173
  }
4174
+ if (revision.lastFrameFile) {
4175
+ if (!revision.lastFrameSha256 || !existsSync4(revision.lastFrameFile)) {
4176
+ return { ready: false, reason: `revision last frame is missing: ${revision.lastFrameFile}` };
4177
+ }
4178
+ if (await sha256File(revision.lastFrameFile) !== revision.lastFrameSha256) {
4179
+ return { ready: false, reason: "revision last frame changed after the manifest was resolved" };
4180
+ }
4181
+ }
4104
4182
  return { ready: true, reason: "revision inputs are current" };
4105
4183
  }
4184
+ async function inspectSourceMembers(spec, lock) {
4185
+ const revision = spec.revision;
4186
+ const members = revision.sourceMembers;
4187
+ for (const member of members) {
4188
+ if (!existsSync4(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
4189
+ if (await sha256File(member.file) !== member.sha256) {
4190
+ return { ready: false, reason: `revision source member changed after the manifest was resolved: ${member.file}` };
4191
+ }
4192
+ }
4193
+ const parent = revision.sourceSpec;
4194
+ if (parent.source) return { ready: true, reason: "declared source set is present" };
4195
+ const entry = lock.entries[lockKey(parent.styleId, parent.assetId)];
4196
+ if (!entry) return { ready: false, reason: "revision source is not in the lockfile" };
4197
+ const recorded = entry.outputs.map((_, index) => path8.resolve(currentEntryOutputPath(entry, parent, index))).sort();
4198
+ const found = members.map((member) => path8.resolve(member.file)).sort();
4199
+ if (recorded.length !== found.length || recorded.some((file, index) => file !== found[index])) {
4200
+ return {
4201
+ ready: false,
4202
+ 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`
4203
+ };
4204
+ }
4205
+ return { ready: true, reason: "revision source set is current" };
4206
+ }
4106
4207
  async function requireRevisionReady(spec, lock) {
4107
4208
  const inspection = await inspectRevisionReadiness(spec, lock);
4108
4209
  if (inspection && !inspection.ready) throw new Error(inspection.reason);
@@ -4808,14 +4909,15 @@ var ComfyUIProvider = class _ComfyUIProvider {
4808
4909
  }
4809
4910
  /**
4810
4911
  * 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.
4912
+ * workflow does with `bindings.sourceImage`. The frame-set modes
4913
+ * (`animate`, `animate-pixminimax`, `interpolate`, `edit-animation`) are
4914
+ * the exception: they produce an ordered frame set, and this adapter's
4915
+ * revision path always writes a single output image (see `submit`/`fetch`
4916
+ * below) — a real structural gap, not a missing binding, so they are
4917
+ * rejected here rather than only failing once `validate` runs.
4816
4918
  */
4817
4919
  supportsRevision(mode2) {
4818
- return mode2 !== "animate" && mode2 !== "animate-pixminimax";
4920
+ return !FRAME_SET_REVISION_MODES.includes(mode2);
4819
4921
  }
4820
4922
  estimate(spec) {
4821
4923
  return {
@@ -4868,6 +4970,11 @@ var ComfyUIProvider = class _ComfyUIProvider {
4868
4970
  throw new Error("ComfyUI providerOptions.frames requires generator: frames");
4869
4971
  }
4870
4972
  if (spec.revision) {
4973
+ if (spec.revision.sourceMembers) {
4974
+ throw new Error(
4975
+ `ComfyUI revisions read one source image; ${spec.revision.sourceAssetId} is a ${spec.revision.sourceMembers.length}-member set (directions or frames)`
4976
+ );
4977
+ }
4871
4978
  if (!options.bindings.sourceImage) {
4872
4979
  throw new Error("ComfyUI revisions require bindings.sourceImage");
4873
4980
  }
@@ -5603,6 +5710,21 @@ var CorrectPixelartResponseSchema = z2.object({
5603
5710
  images: z2.array(z2.object({ base64: z2.string().min(1) }).passthrough()).min(1),
5604
5711
  usage: z2.unknown().optional()
5605
5712
  }).passthrough();
5713
+ var UnzoomResponseSchema = z2.object({
5714
+ image: z2.object({ base64: z2.string().min(1) }).passthrough(),
5715
+ original_size: z2.object({ width: z2.number().int(), height: z2.number().int() }).passthrough(),
5716
+ unzoomed_size: z2.object({ width: z2.number().int(), height: z2.number().int() }).passthrough(),
5717
+ zoom_factor_detected: z2.number(),
5718
+ usage: z2.unknown().optional()
5719
+ }).passthrough();
5720
+ var FontJobSchema = z2.object({
5721
+ job_id: z2.string().min(1),
5722
+ status: z2.string(),
5723
+ glyph_px: z2.number().int().nullable().optional(),
5724
+ download_atlas_url: z2.string().nullable().optional(),
5725
+ download_ttf_url: z2.string().nullable().optional(),
5726
+ usage: z2.unknown().optional()
5727
+ }).passthrough();
5606
5728
  var SelectFramesSchema = z2.object({ created_object_ids: z2.array(z2.string()) }).passthrough();
5607
5729
  var CharacterSubmitSchema = z2.object({
5608
5730
  background_job_id: z2.string().min(1),
@@ -5687,6 +5809,17 @@ function validateResponse(schema, raw, operation) {
5687
5809
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
5688
5810
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
5689
5811
  }
5812
+ function cleanupImages(args, operation) {
5813
+ const images = args.images ?? (args.image ? [args.image] : []);
5814
+ if (!images.length) throw new Error(`${operation} needs at least one image`);
5815
+ return images;
5816
+ }
5817
+ function cleanupResults(images, sent, operation) {
5818
+ if (images.length !== sent) {
5819
+ throw new Error(`Invalid PixelLab response for ${operation}: sent ${sent} image(s), got ${images.length} back`);
5820
+ }
5821
+ return images.map((image) => Buffer.from(image.base64, "base64"));
5822
+ }
5690
5823
  var PixelLabError = class extends ProviderError {
5691
5824
  constructor(message6, status, body) {
5692
5825
  super("pixellab", message6, { status });
@@ -5715,9 +5848,9 @@ var PixelLabClient = class {
5715
5848
  apiKey;
5716
5849
  http;
5717
5850
  /** Retries transport failures, 429, and 5xx; see http.ts for the policy. */
5718
- async request(path45, init) {
5851
+ async request(path47, init) {
5719
5852
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
5720
- const res = await this.http(`${BASE}${path45}`, {
5853
+ const res = await this.http(`${BASE}${path47}`, {
5721
5854
  ...init,
5722
5855
  headers: {
5723
5856
  Authorization: auth,
@@ -5727,13 +5860,13 @@ var PixelLabClient = class {
5727
5860
  });
5728
5861
  const text = await res.text();
5729
5862
  if (!res.ok) {
5730
- throw new PixelLabError(`${init?.method ?? "GET"} ${path45} \u2192 ${res.status}`, res.status, text);
5863
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path47} \u2192 ${res.status}`, res.status, text);
5731
5864
  }
5732
5865
  if (!text) return {};
5733
5866
  try {
5734
5867
  return JSON.parse(text);
5735
5868
  } catch {
5736
- throw new Error(`${init?.method ?? "GET"} ${path45} returned invalid JSON`);
5869
+ throw new Error(`${init?.method ?? "GET"} ${path47} returned invalid JSON`);
5737
5870
  }
5738
5871
  }
5739
5872
  async balance() {
@@ -6119,10 +6252,10 @@ var PixelLabClient = class {
6119
6252
  const palette = args.paletteSwatchBase64 ? { color_image: { type: "base64", base64: args.paletteSwatchBase64, format: "png" }, force_colors: true } : {};
6120
6253
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
6121
6254
  const south = args.reference?.south;
6122
- let path45;
6255
+ let path47;
6123
6256
  let body;
6124
6257
  if (args.mode === "v3") {
6125
- path45 = "/create-character-v3";
6258
+ path47 = "/create-character-v3";
6126
6259
  body = {
6127
6260
  description: args.description,
6128
6261
  image_size: imageSize,
@@ -6136,7 +6269,7 @@ var PixelLabClient = class {
6136
6269
  ...args.enhancePrompt ? { enhance_prompt: true } : {}
6137
6270
  };
6138
6271
  } else if (args.mode === "pro-flash") {
6139
- path45 = "/create-character-pro-flash";
6272
+ path47 = "/create-character-pro-flash";
6140
6273
  const traits = args.styleTraits;
6141
6274
  body = {
6142
6275
  description: args.description,
@@ -6159,7 +6292,7 @@ var PixelLabClient = class {
6159
6292
  } : {}
6160
6293
  };
6161
6294
  } else if (args.mode === "pro") {
6162
- path45 = "/create-character-pro";
6295
+ path47 = "/create-character-pro";
6163
6296
  body = {
6164
6297
  description: args.description,
6165
6298
  image_size: imageSize,
@@ -6173,7 +6306,7 @@ var PixelLabClient = class {
6173
6306
  ...!south && args.styleCharacterId ? { style_character_id: args.styleCharacterId } : {}
6174
6307
  };
6175
6308
  } else {
6176
- path45 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
6309
+ path47 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
6177
6310
  body = {
6178
6311
  description: args.description,
6179
6312
  image_size: imageSize,
@@ -6190,8 +6323,8 @@ var PixelLabClient = class {
6190
6323
  ...palette
6191
6324
  };
6192
6325
  }
6193
- const raw = await this.request(path45, { method: "POST", body: JSON.stringify(body) });
6194
- return validateResponse(CharacterSubmitSchema, raw, path45.slice(1));
6326
+ const raw = await this.request(path47, { method: "POST", body: JSON.stringify(body) });
6327
+ return validateResponse(CharacterSubmitSchema, raw, path47.slice(1));
6195
6328
  }
6196
6329
  /** A text edit of an existing character, applied to every direction and stored as a sibling. */
6197
6330
  async createCharacterState(args) {
@@ -6353,20 +6486,52 @@ var PixelLabClient = class {
6353
6486
  );
6354
6487
  }
6355
6488
  /**
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.
6489
+ * `/generate-ui-v2` ("Generate UI (Pro)"): one UI element (a button, a
6490
+ * health bar, an inventory slot, a dialogue box) from a description,
6491
+ * guided by an optional concept image and a natural-language color
6492
+ * palette. A plain background job; unlike `/create-ui-asset` there is no
6493
+ * piece/element layout, and sizes start at 16px instead of 192. Its
6494
+ * completed shape has not been exercised live; `pollUiElement` reads it
6495
+ * defensively.
6496
+ */
6497
+ async generateUiV2(args) {
6498
+ const body = {
6499
+ description: args.description,
6500
+ image_size: { width: args.width, height: args.height }
6501
+ };
6502
+ if (args.conceptImage) {
6503
+ body.concept_image = {
6504
+ image: args.conceptImage.image,
6505
+ size: { width: args.conceptImage.width, height: args.conceptImage.height }
6506
+ };
6507
+ }
6508
+ if (args.colorPalette) body.color_palette = args.colorPalette;
6509
+ if (args.noBackground != null) body.no_background = args.noBackground;
6510
+ if (args.seed != null) body.seed = args.seed;
6511
+ return validateResponse(
6512
+ RevisionJobSubmitSchema,
6513
+ await this.request("/generate-ui-v2", { method: "POST", body: JSON.stringify(body) }),
6514
+ "generate-ui-v2"
6515
+ );
6516
+ }
6517
+ /**
6518
+ * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image, or
6519
+ * several same-size frames together onto ONE shared palette, synchronously
6520
+ * — no `background_job_id`, the result comes back in this same response,
6521
+ * like `createImagePixflux`. Passing every frame of an animation (or every
6522
+ * direction of a character) in one call is the endpoint's whole point:
6523
+ * frames quantized separately drift onto different palettes. The schema's
6524
+ * own response example is dollar-denominated (`usage: {type: "usd", usd:
6525
+ * 0.02}`), which — matching this codebase's repeated experience with
6526
+ * PixelLab's documented-vs-billed cost mismatches (`isometricTile`,
6527
+ * `objectPro`) — did not hold: confirmed live against a Tier 2 account at
6528
+ * exactly 0.1 generations for a 32x32 source (docs/REVISIONS.md).
6529
+ * `numColors` and `paletteImage` are mutually exclusive upstream; the
6530
+ * manifest schema already enforces that before this is ever called.
6367
6531
  */
6368
6532
  async reduceColors(args) {
6369
- const body = { images: [args.image] };
6533
+ const images = cleanupImages(args, "reduce-colors");
6534
+ const body = { images };
6370
6535
  if (args.numColors != null) body.num_colors = args.numColors;
6371
6536
  if (args.paletteImage) body.palette_image = args.paletteImage;
6372
6537
  if (args.dithering) body.dithering = args.dithering;
@@ -6376,8 +6541,10 @@ var PixelLabClient = class {
6376
6541
  await this.request("/reduce-colors", { method: "POST", body: JSON.stringify(body) }),
6377
6542
  "reduce-colors"
6378
6543
  );
6544
+ const pngs = cleanupResults(res.images, images.length, "reduce-colors");
6379
6545
  return {
6380
- png: Buffer.from(res.images[0].base64, "base64"),
6546
+ png: pngs[0],
6547
+ pngs,
6381
6548
  paletteStripPng: Buffer.from(res.palette.base64, "base64"),
6382
6549
  nColors: res.n_colors,
6383
6550
  usage: res.usage
@@ -6386,19 +6553,117 @@ var PixelLabClient = class {
6386
6553
  /**
6387
6554
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
6388
6555
  * 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
6556
+ * `reduceColors` above, no background job, and likewise corrects several
6557
+ * same-size frames together when given `images`. Cost is confirmed live
6558
+ * at 0.1 generations for a 32x32 source, not the schema's own
6391
6559
  * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
6392
6560
  */
6393
6561
  async correctPixelart(args) {
6394
- const body = { images: [args.image] };
6562
+ const images = cleanupImages(args, "correct-pixelart");
6563
+ const body = { images };
6395
6564
  if (args.strength != null) body.strength = args.strength;
6396
6565
  const res = validateResponse(
6397
6566
  CorrectPixelartResponseSchema,
6398
6567
  await this.request("/correct-pixelart", { method: "POST", body: JSON.stringify(body) }),
6399
6568
  "correct-pixelart"
6400
6569
  );
6401
- return { png: Buffer.from(res.images[0].base64, "base64"), usage: res.usage };
6570
+ const pngs = cleanupResults(res.images, images.length, "correct-pixelart");
6571
+ return { png: pngs[0], pngs, usage: res.usage };
6572
+ }
6573
+ /**
6574
+ * `/unzoom`: recover native-resolution pixel art from an upscaled image (a
6575
+ * 32x32 sprite saved at 512x512). Synchronous like the Cleanup tier.
6576
+ * PixelLab's own API overview names this "the most common cause of
6577
+ * disappointing output": run it on outside artwork before it becomes a
6578
+ * style or reference image. The input must be at least 256x256 and at
6579
+ * most 2048x2048 in area, and the result is opaque — transparency is
6580
+ * composited onto white before the grid is detected. `quantize` is 0 to
6581
+ * auto-detect a palette, -1 to keep every color, or 2-256 for exactly
6582
+ * that many.
6583
+ */
6584
+ async unzoom(args) {
6585
+ const body = { image: args.image };
6586
+ if (args.quantize != null) body.quantize = args.quantize;
6587
+ const res = validateResponse(
6588
+ UnzoomResponseSchema,
6589
+ await this.request("/unzoom", { method: "POST", body: JSON.stringify(body) }),
6590
+ "unzoom"
6591
+ );
6592
+ return {
6593
+ png: Buffer.from(res.image.base64, "base64"),
6594
+ originalSize: { width: res.original_size.width, height: res.original_size.height },
6595
+ unzoomedSize: { width: res.unzoomed_size.width, height: res.unzoomed_size.height },
6596
+ zoomFactor: res.zoom_factor_detected,
6597
+ usage: res.usage
6598
+ };
6599
+ }
6600
+ /**
6601
+ * `/interpolation-v2` ("Interpolate (Pro)"): generate the in-between
6602
+ * frames from one keyframe to another, guided by a short `action`. Unlike
6603
+ * `last_frame` on `/animate-with-text-v3`, both ends are required and the
6604
+ * frame count is the model's own choice (its docs say "typically 4-8").
6605
+ * Output frames are 16 to 128 pixels per side. A plain background job;
6606
+ * its completed shape is read defensively by `pollAnimateRevision`.
6607
+ */
6608
+ async interpolationV2(args) {
6609
+ const size = { width: args.width, height: args.height };
6610
+ const body = {
6611
+ start_image: { image: args.startImage, size },
6612
+ end_image: { image: args.endImage, size },
6613
+ action: args.action,
6614
+ image_size: size
6615
+ };
6616
+ if (args.seed != null) body.seed = args.seed;
6617
+ if (args.noBackground != null) body.no_background = args.noBackground;
6618
+ return validateResponse(
6619
+ RevisionJobSubmitSchema,
6620
+ await this.request("/interpolation-v2", { method: "POST", body: JSON.stringify(body) }),
6621
+ "interpolation-v2"
6622
+ );
6623
+ }
6624
+ /**
6625
+ * `/edit-animation-v2` ("Edit animation (Pro)"): apply one text edit
6626
+ * across every frame of an existing animation at once, so the change
6627
+ * stays consistent frame to frame. 2 to 16 frames, but the real ceiling
6628
+ * shrinks with frame size because every frame is packed into one grid:
6629
+ * 16 at up to 64px, 9 at 65-80px, 4 at 81-256px.
6630
+ */
6631
+ async editAnimationV2(args) {
6632
+ const size = { width: args.width, height: args.height };
6633
+ const body = {
6634
+ description: args.description,
6635
+ frames: args.frames.map((image) => ({ image, size })),
6636
+ image_size: size
6637
+ };
6638
+ if (args.seed != null) body.seed = args.seed;
6639
+ if (args.noBackground != null) body.no_background = args.noBackground;
6640
+ return validateResponse(
6641
+ RevisionJobSubmitSchema,
6642
+ await this.request("/edit-animation-v2", { method: "POST", body: JSON.stringify(body) }),
6643
+ "edit-animation-v2"
6644
+ );
6645
+ }
6646
+ /**
6647
+ * `/generate-font-pro`: an 80-glyph pixel font (A-Z, a-z, 0-9, common
6648
+ * game-UI punctuation) from a style description, delivered as a glyph
6649
+ * atlas PNG plus a ready-to-use `.ttf`. Documented at 25 subscription
6650
+ * generations. Poll with `getFontJob`.
6651
+ */
6652
+ async generateFontPro(args) {
6653
+ const body = { description: args.description, weight: args.weight };
6654
+ if (args.glyphPx != null) body.glyph_px = args.glyphPx;
6655
+ if (args.seed != null) body.seed = args.seed;
6656
+ if (args.fontName) body.font_name = args.fontName;
6657
+ return validateResponse(
6658
+ RevisionJobSubmitSchema,
6659
+ await this.request("/generate-font-pro", { method: "POST", body: JSON.stringify(body) }),
6660
+ "generate-font-pro"
6661
+ );
6662
+ }
6663
+ /** `GET /generate-font-pro/{job_id}`: status, then no-auth atlas and `.ttf` URLs once completed. */
6664
+ async getFontJob(jobId) {
6665
+ const raw = await this.request(`/generate-font-pro/${encodeURIComponent(jobId)}`);
6666
+ return validateResponse(FontJobSchema, raw, "generate-font-pro/{job_id}");
6402
6667
  }
6403
6668
  /**
6404
6669
  * `/animate-with-text-v3`: animate a loose image from a text description,
@@ -6619,7 +6884,7 @@ var PixelLabProvider = class _PixelLabProvider {
6619
6884
  return new _PixelLabProvider(new PixelLabClient("download-only"));
6620
6885
  }
6621
6886
  supports(generator) {
6622
- return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset";
6887
+ return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset" || generator === "uiElement";
6623
6888
  }
6624
6889
  /**
6625
6890
  * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
@@ -6629,12 +6894,15 @@ var PixelLabProvider = class _PixelLabProvider {
6629
6894
  * rather than a described edit — and `animate`/`animate-pixminimax`
6630
6895
  * (`/animate-with-text-v3`, `/animate-pixminimax`), which animate any loose
6631
6896
  * 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.
6897
+ * required — and `interpolate` (`/interpolation-v2`, the in-betweens
6898
+ * from one keyframe to another) and `edit-animation`
6899
+ * (`/edit-animation-v2`, one text edit applied across a whole frame set).
6900
+ * `outpaint` does not: there is no canvas-expansion endpoint in the API,
6901
+ * matching docs/REVISIONS.md's note that no provider ships a tested
6902
+ * outpaint path yet.
6635
6903
  */
6636
6904
  supportsRevision(mode2) {
6637
- return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax";
6905
+ return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax" || mode2 === "interpolate" || mode2 === "edit-animation";
6638
6906
  }
6639
6907
  /** PixelLab's own constraints: submissions must be >2s apart, and
6640
6908
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -6662,6 +6930,12 @@ var PixelLabProvider = class _PixelLabProvider {
6662
6930
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
6663
6931
  return { unit: "generations", amount: 0.1, candidates: 1 };
6664
6932
  }
6933
+ if (spec.revision?.mode === "interpolate" || spec.revision?.mode === "edit-animation") {
6934
+ const width = spec.revision.sourceWidth ?? spec.width;
6935
+ const height = spec.revision.sourceHeight ?? spec.height;
6936
+ const side = spec.revision.mode === "edit-animation" ? Math.ceil(Math.sqrt(Math.max(1, spec.revision.sourceMembers?.length ?? 1))) : 1;
6937
+ return { unit: "generations", amount: generationCost(width * side, height * side, "1dir"), candidates: 1 };
6938
+ }
6665
6939
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
6666
6940
  const width = spec.revision.sourceWidth ?? spec.width;
6667
6941
  const height = spec.revision.sourceHeight ?? spec.height;
@@ -6690,6 +6964,12 @@ var PixelLabProvider = class _PixelLabProvider {
6690
6964
  };
6691
6965
  }
6692
6966
  validate(spec, styleImages) {
6967
+ const members = spec.revision?.sourceMembers;
6968
+ if (spec.revision && members && !MEMBER_SET_REVISION_MODES.includes(spec.revision.mode)) {
6969
+ throw new Error(
6970
+ `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`
6971
+ );
6972
+ }
6693
6973
  if (spec.revision?.mode === "inpaint") {
6694
6974
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
6695
6975
  if (width != null && height != null && (width < 32 || height < 32 || width > 512 || height > 512)) {
@@ -6698,9 +6978,10 @@ var PixelLabProvider = class _PixelLabProvider {
6698
6978
  }
6699
6979
  if (spec.revision?.mode === "reduce-colors") {
6700
6980
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
6701
- if (width != null && height != null && width * height > 512 * 512) {
6981
+ const count = members?.length ?? 1;
6982
+ if (width != null && height != null && width * height * count > 512 * 512) {
6702
6983
  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`
6984
+ `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
6985
  );
6705
6986
  }
6706
6987
  }
@@ -6724,6 +7005,36 @@ var PixelLabProvider = class _PixelLabProvider {
6724
7005
  );
6725
7006
  }
6726
7007
  }
7008
+ if (spec.revision?.mode === "interpolate") {
7009
+ const { sourceWidth: width, sourceHeight: height, lastFrameWidth, lastFrameHeight } = spec.revision;
7010
+ if (width != null && height != null && (width < 16 || height < 16 || width > 128 || height > 128)) {
7011
+ throw new Error(`PixelLab interpolate keyframes are ${width}x${height}; the API takes 16 to 128 pixels per side`);
7012
+ }
7013
+ if (lastFrameWidth != null && lastFrameHeight != null && width != null && height != null && (lastFrameWidth !== width || lastFrameHeight !== height)) {
7014
+ throw new Error(
7015
+ `PixelLab interpolate lastFrame is ${lastFrameWidth}x${lastFrameHeight}; the start keyframe is ${width}x${height} \u2014 they must match`
7016
+ );
7017
+ }
7018
+ }
7019
+ if (spec.revision?.mode === "edit-animation") {
7020
+ const { sourceWidth: width, sourceHeight: height, sourceAssetId } = spec.revision;
7021
+ if (width != null && height != null) {
7022
+ if (!members || members.length < 2) {
7023
+ throw new Error(
7024
+ `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)`
7025
+ );
7026
+ }
7027
+ if (width < 16 || height < 16 || width > 256 || height > 256) {
7028
+ throw new Error(`PixelLab edit-animation frames are ${width}x${height}; the API takes 16 to 256 pixels per side`);
7029
+ }
7030
+ const ceiling = editAnimationFrameCeiling(width, height);
7031
+ if (members.length > ceiling) {
7032
+ throw new Error(
7033
+ `PixelLab edit-animation takes at most ${ceiling} frames at ${width}x${height} (every frame is packed into one grid); ${sourceAssetId} has ${members.length}`
7034
+ );
7035
+ }
7036
+ }
7037
+ }
6727
7038
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
6728
7039
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
6729
7040
  }
@@ -6740,6 +7051,14 @@ var PixelLabProvider = class _PixelLabProvider {
6740
7051
  `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
7052
  );
6742
7053
  }
7054
+ if (spec.generator === "uiElement" && (spec.width < 16 || spec.height < 16 || spec.width > 792 || spec.height > 688)) {
7055
+ throw new Error(
7056
+ `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)`
7057
+ );
7058
+ }
7059
+ if (spec.generator === "uiElement" && styleImages.length > 1) {
7060
+ throw new Error("PixelLab uiElement takes one concept image; list at most one styleImages entry");
7061
+ }
6743
7062
  if (spec.generator === "map") {
6744
7063
  requirePixelLabOption("map", "view", spec.view, ["low top-down", "high top-down", "side"]);
6745
7064
  requirePixelLabOption("map", "outline", spec.outline, [
@@ -6823,8 +7142,8 @@ var PixelLabProvider = class _PixelLabProvider {
6823
7142
  throw new Error(`PixelLab isometricTile: size must be 16 to 64 pixels, got ${spec.width}`);
6824
7143
  }
6825
7144
  }
6826
- if (spec.generator === "character") this.validateCharacter(spec, styleImages);
6827
- if (spec.generator === "objectPro") this.validateObjectPro(spec, styleImages);
7145
+ if (spec.generator === "character" && !spec.revision) this.validateCharacter(spec, styleImages);
7146
+ if (spec.generator === "objectPro" && !spec.revision) this.validateObjectPro(spec, styleImages);
6828
7147
  if ((spec.generator === "map" || spec.generator === "pixflux") && styleImages.length) {
6829
7148
  throw new Error(`PixelLab ${spec.generator} does not support style images`);
6830
7149
  }
@@ -7391,6 +7710,19 @@ var PixelLabProvider = class _PixelLabProvider {
7391
7710
  });
7392
7711
  return { jobId: res2.ui_asset_id, metadata: { backgroundJobId: res2.background_job_id } };
7393
7712
  }
7713
+ if (spec.generator === "uiElement") {
7714
+ const concept = styleImages[0];
7715
+ const res2 = await this.client.generateUiV2({
7716
+ description: spec.prompt,
7717
+ width: spec.width,
7718
+ height: spec.height,
7719
+ conceptImage: concept ? { image: { base64: concept.base64, format: concept.format }, width: concept.width, height: concept.height } : void 0,
7720
+ colorPalette: spec.uiColorPalette,
7721
+ noBackground: spec.noBackground,
7722
+ seed: spec.seed
7723
+ });
7724
+ return { jobId: res2.background_job_id };
7725
+ }
7394
7726
  if (spec.generator === "pixflux") {
7395
7727
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
7396
7728
  const { png } = await this.client.createImagePixflux({
@@ -7519,13 +7851,18 @@ var PixelLabProvider = class _PixelLabProvider {
7519
7851
  if (!revision.sourceSha256 || !revision.sourceFormat || revision.sourceWidth == null || revision.sourceHeight == null) {
7520
7852
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
7521
7853
  }
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 };
7854
+ const sourceFiles = revision.sourceMembers ?? [{ file: revision.sourceFile, sha256: revision.sourceSha256, format: revision.sourceFormat }];
7855
+ const images = sourceFiles.map((member) => {
7856
+ const bytes = readFileSync2(member.file);
7857
+ if (sha256(bytes) !== member.sha256) {
7858
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
7859
+ }
7860
+ return { base64: bytes.toString("base64"), format: member.format };
7861
+ });
7862
+ const image = images[0];
7527
7863
  const width = revision.sourceWidth;
7528
7864
  const height = revision.sourceHeight;
7865
+ const roles = revision.sourceMembers?.map((member) => member.role);
7529
7866
  if (revision.mode === "inpaint") {
7530
7867
  if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
7531
7868
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
@@ -7558,34 +7895,45 @@ var PixelLabProvider = class _PixelLabProvider {
7558
7895
  paletteImage = { base64: paletteBytes.toString("base64"), format: revision.paletteImageFormat };
7559
7896
  }
7560
7897
  const res2 = await this.client.reduceColors({
7561
- image,
7898
+ images,
7562
7899
  numColors: revision.numColors,
7563
7900
  paletteImage,
7564
7901
  dithering: revision.dithering,
7565
7902
  ditheringStrength: revision.ditheringStrength
7566
7903
  });
7567
- const jobId = randomUUID3();
7568
- writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
7569
- return { jobId, metadata: { revisionUsage: res2.usage } };
7904
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
7570
7905
  }
7571
7906
  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 } };
7907
+ const res2 = await this.client.correctPixelart({ images, strength: revision.strength });
7908
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
7909
+ }
7910
+ if (revision.mode === "interpolate") {
7911
+ const lastFrame = this.readLastFrame(spec);
7912
+ if (!lastFrame) throw new Error(`${spec.styleId}/${spec.assetId}: interpolate needs lastFrame, the ending keyframe`);
7913
+ const res2 = await this.client.interpolationV2({
7914
+ startImage: image,
7915
+ endImage: lastFrame,
7916
+ width,
7917
+ height,
7918
+ action: spec.prompt,
7919
+ seed: spec.seed,
7920
+ noBackground: spec.noBackground
7921
+ });
7922
+ return { jobId: res2.background_job_id };
7923
+ }
7924
+ if (revision.mode === "edit-animation") {
7925
+ const res2 = await this.client.editAnimationV2({
7926
+ frames: images,
7927
+ width,
7928
+ height,
7929
+ description: spec.prompt,
7930
+ seed: spec.seed,
7931
+ noBackground: spec.noBackground
7932
+ });
7933
+ return { jobId: res2.background_job_id };
7576
7934
  }
7577
7935
  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
- }
7936
+ const lastFrame = this.readLastFrame(spec);
7589
7937
  if (revision.mode === "animate") {
7590
7938
  const res3 = await this.client.animateWithTextV3({
7591
7939
  firstFrame: image,
@@ -7625,6 +7973,36 @@ var PixelLabProvider = class _PixelLabProvider {
7625
7973
  });
7626
7974
  return { jobId: res.background_job_id };
7627
7975
  }
7976
+ /** A revision's pinned ending frame, proven to be the bytes the spec hashed; undefined when none is declared. */
7977
+ readLastFrame(spec) {
7978
+ const revision = spec.revision;
7979
+ if (!revision.lastFrameFile) return void 0;
7980
+ if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
7981
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
7982
+ }
7983
+ const bytes = readFileSync2(revision.lastFrameFile);
7984
+ if (sha256(bytes) !== revision.lastFrameSha256) {
7985
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
7986
+ }
7987
+ return { base64: bytes.toString("base64"), format: revision.lastFrameFormat };
7988
+ }
7989
+ /**
7990
+ * Park a synchronous Cleanup-tier result the way pixflux parks its own:
7991
+ * one `<id>.png` for a single image, or `<id>-<n>.png` per member with the
7992
+ * member roles recorded so `pollCachedRevision` hands them back under the
7993
+ * same names the parent's set used.
7994
+ */
7995
+ cacheCleanupResult(pngs, usage, roles, fps) {
7996
+ const jobId = randomUUID3();
7997
+ if (!roles) {
7998
+ writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
7999
+ return { jobId, metadata: { revisionUsage: usage } };
8000
+ }
8001
+ for (const [index, png] of pngs.entries()) {
8002
+ writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
8003
+ }
8004
+ return { jobId, metadata: { revisionUsage: usage, revisionMembers: roles, ...fps != null ? { revisionFps: fps } : {} } };
8005
+ }
7628
8006
  /**
7629
8007
  * What a background job actually billed, once its own record is still
7630
8008
  * around to ask; see `billedFromUsage`. A stale or already-cleaned-up job
@@ -7645,7 +8023,7 @@ var PixelLabProvider = class _PixelLabProvider {
7645
8023
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart") {
7646
8024
  return this.pollCachedRevision(jobId, context);
7647
8025
  }
7648
- if (revisionMode === "animate" || revisionMode === "animate-pixminimax") {
8026
+ if (revisionMode === "animate" || revisionMode === "animate-pixminimax" || revisionMode === "interpolate" || revisionMode === "edit-animation") {
7649
8027
  return this.pollAnimateRevision(jobId, context);
7650
8028
  }
7651
8029
  if (context?.spec?.revision) return this.pollRevision(jobId);
@@ -7668,6 +8046,7 @@ var PixelLabProvider = class _PixelLabProvider {
7668
8046
  if (generator === "character") return this.pollCharacter(jobId, context);
7669
8047
  if (generator === "objectPro") return this.pollObjectPro(jobId, context);
7670
8048
  if (generator === "uiAsset") return this.pollUiAsset(jobId, context);
8049
+ if (generator === "uiElement") return this.pollUiElement(jobId);
7671
8050
  const backgroundJobId = context?.metadata?.backgroundJobId;
7672
8051
  const obj = await this.client.getObject(jobId);
7673
8052
  if (obj.status === "review") {
@@ -7694,15 +8073,32 @@ var PixelLabProvider = class _PixelLabProvider {
7694
8073
  * work.
7695
8074
  */
7696
8075
  async pollCachedRevision(jobId, context) {
7697
- const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7698
- if (!existsSync7(file)) {
8076
+ const usage = context?.metadata?.revisionUsage;
8077
+ const roles = context?.metadata?.revisionMembers;
8078
+ const gone = {
8079
+ status: "failed",
8080
+ error: "revision result is no longer cached locally; re-run submit for this asset"
8081
+ };
8082
+ if (Array.isArray(roles) && roles.length) {
8083
+ const sources = roles.map((role, index) => ({
8084
+ url: `file://${path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
8085
+ role: String(role)
8086
+ }));
8087
+ if (sources.some((source) => !existsSync7(source.url.slice("file://".length)))) return gone;
8088
+ const fps = context?.metadata?.revisionFps;
8089
+ const frames = sources.every((source) => source.role.startsWith("frame-"));
7699
8090
  return {
7700
- status: "failed",
7701
- error: "revision result is no longer cached locally; re-run submit for this asset"
8091
+ status: "ready",
8092
+ objectId: jobId,
8093
+ sourceUrl: sources[0].url,
8094
+ sources,
8095
+ ...frames && typeof fps === "number" ? { metadata: { frameSet: { fps, count: sources.length } } } : {},
8096
+ billed: billedFromUsage(usage)
7702
8097
  };
7703
8098
  }
8099
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
8100
+ if (!existsSync7(file)) return gone;
7704
8101
  const sourceUrl = `file://${file}`;
7705
- const usage = context?.metadata?.revisionUsage;
7706
8102
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
7707
8103
  }
7708
8104
  /**
@@ -7726,16 +8122,21 @@ var PixelLabProvider = class _PixelLabProvider {
7726
8122
  if (job.status !== "completed") return { status: "processing" };
7727
8123
  const billed = billedFromUsage(job.usage);
7728
8124
  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
- });
8125
+ const revision = context?.spec?.revision;
8126
+ const fps = revision?.fps ?? revision?.sourceFps ?? 8;
8127
+ const memberRoles = revision?.mode === "edit-animation" ? revision.sourceMembers?.map((member) => member.role) : void 0;
8128
+ const review = (frameUrls) => {
8129
+ const roles = memberRoles?.length === frameUrls.length ? memberRoles : frameUrls.map((_, index) => `frame-${String(index).padStart(2, "0")}`);
8130
+ return {
8131
+ status: "review-set",
8132
+ objectId: jobId,
8133
+ frameUrls,
8134
+ sources: frameUrls.map((url, index) => ({ url, role: roles[index] })),
8135
+ fps,
8136
+ metadata: roles.every((role) => role.startsWith("frame-")) ? { frameSet: { fps, count: frameUrls.length } } : {},
8137
+ billed
8138
+ };
8139
+ };
7739
8140
  for (const key of ["frame_urls", "frames", "images"]) {
7740
8141
  const list = done[key];
7741
8142
  if (!Array.isArray(list) || !list.length) continue;
@@ -7767,7 +8168,7 @@ var PixelLabProvider = class _PixelLabProvider {
7767
8168
  }
7768
8169
  return {
7769
8170
  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`
8171
+ 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
8172
  };
7772
8173
  }
7773
8174
  /**
@@ -7857,6 +8258,36 @@ var PixelLabProvider = class _PixelLabProvider {
7857
8258
  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
8259
  };
7859
8260
  }
8261
+ /**
8262
+ * `/generate-ui-v2` completes a plain background job whose
8263
+ * `last_response.images` (per its own docs and Python client) holds the
8264
+ * result. One image is the expected case and goes straight to selected;
8265
+ * should the endpoint ever return several, they go to review like
8266
+ * imagePro's candidates, decoded to the same local cache. The shape has
8267
+ * not been observed live, so anything else fails naming the keys received.
8268
+ */
8269
+ async pollUiElement(jobId) {
8270
+ const job = await this.client.getBackgroundJob(jobId);
8271
+ if (job.status === "failed") return { status: "failed", error: "UI element job failed upstream" };
8272
+ if (job.status !== "completed") return { status: "processing" };
8273
+ const billed = billedFromUsage(job.usage);
8274
+ const done = job.last_response ?? {};
8275
+ const images = Array.isArray(done.images) ? done.images : done.image ? [done.image] : [];
8276
+ const urls = [];
8277
+ for (const [index, candidate] of images.entries()) {
8278
+ const base64 = extractBase64(candidate);
8279
+ if (!base64) continue;
8280
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
8281
+ writeFileSync(file, Buffer.from(base64, "base64"));
8282
+ urls.push(`file://${file}`);
8283
+ }
8284
+ if (urls.length === 1) return { status: "ready", objectId: jobId, sourceUrl: urls[0], sources: [{ url: urls[0] }], billed };
8285
+ if (urls.length > 1) return { status: "review", candidateUrls: urls, billed };
8286
+ return {
8287
+ status: "failed",
8288
+ 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`
8289
+ };
8290
+ }
7860
8291
  /**
7861
8292
  * Map objects need their own path because the `/map-objects/{id}` record is
7862
8293
  * deleted upstream roughly 8 hours after creation while the image survives in
@@ -8017,9 +8448,9 @@ var PixelLabProvider = class _PixelLabProvider {
8017
8448
  if (!url) throw new Error(`tiles job ${jobId} has no variation at index ${index}`);
8018
8449
  return { objectId: `${jobId}#${index}`, sourceUrl: url };
8019
8450
  }
8020
- if (generator === "imagePro") {
8451
+ if (generator === "imagePro" || generator === "uiElement") {
8021
8452
  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}`);
8453
+ if (!existsSync7(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
8023
8454
  return { objectId: `${jobId}#${index}`, sourceUrl: `file://${file}` };
8024
8455
  }
8025
8456
  const promoted = await this.client.selectFrames(jobId, [index], commonTag);
@@ -8173,6 +8604,12 @@ function readPose(spec, what, image) {
8173
8604
  }
8174
8605
  return { base64: bytes.toString("base64"), format: image.format };
8175
8606
  }
8607
+ function editAnimationFrameCeiling(width, height) {
8608
+ const side = Math.max(width, height);
8609
+ if (side <= 64) return 16;
8610
+ if (side <= 80) return 9;
8611
+ return 4;
8612
+ }
8176
8613
  function rotationSources(character) {
8177
8614
  const order = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
8178
8615
  const sources = [];
@@ -8265,9 +8702,9 @@ var RetroDiffusionClient = class {
8265
8702
  if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
8266
8703
  return Buffer.from(await response.arrayBuffer());
8267
8704
  }
8268
- async call(path45, init = {}) {
8705
+ async call(path47, init = {}) {
8269
8706
  if (!this.token) throw new Error("RD_API_KEY is not set");
8270
- const response = await this.request(`${this.baseUrl}${path45}`, {
8707
+ const response = await this.request(`${this.baseUrl}${path47}`, {
8271
8708
  ...init,
8272
8709
  headers: {
8273
8710
  "Content-Type": "application/json",
@@ -9127,10 +9564,102 @@ registerProvider({
9127
9564
  import { readFile as readFile6 } from "fs/promises";
9128
9565
  import { existsSync as existsSync8 } from "fs";
9129
9566
  import path13 from "path";
9567
+
9568
+ // src/loop-directions.ts
9569
+ var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
9570
+ var MIRROR_FIELDS = ["category", "tags", "styles"];
9571
+ function loopDirectionId(shorthandId, direction) {
9572
+ return `${shorthandId}.${direction}`;
9573
+ }
9574
+ function expandLoopDirections(raw) {
9575
+ const families = {};
9576
+ const issues = [];
9577
+ if (!isObject2(raw) || !isObject2(raw.assets)) return { raw, families, issues };
9578
+ const assets = raw.assets;
9579
+ if (!Object.values(assets).some((asset) => isObject2(asset) && isObject2(asset.animation) && "directions" in asset.animation)) {
9580
+ return { raw, families, issues };
9581
+ }
9582
+ const expanded = {};
9583
+ const claimed = new Set(Object.keys(assets));
9584
+ for (const [id, asset] of Object.entries(assets)) {
9585
+ if (!isObject2(asset) || !isObject2(asset.animation) || !("directions" in asset.animation)) {
9586
+ expanded[id] = asset;
9587
+ continue;
9588
+ }
9589
+ const where = `assets.${id}.animation.directions`;
9590
+ const { directions: rawDirections, ...animation } = asset.animation;
9591
+ const parsed = CharacterDirectionSchema.array().min(1).safeParse(rawDirections);
9592
+ if (!parsed.success) {
9593
+ issues.push(`${where}: expected a non-empty list of directions (${CharacterDirectionSchema.options.join(", ")})`);
9594
+ continue;
9595
+ }
9596
+ const directions = parsed.data;
9597
+ const duplicate = directions.find((direction, index) => directions.indexOf(direction) !== index);
9598
+ if (duplicate) {
9599
+ issues.push(`${where}: "${duplicate}" is listed twice`);
9600
+ continue;
9601
+ }
9602
+ if ("direction" in animation) {
9603
+ issues.push(`assets.${id}.animation: direction and directions are mutually exclusive; directions names every loop to make`);
9604
+ continue;
9605
+ }
9606
+ const single = SINGLE_OUTPUT_FIELDS.filter((field) => field in asset);
9607
+ if (single.length) {
9608
+ issues.push(
9609
+ `assets.${id}: ${single.join(", ")} name${single.length === 1 ? "s" : ""} one output, but directions makes ${directions.length} loops; declare the loops explicitly to set ${single.length === 1 ? "it" : "them"} per direction`
9610
+ );
9611
+ continue;
9612
+ }
9613
+ const loops = directions.map((direction) => ({ id: loopDirectionId(id, direction), direction }));
9614
+ const named = new Set(directions);
9615
+ const mirrors = [];
9616
+ for (const { id: loopId, direction } of loops) {
9617
+ const flipped = MIRRORED_DIRECTION[direction];
9618
+ if (flipped === direction || named.has(flipped)) continue;
9619
+ named.add(flipped);
9620
+ mirrors.push({ id: loopDirectionId(id, flipped), of: loopId });
9621
+ }
9622
+ const ids = [...loops.map((loop) => loop.id), ...mirrors.map((mirror) => mirror.id)];
9623
+ const collision = ids.find((expandedId) => claimed.has(expandedId));
9624
+ if (collision) {
9625
+ issues.push(`${where}: expands to "${collision}", which is already declared`);
9626
+ continue;
9627
+ }
9628
+ for (const expandedId of ids) claimed.add(expandedId);
9629
+ for (const loop of loops) {
9630
+ expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
9631
+ }
9632
+ for (const mirror of mirrors) {
9633
+ const inherited = {};
9634
+ for (const field of MIRROR_FIELDS) if (field in asset) inherited[field] = asset[field];
9635
+ expanded[mirror.id] = { mirror: mirror.of, ...inherited };
9636
+ }
9637
+ families[id] = ids;
9638
+ }
9639
+ return { raw: { ...raw, assets: expanded }, families, issues };
9640
+ }
9641
+ function expandAssetFilter(ids, families) {
9642
+ if (!families) return [...ids];
9643
+ return [...new Set(ids.flatMap((id) => families[id] ?? [id]))];
9644
+ }
9645
+ function loopShorthandOf(assetId, families) {
9646
+ if (!families) return void 0;
9647
+ return Object.keys(families).find((id) => families[id].includes(assetId));
9648
+ }
9649
+ function isObject2(value) {
9650
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9651
+ }
9652
+
9653
+ // src/manifest.ts
9130
9654
  async function loadManifest(manifestPath) {
9131
9655
  const abs = path13.resolve(manifestPath);
9132
9656
  if (!existsSync8(abs)) throw new ProjectError(`No manifest at ${abs}`);
9133
- const input = ManifestInputSchema.safeParse(JSON.parse(await readFile6(abs, "utf8")));
9657
+ const expansion = expandLoopDirections(JSON.parse(await readFile6(abs, "utf8")));
9658
+ if (expansion.issues.length) {
9659
+ throw new ProjectError(`Manifest at ${abs} is invalid:
9660
+ ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
9661
+ }
9662
+ const input = ManifestInputSchema.safeParse(expansion.raw);
9134
9663
  if (!input.success) {
9135
9664
  const issues = formatManifestIssues(input.error.issues);
9136
9665
  throw new ProjectError(`Manifest at ${abs} is invalid:
@@ -9215,7 +9744,8 @@ ${issues}`);
9215
9744
  throw new ProjectError(`Manifest at ${abs} is invalid:
9216
9745
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
9217
9746
  }
9218
- return { manifest: parsed.data, root: path13.dirname(abs), path: abs };
9747
+ const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
9748
+ return { manifest: parsed.data, root: path13.dirname(abs), path: abs, ...loopFamilies };
9219
9749
  }
9220
9750
  function parentAssetId(asset) {
9221
9751
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
@@ -9299,12 +9829,13 @@ async function resolveSpecs(loaded, filter) {
9299
9829
  );
9300
9830
  }
9301
9831
  }
9302
- for (const unknownAsset of filter?.assets ?? []) {
9832
+ const assetFilter = expandAssetFilter(filter?.assets ?? [], loaded.loopFamilies);
9833
+ for (const unknownAsset of assetFilter) {
9303
9834
  if (!manifest.assets[unknownAsset]) {
9304
9835
  throw new UsageError(`Unknown asset "${unknownAsset}".`);
9305
9836
  }
9306
9837
  }
9307
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
9838
+ const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
9308
9839
  const resolutionAssetIds = new Set(requestedAssetIds);
9309
9840
  for (const assetId of [...requestedAssetIds]) {
9310
9841
  let current = parentAssetId(manifest.assets[assetId]);
@@ -9399,7 +9930,7 @@ async function resolveSpecs(loaded, filter) {
9399
9930
  size = asset.size ?? style.size ?? 64;
9400
9931
  width = size;
9401
9932
  height = size;
9402
- } else if (generator === "uiAsset") {
9933
+ } else if (generator === "uiAsset" || generator === "uiElement") {
9403
9934
  width = asset.width ?? style.size ?? 256;
9404
9935
  height = asset.height ?? style.size ?? 256;
9405
9936
  size = Math.max(width, height);
@@ -9466,7 +9997,7 @@ async function resolveSpecs(loaded, filter) {
9466
9997
  seed: style.seed,
9467
9998
  uiPieces: generator === "uiAsset" ? asset.pieces : void 0,
9468
9999
  uiElements: generator === "uiAsset" ? asset.elements : void 0,
9469
- uiColorPalette: generator === "uiAsset" ? style.uiColorPalette : void 0,
10000
+ uiColorPalette: generator === "uiAsset" || generator === "uiElement" ? style.uiColorPalette : void 0,
9470
10001
  palette: style.palette,
9471
10002
  enforcePalette: style.enforcePalette,
9472
10003
  noBackground: style.noBackground,
@@ -9506,8 +10037,11 @@ async function resolveSpecs(loaded, filter) {
9506
10037
  terrainView: generator === "terrain" ? style.terrainView : void 0,
9507
10038
  isometricTileSize: generator === "isometricTile" ? style.isometricTileSize : void 0,
9508
10039
  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 }) } : {},
10040
+ // A revision in a character or objectPro style is a provider edit of
10041
+ // its parent's pixels, not a new character: giving it a base shape
10042
+ // would have packing, the gallery, and adoption treat it as one.
10043
+ ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
10044
+ ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9511
10045
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
9512
10046
  costUnit: "generations",
9513
10047
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -9658,8 +10192,11 @@ async function resolveSpecs(loaded, filter) {
9658
10192
  );
9659
10193
  }
9660
10194
  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");
10195
+ const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path13.resolve(root, sourceSpec.source) : sourceSpec.outFile);
10196
+ const sourceMembers = await revisionSourceMembers(sourceStem, `revision source ${asset.revision.from}`);
10197
+ const sourceFile = sourceMembers?.[0]?.file ?? sourceStem;
10198
+ const sourceImage = sourceMembers ? { ...sourceMembers[0], hash: sha256(JSON.stringify(sourceMembers.map((member) => member.sha256))) } : await optionalRevisionImage(sourceFile, "revision source");
10199
+ const sourceFps = sourceSpec.character?.animation?.fps ?? sourceSpec.objectPro?.animation?.fps ?? sourceSpec.revision?.fps ?? sourceSpec.revision?.sourceFps;
9663
10200
  const maskFile = asset.revision.mask ? path13.resolve(root, asset.revision.mask) : void 0;
9664
10201
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
9665
10202
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
@@ -9680,6 +10217,8 @@ async function resolveSpecs(loaded, filter) {
9680
10217
  sourceHeight: sourceImage?.height ?? null,
9681
10218
  sourceFormat: sourceImage?.format ?? null,
9682
10219
  sourceSpec,
10220
+ ...sourceMembers ? { sourceMembers } : {},
10221
+ ...sourceMembers && sourceFps != null ? { sourceFps } : {},
9683
10222
  ...maskFile ? {
9684
10223
  maskFile,
9685
10224
  maskSha256: maskImage?.hash ?? null,
@@ -9829,6 +10368,38 @@ async function optionalRevisionImage(file, label, requiredFormat) {
9829
10368
  }
9830
10369
  return { hash: sha256(bytes), ...metadata };
9831
10370
  }
10371
+ async function revisionSourceMembers(stem, label) {
10372
+ if (existsSync8(stem)) return null;
10373
+ const at = (role) => memberPath(stem, role, 0, 2, MediaType.PNG);
10374
+ let roles = [];
10375
+ for (const set of [CHARACTER_DIRECTIONS_8, CHARACTER_DIRECTIONS_4]) {
10376
+ if (set.every((direction) => existsSync8(at(direction)))) {
10377
+ roles = [...set];
10378
+ break;
10379
+ }
10380
+ }
10381
+ if (!roles.length) {
10382
+ for (let index = 0; existsSync8(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
10383
+ roles.push(`frame-${String(index).padStart(2, "0")}`);
10384
+ }
10385
+ }
10386
+ if (!roles.length) return null;
10387
+ const members = [];
10388
+ for (const role of roles) {
10389
+ const file = at(role);
10390
+ const image = await optionalRevisionImage(file, `${label} (${role})`);
10391
+ if (!image) return null;
10392
+ members.push({ role, file, sha256: image.hash, width: image.width, height: image.height, format: image.format });
10393
+ }
10394
+ const first = members[0];
10395
+ const odd = members.find((member) => member.width !== first.width || member.height !== first.height);
10396
+ if (odd) {
10397
+ throw new Error(
10398
+ `${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`
10399
+ );
10400
+ }
10401
+ return members;
10402
+ }
9832
10403
  async function resolveStyleImages(loaded, styleId) {
9833
10404
  const style = loaded.manifest.styles[styleId];
9834
10405
  if (!style) throw new Error(`Unknown style "${styleId}"`);
@@ -10342,10 +10913,10 @@ function isSha256Hash(value) {
10342
10913
  function parseCache(value) {
10343
10914
  return HashCacheSchema.parse(value);
10344
10915
  }
10345
- async function loadCache(path45) {
10346
- if (!existsSync10(path45)) return { version: 1, hashes: {} };
10916
+ async function loadCache(path47) {
10917
+ if (!existsSync10(path47)) return { version: 1, hashes: {} };
10347
10918
  try {
10348
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path45, "utf8")));
10919
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path47, "utf8")));
10349
10920
  if (!parsed.success) return { version: 1, hashes: {} };
10350
10921
  return {
10351
10922
  version: 1,
@@ -10357,18 +10928,18 @@ async function loadCache(path45) {
10357
10928
  return { version: 1, hashes: {} };
10358
10929
  }
10359
10930
  }
10360
- async function saveCache(path45, cache) {
10931
+ async function saveCache(path47, cache) {
10361
10932
  const sorted = {};
10362
10933
  for (const key of Object.keys(cache.hashes).sort()) {
10363
10934
  const hash = cache.hashes[key];
10364
10935
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
10365
10936
  sorted[key] = hash;
10366
10937
  }
10367
- await mkdir5(pathModule.dirname(pathModule.resolve(path45)), { recursive: true });
10368
- const tmp = `${path45}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10938
+ await mkdir5(pathModule.dirname(pathModule.resolve(path47)), { recursive: true });
10939
+ const tmp = `${path47}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10369
10940
  try {
10370
10941
  await writeFile5(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
10371
- await rename5(tmp, path45);
10942
+ await rename5(tmp, path47);
10372
10943
  } finally {
10373
10944
  await rm5(tmp, { force: true });
10374
10945
  }
@@ -12271,11 +12842,21 @@ function assetStyles(raw, asset) {
12271
12842
  const own = Array.isArray(asset.styles) ? asset.styles : [];
12272
12843
  return own.length ? own : Object.keys(raw.styles ?? {});
12273
12844
  }
12845
+ function declaredAssets(raw) {
12846
+ const expanded = expandLoopDirections(raw).raw;
12847
+ return new Set(Object.keys(expanded.assets ?? {}));
12848
+ }
12849
+ function undeclaredAsset(raw, assetId) {
12850
+ const shorthand = loopShorthandOf(assetId, expandLoopDirections(raw).families);
12851
+ return new ManifestEditError(
12852
+ shorthand ? `asset "${assetId}" is expanded from "${shorthand}"'s animation.directions; edit "${shorthand}" (it applies to every direction), or write this direction out as its own asset to change it alone` : `asset "${assetId}" is not declared by the manifest`
12853
+ );
12854
+ }
12274
12855
  function applyEdit(raw, edit) {
12275
12856
  raw.assets ??= {};
12276
12857
  if (edit.action === "set-source" || edit.action === "clear-source") {
12277
12858
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
12278
- if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
12859
+ if (!asset2) throw undeclaredAsset(raw, edit.assetId);
12279
12860
  if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
12280
12861
  throw new ManifestEditError(`unknown style "${edit.styleId}"`);
12281
12862
  }
@@ -12327,7 +12908,7 @@ function applyEdit(raw, edit) {
12327
12908
  return;
12328
12909
  }
12329
12910
  if (edit.action === "add-asset") {
12330
- if (Object.hasOwn(raw.assets, edit.assetId)) {
12911
+ if (declaredAssets(raw).has(edit.assetId)) {
12331
12912
  throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
12332
12913
  }
12333
12914
  const { styles, tags, ...rest } = edit.asset;
@@ -12341,20 +12922,20 @@ function applyEdit(raw, edit) {
12341
12922
  }
12342
12923
  asset2.styles = styles;
12343
12924
  }
12344
- if (edit.asset.revision && !Object.hasOwn(raw.assets, edit.asset.revision.from)) {
12925
+ if (edit.asset.revision && !declaredAssets(raw).has(edit.asset.revision.from)) {
12345
12926
  throw new ManifestEditError(`revision parent "${edit.asset.revision.from}" is not declared by the manifest`);
12346
12927
  }
12347
- if (edit.asset.state && !Object.hasOwn(raw.assets, edit.asset.state.of)) {
12928
+ if (edit.asset.state && !declaredAssets(raw).has(edit.asset.state.of)) {
12348
12929
  throw new ManifestEditError(`state parent "${edit.asset.state.of}" is not declared by the manifest`);
12349
12930
  }
12350
- if (edit.asset.animation && !Object.hasOwn(raw.assets, edit.asset.animation.of)) {
12931
+ if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
12351
12932
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
12352
12933
  }
12353
12934
  raw.assets[edit.assetId] = asset2;
12354
12935
  return;
12355
12936
  }
12356
12937
  const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
12357
- if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
12938
+ if (!asset) throw undeclaredAsset(raw, edit.assetId);
12358
12939
  const { patch } = edit;
12359
12940
  if (patch.prompt !== void 0) asset.prompt = patch.prompt;
12360
12941
  if (patch.promptForStyle) {
@@ -13001,6 +13582,7 @@ async function samePixels(a, b) {
13001
13582
  var metadataFps = (entry) => entry ? frameSetFps(entry) : null;
13002
13583
  function describeCharacter(spec, entry) {
13003
13584
  const recordedCharacter = entry?.providerMetadata?.[entry.provider]?.character;
13585
+ if (spec?.revision) return null;
13004
13586
  if (spec?.character) {
13005
13587
  const character = spec.character;
13006
13588
  return {
@@ -13141,6 +13723,7 @@ async function describeQuality(media, root, inspection) {
13141
13723
  }
13142
13724
  async function buildGallerySnapshot(opts) {
13143
13725
  const { loaded, specs, lock } = opts;
13726
+ const filter = opts.filter?.assets?.length ? { ...opts.filter, assets: expandAssetFilter(opts.filter.assets, loaded.loopFamilies) } : opts.filter;
13144
13727
  const root = loaded.root;
13145
13728
  const media = /* @__PURE__ */ new Map();
13146
13729
  const plan = await buildPlan(specs, lock);
@@ -13285,7 +13868,7 @@ async function buildGallerySnapshot(opts) {
13285
13868
  for (const [key, entry] of Object.entries(lock.entries)) {
13286
13869
  if (shown.has(key)) continue;
13287
13870
  if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
13288
- if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
13871
+ if (!matchesFilter(filter, entry.styleId, entry.assetId)) continue;
13289
13872
  const outputs = await Promise.all(
13290
13873
  entry.outputs.map(
13291
13874
  (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
@@ -13451,7 +14034,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
13451
14034
  try {
13452
14035
  const loaded = await loadManifest(manifestPath);
13453
14036
  const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
13454
- const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
14037
+ const assetFilter = expandAssetFilter(filter.assets, loaded.loopFamilies).filter((id) => loaded.manifest.assets[id]);
13455
14038
  const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
13456
14039
  const lock = await loadLock(lockPath);
13457
14040
  let projectItems = [];
@@ -17723,20 +18306,173 @@ function sheetFormat(args) {
17723
18306
  return format;
17724
18307
  }
17725
18308
 
17726
- // src/cli/commands/quality.ts
18309
+ // src/cli/commands/pixellab-utils.ts
17727
18310
  import path40 from "path";
17728
- import { readFile as readFile27 } from "fs/promises";
17729
18311
 
17730
- // src/pipeline/quality-regression.ts
17731
- import path39 from "path";
18312
+ // src/pixellab-utils.ts
17732
18313
  import { existsSync as existsSync29 } from "fs";
17733
- import { readFile as readFile26 } from "fs/promises";
18314
+ import { mkdir as mkdir11, readFile as readFile26, writeFile as writeFile14 } from "fs/promises";
18315
+ import path39 from "path";
18316
+ var UNZOOM_MIN_SIDE = 256;
18317
+ var UNZOOM_MAX_AREA = 2048 * 2048;
18318
+ function defaultUnzoomOut(input) {
18319
+ const ext = path39.extname(input);
18320
+ return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
18321
+ }
18322
+ async function unzoomFile(client2, input, opts = {}) {
18323
+ const out = path39.resolve(opts.out ?? defaultUnzoomOut(input));
18324
+ if (path39.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
18325
+ if (existsSync29(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
18326
+ const bytes = await readFile26(input);
18327
+ const metadata = imageMetadata(bytes);
18328
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
18329
+ const { width, height, format } = metadata;
18330
+ if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
18331
+ throw new Error(
18332
+ `${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`
18333
+ );
18334
+ }
18335
+ if (width * height > UNZOOM_MAX_AREA) {
18336
+ throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
18337
+ }
18338
+ const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
18339
+ await mkdir11(path39.dirname(out), { recursive: true });
18340
+ await writeFile14(out, res.png);
18341
+ return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
18342
+ }
18343
+ var FONT_WEIGHTS = ["Bold", "Regular"];
18344
+ var FONT_GLYPH_SIZES = [8, 16, 32, 64];
18345
+ var FONT_COST_GENERATIONS = 25;
18346
+ function fontOutputPaths(out) {
18347
+ const base = path39.resolve(out.replace(/\.(ttf|png)$/i, ""));
18348
+ return { ttf: `${base}.ttf`, atlas: `${base}.png` };
18349
+ }
18350
+ async function generateFont(client2, opts) {
18351
+ const { ttf, atlas } = fontOutputPaths(opts.out);
18352
+ if (!opts.force) {
18353
+ for (const file of [ttf, atlas]) {
18354
+ if (existsSync29(file)) throw new Error(`${file} already exists; pass --force to replace it`);
18355
+ }
18356
+ }
18357
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
18358
+ const interval = opts.intervalMs ?? 5e3;
18359
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
18360
+ const submitted = await client2.generateFontPro({
18361
+ description: opts.description,
18362
+ weight: opts.weight,
18363
+ glyphPx: opts.glyphPx,
18364
+ seed: opts.seed,
18365
+ fontName: opts.fontName
18366
+ });
18367
+ const jobId = submitted.background_job_id;
18368
+ const started = Date.now();
18369
+ for (; ; ) {
18370
+ const job = await client2.getFontJob(jobId);
18371
+ opts.onProgress?.(job.status);
18372
+ if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
18373
+ if (job.status === "completed") {
18374
+ if (!job.download_ttf_url || !job.download_atlas_url) {
18375
+ throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
18376
+ }
18377
+ const [ttfBytes, atlasBytes] = await Promise.all([
18378
+ client2.download(job.download_ttf_url),
18379
+ client2.download(job.download_atlas_url)
18380
+ ]);
18381
+ await mkdir11(path39.dirname(ttf), { recursive: true });
18382
+ await writeFile14(ttf, ttfBytes);
18383
+ await writeFile14(atlas, atlasBytes);
18384
+ return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
18385
+ }
18386
+ if (Date.now() - started > timeout) {
18387
+ throw new Error(
18388
+ `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
18389
+ );
18390
+ }
18391
+ await sleep3(interval);
18392
+ }
18393
+ }
18394
+
18395
+ // src/cli/commands/pixellab-utils.ts
18396
+ function loadKeys(args) {
18397
+ loadEnvFiles(path40.dirname(path40.resolve(args.manifest)));
18398
+ loadEnvFiles(process.cwd());
18399
+ }
18400
+ async function runUnzoom(args) {
18401
+ if (!args.from) throw new UsageError("unzoom needs --from <image>: the upscaled PNG or JPEG to shrink back to its native grid");
18402
+ const out = args.out ?? defaultUnzoomOut(args.from);
18403
+ if (args.dryRun) {
18404
+ log(` would unzoom ${args.from} \u2192 ${out}${args.quantize === void 0 ? "" : ` (quantize ${args.quantize})`}; nothing sent`);
18405
+ return;
18406
+ }
18407
+ loadKeys(args);
18408
+ const res = await unzoomFile(clientFromEnv(), args.from, { out, quantize: args.quantize, force: args.force });
18409
+ if (args.json) {
18410
+ log(JSON.stringify({ version: 1, ...res }, null, 2));
18411
+ return;
18412
+ }
18413
+ log(
18414
+ ` ${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}`
18415
+ );
18416
+ log(" the result is opaque: PixelLab composites transparency onto white first");
18417
+ }
18418
+ async function runFont(args) {
18419
+ if (!args.description) throw new UsageError('font needs --description "<style>", e.g. "warm orange arcade font"');
18420
+ if (!args.out) throw new UsageError("font needs --out <base>: <base>.ttf and <base>.png are written");
18421
+ const weight = args.weight ?? "Regular";
18422
+ if (!FONT_WEIGHTS.includes(weight)) {
18423
+ throw new UsageError(`--weight must be ${FONT_WEIGHTS.join(" or ")}, got "${args.weight}"`);
18424
+ }
18425
+ const glyphPx = args.glyphPx ?? 16;
18426
+ if (!FONT_GLYPH_SIZES.includes(glyphPx)) {
18427
+ throw new UsageError(`--glyph-px must be ${FONT_GLYPH_SIZES.join(", ")}, got "${args.glyphPx}"`);
18428
+ }
18429
+ const { ttf, atlas } = fontOutputPaths(args.out);
18430
+ log(` font: "${args.description}", ${weight}, ${glyphPx}px glyphs \u2192 ${ttf} + ${atlas}`);
18431
+ log(` cost: ${FONT_COST_GENERATIONS} generations (PixelLab's documented price)`);
18432
+ if (args.budget !== void 0 && args.budget < FONT_COST_GENERATIONS) {
18433
+ throw new Error(`--budget ${args.budget} is below this font's ${FONT_COST_GENERATIONS}-generation cost`);
18434
+ }
18435
+ if (args.dryRun) {
18436
+ log(" dry run: nothing sent");
18437
+ return;
18438
+ }
18439
+ if (!await confirm(` Spend ${FONT_COST_GENERATIONS} generations on this font?`, args.yes)) return;
18440
+ loadKeys(args);
18441
+ let last = "";
18442
+ const res = await generateFont(clientFromEnv(), {
18443
+ description: args.description,
18444
+ weight,
18445
+ glyphPx,
18446
+ fontName: args.name,
18447
+ out: args.out,
18448
+ force: args.force,
18449
+ onProgress: (status) => {
18450
+ if (status !== last) log(` ${status}\u2026`);
18451
+ last = status;
18452
+ }
18453
+ });
18454
+ if (args.json) {
18455
+ log(JSON.stringify({ version: 1, ...res }, null, 2));
18456
+ return;
18457
+ }
18458
+ log(` wrote ${res.ttf}`);
18459
+ log(` wrote ${res.atlas}`);
18460
+ }
18461
+
18462
+ // src/cli/commands/quality.ts
18463
+ import path42 from "path";
18464
+ import { readFile as readFile28 } from "fs/promises";
18465
+
18466
+ // src/pipeline/quality-regression.ts
18467
+ import path41 from "path";
18468
+ import { existsSync as existsSync30 } from "fs";
18469
+ import { readFile as readFile27 } from "fs/promises";
17734
18470
  import { z as z9 } from "zod";
17735
18471
  var SHA256_RE = /^[0-9a-f]{64}$/;
17736
18472
  var HEX_RE = /^#[0-9a-f]{6}$/;
17737
18473
  var MAX_REDMEAN_DISTANCE = 765;
17738
18474
  var QualityPathSchema = z9.string().min(1).refine(
17739
- (value) => !path39.posix.isAbsolute(value) && !value.includes("\\"),
18475
+ (value) => !path41.posix.isAbsolute(value) && !value.includes("\\"),
17740
18476
  "expected a portable relative path"
17741
18477
  );
17742
18478
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -17883,10 +18619,10 @@ function measureDecodedImageQuality(png, digest2) {
17883
18619
  };
17884
18620
  }
17885
18621
  async function measureImageQuality(file) {
17886
- const absolute = path39.resolve(file);
18622
+ const absolute = path41.resolve(file);
17887
18623
  let bytes;
17888
18624
  try {
17889
- bytes = await readFile26(absolute);
18625
+ bytes = await readFile27(absolute);
17890
18626
  } catch (error) {
17891
18627
  throw new Error(`Cannot read quality image ${absolute}: ${message4(error)}`, { cause: error });
17892
18628
  }
@@ -17905,11 +18641,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
17905
18641
  }
17906
18642
  const ids = parsed.data.map((entry) => entry.id);
17907
18643
  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));
18644
+ const root = path41.dirname(path41.resolve(inputsFilePath));
17909
18645
  return parsed.data.map((entry) => ({
17910
18646
  id: entry.id,
17911
- file: path39.resolve(root, entry.path),
17912
- ...entry.record ? { record: path39.resolve(root, entry.record) } : {},
18647
+ file: path41.resolve(root, entry.path),
18648
+ ...entry.record ? { record: path41.resolve(root, entry.record) } : {},
17913
18649
  tolerances: QualityToleranceSchema.parse({
17914
18650
  ...DEFAULT_QUALITY_TOLERANCES,
17915
18651
  ...entry.tolerances
@@ -17917,7 +18653,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
17917
18653
  }));
17918
18654
  }
17919
18655
  function portableRelative2(from, to) {
17920
- return path39.relative(from, path39.resolve(to)).split(path39.sep).join("/") || ".";
18656
+ return path41.relative(from, path41.resolve(to)).split(path41.sep).join("/") || ".";
17921
18657
  }
17922
18658
  async function verifyRecordForImage(recordPath, imagePath) {
17923
18659
  const verification = await verifyArtifactBundle(recordPath);
@@ -17932,20 +18668,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
17932
18668
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
17933
18669
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
17934
18670
  }
17935
- const outputs = record.outputs.map((output) => path39.resolve(path39.dirname(recordPath), output.path));
17936
- if (!outputs.includes(path39.resolve(imagePath))) {
18671
+ const outputs = record.outputs.map((output) => path41.resolve(path41.dirname(recordPath), output.path));
18672
+ if (!outputs.includes(path41.resolve(imagePath))) {
17937
18673
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
17938
18674
  }
17939
18675
  }
17940
18676
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
17941
18677
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
17942
- const absolute = path39.resolve(baselinePath);
18678
+ const absolute = path41.resolve(baselinePath);
17943
18679
  for (const input of inputs) {
17944
- if (path39.resolve(input.file) === absolute || input.record && path39.resolve(input.record) === absolute) {
18680
+ if (path41.resolve(input.file) === absolute || input.record && path41.resolve(input.record) === absolute) {
17945
18681
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
17946
18682
  }
17947
18683
  }
17948
- const root = path39.dirname(absolute);
18684
+ const root = path41.dirname(absolute);
17949
18685
  const cases = [];
17950
18686
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
17951
18687
  const expected = await measureImageQuality(input.file);
@@ -17972,8 +18708,8 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
17972
18708
  cases
17973
18709
  });
17974
18710
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
17975
- if (existsSync29(absolute) && !options.force) {
17976
- const current = await readFile26(absolute);
18711
+ if (existsSync30(absolute) && !options.force) {
18712
+ const current = await readFile27(absolute);
17977
18713
  if (!current.equals(data)) {
17978
18714
  throw new OverwriteRefusedError(
17979
18715
  `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
@@ -17985,10 +18721,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
17985
18721
  return { baseline, path: absolute, changed: result.changed.length > 0 };
17986
18722
  }
17987
18723
  async function readQualityBaseline(baselinePath) {
17988
- const absolute = path39.resolve(baselinePath);
18724
+ const absolute = path41.resolve(baselinePath);
17989
18725
  let raw;
17990
18726
  try {
17991
- raw = JSON.parse(await readFile26(absolute, "utf8"));
18727
+ raw = JSON.parse(await readFile27(absolute, "utf8"));
17992
18728
  } catch (error) {
17993
18729
  throw new Error(`Could not read quality baseline ${absolute}: ${message4(error)}`, { cause: error });
17994
18730
  }
@@ -18058,12 +18794,12 @@ function regressionViolations(expected, actual, tolerances) {
18058
18794
  return { violations, warnings };
18059
18795
  }
18060
18796
  async function checkQualityBaseline(baselinePath) {
18061
- const absolute = path39.resolve(baselinePath);
18797
+ const absolute = path41.resolve(baselinePath);
18062
18798
  const baseline = await readQualityBaseline(absolute);
18063
- const root = path39.dirname(absolute);
18799
+ const root = path41.dirname(absolute);
18064
18800
  const cases = [];
18065
18801
  for (const entry of baseline.cases) {
18066
- const file = path39.resolve(root, entry.file);
18802
+ const file = path41.resolve(root, entry.file);
18067
18803
  let actual = null;
18068
18804
  const violations = [];
18069
18805
  const warnings = [];
@@ -18076,7 +18812,7 @@ async function checkQualityBaseline(baselinePath) {
18076
18812
  violations.push(message4(error));
18077
18813
  }
18078
18814
  if (entry.record) {
18079
- const recordPath = path39.resolve(root, entry.record.path);
18815
+ const recordPath = path41.resolve(root, entry.record.path);
18080
18816
  try {
18081
18817
  const recordHash = await sha256File(recordPath);
18082
18818
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -18118,15 +18854,15 @@ async function runQuality(args) {
18118
18854
  if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
18119
18855
  let raw;
18120
18856
  try {
18121
- raw = JSON.parse(await readFile27(path40.resolve(args.inputs), "utf8"));
18857
+ raw = JSON.parse(await readFile28(path42.resolve(args.inputs), "utf8"));
18122
18858
  } catch (error) {
18123
18859
  throw new Error(
18124
- `Could not read quality inputs ${path40.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
18860
+ `Could not read quality inputs ${path42.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
18125
18861
  { cause: error }
18126
18862
  );
18127
18863
  }
18128
18864
  const inputs = resolveQualityInputs(raw, args.inputs);
18129
- if (path40.resolve(args.inputs) === path40.resolve(args.out)) {
18865
+ if (path42.resolve(args.inputs) === path42.resolve(args.out)) {
18130
18866
  throw new Error("quality snapshot --out must not overwrite its --inputs file.");
18131
18867
  }
18132
18868
  const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
@@ -18139,7 +18875,7 @@ async function runQuality(args) {
18139
18875
  }, null, 2));
18140
18876
  } else {
18141
18877
  log(
18142
- ` ${result.changed ? "wrote" : "unchanged"} ${path40.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
18878
+ ` ${result.changed ? "wrote" : "unchanged"} ${path42.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
18143
18879
  );
18144
18880
  log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
18145
18881
  }
@@ -18165,12 +18901,12 @@ async function runQuality(args) {
18165
18901
  }
18166
18902
 
18167
18903
  // src/cli/commands/recipe.ts
18168
- import path42 from "path";
18904
+ import path44 from "path";
18169
18905
 
18170
18906
  // 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";
18907
+ import path43 from "path";
18908
+ import { existsSync as existsSync31 } from "fs";
18909
+ import { readdir as readdir3, readFile as readFile29 } from "fs/promises";
18174
18910
  import { fileURLToPath } from "url";
18175
18911
  import { z as z10 } from "zod";
18176
18912
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
@@ -18179,7 +18915,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
18179
18915
  var RECIPE_FILE = "pixelkiln.recipe.json";
18180
18916
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
18181
18917
  var PortablePathSchema = z10.string().min(1).refine((value) => {
18182
- if (value.includes("\\") || path41.posix.isAbsolute(value)) return false;
18918
+ if (value.includes("\\") || path43.posix.isAbsolute(value)) return false;
18183
18919
  const parts = value.split("/");
18184
18920
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
18185
18921
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -18355,10 +19091,10 @@ function message5(error) {
18355
19091
  return error instanceof Error ? error.message : String(error);
18356
19092
  }
18357
19093
  async function readRecipeFile(recipePath, bundled) {
18358
- const absolute = path41.resolve(recipePath);
19094
+ const absolute = path43.resolve(recipePath);
18359
19095
  let raw;
18360
19096
  try {
18361
- raw = JSON.parse(await readFile28(absolute, "utf8"));
19097
+ raw = JSON.parse(await readFile29(absolute, "utf8"));
18362
19098
  } catch (error) {
18363
19099
  throw new Error(`Could not read recipe ${absolute}: ${message5(error)}`, { cause: error });
18364
19100
  }
@@ -18369,13 +19105,13 @@ async function readRecipeFile(recipePath, bundled) {
18369
19105
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
18370
19106
  );
18371
19107
  }
18372
- return { recipe: parsed.data, authored: raw, path: absolute, dir: path41.dirname(absolute), bundled };
19108
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: path43.dirname(absolute), bundled };
18373
19109
  }
18374
19110
  async function walkRecipeFiles(root) {
18375
- if (!existsSync30(root)) return [];
19111
+ if (!existsSync31(root)) return [];
18376
19112
  const out = [];
18377
19113
  for (const entry of await readdir3(root, { withFileTypes: true })) {
18378
- const resolved = path41.join(root, entry.name);
19114
+ const resolved = path43.join(root, entry.name);
18379
19115
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
18380
19116
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
18381
19117
  }
@@ -18386,7 +19122,7 @@ function bundledRecipeRoot() {
18386
19122
  }
18387
19123
  async function listBundledRecipes(root = bundledRecipeRoot()) {
18388
19124
  const recipes = await Promise.all(
18389
- (await walkRecipeFiles(path41.resolve(root))).map((file) => readRecipeFile(file, true))
19125
+ (await walkRecipeFiles(path43.resolve(root))).map((file) => readRecipeFile(file, true))
18390
19126
  );
18391
19127
  return recipes.sort(
18392
19128
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -18409,9 +19145,9 @@ function parseSelector(selector) {
18409
19145
  return { id: match[1], version: match[2] };
18410
19146
  }
18411
19147
  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);
19148
+ const local = path43.resolve(target);
19149
+ if (existsSync31(local)) {
19150
+ const recipePath = path43.basename(local) === RECIPE_FILE ? local : path43.join(local, RECIPE_FILE);
18415
19151
  return readRecipeFile(recipePath, false);
18416
19152
  }
18417
19153
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -18425,8 +19161,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
18425
19161
  return matches[0];
18426
19162
  }
18427
19163
  async function verifyFile(root, file) {
18428
- const absolute = path41.resolve(root, ...file.path.split("/"));
18429
- if (!existsSync30(absolute)) {
19164
+ const absolute = path43.resolve(root, ...file.path.split("/"));
19165
+ if (!existsSync31(absolute)) {
18430
19166
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
18431
19167
  }
18432
19168
  const actualSha256 = await sha256File(absolute);
@@ -18441,7 +19177,7 @@ async function verifyRecipe(target, options = {}) {
18441
19177
  const loaded = await resolveRecipe(target, options.bundledRoot);
18442
19178
  const actualIntegrity = recipeDigest(loaded.authored);
18443
19179
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
18444
- const modelRoot = options.modelRoot ? path41.resolve(options.modelRoot) : null;
19180
+ const modelRoot = options.modelRoot ? path43.resolve(options.modelRoot) : null;
18445
19181
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
18446
19182
  ...await verifyFile(modelRoot, model),
18447
19183
  source: model.source,
@@ -18475,7 +19211,7 @@ async function verifyRecipe(target, options = {}) {
18475
19211
  };
18476
19212
  }
18477
19213
  function renderStyle(style, destination, cwd) {
18478
- const portableDir = path41.relative(cwd, destination).split(path41.sep).join("/") || ".";
19214
+ const portableDir = path43.relative(cwd, destination).split(path43.sep).join("/") || ".";
18479
19215
  const replace = (value) => {
18480
19216
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
18481
19217
  if (Array.isArray(value)) return value.map(replace);
@@ -18487,25 +19223,25 @@ function renderStyle(style, destination, cwd) {
18487
19223
  return replace(style);
18488
19224
  }
18489
19225
  async function installRecipe(target, options = {}) {
18490
- const cwd = path41.resolve(options.cwd ?? process.cwd());
19226
+ const cwd = path43.resolve(options.cwd ?? process.cwd());
18491
19227
  const loaded = await resolveRecipe(target, options.bundledRoot);
18492
19228
  const verification = await verifyRecipe(loaded.path);
18493
19229
  if (!verification.ok) {
18494
19230
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
18495
19231
  }
18496
- const destination = options.out ? path41.resolve(cwd, options.out) : path41.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
19232
+ const destination = options.out ? path43.resolve(cwd, options.out) : path43.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
18497
19233
  const sources = [
18498
19234
  { path: RECIPE_FILE, source: loaded.path },
18499
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path41.join(loaded.dir, ...file.path.split("/")) }))
19235
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path43.join(loaded.dir, ...file.path.split("/")) }))
18500
19236
  ];
18501
19237
  const files = await Promise.all(sources.map(async (file) => ({
18502
- path: path41.join(destination, ...file.path.split("/")),
18503
- data: await readFile28(file.source)
19238
+ path: path43.join(destination, ...file.path.split("/")),
19239
+ data: await readFile29(file.source)
18504
19240
  })));
18505
19241
  if (!options.force) {
18506
19242
  for (const file of files) {
18507
- if (!existsSync30(file.path)) continue;
18508
- const current = await readFile28(file.path);
19243
+ if (!existsSync31(file.path)) continue;
19244
+ const current = await readFile29(file.path);
18509
19245
  if (!current.equals(file.data)) {
18510
19246
  throw new OverwriteRefusedError(
18511
19247
  `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
@@ -18599,14 +19335,14 @@ async function runRecipe(args) {
18599
19335
  }, null, 2));
18600
19336
  } else {
18601
19337
  log(` installed ${result.recipe.id}@${result.recipe.version}`);
18602
- log(` ${path42.relative(process.cwd(), result.destination) || "."}`);
19338
+ log(` ${path44.relative(process.cwd(), result.destination) || "."}`);
18603
19339
  log(`
18604
19340
  Add this entry under your manifest's styles object:`);
18605
19341
  log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
18606
19342
  if (result.recipe.models.length) {
18607
19343
  log(`
18608
19344
  Models are not downloaded automatically. Verify them with:`);
18609
- log(` pixelkiln recipe verify ${path42.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
19345
+ log(` pixelkiln recipe verify ${path44.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
18610
19346
  }
18611
19347
  }
18612
19348
  return;
@@ -18614,7 +19350,7 @@ async function runRecipe(args) {
18614
19350
  }
18615
19351
 
18616
19352
  // src/cli/commands/refine.ts
18617
- import path43 from "path";
19353
+ import path45 from "path";
18618
19354
  async function runRefine(args) {
18619
19355
  if (args.from || args.subcommand === "approve") {
18620
19356
  if (!args.from) {
@@ -18644,12 +19380,12 @@ async function runRefine(args) {
18644
19380
  ` recovered ${result3.detection.columns}x${result3.detection.rows} native grid (${result3.detection.confidence}: ${result3.detection.consensus})`
18645
19381
  );
18646
19382
  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)}`);
19383
+ log(` wrote ${path45.relative(process.cwd(), result3.output)}`);
19384
+ log(` quality record: ${path45.relative(process.cwd(), result3.record)}`);
18649
19385
  log(`
18650
19386
  Automated checks passed. Human 1\xD7 review is still required:`);
18651
19387
  log(
18652
- ` pixelkiln refine approve --from ${path43.relative(process.cwd(), result3.record)} --reviewer "Your Name"`
19388
+ ` pixelkiln refine approve --from ${path45.relative(process.cwd(), result3.record)} --reviewer "Your Name"`
18653
19389
  );
18654
19390
  }
18655
19391
  return;
@@ -18677,7 +19413,7 @@ async function runRefine(args) {
18677
19413
  if (args.json) log(JSON.stringify(result3, null, 2));
18678
19414
  else {
18679
19415
  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()}`
19416
+ 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
19417
  );
18682
19418
  }
18683
19419
  return;
@@ -18686,7 +19422,7 @@ async function runRefine(args) {
18686
19422
  if (args.json) {
18687
19423
  log(JSON.stringify(result2, null, 2));
18688
19424
  } else {
18689
- log(` ${result2.safe ? "release-ready" : "not release-ready"}: ${path43.relative(process.cwd(), result2.output || result2.record)}`);
19425
+ log(` ${result2.safe ? "release-ready" : "not release-ready"}: ${path45.relative(process.cwd(), result2.output || result2.record)}`);
18690
19426
  for (const reason of result2.reasons) log(` ${reason}`);
18691
19427
  }
18692
19428
  if (!result2.safe) process.exitCode = 1;
@@ -18732,7 +19468,7 @@ async function runRefine(args) {
18732
19468
  log(` ${item.state.padEnd(16)} ${item.key} ${item.reason}`);
18733
19469
  if (item.state === "needs-approval") {
18734
19470
  log(
18735
- ` pixelkiln refine approve --from ${path43.relative(process.cwd(), item.record)} --reviewer "Your Name"`
19471
+ ` pixelkiln refine approve --from ${path45.relative(process.cwd(), item.record)} --reviewer "Your Name"`
18736
19472
  );
18737
19473
  }
18738
19474
  }
@@ -18784,15 +19520,15 @@ async function runTools(args) {
18784
19520
  }
18785
19521
 
18786
19522
  // src/cli/commands/workspace.ts
18787
- import path44 from "path";
18788
- import { existsSync as existsSync31 } from "fs";
19523
+ import path46 from "path";
19524
+ import { existsSync as existsSync32 } from "fs";
18789
19525
  async function runWorkspace(args) {
18790
- const workspacePath = path44.resolve(args.workspace ?? "pixelkiln.workspace.json");
18791
- const dir = path44.dirname(workspacePath);
19526
+ const workspacePath = path46.resolve(args.workspace ?? "pixelkiln.workspace.json");
19527
+ const dir = path46.dirname(workspacePath);
18792
19528
  if (args.subcommand === "add") {
18793
- const manifestPath = path44.resolve(args.target);
19529
+ const manifestPath = path46.resolve(args.target);
18794
19530
  const loadedTarget = await loadManifest(manifestPath);
18795
- const lockPath = args.explicitLock ? path44.resolve(args.explicitLock) : path44.join(path44.dirname(manifestPath), "pixelkiln.lock.json");
19531
+ const lockPath = args.explicitLock ? path46.resolve(args.explicitLock) : path46.join(path46.dirname(manifestPath), "pixelkiln.lock.json");
18796
19532
  const ws = await loadWorkspace(workspacePath);
18797
19533
  const id = args.name ?? loadedTarget.manifest.name;
18798
19534
  if (ws.projects.some((p) => p.id === id)) {
@@ -18812,10 +19548,10 @@ async function runWorkspace(args) {
18812
19548
  ...args.account ? { account: args.account } : {}
18813
19549
  };
18814
19550
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
18815
- log(` registered "${id}" in ${path44.relative(process.cwd(), workspacePath)}`);
19551
+ log(` registered "${id}" in ${path46.relative(process.cwd(), workspacePath)}`);
18816
19552
  log(` manifest: ${project.manifest}`);
18817
19553
  log(` lock: ${project.lock}`);
18818
- if (!existsSync31(lockPath)) {
19554
+ if (!existsSync32(lockPath)) {
18819
19555
  log(
18820
19556
  ` warning: no lockfile there yet; this project contributes no claims until one is generated`
18821
19557
  );
@@ -18824,7 +19560,7 @@ async function runWorkspace(args) {
18824
19560
  }
18825
19561
  if (args.subcommand === "remove") {
18826
19562
  const ws = await loadWorkspace(workspacePath);
18827
- const resolvedTarget = path44.resolve(args.target);
19563
+ const resolvedTarget = path46.resolve(args.target);
18828
19564
  const match = ws.projects.find(
18829
19565
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
18830
19566
  );
@@ -18835,11 +19571,11 @@ async function runWorkspace(args) {
18835
19571
  version: 1,
18836
19572
  projects: ws.projects.filter((p) => p !== match)
18837
19573
  });
18838
- log(` removed "${match.id}" from ${path44.relative(process.cwd(), workspacePath)}`);
19574
+ log(` removed "${match.id}" from ${path46.relative(process.cwd(), workspacePath)}`);
18839
19575
  return;
18840
19576
  }
18841
19577
  if (args.subcommand === "list") {
18842
- if (!existsSync31(workspacePath)) {
19578
+ if (!existsSync32(workspacePath)) {
18843
19579
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
18844
19580
  }
18845
19581
  const ws = await loadWorkspace(workspacePath);
@@ -18847,9 +19583,9 @@ async function runWorkspace(args) {
18847
19583
  if (args.json) {
18848
19584
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
18849
19585
  } else if (!ws.projects.length) {
18850
- log(` no projects registered in ${path44.relative(process.cwd(), workspacePath)}`);
19586
+ log(` no projects registered in ${path46.relative(process.cwd(), workspacePath)}`);
18851
19587
  } else {
18852
- log(` ${ws.projects.length} project(s) in ${path44.relative(process.cwd(), workspacePath)}:`);
19588
+ log(` ${ws.projects.length} project(s) in ${path46.relative(process.cwd(), workspacePath)}:`);
18853
19589
  for (const p of ws.projects) {
18854
19590
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
18855
19591
  }
@@ -18859,7 +19595,7 @@ async function runWorkspace(args) {
18859
19595
  return;
18860
19596
  }
18861
19597
  if (args.subcommand === "status") {
18862
- if (!existsSync31(workspacePath)) {
19598
+ if (!existsSync32(workspacePath)) {
18863
19599
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
18864
19600
  }
18865
19601
  const ws = await loadWorkspace(workspacePath);
@@ -18867,7 +19603,7 @@ async function runWorkspace(args) {
18867
19603
  if (args.json) {
18868
19604
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
18869
19605
  } else {
18870
- log(` workspace: ${path44.relative(process.cwd(), workspacePath)}`);
19606
+ log(` workspace: ${path46.relative(process.cwd(), workspacePath)}`);
18871
19607
  for (const p of report.projects) {
18872
19608
  if (p.error) {
18873
19609
  log(`
@@ -18921,8 +19657,8 @@ async function runHelp() {
18921
19657
  async function runVersion() {
18922
19658
  for (const up of ["../package.json", "../../package.json"]) {
18923
19659
  const url = new URL(up, import.meta.url);
18924
- if (!existsSync32(url)) continue;
18925
- const pkg = JSON.parse(await readFile29(url, "utf8"));
19660
+ if (!existsSync33(url)) continue;
19661
+ const pkg = JSON.parse(await readFile30(url, "utf8"));
18926
19662
  if (pkg.name === "pixelkiln") {
18927
19663
  log(`${pkg.name} ${pkg.version}`);
18928
19664
  return;
@@ -18965,7 +19701,9 @@ var RUNNERS = {
18965
19701
  quality: runQuality,
18966
19702
  refine: runRefine,
18967
19703
  recipe: runRecipe,
18968
- workspace: runWorkspace
19704
+ workspace: runWorkspace,
19705
+ unzoom: runUnzoom,
19706
+ font: runFont
18969
19707
  };
18970
19708
  async function main(argv = process.argv.slice(2)) {
18971
19709
  const args = parseArgs(argv);