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/index.cjs CHANGED
@@ -52,6 +52,10 @@ __export(index_exports, {
52
52
  EXIT_CODES: () => EXIT_CODES,
53
53
  EditorInstallError: () => EditorInstallError,
54
54
  FAKE_PNG: () => FAKE_PNG,
55
+ FONT_COST_GENERATIONS: () => FONT_COST_GENERATIONS,
56
+ FONT_GLYPH_SIZES: () => FONT_GLYPH_SIZES,
57
+ FONT_WEIGHTS: () => FONT_WEIGHTS,
58
+ FRAME_SET_REVISION_MODES: () => FRAME_SET_REVISION_MODES,
55
59
  FakeProvider: () => FakeProvider,
56
60
  GenerateRequestError: () => GenerateRequestError,
57
61
  GenerateRequestSchema: () => GenerateRequestSchema,
@@ -68,6 +72,7 @@ __export(index_exports, {
68
72
  MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
69
73
  MAX_HAND_EDIT_BYTES: () => MAX_HAND_EDIT_BYTES,
70
74
  MAX_RETRIES: () => MAX_RETRIES,
75
+ MEMBER_SET_REVISION_MODES: () => MEMBER_SET_REVISION_MODES,
71
76
  MIRRORED_DIRECTION: () => MIRRORED_DIRECTION,
72
77
  ManifestDriftError: () => ManifestDriftError,
73
78
  ManifestEditError: () => ManifestEditError,
@@ -97,6 +102,8 @@ __export(index_exports, {
97
102
  ScenarioProvider: () => ScenarioProvider,
98
103
  StyleInputSchema: () => StyleInputSchema,
99
104
  StyleSchema: () => StyleSchema,
105
+ UNZOOM_MAX_AREA: () => UNZOOM_MAX_AREA,
106
+ UNZOOM_MIN_SIDE: () => UNZOOM_MIN_SIDE,
100
107
  UiElementSchema: () => UiElementSchema,
101
108
  UiPieceSchema: () => UiPieceSchema,
102
109
  UnsupportedCapabilityError: () => UnsupportedCapabilityError,
@@ -131,6 +138,7 @@ __export(index_exports, {
131
138
  currentEntryOutputPath: () => currentEntryOutputPath,
132
139
  currentOutputPath: () => currentOutputPath,
133
140
  defaultOpenCommand: () => defaultOpenCommand,
141
+ defaultUnzoomOut: () => defaultUnzoomOut,
134
142
  detachHandEdit: () => detachHandEdit,
135
143
  detectMediaType: () => detectMediaType,
136
144
  doctor: () => doctor,
@@ -148,10 +156,12 @@ __export(index_exports, {
148
156
  fetchAssets: () => fetchAssets,
149
157
  fetchWithRetry: () => fetchWithRetry,
150
158
  findOrphans: () => findOrphans,
159
+ fontOutputPaths: () => fontOutputPaths,
151
160
  formatCost: () => formatCost,
152
161
  frameSetFps: () => frameSetFps,
153
162
  galleryMediaId: () => galleryMediaId,
154
163
  galleryMediaRoute: () => galleryMediaRoute,
164
+ generateFont: () => generateFont,
155
165
  generationCost: () => generationCost,
156
166
  groupOrphansByStyle: () => groupOrphansByStyle,
157
167
  handEditBase: () => handEditBase,
@@ -287,6 +297,7 @@ __export(index_exports, {
287
297
  tilesCost: () => tilesCost,
288
298
  toPortablePath: () => toPortablePath,
289
299
  totalSpend: () => totalSpend,
300
+ unzoomFile: () => unzoomFile,
290
301
  upsert: () => upsert,
291
302
  validateCostEstimate: () => validateCostEstimate,
292
303
  validateMedia: () => validateMedia,
@@ -308,7 +319,7 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
308
319
  // src/types.ts
309
320
  var import_zod = require("zod");
310
321
  var MediaTypeSchema = import_zod.z.enum(["image/png", "image/gif"]);
311
- var GeneratorSchema = import_zod.z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset"]);
322
+ var GeneratorSchema = import_zod.z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset", "uiElement"]);
312
323
  var GridConfidenceSchema = import_zod.z.enum(["low", "medium", "high"]);
313
324
  var RevisionModeSchema = import_zod.z.enum([
314
325
  "image-to-image",
@@ -317,7 +328,9 @@ var RevisionModeSchema = import_zod.z.enum([
317
328
  "reduce-colors",
318
329
  "correct-pixelart",
319
330
  "animate",
320
- "animate-pixminimax"
331
+ "animate-pixminimax",
332
+ "interpolate",
333
+ "edit-animation"
321
334
  ]);
322
335
  function tileVariationCount(descriptions) {
323
336
  return Math.max(1, descriptions) * 4;
@@ -498,6 +511,14 @@ var CharacterAnimationSchema = import_zod.z.object({
498
511
  template: import_zod.z.string().min(1).optional(),
499
512
  /** Direction to animate. */
500
513
  direction: CharacterDirectionSchema.default("south"),
514
+ /**
515
+ * Shorthand for one loop in several directions, instead of `direction`.
516
+ * Expanded when the manifest loads into one loop per named direction
517
+ * (`<id>.<direction>`) plus a mirror for each direction whose flip is
518
+ * not named, exactly as if each had been written out. See
519
+ * `src/loop-directions.ts`.
520
+ */
521
+ directions: import_zod.z.array(CharacterDirectionSchema).min(1).optional(),
501
522
  /** Frames to generate without a template (v3). Even, 4 to 16. */
502
523
  frames: import_zod.z.number().int().min(4).max(16).optional(),
503
524
  /** Playback rate recorded with the frames; PixelLab does not store one. */
@@ -526,6 +547,13 @@ var CharacterAnimationSchema = import_zod.z.object({
526
547
  /** v3 text loops only: let PixelLab expand the action into a fuller motion description first. */
527
548
  enhancePrompt: import_zod.z.boolean().optional()
528
549
  }).strict().superRefine((animation, context) => {
550
+ if (animation.directions !== void 0) {
551
+ context.addIssue({
552
+ code: import_zod.z.ZodIssueCode.custom,
553
+ message: "directions is expanded when the manifest loads; validate the loaded manifest, not the raw file",
554
+ path: ["directions"]
555
+ });
556
+ }
529
557
  if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
530
558
  context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
531
559
  }
@@ -586,9 +614,13 @@ var RevisionSchema = import_zod.z.object({
586
614
  * the provider layer, since it differs by mode).
587
615
  */
588
616
  frames: import_zod.z.number().int().min(4).max(40).optional(),
589
- /** `animate`/`animate-pixminimax` only: playback rate recorded with the frames; PixelLab does not store one. */
617
+ /** `animate`/`animate-pixminimax`/`interpolate`/`edit-animation` only: playback rate recorded with the frames; PixelLab does not store one. */
590
618
  fps: import_zod.z.number().int().min(1).max(60).optional(),
591
- /** `animate`/`animate-pixminimax` only: manifest-relative image pinning where the motion ends (interpolation instead of open-ended animation). */
619
+ /**
620
+ * `animate`/`animate-pixminimax`: manifest-relative image pinning where
621
+ * the motion ends (interpolation instead of open-ended animation).
622
+ * `interpolate`: required, the ending keyframe; the parent is the start.
623
+ */
592
624
  lastFrame: import_zod.z.string().min(1).optional(),
593
625
  /** `animate-pixminimax` only: facing direction, used only alongside `enhancePrompt` to hold the sprite's facing. */
594
626
  direction: CharacterDirectionSchema.optional(),
@@ -632,14 +664,15 @@ var RevisionSchema = import_zod.z.object({
632
664
  path: ["strength"]
633
665
  });
634
666
  }
635
- if (revision.strength !== void 0 && (revision.mode === "animate" || revision.mode === "animate-pixminimax")) {
667
+ const generative = revision.mode === "animate" || revision.mode === "animate-pixminimax" || revision.mode === "interpolate" || revision.mode === "edit-animation";
668
+ if (revision.strength !== void 0 && generative) {
636
669
  context.addIssue({
637
670
  code: import_zod.z.ZodIssueCode.custom,
638
- message: `${revision.mode} revisions do not take a strength; use enhancePrompt`,
671
+ 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`,
639
672
  path: ["strength"]
640
673
  });
641
674
  }
642
- for (const field of ["frames", "fps", "lastFrame", "enhancePrompt"]) {
675
+ for (const field of ["frames", "enhancePrompt"]) {
643
676
  if (revision[field] !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax") {
644
677
  context.addIssue({
645
678
  code: import_zod.z.ZodIssueCode.custom,
@@ -648,6 +681,27 @@ var RevisionSchema = import_zod.z.object({
648
681
  });
649
682
  }
650
683
  }
684
+ if (revision.lastFrame !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax" && revision.mode !== "interpolate") {
685
+ context.addIssue({
686
+ code: import_zod.z.ZodIssueCode.custom,
687
+ message: "lastFrame applies to animate/animate-pixminimax/interpolate revisions only",
688
+ path: ["lastFrame"]
689
+ });
690
+ }
691
+ if (revision.mode === "interpolate" && revision.lastFrame === void 0) {
692
+ context.addIssue({
693
+ code: import_zod.z.ZodIssueCode.custom,
694
+ message: "interpolate revisions require lastFrame: the ending keyframe to interpolate toward",
695
+ path: ["lastFrame"]
696
+ });
697
+ }
698
+ if (revision.fps !== void 0 && !generative) {
699
+ context.addIssue({
700
+ code: import_zod.z.ZodIssueCode.custom,
701
+ message: "fps applies to animate/animate-pixminimax/interpolate/edit-animation revisions only",
702
+ path: ["fps"]
703
+ });
704
+ }
651
705
  if (revision.direction !== void 0 && revision.mode !== "animate-pixminimax") {
652
706
  context.addIssue({
653
707
  code: import_zod.z.ZodIssueCode.custom,
@@ -659,6 +713,8 @@ var RevisionSchema = import_zod.z.object({
659
713
  context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
660
714
  }
661
715
  });
716
+ var FRAME_SET_REVISION_MODES = ["animate", "animate-pixminimax", "interpolate", "edit-animation"];
717
+ var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation"];
662
718
  var StyleObjectSchema = import_zod.z.object({
663
719
  /** Generation backend for this style. Omit to inherit the manifest default. */
664
720
  provider: import_zod.z.string().min(1).optional(),
@@ -777,7 +833,7 @@ var StyleObjectSchema = import_zod.z.object({
777
833
  */
778
834
  terrainMode: import_zod.z.enum(["standard", "pro"]).optional(),
779
835
  /**
780
- * `uiAsset` generator only. A natural-language palette hint sent as-is
836
+ * `uiAsset` and `uiElement` generators only. A natural-language palette hint sent as-is
781
837
  * (e.g. "brown and gold"), distinct from `palette`'s hex-color array —
782
838
  * this is a prompt-level steer, not a local quantization target.
783
839
  */
@@ -1655,6 +1711,21 @@ var CorrectPixelartResponseSchema = import_zod2.z.object({
1655
1711
  images: import_zod2.z.array(import_zod2.z.object({ base64: import_zod2.z.string().min(1) }).passthrough()).min(1),
1656
1712
  usage: import_zod2.z.unknown().optional()
1657
1713
  }).passthrough();
1714
+ var UnzoomResponseSchema = import_zod2.z.object({
1715
+ image: import_zod2.z.object({ base64: import_zod2.z.string().min(1) }).passthrough(),
1716
+ original_size: import_zod2.z.object({ width: import_zod2.z.number().int(), height: import_zod2.z.number().int() }).passthrough(),
1717
+ unzoomed_size: import_zod2.z.object({ width: import_zod2.z.number().int(), height: import_zod2.z.number().int() }).passthrough(),
1718
+ zoom_factor_detected: import_zod2.z.number(),
1719
+ usage: import_zod2.z.unknown().optional()
1720
+ }).passthrough();
1721
+ var FontJobSchema = import_zod2.z.object({
1722
+ job_id: import_zod2.z.string().min(1),
1723
+ status: import_zod2.z.string(),
1724
+ glyph_px: import_zod2.z.number().int().nullable().optional(),
1725
+ download_atlas_url: import_zod2.z.string().nullable().optional(),
1726
+ download_ttf_url: import_zod2.z.string().nullable().optional(),
1727
+ usage: import_zod2.z.unknown().optional()
1728
+ }).passthrough();
1658
1729
  var SelectFramesSchema = import_zod2.z.object({ created_object_ids: import_zod2.z.array(import_zod2.z.string()) }).passthrough();
1659
1730
  var CharacterSubmitSchema = import_zod2.z.object({
1660
1731
  background_job_id: import_zod2.z.string().min(1),
@@ -1739,6 +1810,17 @@ function validateResponse(schema, raw, operation) {
1739
1810
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
1740
1811
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
1741
1812
  }
1813
+ function cleanupImages(args, operation) {
1814
+ const images = args.images ?? (args.image ? [args.image] : []);
1815
+ if (!images.length) throw new Error(`${operation} needs at least one image`);
1816
+ return images;
1817
+ }
1818
+ function cleanupResults(images, sent, operation) {
1819
+ if (images.length !== sent) {
1820
+ throw new Error(`Invalid PixelLab response for ${operation}: sent ${sent} image(s), got ${images.length} back`);
1821
+ }
1822
+ return images.map((image) => Buffer.from(image.base64, "base64"));
1823
+ }
1742
1824
  var PixelLabError = class extends ProviderError {
1743
1825
  constructor(message6, status, body) {
1744
1826
  super("pixellab", message6, { status });
@@ -1767,9 +1849,9 @@ var PixelLabClient = class {
1767
1849
  apiKey;
1768
1850
  http;
1769
1851
  /** Retries transport failures, 429, and 5xx; see http.ts for the policy. */
1770
- async request(path33, init) {
1852
+ async request(path34, init) {
1771
1853
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1772
- const res = await this.http(`${BASE}${path33}`, {
1854
+ const res = await this.http(`${BASE}${path34}`, {
1773
1855
  ...init,
1774
1856
  headers: {
1775
1857
  Authorization: auth,
@@ -1779,13 +1861,13 @@ var PixelLabClient = class {
1779
1861
  });
1780
1862
  const text = await res.text();
1781
1863
  if (!res.ok) {
1782
- throw new PixelLabError(`${init?.method ?? "GET"} ${path33} \u2192 ${res.status}`, res.status, text);
1864
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path34} \u2192 ${res.status}`, res.status, text);
1783
1865
  }
1784
1866
  if (!text) return {};
1785
1867
  try {
1786
1868
  return JSON.parse(text);
1787
1869
  } catch {
1788
- throw new Error(`${init?.method ?? "GET"} ${path33} returned invalid JSON`);
1870
+ throw new Error(`${init?.method ?? "GET"} ${path34} returned invalid JSON`);
1789
1871
  }
1790
1872
  }
1791
1873
  async balance() {
@@ -2171,10 +2253,10 @@ var PixelLabClient = class {
2171
2253
  const palette = args.paletteSwatchBase64 ? { color_image: { type: "base64", base64: args.paletteSwatchBase64, format: "png" }, force_colors: true } : {};
2172
2254
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
2173
2255
  const south = args.reference?.south;
2174
- let path33;
2256
+ let path34;
2175
2257
  let body;
2176
2258
  if (args.mode === "v3") {
2177
- path33 = "/create-character-v3";
2259
+ path34 = "/create-character-v3";
2178
2260
  body = {
2179
2261
  description: args.description,
2180
2262
  image_size: imageSize,
@@ -2188,7 +2270,7 @@ var PixelLabClient = class {
2188
2270
  ...args.enhancePrompt ? { enhance_prompt: true } : {}
2189
2271
  };
2190
2272
  } else if (args.mode === "pro-flash") {
2191
- path33 = "/create-character-pro-flash";
2273
+ path34 = "/create-character-pro-flash";
2192
2274
  const traits = args.styleTraits;
2193
2275
  body = {
2194
2276
  description: args.description,
@@ -2211,7 +2293,7 @@ var PixelLabClient = class {
2211
2293
  } : {}
2212
2294
  };
2213
2295
  } else if (args.mode === "pro") {
2214
- path33 = "/create-character-pro";
2296
+ path34 = "/create-character-pro";
2215
2297
  body = {
2216
2298
  description: args.description,
2217
2299
  image_size: imageSize,
@@ -2225,7 +2307,7 @@ var PixelLabClient = class {
2225
2307
  ...!south && args.styleCharacterId ? { style_character_id: args.styleCharacterId } : {}
2226
2308
  };
2227
2309
  } else {
2228
- path33 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
2310
+ path34 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
2229
2311
  body = {
2230
2312
  description: args.description,
2231
2313
  image_size: imageSize,
@@ -2242,8 +2324,8 @@ var PixelLabClient = class {
2242
2324
  ...palette
2243
2325
  };
2244
2326
  }
2245
- const raw = await this.request(path33, { method: "POST", body: JSON.stringify(body) });
2246
- return validateResponse(CharacterSubmitSchema, raw, path33.slice(1));
2327
+ const raw = await this.request(path34, { method: "POST", body: JSON.stringify(body) });
2328
+ return validateResponse(CharacterSubmitSchema, raw, path34.slice(1));
2247
2329
  }
2248
2330
  /** A text edit of an existing character, applied to every direction and stored as a sibling. */
2249
2331
  async createCharacterState(args) {
@@ -2405,20 +2487,52 @@ var PixelLabClient = class {
2405
2487
  );
2406
2488
  }
2407
2489
  /**
2408
- * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image onto a
2409
- * smaller palette, synchronously — no `background_job_id`, the result
2410
- * comes back in this same response, like `createImagePixflux`. The
2411
- * schema's own response example is dollar-denominated (`usage: {type:
2412
- * "usd", usd: 0.02}`), which — matching this codebase's repeated
2413
- * experience with PixelLab's documented-vs-billed cost mismatches
2414
- * (`isometricTile`, `objectPro`) — did not hold: confirmed live against a
2415
- * Tier 2 account at exactly 0.1 generations for a 32x32 source
2416
- * (docs/REVISIONS.md). `numColors` and `paletteImage` are mutually
2417
- * exclusive upstream; the manifest schema already enforces that before
2418
- * this is ever called.
2490
+ * `/generate-ui-v2` ("Generate UI (Pro)"): one UI element (a button, a
2491
+ * health bar, an inventory slot, a dialogue box) from a description,
2492
+ * guided by an optional concept image and a natural-language color
2493
+ * palette. A plain background job; unlike `/create-ui-asset` there is no
2494
+ * piece/element layout, and sizes start at 16px instead of 192. Its
2495
+ * completed shape has not been exercised live; `pollUiElement` reads it
2496
+ * defensively.
2497
+ */
2498
+ async generateUiV2(args) {
2499
+ const body = {
2500
+ description: args.description,
2501
+ image_size: { width: args.width, height: args.height }
2502
+ };
2503
+ if (args.conceptImage) {
2504
+ body.concept_image = {
2505
+ image: args.conceptImage.image,
2506
+ size: { width: args.conceptImage.width, height: args.conceptImage.height }
2507
+ };
2508
+ }
2509
+ if (args.colorPalette) body.color_palette = args.colorPalette;
2510
+ if (args.noBackground != null) body.no_background = args.noBackground;
2511
+ if (args.seed != null) body.seed = args.seed;
2512
+ return validateResponse(
2513
+ RevisionJobSubmitSchema,
2514
+ await this.request("/generate-ui-v2", { method: "POST", body: JSON.stringify(body) }),
2515
+ "generate-ui-v2"
2516
+ );
2517
+ }
2518
+ /**
2519
+ * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image, or
2520
+ * several same-size frames together onto ONE shared palette, synchronously
2521
+ * — no `background_job_id`, the result comes back in this same response,
2522
+ * like `createImagePixflux`. Passing every frame of an animation (or every
2523
+ * direction of a character) in one call is the endpoint's whole point:
2524
+ * frames quantized separately drift onto different palettes. The schema's
2525
+ * own response example is dollar-denominated (`usage: {type: "usd", usd:
2526
+ * 0.02}`), which — matching this codebase's repeated experience with
2527
+ * PixelLab's documented-vs-billed cost mismatches (`isometricTile`,
2528
+ * `objectPro`) — did not hold: confirmed live against a Tier 2 account at
2529
+ * exactly 0.1 generations for a 32x32 source (docs/REVISIONS.md).
2530
+ * `numColors` and `paletteImage` are mutually exclusive upstream; the
2531
+ * manifest schema already enforces that before this is ever called.
2419
2532
  */
2420
2533
  async reduceColors(args) {
2421
- const body = { images: [args.image] };
2534
+ const images = cleanupImages(args, "reduce-colors");
2535
+ const body = { images };
2422
2536
  if (args.numColors != null) body.num_colors = args.numColors;
2423
2537
  if (args.paletteImage) body.palette_image = args.paletteImage;
2424
2538
  if (args.dithering) body.dithering = args.dithering;
@@ -2428,8 +2542,10 @@ var PixelLabClient = class {
2428
2542
  await this.request("/reduce-colors", { method: "POST", body: JSON.stringify(body) }),
2429
2543
  "reduce-colors"
2430
2544
  );
2545
+ const pngs = cleanupResults(res.images, images.length, "reduce-colors");
2431
2546
  return {
2432
- png: Buffer.from(res.images[0].base64, "base64"),
2547
+ png: pngs[0],
2548
+ pngs,
2433
2549
  paletteStripPng: Buffer.from(res.palette.base64, "base64"),
2434
2550
  nColors: res.n_colors,
2435
2551
  usage: res.usage
@@ -2438,19 +2554,117 @@ var PixelLabClient = class {
2438
2554
  /**
2439
2555
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
2440
2556
  * stray pixels without resizing, synchronously — same shape as
2441
- * `reduceColors` above, no background job. Cost is likewise confirmed
2442
- * live at 0.1 generations for a 32x32 source, not the schema's own
2557
+ * `reduceColors` above, no background job, and likewise corrects several
2558
+ * same-size frames together when given `images`. Cost is confirmed live
2559
+ * at 0.1 generations for a 32x32 source, not the schema's own
2443
2560
  * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
2444
2561
  */
2445
2562
  async correctPixelart(args) {
2446
- const body = { images: [args.image] };
2563
+ const images = cleanupImages(args, "correct-pixelart");
2564
+ const body = { images };
2447
2565
  if (args.strength != null) body.strength = args.strength;
2448
2566
  const res = validateResponse(
2449
2567
  CorrectPixelartResponseSchema,
2450
2568
  await this.request("/correct-pixelart", { method: "POST", body: JSON.stringify(body) }),
2451
2569
  "correct-pixelart"
2452
2570
  );
2453
- return { png: Buffer.from(res.images[0].base64, "base64"), usage: res.usage };
2571
+ const pngs = cleanupResults(res.images, images.length, "correct-pixelart");
2572
+ return { png: pngs[0], pngs, usage: res.usage };
2573
+ }
2574
+ /**
2575
+ * `/unzoom`: recover native-resolution pixel art from an upscaled image (a
2576
+ * 32x32 sprite saved at 512x512). Synchronous like the Cleanup tier.
2577
+ * PixelLab's own API overview names this "the most common cause of
2578
+ * disappointing output": run it on outside artwork before it becomes a
2579
+ * style or reference image. The input must be at least 256x256 and at
2580
+ * most 2048x2048 in area, and the result is opaque — transparency is
2581
+ * composited onto white before the grid is detected. `quantize` is 0 to
2582
+ * auto-detect a palette, -1 to keep every color, or 2-256 for exactly
2583
+ * that many.
2584
+ */
2585
+ async unzoom(args) {
2586
+ const body = { image: args.image };
2587
+ if (args.quantize != null) body.quantize = args.quantize;
2588
+ const res = validateResponse(
2589
+ UnzoomResponseSchema,
2590
+ await this.request("/unzoom", { method: "POST", body: JSON.stringify(body) }),
2591
+ "unzoom"
2592
+ );
2593
+ return {
2594
+ png: Buffer.from(res.image.base64, "base64"),
2595
+ originalSize: { width: res.original_size.width, height: res.original_size.height },
2596
+ unzoomedSize: { width: res.unzoomed_size.width, height: res.unzoomed_size.height },
2597
+ zoomFactor: res.zoom_factor_detected,
2598
+ usage: res.usage
2599
+ };
2600
+ }
2601
+ /**
2602
+ * `/interpolation-v2` ("Interpolate (Pro)"): generate the in-between
2603
+ * frames from one keyframe to another, guided by a short `action`. Unlike
2604
+ * `last_frame` on `/animate-with-text-v3`, both ends are required and the
2605
+ * frame count is the model's own choice (its docs say "typically 4-8").
2606
+ * Output frames are 16 to 128 pixels per side. A plain background job;
2607
+ * its completed shape is read defensively by `pollAnimateRevision`.
2608
+ */
2609
+ async interpolationV2(args) {
2610
+ const size = { width: args.width, height: args.height };
2611
+ const body = {
2612
+ start_image: { image: args.startImage, size },
2613
+ end_image: { image: args.endImage, size },
2614
+ action: args.action,
2615
+ image_size: size
2616
+ };
2617
+ if (args.seed != null) body.seed = args.seed;
2618
+ if (args.noBackground != null) body.no_background = args.noBackground;
2619
+ return validateResponse(
2620
+ RevisionJobSubmitSchema,
2621
+ await this.request("/interpolation-v2", { method: "POST", body: JSON.stringify(body) }),
2622
+ "interpolation-v2"
2623
+ );
2624
+ }
2625
+ /**
2626
+ * `/edit-animation-v2` ("Edit animation (Pro)"): apply one text edit
2627
+ * across every frame of an existing animation at once, so the change
2628
+ * stays consistent frame to frame. 2 to 16 frames, but the real ceiling
2629
+ * shrinks with frame size because every frame is packed into one grid:
2630
+ * 16 at up to 64px, 9 at 65-80px, 4 at 81-256px.
2631
+ */
2632
+ async editAnimationV2(args) {
2633
+ const size = { width: args.width, height: args.height };
2634
+ const body = {
2635
+ description: args.description,
2636
+ frames: args.frames.map((image) => ({ image, size })),
2637
+ image_size: size
2638
+ };
2639
+ if (args.seed != null) body.seed = args.seed;
2640
+ if (args.noBackground != null) body.no_background = args.noBackground;
2641
+ return validateResponse(
2642
+ RevisionJobSubmitSchema,
2643
+ await this.request("/edit-animation-v2", { method: "POST", body: JSON.stringify(body) }),
2644
+ "edit-animation-v2"
2645
+ );
2646
+ }
2647
+ /**
2648
+ * `/generate-font-pro`: an 80-glyph pixel font (A-Z, a-z, 0-9, common
2649
+ * game-UI punctuation) from a style description, delivered as a glyph
2650
+ * atlas PNG plus a ready-to-use `.ttf`. Documented at 25 subscription
2651
+ * generations. Poll with `getFontJob`.
2652
+ */
2653
+ async generateFontPro(args) {
2654
+ const body = { description: args.description, weight: args.weight };
2655
+ if (args.glyphPx != null) body.glyph_px = args.glyphPx;
2656
+ if (args.seed != null) body.seed = args.seed;
2657
+ if (args.fontName) body.font_name = args.fontName;
2658
+ return validateResponse(
2659
+ RevisionJobSubmitSchema,
2660
+ await this.request("/generate-font-pro", { method: "POST", body: JSON.stringify(body) }),
2661
+ "generate-font-pro"
2662
+ );
2663
+ }
2664
+ /** `GET /generate-font-pro/{job_id}`: status, then no-auth atlas and `.ttf` URLs once completed. */
2665
+ async getFontJob(jobId) {
2666
+ const raw = await this.request(`/generate-font-pro/${encodeURIComponent(jobId)}`);
2667
+ return validateResponse(FontJobSchema, raw, "generate-font-pro/{job_id}");
2454
2668
  }
2455
2669
  /**
2456
2670
  * `/animate-with-text-v3`: animate a loose image from a text description,
@@ -3111,10 +3325,10 @@ var import_node_fs = require("fs");
3111
3325
  function sha256(data) {
3112
3326
  return (0, import_node_crypto.createHash)("sha256").update(data).digest("hex");
3113
3327
  }
3114
- async function sha256File(path33) {
3328
+ async function sha256File(path34) {
3115
3329
  return new Promise((resolve, reject) => {
3116
3330
  const hash = (0, import_node_crypto.createHash)("sha256");
3117
- const stream = (0, import_node_fs.createReadStream)(path33);
3331
+ const stream = (0, import_node_fs.createReadStream)(path34);
3118
3332
  stream.on("data", (chunk2) => hash.update(chunk2));
3119
3333
  stream.on("error", reject);
3120
3334
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -3142,7 +3356,7 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3142
3356
  // tile fields are undefined for every other generator. `tileSize` is
3143
3357
  // intentionally absent; width/height are derived from it, so it is
3144
3358
  // already covered.
3145
- noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
3359
+ noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.generator === "uiElement" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
3146
3360
  tileType: spec.tileType,
3147
3361
  tileView: spec.tileView,
3148
3362
  tileFeature: spec.tileFeature,
@@ -3349,7 +3563,7 @@ var PixelLabProvider = class _PixelLabProvider {
3349
3563
  return new _PixelLabProvider(new PixelLabClient("download-only"));
3350
3564
  }
3351
3565
  supports(generator) {
3352
- return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset";
3566
+ return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset" || generator === "uiElement";
3353
3567
  }
3354
3568
  /**
3355
3569
  * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
@@ -3359,12 +3573,15 @@ var PixelLabProvider = class _PixelLabProvider {
3359
3573
  * rather than a described edit — and `animate`/`animate-pixminimax`
3360
3574
  * (`/animate-with-text-v3`, `/animate-pixminimax`), which animate any loose
3361
3575
  * image from a text description with no character/object resource
3362
- * required. `outpaint` does not: there is no canvas-expansion endpoint in
3363
- * the API, matching docs/REVISIONS.md's note that no provider ships a
3364
- * tested outpaint path yet.
3576
+ * required — and `interpolate` (`/interpolation-v2`, the in-betweens
3577
+ * from one keyframe to another) and `edit-animation`
3578
+ * (`/edit-animation-v2`, one text edit applied across a whole frame set).
3579
+ * `outpaint` does not: there is no canvas-expansion endpoint in the API,
3580
+ * matching docs/REVISIONS.md's note that no provider ships a tested
3581
+ * outpaint path yet.
3365
3582
  */
3366
3583
  supportsRevision(mode2) {
3367
- return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax";
3584
+ return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax" || mode2 === "interpolate" || mode2 === "edit-animation";
3368
3585
  }
3369
3586
  /** PixelLab's own constraints: submissions must be >2s apart, and
3370
3587
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -3392,6 +3609,12 @@ var PixelLabProvider = class _PixelLabProvider {
3392
3609
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
3393
3610
  return { unit: "generations", amount: 0.1, candidates: 1 };
3394
3611
  }
3612
+ if (spec.revision?.mode === "interpolate" || spec.revision?.mode === "edit-animation") {
3613
+ const width = spec.revision.sourceWidth ?? spec.width;
3614
+ const height = spec.revision.sourceHeight ?? spec.height;
3615
+ const side = spec.revision.mode === "edit-animation" ? Math.ceil(Math.sqrt(Math.max(1, spec.revision.sourceMembers?.length ?? 1))) : 1;
3616
+ return { unit: "generations", amount: generationCost(width * side, height * side, "1dir"), candidates: 1 };
3617
+ }
3395
3618
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
3396
3619
  const width = spec.revision.sourceWidth ?? spec.width;
3397
3620
  const height = spec.revision.sourceHeight ?? spec.height;
@@ -3420,6 +3643,12 @@ var PixelLabProvider = class _PixelLabProvider {
3420
3643
  };
3421
3644
  }
3422
3645
  validate(spec, styleImages) {
3646
+ const members = spec.revision?.sourceMembers;
3647
+ if (spec.revision && members && !MEMBER_SET_REVISION_MODES.includes(spec.revision.mode)) {
3648
+ throw new Error(
3649
+ `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`
3650
+ );
3651
+ }
3423
3652
  if (spec.revision?.mode === "inpaint") {
3424
3653
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
3425
3654
  if (width != null && height != null && (width < 32 || height < 32 || width > 512 || height > 512)) {
@@ -3428,9 +3657,10 @@ var PixelLabProvider = class _PixelLabProvider {
3428
3657
  }
3429
3658
  if (spec.revision?.mode === "reduce-colors") {
3430
3659
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
3431
- if (width != null && height != null && width * height > 512 * 512) {
3660
+ const count = members?.length ?? 1;
3661
+ if (width != null && height != null && width * height * count > 512 * 512) {
3432
3662
  throw new Error(
3433
- `PixelLab reduce-colors source is ${width}x${height} (${width * height}px\xB2); the API takes at most 512x512 worth of pixels per call`
3663
+ `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`
3434
3664
  );
3435
3665
  }
3436
3666
  }
@@ -3454,6 +3684,36 @@ var PixelLabProvider = class _PixelLabProvider {
3454
3684
  );
3455
3685
  }
3456
3686
  }
3687
+ if (spec.revision?.mode === "interpolate") {
3688
+ const { sourceWidth: width, sourceHeight: height, lastFrameWidth, lastFrameHeight } = spec.revision;
3689
+ if (width != null && height != null && (width < 16 || height < 16 || width > 128 || height > 128)) {
3690
+ throw new Error(`PixelLab interpolate keyframes are ${width}x${height}; the API takes 16 to 128 pixels per side`);
3691
+ }
3692
+ if (lastFrameWidth != null && lastFrameHeight != null && width != null && height != null && (lastFrameWidth !== width || lastFrameHeight !== height)) {
3693
+ throw new Error(
3694
+ `PixelLab interpolate lastFrame is ${lastFrameWidth}x${lastFrameHeight}; the start keyframe is ${width}x${height} \u2014 they must match`
3695
+ );
3696
+ }
3697
+ }
3698
+ if (spec.revision?.mode === "edit-animation") {
3699
+ const { sourceWidth: width, sourceHeight: height, sourceAssetId } = spec.revision;
3700
+ if (width != null && height != null) {
3701
+ if (!members || members.length < 2) {
3702
+ throw new Error(
3703
+ `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)`
3704
+ );
3705
+ }
3706
+ if (width < 16 || height < 16 || width > 256 || height > 256) {
3707
+ throw new Error(`PixelLab edit-animation frames are ${width}x${height}; the API takes 16 to 256 pixels per side`);
3708
+ }
3709
+ const ceiling = editAnimationFrameCeiling(width, height);
3710
+ if (members.length > ceiling) {
3711
+ throw new Error(
3712
+ `PixelLab edit-animation takes at most ${ceiling} frames at ${width}x${height} (every frame is packed into one grid); ${sourceAssetId} has ${members.length}`
3713
+ );
3714
+ }
3715
+ }
3716
+ }
3457
3717
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
3458
3718
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
3459
3719
  }
@@ -3470,6 +3730,14 @@ var PixelLabProvider = class _PixelLabProvider {
3470
3730
  `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)`
3471
3731
  );
3472
3732
  }
3733
+ if (spec.generator === "uiElement" && (spec.width < 16 || spec.height < 16 || spec.width > 792 || spec.height > 688)) {
3734
+ throw new Error(
3735
+ `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)`
3736
+ );
3737
+ }
3738
+ if (spec.generator === "uiElement" && styleImages.length > 1) {
3739
+ throw new Error("PixelLab uiElement takes one concept image; list at most one styleImages entry");
3740
+ }
3473
3741
  if (spec.generator === "map") {
3474
3742
  requirePixelLabOption("map", "view", spec.view, ["low top-down", "high top-down", "side"]);
3475
3743
  requirePixelLabOption("map", "outline", spec.outline, [
@@ -3553,8 +3821,8 @@ var PixelLabProvider = class _PixelLabProvider {
3553
3821
  throw new Error(`PixelLab isometricTile: size must be 16 to 64 pixels, got ${spec.width}`);
3554
3822
  }
3555
3823
  }
3556
- if (spec.generator === "character") this.validateCharacter(spec, styleImages);
3557
- if (spec.generator === "objectPro") this.validateObjectPro(spec, styleImages);
3824
+ if (spec.generator === "character" && !spec.revision) this.validateCharacter(spec, styleImages);
3825
+ if (spec.generator === "objectPro" && !spec.revision) this.validateObjectPro(spec, styleImages);
3558
3826
  if ((spec.generator === "map" || spec.generator === "pixflux") && styleImages.length) {
3559
3827
  throw new Error(`PixelLab ${spec.generator} does not support style images`);
3560
3828
  }
@@ -4121,6 +4389,19 @@ var PixelLabProvider = class _PixelLabProvider {
4121
4389
  });
4122
4390
  return { jobId: res2.ui_asset_id, metadata: { backgroundJobId: res2.background_job_id } };
4123
4391
  }
4392
+ if (spec.generator === "uiElement") {
4393
+ const concept = styleImages[0];
4394
+ const res2 = await this.client.generateUiV2({
4395
+ description: spec.prompt,
4396
+ width: spec.width,
4397
+ height: spec.height,
4398
+ conceptImage: concept ? { image: { base64: concept.base64, format: concept.format }, width: concept.width, height: concept.height } : void 0,
4399
+ colorPalette: spec.uiColorPalette,
4400
+ noBackground: spec.noBackground,
4401
+ seed: spec.seed
4402
+ });
4403
+ return { jobId: res2.background_job_id };
4404
+ }
4124
4405
  if (spec.generator === "pixflux") {
4125
4406
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
4126
4407
  const { png } = await this.client.createImagePixflux({
@@ -4249,13 +4530,18 @@ var PixelLabProvider = class _PixelLabProvider {
4249
4530
  if (!revision.sourceSha256 || !revision.sourceFormat || revision.sourceWidth == null || revision.sourceHeight == null) {
4250
4531
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
4251
4532
  }
4252
- const sourceBytes = (0, import_node_fs2.readFileSync)(revision.sourceFile);
4253
- if (sha256(sourceBytes) !== revision.sourceSha256) {
4254
- throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
4255
- }
4256
- const image = { base64: sourceBytes.toString("base64"), format: revision.sourceFormat };
4533
+ const sourceFiles = revision.sourceMembers ?? [{ file: revision.sourceFile, sha256: revision.sourceSha256, format: revision.sourceFormat }];
4534
+ const images = sourceFiles.map((member) => {
4535
+ const bytes = (0, import_node_fs2.readFileSync)(member.file);
4536
+ if (sha256(bytes) !== member.sha256) {
4537
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
4538
+ }
4539
+ return { base64: bytes.toString("base64"), format: member.format };
4540
+ });
4541
+ const image = images[0];
4257
4542
  const width = revision.sourceWidth;
4258
4543
  const height = revision.sourceHeight;
4544
+ const roles = revision.sourceMembers?.map((member) => member.role);
4259
4545
  if (revision.mode === "inpaint") {
4260
4546
  if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
4261
4547
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
@@ -4288,34 +4574,45 @@ var PixelLabProvider = class _PixelLabProvider {
4288
4574
  paletteImage = { base64: paletteBytes.toString("base64"), format: revision.paletteImageFormat };
4289
4575
  }
4290
4576
  const res2 = await this.client.reduceColors({
4291
- image,
4577
+ images,
4292
4578
  numColors: revision.numColors,
4293
4579
  paletteImage,
4294
4580
  dithering: revision.dithering,
4295
4581
  ditheringStrength: revision.ditheringStrength
4296
4582
  });
4297
- const jobId = (0, import_node_crypto2.randomUUID)();
4298
- (0, import_node_fs2.writeFileSync)(import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
4299
- return { jobId, metadata: { revisionUsage: res2.usage } };
4583
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
4300
4584
  }
4301
4585
  if (revision.mode === "correct-pixelart") {
4302
- const res2 = await this.client.correctPixelart({ image, strength: revision.strength });
4303
- const jobId = (0, import_node_crypto2.randomUUID)();
4304
- (0, import_node_fs2.writeFileSync)(import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
4305
- return { jobId, metadata: { revisionUsage: res2.usage } };
4586
+ const res2 = await this.client.correctPixelart({ images, strength: revision.strength });
4587
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
4588
+ }
4589
+ if (revision.mode === "interpolate") {
4590
+ const lastFrame = this.readLastFrame(spec);
4591
+ if (!lastFrame) throw new Error(`${spec.styleId}/${spec.assetId}: interpolate needs lastFrame, the ending keyframe`);
4592
+ const res2 = await this.client.interpolationV2({
4593
+ startImage: image,
4594
+ endImage: lastFrame,
4595
+ width,
4596
+ height,
4597
+ action: spec.prompt,
4598
+ seed: spec.seed,
4599
+ noBackground: spec.noBackground
4600
+ });
4601
+ return { jobId: res2.background_job_id };
4602
+ }
4603
+ if (revision.mode === "edit-animation") {
4604
+ const res2 = await this.client.editAnimationV2({
4605
+ frames: images,
4606
+ width,
4607
+ height,
4608
+ description: spec.prompt,
4609
+ seed: spec.seed,
4610
+ noBackground: spec.noBackground
4611
+ });
4612
+ return { jobId: res2.background_job_id };
4306
4613
  }
4307
4614
  if (revision.mode === "animate" || revision.mode === "animate-pixminimax") {
4308
- let lastFrame;
4309
- if (revision.lastFrameFile) {
4310
- if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
4311
- throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
4312
- }
4313
- const lastFrameBytes = (0, import_node_fs2.readFileSync)(revision.lastFrameFile);
4314
- if (sha256(lastFrameBytes) !== revision.lastFrameSha256) {
4315
- throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
4316
- }
4317
- lastFrame = { base64: lastFrameBytes.toString("base64"), format: revision.lastFrameFormat };
4318
- }
4615
+ const lastFrame = this.readLastFrame(spec);
4319
4616
  if (revision.mode === "animate") {
4320
4617
  const res3 = await this.client.animateWithTextV3({
4321
4618
  firstFrame: image,
@@ -4355,6 +4652,36 @@ var PixelLabProvider = class _PixelLabProvider {
4355
4652
  });
4356
4653
  return { jobId: res.background_job_id };
4357
4654
  }
4655
+ /** A revision's pinned ending frame, proven to be the bytes the spec hashed; undefined when none is declared. */
4656
+ readLastFrame(spec) {
4657
+ const revision = spec.revision;
4658
+ if (!revision.lastFrameFile) return void 0;
4659
+ if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
4660
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
4661
+ }
4662
+ const bytes = (0, import_node_fs2.readFileSync)(revision.lastFrameFile);
4663
+ if (sha256(bytes) !== revision.lastFrameSha256) {
4664
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
4665
+ }
4666
+ return { base64: bytes.toString("base64"), format: revision.lastFrameFormat };
4667
+ }
4668
+ /**
4669
+ * Park a synchronous Cleanup-tier result the way pixflux parks its own:
4670
+ * one `<id>.png` for a single image, or `<id>-<n>.png` per member with the
4671
+ * member roles recorded so `pollCachedRevision` hands them back under the
4672
+ * same names the parent's set used.
4673
+ */
4674
+ cacheCleanupResult(pngs, usage, roles, fps) {
4675
+ const jobId = (0, import_node_crypto2.randomUUID)();
4676
+ if (!roles) {
4677
+ (0, import_node_fs2.writeFileSync)(import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
4678
+ return { jobId, metadata: { revisionUsage: usage } };
4679
+ }
4680
+ for (const [index, png] of pngs.entries()) {
4681
+ (0, import_node_fs2.writeFileSync)(import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
4682
+ }
4683
+ return { jobId, metadata: { revisionUsage: usage, revisionMembers: roles, ...fps != null ? { revisionFps: fps } : {} } };
4684
+ }
4358
4685
  /**
4359
4686
  * What a background job actually billed, once its own record is still
4360
4687
  * around to ask; see `billedFromUsage`. A stale or already-cleaned-up job
@@ -4375,7 +4702,7 @@ var PixelLabProvider = class _PixelLabProvider {
4375
4702
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart") {
4376
4703
  return this.pollCachedRevision(jobId, context);
4377
4704
  }
4378
- if (revisionMode === "animate" || revisionMode === "animate-pixminimax") {
4705
+ if (revisionMode === "animate" || revisionMode === "animate-pixminimax" || revisionMode === "interpolate" || revisionMode === "edit-animation") {
4379
4706
  return this.pollAnimateRevision(jobId, context);
4380
4707
  }
4381
4708
  if (context?.spec?.revision) return this.pollRevision(jobId);
@@ -4398,6 +4725,7 @@ var PixelLabProvider = class _PixelLabProvider {
4398
4725
  if (generator === "character") return this.pollCharacter(jobId, context);
4399
4726
  if (generator === "objectPro") return this.pollObjectPro(jobId, context);
4400
4727
  if (generator === "uiAsset") return this.pollUiAsset(jobId, context);
4728
+ if (generator === "uiElement") return this.pollUiElement(jobId);
4401
4729
  const backgroundJobId = context?.metadata?.backgroundJobId;
4402
4730
  const obj = await this.client.getObject(jobId);
4403
4731
  if (obj.status === "review") {
@@ -4424,15 +4752,32 @@ var PixelLabProvider = class _PixelLabProvider {
4424
4752
  * work.
4425
4753
  */
4426
4754
  async pollCachedRevision(jobId, context) {
4427
- const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
4428
- if (!(0, import_node_fs2.existsSync)(file)) {
4755
+ const usage = context?.metadata?.revisionUsage;
4756
+ const roles = context?.metadata?.revisionMembers;
4757
+ const gone = {
4758
+ status: "failed",
4759
+ error: "revision result is no longer cached locally; re-run submit for this asset"
4760
+ };
4761
+ if (Array.isArray(roles) && roles.length) {
4762
+ const sources = roles.map((role, index) => ({
4763
+ url: `file://${import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
4764
+ role: String(role)
4765
+ }));
4766
+ if (sources.some((source) => !(0, import_node_fs2.existsSync)(source.url.slice("file://".length)))) return gone;
4767
+ const fps = context?.metadata?.revisionFps;
4768
+ const frames = sources.every((source) => source.role.startsWith("frame-"));
4429
4769
  return {
4430
- status: "failed",
4431
- error: "revision result is no longer cached locally; re-run submit for this asset"
4770
+ status: "ready",
4771
+ objectId: jobId,
4772
+ sourceUrl: sources[0].url,
4773
+ sources,
4774
+ ...frames && typeof fps === "number" ? { metadata: { frameSet: { fps, count: sources.length } } } : {},
4775
+ billed: billedFromUsage(usage)
4432
4776
  };
4433
4777
  }
4778
+ const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
4779
+ if (!(0, import_node_fs2.existsSync)(file)) return gone;
4434
4780
  const sourceUrl = `file://${file}`;
4435
- const usage = context?.metadata?.revisionUsage;
4436
4781
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
4437
4782
  }
4438
4783
  /**
@@ -4456,16 +4801,21 @@ var PixelLabProvider = class _PixelLabProvider {
4456
4801
  if (job.status !== "completed") return { status: "processing" };
4457
4802
  const billed = billedFromUsage(job.usage);
4458
4803
  const done = job.last_response ?? {};
4459
- const fps = context?.spec?.revision?.fps ?? 8;
4460
- const review = (frameUrls) => ({
4461
- status: "review-set",
4462
- objectId: jobId,
4463
- frameUrls,
4464
- sources: frameUrls.map((url, index) => ({ url, role: `frame-${String(index).padStart(2, "0")}` })),
4465
- fps,
4466
- metadata: { frameSet: { fps, count: frameUrls.length } },
4467
- billed
4468
- });
4804
+ const revision = context?.spec?.revision;
4805
+ const fps = revision?.fps ?? revision?.sourceFps ?? 8;
4806
+ const memberRoles = revision?.mode === "edit-animation" ? revision.sourceMembers?.map((member) => member.role) : void 0;
4807
+ const review = (frameUrls) => {
4808
+ const roles = memberRoles?.length === frameUrls.length ? memberRoles : frameUrls.map((_, index) => `frame-${String(index).padStart(2, "0")}`);
4809
+ return {
4810
+ status: "review-set",
4811
+ objectId: jobId,
4812
+ frameUrls,
4813
+ sources: frameUrls.map((url, index) => ({ url, role: roles[index] })),
4814
+ fps,
4815
+ metadata: roles.every((role) => role.startsWith("frame-")) ? { frameSet: { fps, count: frameUrls.length } } : {},
4816
+ billed
4817
+ };
4818
+ };
4469
4819
  for (const key of ["frame_urls", "frames", "images"]) {
4470
4820
  const list = done[key];
4471
4821
  if (!Array.isArray(list) || !list.length) continue;
@@ -4497,7 +4847,7 @@ var PixelLabProvider = class _PixelLabProvider {
4497
4847
  }
4498
4848
  return {
4499
4849
  status: "failed",
4500
- 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`
4850
+ 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`
4501
4851
  };
4502
4852
  }
4503
4853
  /**
@@ -4587,6 +4937,36 @@ var PixelLabProvider = class _PixelLabProvider {
4587
4937
  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`
4588
4938
  };
4589
4939
  }
4940
+ /**
4941
+ * `/generate-ui-v2` completes a plain background job whose
4942
+ * `last_response.images` (per its own docs and Python client) holds the
4943
+ * result. One image is the expected case and goes straight to selected;
4944
+ * should the endpoint ever return several, they go to review like
4945
+ * imagePro's candidates, decoded to the same local cache. The shape has
4946
+ * not been observed live, so anything else fails naming the keys received.
4947
+ */
4948
+ async pollUiElement(jobId) {
4949
+ const job = await this.client.getBackgroundJob(jobId);
4950
+ if (job.status === "failed") return { status: "failed", error: "UI element job failed upstream" };
4951
+ if (job.status !== "completed") return { status: "processing" };
4952
+ const billed = billedFromUsage(job.usage);
4953
+ const done = job.last_response ?? {};
4954
+ const images = Array.isArray(done.images) ? done.images : done.image ? [done.image] : [];
4955
+ const urls = [];
4956
+ for (const [index, candidate] of images.entries()) {
4957
+ const base64 = extractBase64(candidate);
4958
+ if (!base64) continue;
4959
+ const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
4960
+ (0, import_node_fs2.writeFileSync)(file, Buffer.from(base64, "base64"));
4961
+ urls.push(`file://${file}`);
4962
+ }
4963
+ if (urls.length === 1) return { status: "ready", objectId: jobId, sourceUrl: urls[0], sources: [{ url: urls[0] }], billed };
4964
+ if (urls.length > 1) return { status: "review", candidateUrls: urls, billed };
4965
+ return {
4966
+ status: "failed",
4967
+ 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`
4968
+ };
4969
+ }
4590
4970
  /**
4591
4971
  * Map objects need their own path because the `/map-objects/{id}` record is
4592
4972
  * deleted upstream roughly 8 hours after creation while the image survives in
@@ -4747,9 +5127,9 @@ var PixelLabProvider = class _PixelLabProvider {
4747
5127
  if (!url) throw new Error(`tiles job ${jobId} has no variation at index ${index}`);
4748
5128
  return { objectId: `${jobId}#${index}`, sourceUrl: url };
4749
5129
  }
4750
- if (generator === "imagePro") {
5130
+ if (generator === "imagePro" || generator === "uiElement") {
4751
5131
  const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
4752
- if (!(0, import_node_fs2.existsSync)(file)) throw new Error(`imagePro job ${jobId} has no cached candidate at index ${index}`);
5132
+ if (!(0, import_node_fs2.existsSync)(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
4753
5133
  return { objectId: `${jobId}#${index}`, sourceUrl: `file://${file}` };
4754
5134
  }
4755
5135
  const promoted = await this.client.selectFrames(jobId, [index], commonTag);
@@ -4903,6 +5283,12 @@ function readPose(spec, what, image) {
4903
5283
  }
4904
5284
  return { base64: bytes.toString("base64"), format: image.format };
4905
5285
  }
5286
+ function editAnimationFrameCeiling(width, height) {
5287
+ const side = Math.max(width, height);
5288
+ if (side <= 64) return 16;
5289
+ if (side <= 80) return 9;
5290
+ return 4;
5291
+ }
4906
5292
  function rotationSources(character) {
4907
5293
  const order = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
4908
5294
  const sources = [];
@@ -4995,9 +5381,9 @@ var RetroDiffusionClient = class {
4995
5381
  if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
4996
5382
  return Buffer.from(await response.arrayBuffer());
4997
5383
  }
4998
- async call(path33, init = {}) {
5384
+ async call(path34, init = {}) {
4999
5385
  if (!this.token) throw new Error("RD_API_KEY is not set");
5000
- const response = await this.request(`${this.baseUrl}${path33}`, {
5386
+ const response = await this.request(`${this.baseUrl}${path34}`, {
5001
5387
  ...init,
5002
5388
  headers: {
5003
5389
  "Content-Type": "application/json",
@@ -6039,14 +6425,15 @@ var ComfyUIProvider = class _ComfyUIProvider {
6039
6425
  }
6040
6426
  /**
6041
6427
  * Everything else resolves generically to whatever the user's own
6042
- * workflow does with `bindings.sourceImage`. `animate`/`animate-pixminimax`
6043
- * are the one exception: they produce an ordered frame set, and this
6044
- * adapter's revision path always writes a single output image (see
6045
- * `submit`/`fetch` below) — a real structural gap, not a missing binding,
6046
- * so it is rejected here rather than only failing once `validate` runs.
6428
+ * workflow does with `bindings.sourceImage`. The frame-set modes
6429
+ * (`animate`, `animate-pixminimax`, `interpolate`, `edit-animation`) are
6430
+ * the exception: they produce an ordered frame set, and this adapter's
6431
+ * revision path always writes a single output image (see `submit`/`fetch`
6432
+ * below) — a real structural gap, not a missing binding, so they are
6433
+ * rejected here rather than only failing once `validate` runs.
6047
6434
  */
6048
6435
  supportsRevision(mode2) {
6049
- return mode2 !== "animate" && mode2 !== "animate-pixminimax";
6436
+ return !FRAME_SET_REVISION_MODES.includes(mode2);
6050
6437
  }
6051
6438
  estimate(spec) {
6052
6439
  return {
@@ -6099,6 +6486,11 @@ var ComfyUIProvider = class _ComfyUIProvider {
6099
6486
  throw new Error("ComfyUI providerOptions.frames requires generator: frames");
6100
6487
  }
6101
6488
  if (spec.revision) {
6489
+ if (spec.revision.sourceMembers) {
6490
+ throw new Error(
6491
+ `ComfyUI revisions read one source image; ${spec.revision.sourceAssetId} is a ${spec.revision.sourceMembers.length}-member set (directions or frames)`
6492
+ );
6493
+ }
6102
6494
  if (!options.bindings.sourceImage) {
6103
6495
  throw new Error("ComfyUI revisions require bindings.sourceImage");
6104
6496
  }
@@ -6933,6 +7325,91 @@ var import_promises3 = require("fs/promises");
6933
7325
  var import_node_fs5 = require("fs");
6934
7326
  var import_node_path5 = __toESM(require("path"), 1);
6935
7327
 
7328
+ // src/loop-directions.ts
7329
+ var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
7330
+ var MIRROR_FIELDS = ["category", "tags", "styles"];
7331
+ function loopDirectionId(shorthandId, direction) {
7332
+ return `${shorthandId}.${direction}`;
7333
+ }
7334
+ function expandLoopDirections(raw) {
7335
+ const families = {};
7336
+ const issues = [];
7337
+ if (!isObject2(raw) || !isObject2(raw.assets)) return { raw, families, issues };
7338
+ const assets = raw.assets;
7339
+ if (!Object.values(assets).some((asset) => isObject2(asset) && isObject2(asset.animation) && "directions" in asset.animation)) {
7340
+ return { raw, families, issues };
7341
+ }
7342
+ const expanded = {};
7343
+ const claimed = new Set(Object.keys(assets));
7344
+ for (const [id, asset] of Object.entries(assets)) {
7345
+ if (!isObject2(asset) || !isObject2(asset.animation) || !("directions" in asset.animation)) {
7346
+ expanded[id] = asset;
7347
+ continue;
7348
+ }
7349
+ const where = `assets.${id}.animation.directions`;
7350
+ const { directions: rawDirections, ...animation } = asset.animation;
7351
+ const parsed = CharacterDirectionSchema.array().min(1).safeParse(rawDirections);
7352
+ if (!parsed.success) {
7353
+ issues.push(`${where}: expected a non-empty list of directions (${CharacterDirectionSchema.options.join(", ")})`);
7354
+ continue;
7355
+ }
7356
+ const directions = parsed.data;
7357
+ const duplicate = directions.find((direction, index) => directions.indexOf(direction) !== index);
7358
+ if (duplicate) {
7359
+ issues.push(`${where}: "${duplicate}" is listed twice`);
7360
+ continue;
7361
+ }
7362
+ if ("direction" in animation) {
7363
+ issues.push(`assets.${id}.animation: direction and directions are mutually exclusive; directions names every loop to make`);
7364
+ continue;
7365
+ }
7366
+ const single = SINGLE_OUTPUT_FIELDS.filter((field) => field in asset);
7367
+ if (single.length) {
7368
+ issues.push(
7369
+ `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`
7370
+ );
7371
+ continue;
7372
+ }
7373
+ const loops = directions.map((direction) => ({ id: loopDirectionId(id, direction), direction }));
7374
+ const named = new Set(directions);
7375
+ const mirrors = [];
7376
+ for (const { id: loopId, direction } of loops) {
7377
+ const flipped = MIRRORED_DIRECTION[direction];
7378
+ if (flipped === direction || named.has(flipped)) continue;
7379
+ named.add(flipped);
7380
+ mirrors.push({ id: loopDirectionId(id, flipped), of: loopId });
7381
+ }
7382
+ const ids = [...loops.map((loop) => loop.id), ...mirrors.map((mirror) => mirror.id)];
7383
+ const collision = ids.find((expandedId) => claimed.has(expandedId));
7384
+ if (collision) {
7385
+ issues.push(`${where}: expands to "${collision}", which is already declared`);
7386
+ continue;
7387
+ }
7388
+ for (const expandedId of ids) claimed.add(expandedId);
7389
+ for (const loop of loops) {
7390
+ expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
7391
+ }
7392
+ for (const mirror of mirrors) {
7393
+ const inherited = {};
7394
+ for (const field of MIRROR_FIELDS) if (field in asset) inherited[field] = asset[field];
7395
+ expanded[mirror.id] = { mirror: mirror.of, ...inherited };
7396
+ }
7397
+ families[id] = ids;
7398
+ }
7399
+ return { raw: { ...raw, assets: expanded }, families, issues };
7400
+ }
7401
+ function expandAssetFilter(ids, families) {
7402
+ if (!families) return [...ids];
7403
+ return [...new Set(ids.flatMap((id) => families[id] ?? [id]))];
7404
+ }
7405
+ function loopShorthandOf(assetId, families) {
7406
+ if (!families) return void 0;
7407
+ return Object.keys(families).find((id) => families[id].includes(assetId));
7408
+ }
7409
+ function isObject2(value) {
7410
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7411
+ }
7412
+
6936
7413
  // src/outputs.ts
6937
7414
  var import_node_fs4 = require("fs");
6938
7415
  var import_node_path4 = __toESM(require("path"), 1);
@@ -7251,7 +7728,12 @@ function frameSetFps(entry) {
7251
7728
  async function loadManifest(manifestPath) {
7252
7729
  const abs = import_node_path5.default.resolve(manifestPath);
7253
7730
  if (!(0, import_node_fs5.existsSync)(abs)) throw new ProjectError(`No manifest at ${abs}`);
7254
- const input = ManifestInputSchema.safeParse(JSON.parse(await (0, import_promises3.readFile)(abs, "utf8")));
7731
+ const expansion = expandLoopDirections(JSON.parse(await (0, import_promises3.readFile)(abs, "utf8")));
7732
+ if (expansion.issues.length) {
7733
+ throw new ProjectError(`Manifest at ${abs} is invalid:
7734
+ ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
7735
+ }
7736
+ const input = ManifestInputSchema.safeParse(expansion.raw);
7255
7737
  if (!input.success) {
7256
7738
  const issues = formatManifestIssues(input.error.issues);
7257
7739
  throw new ProjectError(`Manifest at ${abs} is invalid:
@@ -7336,7 +7818,8 @@ ${issues}`);
7336
7818
  throw new ProjectError(`Manifest at ${abs} is invalid:
7337
7819
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
7338
7820
  }
7339
- return { manifest: parsed.data, root: import_node_path5.default.dirname(abs), path: abs };
7821
+ const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
7822
+ return { manifest: parsed.data, root: import_node_path5.default.dirname(abs), path: abs, ...loopFamilies };
7340
7823
  }
7341
7824
  function parentAssetId(asset) {
7342
7825
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
@@ -7420,12 +7903,13 @@ async function resolveSpecs(loaded, filter) {
7420
7903
  );
7421
7904
  }
7422
7905
  }
7423
- for (const unknownAsset of filter?.assets ?? []) {
7906
+ const assetFilter = expandAssetFilter(filter?.assets ?? [], loaded.loopFamilies);
7907
+ for (const unknownAsset of assetFilter) {
7424
7908
  if (!manifest.assets[unknownAsset]) {
7425
7909
  throw new UsageError(`Unknown asset "${unknownAsset}".`);
7426
7910
  }
7427
7911
  }
7428
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
7912
+ const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
7429
7913
  const resolutionAssetIds = new Set(requestedAssetIds);
7430
7914
  for (const assetId of [...requestedAssetIds]) {
7431
7915
  let current = parentAssetId(manifest.assets[assetId]);
@@ -7520,7 +8004,7 @@ async function resolveSpecs(loaded, filter) {
7520
8004
  size = asset.size ?? style.size ?? 64;
7521
8005
  width = size;
7522
8006
  height = size;
7523
- } else if (generator === "uiAsset") {
8007
+ } else if (generator === "uiAsset" || generator === "uiElement") {
7524
8008
  width = asset.width ?? style.size ?? 256;
7525
8009
  height = asset.height ?? style.size ?? 256;
7526
8010
  size = Math.max(width, height);
@@ -7587,7 +8071,7 @@ async function resolveSpecs(loaded, filter) {
7587
8071
  seed: style.seed,
7588
8072
  uiPieces: generator === "uiAsset" ? asset.pieces : void 0,
7589
8073
  uiElements: generator === "uiAsset" ? asset.elements : void 0,
7590
- uiColorPalette: generator === "uiAsset" ? style.uiColorPalette : void 0,
8074
+ uiColorPalette: generator === "uiAsset" || generator === "uiElement" ? style.uiColorPalette : void 0,
7591
8075
  palette: style.palette,
7592
8076
  enforcePalette: style.enforcePalette,
7593
8077
  noBackground: style.noBackground,
@@ -7627,8 +8111,11 @@ async function resolveSpecs(loaded, filter) {
7627
8111
  terrainView: generator === "terrain" ? style.terrainView : void 0,
7628
8112
  isometricTileSize: generator === "isometricTile" ? style.isometricTileSize : void 0,
7629
8113
  isometricTileShape: generator === "isometricTile" ? style.isometricTileShape : void 0,
7630
- ...generator === "character" ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7631
- ...generator === "objectPro" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
8114
+ // A revision in a character or objectPro style is a provider edit of
8115
+ // its parent's pixels, not a new character: giving it a base shape
8116
+ // would have packing, the gallery, and adoption treat it as one.
8117
+ ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
8118
+ ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7632
8119
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
7633
8120
  costUnit: "generations",
7634
8121
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -7779,8 +8266,11 @@ async function resolveSpecs(loaded, filter) {
7779
8266
  );
7780
8267
  }
7781
8268
  const sourceSpec = await finalize(asset.revision.from);
7782
- const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? import_node_path5.default.resolve(root, sourceSpec.source) : sourceSpec.outFile);
7783
- const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
8269
+ const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? import_node_path5.default.resolve(root, sourceSpec.source) : sourceSpec.outFile);
8270
+ const sourceMembers = await revisionSourceMembers(sourceStem, `revision source ${asset.revision.from}`);
8271
+ const sourceFile = sourceMembers?.[0]?.file ?? sourceStem;
8272
+ const sourceImage = sourceMembers ? { ...sourceMembers[0], hash: sha256(JSON.stringify(sourceMembers.map((member) => member.sha256))) } : await optionalRevisionImage(sourceFile, "revision source");
8273
+ const sourceFps = sourceSpec.character?.animation?.fps ?? sourceSpec.objectPro?.animation?.fps ?? sourceSpec.revision?.fps ?? sourceSpec.revision?.sourceFps;
7784
8274
  const maskFile = asset.revision.mask ? import_node_path5.default.resolve(root, asset.revision.mask) : void 0;
7785
8275
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
7786
8276
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
@@ -7801,6 +8291,8 @@ async function resolveSpecs(loaded, filter) {
7801
8291
  sourceHeight: sourceImage?.height ?? null,
7802
8292
  sourceFormat: sourceImage?.format ?? null,
7803
8293
  sourceSpec,
8294
+ ...sourceMembers ? { sourceMembers } : {},
8295
+ ...sourceMembers && sourceFps != null ? { sourceFps } : {},
7804
8296
  ...maskFile ? {
7805
8297
  maskFile,
7806
8298
  maskSha256: maskImage?.hash ?? null,
@@ -7950,6 +8442,38 @@ async function optionalRevisionImage(file, label, requiredFormat) {
7950
8442
  }
7951
8443
  return { hash: sha256(bytes), ...metadata };
7952
8444
  }
8445
+ async function revisionSourceMembers(stem, label) {
8446
+ if ((0, import_node_fs5.existsSync)(stem)) return null;
8447
+ const at = (role) => memberPath(stem, role, 0, 2, MediaType.PNG);
8448
+ let roles = [];
8449
+ for (const set of [CHARACTER_DIRECTIONS_8, CHARACTER_DIRECTIONS_4]) {
8450
+ if (set.every((direction) => (0, import_node_fs5.existsSync)(at(direction)))) {
8451
+ roles = [...set];
8452
+ break;
8453
+ }
8454
+ }
8455
+ if (!roles.length) {
8456
+ for (let index = 0; (0, import_node_fs5.existsSync)(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
8457
+ roles.push(`frame-${String(index).padStart(2, "0")}`);
8458
+ }
8459
+ }
8460
+ if (!roles.length) return null;
8461
+ const members = [];
8462
+ for (const role of roles) {
8463
+ const file = at(role);
8464
+ const image = await optionalRevisionImage(file, `${label} (${role})`);
8465
+ if (!image) return null;
8466
+ members.push({ role, file, sha256: image.hash, width: image.width, height: image.height, format: image.format });
8467
+ }
8468
+ const first = members[0];
8469
+ const odd = members.find((member) => member.width !== first.width || member.height !== first.height);
8470
+ if (odd) {
8471
+ throw new Error(
8472
+ `${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`
8473
+ );
8474
+ }
8475
+ return members;
8476
+ }
7953
8477
  async function resolveStyleImages(loaded, styleId) {
7954
8478
  const style = loaded.manifest.styles[styleId];
7955
8479
  if (!style) throw new Error(`Unknown style "${styleId}"`);
@@ -9632,11 +10156,16 @@ async function inspectRevision(spec, lock, seen) {
9632
10156
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
9633
10157
  };
9634
10158
  }
9635
- if (!revision.sourceSha256 || !(0, import_node_fs8.existsSync)(revision.sourceFile)) {
9636
- return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
9637
- }
9638
- if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
9639
- return { ready: false, reason: "revision source changed after the manifest was resolved" };
10159
+ if (revision.sourceMembers) {
10160
+ const members = await inspectSourceMembers(spec, lock);
10161
+ if (!members.ready) return members;
10162
+ } else {
10163
+ if (!revision.sourceSha256 || !(0, import_node_fs8.existsSync)(revision.sourceFile)) {
10164
+ return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
10165
+ }
10166
+ if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
10167
+ return { ready: false, reason: "revision source changed after the manifest was resolved" };
10168
+ }
9640
10169
  }
9641
10170
  if (revision.maskFile) {
9642
10171
  if (!revision.maskSha256 || !(0, import_node_fs8.existsSync)(revision.maskFile)) {
@@ -9646,8 +10175,39 @@ async function inspectRevision(spec, lock, seen) {
9646
10175
  return { ready: false, reason: "revision mask changed after the manifest was resolved" };
9647
10176
  }
9648
10177
  }
10178
+ if (revision.lastFrameFile) {
10179
+ if (!revision.lastFrameSha256 || !(0, import_node_fs8.existsSync)(revision.lastFrameFile)) {
10180
+ return { ready: false, reason: `revision last frame is missing: ${revision.lastFrameFile}` };
10181
+ }
10182
+ if (await sha256File(revision.lastFrameFile) !== revision.lastFrameSha256) {
10183
+ return { ready: false, reason: "revision last frame changed after the manifest was resolved" };
10184
+ }
10185
+ }
9649
10186
  return { ready: true, reason: "revision inputs are current" };
9650
10187
  }
10188
+ async function inspectSourceMembers(spec, lock) {
10189
+ const revision = spec.revision;
10190
+ const members = revision.sourceMembers;
10191
+ for (const member of members) {
10192
+ if (!(0, import_node_fs8.existsSync)(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
10193
+ if (await sha256File(member.file) !== member.sha256) {
10194
+ return { ready: false, reason: `revision source member changed after the manifest was resolved: ${member.file}` };
10195
+ }
10196
+ }
10197
+ const parent = revision.sourceSpec;
10198
+ if (parent.source) return { ready: true, reason: "declared source set is present" };
10199
+ const entry = lock.entries[lockKey(parent.styleId, parent.assetId)];
10200
+ if (!entry) return { ready: false, reason: "revision source is not in the lockfile" };
10201
+ const recorded = entry.outputs.map((_, index) => import_node_path11.default.resolve(currentEntryOutputPath(entry, parent, index))).sort();
10202
+ const found = members.map((member) => import_node_path11.default.resolve(member.file)).sort();
10203
+ if (recorded.length !== found.length || recorded.some((file, index) => file !== found[index])) {
10204
+ return {
10205
+ ready: false,
10206
+ 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`
10207
+ };
10208
+ }
10209
+ return { ready: true, reason: "revision source set is current" };
10210
+ }
9651
10211
  async function requireRevisionReady(spec, lock) {
9652
10212
  const inspection = await inspectRevisionReadiness(spec, lock);
9653
10213
  if (inspection && !inspection.ready) throw new Error(inspection.reason);
@@ -10482,16 +11042,99 @@ function hex(c) {
10482
11042
  return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
10483
11043
  }
10484
11044
 
10485
- // src/pipeline/quality-regression.ts
10486
- var import_node_path16 = __toESM(require("path"), 1);
11045
+ // src/pixellab-utils.ts
10487
11046
  var import_node_fs12 = require("fs");
10488
11047
  var import_promises9 = require("fs/promises");
11048
+ var import_node_path16 = __toESM(require("path"), 1);
11049
+ var UNZOOM_MIN_SIDE = 256;
11050
+ var UNZOOM_MAX_AREA = 2048 * 2048;
11051
+ function defaultUnzoomOut(input) {
11052
+ const ext = import_node_path16.default.extname(input);
11053
+ return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
11054
+ }
11055
+ async function unzoomFile(client2, input, opts = {}) {
11056
+ const out = import_node_path16.default.resolve(opts.out ?? defaultUnzoomOut(input));
11057
+ if (import_node_path16.default.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
11058
+ if ((0, import_node_fs12.existsSync)(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
11059
+ const bytes = await (0, import_promises9.readFile)(input);
11060
+ const metadata = imageMetadata(bytes);
11061
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
11062
+ const { width, height, format } = metadata;
11063
+ if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
11064
+ throw new Error(
11065
+ `${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`
11066
+ );
11067
+ }
11068
+ if (width * height > UNZOOM_MAX_AREA) {
11069
+ throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
11070
+ }
11071
+ const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
11072
+ await (0, import_promises9.mkdir)(import_node_path16.default.dirname(out), { recursive: true });
11073
+ await (0, import_promises9.writeFile)(out, res.png);
11074
+ return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
11075
+ }
11076
+ var FONT_WEIGHTS = ["Bold", "Regular"];
11077
+ var FONT_GLYPH_SIZES = [8, 16, 32, 64];
11078
+ var FONT_COST_GENERATIONS = 25;
11079
+ function fontOutputPaths(out) {
11080
+ const base = import_node_path16.default.resolve(out.replace(/\.(ttf|png)$/i, ""));
11081
+ return { ttf: `${base}.ttf`, atlas: `${base}.png` };
11082
+ }
11083
+ async function generateFont(client2, opts) {
11084
+ const { ttf, atlas } = fontOutputPaths(opts.out);
11085
+ if (!opts.force) {
11086
+ for (const file of [ttf, atlas]) {
11087
+ if ((0, import_node_fs12.existsSync)(file)) throw new Error(`${file} already exists; pass --force to replace it`);
11088
+ }
11089
+ }
11090
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
11091
+ const interval = opts.intervalMs ?? 5e3;
11092
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
11093
+ const submitted = await client2.generateFontPro({
11094
+ description: opts.description,
11095
+ weight: opts.weight,
11096
+ glyphPx: opts.glyphPx,
11097
+ seed: opts.seed,
11098
+ fontName: opts.fontName
11099
+ });
11100
+ const jobId = submitted.background_job_id;
11101
+ const started = Date.now();
11102
+ for (; ; ) {
11103
+ const job = await client2.getFontJob(jobId);
11104
+ opts.onProgress?.(job.status);
11105
+ if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
11106
+ if (job.status === "completed") {
11107
+ if (!job.download_ttf_url || !job.download_atlas_url) {
11108
+ throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
11109
+ }
11110
+ const [ttfBytes, atlasBytes] = await Promise.all([
11111
+ client2.download(job.download_ttf_url),
11112
+ client2.download(job.download_atlas_url)
11113
+ ]);
11114
+ await (0, import_promises9.mkdir)(import_node_path16.default.dirname(ttf), { recursive: true });
11115
+ await (0, import_promises9.writeFile)(ttf, ttfBytes);
11116
+ await (0, import_promises9.writeFile)(atlas, atlasBytes);
11117
+ return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
11118
+ }
11119
+ if (Date.now() - started > timeout) {
11120
+ throw new Error(
11121
+ `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
11122
+ );
11123
+ }
11124
+ await sleep3(interval);
11125
+ }
11126
+ }
11127
+
11128
+ // src/pipeline/quality-regression.ts
11129
+ var import_node_path17 = __toESM(require("path"), 1);
11130
+ var import_node_fs13 = require("fs");
11131
+ var import_promises10 = require("fs/promises");
10489
11132
  var import_zod4 = require("zod");
10490
11133
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
10491
11134
  var HEX_RE = /^#[0-9a-f]{6}$/;
10492
11135
  var MAX_REDMEAN_DISTANCE = 765;
10493
11136
  var QualityPathSchema = import_zod4.z.string().min(1).refine(
10494
- (value) => !import_node_path16.default.posix.isAbsolute(value) && !value.includes("\\"),
11137
+ (value) => !import_node_path17.default.posix.isAbsolute(value) && !value.includes("\\"),
10495
11138
  "expected a portable relative path"
10496
11139
  );
10497
11140
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -10638,10 +11281,10 @@ function measureDecodedImageQuality(png, digest2) {
10638
11281
  };
10639
11282
  }
10640
11283
  async function measureImageQuality(file) {
10641
- const absolute = import_node_path16.default.resolve(file);
11284
+ const absolute = import_node_path17.default.resolve(file);
10642
11285
  let bytes;
10643
11286
  try {
10644
- bytes = await (0, import_promises9.readFile)(absolute);
11287
+ bytes = await (0, import_promises10.readFile)(absolute);
10645
11288
  } catch (error) {
10646
11289
  throw new Error(`Cannot read quality image ${absolute}: ${message4(error)}`, { cause: error });
10647
11290
  }
@@ -10660,11 +11303,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
10660
11303
  }
10661
11304
  const ids = parsed.data.map((entry) => entry.id);
10662
11305
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
10663
- const root = import_node_path16.default.dirname(import_node_path16.default.resolve(inputsFilePath));
11306
+ const root = import_node_path17.default.dirname(import_node_path17.default.resolve(inputsFilePath));
10664
11307
  return parsed.data.map((entry) => ({
10665
11308
  id: entry.id,
10666
- file: import_node_path16.default.resolve(root, entry.path),
10667
- ...entry.record ? { record: import_node_path16.default.resolve(root, entry.record) } : {},
11309
+ file: import_node_path17.default.resolve(root, entry.path),
11310
+ ...entry.record ? { record: import_node_path17.default.resolve(root, entry.record) } : {},
10668
11311
  tolerances: QualityToleranceSchema.parse({
10669
11312
  ...DEFAULT_QUALITY_TOLERANCES,
10670
11313
  ...entry.tolerances
@@ -10672,7 +11315,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
10672
11315
  }));
10673
11316
  }
10674
11317
  function portableRelative2(from, to) {
10675
- return import_node_path16.default.relative(from, import_node_path16.default.resolve(to)).split(import_node_path16.default.sep).join("/") || ".";
11318
+ return import_node_path17.default.relative(from, import_node_path17.default.resolve(to)).split(import_node_path17.default.sep).join("/") || ".";
10676
11319
  }
10677
11320
  async function verifyRecordForImage(recordPath, imagePath) {
10678
11321
  const verification = await verifyArtifactBundle(recordPath);
@@ -10687,20 +11330,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
10687
11330
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
10688
11331
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
10689
11332
  }
10690
- const outputs = record.outputs.map((output) => import_node_path16.default.resolve(import_node_path16.default.dirname(recordPath), output.path));
10691
- if (!outputs.includes(import_node_path16.default.resolve(imagePath))) {
11333
+ const outputs = record.outputs.map((output) => import_node_path17.default.resolve(import_node_path17.default.dirname(recordPath), output.path));
11334
+ if (!outputs.includes(import_node_path17.default.resolve(imagePath))) {
10692
11335
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
10693
11336
  }
10694
11337
  }
10695
11338
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
10696
11339
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
10697
- const absolute = import_node_path16.default.resolve(baselinePath);
11340
+ const absolute = import_node_path17.default.resolve(baselinePath);
10698
11341
  for (const input of inputs) {
10699
- if (import_node_path16.default.resolve(input.file) === absolute || input.record && import_node_path16.default.resolve(input.record) === absolute) {
11342
+ if (import_node_path17.default.resolve(input.file) === absolute || input.record && import_node_path17.default.resolve(input.record) === absolute) {
10700
11343
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
10701
11344
  }
10702
11345
  }
10703
- const root = import_node_path16.default.dirname(absolute);
11346
+ const root = import_node_path17.default.dirname(absolute);
10704
11347
  const cases = [];
10705
11348
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
10706
11349
  const expected = await measureImageQuality(input.file);
@@ -10727,8 +11370,8 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
10727
11370
  cases
10728
11371
  });
10729
11372
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
10730
- if ((0, import_node_fs12.existsSync)(absolute) && !options.force) {
10731
- const current = await (0, import_promises9.readFile)(absolute);
11373
+ if ((0, import_node_fs13.existsSync)(absolute) && !options.force) {
11374
+ const current = await (0, import_promises10.readFile)(absolute);
10732
11375
  if (!current.equals(data)) {
10733
11376
  throw new OverwriteRefusedError(
10734
11377
  `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
@@ -10740,10 +11383,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
10740
11383
  return { baseline, path: absolute, changed: result.changed.length > 0 };
10741
11384
  }
10742
11385
  async function readQualityBaseline(baselinePath) {
10743
- const absolute = import_node_path16.default.resolve(baselinePath);
11386
+ const absolute = import_node_path17.default.resolve(baselinePath);
10744
11387
  let raw;
10745
11388
  try {
10746
- raw = JSON.parse(await (0, import_promises9.readFile)(absolute, "utf8"));
11389
+ raw = JSON.parse(await (0, import_promises10.readFile)(absolute, "utf8"));
10747
11390
  } catch (error) {
10748
11391
  throw new Error(`Could not read quality baseline ${absolute}: ${message4(error)}`, { cause: error });
10749
11392
  }
@@ -10813,12 +11456,12 @@ function regressionViolations(expected, actual, tolerances) {
10813
11456
  return { violations, warnings };
10814
11457
  }
10815
11458
  async function checkQualityBaseline(baselinePath) {
10816
- const absolute = import_node_path16.default.resolve(baselinePath);
11459
+ const absolute = import_node_path17.default.resolve(baselinePath);
10817
11460
  const baseline = await readQualityBaseline(absolute);
10818
- const root = import_node_path16.default.dirname(absolute);
11461
+ const root = import_node_path17.default.dirname(absolute);
10819
11462
  const cases = [];
10820
11463
  for (const entry of baseline.cases) {
10821
- const file = import_node_path16.default.resolve(root, entry.file);
11464
+ const file = import_node_path17.default.resolve(root, entry.file);
10822
11465
  let actual = null;
10823
11466
  const violations = [];
10824
11467
  const warnings = [];
@@ -10831,7 +11474,7 @@ async function checkQualityBaseline(baselinePath) {
10831
11474
  violations.push(message4(error));
10832
11475
  }
10833
11476
  if (entry.record) {
10834
- const recordPath = import_node_path16.default.resolve(root, entry.record.path);
11477
+ const recordPath = import_node_path17.default.resolve(root, entry.record.path);
10835
11478
  try {
10836
11479
  const recordHash = await sha256File(recordPath);
10837
11480
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -10867,14 +11510,14 @@ async function checkQualityBaseline(baselinePath) {
10867
11510
  }
10868
11511
 
10869
11512
  // src/pipeline/cache-health.ts
10870
- var import_node_fs16 = require("fs");
10871
- var import_promises12 = require("fs/promises");
10872
- var import_node_path20 = __toESM(require("path"), 1);
11513
+ var import_node_fs17 = require("fs");
11514
+ var import_promises13 = require("fs/promises");
11515
+ var import_node_path21 = __toESM(require("path"), 1);
10873
11516
 
10874
11517
  // src/cache.ts
10875
- var import_promises10 = require("fs/promises");
10876
- var import_node_fs13 = require("fs");
10877
- var import_node_path17 = __toESM(require("path"), 1);
11518
+ var import_promises11 = require("fs/promises");
11519
+ var import_node_fs14 = require("fs");
11520
+ var import_node_path18 = __toESM(require("path"), 1);
10878
11521
  var import_zod5 = require("zod");
10879
11522
  var HashCacheSchema = import_zod5.z.object({
10880
11523
  version: import_zod5.z.literal(1),
@@ -10888,10 +11531,10 @@ function isSha256Hash(value) {
10888
11531
  function parseCache(value) {
10889
11532
  return HashCacheSchema.parse(value);
10890
11533
  }
10891
- async function loadCache(path33) {
10892
- if (!(0, import_node_fs13.existsSync)(path33)) return { version: 1, hashes: {} };
11534
+ async function loadCache(path34) {
11535
+ if (!(0, import_node_fs14.existsSync)(path34)) return { version: 1, hashes: {} };
10893
11536
  try {
10894
- const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises10.readFile)(path33, "utf8")));
11537
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises11.readFile)(path34, "utf8")));
10895
11538
  if (!parsed.success) return { version: 1, hashes: {} };
10896
11539
  return {
10897
11540
  version: 1,
@@ -10903,20 +11546,20 @@ async function loadCache(path33) {
10903
11546
  return { version: 1, hashes: {} };
10904
11547
  }
10905
11548
  }
10906
- async function saveCache(path33, cache) {
11549
+ async function saveCache(path34, cache) {
10907
11550
  const sorted = {};
10908
11551
  for (const key of Object.keys(cache.hashes).sort()) {
10909
11552
  const hash = cache.hashes[key];
10910
11553
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
10911
11554
  sorted[key] = hash;
10912
11555
  }
10913
- await (0, import_promises10.mkdir)(import_node_path17.default.dirname(import_node_path17.default.resolve(path33)), { recursive: true });
10914
- const tmp = `${path33}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
11556
+ await (0, import_promises11.mkdir)(import_node_path18.default.dirname(import_node_path18.default.resolve(path34)), { recursive: true });
11557
+ const tmp = `${path34}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10915
11558
  try {
10916
- await (0, import_promises10.writeFile)(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
10917
- await (0, import_promises10.rename)(tmp, path33);
11559
+ await (0, import_promises11.writeFile)(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
11560
+ await (0, import_promises11.rename)(tmp, path34);
10918
11561
  } finally {
10919
- await (0, import_promises10.rm)(tmp, { force: true });
11562
+ await (0, import_promises11.rm)(tmp, { force: true });
10920
11563
  }
10921
11564
  }
10922
11565
  function pruneCache(cache, liveIds, namespace = "") {
@@ -10938,20 +11581,20 @@ function cachePathFor(lockPath) {
10938
11581
  }
10939
11582
 
10940
11583
  // src/pipeline/history.ts
10941
- var import_node_fs15 = require("fs");
10942
- var import_node_path19 = __toESM(require("path"), 1);
11584
+ var import_node_fs16 = require("fs");
11585
+ var import_node_path20 = __toESM(require("path"), 1);
10943
11586
 
10944
11587
  // src/pipeline/fetch.ts
10945
- var import_node_fs14 = require("fs");
10946
- var import_promises11 = require("fs/promises");
10947
- var import_node_path18 = __toESM(require("path"), 1);
11588
+ var import_node_fs15 = require("fs");
11589
+ var import_promises12 = require("fs/promises");
11590
+ var import_node_path19 = __toESM(require("path"), 1);
10948
11591
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10949
11592
  const log = opts.onProgress ?? (() => {
10950
11593
  });
10951
11594
  const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
10952
11595
  const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
10953
11596
  normalizeLockOutputPaths(lock, specs);
10954
- const cacheDir = opts.cacheDir === false ? null : import_node_path18.default.resolve(opts.cacheDir ?? import_node_path18.default.join(import_node_path18.default.dirname(lockPath), ".pixelkiln", "cache"));
11597
+ const cacheDir = opts.cacheDir === false ? null : import_node_path19.default.resolve(opts.cacheDir ?? import_node_path19.default.join(import_node_path19.default.dirname(lockPath), ".pixelkiln", "cache"));
10955
11598
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
10956
11599
  if (e.provider !== provider.id || !specByKey.has(key)) return false;
10957
11600
  if (specByKey.get(key)?.mirror) return false;
@@ -10962,15 +11605,15 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10962
11605
  if (spec && e.specHash === spec.specHash && e.outputs.length && !postprocessCurrent(e, spec)) {
10963
11606
  return Boolean(opts.force) || e.outputs.every((output) => {
10964
11607
  const file = resolveOutputPath(output.path, spec.root);
10965
- return (0, import_node_fs14.existsSync)(file) && sha256((0, import_node_fs14.readFileSync)(file)) === output.sha256;
11608
+ return (0, import_node_fs15.existsSync)(file) && sha256((0, import_node_fs15.readFileSync)(file)) === output.sha256;
10966
11609
  });
10967
11610
  }
10968
11611
  if (!opts.repair) return false;
10969
11612
  if (e.outputs.length === 0 || !spec) return true;
10970
11613
  return e.outputs.some((output) => {
10971
11614
  const file = resolveOutputPath(output.path, spec.root);
10972
- if (!(0, import_node_fs14.existsSync)(file)) return true;
10973
- const current = sha256((0, import_node_fs14.readFileSync)(file));
11615
+ if (!(0, import_node_fs15.existsSync)(file)) return true;
11616
+ const current = sha256((0, import_node_fs15.readFileSync)(file));
10974
11617
  if (current === output.sha256) return false;
10975
11618
  return Boolean(opts.force) || (e.supersededOutputs ?? []).some((old) => old.sha256 === current);
10976
11619
  });
@@ -11053,21 +11696,21 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11053
11696
  if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
11054
11697
  fresh = await provider.download(source.url);
11055
11698
  if (recorded && sha256(fresh) === recordedRaw && !reapply) {
11056
- log(` current ${import_node_path18.default.relative(process.cwd(), target)}`);
11699
+ log(` current ${import_node_path19.default.relative(process.cwd(), target)}`);
11057
11700
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
11058
11701
  continue;
11059
11702
  }
11060
11703
  }
11061
11704
  let raw = null;
11062
11705
  let final = null;
11063
- if ((0, import_node_fs14.existsSync)(target)) {
11706
+ if ((0, import_node_fs15.existsSync)(target)) {
11064
11707
  const currentHash = await sha256File(target);
11065
11708
  if (recorded && currentHash === recorded.sha256) {
11066
11709
  if (!fresh && !reapply) {
11067
11710
  if (cacheDir) {
11068
11711
  await cacheMedia(
11069
11712
  cacheDir,
11070
- await (0, import_promises11.readFile)(target),
11713
+ await (0, import_promises12.readFile)(target),
11071
11714
  recorded.mediaType ?? MediaType.PNG,
11072
11715
  recorded.sha256
11073
11716
  );
@@ -11076,10 +11719,10 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11076
11719
  continue;
11077
11720
  }
11078
11721
  if (reapply && !fresh) {
11079
- if (!recorded.raw) raw = await (0, import_promises11.readFile)(target);
11080
- log(` reapply ${import_node_path18.default.relative(process.cwd(), target)}`);
11722
+ if (!recorded.raw) raw = await (0, import_promises12.readFile)(target);
11723
+ log(` reapply ${import_node_path19.default.relative(process.cwd(), target)}`);
11081
11724
  } else {
11082
- log(` changed ${import_node_path18.default.relative(process.cwd(), target)} (upstream)`);
11725
+ log(` changed ${import_node_path19.default.relative(process.cwd(), target)} (upstream)`);
11083
11726
  }
11084
11727
  } else {
11085
11728
  const superseded = (entry.supersededOutputs ?? []).find(
@@ -11100,14 +11743,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11100
11743
  }
11101
11744
  if (outgoing && cacheDir) {
11102
11745
  try {
11103
- const oldBytes = await (0, import_promises11.readFile)(target);
11746
+ const oldBytes = await (0, import_promises12.readFile)(target);
11104
11747
  const oldMediaType = detectMediaType(oldBytes);
11105
11748
  if (oldMediaType) await cacheMedia(cacheDir, oldBytes, oldMediaType, currentHash);
11106
11749
  } catch {
11107
11750
  }
11108
11751
  }
11109
11752
  log(
11110
- ` replace ${import_node_path18.default.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
11753
+ ` replace ${import_node_path19.default.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
11111
11754
  );
11112
11755
  }
11113
11756
  }
@@ -11116,7 +11759,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11116
11759
  } else if (!raw && recorded && cacheDir) {
11117
11760
  if (!reapply) final = await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType);
11118
11761
  if (!final && recordedRaw) raw = await readCachedMedia(cacheDir, recordedRaw, expectedMediaType);
11119
- if (final || raw) log(` cached ${import_node_path18.default.relative(process.cwd(), target)}`);
11762
+ if (final || raw) log(` cached ${import_node_path19.default.relative(process.cwd(), target)}`);
11120
11763
  }
11121
11764
  if (!final && !raw) {
11122
11765
  if (!source.url) {
@@ -11143,13 +11786,13 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11143
11786
  buf = processed.bytes;
11144
11787
  if (processed.changed) {
11145
11788
  rawHash = sha256(raw);
11146
- log(` snapped ${import_node_path18.default.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
11789
+ log(` snapped ${import_node_path19.default.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
11147
11790
  }
11148
11791
  }
11149
11792
  if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
11150
- await (0, import_promises11.mkdir)(import_node_path18.default.dirname(target), { recursive: true });
11793
+ await (0, import_promises12.mkdir)(import_node_path19.default.dirname(target), { recursive: true });
11151
11794
  const tmp = `${target}.pixelkiln.tmp`;
11152
- await (0, import_promises11.writeFile)(tmp, buf);
11795
+ await (0, import_promises12.writeFile)(tmp, buf);
11153
11796
  outputs.push({
11154
11797
  path: portableOutputPath(target, spec.root),
11155
11798
  sha256: sha256(buf),
@@ -11159,9 +11802,9 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11159
11802
  });
11160
11803
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
11161
11804
  await saveLock(lockPath, lock);
11162
- await (0, import_promises11.rename)(tmp, target);
11805
+ await (0, import_promises12.rename)(tmp, target);
11163
11806
  wrote = true;
11164
- log(` wrote ${import_node_path18.default.relative(process.cwd(), target)}`);
11807
+ log(` wrote ${import_node_path19.default.relative(process.cwd(), target)}`);
11165
11808
  }
11166
11809
  if (opts.refresh && !wrote) {
11167
11810
  if (rotated) {
@@ -11205,10 +11848,10 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
11205
11848
  return result;
11206
11849
  }
11207
11850
  async function readCachedMedia(cacheDir, hash, mediaType) {
11208
- const file = import_node_path18.default.join(cacheDir, cacheFileName(hash, mediaType));
11209
- if (!(0, import_node_fs14.existsSync)(file)) return null;
11851
+ const file = import_node_path19.default.join(cacheDir, cacheFileName(hash, mediaType));
11852
+ if (!(0, import_node_fs15.existsSync)(file)) return null;
11210
11853
  try {
11211
- const buf = await (0, import_promises11.readFile)(file);
11854
+ const buf = await (0, import_promises12.readFile)(file);
11212
11855
  if (sha256(buf) !== hash) return null;
11213
11856
  validateMedia(buf, mediaType);
11214
11857
  return buf;
@@ -11219,17 +11862,17 @@ async function readCachedMedia(cacheDir, hash, mediaType) {
11219
11862
  async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
11220
11863
  const hash = knownHash ?? sha256(buf);
11221
11864
  validateMedia(buf, mediaType);
11222
- const file = import_node_path18.default.join(cacheDir, cacheFileName(hash, mediaType));
11865
+ const file = import_node_path19.default.join(cacheDir, cacheFileName(hash, mediaType));
11223
11866
  if (await readCachedMedia(cacheDir, hash, mediaType)) return;
11224
- await (0, import_promises11.mkdir)(cacheDir, { recursive: true });
11867
+ await (0, import_promises12.mkdir)(cacheDir, { recursive: true });
11225
11868
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
11226
- await (0, import_promises11.writeFile)(tmp, buf);
11869
+ await (0, import_promises12.writeFile)(tmp, buf);
11227
11870
  try {
11228
- await (0, import_promises11.rename)(tmp, file);
11871
+ await (0, import_promises12.rename)(tmp, file);
11229
11872
  } catch (err) {
11230
- if (!(0, import_node_fs14.existsSync)(file)) throw err;
11873
+ if (!(0, import_node_fs15.existsSync)(file)) throw err;
11231
11874
  } finally {
11232
- await (0, import_promises11.rm)(tmp, { force: true });
11875
+ await (0, import_promises12.rm)(tmp, { force: true });
11233
11876
  }
11234
11877
  }
11235
11878
  function mergeOutputs(previous, current) {
@@ -11346,10 +11989,10 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
11346
11989
  }
11347
11990
  for (let i = 0; i < entry.outputs.length; i++) {
11348
11991
  const file = currentEntryOutputPath(entry, spec, i);
11349
- if (!(0, import_node_fs15.existsSync)(file)) continue;
11992
+ if (!(0, import_node_fs16.existsSync)(file)) continue;
11350
11993
  if (await sha256File(file) !== entry.outputs[i].sha256 && !opts.force) {
11351
11994
  throw new OverwriteRefusedError(
11352
- `${import_node_path19.default.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
11995
+ `${import_node_path20.default.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
11353
11996
  [file]
11354
11997
  );
11355
11998
  }
@@ -11401,11 +12044,11 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
11401
12044
 
11402
12045
  // src/pipeline/cache-health.ts
11403
12046
  async function inspectCaches(lock, lockPath, options = {}) {
11404
- if (options.prune && !(0, import_node_fs16.existsSync)(lockPath)) {
11405
- throw new Error(`Refusing to prune without an existing lockfile at ${import_node_path20.default.resolve(lockPath)}`);
12047
+ if (options.prune && !(0, import_node_fs17.existsSync)(lockPath)) {
12048
+ throw new Error(`Refusing to prune without an existing lockfile at ${import_node_path21.default.resolve(lockPath)}`);
11406
12049
  }
11407
- const contentDir = import_node_path20.default.resolve(import_node_path20.default.dirname(lockPath), ".pixelkiln", "cache");
11408
- const remotePath = import_node_path20.default.resolve(cachePathFor(lockPath));
12050
+ const contentDir = import_node_path21.default.resolve(import_node_path21.default.dirname(lockPath), ".pixelkiln", "cache");
12051
+ const remotePath = import_node_path21.default.resolve(cachePathFor(lockPath));
11409
12052
  const referenced = referencedHashes(lock);
11410
12053
  let content = await inspectContentCache(contentDir, referenced);
11411
12054
  let remoteHashes = await inspectRemoteHashCache(remotePath);
@@ -11417,7 +12060,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11417
12060
  ]);
11418
12061
  for (const name of names) {
11419
12062
  try {
11420
- await (0, import_promises12.rm)(import_node_path20.default.join(contentDir, name), { force: true });
12063
+ await (0, import_promises13.rm)(import_node_path21.default.join(contentDir, name), { force: true });
11421
12064
  removed.contentFiles++;
11422
12065
  } catch {
11423
12066
  }
@@ -11426,7 +12069,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11426
12069
  await saveCache(remotePath, { version: 1, hashes: {} });
11427
12070
  removed.resetRemoteHashCache = true;
11428
12071
  } else if (remoteHashes.invalidIds.length) {
11429
- const cache = parseCache(JSON.parse(await (0, import_promises12.readFile)(remotePath, "utf8")));
12072
+ const cache = parseCache(JSON.parse(await (0, import_promises13.readFile)(remotePath, "utf8")));
11430
12073
  for (const id of remoteHashes.invalidIds) delete cache.hashes[id];
11431
12074
  removed.remoteHashEntries = remoteHashes.invalidIds.length;
11432
12075
  await saveCache(remotePath, cache);
@@ -11444,7 +12087,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11444
12087
  async function inspectContentCache(contentDir, referenced) {
11445
12088
  const report = {
11446
12089
  path: contentDir,
11447
- exists: (0, import_node_fs16.existsSync)(contentDir),
12090
+ exists: (0, import_node_fs17.existsSync)(contentDir),
11448
12091
  files: 0,
11449
12092
  bytes: 0,
11450
12093
  valid: 0,
@@ -11458,19 +12101,19 @@ async function inspectContentCache(contentDir, referenced) {
11458
12101
  return report;
11459
12102
  }
11460
12103
  const present = /* @__PURE__ */ new Set();
11461
- const entries = await (0, import_promises12.readdir)(contentDir, { withFileTypes: true });
12104
+ const entries = await (0, import_promises13.readdir)(contentDir, { withFileTypes: true });
11462
12105
  for (const entry of entries) {
11463
12106
  if (!entry.isFile()) {
11464
12107
  report.invalid.push({ name: entry.name, reason: "not a regular file" });
11465
12108
  continue;
11466
12109
  }
11467
12110
  report.files++;
11468
- const file = import_node_path20.default.join(contentDir, entry.name);
12111
+ const file = import_node_path21.default.join(contentDir, entry.name);
11469
12112
  const mediaType = mediaTypeFromExtension(entry.name);
11470
12113
  const expected = mediaType ? entry.name.slice(0, -4) : "";
11471
12114
  let bytes;
11472
12115
  try {
11473
- bytes = await (0, import_promises12.readFile)(file);
12116
+ bytes = await (0, import_promises13.readFile)(file);
11474
12117
  report.bytes += bytes.length;
11475
12118
  } catch (err) {
11476
12119
  report.invalid.push({
@@ -11508,7 +12151,7 @@ async function inspectContentCache(contentDir, referenced) {
11508
12151
  async function inspectRemoteHashCache(remotePath) {
11509
12152
  const report = {
11510
12153
  path: remotePath,
11511
- exists: (0, import_node_fs16.existsSync)(remotePath),
12154
+ exists: (0, import_node_fs17.existsSync)(remotePath),
11512
12155
  entries: 0,
11513
12156
  valid: 0,
11514
12157
  invalidIds: [],
@@ -11517,7 +12160,7 @@ async function inspectRemoteHashCache(remotePath) {
11517
12160
  if (!report.exists) return report;
11518
12161
  let cache;
11519
12162
  try {
11520
- cache = parseCache(JSON.parse(await (0, import_promises12.readFile)(remotePath, "utf8")));
12163
+ cache = parseCache(JSON.parse(await (0, import_promises13.readFile)(remotePath, "utf8")));
11521
12164
  } catch (err) {
11522
12165
  report.error = err instanceof Error ? err.message : String(err);
11523
12166
  return report;
@@ -11532,20 +12175,20 @@ async function inspectRemoteHashCache(remotePath) {
11532
12175
  }
11533
12176
 
11534
12177
  // src/pipeline/pack.ts
11535
- var import_node_fs17 = require("fs");
11536
- var import_node_path21 = __toESM(require("path"), 1);
12178
+ var import_node_fs18 = require("fs");
12179
+ var import_node_path22 = __toESM(require("path"), 1);
11537
12180
  var DEFAULT_FRAME_SET_FPS = 12;
11538
12181
  function resolvePackInputs(raw, inputsFilePath) {
11539
12182
  if (!Array.isArray(raw) || !raw.length) {
11540
12183
  throw new Error("--inputs must be a non-empty JSON array of { id, path }");
11541
12184
  }
11542
- const baseDir = import_node_path21.default.dirname(import_node_path21.default.resolve(inputsFilePath));
12185
+ const baseDir = import_node_path22.default.dirname(import_node_path22.default.resolve(inputsFilePath));
11543
12186
  return raw.map((entry, i) => {
11544
12187
  const e = entry;
11545
12188
  if (typeof e.id !== "string" || typeof e.path !== "string") {
11546
12189
  throw new Error(`--inputs[${i}] needs string "id" and "path"`);
11547
12190
  }
11548
- return { id: e.id, path: import_node_path21.default.resolve(baseDir, e.path) };
12191
+ return { id: e.id, path: import_node_path22.default.resolve(baseDir, e.path) };
11549
12192
  });
11550
12193
  }
11551
12194
  function packSprites(inputs, options = {}) {
@@ -11566,7 +12209,7 @@ function packSprites(inputs, options = {}) {
11566
12209
  for (const input of ordered) {
11567
12210
  let sourceHash = null;
11568
12211
  try {
11569
- const bytes = (0, import_node_fs17.readFileSync)(input.path);
12212
+ const bytes = (0, import_node_fs18.readFileSync)(input.path);
11570
12213
  sourceHash = sha256(bytes);
11571
12214
  const png = decodePng(bytes);
11572
12215
  sprites.push({ id: input.id, width: png.width, height: png.height, pixels: png.pixels });
@@ -11629,7 +12272,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
11629
12272
  }
11630
12273
  const inputs2 = Object.entries(options.sourceOverrides).map(([id, source]) => ({
11631
12274
  id,
11632
- path: import_node_path21.default.resolve(source)
12275
+ path: import_node_path22.default.resolve(source)
11633
12276
  }));
11634
12277
  if (!inputs2.length) throw new Error(`No approved quality outputs for style "${styleId}".`);
11635
12278
  const packed2 = packSprites(inputs2, options);
@@ -11653,7 +12296,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
11653
12296
  let outputs = resolveEntryOutputs(entry, id, manifestDir);
11654
12297
  const source = sources[id];
11655
12298
  if (source && outputs.length <= 1) {
11656
- inputs.push({ id, path: import_node_path21.default.resolve(manifestDir, source) });
12299
+ inputs.push({ id, path: import_node_path22.default.resolve(manifestDir, source) });
11657
12300
  continue;
11658
12301
  }
11659
12302
  if (source && sourceIsStem(source, entry, manifestDir)) {
@@ -11687,7 +12330,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
11687
12330
  }
11688
12331
  }
11689
12332
  for (const [id, source] of Object.entries(sources)) {
11690
- if (!locked.has(id)) inputs.push({ id, path: import_node_path21.default.resolve(manifestDir, source) });
12333
+ if (!locked.has(id)) inputs.push({ id, path: import_node_path22.default.resolve(manifestDir, source) });
11691
12334
  }
11692
12335
  if (!inputs.length) {
11693
12336
  throw new Error(`No readable sprites for style "${styleId}"; ${noOutput.length} skipped.`);
@@ -11724,7 +12367,7 @@ function mountSprites(placements, options) {
11724
12367
  for (const p of placements) {
11725
12368
  let sourceHash = null;
11726
12369
  try {
11727
- const bytes = (0, import_node_fs17.readFileSync)(p.path);
12370
+ const bytes = (0, import_node_fs18.readFileSync)(p.path);
11728
12371
  sourceHash = sha256(bytes);
11729
12372
  const png = decodePng(bytes);
11730
12373
  if (png.width > cellWidth || png.height > cellHeight) {
@@ -11795,7 +12438,7 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
11795
12438
  const source = sources[id];
11796
12439
  const entry = lock.entries[`${styleId}/${id}`];
11797
12440
  if (source && !(entry && sourceIsStem(source, entry, manifestDir))) {
11798
- placements.push({ id, path: import_node_path21.default.resolve(manifestDir, source), cell });
12441
+ placements.push({ id, path: import_node_path22.default.resolve(manifestDir, source), cell });
11799
12442
  continue;
11800
12443
  }
11801
12444
  if (source && entry) {
@@ -11828,7 +12471,7 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
11828
12471
  `Nothing to mount for style "${styleId}": ${ids.length} asset(s) declare a cell, but none has generated output or a \`source\`. Run \`pixelkiln gen --style ${styleId}\`, or point each at a file.${detail}`
11829
12472
  );
11830
12473
  }
11831
- const basePng = mount.base ? (0, import_node_fs17.readFileSync)(import_node_path21.default.resolve(manifestDir, mount.base)) : void 0;
12474
+ const basePng = mount.base ? (0, import_node_fs18.readFileSync)(import_node_path22.default.resolve(manifestDir, mount.base)) : void 0;
11832
12475
  const mounted = mountSprites(placements, {
11833
12476
  cellWidth: mount.cellWidth,
11834
12477
  cellHeight: mount.cellHeight,
@@ -11838,7 +12481,7 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
11838
12481
  ...mounted,
11839
12482
  sources: mount.base ? [{
11840
12483
  id: "$base",
11841
- path: import_node_path21.default.resolve(manifestDir, mount.base),
12484
+ path: import_node_path22.default.resolve(manifestDir, mount.base),
11842
12485
  sha256: sha256(basePng),
11843
12486
  included: true
11844
12487
  }, ...mounted.sources] : mounted.sources,
@@ -12616,9 +13259,9 @@ async function poll(provider, lock, lockPath, opts = {}) {
12616
13259
  }
12617
13260
 
12618
13261
  // src/pipeline/doctor.ts
12619
- var import_node_fs18 = require("fs");
12620
- var import_promises13 = require("fs/promises");
12621
- var import_node_path22 = __toESM(require("path"), 1);
13262
+ var import_node_fs19 = require("fs");
13263
+ var import_promises14 = require("fs/promises");
13264
+ var import_node_path23 = __toESM(require("path"), 1);
12622
13265
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12623
13266
  const checks = [];
12624
13267
  const add = (id, level, message6) => checks.push({ id, level, message: message6 });
@@ -12629,14 +13272,14 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12629
13272
  );
12630
13273
  add("lock", "ok", `${Object.keys(lock.entries).length} valid v2 lock entr(ies)`);
12631
13274
  const dirs = [...new Set(specs.flatMap((spec) => [
12632
- import_node_path22.default.dirname(spec.outFile),
12633
- ...spec.quality ? [import_node_path22.default.dirname(spec.quality.outFile)] : []
13275
+ import_node_path23.default.dirname(spec.outFile),
13276
+ ...spec.quality ? [import_node_path23.default.dirname(spec.quality.outFile)] : []
12634
13277
  ]))];
12635
13278
  const unwritable = [];
12636
13279
  for (const dir of dirs) {
12637
13280
  const ancestor = await nearestExistingDirectory(dir);
12638
13281
  try {
12639
- await (0, import_promises13.access)(ancestor, import_node_fs18.constants.W_OK);
13282
+ await (0, import_promises14.access)(ancestor, import_node_fs19.constants.W_OK);
12640
13283
  } catch {
12641
13284
  unwritable.push(dir);
12642
13285
  }
@@ -12653,7 +13296,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12653
13296
  for (let index = 0; index < entry.outputs.length; index++) {
12654
13297
  const output = entry.outputs[index];
12655
13298
  const spec = specByKey.get(key);
12656
- const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, import_node_path22.default.dirname(lockPath));
13299
+ const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, import_node_path23.default.dirname(lockPath));
12657
13300
  const owner = outputOwners.get(absolute);
12658
13301
  if (owner && owner !== key) duplicateOutputs.push(`${output.path} (${owner}, ${key})`);
12659
13302
  outputOwners.set(absolute, key);
@@ -12668,7 +13311,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12668
13311
  } else {
12669
13312
  add("lock-outputs", "ok", `${outputOwners.size} recorded output path(s) are uniquely owned`);
12670
13313
  }
12671
- const cacheDir = import_node_path22.default.join(import_node_path22.default.dirname(lockPath), ".pixelkiln", "cache");
13314
+ const cacheDir = import_node_path23.default.join(import_node_path23.default.dirname(lockPath), ".pixelkiln", "cache");
12672
13315
  const stranded = [];
12673
13316
  const invalidState = [];
12674
13317
  const staleMapUrls = [];
@@ -12684,11 +13327,11 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12684
13327
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
12685
13328
  let hasCache = false;
12686
13329
  for (const output of entry.outputs) {
12687
- const cached = import_node_path22.default.join(
13330
+ const cached = import_node_path23.default.join(
12688
13331
  cacheDir,
12689
13332
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
12690
13333
  );
12691
- if ((0, import_node_fs18.existsSync)(cached) && await sha256File(cached) === output.sha256) {
13334
+ if ((0, import_node_fs19.existsSync)(cached) && await sha256File(cached) === output.sha256) {
12692
13335
  hasCache = true;
12693
13336
  break;
12694
13337
  }
@@ -12781,19 +13424,19 @@ async function nearestExistingDirectory(start) {
12781
13424
  let current = start;
12782
13425
  for (; ; ) {
12783
13426
  try {
12784
- if ((await (0, import_promises13.stat)(current)).isDirectory()) return current;
13427
+ if ((await (0, import_promises14.stat)(current)).isDirectory()) return current;
12785
13428
  } catch {
12786
13429
  }
12787
- const parent = import_node_path22.default.dirname(current);
13430
+ const parent = import_node_path23.default.dirname(current);
12788
13431
  if (parent === current) return current;
12789
13432
  current = parent;
12790
13433
  }
12791
13434
  }
12792
13435
 
12793
13436
  // src/pipeline/adopt.ts
12794
- var import_node_fs19 = require("fs");
12795
- var import_promises14 = require("fs/promises");
12796
- var import_node_path23 = __toESM(require("path"), 1);
13437
+ var import_node_fs20 = require("fs");
13438
+ var import_promises15 = require("fs/promises");
13439
+ var import_node_path24 = __toESM(require("path"), 1);
12797
13440
  async function adopt(provider, specs, lock, lockPath, opts = {}) {
12798
13441
  const log = opts.onProgress ?? (() => {
12799
13442
  });
@@ -12804,11 +13447,11 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
12804
13447
  const localByHash = /* @__PURE__ */ new Map();
12805
13448
  const unmatchedLocal = [];
12806
13449
  for (const spec of specs) {
12807
- if (!(0, import_node_fs19.existsSync)(spec.outFile)) {
13450
+ if (!(0, import_node_fs20.existsSync)(spec.outFile)) {
12808
13451
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
12809
13452
  continue;
12810
13453
  }
12811
- const bytes = await (0, import_promises14.readFile)(spec.outFile);
13454
+ const bytes = await (0, import_promises15.readFile)(spec.outFile);
12812
13455
  try {
12813
13456
  decodePng(bytes);
12814
13457
  } catch (err) {
@@ -12926,13 +13569,13 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
12926
13569
  }
12927
13570
 
12928
13571
  // src/pick/server.ts
12929
- var import_node_fs21 = require("fs");
12930
- var import_promises17 = require("fs/promises");
13572
+ var import_node_fs22 = require("fs");
13573
+ var import_promises18 = require("fs/promises");
12931
13574
 
12932
13575
  // src/pipeline/init.ts
12933
- var import_promises15 = require("fs/promises");
12934
- var import_node_fs20 = require("fs");
12935
- var import_node_path24 = __toESM(require("path"), 1);
13576
+ var import_promises16 = require("fs/promises");
13577
+ var import_node_fs21 = require("fs");
13578
+ var import_node_path25 = __toESM(require("path"), 1);
12936
13579
  function pngSize(buf) {
12937
13580
  if (buf.length < 24) return null;
12938
13581
  const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
@@ -12945,8 +13588,8 @@ function slugify(value) {
12945
13588
  }
12946
13589
  async function walk(dir, exclude) {
12947
13590
  const out = [];
12948
- for (const entry of await (0, import_promises15.readdir)(dir, { withFileTypes: true })) {
12949
- const full = import_node_path24.default.join(dir, entry.name);
13591
+ for (const entry of await (0, import_promises16.readdir)(dir, { withFileTypes: true })) {
13592
+ const full = import_node_path25.default.join(dir, entry.name);
12950
13593
  if (exclude.some((x) => full.includes(x))) continue;
12951
13594
  if (entry.isDirectory()) out.push(...await walk(full, exclude));
12952
13595
  else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
@@ -12960,14 +13603,14 @@ async function scanAssets(root, opts = {}) {
12960
13603
  const skipped = [];
12961
13604
  const seen = /* @__PURE__ */ new Map();
12962
13605
  for (const file of files.sort()) {
12963
- const size = pngSize(await (0, import_promises15.readFile)(file));
13606
+ const size = pngSize(await (0, import_promises16.readFile)(file));
12964
13607
  if (!size) {
12965
13608
  skipped.push(`${file} (not a readable PNG)`);
12966
13609
  continue;
12967
13610
  }
12968
- const rel = import_node_path24.default.relative(root, file);
12969
- const category = import_node_path24.default.dirname(rel) === "." ? "" : import_node_path24.default.dirname(rel);
12970
- let id = slugify(import_node_path24.default.basename(rel));
13611
+ const rel = import_node_path25.default.relative(root, file);
13612
+ const category = import_node_path25.default.dirname(rel) === "." ? "" : import_node_path25.default.dirname(rel);
13613
+ let id = slugify(import_node_path25.default.basename(rel));
12971
13614
  if (seen.has(id)) {
12972
13615
  const n = seen.get(id) + 1;
12973
13616
  seen.set(id, n);
@@ -13431,7 +14074,7 @@ document.getElementById('submit').onclick = async () => {
13431
14074
 
13432
14075
  // src/pick/review-server.ts
13433
14076
  var import_node_http = require("http");
13434
- var import_promises16 = require("fs/promises");
14077
+ var import_promises17 = require("fs/promises");
13435
14078
  function serveReviewPage(opts) {
13436
14079
  const log = opts.onProgress ?? (() => {
13437
14080
  });
@@ -13440,7 +14083,7 @@ function serveReviewPage(opts) {
13440
14083
  if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
13441
14084
  try {
13442
14085
  const asset = opts.assets.get(req.url);
13443
- const bytes = await (0, import_promises16.readFile)(asset.path);
14086
+ const bytes = await (0, import_promises17.readFile)(asset.path);
13444
14087
  res.writeHead(200, {
13445
14088
  "Content-Type": asset.contentType,
13446
14089
  "Cache-Control": "no-store",
@@ -13516,13 +14159,13 @@ async function currentArt(entry, spec) {
13516
14159
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
13517
14160
  if (!previous) return null;
13518
14161
  const file = resolveOutputPath(previous.path, spec.root);
13519
- if (!(0, import_node_fs21.existsSync)(file)) return null;
14162
+ if (!(0, import_node_fs22.existsSync)(file)) return null;
13520
14163
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
13521
14164
  if (!contentType) return null;
13522
14165
  let width = entry.width;
13523
14166
  let height = entry.height;
13524
14167
  if (contentType === "image/png") {
13525
- const size = pngSize(await (0, import_promises17.readFile)(file));
14168
+ const size = pngSize(await (0, import_promises18.readFile)(file));
13526
14169
  if (size) ({ width, height } = size);
13527
14170
  }
13528
14171
  return { path: file, contentType, width, height };
@@ -13691,14 +14334,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
13691
14334
 
13692
14335
  // src/gallery/snapshot.ts
13693
14336
  var import_node_crypto6 = require("crypto");
13694
- var import_node_fs24 = require("fs");
13695
- var import_promises21 = require("fs/promises");
13696
- var import_node_path29 = __toESM(require("path"), 1);
14337
+ var import_node_fs25 = require("fs");
14338
+ var import_promises22 = require("fs/promises");
14339
+ var import_node_path30 = __toESM(require("path"), 1);
13697
14340
 
13698
14341
  // src/workspace.ts
13699
- var import_promises18 = require("fs/promises");
13700
- var import_node_fs22 = require("fs");
13701
- var import_node_path25 = __toESM(require("path"), 1);
14342
+ var import_promises19 = require("fs/promises");
14343
+ var import_node_fs23 = require("fs");
14344
+ var import_node_path26 = __toESM(require("path"), 1);
13702
14345
  var import_zod6 = require("zod");
13703
14346
  var WorkspaceProjectSchema = import_zod6.z.object({
13704
14347
  id: import_zod6.z.string().min(1),
@@ -13723,10 +14366,10 @@ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message
13723
14366
  );
13724
14367
  }
13725
14368
  async function loadWorkspace(workspacePath) {
13726
- if (!(0, import_node_fs22.existsSync)(workspacePath)) return { version: 1, projects: [] };
14369
+ if (!(0, import_node_fs23.existsSync)(workspacePath)) return { version: 1, projects: [] };
13727
14370
  let raw;
13728
14371
  try {
13729
- raw = JSON.parse(await (0, import_promises18.readFile)(workspacePath, "utf8"));
14372
+ raw = JSON.parse(await (0, import_promises19.readFile)(workspacePath, "utf8"));
13730
14373
  } catch (err) {
13731
14374
  throw new ProjectError(
13732
14375
  `Workspace catalog at ${workspacePath} is malformed:
@@ -13740,22 +14383,22 @@ async function saveWorkspace(workspacePath, ws) {
13740
14383
  version: 1,
13741
14384
  projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
13742
14385
  };
13743
- await (0, import_promises18.mkdir)(import_node_path25.default.dirname(import_node_path25.default.resolve(workspacePath)), { recursive: true });
14386
+ await (0, import_promises19.mkdir)(import_node_path26.default.dirname(import_node_path26.default.resolve(workspacePath)), { recursive: true });
13744
14387
  const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
13745
14388
  try {
13746
- await (0, import_promises18.writeFile)(tmp, JSON.stringify(sorted, null, 2) + "\n");
13747
- await (0, import_promises18.rename)(tmp, workspacePath);
14389
+ await (0, import_promises19.writeFile)(tmp, JSON.stringify(sorted, null, 2) + "\n");
14390
+ await (0, import_promises19.rename)(tmp, workspacePath);
13748
14391
  } finally {
13749
- await (0, import_promises18.rm)(tmp, { force: true });
14392
+ await (0, import_promises19.rm)(tmp, { force: true });
13750
14393
  }
13751
14394
  }
13752
14395
  function toPortablePath(dir, absolute) {
13753
- return import_node_path25.default.relative(dir, absolute).split(import_node_path25.default.sep).join("/");
14396
+ return import_node_path26.default.relative(dir, absolute).split(import_node_path26.default.sep).join("/");
13754
14397
  }
13755
14398
  function resolveProject(dir, project) {
13756
14399
  return {
13757
- manifestPath: import_node_path25.default.resolve(dir, project.manifest.split("/").join(import_node_path25.default.sep)),
13758
- lockPath: import_node_path25.default.resolve(dir, project.lock.split("/").join(import_node_path25.default.sep))
14400
+ manifestPath: import_node_path26.default.resolve(dir, project.manifest.split("/").join(import_node_path26.default.sep)),
14401
+ lockPath: import_node_path26.default.resolve(dir, project.lock.split("/").join(import_node_path26.default.sep))
13759
14402
  };
13760
14403
  }
13761
14404
  function validateWorkspace(ws, dir) {
@@ -13768,21 +14411,21 @@ function validateWorkspace(ws, dir) {
13768
14411
  const { manifestPath, lockPath } = resolveProject(dir, project);
13769
14412
  lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
13770
14413
  manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
13771
- if (import_node_path25.default.isAbsolute(project.manifest) || import_node_path25.default.isAbsolute(project.lock)) {
14414
+ if (import_node_path26.default.isAbsolute(project.manifest) || import_node_path26.default.isAbsolute(project.lock)) {
13772
14415
  diagnostics.push({
13773
14416
  id: "absolute-path",
13774
14417
  level: "warning",
13775
14418
  message: `project "${project.id}" stores an absolute path; the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
13776
14419
  });
13777
14420
  }
13778
- if (!(0, import_node_fs22.existsSync)(manifestPath)) {
14421
+ if (!(0, import_node_fs23.existsSync)(manifestPath)) {
13779
14422
  diagnostics.push({
13780
14423
  id: "missing-manifest",
13781
14424
  level: "error",
13782
14425
  message: `project "${project.id}" manifest not found: ${manifestPath}`
13783
14426
  });
13784
14427
  }
13785
- if (!(0, import_node_fs22.existsSync)(lockPath)) {
14428
+ if (!(0, import_node_fs23.existsSync)(lockPath)) {
13786
14429
  diagnostics.push({
13787
14430
  id: "missing-lock",
13788
14431
  level: "error",
@@ -13829,12 +14472,12 @@ function validateWorkspace(ws, dir) {
13829
14472
  }
13830
14473
 
13831
14474
  // src/gallery/edit.ts
13832
- var import_node_path28 = __toESM(require("path"), 1);
14475
+ var import_node_path29 = __toESM(require("path"), 1);
13833
14476
  var import_zod9 = require("zod");
13834
14477
 
13835
14478
  // src/manifest-edit.ts
13836
- var import_promises19 = require("fs/promises");
13837
- var import_node_path26 = __toESM(require("path"), 1);
14479
+ var import_promises20 = require("fs/promises");
14480
+ var import_node_path27 = __toESM(require("path"), 1);
13838
14481
  var import_zod7 = require("zod");
13839
14482
  var HexSha = import_zod7.z.string().regex(/^[0-9a-f]{64}$/);
13840
14483
  var AssetPatchSchema = import_zod7.z.object({
@@ -13990,11 +14633,21 @@ function assetStyles(raw, asset) {
13990
14633
  const own = Array.isArray(asset.styles) ? asset.styles : [];
13991
14634
  return own.length ? own : Object.keys(raw.styles ?? {});
13992
14635
  }
14636
+ function declaredAssets(raw) {
14637
+ const expanded = expandLoopDirections(raw).raw;
14638
+ return new Set(Object.keys(expanded.assets ?? {}));
14639
+ }
14640
+ function undeclaredAsset(raw, assetId) {
14641
+ const shorthand = loopShorthandOf(assetId, expandLoopDirections(raw).families);
14642
+ return new ManifestEditError(
14643
+ 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`
14644
+ );
14645
+ }
13993
14646
  function applyEdit(raw, edit) {
13994
14647
  raw.assets ??= {};
13995
14648
  if (edit.action === "set-source" || edit.action === "clear-source") {
13996
14649
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
13997
- if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
14650
+ if (!asset2) throw undeclaredAsset(raw, edit.assetId);
13998
14651
  if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
13999
14652
  throw new ManifestEditError(`unknown style "${edit.styleId}"`);
14000
14653
  }
@@ -14046,7 +14699,7 @@ function applyEdit(raw, edit) {
14046
14699
  return;
14047
14700
  }
14048
14701
  if (edit.action === "add-asset") {
14049
- if (Object.hasOwn(raw.assets, edit.assetId)) {
14702
+ if (declaredAssets(raw).has(edit.assetId)) {
14050
14703
  throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
14051
14704
  }
14052
14705
  const { styles, tags, ...rest } = edit.asset;
@@ -14060,20 +14713,20 @@ function applyEdit(raw, edit) {
14060
14713
  }
14061
14714
  asset2.styles = styles;
14062
14715
  }
14063
- if (edit.asset.revision && !Object.hasOwn(raw.assets, edit.asset.revision.from)) {
14716
+ if (edit.asset.revision && !declaredAssets(raw).has(edit.asset.revision.from)) {
14064
14717
  throw new ManifestEditError(`revision parent "${edit.asset.revision.from}" is not declared by the manifest`);
14065
14718
  }
14066
- if (edit.asset.state && !Object.hasOwn(raw.assets, edit.asset.state.of)) {
14719
+ if (edit.asset.state && !declaredAssets(raw).has(edit.asset.state.of)) {
14067
14720
  throw new ManifestEditError(`state parent "${edit.asset.state.of}" is not declared by the manifest`);
14068
14721
  }
14069
- if (edit.asset.animation && !Object.hasOwn(raw.assets, edit.asset.animation.of)) {
14722
+ if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
14070
14723
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
14071
14724
  }
14072
14725
  raw.assets[edit.assetId] = asset2;
14073
14726
  return;
14074
14727
  }
14075
14728
  const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
14076
- if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
14729
+ if (!asset) throw undeclaredAsset(raw, edit.assetId);
14077
14730
  const { patch } = edit;
14078
14731
  if (patch.prompt !== void 0) asset.prompt = patch.prompt;
14079
14732
  if (patch.promptForStyle) {
@@ -14097,8 +14750,8 @@ function applyEdit(raw, edit) {
14097
14750
  }
14098
14751
  }
14099
14752
  async function applyManifestEdit(manifestPath, edit) {
14100
- const absolute = import_node_path26.default.resolve(manifestPath);
14101
- const original = await (0, import_promises19.readFile)(absolute, "utf8");
14753
+ const absolute = import_node_path27.default.resolve(manifestPath);
14754
+ const original = await (0, import_promises20.readFile)(absolute, "utf8");
14102
14755
  const before = sha256(original);
14103
14756
  if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
14104
14757
  let raw;
@@ -14112,12 +14765,12 @@ async function applyManifestEdit(manifestPath, edit) {
14112
14765
  applyEdit(raw, edit);
14113
14766
  const next = serializeLike(original, raw);
14114
14767
  if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
14115
- const tmp = import_node_path26.default.join(
14116
- import_node_path26.default.dirname(absolute),
14117
- `.${import_node_path26.default.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
14768
+ const tmp = import_node_path27.default.join(
14769
+ import_node_path27.default.dirname(absolute),
14770
+ `.${import_node_path27.default.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
14118
14771
  );
14119
14772
  try {
14120
- await (0, import_promises19.writeFile)(tmp, next);
14773
+ await (0, import_promises20.writeFile)(tmp, next);
14121
14774
  try {
14122
14775
  const loaded = await loadManifest(tmp);
14123
14776
  await resolveSpecs(loaded);
@@ -14125,26 +14778,26 @@ async function applyManifestEdit(manifestPath, edit) {
14125
14778
  const message6 = error instanceof Error ? error.message : String(error);
14126
14779
  throw new ManifestEditError(message6.replace(tmp, absolute));
14127
14780
  }
14128
- await (0, import_promises19.rename)(tmp, absolute);
14781
+ await (0, import_promises20.rename)(tmp, absolute);
14129
14782
  } finally {
14130
- await (0, import_promises19.rm)(tmp, { force: true });
14783
+ await (0, import_promises20.rm)(tmp, { force: true });
14131
14784
  }
14132
14785
  return { manifestPath: absolute, sha256: sha256(next), changed: true };
14133
14786
  }
14134
14787
 
14135
14788
  // src/pipeline/hand-edit.ts
14136
- var import_node_fs23 = require("fs");
14137
- var import_promises20 = require("fs/promises");
14138
- var import_node_path27 = __toESM(require("path"), 1);
14789
+ var import_node_fs24 = require("fs");
14790
+ var import_promises21 = require("fs/promises");
14791
+ var import_node_path28 = __toESM(require("path"), 1);
14139
14792
  var import_zod8 = require("zod");
14140
14793
  var HAND_EDIT_DIR = "edits";
14141
14794
  function handEditPath(loaded, spec) {
14142
14795
  const style = loaded.manifest.styles[spec.styleId];
14143
- const outDir = import_node_path27.default.resolve(loaded.root, style?.outDir ?? ".");
14144
- const relative = import_node_path27.default.relative(outDir, spec.outFile);
14145
- const inside = relative && !relative.startsWith("..") && !import_node_path27.default.isAbsolute(relative);
14146
- const file = inside ? relative : import_node_path27.default.basename(spec.outFile);
14147
- return import_node_path27.default.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
14796
+ const outDir = import_node_path28.default.resolve(loaded.root, style?.outDir ?? ".");
14797
+ const relative = import_node_path28.default.relative(outDir, spec.outFile);
14798
+ const inside = relative && !relative.startsWith("..") && !import_node_path28.default.isAbsolute(relative);
14799
+ const file = inside ? relative : import_node_path28.default.basename(spec.outFile);
14800
+ return import_node_path28.default.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
14148
14801
  }
14149
14802
  function handEditBases(spec, lock) {
14150
14803
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -14157,10 +14810,10 @@ function handEditBases(spec, lock) {
14157
14810
  }));
14158
14811
  if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
14159
14812
  if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
14160
- if (!members.every((member) => (0, import_node_fs23.existsSync)(member.path))) return null;
14813
+ if (!members.every((member) => (0, import_node_fs24.existsSync)(member.path))) return null;
14161
14814
  return members.map(({ role, path: file }) => ({ role, path: file }));
14162
14815
  }
14163
- return (0, import_node_fs23.existsSync)(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
14816
+ return (0, import_node_fs24.existsSync)(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
14164
14817
  }
14165
14818
  function handEditBase(spec, lock) {
14166
14819
  const bases = handEditBases(spec, lock);
@@ -14190,19 +14843,19 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
14190
14843
  `${spec.styleId}/${spec.assetId} declares ${spec.source}, one committed file placed for the whole set; clear that source (pixelkiln edit detach) to hand-edit the members one by one`
14191
14844
  );
14192
14845
  }
14193
- const existing = spec.source ? import_node_path27.default.resolve(loaded.root, spec.source) : null;
14846
+ const existing = spec.source ? import_node_path28.default.resolve(loaded.root, spec.source) : null;
14194
14847
  const editPath = existing ?? handEditPath(loaded, spec);
14195
14848
  const members = handEditMembers(loaded, spec, lock, editPath);
14196
14849
  let created = false;
14197
14850
  for (const member of members) {
14198
- if ((0, import_node_fs23.existsSync)(member.path)) continue;
14851
+ if ((0, import_node_fs24.existsSync)(member.path)) continue;
14199
14852
  if (!member.base) {
14200
14853
  throw new ManifestEditError(
14201
14854
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
14202
14855
  );
14203
14856
  }
14204
- await (0, import_promises20.mkdir)(import_node_path27.default.dirname(member.path), { recursive: true });
14205
- await (0, import_promises20.copyFile)(member.base, member.path);
14857
+ await (0, import_promises21.mkdir)(import_node_path28.default.dirname(member.path), { recursive: true });
14858
+ await (0, import_promises21.copyFile)(member.base, member.path);
14206
14859
  created = true;
14207
14860
  }
14208
14861
  const source = portableOutputPath(editPath, loaded.root);
@@ -14280,7 +14933,7 @@ function handEditProjectPath(editPath) {
14280
14933
  async function readHandEditCompanion(editPath) {
14281
14934
  let text;
14282
14935
  try {
14283
- text = await (0, import_promises20.readFile)(handEditCompanionPath(editPath), "utf8");
14936
+ text = await (0, import_promises21.readFile)(handEditCompanionPath(editPath), "utf8");
14284
14937
  } catch {
14285
14938
  return null;
14286
14939
  }
@@ -14323,7 +14976,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
14323
14976
  }
14324
14977
  });
14325
14978
  const bases = handEditBases(spec, lock);
14326
- const expectedSize = bases?.[0] ? decodePng(await (0, import_promises20.readFile)(bases[0].path)) : { width: spec.width, height: spec.height };
14979
+ const expectedSize = bases?.[0] ? decodePng(await (0, import_promises21.readFile)(bases[0].path)) : { width: spec.width, height: spec.height };
14327
14980
  for (const [index, image] of decoded.entries()) {
14328
14981
  if (image.width !== expectedSize.width || image.height !== expectedSize.height) {
14329
14982
  const which = memberSet ? `member ${incoming[index].role ?? index}` : "the edit";
@@ -14339,7 +14992,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
14339
14992
  editor: input.editor,
14340
14993
  protocol: input.protocol,
14341
14994
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
14342
- project: projectPath ? import_node_path27.default.basename(projectPath) : null,
14995
+ project: projectPath ? import_node_path28.default.basename(projectPath) : null,
14343
14996
  outputs: incoming.map((frame, index) => ({
14344
14997
  role: frame.role,
14345
14998
  basedOn: basedOnHash(entry, index),
@@ -14369,10 +15022,10 @@ function basedOnHash(entry, index) {
14369
15022
  async function replaceFile(file, bytes) {
14370
15023
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
14371
15024
  try {
14372
- await (0, import_promises20.writeFile)(tmp, bytes);
14373
- await (0, import_promises20.rename)(tmp, file);
15025
+ await (0, import_promises21.writeFile)(tmp, bytes);
15026
+ await (0, import_promises21.rename)(tmp, file);
14374
15027
  } finally {
14375
- await (0, import_promises20.rm)(tmp, { force: true });
15028
+ await (0, import_promises21.rm)(tmp, { force: true });
14376
15029
  }
14377
15030
  }
14378
15031
  function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
@@ -14469,7 +15122,7 @@ function createGalleryEditHandler(opts) {
14469
15122
  }
14470
15123
  const result = await applyManifestEdit(manifestPath, parsed.data);
14471
15124
  if (result.changed) {
14472
- const relative = import_node_path28.default.relative(process.cwd(), result.manifestPath);
15125
+ const relative = import_node_path29.default.relative(process.cwd(), result.manifestPath);
14473
15126
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
14474
15127
  const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.action === "set-source" || parsed.data.action === "clear-source" ? `${parsed.data.styleId}/${parsed.data.assetId} source` : parsed.data.assetId;
14475
15128
  log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
@@ -14481,7 +15134,7 @@ function createGalleryEditHandler(opts) {
14481
15134
  // src/gallery/snapshot.ts
14482
15135
  var PROJECT_FILE_TYPE = "application/octet-stream";
14483
15136
  function galleryMediaId(absolutePath) {
14484
- return (0, import_node_crypto6.createHash)("sha256").update(import_node_path29.default.resolve(absolutePath)).digest("hex").slice(0, 24);
15137
+ return (0, import_node_crypto6.createHash)("sha256").update(import_node_path30.default.resolve(absolutePath)).digest("hex").slice(0, 24);
14485
15138
  }
14486
15139
  function galleryMediaRoute(id) {
14487
15140
  return `/media/${id}`;
@@ -14500,9 +15153,9 @@ function describeHistory(media, entry, cacheDir) {
14500
15153
  return (entry.history ?? []).map((generation, i) => {
14501
15154
  const outputs = generation.outputs.map((output) => {
14502
15155
  const mediaType = output.mediaType ?? MediaType.PNG;
14503
- const file = import_node_path29.default.join(cacheDir, cacheFileName(output.sha256, mediaType));
15156
+ const file = import_node_path30.default.join(cacheDir, cacheFileName(output.sha256, mediaType));
14504
15157
  let url = null;
14505
- if ((0, import_node_fs24.existsSync)(file)) {
15158
+ if ((0, import_node_fs25.existsSync)(file)) {
14506
15159
  const id = galleryMediaId(file);
14507
15160
  media.set(id, { path: file, contentType: mediaType });
14508
15161
  url = `${galleryMediaRoute(id)}?v=${output.sha256.slice(0, 24)}`;
@@ -14534,7 +15187,7 @@ function describeHistory(media, entry, cacheDir) {
14534
15187
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
14535
15188
  async function samePixels(a, b) {
14536
15189
  try {
14537
- const [left, right] = await Promise.all([(0, import_promises21.readFile)(a), (0, import_promises21.readFile)(b)]);
15190
+ const [left, right] = await Promise.all([(0, import_promises22.readFile)(a), (0, import_promises22.readFile)(b)]);
14538
15191
  if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
14539
15192
  const x = decodePng(left);
14540
15193
  const y = decodePng(right);
@@ -14546,6 +15199,7 @@ async function samePixels(a, b) {
14546
15199
  var metadataFps = (entry) => entry ? frameSetFps(entry) : null;
14547
15200
  function describeCharacter(spec, entry) {
14548
15201
  const recordedCharacter = entry?.providerMetadata?.[entry.provider]?.character;
15202
+ if (spec?.revision) return null;
14549
15203
  if (spec?.character) {
14550
15204
  const character = spec.character;
14551
15205
  return {
@@ -14599,7 +15253,7 @@ function describeCharacter(spec, entry) {
14599
15253
  }
14600
15254
  async function fileInfo(absolutePath) {
14601
15255
  try {
14602
- const info = await (0, import_promises21.stat)(absolutePath);
15256
+ const info = await (0, import_promises22.stat)(absolutePath);
14603
15257
  return info.isFile() ? { bytes: info.size, modifiedAt: info.mtime.toISOString() } : null;
14604
15258
  } catch {
14605
15259
  return null;
@@ -14631,10 +15285,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
14631
15285
  async function describeQuality(media, root, inspection) {
14632
15286
  const outputPaths = inspection.outputs ?? [inspection.output];
14633
15287
  const outputs = await Promise.all(
14634
- outputPaths.map((file) => describeOutput(media, root, import_node_path29.default.resolve(root, file)))
15288
+ outputPaths.map((file) => describeOutput(media, root, import_node_path30.default.resolve(root, file)))
14635
15289
  );
14636
- const recordPath = import_node_path29.default.resolve(root, inspection.record);
14637
- const recordExists = (0, import_node_fs24.existsSync)(recordPath);
15290
+ const recordPath = import_node_path30.default.resolve(root, inspection.record);
15291
+ const recordExists = (0, import_node_fs25.existsSync)(recordPath);
14638
15292
  let options = null;
14639
15293
  let check = null;
14640
15294
  if (recordExists) {
@@ -14653,7 +15307,7 @@ async function describeQuality(media, root, inspection) {
14653
15307
  return {
14654
15308
  state: inspection.state,
14655
15309
  reason: inspection.reason,
14656
- output: portableOutputPath(import_node_path29.default.resolve(root, inspection.output), root),
15310
+ output: portableOutputPath(import_node_path30.default.resolve(root, inspection.output), root),
14657
15311
  record: portableOutputPath(recordPath, root),
14658
15312
  recordExists,
14659
15313
  outputs,
@@ -14686,13 +15340,14 @@ async function describeQuality(media, root, inspection) {
14686
15340
  }
14687
15341
  async function buildGallerySnapshot(opts) {
14688
15342
  const { loaded, specs, lock } = opts;
15343
+ const filter = opts.filter?.assets?.length ? { ...opts.filter, assets: expandAssetFilter(opts.filter.assets, loaded.loopFamilies) } : opts.filter;
14689
15344
  const root = loaded.root;
14690
15345
  const media = /* @__PURE__ */ new Map();
14691
15346
  const plan = await buildPlan(specs, lock);
14692
15347
  const items = [];
14693
- const manifestText = await (0, import_promises21.readFile)(loaded.path, "utf8");
15348
+ const manifestText = await (0, import_promises22.readFile)(loaded.path, "utf8");
14694
15349
  const manifestSha256 = sha256(manifestText);
14695
- const cacheDir = import_node_path29.default.resolve(import_node_path29.default.dirname(opts.lockPath), ".pixelkiln", "cache");
15350
+ const cacheDir = import_node_path30.default.resolve(import_node_path30.default.dirname(opts.lockPath), ".pixelkiln", "cache");
14696
15351
  let rawStyles = {};
14697
15352
  try {
14698
15353
  const raw = JSON.parse(manifestText);
@@ -14716,12 +15371,12 @@ async function buildGallerySnapshot(opts) {
14716
15371
  )
14717
15372
  );
14718
15373
  if (spec.source) {
14719
- const editPath = import_node_path29.default.resolve(root, spec.source);
15374
+ const editPath = import_node_path30.default.resolve(root, spec.source);
14720
15375
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
14721
15376
  const editShas = await Promise.all(
14722
15377
  Array.from({ length: memberCount }, async (_, index) => {
14723
15378
  const file = sourceOutputPath(spec.source, entry, index, root);
14724
- return { file, sha256: (0, import_node_fs24.existsSync)(file) ? await sha256File(file) : null };
15379
+ return { file, sha256: (0, import_node_fs25.existsSync)(file) ? await sha256File(file) : null };
14725
15380
  })
14726
15381
  );
14727
15382
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -14729,7 +15384,7 @@ async function buildGallerySnapshot(opts) {
14729
15384
  const companion = await readHandEditCompanion(editPath);
14730
15385
  if (companion) {
14731
15386
  const projectPath = handEditProjectPath(editPath);
14732
- const project = companion.project && (0, import_node_fs24.existsSync)(projectPath) ? await fileInfo(projectPath) : null;
15387
+ const project = companion.project && (0, import_node_fs25.existsSync)(projectPath) ? await fileInfo(projectPath) : null;
14733
15388
  let projectUrl = null;
14734
15389
  if (project) {
14735
15390
  const id = galleryMediaId(projectPath);
@@ -14765,8 +15420,8 @@ async function buildGallerySnapshot(opts) {
14765
15420
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
14766
15421
  }
14767
15422
  } else if (spec.source) {
14768
- outputs = [await describeOutput(media, root, import_node_path29.default.resolve(root, spec.source))];
14769
- } else if (!entry && (0, import_node_fs24.existsSync)(spec.outFile)) {
15423
+ outputs = [await describeOutput(media, root, import_node_path30.default.resolve(root, spec.source))];
15424
+ } else if (!entry && (0, import_node_fs25.existsSync)(spec.outFile)) {
14770
15425
  outputs = [await describeOutput(media, root, spec.outFile)];
14771
15426
  } else {
14772
15427
  outputs = [];
@@ -14830,7 +15485,7 @@ async function buildGallerySnapshot(opts) {
14830
15485
  for (const [key, entry] of Object.entries(lock.entries)) {
14831
15486
  if (shown.has(key)) continue;
14832
15487
  if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
14833
- if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
15488
+ if (!matchesFilter(filter, entry.styleId, entry.assetId)) continue;
14834
15489
  const outputs = await Promise.all(
14835
15490
  entry.outputs.map(
14836
15491
  (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
@@ -14959,7 +15614,7 @@ async function buildGallerySnapshot(opts) {
14959
15614
  id: loaded.manifest.name,
14960
15615
  name: loaded.manifest.name,
14961
15616
  manifest: loaded.path,
14962
- lock: import_node_path29.default.resolve(opts.lockPath),
15617
+ lock: import_node_path30.default.resolve(opts.lockPath),
14963
15618
  root,
14964
15619
  provider: loaded.manifest.provider,
14965
15620
  account: null,
@@ -14985,7 +15640,7 @@ async function buildGallerySnapshot(opts) {
14985
15640
  return { snapshot, media };
14986
15641
  }
14987
15642
  async function buildWorkspaceGallerySnapshot(opts) {
14988
- const dir = import_node_path29.default.dirname(import_node_path29.default.resolve(opts.workspacePath));
15643
+ const dir = import_node_path30.default.dirname(import_node_path30.default.resolve(opts.workspacePath));
14989
15644
  const media = /* @__PURE__ */ new Map();
14990
15645
  const projects = [];
14991
15646
  const items = [];
@@ -14996,7 +15651,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
14996
15651
  try {
14997
15652
  const loaded = await loadManifest(manifestPath);
14998
15653
  const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
14999
- const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
15654
+ const assetFilter = expandAssetFilter(filter.assets, loaded.loopFamilies).filter((id) => loaded.manifest.assets[id]);
15000
15655
  const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
15001
15656
  const lock = await loadLock(lockPath);
15002
15657
  let projectItems = [];
@@ -15028,11 +15683,11 @@ async function buildWorkspaceGallerySnapshot(opts) {
15028
15683
  id: project.id,
15029
15684
  name: loaded.manifest.name,
15030
15685
  manifest: manifestPath,
15031
- lock: import_node_path29.default.resolve(lockPath),
15686
+ lock: import_node_path30.default.resolve(lockPath),
15032
15687
  root: loaded.root,
15033
15688
  provider: loaded.manifest.provider,
15034
15689
  account: project.account ?? null,
15035
- manifestSha256: manifestSha256 ?? sha256(await (0, import_promises21.readFile)(manifestPath, "utf8")),
15690
+ manifestSha256: manifestSha256 ?? sha256(await (0, import_promises22.readFile)(manifestPath, "utf8")),
15036
15691
  historyLimit: historyLimit(loaded.manifest),
15037
15692
  entries: Object.keys(lock.entries).length,
15038
15693
  items: projectItems.length,
@@ -15044,8 +15699,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
15044
15699
  id: project.id,
15045
15700
  name: project.id,
15046
15701
  manifest: manifestPath,
15047
- lock: import_node_path29.default.resolve(lockPath),
15048
- root: import_node_path29.default.dirname(manifestPath),
15702
+ lock: import_node_path30.default.resolve(lockPath),
15703
+ root: import_node_path30.default.dirname(manifestPath),
15049
15704
  provider: project.provider,
15050
15705
  account: project.account ?? null,
15051
15706
  manifestSha256: null,
@@ -15072,7 +15727,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
15072
15727
  version: 1,
15073
15728
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
15074
15729
  project: null,
15075
- workspace: { path: import_node_path29.default.resolve(opts.workspacePath), dir, projects },
15730
+ workspace: { path: import_node_path30.default.resolve(opts.workspacePath), dir, projects },
15076
15731
  filter,
15077
15732
  totals: {
15078
15733
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -15089,13 +15744,13 @@ async function buildWorkspaceGallerySnapshot(opts) {
15089
15744
  }
15090
15745
 
15091
15746
  // src/gallery/page.ts
15092
- var import_node_fs25 = require("fs");
15747
+ var import_node_fs26 = require("fs");
15093
15748
  var CLIENT_DIR = new URL("./client/", importMetaUrl);
15094
15749
  var client;
15095
15750
  function clientAssets() {
15096
15751
  client ??= {
15097
- css: (0, import_node_fs25.readFileSync)(new URL("gallery.css", CLIENT_DIR), "utf8"),
15098
- js: (0, import_node_fs25.readFileSync)(new URL("gallery.js", CLIENT_DIR), "utf8")
15752
+ css: (0, import_node_fs26.readFileSync)(new URL("gallery.css", CLIENT_DIR), "utf8"),
15753
+ js: (0, import_node_fs26.readFileSync)(new URL("gallery.js", CLIENT_DIR), "utf8")
15099
15754
  };
15100
15755
  return client;
15101
15756
  }
@@ -15221,10 +15876,10 @@ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download
15221
15876
 
15222
15877
  // src/editor/install.ts
15223
15878
  var import_node_crypto7 = require("crypto");
15224
- var import_node_fs26 = require("fs");
15225
- var import_promises22 = require("fs/promises");
15879
+ var import_node_fs27 = require("fs");
15880
+ var import_promises23 = require("fs/promises");
15226
15881
  var import_node_os3 = __toESM(require("os"), 1);
15227
- var import_node_path30 = __toESM(require("path"), 1);
15882
+ var import_node_path31 = __toESM(require("path"), 1);
15228
15883
  var EditorInstallError = class extends Error {
15229
15884
  constructor(message6, file) {
15230
15885
  super(message6);
@@ -15235,15 +15890,15 @@ var EditorInstallError = class extends Error {
15235
15890
  };
15236
15891
  function editorToolsRoot() {
15237
15892
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
15238
- if (override) return import_node_path30.default.resolve(override);
15239
- if (process.platform === "darwin") return import_node_path30.default.join(import_node_os3.default.homedir(), "Library", "Caches", "pixelkiln", "tools");
15893
+ if (override) return import_node_path31.default.resolve(override);
15894
+ if (process.platform === "darwin") return import_node_path31.default.join(import_node_os3.default.homedir(), "Library", "Caches", "pixelkiln", "tools");
15240
15895
  if (process.platform === "win32") {
15241
- return import_node_path30.default.join(process.env.LOCALAPPDATA ?? import_node_path30.default.join(import_node_os3.default.homedir(), "AppData", "Local"), "pixelkiln", "tools");
15896
+ return import_node_path31.default.join(process.env.LOCALAPPDATA ?? import_node_path31.default.join(import_node_os3.default.homedir(), "AppData", "Local"), "pixelkiln", "tools");
15242
15897
  }
15243
- return import_node_path30.default.join(process.env.XDG_CACHE_HOME ?? import_node_path30.default.join(import_node_os3.default.homedir(), ".cache"), "pixelkiln", "tools");
15898
+ return import_node_path31.default.join(process.env.XDG_CACHE_HOME ?? import_node_path31.default.join(import_node_os3.default.homedir(), ".cache"), "pixelkiln", "tools");
15244
15899
  }
15245
15900
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
15246
- return import_node_path30.default.join(root, "pixelorama", pin.release ?? "unpublished");
15901
+ return import_node_path31.default.join(root, "pixelorama", pin.release ?? "unpublished");
15247
15902
  }
15248
15903
  function editorBaseUrl(pin = EDITOR_PIN) {
15249
15904
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -15253,7 +15908,7 @@ function editorBaseUrl(pin = EDITOR_PIN) {
15253
15908
  }
15254
15909
  async function sha256Of(file) {
15255
15910
  try {
15256
- return (0, import_node_crypto7.createHash)("sha256").update(await (0, import_promises22.readFile)(file)).digest("hex");
15911
+ return (0, import_node_crypto7.createHash)("sha256").update(await (0, import_promises23.readFile)(file)).digest("hex");
15257
15912
  } catch {
15258
15913
  return null;
15259
15914
  }
@@ -15266,8 +15921,8 @@ async function editorStatus(opts = {}) {
15266
15921
  let totalBytes = 0;
15267
15922
  for (const [name, expected] of Object.entries(pin.files)) {
15268
15923
  totalBytes += expected.bytes;
15269
- const file = import_node_path30.default.join(dir, name);
15270
- const info = (0, import_node_fs26.existsSync)(file) ? await (0, import_promises22.stat)(file).catch(() => null) : null;
15924
+ const file = import_node_path31.default.join(dir, name);
15925
+ const info = (0, import_node_fs27.existsSync)(file) ? await (0, import_promises23.stat)(file).catch(() => null) : null;
15271
15926
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
15272
15927
  installedBytes += expected.bytes;
15273
15928
  } else {
@@ -15296,7 +15951,7 @@ async function installEditor(opts = {}) {
15296
15951
  );
15297
15952
  }
15298
15953
  if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
15299
- await (0, import_promises22.mkdir)(dir, { recursive: true });
15954
+ await (0, import_promises23.mkdir)(dir, { recursive: true });
15300
15955
  const downloaded = [];
15301
15956
  const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
15302
15957
  let fetchedBytes = before.installedBytes;
@@ -15324,13 +15979,13 @@ async function installEditor(opts = {}) {
15324
15979
  if (digest2 !== expected.sha256) {
15325
15980
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
15326
15981
  }
15327
- const final = import_node_path30.default.join(dir, name);
15982
+ const final = import_node_path31.default.join(dir, name);
15328
15983
  const tmp = `${final}.${process.pid}.part`;
15329
15984
  try {
15330
- await (0, import_promises22.writeFile)(tmp, bytes);
15331
- await (0, import_promises22.rename)(tmp, final);
15985
+ await (0, import_promises23.writeFile)(tmp, bytes);
15986
+ await (0, import_promises23.rename)(tmp, final);
15332
15987
  } finally {
15333
- await (0, import_promises22.rm)(tmp, { force: true });
15988
+ await (0, import_promises23.rm)(tmp, { force: true });
15334
15989
  }
15335
15990
  fetchedBytes += bytes.length;
15336
15991
  downloaded.push(name);
@@ -15370,12 +16025,12 @@ var EDITOR_CONTENT_TYPES = {
15370
16025
  ".json": "application/json; charset=utf-8"
15371
16026
  };
15372
16027
  function editorContentType(name) {
15373
- return EDITOR_CONTENT_TYPES[import_node_path30.default.extname(name).toLowerCase()] ?? null;
16028
+ return EDITOR_CONTENT_TYPES[import_node_path31.default.extname(name).toLowerCase()] ?? null;
15374
16029
  }
15375
16030
 
15376
16031
  // src/gallery/editor.ts
15377
- var import_promises23 = require("fs/promises");
15378
- var import_node_path31 = __toESM(require("path"), 1);
16032
+ var import_promises24 = require("fs/promises");
16033
+ var import_node_path32 = __toESM(require("path"), 1);
15379
16034
  function editorRoute(release, name = "index.html") {
15380
16035
  return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
15381
16036
  }
@@ -15448,7 +16103,7 @@ function createGalleryEditorHandlers(opts = {}) {
15448
16103
  if (!status.installed) return null;
15449
16104
  let bytes;
15450
16105
  try {
15451
- bytes = await (0, import_promises23.readFile)(import_node_path31.default.join(dir, name));
16106
+ bytes = await (0, import_promises24.readFile)(import_node_path32.default.join(dir, name));
15452
16107
  } catch {
15453
16108
  verified = null;
15454
16109
  return null;
@@ -15465,7 +16120,7 @@ function createGalleryEditorHandlers(opts = {}) {
15465
16120
  // src/gallery/server.ts
15466
16121
  var import_node_crypto8 = require("crypto");
15467
16122
  var import_node_http2 = require("http");
15468
- var import_promises24 = require("fs/promises");
16123
+ var import_promises25 = require("fs/promises");
15469
16124
  var MAX_EDIT_BYTES = 48 * 1024 * 1024;
15470
16125
  var EDITOR_CSP = [
15471
16126
  "default-src 'self'",
@@ -15624,7 +16279,7 @@ async function serveGallery(opts) {
15624
16279
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
15625
16280
  if (!asset) return fail(404, "no such review asset");
15626
16281
  try {
15627
- const bytes = await (0, import_promises24.readFile)(asset.path);
16282
+ const bytes = await (0, import_promises25.readFile)(asset.path);
15628
16283
  res.writeHead(200, {
15629
16284
  "Content-Type": asset.contentType,
15630
16285
  "Cache-Control": "no-store",
@@ -15700,7 +16355,7 @@ async function serveGallery(opts) {
15700
16355
  if (!asset) return fail(404, "no such gallery media");
15701
16356
  let bytes;
15702
16357
  try {
15703
- bytes = await (0, import_promises24.readFile)(asset.path);
16358
+ bytes = await (0, import_promises25.readFile)(asset.path);
15704
16359
  } catch {
15705
16360
  return fail(404, "gallery media is no longer on disk; refresh the page");
15706
16361
  }
@@ -16074,19 +16729,19 @@ function createGenerateHandlers(opts) {
16074
16729
  }
16075
16730
 
16076
16731
  // src/pipeline/salvage.ts
16077
- var import_promises25 = require("fs/promises");
16078
- var import_node_fs27 = require("fs");
16079
- var import_node_path32 = __toESM(require("path"), 1);
16732
+ var import_promises26 = require("fs/promises");
16733
+ var import_node_fs28 = require("fs");
16734
+ var import_node_path33 = __toESM(require("path"), 1);
16080
16735
  function providerClaimId(provider, objectId) {
16081
16736
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
16082
16737
  }
16083
16738
  async function loadClaims(lockPaths, opts = {}) {
16084
16739
  const claimed = /* @__PURE__ */ new Set();
16085
16740
  for (const p of lockPaths) {
16086
- if (!(0, import_node_fs27.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
16741
+ if (!(0, import_node_fs28.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
16087
16742
  let parsed;
16088
16743
  try {
16089
- parsed = parseLock(JSON.parse(await (0, import_promises25.readFile)(p, "utf8")));
16744
+ parsed = parseLock(JSON.parse(await (0, import_promises26.readFile)(p, "utf8")));
16090
16745
  } catch {
16091
16746
  throw new Error(`Claim lockfile is malformed: ${p}`);
16092
16747
  }
@@ -16139,20 +16794,20 @@ function matchOrphanStyle(prompt, manifest) {
16139
16794
  return matchStyleByPattern(prompt, manifest);
16140
16795
  }
16141
16796
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
16142
- const own = import_node_path32.default.resolve(ownManifestPath);
16797
+ const own = import_node_path33.default.resolve(ownManifestPath);
16143
16798
  const siblingManifestPaths = [
16144
16799
  .../* @__PURE__ */ new Set([
16145
16800
  ...workspaceManifestPaths,
16146
- ...claimPaths.map((c) => import_node_path32.default.join(import_node_path32.default.dirname(import_node_path32.default.resolve(c)), "pixelkiln.manifest.json"))
16801
+ ...claimPaths.map((c) => import_node_path33.default.join(import_node_path33.default.dirname(import_node_path33.default.resolve(c)), "pixelkiln.manifest.json"))
16147
16802
  ])
16148
16803
  ];
16149
16804
  const siblings = [];
16150
16805
  for (const siblingManifestPath of siblingManifestPaths) {
16151
- if (import_node_path32.default.resolve(siblingManifestPath) === own) continue;
16152
- if (!(0, import_node_fs27.existsSync)(siblingManifestPath)) continue;
16806
+ if (import_node_path33.default.resolve(siblingManifestPath) === own) continue;
16807
+ if (!(0, import_node_fs28.existsSync)(siblingManifestPath)) continue;
16153
16808
  try {
16154
16809
  const { manifest } = await loadManifest(siblingManifestPath);
16155
- siblings.push({ label: import_node_path32.default.basename(import_node_path32.default.dirname(siblingManifestPath)), manifest });
16810
+ siblings.push({ label: import_node_path33.default.basename(import_node_path33.default.dirname(siblingManifestPath)), manifest });
16156
16811
  } catch {
16157
16812
  }
16158
16813
  }
@@ -16268,8 +16923,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
16268
16923
  }
16269
16924
 
16270
16925
  // src/pick/salvage-server.ts
16271
- var import_promises26 = require("fs/promises");
16272
- var import_node_path33 = __toESM(require("path"), 1);
16926
+ var import_promises27 = require("fs/promises");
16927
+ var import_node_path34 = __toESM(require("path"), 1);
16273
16928
 
16274
16929
  // src/pick/salvage-sheet.ts
16275
16930
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -16434,7 +17089,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16434
17089
  });
16435
17090
  const html = renderSalvageSheet(orphans, {
16436
17091
  styleId: ctx.styleId,
16437
- importDir: import_node_path33.default.relative(process.cwd(), ctx.importDir) || "."
17092
+ importDir: import_node_path34.default.relative(process.cwd(), ctx.importDir) || "."
16438
17093
  });
16439
17094
  const byId = new Map(orphans.map((o) => [o.id, o]));
16440
17095
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -16463,10 +17118,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16463
17118
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
16464
17119
  decodePng(buf);
16465
17120
  const assetId = idFromPrompt(orphan.prompt, taken);
16466
- const rel = import_node_path33.default.join("_salvaged", `${assetId}.png`);
16467
- const outFile = import_node_path33.default.resolve(ctx.importDir, rel);
16468
- await (0, import_promises26.mkdir)(import_node_path33.default.dirname(outFile), { recursive: true });
16469
- await (0, import_promises26.writeFile)(outFile, buf);
17121
+ const rel = import_node_path34.default.join("_salvaged", `${assetId}.png`);
17122
+ const outFile = import_node_path34.default.resolve(ctx.importDir, rel);
17123
+ await (0, import_promises27.mkdir)(import_node_path34.default.dirname(outFile), { recursive: true });
17124
+ await (0, import_promises27.writeFile)(outFile, buf);
16470
17125
  ctx.manifest.assets[assetId] = {
16471
17126
  prompt: orphan.prompt,
16472
17127
  promptByStyle: {},
@@ -16498,7 +17153,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16498
17153
  sourceUrl: durableSource,
16499
17154
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
16500
17155
  outputs: [{
16501
- path: portableOutputPath(outFile, import_node_path33.default.dirname(ctx.manifestPath)),
17156
+ path: portableOutputPath(outFile, import_node_path34.default.dirname(ctx.manifestPath)),
16502
17157
  sha256: sha256(buf)
16503
17158
  }],
16504
17159
  submittedAt: orphan.createdAt,
@@ -16522,9 +17177,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16522
17177
  }
16523
17178
  }
16524
17179
  await applyTags(provider, decisions, existingTags, { onProgress: log });
16525
- const raw = JSON.parse(await (0, import_promises26.readFile)(ctx.manifestPath, "utf8"));
17180
+ const raw = JSON.parse(await (0, import_promises27.readFile)(ctx.manifestPath, "utf8"));
16526
17181
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
16527
- await (0, import_promises26.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
17182
+ await (0, import_promises27.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
16528
17183
  await saveLock(ctx.lockPath, ctx.lock);
16529
17184
  return result;
16530
17185
  }
@@ -16654,6 +17309,10 @@ async function workspaceStatus(ws, dir) {
16654
17309
  EXIT_CODES,
16655
17310
  EditorInstallError,
16656
17311
  FAKE_PNG,
17312
+ FONT_COST_GENERATIONS,
17313
+ FONT_GLYPH_SIZES,
17314
+ FONT_WEIGHTS,
17315
+ FRAME_SET_REVISION_MODES,
16657
17316
  FakeProvider,
16658
17317
  GenerateRequestError,
16659
17318
  GenerateRequestSchema,
@@ -16670,6 +17329,7 @@ async function workspaceStatus(ws, dir) {
16670
17329
  MAX_DOWNLOAD_BYTES,
16671
17330
  MAX_HAND_EDIT_BYTES,
16672
17331
  MAX_RETRIES,
17332
+ MEMBER_SET_REVISION_MODES,
16673
17333
  MIRRORED_DIRECTION,
16674
17334
  ManifestDriftError,
16675
17335
  ManifestEditError,
@@ -16699,6 +17359,8 @@ async function workspaceStatus(ws, dir) {
16699
17359
  ScenarioProvider,
16700
17360
  StyleInputSchema,
16701
17361
  StyleSchema,
17362
+ UNZOOM_MAX_AREA,
17363
+ UNZOOM_MIN_SIDE,
16702
17364
  UiElementSchema,
16703
17365
  UiPieceSchema,
16704
17366
  UnsupportedCapabilityError,
@@ -16733,6 +17395,7 @@ async function workspaceStatus(ws, dir) {
16733
17395
  currentEntryOutputPath,
16734
17396
  currentOutputPath,
16735
17397
  defaultOpenCommand,
17398
+ defaultUnzoomOut,
16736
17399
  detachHandEdit,
16737
17400
  detectMediaType,
16738
17401
  doctor,
@@ -16750,10 +17413,12 @@ async function workspaceStatus(ws, dir) {
16750
17413
  fetchAssets,
16751
17414
  fetchWithRetry,
16752
17415
  findOrphans,
17416
+ fontOutputPaths,
16753
17417
  formatCost,
16754
17418
  frameSetFps,
16755
17419
  galleryMediaId,
16756
17420
  galleryMediaRoute,
17421
+ generateFont,
16757
17422
  generationCost,
16758
17423
  groupOrphansByStyle,
16759
17424
  handEditBase,
@@ -16889,6 +17554,7 @@ async function workspaceStatus(ws, dir) {
16889
17554
  tilesCost,
16890
17555
  toPortablePath,
16891
17556
  totalSpend,
17557
+ unzoomFile,
16892
17558
  upsert,
16893
17559
  validateCostEstimate,
16894
17560
  validateMedia,