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.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/types.ts
2
2
  import { z } from "zod";
3
3
  var MediaTypeSchema = z.enum(["image/png", "image/gif"]);
4
- var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset"]);
4
+ var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset", "uiElement"]);
5
5
  var GridConfidenceSchema = z.enum(["low", "medium", "high"]);
6
6
  var RevisionModeSchema = z.enum([
7
7
  "image-to-image",
@@ -10,7 +10,9 @@ var RevisionModeSchema = z.enum([
10
10
  "reduce-colors",
11
11
  "correct-pixelart",
12
12
  "animate",
13
- "animate-pixminimax"
13
+ "animate-pixminimax",
14
+ "interpolate",
15
+ "edit-animation"
14
16
  ]);
15
17
  function tileVariationCount(descriptions) {
16
18
  return Math.max(1, descriptions) * 4;
@@ -191,6 +193,14 @@ var CharacterAnimationSchema = z.object({
191
193
  template: z.string().min(1).optional(),
192
194
  /** Direction to animate. */
193
195
  direction: CharacterDirectionSchema.default("south"),
196
+ /**
197
+ * Shorthand for one loop in several directions, instead of `direction`.
198
+ * Expanded when the manifest loads into one loop per named direction
199
+ * (`<id>.<direction>`) plus a mirror for each direction whose flip is
200
+ * not named, exactly as if each had been written out. See
201
+ * `src/loop-directions.ts`.
202
+ */
203
+ directions: z.array(CharacterDirectionSchema).min(1).optional(),
194
204
  /** Frames to generate without a template (v3). Even, 4 to 16. */
195
205
  frames: z.number().int().min(4).max(16).optional(),
196
206
  /** Playback rate recorded with the frames; PixelLab does not store one. */
@@ -219,6 +229,13 @@ var CharacterAnimationSchema = z.object({
219
229
  /** v3 text loops only: let PixelLab expand the action into a fuller motion description first. */
220
230
  enhancePrompt: z.boolean().optional()
221
231
  }).strict().superRefine((animation, context) => {
232
+ if (animation.directions !== void 0) {
233
+ context.addIssue({
234
+ code: z.ZodIssueCode.custom,
235
+ message: "directions is expanded when the manifest loads; validate the loaded manifest, not the raw file",
236
+ path: ["directions"]
237
+ });
238
+ }
222
239
  if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
223
240
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
224
241
  }
@@ -279,9 +296,13 @@ var RevisionSchema = z.object({
279
296
  * the provider layer, since it differs by mode).
280
297
  */
281
298
  frames: z.number().int().min(4).max(40).optional(),
282
- /** `animate`/`animate-pixminimax` only: playback rate recorded with the frames; PixelLab does not store one. */
299
+ /** `animate`/`animate-pixminimax`/`interpolate`/`edit-animation` only: playback rate recorded with the frames; PixelLab does not store one. */
283
300
  fps: z.number().int().min(1).max(60).optional(),
284
- /** `animate`/`animate-pixminimax` only: manifest-relative image pinning where the motion ends (interpolation instead of open-ended animation). */
301
+ /**
302
+ * `animate`/`animate-pixminimax`: manifest-relative image pinning where
303
+ * the motion ends (interpolation instead of open-ended animation).
304
+ * `interpolate`: required, the ending keyframe; the parent is the start.
305
+ */
285
306
  lastFrame: z.string().min(1).optional(),
286
307
  /** `animate-pixminimax` only: facing direction, used only alongside `enhancePrompt` to hold the sprite's facing. */
287
308
  direction: CharacterDirectionSchema.optional(),
@@ -325,14 +346,15 @@ var RevisionSchema = z.object({
325
346
  path: ["strength"]
326
347
  });
327
348
  }
328
- if (revision.strength !== void 0 && (revision.mode === "animate" || revision.mode === "animate-pixminimax")) {
349
+ const generative = revision.mode === "animate" || revision.mode === "animate-pixminimax" || revision.mode === "interpolate" || revision.mode === "edit-animation";
350
+ if (revision.strength !== void 0 && generative) {
329
351
  context.addIssue({
330
352
  code: z.ZodIssueCode.custom,
331
- message: `${revision.mode} revisions do not take a strength; use enhancePrompt`,
353
+ 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`,
332
354
  path: ["strength"]
333
355
  });
334
356
  }
335
- for (const field of ["frames", "fps", "lastFrame", "enhancePrompt"]) {
357
+ for (const field of ["frames", "enhancePrompt"]) {
336
358
  if (revision[field] !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax") {
337
359
  context.addIssue({
338
360
  code: z.ZodIssueCode.custom,
@@ -341,6 +363,27 @@ var RevisionSchema = z.object({
341
363
  });
342
364
  }
343
365
  }
366
+ if (revision.lastFrame !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax" && revision.mode !== "interpolate") {
367
+ context.addIssue({
368
+ code: z.ZodIssueCode.custom,
369
+ message: "lastFrame applies to animate/animate-pixminimax/interpolate revisions only",
370
+ path: ["lastFrame"]
371
+ });
372
+ }
373
+ if (revision.mode === "interpolate" && revision.lastFrame === void 0) {
374
+ context.addIssue({
375
+ code: z.ZodIssueCode.custom,
376
+ message: "interpolate revisions require lastFrame: the ending keyframe to interpolate toward",
377
+ path: ["lastFrame"]
378
+ });
379
+ }
380
+ if (revision.fps !== void 0 && !generative) {
381
+ context.addIssue({
382
+ code: z.ZodIssueCode.custom,
383
+ message: "fps applies to animate/animate-pixminimax/interpolate/edit-animation revisions only",
384
+ path: ["fps"]
385
+ });
386
+ }
344
387
  if (revision.direction !== void 0 && revision.mode !== "animate-pixminimax") {
345
388
  context.addIssue({
346
389
  code: z.ZodIssueCode.custom,
@@ -352,6 +395,8 @@ var RevisionSchema = z.object({
352
395
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
353
396
  }
354
397
  });
398
+ var FRAME_SET_REVISION_MODES = ["animate", "animate-pixminimax", "interpolate", "edit-animation"];
399
+ var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation"];
355
400
  var StyleObjectSchema = z.object({
356
401
  /** Generation backend for this style. Omit to inherit the manifest default. */
357
402
  provider: z.string().min(1).optional(),
@@ -470,7 +515,7 @@ var StyleObjectSchema = z.object({
470
515
  */
471
516
  terrainMode: z.enum(["standard", "pro"]).optional(),
472
517
  /**
473
- * `uiAsset` generator only. A natural-language palette hint sent as-is
518
+ * `uiAsset` and `uiElement` generators only. A natural-language palette hint sent as-is
474
519
  * (e.g. "brown and gold"), distinct from `palette`'s hex-color array —
475
520
  * this is a prompt-level steer, not a local quantization target.
476
521
  */
@@ -1348,6 +1393,21 @@ var CorrectPixelartResponseSchema = z2.object({
1348
1393
  images: z2.array(z2.object({ base64: z2.string().min(1) }).passthrough()).min(1),
1349
1394
  usage: z2.unknown().optional()
1350
1395
  }).passthrough();
1396
+ var UnzoomResponseSchema = z2.object({
1397
+ image: z2.object({ base64: z2.string().min(1) }).passthrough(),
1398
+ original_size: z2.object({ width: z2.number().int(), height: z2.number().int() }).passthrough(),
1399
+ unzoomed_size: z2.object({ width: z2.number().int(), height: z2.number().int() }).passthrough(),
1400
+ zoom_factor_detected: z2.number(),
1401
+ usage: z2.unknown().optional()
1402
+ }).passthrough();
1403
+ var FontJobSchema = z2.object({
1404
+ job_id: z2.string().min(1),
1405
+ status: z2.string(),
1406
+ glyph_px: z2.number().int().nullable().optional(),
1407
+ download_atlas_url: z2.string().nullable().optional(),
1408
+ download_ttf_url: z2.string().nullable().optional(),
1409
+ usage: z2.unknown().optional()
1410
+ }).passthrough();
1351
1411
  var SelectFramesSchema = z2.object({ created_object_ids: z2.array(z2.string()) }).passthrough();
1352
1412
  var CharacterSubmitSchema = z2.object({
1353
1413
  background_job_id: z2.string().min(1),
@@ -1432,6 +1492,17 @@ function validateResponse(schema, raw, operation) {
1432
1492
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
1433
1493
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
1434
1494
  }
1495
+ function cleanupImages(args, operation) {
1496
+ const images = args.images ?? (args.image ? [args.image] : []);
1497
+ if (!images.length) throw new Error(`${operation} needs at least one image`);
1498
+ return images;
1499
+ }
1500
+ function cleanupResults(images, sent, operation) {
1501
+ if (images.length !== sent) {
1502
+ throw new Error(`Invalid PixelLab response for ${operation}: sent ${sent} image(s), got ${images.length} back`);
1503
+ }
1504
+ return images.map((image) => Buffer.from(image.base64, "base64"));
1505
+ }
1435
1506
  var PixelLabError = class extends ProviderError {
1436
1507
  constructor(message6, status, body) {
1437
1508
  super("pixellab", message6, { status });
@@ -1460,9 +1531,9 @@ var PixelLabClient = class {
1460
1531
  apiKey;
1461
1532
  http;
1462
1533
  /** Retries transport failures, 429, and 5xx; see http.ts for the policy. */
1463
- async request(path33, init) {
1534
+ async request(path34, init) {
1464
1535
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1465
- const res = await this.http(`${BASE}${path33}`, {
1536
+ const res = await this.http(`${BASE}${path34}`, {
1466
1537
  ...init,
1467
1538
  headers: {
1468
1539
  Authorization: auth,
@@ -1472,13 +1543,13 @@ var PixelLabClient = class {
1472
1543
  });
1473
1544
  const text = await res.text();
1474
1545
  if (!res.ok) {
1475
- throw new PixelLabError(`${init?.method ?? "GET"} ${path33} \u2192 ${res.status}`, res.status, text);
1546
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path34} \u2192 ${res.status}`, res.status, text);
1476
1547
  }
1477
1548
  if (!text) return {};
1478
1549
  try {
1479
1550
  return JSON.parse(text);
1480
1551
  } catch {
1481
- throw new Error(`${init?.method ?? "GET"} ${path33} returned invalid JSON`);
1552
+ throw new Error(`${init?.method ?? "GET"} ${path34} returned invalid JSON`);
1482
1553
  }
1483
1554
  }
1484
1555
  async balance() {
@@ -1864,10 +1935,10 @@ var PixelLabClient = class {
1864
1935
  const palette = args.paletteSwatchBase64 ? { color_image: { type: "base64", base64: args.paletteSwatchBase64, format: "png" }, force_colors: true } : {};
1865
1936
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
1866
1937
  const south = args.reference?.south;
1867
- let path33;
1938
+ let path34;
1868
1939
  let body;
1869
1940
  if (args.mode === "v3") {
1870
- path33 = "/create-character-v3";
1941
+ path34 = "/create-character-v3";
1871
1942
  body = {
1872
1943
  description: args.description,
1873
1944
  image_size: imageSize,
@@ -1881,7 +1952,7 @@ var PixelLabClient = class {
1881
1952
  ...args.enhancePrompt ? { enhance_prompt: true } : {}
1882
1953
  };
1883
1954
  } else if (args.mode === "pro-flash") {
1884
- path33 = "/create-character-pro-flash";
1955
+ path34 = "/create-character-pro-flash";
1885
1956
  const traits = args.styleTraits;
1886
1957
  body = {
1887
1958
  description: args.description,
@@ -1904,7 +1975,7 @@ var PixelLabClient = class {
1904
1975
  } : {}
1905
1976
  };
1906
1977
  } else if (args.mode === "pro") {
1907
- path33 = "/create-character-pro";
1978
+ path34 = "/create-character-pro";
1908
1979
  body = {
1909
1980
  description: args.description,
1910
1981
  image_size: imageSize,
@@ -1918,7 +1989,7 @@ var PixelLabClient = class {
1918
1989
  ...!south && args.styleCharacterId ? { style_character_id: args.styleCharacterId } : {}
1919
1990
  };
1920
1991
  } else {
1921
- path33 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
1992
+ path34 = args.directions === 4 ? "/create-character-with-4-directions" : "/create-character-with-8-directions";
1922
1993
  body = {
1923
1994
  description: args.description,
1924
1995
  image_size: imageSize,
@@ -1935,8 +2006,8 @@ var PixelLabClient = class {
1935
2006
  ...palette
1936
2007
  };
1937
2008
  }
1938
- const raw = await this.request(path33, { method: "POST", body: JSON.stringify(body) });
1939
- return validateResponse(CharacterSubmitSchema, raw, path33.slice(1));
2009
+ const raw = await this.request(path34, { method: "POST", body: JSON.stringify(body) });
2010
+ return validateResponse(CharacterSubmitSchema, raw, path34.slice(1));
1940
2011
  }
1941
2012
  /** A text edit of an existing character, applied to every direction and stored as a sibling. */
1942
2013
  async createCharacterState(args) {
@@ -2098,20 +2169,52 @@ var PixelLabClient = class {
2098
2169
  );
2099
2170
  }
2100
2171
  /**
2101
- * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image onto a
2102
- * smaller palette, synchronously — no `background_job_id`, the result
2103
- * comes back in this same response, like `createImagePixflux`. The
2104
- * schema's own response example is dollar-denominated (`usage: {type:
2105
- * "usd", usd: 0.02}`), which — matching this codebase's repeated
2106
- * experience with PixelLab's documented-vs-billed cost mismatches
2107
- * (`isometricTile`, `objectPro`) — did not hold: confirmed live against a
2108
- * Tier 2 account at exactly 0.1 generations for a 32x32 source
2109
- * (docs/REVISIONS.md). `numColors` and `paletteImage` are mutually
2110
- * exclusive upstream; the manifest schema already enforces that before
2111
- * this is ever called.
2172
+ * `/generate-ui-v2` ("Generate UI (Pro)"): one UI element (a button, a
2173
+ * health bar, an inventory slot, a dialogue box) from a description,
2174
+ * guided by an optional concept image and a natural-language color
2175
+ * palette. A plain background job; unlike `/create-ui-asset` there is no
2176
+ * piece/element layout, and sizes start at 16px instead of 192. Its
2177
+ * completed shape has not been exercised live; `pollUiElement` reads it
2178
+ * defensively.
2179
+ */
2180
+ async generateUiV2(args) {
2181
+ const body = {
2182
+ description: args.description,
2183
+ image_size: { width: args.width, height: args.height }
2184
+ };
2185
+ if (args.conceptImage) {
2186
+ body.concept_image = {
2187
+ image: args.conceptImage.image,
2188
+ size: { width: args.conceptImage.width, height: args.conceptImage.height }
2189
+ };
2190
+ }
2191
+ if (args.colorPalette) body.color_palette = args.colorPalette;
2192
+ if (args.noBackground != null) body.no_background = args.noBackground;
2193
+ if (args.seed != null) body.seed = args.seed;
2194
+ return validateResponse(
2195
+ RevisionJobSubmitSchema,
2196
+ await this.request("/generate-ui-v2", { method: "POST", body: JSON.stringify(body) }),
2197
+ "generate-ui-v2"
2198
+ );
2199
+ }
2200
+ /**
2201
+ * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image, or
2202
+ * several same-size frames together onto ONE shared palette, synchronously
2203
+ * — no `background_job_id`, the result comes back in this same response,
2204
+ * like `createImagePixflux`. Passing every frame of an animation (or every
2205
+ * direction of a character) in one call is the endpoint's whole point:
2206
+ * frames quantized separately drift onto different palettes. The schema's
2207
+ * own response example is dollar-denominated (`usage: {type: "usd", usd:
2208
+ * 0.02}`), which — matching this codebase's repeated experience with
2209
+ * PixelLab's documented-vs-billed cost mismatches (`isometricTile`,
2210
+ * `objectPro`) — did not hold: confirmed live against a Tier 2 account at
2211
+ * exactly 0.1 generations for a 32x32 source (docs/REVISIONS.md).
2212
+ * `numColors` and `paletteImage` are mutually exclusive upstream; the
2213
+ * manifest schema already enforces that before this is ever called.
2112
2214
  */
2113
2215
  async reduceColors(args) {
2114
- const body = { images: [args.image] };
2216
+ const images = cleanupImages(args, "reduce-colors");
2217
+ const body = { images };
2115
2218
  if (args.numColors != null) body.num_colors = args.numColors;
2116
2219
  if (args.paletteImage) body.palette_image = args.paletteImage;
2117
2220
  if (args.dithering) body.dithering = args.dithering;
@@ -2121,8 +2224,10 @@ var PixelLabClient = class {
2121
2224
  await this.request("/reduce-colors", { method: "POST", body: JSON.stringify(body) }),
2122
2225
  "reduce-colors"
2123
2226
  );
2227
+ const pngs = cleanupResults(res.images, images.length, "reduce-colors");
2124
2228
  return {
2125
- png: Buffer.from(res.images[0].base64, "base64"),
2229
+ png: pngs[0],
2230
+ pngs,
2126
2231
  paletteStripPng: Buffer.from(res.palette.base64, "base64"),
2127
2232
  nColors: res.n_colors,
2128
2233
  usage: res.usage
@@ -2131,19 +2236,117 @@ var PixelLabClient = class {
2131
2236
  /**
2132
2237
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
2133
2238
  * stray pixels without resizing, synchronously — same shape as
2134
- * `reduceColors` above, no background job. Cost is likewise confirmed
2135
- * live at 0.1 generations for a 32x32 source, not the schema's own
2239
+ * `reduceColors` above, no background job, and likewise corrects several
2240
+ * same-size frames together when given `images`. Cost is confirmed live
2241
+ * at 0.1 generations for a 32x32 source, not the schema's own
2136
2242
  * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
2137
2243
  */
2138
2244
  async correctPixelart(args) {
2139
- const body = { images: [args.image] };
2245
+ const images = cleanupImages(args, "correct-pixelart");
2246
+ const body = { images };
2140
2247
  if (args.strength != null) body.strength = args.strength;
2141
2248
  const res = validateResponse(
2142
2249
  CorrectPixelartResponseSchema,
2143
2250
  await this.request("/correct-pixelart", { method: "POST", body: JSON.stringify(body) }),
2144
2251
  "correct-pixelart"
2145
2252
  );
2146
- return { png: Buffer.from(res.images[0].base64, "base64"), usage: res.usage };
2253
+ const pngs = cleanupResults(res.images, images.length, "correct-pixelart");
2254
+ return { png: pngs[0], pngs, usage: res.usage };
2255
+ }
2256
+ /**
2257
+ * `/unzoom`: recover native-resolution pixel art from an upscaled image (a
2258
+ * 32x32 sprite saved at 512x512). Synchronous like the Cleanup tier.
2259
+ * PixelLab's own API overview names this "the most common cause of
2260
+ * disappointing output": run it on outside artwork before it becomes a
2261
+ * style or reference image. The input must be at least 256x256 and at
2262
+ * most 2048x2048 in area, and the result is opaque — transparency is
2263
+ * composited onto white before the grid is detected. `quantize` is 0 to
2264
+ * auto-detect a palette, -1 to keep every color, or 2-256 for exactly
2265
+ * that many.
2266
+ */
2267
+ async unzoom(args) {
2268
+ const body = { image: args.image };
2269
+ if (args.quantize != null) body.quantize = args.quantize;
2270
+ const res = validateResponse(
2271
+ UnzoomResponseSchema,
2272
+ await this.request("/unzoom", { method: "POST", body: JSON.stringify(body) }),
2273
+ "unzoom"
2274
+ );
2275
+ return {
2276
+ png: Buffer.from(res.image.base64, "base64"),
2277
+ originalSize: { width: res.original_size.width, height: res.original_size.height },
2278
+ unzoomedSize: { width: res.unzoomed_size.width, height: res.unzoomed_size.height },
2279
+ zoomFactor: res.zoom_factor_detected,
2280
+ usage: res.usage
2281
+ };
2282
+ }
2283
+ /**
2284
+ * `/interpolation-v2` ("Interpolate (Pro)"): generate the in-between
2285
+ * frames from one keyframe to another, guided by a short `action`. Unlike
2286
+ * `last_frame` on `/animate-with-text-v3`, both ends are required and the
2287
+ * frame count is the model's own choice (its docs say "typically 4-8").
2288
+ * Output frames are 16 to 128 pixels per side. A plain background job;
2289
+ * its completed shape is read defensively by `pollAnimateRevision`.
2290
+ */
2291
+ async interpolationV2(args) {
2292
+ const size = { width: args.width, height: args.height };
2293
+ const body = {
2294
+ start_image: { image: args.startImage, size },
2295
+ end_image: { image: args.endImage, size },
2296
+ action: args.action,
2297
+ image_size: size
2298
+ };
2299
+ if (args.seed != null) body.seed = args.seed;
2300
+ if (args.noBackground != null) body.no_background = args.noBackground;
2301
+ return validateResponse(
2302
+ RevisionJobSubmitSchema,
2303
+ await this.request("/interpolation-v2", { method: "POST", body: JSON.stringify(body) }),
2304
+ "interpolation-v2"
2305
+ );
2306
+ }
2307
+ /**
2308
+ * `/edit-animation-v2` ("Edit animation (Pro)"): apply one text edit
2309
+ * across every frame of an existing animation at once, so the change
2310
+ * stays consistent frame to frame. 2 to 16 frames, but the real ceiling
2311
+ * shrinks with frame size because every frame is packed into one grid:
2312
+ * 16 at up to 64px, 9 at 65-80px, 4 at 81-256px.
2313
+ */
2314
+ async editAnimationV2(args) {
2315
+ const size = { width: args.width, height: args.height };
2316
+ const body = {
2317
+ description: args.description,
2318
+ frames: args.frames.map((image) => ({ image, size })),
2319
+ image_size: size
2320
+ };
2321
+ if (args.seed != null) body.seed = args.seed;
2322
+ if (args.noBackground != null) body.no_background = args.noBackground;
2323
+ return validateResponse(
2324
+ RevisionJobSubmitSchema,
2325
+ await this.request("/edit-animation-v2", { method: "POST", body: JSON.stringify(body) }),
2326
+ "edit-animation-v2"
2327
+ );
2328
+ }
2329
+ /**
2330
+ * `/generate-font-pro`: an 80-glyph pixel font (A-Z, a-z, 0-9, common
2331
+ * game-UI punctuation) from a style description, delivered as a glyph
2332
+ * atlas PNG plus a ready-to-use `.ttf`. Documented at 25 subscription
2333
+ * generations. Poll with `getFontJob`.
2334
+ */
2335
+ async generateFontPro(args) {
2336
+ const body = { description: args.description, weight: args.weight };
2337
+ if (args.glyphPx != null) body.glyph_px = args.glyphPx;
2338
+ if (args.seed != null) body.seed = args.seed;
2339
+ if (args.fontName) body.font_name = args.fontName;
2340
+ return validateResponse(
2341
+ RevisionJobSubmitSchema,
2342
+ await this.request("/generate-font-pro", { method: "POST", body: JSON.stringify(body) }),
2343
+ "generate-font-pro"
2344
+ );
2345
+ }
2346
+ /** `GET /generate-font-pro/{job_id}`: status, then no-auth atlas and `.ttf` URLs once completed. */
2347
+ async getFontJob(jobId) {
2348
+ const raw = await this.request(`/generate-font-pro/${encodeURIComponent(jobId)}`);
2349
+ return validateResponse(FontJobSchema, raw, "generate-font-pro/{job_id}");
2147
2350
  }
2148
2351
  /**
2149
2352
  * `/animate-with-text-v3`: animate a loose image from a text description,
@@ -2804,10 +3007,10 @@ import { createReadStream } from "fs";
2804
3007
  function sha256(data) {
2805
3008
  return createHash("sha256").update(data).digest("hex");
2806
3009
  }
2807
- async function sha256File(path33) {
3010
+ async function sha256File(path34) {
2808
3011
  return new Promise((resolve, reject) => {
2809
3012
  const hash = createHash("sha256");
2810
- const stream = createReadStream(path33);
3013
+ const stream = createReadStream(path34);
2811
3014
  stream.on("data", (chunk2) => hash.update(chunk2));
2812
3015
  stream.on("error", reject);
2813
3016
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -2835,7 +3038,7 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
2835
3038
  // tile fields are undefined for every other generator. `tileSize` is
2836
3039
  // intentionally absent; width/height are derived from it, so it is
2837
3040
  // already covered.
2838
- noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
3041
+ noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.generator === "uiElement" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
2839
3042
  tileType: spec.tileType,
2840
3043
  tileView: spec.tileView,
2841
3044
  tileFeature: spec.tileFeature,
@@ -3042,7 +3245,7 @@ var PixelLabProvider = class _PixelLabProvider {
3042
3245
  return new _PixelLabProvider(new PixelLabClient("download-only"));
3043
3246
  }
3044
3247
  supports(generator) {
3045
- return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset";
3248
+ return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset" || generator === "uiElement";
3046
3249
  }
3047
3250
  /**
3048
3251
  * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
@@ -3052,12 +3255,15 @@ var PixelLabProvider = class _PixelLabProvider {
3052
3255
  * rather than a described edit — and `animate`/`animate-pixminimax`
3053
3256
  * (`/animate-with-text-v3`, `/animate-pixminimax`), which animate any loose
3054
3257
  * image from a text description with no character/object resource
3055
- * required. `outpaint` does not: there is no canvas-expansion endpoint in
3056
- * the API, matching docs/REVISIONS.md's note that no provider ships a
3057
- * tested outpaint path yet.
3258
+ * required — and `interpolate` (`/interpolation-v2`, the in-betweens
3259
+ * from one keyframe to another) and `edit-animation`
3260
+ * (`/edit-animation-v2`, one text edit applied across a whole frame set).
3261
+ * `outpaint` does not: there is no canvas-expansion endpoint in the API,
3262
+ * matching docs/REVISIONS.md's note that no provider ships a tested
3263
+ * outpaint path yet.
3058
3264
  */
3059
3265
  supportsRevision(mode2) {
3060
- return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax";
3266
+ return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax" || mode2 === "interpolate" || mode2 === "edit-animation";
3061
3267
  }
3062
3268
  /** PixelLab's own constraints: submissions must be >2s apart, and
3063
3269
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -3085,6 +3291,12 @@ var PixelLabProvider = class _PixelLabProvider {
3085
3291
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
3086
3292
  return { unit: "generations", amount: 0.1, candidates: 1 };
3087
3293
  }
3294
+ if (spec.revision?.mode === "interpolate" || spec.revision?.mode === "edit-animation") {
3295
+ const width = spec.revision.sourceWidth ?? spec.width;
3296
+ const height = spec.revision.sourceHeight ?? spec.height;
3297
+ const side = spec.revision.mode === "edit-animation" ? Math.ceil(Math.sqrt(Math.max(1, spec.revision.sourceMembers?.length ?? 1))) : 1;
3298
+ return { unit: "generations", amount: generationCost(width * side, height * side, "1dir"), candidates: 1 };
3299
+ }
3088
3300
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
3089
3301
  const width = spec.revision.sourceWidth ?? spec.width;
3090
3302
  const height = spec.revision.sourceHeight ?? spec.height;
@@ -3113,6 +3325,12 @@ var PixelLabProvider = class _PixelLabProvider {
3113
3325
  };
3114
3326
  }
3115
3327
  validate(spec, styleImages) {
3328
+ const members = spec.revision?.sourceMembers;
3329
+ if (spec.revision && members && !MEMBER_SET_REVISION_MODES.includes(spec.revision.mode)) {
3330
+ throw new Error(
3331
+ `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`
3332
+ );
3333
+ }
3116
3334
  if (spec.revision?.mode === "inpaint") {
3117
3335
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
3118
3336
  if (width != null && height != null && (width < 32 || height < 32 || width > 512 || height > 512)) {
@@ -3121,9 +3339,10 @@ var PixelLabProvider = class _PixelLabProvider {
3121
3339
  }
3122
3340
  if (spec.revision?.mode === "reduce-colors") {
3123
3341
  const { sourceWidth: width, sourceHeight: height } = spec.revision;
3124
- if (width != null && height != null && width * height > 512 * 512) {
3342
+ const count = members?.length ?? 1;
3343
+ if (width != null && height != null && width * height * count > 512 * 512) {
3125
3344
  throw new Error(
3126
- `PixelLab reduce-colors source is ${width}x${height} (${width * height}px\xB2); the API takes at most 512x512 worth of pixels per call`
3345
+ `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`
3127
3346
  );
3128
3347
  }
3129
3348
  }
@@ -3147,6 +3366,36 @@ var PixelLabProvider = class _PixelLabProvider {
3147
3366
  );
3148
3367
  }
3149
3368
  }
3369
+ if (spec.revision?.mode === "interpolate") {
3370
+ const { sourceWidth: width, sourceHeight: height, lastFrameWidth, lastFrameHeight } = spec.revision;
3371
+ if (width != null && height != null && (width < 16 || height < 16 || width > 128 || height > 128)) {
3372
+ throw new Error(`PixelLab interpolate keyframes are ${width}x${height}; the API takes 16 to 128 pixels per side`);
3373
+ }
3374
+ if (lastFrameWidth != null && lastFrameHeight != null && width != null && height != null && (lastFrameWidth !== width || lastFrameHeight !== height)) {
3375
+ throw new Error(
3376
+ `PixelLab interpolate lastFrame is ${lastFrameWidth}x${lastFrameHeight}; the start keyframe is ${width}x${height} \u2014 they must match`
3377
+ );
3378
+ }
3379
+ }
3380
+ if (spec.revision?.mode === "edit-animation") {
3381
+ const { sourceWidth: width, sourceHeight: height, sourceAssetId } = spec.revision;
3382
+ if (width != null && height != null) {
3383
+ if (!members || members.length < 2) {
3384
+ throw new Error(
3385
+ `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)`
3386
+ );
3387
+ }
3388
+ if (width < 16 || height < 16 || width > 256 || height > 256) {
3389
+ throw new Error(`PixelLab edit-animation frames are ${width}x${height}; the API takes 16 to 256 pixels per side`);
3390
+ }
3391
+ const ceiling = editAnimationFrameCeiling(width, height);
3392
+ if (members.length > ceiling) {
3393
+ throw new Error(
3394
+ `PixelLab edit-animation takes at most ${ceiling} frames at ${width}x${height} (every frame is packed into one grid); ${sourceAssetId} has ${members.length}`
3395
+ );
3396
+ }
3397
+ }
3398
+ }
3150
3399
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
3151
3400
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
3152
3401
  }
@@ -3163,6 +3412,14 @@ var PixelLabProvider = class _PixelLabProvider {
3163
3412
  `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)`
3164
3413
  );
3165
3414
  }
3415
+ if (spec.generator === "uiElement" && (spec.width < 16 || spec.height < 16 || spec.width > 792 || spec.height > 688)) {
3416
+ throw new Error(
3417
+ `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)`
3418
+ );
3419
+ }
3420
+ if (spec.generator === "uiElement" && styleImages.length > 1) {
3421
+ throw new Error("PixelLab uiElement takes one concept image; list at most one styleImages entry");
3422
+ }
3166
3423
  if (spec.generator === "map") {
3167
3424
  requirePixelLabOption("map", "view", spec.view, ["low top-down", "high top-down", "side"]);
3168
3425
  requirePixelLabOption("map", "outline", spec.outline, [
@@ -3246,8 +3503,8 @@ var PixelLabProvider = class _PixelLabProvider {
3246
3503
  throw new Error(`PixelLab isometricTile: size must be 16 to 64 pixels, got ${spec.width}`);
3247
3504
  }
3248
3505
  }
3249
- if (spec.generator === "character") this.validateCharacter(spec, styleImages);
3250
- if (spec.generator === "objectPro") this.validateObjectPro(spec, styleImages);
3506
+ if (spec.generator === "character" && !spec.revision) this.validateCharacter(spec, styleImages);
3507
+ if (spec.generator === "objectPro" && !spec.revision) this.validateObjectPro(spec, styleImages);
3251
3508
  if ((spec.generator === "map" || spec.generator === "pixflux") && styleImages.length) {
3252
3509
  throw new Error(`PixelLab ${spec.generator} does not support style images`);
3253
3510
  }
@@ -3814,6 +4071,19 @@ var PixelLabProvider = class _PixelLabProvider {
3814
4071
  });
3815
4072
  return { jobId: res2.ui_asset_id, metadata: { backgroundJobId: res2.background_job_id } };
3816
4073
  }
4074
+ if (spec.generator === "uiElement") {
4075
+ const concept = styleImages[0];
4076
+ const res2 = await this.client.generateUiV2({
4077
+ description: spec.prompt,
4078
+ width: spec.width,
4079
+ height: spec.height,
4080
+ conceptImage: concept ? { image: { base64: concept.base64, format: concept.format }, width: concept.width, height: concept.height } : void 0,
4081
+ colorPalette: spec.uiColorPalette,
4082
+ noBackground: spec.noBackground,
4083
+ seed: spec.seed
4084
+ });
4085
+ return { jobId: res2.background_job_id };
4086
+ }
3817
4087
  if (spec.generator === "pixflux") {
3818
4088
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
3819
4089
  const { png } = await this.client.createImagePixflux({
@@ -3942,13 +4212,18 @@ var PixelLabProvider = class _PixelLabProvider {
3942
4212
  if (!revision.sourceSha256 || !revision.sourceFormat || revision.sourceWidth == null || revision.sourceHeight == null) {
3943
4213
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
3944
4214
  }
3945
- const sourceBytes = readFileSync(revision.sourceFile);
3946
- if (sha256(sourceBytes) !== revision.sourceSha256) {
3947
- throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
3948
- }
3949
- const image = { base64: sourceBytes.toString("base64"), format: revision.sourceFormat };
4215
+ const sourceFiles = revision.sourceMembers ?? [{ file: revision.sourceFile, sha256: revision.sourceSha256, format: revision.sourceFormat }];
4216
+ const images = sourceFiles.map((member) => {
4217
+ const bytes = readFileSync(member.file);
4218
+ if (sha256(bytes) !== member.sha256) {
4219
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
4220
+ }
4221
+ return { base64: bytes.toString("base64"), format: member.format };
4222
+ });
4223
+ const image = images[0];
3950
4224
  const width = revision.sourceWidth;
3951
4225
  const height = revision.sourceHeight;
4226
+ const roles = revision.sourceMembers?.map((member) => member.role);
3952
4227
  if (revision.mode === "inpaint") {
3953
4228
  if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
3954
4229
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
@@ -3981,34 +4256,45 @@ var PixelLabProvider = class _PixelLabProvider {
3981
4256
  paletteImage = { base64: paletteBytes.toString("base64"), format: revision.paletteImageFormat };
3982
4257
  }
3983
4258
  const res2 = await this.client.reduceColors({
3984
- image,
4259
+ images,
3985
4260
  numColors: revision.numColors,
3986
4261
  paletteImage,
3987
4262
  dithering: revision.dithering,
3988
4263
  ditheringStrength: revision.ditheringStrength
3989
4264
  });
3990
- const jobId = randomUUID();
3991
- writeFileSync(path.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
3992
- return { jobId, metadata: { revisionUsage: res2.usage } };
4265
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
3993
4266
  }
3994
4267
  if (revision.mode === "correct-pixelart") {
3995
- const res2 = await this.client.correctPixelart({ image, strength: revision.strength });
3996
- const jobId = randomUUID();
3997
- writeFileSync(path.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
3998
- return { jobId, metadata: { revisionUsage: res2.usage } };
4268
+ const res2 = await this.client.correctPixelart({ images, strength: revision.strength });
4269
+ return this.cacheCleanupResult(res2.pngs, res2.usage, roles, revision.sourceFps);
4270
+ }
4271
+ if (revision.mode === "interpolate") {
4272
+ const lastFrame = this.readLastFrame(spec);
4273
+ if (!lastFrame) throw new Error(`${spec.styleId}/${spec.assetId}: interpolate needs lastFrame, the ending keyframe`);
4274
+ const res2 = await this.client.interpolationV2({
4275
+ startImage: image,
4276
+ endImage: lastFrame,
4277
+ width,
4278
+ height,
4279
+ action: spec.prompt,
4280
+ seed: spec.seed,
4281
+ noBackground: spec.noBackground
4282
+ });
4283
+ return { jobId: res2.background_job_id };
4284
+ }
4285
+ if (revision.mode === "edit-animation") {
4286
+ const res2 = await this.client.editAnimationV2({
4287
+ frames: images,
4288
+ width,
4289
+ height,
4290
+ description: spec.prompt,
4291
+ seed: spec.seed,
4292
+ noBackground: spec.noBackground
4293
+ });
4294
+ return { jobId: res2.background_job_id };
3999
4295
  }
4000
4296
  if (revision.mode === "animate" || revision.mode === "animate-pixminimax") {
4001
- let lastFrame;
4002
- if (revision.lastFrameFile) {
4003
- if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
4004
- throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
4005
- }
4006
- const lastFrameBytes = readFileSync(revision.lastFrameFile);
4007
- if (sha256(lastFrameBytes) !== revision.lastFrameSha256) {
4008
- throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
4009
- }
4010
- lastFrame = { base64: lastFrameBytes.toString("base64"), format: revision.lastFrameFormat };
4011
- }
4297
+ const lastFrame = this.readLastFrame(spec);
4012
4298
  if (revision.mode === "animate") {
4013
4299
  const res3 = await this.client.animateWithTextV3({
4014
4300
  firstFrame: image,
@@ -4048,6 +4334,36 @@ var PixelLabProvider = class _PixelLabProvider {
4048
4334
  });
4049
4335
  return { jobId: res.background_job_id };
4050
4336
  }
4337
+ /** A revision's pinned ending frame, proven to be the bytes the spec hashed; undefined when none is declared. */
4338
+ readLastFrame(spec) {
4339
+ const revision = spec.revision;
4340
+ if (!revision.lastFrameFile) return void 0;
4341
+ if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
4342
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
4343
+ }
4344
+ const bytes = readFileSync(revision.lastFrameFile);
4345
+ if (sha256(bytes) !== revision.lastFrameSha256) {
4346
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
4347
+ }
4348
+ return { base64: bytes.toString("base64"), format: revision.lastFrameFormat };
4349
+ }
4350
+ /**
4351
+ * Park a synchronous Cleanup-tier result the way pixflux parks its own:
4352
+ * one `<id>.png` for a single image, or `<id>-<n>.png` per member with the
4353
+ * member roles recorded so `pollCachedRevision` hands them back under the
4354
+ * same names the parent's set used.
4355
+ */
4356
+ cacheCleanupResult(pngs, usage, roles, fps) {
4357
+ const jobId = randomUUID();
4358
+ if (!roles) {
4359
+ writeFileSync(path.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
4360
+ return { jobId, metadata: { revisionUsage: usage } };
4361
+ }
4362
+ for (const [index, png] of pngs.entries()) {
4363
+ writeFileSync(path.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
4364
+ }
4365
+ return { jobId, metadata: { revisionUsage: usage, revisionMembers: roles, ...fps != null ? { revisionFps: fps } : {} } };
4366
+ }
4051
4367
  /**
4052
4368
  * What a background job actually billed, once its own record is still
4053
4369
  * around to ask; see `billedFromUsage`. A stale or already-cleaned-up job
@@ -4068,7 +4384,7 @@ var PixelLabProvider = class _PixelLabProvider {
4068
4384
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart") {
4069
4385
  return this.pollCachedRevision(jobId, context);
4070
4386
  }
4071
- if (revisionMode === "animate" || revisionMode === "animate-pixminimax") {
4387
+ if (revisionMode === "animate" || revisionMode === "animate-pixminimax" || revisionMode === "interpolate" || revisionMode === "edit-animation") {
4072
4388
  return this.pollAnimateRevision(jobId, context);
4073
4389
  }
4074
4390
  if (context?.spec?.revision) return this.pollRevision(jobId);
@@ -4091,6 +4407,7 @@ var PixelLabProvider = class _PixelLabProvider {
4091
4407
  if (generator === "character") return this.pollCharacter(jobId, context);
4092
4408
  if (generator === "objectPro") return this.pollObjectPro(jobId, context);
4093
4409
  if (generator === "uiAsset") return this.pollUiAsset(jobId, context);
4410
+ if (generator === "uiElement") return this.pollUiElement(jobId);
4094
4411
  const backgroundJobId = context?.metadata?.backgroundJobId;
4095
4412
  const obj = await this.client.getObject(jobId);
4096
4413
  if (obj.status === "review") {
@@ -4117,15 +4434,32 @@ var PixelLabProvider = class _PixelLabProvider {
4117
4434
  * work.
4118
4435
  */
4119
4436
  async pollCachedRevision(jobId, context) {
4120
- const file = path.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
4121
- if (!existsSync(file)) {
4437
+ const usage = context?.metadata?.revisionUsage;
4438
+ const roles = context?.metadata?.revisionMembers;
4439
+ const gone = {
4440
+ status: "failed",
4441
+ error: "revision result is no longer cached locally; re-run submit for this asset"
4442
+ };
4443
+ if (Array.isArray(roles) && roles.length) {
4444
+ const sources = roles.map((role, index) => ({
4445
+ url: `file://${path.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
4446
+ role: String(role)
4447
+ }));
4448
+ if (sources.some((source) => !existsSync(source.url.slice("file://".length)))) return gone;
4449
+ const fps = context?.metadata?.revisionFps;
4450
+ const frames = sources.every((source) => source.role.startsWith("frame-"));
4122
4451
  return {
4123
- status: "failed",
4124
- error: "revision result is no longer cached locally; re-run submit for this asset"
4452
+ status: "ready",
4453
+ objectId: jobId,
4454
+ sourceUrl: sources[0].url,
4455
+ sources,
4456
+ ...frames && typeof fps === "number" ? { metadata: { frameSet: { fps, count: sources.length } } } : {},
4457
+ billed: billedFromUsage(usage)
4125
4458
  };
4126
4459
  }
4460
+ const file = path.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
4461
+ if (!existsSync(file)) return gone;
4127
4462
  const sourceUrl = `file://${file}`;
4128
- const usage = context?.metadata?.revisionUsage;
4129
4463
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
4130
4464
  }
4131
4465
  /**
@@ -4149,16 +4483,21 @@ var PixelLabProvider = class _PixelLabProvider {
4149
4483
  if (job.status !== "completed") return { status: "processing" };
4150
4484
  const billed = billedFromUsage(job.usage);
4151
4485
  const done = job.last_response ?? {};
4152
- const fps = context?.spec?.revision?.fps ?? 8;
4153
- const review = (frameUrls) => ({
4154
- status: "review-set",
4155
- objectId: jobId,
4156
- frameUrls,
4157
- sources: frameUrls.map((url, index) => ({ url, role: `frame-${String(index).padStart(2, "0")}` })),
4158
- fps,
4159
- metadata: { frameSet: { fps, count: frameUrls.length } },
4160
- billed
4161
- });
4486
+ const revision = context?.spec?.revision;
4487
+ const fps = revision?.fps ?? revision?.sourceFps ?? 8;
4488
+ const memberRoles = revision?.mode === "edit-animation" ? revision.sourceMembers?.map((member) => member.role) : void 0;
4489
+ const review = (frameUrls) => {
4490
+ const roles = memberRoles?.length === frameUrls.length ? memberRoles : frameUrls.map((_, index) => `frame-${String(index).padStart(2, "0")}`);
4491
+ return {
4492
+ status: "review-set",
4493
+ objectId: jobId,
4494
+ frameUrls,
4495
+ sources: frameUrls.map((url, index) => ({ url, role: roles[index] })),
4496
+ fps,
4497
+ metadata: roles.every((role) => role.startsWith("frame-")) ? { frameSet: { fps, count: frameUrls.length } } : {},
4498
+ billed
4499
+ };
4500
+ };
4162
4501
  for (const key of ["frame_urls", "frames", "images"]) {
4163
4502
  const list = done[key];
4164
4503
  if (!Array.isArray(list) || !list.length) continue;
@@ -4190,7 +4529,7 @@ var PixelLabProvider = class _PixelLabProvider {
4190
4529
  }
4191
4530
  return {
4192
4531
  status: "failed",
4193
- 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`
4532
+ 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`
4194
4533
  };
4195
4534
  }
4196
4535
  /**
@@ -4280,6 +4619,36 @@ var PixelLabProvider = class _PixelLabProvider {
4280
4619
  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`
4281
4620
  };
4282
4621
  }
4622
+ /**
4623
+ * `/generate-ui-v2` completes a plain background job whose
4624
+ * `last_response.images` (per its own docs and Python client) holds the
4625
+ * result. One image is the expected case and goes straight to selected;
4626
+ * should the endpoint ever return several, they go to review like
4627
+ * imagePro's candidates, decoded to the same local cache. The shape has
4628
+ * not been observed live, so anything else fails naming the keys received.
4629
+ */
4630
+ async pollUiElement(jobId) {
4631
+ const job = await this.client.getBackgroundJob(jobId);
4632
+ if (job.status === "failed") return { status: "failed", error: "UI element job failed upstream" };
4633
+ if (job.status !== "completed") return { status: "processing" };
4634
+ const billed = billedFromUsage(job.usage);
4635
+ const done = job.last_response ?? {};
4636
+ const images = Array.isArray(done.images) ? done.images : done.image ? [done.image] : [];
4637
+ const urls = [];
4638
+ for (const [index, candidate] of images.entries()) {
4639
+ const base64 = extractBase64(candidate);
4640
+ if (!base64) continue;
4641
+ const file = path.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
4642
+ writeFileSync(file, Buffer.from(base64, "base64"));
4643
+ urls.push(`file://${file}`);
4644
+ }
4645
+ if (urls.length === 1) return { status: "ready", objectId: jobId, sourceUrl: urls[0], sources: [{ url: urls[0] }], billed };
4646
+ if (urls.length > 1) return { status: "review", candidateUrls: urls, billed };
4647
+ return {
4648
+ status: "failed",
4649
+ 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`
4650
+ };
4651
+ }
4283
4652
  /**
4284
4653
  * Map objects need their own path because the `/map-objects/{id}` record is
4285
4654
  * deleted upstream roughly 8 hours after creation while the image survives in
@@ -4440,9 +4809,9 @@ var PixelLabProvider = class _PixelLabProvider {
4440
4809
  if (!url) throw new Error(`tiles job ${jobId} has no variation at index ${index}`);
4441
4810
  return { objectId: `${jobId}#${index}`, sourceUrl: url };
4442
4811
  }
4443
- if (generator === "imagePro") {
4812
+ if (generator === "imagePro" || generator === "uiElement") {
4444
4813
  const file = path.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
4445
- if (!existsSync(file)) throw new Error(`imagePro job ${jobId} has no cached candidate at index ${index}`);
4814
+ if (!existsSync(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
4446
4815
  return { objectId: `${jobId}#${index}`, sourceUrl: `file://${file}` };
4447
4816
  }
4448
4817
  const promoted = await this.client.selectFrames(jobId, [index], commonTag);
@@ -4596,6 +4965,12 @@ function readPose(spec, what, image) {
4596
4965
  }
4597
4966
  return { base64: bytes.toString("base64"), format: image.format };
4598
4967
  }
4968
+ function editAnimationFrameCeiling(width, height) {
4969
+ const side = Math.max(width, height);
4970
+ if (side <= 64) return 16;
4971
+ if (side <= 80) return 9;
4972
+ return 4;
4973
+ }
4599
4974
  function rotationSources(character) {
4600
4975
  const order = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
4601
4976
  const sources = [];
@@ -4688,9 +5063,9 @@ var RetroDiffusionClient = class {
4688
5063
  if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
4689
5064
  return Buffer.from(await response.arrayBuffer());
4690
5065
  }
4691
- async call(path33, init = {}) {
5066
+ async call(path34, init = {}) {
4692
5067
  if (!this.token) throw new Error("RD_API_KEY is not set");
4693
- const response = await this.request(`${this.baseUrl}${path33}`, {
5068
+ const response = await this.request(`${this.baseUrl}${path34}`, {
4694
5069
  ...init,
4695
5070
  headers: {
4696
5071
  "Content-Type": "application/json",
@@ -5732,14 +6107,15 @@ var ComfyUIProvider = class _ComfyUIProvider {
5732
6107
  }
5733
6108
  /**
5734
6109
  * Everything else resolves generically to whatever the user's own
5735
- * workflow does with `bindings.sourceImage`. `animate`/`animate-pixminimax`
5736
- * are the one exception: they produce an ordered frame set, and this
5737
- * adapter's revision path always writes a single output image (see
5738
- * `submit`/`fetch` below) — a real structural gap, not a missing binding,
5739
- * so it is rejected here rather than only failing once `validate` runs.
6110
+ * workflow does with `bindings.sourceImage`. The frame-set modes
6111
+ * (`animate`, `animate-pixminimax`, `interpolate`, `edit-animation`) are
6112
+ * the exception: they produce an ordered frame set, and this adapter's
6113
+ * revision path always writes a single output image (see `submit`/`fetch`
6114
+ * below) — a real structural gap, not a missing binding, so they are
6115
+ * rejected here rather than only failing once `validate` runs.
5740
6116
  */
5741
6117
  supportsRevision(mode2) {
5742
- return mode2 !== "animate" && mode2 !== "animate-pixminimax";
6118
+ return !FRAME_SET_REVISION_MODES.includes(mode2);
5743
6119
  }
5744
6120
  estimate(spec) {
5745
6121
  return {
@@ -5792,6 +6168,11 @@ var ComfyUIProvider = class _ComfyUIProvider {
5792
6168
  throw new Error("ComfyUI providerOptions.frames requires generator: frames");
5793
6169
  }
5794
6170
  if (spec.revision) {
6171
+ if (spec.revision.sourceMembers) {
6172
+ throw new Error(
6173
+ `ComfyUI revisions read one source image; ${spec.revision.sourceAssetId} is a ${spec.revision.sourceMembers.length}-member set (directions or frames)`
6174
+ );
6175
+ }
5795
6176
  if (!options.bindings.sourceImage) {
5796
6177
  throw new Error("ComfyUI revisions require bindings.sourceImage");
5797
6178
  }
@@ -6626,6 +7007,91 @@ import { readFile as readFile3 } from "fs/promises";
6626
7007
  import { existsSync as existsSync4 } from "fs";
6627
7008
  import path5 from "path";
6628
7009
 
7010
+ // src/loop-directions.ts
7011
+ var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
7012
+ var MIRROR_FIELDS = ["category", "tags", "styles"];
7013
+ function loopDirectionId(shorthandId, direction) {
7014
+ return `${shorthandId}.${direction}`;
7015
+ }
7016
+ function expandLoopDirections(raw) {
7017
+ const families = {};
7018
+ const issues = [];
7019
+ if (!isObject2(raw) || !isObject2(raw.assets)) return { raw, families, issues };
7020
+ const assets = raw.assets;
7021
+ if (!Object.values(assets).some((asset) => isObject2(asset) && isObject2(asset.animation) && "directions" in asset.animation)) {
7022
+ return { raw, families, issues };
7023
+ }
7024
+ const expanded = {};
7025
+ const claimed = new Set(Object.keys(assets));
7026
+ for (const [id, asset] of Object.entries(assets)) {
7027
+ if (!isObject2(asset) || !isObject2(asset.animation) || !("directions" in asset.animation)) {
7028
+ expanded[id] = asset;
7029
+ continue;
7030
+ }
7031
+ const where = `assets.${id}.animation.directions`;
7032
+ const { directions: rawDirections, ...animation } = asset.animation;
7033
+ const parsed = CharacterDirectionSchema.array().min(1).safeParse(rawDirections);
7034
+ if (!parsed.success) {
7035
+ issues.push(`${where}: expected a non-empty list of directions (${CharacterDirectionSchema.options.join(", ")})`);
7036
+ continue;
7037
+ }
7038
+ const directions = parsed.data;
7039
+ const duplicate = directions.find((direction, index) => directions.indexOf(direction) !== index);
7040
+ if (duplicate) {
7041
+ issues.push(`${where}: "${duplicate}" is listed twice`);
7042
+ continue;
7043
+ }
7044
+ if ("direction" in animation) {
7045
+ issues.push(`assets.${id}.animation: direction and directions are mutually exclusive; directions names every loop to make`);
7046
+ continue;
7047
+ }
7048
+ const single = SINGLE_OUTPUT_FIELDS.filter((field) => field in asset);
7049
+ if (single.length) {
7050
+ issues.push(
7051
+ `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`
7052
+ );
7053
+ continue;
7054
+ }
7055
+ const loops = directions.map((direction) => ({ id: loopDirectionId(id, direction), direction }));
7056
+ const named = new Set(directions);
7057
+ const mirrors = [];
7058
+ for (const { id: loopId, direction } of loops) {
7059
+ const flipped = MIRRORED_DIRECTION[direction];
7060
+ if (flipped === direction || named.has(flipped)) continue;
7061
+ named.add(flipped);
7062
+ mirrors.push({ id: loopDirectionId(id, flipped), of: loopId });
7063
+ }
7064
+ const ids = [...loops.map((loop) => loop.id), ...mirrors.map((mirror) => mirror.id)];
7065
+ const collision = ids.find((expandedId) => claimed.has(expandedId));
7066
+ if (collision) {
7067
+ issues.push(`${where}: expands to "${collision}", which is already declared`);
7068
+ continue;
7069
+ }
7070
+ for (const expandedId of ids) claimed.add(expandedId);
7071
+ for (const loop of loops) {
7072
+ expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
7073
+ }
7074
+ for (const mirror of mirrors) {
7075
+ const inherited = {};
7076
+ for (const field of MIRROR_FIELDS) if (field in asset) inherited[field] = asset[field];
7077
+ expanded[mirror.id] = { mirror: mirror.of, ...inherited };
7078
+ }
7079
+ families[id] = ids;
7080
+ }
7081
+ return { raw: { ...raw, assets: expanded }, families, issues };
7082
+ }
7083
+ function expandAssetFilter(ids, families) {
7084
+ if (!families) return [...ids];
7085
+ return [...new Set(ids.flatMap((id) => families[id] ?? [id]))];
7086
+ }
7087
+ function loopShorthandOf(assetId, families) {
7088
+ if (!families) return void 0;
7089
+ return Object.keys(families).find((id) => families[id].includes(assetId));
7090
+ }
7091
+ function isObject2(value) {
7092
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7093
+ }
7094
+
6629
7095
  // src/outputs.ts
6630
7096
  import { existsSync as existsSync3 } from "fs";
6631
7097
  import path4 from "path";
@@ -6944,7 +7410,12 @@ function frameSetFps(entry) {
6944
7410
  async function loadManifest(manifestPath) {
6945
7411
  const abs = path5.resolve(manifestPath);
6946
7412
  if (!existsSync4(abs)) throw new ProjectError(`No manifest at ${abs}`);
6947
- const input = ManifestInputSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
7413
+ const expansion = expandLoopDirections(JSON.parse(await readFile3(abs, "utf8")));
7414
+ if (expansion.issues.length) {
7415
+ throw new ProjectError(`Manifest at ${abs} is invalid:
7416
+ ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
7417
+ }
7418
+ const input = ManifestInputSchema.safeParse(expansion.raw);
6948
7419
  if (!input.success) {
6949
7420
  const issues = formatManifestIssues(input.error.issues);
6950
7421
  throw new ProjectError(`Manifest at ${abs} is invalid:
@@ -7029,7 +7500,8 @@ ${issues}`);
7029
7500
  throw new ProjectError(`Manifest at ${abs} is invalid:
7030
7501
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
7031
7502
  }
7032
- return { manifest: parsed.data, root: path5.dirname(abs), path: abs };
7503
+ const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
7504
+ return { manifest: parsed.data, root: path5.dirname(abs), path: abs, ...loopFamilies };
7033
7505
  }
7034
7506
  function parentAssetId(asset) {
7035
7507
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
@@ -7113,12 +7585,13 @@ async function resolveSpecs(loaded, filter) {
7113
7585
  );
7114
7586
  }
7115
7587
  }
7116
- for (const unknownAsset of filter?.assets ?? []) {
7588
+ const assetFilter = expandAssetFilter(filter?.assets ?? [], loaded.loopFamilies);
7589
+ for (const unknownAsset of assetFilter) {
7117
7590
  if (!manifest.assets[unknownAsset]) {
7118
7591
  throw new UsageError(`Unknown asset "${unknownAsset}".`);
7119
7592
  }
7120
7593
  }
7121
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
7594
+ const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
7122
7595
  const resolutionAssetIds = new Set(requestedAssetIds);
7123
7596
  for (const assetId of [...requestedAssetIds]) {
7124
7597
  let current = parentAssetId(manifest.assets[assetId]);
@@ -7213,7 +7686,7 @@ async function resolveSpecs(loaded, filter) {
7213
7686
  size = asset.size ?? style.size ?? 64;
7214
7687
  width = size;
7215
7688
  height = size;
7216
- } else if (generator === "uiAsset") {
7689
+ } else if (generator === "uiAsset" || generator === "uiElement") {
7217
7690
  width = asset.width ?? style.size ?? 256;
7218
7691
  height = asset.height ?? style.size ?? 256;
7219
7692
  size = Math.max(width, height);
@@ -7280,7 +7753,7 @@ async function resolveSpecs(loaded, filter) {
7280
7753
  seed: style.seed,
7281
7754
  uiPieces: generator === "uiAsset" ? asset.pieces : void 0,
7282
7755
  uiElements: generator === "uiAsset" ? asset.elements : void 0,
7283
- uiColorPalette: generator === "uiAsset" ? style.uiColorPalette : void 0,
7756
+ uiColorPalette: generator === "uiAsset" || generator === "uiElement" ? style.uiColorPalette : void 0,
7284
7757
  palette: style.palette,
7285
7758
  enforcePalette: style.enforcePalette,
7286
7759
  noBackground: style.noBackground,
@@ -7320,8 +7793,11 @@ async function resolveSpecs(loaded, filter) {
7320
7793
  terrainView: generator === "terrain" ? style.terrainView : void 0,
7321
7794
  isometricTileSize: generator === "isometricTile" ? style.isometricTileSize : void 0,
7322
7795
  isometricTileShape: generator === "isometricTile" ? style.isometricTileShape : void 0,
7323
- ...generator === "character" ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7324
- ...generator === "objectPro" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7796
+ // A revision in a character or objectPro style is a provider edit of
7797
+ // its parent's pixels, not a new character: giving it a base shape
7798
+ // would have packing, the gallery, and adoption treat it as one.
7799
+ ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7800
+ ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7325
7801
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
7326
7802
  costUnit: "generations",
7327
7803
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -7472,8 +7948,11 @@ async function resolveSpecs(loaded, filter) {
7472
7948
  );
7473
7949
  }
7474
7950
  const sourceSpec = await finalize(asset.revision.from);
7475
- const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path5.resolve(root, sourceSpec.source) : sourceSpec.outFile);
7476
- const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
7951
+ const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path5.resolve(root, sourceSpec.source) : sourceSpec.outFile);
7952
+ const sourceMembers = await revisionSourceMembers(sourceStem, `revision source ${asset.revision.from}`);
7953
+ const sourceFile = sourceMembers?.[0]?.file ?? sourceStem;
7954
+ const sourceImage = sourceMembers ? { ...sourceMembers[0], hash: sha256(JSON.stringify(sourceMembers.map((member) => member.sha256))) } : await optionalRevisionImage(sourceFile, "revision source");
7955
+ const sourceFps = sourceSpec.character?.animation?.fps ?? sourceSpec.objectPro?.animation?.fps ?? sourceSpec.revision?.fps ?? sourceSpec.revision?.sourceFps;
7477
7956
  const maskFile = asset.revision.mask ? path5.resolve(root, asset.revision.mask) : void 0;
7478
7957
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
7479
7958
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
@@ -7494,6 +7973,8 @@ async function resolveSpecs(loaded, filter) {
7494
7973
  sourceHeight: sourceImage?.height ?? null,
7495
7974
  sourceFormat: sourceImage?.format ?? null,
7496
7975
  sourceSpec,
7976
+ ...sourceMembers ? { sourceMembers } : {},
7977
+ ...sourceMembers && sourceFps != null ? { sourceFps } : {},
7497
7978
  ...maskFile ? {
7498
7979
  maskFile,
7499
7980
  maskSha256: maskImage?.hash ?? null,
@@ -7643,6 +8124,38 @@ async function optionalRevisionImage(file, label, requiredFormat) {
7643
8124
  }
7644
8125
  return { hash: sha256(bytes), ...metadata };
7645
8126
  }
8127
+ async function revisionSourceMembers(stem, label) {
8128
+ if (existsSync4(stem)) return null;
8129
+ const at = (role) => memberPath(stem, role, 0, 2, MediaType.PNG);
8130
+ let roles = [];
8131
+ for (const set of [CHARACTER_DIRECTIONS_8, CHARACTER_DIRECTIONS_4]) {
8132
+ if (set.every((direction) => existsSync4(at(direction)))) {
8133
+ roles = [...set];
8134
+ break;
8135
+ }
8136
+ }
8137
+ if (!roles.length) {
8138
+ for (let index = 0; existsSync4(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
8139
+ roles.push(`frame-${String(index).padStart(2, "0")}`);
8140
+ }
8141
+ }
8142
+ if (!roles.length) return null;
8143
+ const members = [];
8144
+ for (const role of roles) {
8145
+ const file = at(role);
8146
+ const image = await optionalRevisionImage(file, `${label} (${role})`);
8147
+ if (!image) return null;
8148
+ members.push({ role, file, sha256: image.hash, width: image.width, height: image.height, format: image.format });
8149
+ }
8150
+ const first = members[0];
8151
+ const odd = members.find((member) => member.width !== first.width || member.height !== first.height);
8152
+ if (odd) {
8153
+ throw new Error(
8154
+ `${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`
8155
+ );
8156
+ }
8157
+ return members;
8158
+ }
7646
8159
  async function resolveStyleImages(loaded, styleId) {
7647
8160
  const style = loaded.manifest.styles[styleId];
7648
8161
  if (!style) throw new Error(`Unknown style "${styleId}"`);
@@ -9325,11 +9838,16 @@ async function inspectRevision(spec, lock, seen) {
9325
9838
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
9326
9839
  };
9327
9840
  }
9328
- if (!revision.sourceSha256 || !existsSync7(revision.sourceFile)) {
9329
- return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
9330
- }
9331
- if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
9332
- return { ready: false, reason: "revision source changed after the manifest was resolved" };
9841
+ if (revision.sourceMembers) {
9842
+ const members = await inspectSourceMembers(spec, lock);
9843
+ if (!members.ready) return members;
9844
+ } else {
9845
+ if (!revision.sourceSha256 || !existsSync7(revision.sourceFile)) {
9846
+ return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
9847
+ }
9848
+ if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
9849
+ return { ready: false, reason: "revision source changed after the manifest was resolved" };
9850
+ }
9333
9851
  }
9334
9852
  if (revision.maskFile) {
9335
9853
  if (!revision.maskSha256 || !existsSync7(revision.maskFile)) {
@@ -9339,8 +9857,39 @@ async function inspectRevision(spec, lock, seen) {
9339
9857
  return { ready: false, reason: "revision mask changed after the manifest was resolved" };
9340
9858
  }
9341
9859
  }
9860
+ if (revision.lastFrameFile) {
9861
+ if (!revision.lastFrameSha256 || !existsSync7(revision.lastFrameFile)) {
9862
+ return { ready: false, reason: `revision last frame is missing: ${revision.lastFrameFile}` };
9863
+ }
9864
+ if (await sha256File(revision.lastFrameFile) !== revision.lastFrameSha256) {
9865
+ return { ready: false, reason: "revision last frame changed after the manifest was resolved" };
9866
+ }
9867
+ }
9342
9868
  return { ready: true, reason: "revision inputs are current" };
9343
9869
  }
9870
+ async function inspectSourceMembers(spec, lock) {
9871
+ const revision = spec.revision;
9872
+ const members = revision.sourceMembers;
9873
+ for (const member of members) {
9874
+ if (!existsSync7(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
9875
+ if (await sha256File(member.file) !== member.sha256) {
9876
+ return { ready: false, reason: `revision source member changed after the manifest was resolved: ${member.file}` };
9877
+ }
9878
+ }
9879
+ const parent = revision.sourceSpec;
9880
+ if (parent.source) return { ready: true, reason: "declared source set is present" };
9881
+ const entry = lock.entries[lockKey(parent.styleId, parent.assetId)];
9882
+ if (!entry) return { ready: false, reason: "revision source is not in the lockfile" };
9883
+ const recorded = entry.outputs.map((_, index) => path11.resolve(currentEntryOutputPath(entry, parent, index))).sort();
9884
+ const found = members.map((member) => path11.resolve(member.file)).sort();
9885
+ if (recorded.length !== found.length || recorded.some((file, index) => file !== found[index])) {
9886
+ return {
9887
+ ready: false,
9888
+ 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`
9889
+ };
9890
+ }
9891
+ return { ready: true, reason: "revision source set is current" };
9892
+ }
9344
9893
  async function requireRevisionReady(spec, lock) {
9345
9894
  const inspection = await inspectRevisionReadiness(spec, lock);
9346
9895
  if (inspection && !inspection.ready) throw new Error(inspection.reason);
@@ -10175,16 +10724,99 @@ function hex(c) {
10175
10724
  return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
10176
10725
  }
10177
10726
 
10178
- // src/pipeline/quality-regression.ts
10179
- import path16 from "path";
10727
+ // src/pixellab-utils.ts
10180
10728
  import { existsSync as existsSync11 } from "fs";
10181
- import { readFile as readFile9 } from "fs/promises";
10729
+ import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
10730
+ import path16 from "path";
10731
+ var UNZOOM_MIN_SIDE = 256;
10732
+ var UNZOOM_MAX_AREA = 2048 * 2048;
10733
+ function defaultUnzoomOut(input) {
10734
+ const ext = path16.extname(input);
10735
+ return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
10736
+ }
10737
+ async function unzoomFile(client2, input, opts = {}) {
10738
+ const out = path16.resolve(opts.out ?? defaultUnzoomOut(input));
10739
+ if (path16.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
10740
+ if (existsSync11(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
10741
+ const bytes = await readFile9(input);
10742
+ const metadata = imageMetadata(bytes);
10743
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
10744
+ const { width, height, format } = metadata;
10745
+ if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
10746
+ throw new Error(
10747
+ `${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`
10748
+ );
10749
+ }
10750
+ if (width * height > UNZOOM_MAX_AREA) {
10751
+ throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
10752
+ }
10753
+ const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
10754
+ await mkdir4(path16.dirname(out), { recursive: true });
10755
+ await writeFile4(out, res.png);
10756
+ return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
10757
+ }
10758
+ var FONT_WEIGHTS = ["Bold", "Regular"];
10759
+ var FONT_GLYPH_SIZES = [8, 16, 32, 64];
10760
+ var FONT_COST_GENERATIONS = 25;
10761
+ function fontOutputPaths(out) {
10762
+ const base = path16.resolve(out.replace(/\.(ttf|png)$/i, ""));
10763
+ return { ttf: `${base}.ttf`, atlas: `${base}.png` };
10764
+ }
10765
+ async function generateFont(client2, opts) {
10766
+ const { ttf, atlas } = fontOutputPaths(opts.out);
10767
+ if (!opts.force) {
10768
+ for (const file of [ttf, atlas]) {
10769
+ if (existsSync11(file)) throw new Error(`${file} already exists; pass --force to replace it`);
10770
+ }
10771
+ }
10772
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
10773
+ const interval = opts.intervalMs ?? 5e3;
10774
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
10775
+ const submitted = await client2.generateFontPro({
10776
+ description: opts.description,
10777
+ weight: opts.weight,
10778
+ glyphPx: opts.glyphPx,
10779
+ seed: opts.seed,
10780
+ fontName: opts.fontName
10781
+ });
10782
+ const jobId = submitted.background_job_id;
10783
+ const started = Date.now();
10784
+ for (; ; ) {
10785
+ const job = await client2.getFontJob(jobId);
10786
+ opts.onProgress?.(job.status);
10787
+ if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
10788
+ if (job.status === "completed") {
10789
+ if (!job.download_ttf_url || !job.download_atlas_url) {
10790
+ throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
10791
+ }
10792
+ const [ttfBytes, atlasBytes] = await Promise.all([
10793
+ client2.download(job.download_ttf_url),
10794
+ client2.download(job.download_atlas_url)
10795
+ ]);
10796
+ await mkdir4(path16.dirname(ttf), { recursive: true });
10797
+ await writeFile4(ttf, ttfBytes);
10798
+ await writeFile4(atlas, atlasBytes);
10799
+ return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
10800
+ }
10801
+ if (Date.now() - started > timeout) {
10802
+ throw new Error(
10803
+ `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
10804
+ );
10805
+ }
10806
+ await sleep3(interval);
10807
+ }
10808
+ }
10809
+
10810
+ // src/pipeline/quality-regression.ts
10811
+ import path17 from "path";
10812
+ import { existsSync as existsSync12 } from "fs";
10813
+ import { readFile as readFile10 } from "fs/promises";
10182
10814
  import { z as z4 } from "zod";
10183
10815
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
10184
10816
  var HEX_RE = /^#[0-9a-f]{6}$/;
10185
10817
  var MAX_REDMEAN_DISTANCE = 765;
10186
10818
  var QualityPathSchema = z4.string().min(1).refine(
10187
- (value) => !path16.posix.isAbsolute(value) && !value.includes("\\"),
10819
+ (value) => !path17.posix.isAbsolute(value) && !value.includes("\\"),
10188
10820
  "expected a portable relative path"
10189
10821
  );
10190
10822
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -10331,10 +10963,10 @@ function measureDecodedImageQuality(png, digest2) {
10331
10963
  };
10332
10964
  }
10333
10965
  async function measureImageQuality(file) {
10334
- const absolute = path16.resolve(file);
10966
+ const absolute = path17.resolve(file);
10335
10967
  let bytes;
10336
10968
  try {
10337
- bytes = await readFile9(absolute);
10969
+ bytes = await readFile10(absolute);
10338
10970
  } catch (error) {
10339
10971
  throw new Error(`Cannot read quality image ${absolute}: ${message4(error)}`, { cause: error });
10340
10972
  }
@@ -10353,11 +10985,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
10353
10985
  }
10354
10986
  const ids = parsed.data.map((entry) => entry.id);
10355
10987
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
10356
- const root = path16.dirname(path16.resolve(inputsFilePath));
10988
+ const root = path17.dirname(path17.resolve(inputsFilePath));
10357
10989
  return parsed.data.map((entry) => ({
10358
10990
  id: entry.id,
10359
- file: path16.resolve(root, entry.path),
10360
- ...entry.record ? { record: path16.resolve(root, entry.record) } : {},
10991
+ file: path17.resolve(root, entry.path),
10992
+ ...entry.record ? { record: path17.resolve(root, entry.record) } : {},
10361
10993
  tolerances: QualityToleranceSchema.parse({
10362
10994
  ...DEFAULT_QUALITY_TOLERANCES,
10363
10995
  ...entry.tolerances
@@ -10365,7 +10997,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
10365
10997
  }));
10366
10998
  }
10367
10999
  function portableRelative2(from, to) {
10368
- return path16.relative(from, path16.resolve(to)).split(path16.sep).join("/") || ".";
11000
+ return path17.relative(from, path17.resolve(to)).split(path17.sep).join("/") || ".";
10369
11001
  }
10370
11002
  async function verifyRecordForImage(recordPath, imagePath) {
10371
11003
  const verification = await verifyArtifactBundle(recordPath);
@@ -10380,20 +11012,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
10380
11012
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
10381
11013
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
10382
11014
  }
10383
- const outputs = record.outputs.map((output) => path16.resolve(path16.dirname(recordPath), output.path));
10384
- if (!outputs.includes(path16.resolve(imagePath))) {
11015
+ const outputs = record.outputs.map((output) => path17.resolve(path17.dirname(recordPath), output.path));
11016
+ if (!outputs.includes(path17.resolve(imagePath))) {
10385
11017
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
10386
11018
  }
10387
11019
  }
10388
11020
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
10389
11021
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
10390
- const absolute = path16.resolve(baselinePath);
11022
+ const absolute = path17.resolve(baselinePath);
10391
11023
  for (const input of inputs) {
10392
- if (path16.resolve(input.file) === absolute || input.record && path16.resolve(input.record) === absolute) {
11024
+ if (path17.resolve(input.file) === absolute || input.record && path17.resolve(input.record) === absolute) {
10393
11025
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
10394
11026
  }
10395
11027
  }
10396
- const root = path16.dirname(absolute);
11028
+ const root = path17.dirname(absolute);
10397
11029
  const cases = [];
10398
11030
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
10399
11031
  const expected = await measureImageQuality(input.file);
@@ -10420,8 +11052,8 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
10420
11052
  cases
10421
11053
  });
10422
11054
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
10423
- if (existsSync11(absolute) && !options.force) {
10424
- const current = await readFile9(absolute);
11055
+ if (existsSync12(absolute) && !options.force) {
11056
+ const current = await readFile10(absolute);
10425
11057
  if (!current.equals(data)) {
10426
11058
  throw new OverwriteRefusedError(
10427
11059
  `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
@@ -10433,10 +11065,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
10433
11065
  return { baseline, path: absolute, changed: result.changed.length > 0 };
10434
11066
  }
10435
11067
  async function readQualityBaseline(baselinePath) {
10436
- const absolute = path16.resolve(baselinePath);
11068
+ const absolute = path17.resolve(baselinePath);
10437
11069
  let raw;
10438
11070
  try {
10439
- raw = JSON.parse(await readFile9(absolute, "utf8"));
11071
+ raw = JSON.parse(await readFile10(absolute, "utf8"));
10440
11072
  } catch (error) {
10441
11073
  throw new Error(`Could not read quality baseline ${absolute}: ${message4(error)}`, { cause: error });
10442
11074
  }
@@ -10506,12 +11138,12 @@ function regressionViolations(expected, actual, tolerances) {
10506
11138
  return { violations, warnings };
10507
11139
  }
10508
11140
  async function checkQualityBaseline(baselinePath) {
10509
- const absolute = path16.resolve(baselinePath);
11141
+ const absolute = path17.resolve(baselinePath);
10510
11142
  const baseline = await readQualityBaseline(absolute);
10511
- const root = path16.dirname(absolute);
11143
+ const root = path17.dirname(absolute);
10512
11144
  const cases = [];
10513
11145
  for (const entry of baseline.cases) {
10514
- const file = path16.resolve(root, entry.file);
11146
+ const file = path17.resolve(root, entry.file);
10515
11147
  let actual = null;
10516
11148
  const violations = [];
10517
11149
  const warnings = [];
@@ -10524,7 +11156,7 @@ async function checkQualityBaseline(baselinePath) {
10524
11156
  violations.push(message4(error));
10525
11157
  }
10526
11158
  if (entry.record) {
10527
- const recordPath = path16.resolve(root, entry.record.path);
11159
+ const recordPath = path17.resolve(root, entry.record.path);
10528
11160
  try {
10529
11161
  const recordHash = await sha256File(recordPath);
10530
11162
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -10560,13 +11192,13 @@ async function checkQualityBaseline(baselinePath) {
10560
11192
  }
10561
11193
 
10562
11194
  // src/pipeline/cache-health.ts
10563
- import { existsSync as existsSync15 } from "fs";
10564
- import { readFile as readFile12, readdir as readdir2, rm as rm6 } from "fs/promises";
10565
- import path19 from "path";
11195
+ import { existsSync as existsSync16 } from "fs";
11196
+ import { readFile as readFile13, readdir as readdir2, rm as rm6 } from "fs/promises";
11197
+ import path20 from "path";
10566
11198
 
10567
11199
  // src/cache.ts
10568
- import { mkdir as mkdir4, readFile as readFile10, rename as rename4, rm as rm4, writeFile as writeFile4 } from "fs/promises";
10569
- import { existsSync as existsSync12 } from "fs";
11200
+ import { mkdir as mkdir5, readFile as readFile11, rename as rename4, rm as rm4, writeFile as writeFile5 } from "fs/promises";
11201
+ import { existsSync as existsSync13 } from "fs";
10570
11202
  import pathModule from "path";
10571
11203
  import { z as z5 } from "zod";
10572
11204
  var HashCacheSchema = z5.object({
@@ -10581,10 +11213,10 @@ function isSha256Hash(value) {
10581
11213
  function parseCache(value) {
10582
11214
  return HashCacheSchema.parse(value);
10583
11215
  }
10584
- async function loadCache(path33) {
10585
- if (!existsSync12(path33)) return { version: 1, hashes: {} };
11216
+ async function loadCache(path34) {
11217
+ if (!existsSync13(path34)) return { version: 1, hashes: {} };
10586
11218
  try {
10587
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile10(path33, "utf8")));
11219
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile11(path34, "utf8")));
10588
11220
  if (!parsed.success) return { version: 1, hashes: {} };
10589
11221
  return {
10590
11222
  version: 1,
@@ -10596,18 +11228,18 @@ async function loadCache(path33) {
10596
11228
  return { version: 1, hashes: {} };
10597
11229
  }
10598
11230
  }
10599
- async function saveCache(path33, cache) {
11231
+ async function saveCache(path34, cache) {
10600
11232
  const sorted = {};
10601
11233
  for (const key of Object.keys(cache.hashes).sort()) {
10602
11234
  const hash = cache.hashes[key];
10603
11235
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
10604
11236
  sorted[key] = hash;
10605
11237
  }
10606
- await mkdir4(pathModule.dirname(pathModule.resolve(path33)), { recursive: true });
10607
- const tmp = `${path33}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
11238
+ await mkdir5(pathModule.dirname(pathModule.resolve(path34)), { recursive: true });
11239
+ const tmp = `${path34}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10608
11240
  try {
10609
- await writeFile4(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
10610
- await rename4(tmp, path33);
11241
+ await writeFile5(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
11242
+ await rename4(tmp, path34);
10611
11243
  } finally {
10612
11244
  await rm4(tmp, { force: true });
10613
11245
  }
@@ -10631,20 +11263,20 @@ function cachePathFor(lockPath) {
10631
11263
  }
10632
11264
 
10633
11265
  // src/pipeline/history.ts
10634
- import { existsSync as existsSync14 } from "fs";
10635
- import path18 from "path";
11266
+ import { existsSync as existsSync15 } from "fs";
11267
+ import path19 from "path";
10636
11268
 
10637
11269
  // src/pipeline/fetch.ts
10638
- import { existsSync as existsSync13, readFileSync as readFileSync3 } from "fs";
10639
- import { mkdir as mkdir5, readFile as readFile11, rename as rename5, rm as rm5, writeFile as writeFile5 } from "fs/promises";
10640
- import path17 from "path";
11270
+ import { existsSync as existsSync14, readFileSync as readFileSync3 } from "fs";
11271
+ import { mkdir as mkdir6, readFile as readFile12, rename as rename5, rm as rm5, writeFile as writeFile6 } from "fs/promises";
11272
+ import path18 from "path";
10641
11273
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10642
11274
  const log = opts.onProgress ?? (() => {
10643
11275
  });
10644
11276
  const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
10645
11277
  const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
10646
11278
  normalizeLockOutputPaths(lock, specs);
10647
- const cacheDir = opts.cacheDir === false ? null : path17.resolve(opts.cacheDir ?? path17.join(path17.dirname(lockPath), ".pixelkiln", "cache"));
11279
+ const cacheDir = opts.cacheDir === false ? null : path18.resolve(opts.cacheDir ?? path18.join(path18.dirname(lockPath), ".pixelkiln", "cache"));
10648
11280
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
10649
11281
  if (e.provider !== provider.id || !specByKey.has(key)) return false;
10650
11282
  if (specByKey.get(key)?.mirror) return false;
@@ -10655,14 +11287,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10655
11287
  if (spec && e.specHash === spec.specHash && e.outputs.length && !postprocessCurrent(e, spec)) {
10656
11288
  return Boolean(opts.force) || e.outputs.every((output) => {
10657
11289
  const file = resolveOutputPath(output.path, spec.root);
10658
- return existsSync13(file) && sha256(readFileSync3(file)) === output.sha256;
11290
+ return existsSync14(file) && sha256(readFileSync3(file)) === output.sha256;
10659
11291
  });
10660
11292
  }
10661
11293
  if (!opts.repair) return false;
10662
11294
  if (e.outputs.length === 0 || !spec) return true;
10663
11295
  return e.outputs.some((output) => {
10664
11296
  const file = resolveOutputPath(output.path, spec.root);
10665
- if (!existsSync13(file)) return true;
11297
+ if (!existsSync14(file)) return true;
10666
11298
  const current = sha256(readFileSync3(file));
10667
11299
  if (current === output.sha256) return false;
10668
11300
  return Boolean(opts.force) || (e.supersededOutputs ?? []).some((old) => old.sha256 === current);
@@ -10746,21 +11378,21 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10746
11378
  if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
10747
11379
  fresh = await provider.download(source.url);
10748
11380
  if (recorded && sha256(fresh) === recordedRaw && !reapply) {
10749
- log(` current ${path17.relative(process.cwd(), target)}`);
11381
+ log(` current ${path18.relative(process.cwd(), target)}`);
10750
11382
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
10751
11383
  continue;
10752
11384
  }
10753
11385
  }
10754
11386
  let raw = null;
10755
11387
  let final = null;
10756
- if (existsSync13(target)) {
11388
+ if (existsSync14(target)) {
10757
11389
  const currentHash = await sha256File(target);
10758
11390
  if (recorded && currentHash === recorded.sha256) {
10759
11391
  if (!fresh && !reapply) {
10760
11392
  if (cacheDir) {
10761
11393
  await cacheMedia(
10762
11394
  cacheDir,
10763
- await readFile11(target),
11395
+ await readFile12(target),
10764
11396
  recorded.mediaType ?? MediaType.PNG,
10765
11397
  recorded.sha256
10766
11398
  );
@@ -10769,10 +11401,10 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10769
11401
  continue;
10770
11402
  }
10771
11403
  if (reapply && !fresh) {
10772
- if (!recorded.raw) raw = await readFile11(target);
10773
- log(` reapply ${path17.relative(process.cwd(), target)}`);
11404
+ if (!recorded.raw) raw = await readFile12(target);
11405
+ log(` reapply ${path18.relative(process.cwd(), target)}`);
10774
11406
  } else {
10775
- log(` changed ${path17.relative(process.cwd(), target)} (upstream)`);
11407
+ log(` changed ${path18.relative(process.cwd(), target)} (upstream)`);
10776
11408
  }
10777
11409
  } else {
10778
11410
  const superseded = (entry.supersededOutputs ?? []).find(
@@ -10793,14 +11425,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10793
11425
  }
10794
11426
  if (outgoing && cacheDir) {
10795
11427
  try {
10796
- const oldBytes = await readFile11(target);
11428
+ const oldBytes = await readFile12(target);
10797
11429
  const oldMediaType = detectMediaType(oldBytes);
10798
11430
  if (oldMediaType) await cacheMedia(cacheDir, oldBytes, oldMediaType, currentHash);
10799
11431
  } catch {
10800
11432
  }
10801
11433
  }
10802
11434
  log(
10803
- ` replace ${path17.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
11435
+ ` replace ${path18.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
10804
11436
  );
10805
11437
  }
10806
11438
  }
@@ -10809,7 +11441,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10809
11441
  } else if (!raw && recorded && cacheDir) {
10810
11442
  if (!reapply) final = await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType);
10811
11443
  if (!final && recordedRaw) raw = await readCachedMedia(cacheDir, recordedRaw, expectedMediaType);
10812
- if (final || raw) log(` cached ${path17.relative(process.cwd(), target)}`);
11444
+ if (final || raw) log(` cached ${path18.relative(process.cwd(), target)}`);
10813
11445
  }
10814
11446
  if (!final && !raw) {
10815
11447
  if (!source.url) {
@@ -10836,13 +11468,13 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10836
11468
  buf = processed.bytes;
10837
11469
  if (processed.changed) {
10838
11470
  rawHash = sha256(raw);
10839
- log(` snapped ${path17.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
11471
+ log(` snapped ${path18.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
10840
11472
  }
10841
11473
  }
10842
11474
  if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
10843
- await mkdir5(path17.dirname(target), { recursive: true });
11475
+ await mkdir6(path18.dirname(target), { recursive: true });
10844
11476
  const tmp = `${target}.pixelkiln.tmp`;
10845
- await writeFile5(tmp, buf);
11477
+ await writeFile6(tmp, buf);
10846
11478
  outputs.push({
10847
11479
  path: portableOutputPath(target, spec.root),
10848
11480
  sha256: sha256(buf),
@@ -10854,7 +11486,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10854
11486
  await saveLock(lockPath, lock);
10855
11487
  await rename5(tmp, target);
10856
11488
  wrote = true;
10857
- log(` wrote ${path17.relative(process.cwd(), target)}`);
11489
+ log(` wrote ${path18.relative(process.cwd(), target)}`);
10858
11490
  }
10859
11491
  if (opts.refresh && !wrote) {
10860
11492
  if (rotated) {
@@ -10898,10 +11530,10 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
10898
11530
  return result;
10899
11531
  }
10900
11532
  async function readCachedMedia(cacheDir, hash, mediaType) {
10901
- const file = path17.join(cacheDir, cacheFileName(hash, mediaType));
10902
- if (!existsSync13(file)) return null;
11533
+ const file = path18.join(cacheDir, cacheFileName(hash, mediaType));
11534
+ if (!existsSync14(file)) return null;
10903
11535
  try {
10904
- const buf = await readFile11(file);
11536
+ const buf = await readFile12(file);
10905
11537
  if (sha256(buf) !== hash) return null;
10906
11538
  validateMedia(buf, mediaType);
10907
11539
  return buf;
@@ -10912,15 +11544,15 @@ async function readCachedMedia(cacheDir, hash, mediaType) {
10912
11544
  async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
10913
11545
  const hash = knownHash ?? sha256(buf);
10914
11546
  validateMedia(buf, mediaType);
10915
- const file = path17.join(cacheDir, cacheFileName(hash, mediaType));
11547
+ const file = path18.join(cacheDir, cacheFileName(hash, mediaType));
10916
11548
  if (await readCachedMedia(cacheDir, hash, mediaType)) return;
10917
- await mkdir5(cacheDir, { recursive: true });
11549
+ await mkdir6(cacheDir, { recursive: true });
10918
11550
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
10919
- await writeFile5(tmp, buf);
11551
+ await writeFile6(tmp, buf);
10920
11552
  try {
10921
11553
  await rename5(tmp, file);
10922
11554
  } catch (err) {
10923
- if (!existsSync13(file)) throw err;
11555
+ if (!existsSync14(file)) throw err;
10924
11556
  } finally {
10925
11557
  await rm5(tmp, { force: true });
10926
11558
  }
@@ -11039,10 +11671,10 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
11039
11671
  }
11040
11672
  for (let i = 0; i < entry.outputs.length; i++) {
11041
11673
  const file = currentEntryOutputPath(entry, spec, i);
11042
- if (!existsSync14(file)) continue;
11674
+ if (!existsSync15(file)) continue;
11043
11675
  if (await sha256File(file) !== entry.outputs[i].sha256 && !opts.force) {
11044
11676
  throw new OverwriteRefusedError(
11045
- `${path18.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
11677
+ `${path19.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
11046
11678
  [file]
11047
11679
  );
11048
11680
  }
@@ -11094,11 +11726,11 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
11094
11726
 
11095
11727
  // src/pipeline/cache-health.ts
11096
11728
  async function inspectCaches(lock, lockPath, options = {}) {
11097
- if (options.prune && !existsSync15(lockPath)) {
11098
- throw new Error(`Refusing to prune without an existing lockfile at ${path19.resolve(lockPath)}`);
11729
+ if (options.prune && !existsSync16(lockPath)) {
11730
+ throw new Error(`Refusing to prune without an existing lockfile at ${path20.resolve(lockPath)}`);
11099
11731
  }
11100
- const contentDir = path19.resolve(path19.dirname(lockPath), ".pixelkiln", "cache");
11101
- const remotePath = path19.resolve(cachePathFor(lockPath));
11732
+ const contentDir = path20.resolve(path20.dirname(lockPath), ".pixelkiln", "cache");
11733
+ const remotePath = path20.resolve(cachePathFor(lockPath));
11102
11734
  const referenced = referencedHashes(lock);
11103
11735
  let content = await inspectContentCache(contentDir, referenced);
11104
11736
  let remoteHashes = await inspectRemoteHashCache(remotePath);
@@ -11110,7 +11742,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11110
11742
  ]);
11111
11743
  for (const name of names) {
11112
11744
  try {
11113
- await rm6(path19.join(contentDir, name), { force: true });
11745
+ await rm6(path20.join(contentDir, name), { force: true });
11114
11746
  removed.contentFiles++;
11115
11747
  } catch {
11116
11748
  }
@@ -11119,7 +11751,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11119
11751
  await saveCache(remotePath, { version: 1, hashes: {} });
11120
11752
  removed.resetRemoteHashCache = true;
11121
11753
  } else if (remoteHashes.invalidIds.length) {
11122
- const cache = parseCache(JSON.parse(await readFile12(remotePath, "utf8")));
11754
+ const cache = parseCache(JSON.parse(await readFile13(remotePath, "utf8")));
11123
11755
  for (const id of remoteHashes.invalidIds) delete cache.hashes[id];
11124
11756
  removed.remoteHashEntries = remoteHashes.invalidIds.length;
11125
11757
  await saveCache(remotePath, cache);
@@ -11137,7 +11769,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11137
11769
  async function inspectContentCache(contentDir, referenced) {
11138
11770
  const report = {
11139
11771
  path: contentDir,
11140
- exists: existsSync15(contentDir),
11772
+ exists: existsSync16(contentDir),
11141
11773
  files: 0,
11142
11774
  bytes: 0,
11143
11775
  valid: 0,
@@ -11158,12 +11790,12 @@ async function inspectContentCache(contentDir, referenced) {
11158
11790
  continue;
11159
11791
  }
11160
11792
  report.files++;
11161
- const file = path19.join(contentDir, entry.name);
11793
+ const file = path20.join(contentDir, entry.name);
11162
11794
  const mediaType = mediaTypeFromExtension(entry.name);
11163
11795
  const expected = mediaType ? entry.name.slice(0, -4) : "";
11164
11796
  let bytes;
11165
11797
  try {
11166
- bytes = await readFile12(file);
11798
+ bytes = await readFile13(file);
11167
11799
  report.bytes += bytes.length;
11168
11800
  } catch (err) {
11169
11801
  report.invalid.push({
@@ -11201,7 +11833,7 @@ async function inspectContentCache(contentDir, referenced) {
11201
11833
  async function inspectRemoteHashCache(remotePath) {
11202
11834
  const report = {
11203
11835
  path: remotePath,
11204
- exists: existsSync15(remotePath),
11836
+ exists: existsSync16(remotePath),
11205
11837
  entries: 0,
11206
11838
  valid: 0,
11207
11839
  invalidIds: [],
@@ -11210,7 +11842,7 @@ async function inspectRemoteHashCache(remotePath) {
11210
11842
  if (!report.exists) return report;
11211
11843
  let cache;
11212
11844
  try {
11213
- cache = parseCache(JSON.parse(await readFile12(remotePath, "utf8")));
11845
+ cache = parseCache(JSON.parse(await readFile13(remotePath, "utf8")));
11214
11846
  } catch (err) {
11215
11847
  report.error = err instanceof Error ? err.message : String(err);
11216
11848
  return report;
@@ -11226,19 +11858,19 @@ async function inspectRemoteHashCache(remotePath) {
11226
11858
 
11227
11859
  // src/pipeline/pack.ts
11228
11860
  import { readFileSync as readFileSync4 } from "fs";
11229
- import path20 from "path";
11861
+ import path21 from "path";
11230
11862
  var DEFAULT_FRAME_SET_FPS = 12;
11231
11863
  function resolvePackInputs(raw, inputsFilePath) {
11232
11864
  if (!Array.isArray(raw) || !raw.length) {
11233
11865
  throw new Error("--inputs must be a non-empty JSON array of { id, path }");
11234
11866
  }
11235
- const baseDir = path20.dirname(path20.resolve(inputsFilePath));
11867
+ const baseDir = path21.dirname(path21.resolve(inputsFilePath));
11236
11868
  return raw.map((entry, i) => {
11237
11869
  const e = entry;
11238
11870
  if (typeof e.id !== "string" || typeof e.path !== "string") {
11239
11871
  throw new Error(`--inputs[${i}] needs string "id" and "path"`);
11240
11872
  }
11241
- return { id: e.id, path: path20.resolve(baseDir, e.path) };
11873
+ return { id: e.id, path: path21.resolve(baseDir, e.path) };
11242
11874
  });
11243
11875
  }
11244
11876
  function packSprites(inputs, options = {}) {
@@ -11322,7 +11954,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
11322
11954
  }
11323
11955
  const inputs2 = Object.entries(options.sourceOverrides).map(([id, source]) => ({
11324
11956
  id,
11325
- path: path20.resolve(source)
11957
+ path: path21.resolve(source)
11326
11958
  }));
11327
11959
  if (!inputs2.length) throw new Error(`No approved quality outputs for style "${styleId}".`);
11328
11960
  const packed2 = packSprites(inputs2, options);
@@ -11346,7 +11978,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
11346
11978
  let outputs = resolveEntryOutputs(entry, id, manifestDir);
11347
11979
  const source = sources[id];
11348
11980
  if (source && outputs.length <= 1) {
11349
- inputs.push({ id, path: path20.resolve(manifestDir, source) });
11981
+ inputs.push({ id, path: path21.resolve(manifestDir, source) });
11350
11982
  continue;
11351
11983
  }
11352
11984
  if (source && sourceIsStem(source, entry, manifestDir)) {
@@ -11380,7 +12012,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
11380
12012
  }
11381
12013
  }
11382
12014
  for (const [id, source] of Object.entries(sources)) {
11383
- if (!locked.has(id)) inputs.push({ id, path: path20.resolve(manifestDir, source) });
12015
+ if (!locked.has(id)) inputs.push({ id, path: path21.resolve(manifestDir, source) });
11384
12016
  }
11385
12017
  if (!inputs.length) {
11386
12018
  throw new Error(`No readable sprites for style "${styleId}"; ${noOutput.length} skipped.`);
@@ -11488,7 +12120,7 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
11488
12120
  const source = sources[id];
11489
12121
  const entry = lock.entries[`${styleId}/${id}`];
11490
12122
  if (source && !(entry && sourceIsStem(source, entry, manifestDir))) {
11491
- placements.push({ id, path: path20.resolve(manifestDir, source), cell });
12123
+ placements.push({ id, path: path21.resolve(manifestDir, source), cell });
11492
12124
  continue;
11493
12125
  }
11494
12126
  if (source && entry) {
@@ -11521,7 +12153,7 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
11521
12153
  `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}`
11522
12154
  );
11523
12155
  }
11524
- const basePng = mount.base ? readFileSync4(path20.resolve(manifestDir, mount.base)) : void 0;
12156
+ const basePng = mount.base ? readFileSync4(path21.resolve(manifestDir, mount.base)) : void 0;
11525
12157
  const mounted = mountSprites(placements, {
11526
12158
  cellWidth: mount.cellWidth,
11527
12159
  cellHeight: mount.cellHeight,
@@ -11531,7 +12163,7 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
11531
12163
  ...mounted,
11532
12164
  sources: mount.base ? [{
11533
12165
  id: "$base",
11534
- path: path20.resolve(manifestDir, mount.base),
12166
+ path: path21.resolve(manifestDir, mount.base),
11535
12167
  sha256: sha256(basePng),
11536
12168
  included: true
11537
12169
  }, ...mounted.sources] : mounted.sources,
@@ -12309,9 +12941,9 @@ async function poll(provider, lock, lockPath, opts = {}) {
12309
12941
  }
12310
12942
 
12311
12943
  // src/pipeline/doctor.ts
12312
- import { constants, existsSync as existsSync16 } from "fs";
12944
+ import { constants, existsSync as existsSync17 } from "fs";
12313
12945
  import { access, stat as stat2 } from "fs/promises";
12314
- import path21 from "path";
12946
+ import path22 from "path";
12315
12947
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12316
12948
  const checks = [];
12317
12949
  const add = (id, level, message6) => checks.push({ id, level, message: message6 });
@@ -12322,8 +12954,8 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12322
12954
  );
12323
12955
  add("lock", "ok", `${Object.keys(lock.entries).length} valid v2 lock entr(ies)`);
12324
12956
  const dirs = [...new Set(specs.flatMap((spec) => [
12325
- path21.dirname(spec.outFile),
12326
- ...spec.quality ? [path21.dirname(spec.quality.outFile)] : []
12957
+ path22.dirname(spec.outFile),
12958
+ ...spec.quality ? [path22.dirname(spec.quality.outFile)] : []
12327
12959
  ]))];
12328
12960
  const unwritable = [];
12329
12961
  for (const dir of dirs) {
@@ -12346,7 +12978,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12346
12978
  for (let index = 0; index < entry.outputs.length; index++) {
12347
12979
  const output = entry.outputs[index];
12348
12980
  const spec = specByKey.get(key);
12349
- const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, path21.dirname(lockPath));
12981
+ const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, path22.dirname(lockPath));
12350
12982
  const owner = outputOwners.get(absolute);
12351
12983
  if (owner && owner !== key) duplicateOutputs.push(`${output.path} (${owner}, ${key})`);
12352
12984
  outputOwners.set(absolute, key);
@@ -12361,7 +12993,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12361
12993
  } else {
12362
12994
  add("lock-outputs", "ok", `${outputOwners.size} recorded output path(s) are uniquely owned`);
12363
12995
  }
12364
- const cacheDir = path21.join(path21.dirname(lockPath), ".pixelkiln", "cache");
12996
+ const cacheDir = path22.join(path22.dirname(lockPath), ".pixelkiln", "cache");
12365
12997
  const stranded = [];
12366
12998
  const invalidState = [];
12367
12999
  const staleMapUrls = [];
@@ -12377,11 +13009,11 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12377
13009
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
12378
13010
  let hasCache = false;
12379
13011
  for (const output of entry.outputs) {
12380
- const cached = path21.join(
13012
+ const cached = path22.join(
12381
13013
  cacheDir,
12382
13014
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
12383
13015
  );
12384
- if (existsSync16(cached) && await sha256File(cached) === output.sha256) {
13016
+ if (existsSync17(cached) && await sha256File(cached) === output.sha256) {
12385
13017
  hasCache = true;
12386
13018
  break;
12387
13019
  }
@@ -12477,16 +13109,16 @@ async function nearestExistingDirectory(start) {
12477
13109
  if ((await stat2(current)).isDirectory()) return current;
12478
13110
  } catch {
12479
13111
  }
12480
- const parent = path21.dirname(current);
13112
+ const parent = path22.dirname(current);
12481
13113
  if (parent === current) return current;
12482
13114
  current = parent;
12483
13115
  }
12484
13116
  }
12485
13117
 
12486
13118
  // src/pipeline/adopt.ts
12487
- import { existsSync as existsSync17 } from "fs";
12488
- import { readFile as readFile13, writeFile as writeFile6 } from "fs/promises";
12489
- import path22 from "path";
13119
+ import { existsSync as existsSync18 } from "fs";
13120
+ import { readFile as readFile14, writeFile as writeFile7 } from "fs/promises";
13121
+ import path23 from "path";
12490
13122
  async function adopt(provider, specs, lock, lockPath, opts = {}) {
12491
13123
  const log = opts.onProgress ?? (() => {
12492
13124
  });
@@ -12497,11 +13129,11 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
12497
13129
  const localByHash = /* @__PURE__ */ new Map();
12498
13130
  const unmatchedLocal = [];
12499
13131
  for (const spec of specs) {
12500
- if (!existsSync17(spec.outFile)) {
13132
+ if (!existsSync18(spec.outFile)) {
12501
13133
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
12502
13134
  continue;
12503
13135
  }
12504
- const bytes = await readFile13(spec.outFile);
13136
+ const bytes = await readFile14(spec.outFile);
12505
13137
  try {
12506
13138
  decodePng(bytes);
12507
13139
  } catch (err) {
@@ -12619,13 +13251,13 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
12619
13251
  }
12620
13252
 
12621
13253
  // src/pick/server.ts
12622
- import { existsSync as existsSync19 } from "fs";
12623
- import { readFile as readFile16 } from "fs/promises";
13254
+ import { existsSync as existsSync20 } from "fs";
13255
+ import { readFile as readFile17 } from "fs/promises";
12624
13256
 
12625
13257
  // src/pipeline/init.ts
12626
- import { readdir as readdir3, readFile as readFile14, writeFile as writeFile7 } from "fs/promises";
12627
- import { existsSync as existsSync18 } from "fs";
12628
- import path23 from "path";
13258
+ import { readdir as readdir3, readFile as readFile15, writeFile as writeFile8 } from "fs/promises";
13259
+ import { existsSync as existsSync19 } from "fs";
13260
+ import path24 from "path";
12629
13261
  function pngSize(buf) {
12630
13262
  if (buf.length < 24) return null;
12631
13263
  const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
@@ -12639,7 +13271,7 @@ function slugify(value) {
12639
13271
  async function walk(dir, exclude) {
12640
13272
  const out = [];
12641
13273
  for (const entry of await readdir3(dir, { withFileTypes: true })) {
12642
- const full = path23.join(dir, entry.name);
13274
+ const full = path24.join(dir, entry.name);
12643
13275
  if (exclude.some((x) => full.includes(x))) continue;
12644
13276
  if (entry.isDirectory()) out.push(...await walk(full, exclude));
12645
13277
  else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
@@ -12653,14 +13285,14 @@ async function scanAssets(root, opts = {}) {
12653
13285
  const skipped = [];
12654
13286
  const seen = /* @__PURE__ */ new Map();
12655
13287
  for (const file of files.sort()) {
12656
- const size = pngSize(await readFile14(file));
13288
+ const size = pngSize(await readFile15(file));
12657
13289
  if (!size) {
12658
13290
  skipped.push(`${file} (not a readable PNG)`);
12659
13291
  continue;
12660
13292
  }
12661
- const rel = path23.relative(root, file);
12662
- const category = path23.dirname(rel) === "." ? "" : path23.dirname(rel);
12663
- let id = slugify(path23.basename(rel));
13293
+ const rel = path24.relative(root, file);
13294
+ const category = path24.dirname(rel) === "." ? "" : path24.dirname(rel);
13295
+ let id = slugify(path24.basename(rel));
12664
13296
  if (seen.has(id)) {
12665
13297
  const n = seen.get(id) + 1;
12666
13298
  seen.set(id, n);
@@ -13124,7 +13756,7 @@ document.getElementById('submit').onclick = async () => {
13124
13756
 
13125
13757
  // src/pick/review-server.ts
13126
13758
  import { createServer } from "http";
13127
- import { readFile as readFile15 } from "fs/promises";
13759
+ import { readFile as readFile16 } from "fs/promises";
13128
13760
  function serveReviewPage(opts) {
13129
13761
  const log = opts.onProgress ?? (() => {
13130
13762
  });
@@ -13133,7 +13765,7 @@ function serveReviewPage(opts) {
13133
13765
  if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
13134
13766
  try {
13135
13767
  const asset = opts.assets.get(req.url);
13136
- const bytes = await readFile15(asset.path);
13768
+ const bytes = await readFile16(asset.path);
13137
13769
  res.writeHead(200, {
13138
13770
  "Content-Type": asset.contentType,
13139
13771
  "Cache-Control": "no-store",
@@ -13209,13 +13841,13 @@ async function currentArt(entry, spec) {
13209
13841
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
13210
13842
  if (!previous) return null;
13211
13843
  const file = resolveOutputPath(previous.path, spec.root);
13212
- if (!existsSync19(file)) return null;
13844
+ if (!existsSync20(file)) return null;
13213
13845
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
13214
13846
  if (!contentType) return null;
13215
13847
  let width = entry.width;
13216
13848
  let height = entry.height;
13217
13849
  if (contentType === "image/png") {
13218
- const size = pngSize(await readFile16(file));
13850
+ const size = pngSize(await readFile17(file));
13219
13851
  if (size) ({ width, height } = size);
13220
13852
  }
13221
13853
  return { path: file, contentType, width, height };
@@ -13384,14 +14016,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
13384
14016
 
13385
14017
  // src/gallery/snapshot.ts
13386
14018
  import { createHash as createHash4 } from "crypto";
13387
- import { existsSync as existsSync22 } from "fs";
13388
- import { readFile as readFile20, stat as stat3 } from "fs/promises";
13389
- import path28 from "path";
14019
+ import { existsSync as existsSync23 } from "fs";
14020
+ import { readFile as readFile21, stat as stat3 } from "fs/promises";
14021
+ import path29 from "path";
13390
14022
 
13391
14023
  // src/workspace.ts
13392
- import { mkdir as mkdir6, readFile as readFile17, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
13393
- import { existsSync as existsSync20 } from "fs";
13394
- import path24 from "path";
14024
+ import { mkdir as mkdir7, readFile as readFile18, rename as rename6, rm as rm7, writeFile as writeFile9 } from "fs/promises";
14025
+ import { existsSync as existsSync21 } from "fs";
14026
+ import path25 from "path";
13395
14027
  import { z as z6 } from "zod";
13396
14028
  var WorkspaceProjectSchema = z6.object({
13397
14029
  id: z6.string().min(1),
@@ -13416,10 +14048,10 @@ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message
13416
14048
  );
13417
14049
  }
13418
14050
  async function loadWorkspace(workspacePath) {
13419
- if (!existsSync20(workspacePath)) return { version: 1, projects: [] };
14051
+ if (!existsSync21(workspacePath)) return { version: 1, projects: [] };
13420
14052
  let raw;
13421
14053
  try {
13422
- raw = JSON.parse(await readFile17(workspacePath, "utf8"));
14054
+ raw = JSON.parse(await readFile18(workspacePath, "utf8"));
13423
14055
  } catch (err) {
13424
14056
  throw new ProjectError(
13425
14057
  `Workspace catalog at ${workspacePath} is malformed:
@@ -13433,22 +14065,22 @@ async function saveWorkspace(workspacePath, ws) {
13433
14065
  version: 1,
13434
14066
  projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
13435
14067
  };
13436
- await mkdir6(path24.dirname(path24.resolve(workspacePath)), { recursive: true });
14068
+ await mkdir7(path25.dirname(path25.resolve(workspacePath)), { recursive: true });
13437
14069
  const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
13438
14070
  try {
13439
- await writeFile8(tmp, JSON.stringify(sorted, null, 2) + "\n");
14071
+ await writeFile9(tmp, JSON.stringify(sorted, null, 2) + "\n");
13440
14072
  await rename6(tmp, workspacePath);
13441
14073
  } finally {
13442
14074
  await rm7(tmp, { force: true });
13443
14075
  }
13444
14076
  }
13445
14077
  function toPortablePath(dir, absolute) {
13446
- return path24.relative(dir, absolute).split(path24.sep).join("/");
14078
+ return path25.relative(dir, absolute).split(path25.sep).join("/");
13447
14079
  }
13448
14080
  function resolveProject(dir, project) {
13449
14081
  return {
13450
- manifestPath: path24.resolve(dir, project.manifest.split("/").join(path24.sep)),
13451
- lockPath: path24.resolve(dir, project.lock.split("/").join(path24.sep))
14082
+ manifestPath: path25.resolve(dir, project.manifest.split("/").join(path25.sep)),
14083
+ lockPath: path25.resolve(dir, project.lock.split("/").join(path25.sep))
13452
14084
  };
13453
14085
  }
13454
14086
  function validateWorkspace(ws, dir) {
@@ -13461,21 +14093,21 @@ function validateWorkspace(ws, dir) {
13461
14093
  const { manifestPath, lockPath } = resolveProject(dir, project);
13462
14094
  lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
13463
14095
  manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
13464
- if (path24.isAbsolute(project.manifest) || path24.isAbsolute(project.lock)) {
14096
+ if (path25.isAbsolute(project.manifest) || path25.isAbsolute(project.lock)) {
13465
14097
  diagnostics.push({
13466
14098
  id: "absolute-path",
13467
14099
  level: "warning",
13468
14100
  message: `project "${project.id}" stores an absolute path; the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
13469
14101
  });
13470
14102
  }
13471
- if (!existsSync20(manifestPath)) {
14103
+ if (!existsSync21(manifestPath)) {
13472
14104
  diagnostics.push({
13473
14105
  id: "missing-manifest",
13474
14106
  level: "error",
13475
14107
  message: `project "${project.id}" manifest not found: ${manifestPath}`
13476
14108
  });
13477
14109
  }
13478
- if (!existsSync20(lockPath)) {
14110
+ if (!existsSync21(lockPath)) {
13479
14111
  diagnostics.push({
13480
14112
  id: "missing-lock",
13481
14113
  level: "error",
@@ -13522,12 +14154,12 @@ function validateWorkspace(ws, dir) {
13522
14154
  }
13523
14155
 
13524
14156
  // src/gallery/edit.ts
13525
- import path27 from "path";
14157
+ import path28 from "path";
13526
14158
  import { z as z9 } from "zod";
13527
14159
 
13528
14160
  // src/manifest-edit.ts
13529
- import { readFile as readFile18, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
13530
- import path25 from "path";
14161
+ import { readFile as readFile19, rename as rename7, rm as rm8, writeFile as writeFile10 } from "fs/promises";
14162
+ import path26 from "path";
13531
14163
  import { z as z7 } from "zod";
13532
14164
  var HexSha = z7.string().regex(/^[0-9a-f]{64}$/);
13533
14165
  var AssetPatchSchema = z7.object({
@@ -13683,11 +14315,21 @@ function assetStyles(raw, asset) {
13683
14315
  const own = Array.isArray(asset.styles) ? asset.styles : [];
13684
14316
  return own.length ? own : Object.keys(raw.styles ?? {});
13685
14317
  }
14318
+ function declaredAssets(raw) {
14319
+ const expanded = expandLoopDirections(raw).raw;
14320
+ return new Set(Object.keys(expanded.assets ?? {}));
14321
+ }
14322
+ function undeclaredAsset(raw, assetId) {
14323
+ const shorthand = loopShorthandOf(assetId, expandLoopDirections(raw).families);
14324
+ return new ManifestEditError(
14325
+ 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`
14326
+ );
14327
+ }
13686
14328
  function applyEdit(raw, edit) {
13687
14329
  raw.assets ??= {};
13688
14330
  if (edit.action === "set-source" || edit.action === "clear-source") {
13689
14331
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
13690
- if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
14332
+ if (!asset2) throw undeclaredAsset(raw, edit.assetId);
13691
14333
  if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
13692
14334
  throw new ManifestEditError(`unknown style "${edit.styleId}"`);
13693
14335
  }
@@ -13739,7 +14381,7 @@ function applyEdit(raw, edit) {
13739
14381
  return;
13740
14382
  }
13741
14383
  if (edit.action === "add-asset") {
13742
- if (Object.hasOwn(raw.assets, edit.assetId)) {
14384
+ if (declaredAssets(raw).has(edit.assetId)) {
13743
14385
  throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
13744
14386
  }
13745
14387
  const { styles, tags, ...rest } = edit.asset;
@@ -13753,20 +14395,20 @@ function applyEdit(raw, edit) {
13753
14395
  }
13754
14396
  asset2.styles = styles;
13755
14397
  }
13756
- if (edit.asset.revision && !Object.hasOwn(raw.assets, edit.asset.revision.from)) {
14398
+ if (edit.asset.revision && !declaredAssets(raw).has(edit.asset.revision.from)) {
13757
14399
  throw new ManifestEditError(`revision parent "${edit.asset.revision.from}" is not declared by the manifest`);
13758
14400
  }
13759
- if (edit.asset.state && !Object.hasOwn(raw.assets, edit.asset.state.of)) {
14401
+ if (edit.asset.state && !declaredAssets(raw).has(edit.asset.state.of)) {
13760
14402
  throw new ManifestEditError(`state parent "${edit.asset.state.of}" is not declared by the manifest`);
13761
14403
  }
13762
- if (edit.asset.animation && !Object.hasOwn(raw.assets, edit.asset.animation.of)) {
14404
+ if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
13763
14405
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
13764
14406
  }
13765
14407
  raw.assets[edit.assetId] = asset2;
13766
14408
  return;
13767
14409
  }
13768
14410
  const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
13769
- if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
14411
+ if (!asset) throw undeclaredAsset(raw, edit.assetId);
13770
14412
  const { patch } = edit;
13771
14413
  if (patch.prompt !== void 0) asset.prompt = patch.prompt;
13772
14414
  if (patch.promptForStyle) {
@@ -13790,8 +14432,8 @@ function applyEdit(raw, edit) {
13790
14432
  }
13791
14433
  }
13792
14434
  async function applyManifestEdit(manifestPath, edit) {
13793
- const absolute = path25.resolve(manifestPath);
13794
- const original = await readFile18(absolute, "utf8");
14435
+ const absolute = path26.resolve(manifestPath);
14436
+ const original = await readFile19(absolute, "utf8");
13795
14437
  const before = sha256(original);
13796
14438
  if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
13797
14439
  let raw;
@@ -13805,12 +14447,12 @@ async function applyManifestEdit(manifestPath, edit) {
13805
14447
  applyEdit(raw, edit);
13806
14448
  const next = serializeLike(original, raw);
13807
14449
  if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
13808
- const tmp = path25.join(
13809
- path25.dirname(absolute),
13810
- `.${path25.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
14450
+ const tmp = path26.join(
14451
+ path26.dirname(absolute),
14452
+ `.${path26.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
13811
14453
  );
13812
14454
  try {
13813
- await writeFile9(tmp, next);
14455
+ await writeFile10(tmp, next);
13814
14456
  try {
13815
14457
  const loaded = await loadManifest(tmp);
13816
14458
  await resolveSpecs(loaded);
@@ -13826,18 +14468,18 @@ async function applyManifestEdit(manifestPath, edit) {
13826
14468
  }
13827
14469
 
13828
14470
  // src/pipeline/hand-edit.ts
13829
- import { existsSync as existsSync21 } from "fs";
13830
- import { copyFile, mkdir as mkdir7, readFile as readFile19, rename as rename8, rm as rm9, writeFile as writeFile10 } from "fs/promises";
13831
- import path26 from "path";
14471
+ import { existsSync as existsSync22 } from "fs";
14472
+ import { copyFile, mkdir as mkdir8, readFile as readFile20, rename as rename8, rm as rm9, writeFile as writeFile11 } from "fs/promises";
14473
+ import path27 from "path";
13832
14474
  import { z as z8 } from "zod";
13833
14475
  var HAND_EDIT_DIR = "edits";
13834
14476
  function handEditPath(loaded, spec) {
13835
14477
  const style = loaded.manifest.styles[spec.styleId];
13836
- const outDir = path26.resolve(loaded.root, style?.outDir ?? ".");
13837
- const relative = path26.relative(outDir, spec.outFile);
13838
- const inside = relative && !relative.startsWith("..") && !path26.isAbsolute(relative);
13839
- const file = inside ? relative : path26.basename(spec.outFile);
13840
- return path26.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
14478
+ const outDir = path27.resolve(loaded.root, style?.outDir ?? ".");
14479
+ const relative = path27.relative(outDir, spec.outFile);
14480
+ const inside = relative && !relative.startsWith("..") && !path27.isAbsolute(relative);
14481
+ const file = inside ? relative : path27.basename(spec.outFile);
14482
+ return path27.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
13841
14483
  }
13842
14484
  function handEditBases(spec, lock) {
13843
14485
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -13850,10 +14492,10 @@ function handEditBases(spec, lock) {
13850
14492
  }));
13851
14493
  if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
13852
14494
  if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
13853
- if (!members.every((member) => existsSync21(member.path))) return null;
14495
+ if (!members.every((member) => existsSync22(member.path))) return null;
13854
14496
  return members.map(({ role, path: file }) => ({ role, path: file }));
13855
14497
  }
13856
- return existsSync21(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
14498
+ return existsSync22(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
13857
14499
  }
13858
14500
  function handEditBase(spec, lock) {
13859
14501
  const bases = handEditBases(spec, lock);
@@ -13883,18 +14525,18 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
13883
14525
  `${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`
13884
14526
  );
13885
14527
  }
13886
- const existing = spec.source ? path26.resolve(loaded.root, spec.source) : null;
14528
+ const existing = spec.source ? path27.resolve(loaded.root, spec.source) : null;
13887
14529
  const editPath = existing ?? handEditPath(loaded, spec);
13888
14530
  const members = handEditMembers(loaded, spec, lock, editPath);
13889
14531
  let created = false;
13890
14532
  for (const member of members) {
13891
- if (existsSync21(member.path)) continue;
14533
+ if (existsSync22(member.path)) continue;
13892
14534
  if (!member.base) {
13893
14535
  throw new ManifestEditError(
13894
14536
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
13895
14537
  );
13896
14538
  }
13897
- await mkdir7(path26.dirname(member.path), { recursive: true });
14539
+ await mkdir8(path27.dirname(member.path), { recursive: true });
13898
14540
  await copyFile(member.base, member.path);
13899
14541
  created = true;
13900
14542
  }
@@ -13973,7 +14615,7 @@ function handEditProjectPath(editPath) {
13973
14615
  async function readHandEditCompanion(editPath) {
13974
14616
  let text;
13975
14617
  try {
13976
- text = await readFile19(handEditCompanionPath(editPath), "utf8");
14618
+ text = await readFile20(handEditCompanionPath(editPath), "utf8");
13977
14619
  } catch {
13978
14620
  return null;
13979
14621
  }
@@ -14016,7 +14658,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
14016
14658
  }
14017
14659
  });
14018
14660
  const bases = handEditBases(spec, lock);
14019
- const expectedSize = bases?.[0] ? decodePng(await readFile19(bases[0].path)) : { width: spec.width, height: spec.height };
14661
+ const expectedSize = bases?.[0] ? decodePng(await readFile20(bases[0].path)) : { width: spec.width, height: spec.height };
14020
14662
  for (const [index, image] of decoded.entries()) {
14021
14663
  if (image.width !== expectedSize.width || image.height !== expectedSize.height) {
14022
14664
  const which = memberSet ? `member ${incoming[index].role ?? index}` : "the edit";
@@ -14032,7 +14674,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
14032
14674
  editor: input.editor,
14033
14675
  protocol: input.protocol,
14034
14676
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
14035
- project: projectPath ? path26.basename(projectPath) : null,
14677
+ project: projectPath ? path27.basename(projectPath) : null,
14036
14678
  outputs: incoming.map((frame, index) => ({
14037
14679
  role: frame.role,
14038
14680
  basedOn: basedOnHash(entry, index),
@@ -14062,7 +14704,7 @@ function basedOnHash(entry, index) {
14062
14704
  async function replaceFile(file, bytes) {
14063
14705
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
14064
14706
  try {
14065
- await writeFile10(tmp, bytes);
14707
+ await writeFile11(tmp, bytes);
14066
14708
  await rename8(tmp, file);
14067
14709
  } finally {
14068
14710
  await rm9(tmp, { force: true });
@@ -14162,7 +14804,7 @@ function createGalleryEditHandler(opts) {
14162
14804
  }
14163
14805
  const result = await applyManifestEdit(manifestPath, parsed.data);
14164
14806
  if (result.changed) {
14165
- const relative = path27.relative(process.cwd(), result.manifestPath);
14807
+ const relative = path28.relative(process.cwd(), result.manifestPath);
14166
14808
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
14167
14809
  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;
14168
14810
  log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
@@ -14174,7 +14816,7 @@ function createGalleryEditHandler(opts) {
14174
14816
  // src/gallery/snapshot.ts
14175
14817
  var PROJECT_FILE_TYPE = "application/octet-stream";
14176
14818
  function galleryMediaId(absolutePath) {
14177
- return createHash4("sha256").update(path28.resolve(absolutePath)).digest("hex").slice(0, 24);
14819
+ return createHash4("sha256").update(path29.resolve(absolutePath)).digest("hex").slice(0, 24);
14178
14820
  }
14179
14821
  function galleryMediaRoute(id) {
14180
14822
  return `/media/${id}`;
@@ -14193,9 +14835,9 @@ function describeHistory(media, entry, cacheDir) {
14193
14835
  return (entry.history ?? []).map((generation, i) => {
14194
14836
  const outputs = generation.outputs.map((output) => {
14195
14837
  const mediaType = output.mediaType ?? MediaType.PNG;
14196
- const file = path28.join(cacheDir, cacheFileName(output.sha256, mediaType));
14838
+ const file = path29.join(cacheDir, cacheFileName(output.sha256, mediaType));
14197
14839
  let url = null;
14198
- if (existsSync22(file)) {
14840
+ if (existsSync23(file)) {
14199
14841
  const id = galleryMediaId(file);
14200
14842
  media.set(id, { path: file, contentType: mediaType });
14201
14843
  url = `${galleryMediaRoute(id)}?v=${output.sha256.slice(0, 24)}`;
@@ -14227,7 +14869,7 @@ function describeHistory(media, entry, cacheDir) {
14227
14869
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
14228
14870
  async function samePixels(a, b) {
14229
14871
  try {
14230
- const [left, right] = await Promise.all([readFile20(a), readFile20(b)]);
14872
+ const [left, right] = await Promise.all([readFile21(a), readFile21(b)]);
14231
14873
  if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
14232
14874
  const x = decodePng(left);
14233
14875
  const y = decodePng(right);
@@ -14239,6 +14881,7 @@ async function samePixels(a, b) {
14239
14881
  var metadataFps = (entry) => entry ? frameSetFps(entry) : null;
14240
14882
  function describeCharacter(spec, entry) {
14241
14883
  const recordedCharacter = entry?.providerMetadata?.[entry.provider]?.character;
14884
+ if (spec?.revision) return null;
14242
14885
  if (spec?.character) {
14243
14886
  const character = spec.character;
14244
14887
  return {
@@ -14324,10 +14967,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
14324
14967
  async function describeQuality(media, root, inspection) {
14325
14968
  const outputPaths = inspection.outputs ?? [inspection.output];
14326
14969
  const outputs = await Promise.all(
14327
- outputPaths.map((file) => describeOutput(media, root, path28.resolve(root, file)))
14970
+ outputPaths.map((file) => describeOutput(media, root, path29.resolve(root, file)))
14328
14971
  );
14329
- const recordPath = path28.resolve(root, inspection.record);
14330
- const recordExists = existsSync22(recordPath);
14972
+ const recordPath = path29.resolve(root, inspection.record);
14973
+ const recordExists = existsSync23(recordPath);
14331
14974
  let options = null;
14332
14975
  let check = null;
14333
14976
  if (recordExists) {
@@ -14346,7 +14989,7 @@ async function describeQuality(media, root, inspection) {
14346
14989
  return {
14347
14990
  state: inspection.state,
14348
14991
  reason: inspection.reason,
14349
- output: portableOutputPath(path28.resolve(root, inspection.output), root),
14992
+ output: portableOutputPath(path29.resolve(root, inspection.output), root),
14350
14993
  record: portableOutputPath(recordPath, root),
14351
14994
  recordExists,
14352
14995
  outputs,
@@ -14379,13 +15022,14 @@ async function describeQuality(media, root, inspection) {
14379
15022
  }
14380
15023
  async function buildGallerySnapshot(opts) {
14381
15024
  const { loaded, specs, lock } = opts;
15025
+ const filter = opts.filter?.assets?.length ? { ...opts.filter, assets: expandAssetFilter(opts.filter.assets, loaded.loopFamilies) } : opts.filter;
14382
15026
  const root = loaded.root;
14383
15027
  const media = /* @__PURE__ */ new Map();
14384
15028
  const plan = await buildPlan(specs, lock);
14385
15029
  const items = [];
14386
- const manifestText = await readFile20(loaded.path, "utf8");
15030
+ const manifestText = await readFile21(loaded.path, "utf8");
14387
15031
  const manifestSha256 = sha256(manifestText);
14388
- const cacheDir = path28.resolve(path28.dirname(opts.lockPath), ".pixelkiln", "cache");
15032
+ const cacheDir = path29.resolve(path29.dirname(opts.lockPath), ".pixelkiln", "cache");
14389
15033
  let rawStyles = {};
14390
15034
  try {
14391
15035
  const raw = JSON.parse(manifestText);
@@ -14409,12 +15053,12 @@ async function buildGallerySnapshot(opts) {
14409
15053
  )
14410
15054
  );
14411
15055
  if (spec.source) {
14412
- const editPath = path28.resolve(root, spec.source);
15056
+ const editPath = path29.resolve(root, spec.source);
14413
15057
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
14414
15058
  const editShas = await Promise.all(
14415
15059
  Array.from({ length: memberCount }, async (_, index) => {
14416
15060
  const file = sourceOutputPath(spec.source, entry, index, root);
14417
- return { file, sha256: existsSync22(file) ? await sha256File(file) : null };
15061
+ return { file, sha256: existsSync23(file) ? await sha256File(file) : null };
14418
15062
  })
14419
15063
  );
14420
15064
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -14422,7 +15066,7 @@ async function buildGallerySnapshot(opts) {
14422
15066
  const companion = await readHandEditCompanion(editPath);
14423
15067
  if (companion) {
14424
15068
  const projectPath = handEditProjectPath(editPath);
14425
- const project = companion.project && existsSync22(projectPath) ? await fileInfo(projectPath) : null;
15069
+ const project = companion.project && existsSync23(projectPath) ? await fileInfo(projectPath) : null;
14426
15070
  let projectUrl = null;
14427
15071
  if (project) {
14428
15072
  const id = galleryMediaId(projectPath);
@@ -14458,8 +15102,8 @@ async function buildGallerySnapshot(opts) {
14458
15102
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
14459
15103
  }
14460
15104
  } else if (spec.source) {
14461
- outputs = [await describeOutput(media, root, path28.resolve(root, spec.source))];
14462
- } else if (!entry && existsSync22(spec.outFile)) {
15105
+ outputs = [await describeOutput(media, root, path29.resolve(root, spec.source))];
15106
+ } else if (!entry && existsSync23(spec.outFile)) {
14463
15107
  outputs = [await describeOutput(media, root, spec.outFile)];
14464
15108
  } else {
14465
15109
  outputs = [];
@@ -14523,7 +15167,7 @@ async function buildGallerySnapshot(opts) {
14523
15167
  for (const [key, entry] of Object.entries(lock.entries)) {
14524
15168
  if (shown.has(key)) continue;
14525
15169
  if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
14526
- if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
15170
+ if (!matchesFilter(filter, entry.styleId, entry.assetId)) continue;
14527
15171
  const outputs = await Promise.all(
14528
15172
  entry.outputs.map(
14529
15173
  (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
@@ -14652,7 +15296,7 @@ async function buildGallerySnapshot(opts) {
14652
15296
  id: loaded.manifest.name,
14653
15297
  name: loaded.manifest.name,
14654
15298
  manifest: loaded.path,
14655
- lock: path28.resolve(opts.lockPath),
15299
+ lock: path29.resolve(opts.lockPath),
14656
15300
  root,
14657
15301
  provider: loaded.manifest.provider,
14658
15302
  account: null,
@@ -14678,7 +15322,7 @@ async function buildGallerySnapshot(opts) {
14678
15322
  return { snapshot, media };
14679
15323
  }
14680
15324
  async function buildWorkspaceGallerySnapshot(opts) {
14681
- const dir = path28.dirname(path28.resolve(opts.workspacePath));
15325
+ const dir = path29.dirname(path29.resolve(opts.workspacePath));
14682
15326
  const media = /* @__PURE__ */ new Map();
14683
15327
  const projects = [];
14684
15328
  const items = [];
@@ -14689,7 +15333,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
14689
15333
  try {
14690
15334
  const loaded = await loadManifest(manifestPath);
14691
15335
  const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
14692
- const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
15336
+ const assetFilter = expandAssetFilter(filter.assets, loaded.loopFamilies).filter((id) => loaded.manifest.assets[id]);
14693
15337
  const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
14694
15338
  const lock = await loadLock(lockPath);
14695
15339
  let projectItems = [];
@@ -14721,11 +15365,11 @@ async function buildWorkspaceGallerySnapshot(opts) {
14721
15365
  id: project.id,
14722
15366
  name: loaded.manifest.name,
14723
15367
  manifest: manifestPath,
14724
- lock: path28.resolve(lockPath),
15368
+ lock: path29.resolve(lockPath),
14725
15369
  root: loaded.root,
14726
15370
  provider: loaded.manifest.provider,
14727
15371
  account: project.account ?? null,
14728
- manifestSha256: manifestSha256 ?? sha256(await readFile20(manifestPath, "utf8")),
15372
+ manifestSha256: manifestSha256 ?? sha256(await readFile21(manifestPath, "utf8")),
14729
15373
  historyLimit: historyLimit(loaded.manifest),
14730
15374
  entries: Object.keys(lock.entries).length,
14731
15375
  items: projectItems.length,
@@ -14737,8 +15381,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
14737
15381
  id: project.id,
14738
15382
  name: project.id,
14739
15383
  manifest: manifestPath,
14740
- lock: path28.resolve(lockPath),
14741
- root: path28.dirname(manifestPath),
15384
+ lock: path29.resolve(lockPath),
15385
+ root: path29.dirname(manifestPath),
14742
15386
  provider: project.provider,
14743
15387
  account: project.account ?? null,
14744
15388
  manifestSha256: null,
@@ -14765,7 +15409,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
14765
15409
  version: 1,
14766
15410
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
14767
15411
  project: null,
14768
- workspace: { path: path28.resolve(opts.workspacePath), dir, projects },
15412
+ workspace: { path: path29.resolve(opts.workspacePath), dir, projects },
14769
15413
  filter,
14770
15414
  totals: {
14771
15415
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -14914,10 +15558,10 @@ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download
14914
15558
 
14915
15559
  // src/editor/install.ts
14916
15560
  import { createHash as createHash5 } from "crypto";
14917
- import { existsSync as existsSync23 } from "fs";
14918
- import { mkdir as mkdir8, readFile as readFile21, rename as rename9, rm as rm10, stat as stat4, writeFile as writeFile11 } from "fs/promises";
15561
+ import { existsSync as existsSync24 } from "fs";
15562
+ import { mkdir as mkdir9, readFile as readFile22, rename as rename9, rm as rm10, stat as stat4, writeFile as writeFile12 } from "fs/promises";
14919
15563
  import os2 from "os";
14920
- import path29 from "path";
15564
+ import path30 from "path";
14921
15565
  var EditorInstallError = class extends Error {
14922
15566
  constructor(message6, file) {
14923
15567
  super(message6);
@@ -14928,15 +15572,15 @@ var EditorInstallError = class extends Error {
14928
15572
  };
14929
15573
  function editorToolsRoot() {
14930
15574
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
14931
- if (override) return path29.resolve(override);
14932
- if (process.platform === "darwin") return path29.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
15575
+ if (override) return path30.resolve(override);
15576
+ if (process.platform === "darwin") return path30.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
14933
15577
  if (process.platform === "win32") {
14934
- return path29.join(process.env.LOCALAPPDATA ?? path29.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
15578
+ return path30.join(process.env.LOCALAPPDATA ?? path30.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
14935
15579
  }
14936
- return path29.join(process.env.XDG_CACHE_HOME ?? path29.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
15580
+ return path30.join(process.env.XDG_CACHE_HOME ?? path30.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
14937
15581
  }
14938
15582
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
14939
- return path29.join(root, "pixelorama", pin.release ?? "unpublished");
15583
+ return path30.join(root, "pixelorama", pin.release ?? "unpublished");
14940
15584
  }
14941
15585
  function editorBaseUrl(pin = EDITOR_PIN) {
14942
15586
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -14946,7 +15590,7 @@ function editorBaseUrl(pin = EDITOR_PIN) {
14946
15590
  }
14947
15591
  async function sha256Of(file) {
14948
15592
  try {
14949
- return createHash5("sha256").update(await readFile21(file)).digest("hex");
15593
+ return createHash5("sha256").update(await readFile22(file)).digest("hex");
14950
15594
  } catch {
14951
15595
  return null;
14952
15596
  }
@@ -14959,8 +15603,8 @@ async function editorStatus(opts = {}) {
14959
15603
  let totalBytes = 0;
14960
15604
  for (const [name, expected] of Object.entries(pin.files)) {
14961
15605
  totalBytes += expected.bytes;
14962
- const file = path29.join(dir, name);
14963
- const info = existsSync23(file) ? await stat4(file).catch(() => null) : null;
15606
+ const file = path30.join(dir, name);
15607
+ const info = existsSync24(file) ? await stat4(file).catch(() => null) : null;
14964
15608
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
14965
15609
  installedBytes += expected.bytes;
14966
15610
  } else {
@@ -14989,7 +15633,7 @@ async function installEditor(opts = {}) {
14989
15633
  );
14990
15634
  }
14991
15635
  if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
14992
- await mkdir8(dir, { recursive: true });
15636
+ await mkdir9(dir, { recursive: true });
14993
15637
  const downloaded = [];
14994
15638
  const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
14995
15639
  let fetchedBytes = before.installedBytes;
@@ -15017,10 +15661,10 @@ async function installEditor(opts = {}) {
15017
15661
  if (digest2 !== expected.sha256) {
15018
15662
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
15019
15663
  }
15020
- const final = path29.join(dir, name);
15664
+ const final = path30.join(dir, name);
15021
15665
  const tmp = `${final}.${process.pid}.part`;
15022
15666
  try {
15023
- await writeFile11(tmp, bytes);
15667
+ await writeFile12(tmp, bytes);
15024
15668
  await rename9(tmp, final);
15025
15669
  } finally {
15026
15670
  await rm10(tmp, { force: true });
@@ -15063,12 +15707,12 @@ var EDITOR_CONTENT_TYPES = {
15063
15707
  ".json": "application/json; charset=utf-8"
15064
15708
  };
15065
15709
  function editorContentType(name) {
15066
- return EDITOR_CONTENT_TYPES[path29.extname(name).toLowerCase()] ?? null;
15710
+ return EDITOR_CONTENT_TYPES[path30.extname(name).toLowerCase()] ?? null;
15067
15711
  }
15068
15712
 
15069
15713
  // src/gallery/editor.ts
15070
- import { readFile as readFile22 } from "fs/promises";
15071
- import path30 from "path";
15714
+ import { readFile as readFile23 } from "fs/promises";
15715
+ import path31 from "path";
15072
15716
  function editorRoute(release, name = "index.html") {
15073
15717
  return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
15074
15718
  }
@@ -15141,7 +15785,7 @@ function createGalleryEditorHandlers(opts = {}) {
15141
15785
  if (!status.installed) return null;
15142
15786
  let bytes;
15143
15787
  try {
15144
- bytes = await readFile22(path30.join(dir, name));
15788
+ bytes = await readFile23(path31.join(dir, name));
15145
15789
  } catch {
15146
15790
  verified = null;
15147
15791
  return null;
@@ -15158,7 +15802,7 @@ function createGalleryEditorHandlers(opts = {}) {
15158
15802
  // src/gallery/server.ts
15159
15803
  import { randomBytes } from "crypto";
15160
15804
  import { createServer as createServer2 } from "http";
15161
- import { readFile as readFile23 } from "fs/promises";
15805
+ import { readFile as readFile24 } from "fs/promises";
15162
15806
  var MAX_EDIT_BYTES = 48 * 1024 * 1024;
15163
15807
  var EDITOR_CSP = [
15164
15808
  "default-src 'self'",
@@ -15317,7 +15961,7 @@ async function serveGallery(opts) {
15317
15961
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
15318
15962
  if (!asset) return fail(404, "no such review asset");
15319
15963
  try {
15320
- const bytes = await readFile23(asset.path);
15964
+ const bytes = await readFile24(asset.path);
15321
15965
  res.writeHead(200, {
15322
15966
  "Content-Type": asset.contentType,
15323
15967
  "Cache-Control": "no-store",
@@ -15393,7 +16037,7 @@ async function serveGallery(opts) {
15393
16037
  if (!asset) return fail(404, "no such gallery media");
15394
16038
  let bytes;
15395
16039
  try {
15396
- bytes = await readFile23(asset.path);
16040
+ bytes = await readFile24(asset.path);
15397
16041
  } catch {
15398
16042
  return fail(404, "gallery media is no longer on disk; refresh the page");
15399
16043
  }
@@ -15767,19 +16411,19 @@ function createGenerateHandlers(opts) {
15767
16411
  }
15768
16412
 
15769
16413
  // src/pipeline/salvage.ts
15770
- import { readFile as readFile24 } from "fs/promises";
15771
- import { existsSync as existsSync24 } from "fs";
15772
- import path31 from "path";
16414
+ import { readFile as readFile25 } from "fs/promises";
16415
+ import { existsSync as existsSync25 } from "fs";
16416
+ import path32 from "path";
15773
16417
  function providerClaimId(provider, objectId) {
15774
16418
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
15775
16419
  }
15776
16420
  async function loadClaims(lockPaths, opts = {}) {
15777
16421
  const claimed = /* @__PURE__ */ new Set();
15778
16422
  for (const p of lockPaths) {
15779
- if (!existsSync24(p)) throw new Error(`Claim lockfile not found: ${p}`);
16423
+ if (!existsSync25(p)) throw new Error(`Claim lockfile not found: ${p}`);
15780
16424
  let parsed;
15781
16425
  try {
15782
- parsed = parseLock(JSON.parse(await readFile24(p, "utf8")));
16426
+ parsed = parseLock(JSON.parse(await readFile25(p, "utf8")));
15783
16427
  } catch {
15784
16428
  throw new Error(`Claim lockfile is malformed: ${p}`);
15785
16429
  }
@@ -15832,20 +16476,20 @@ function matchOrphanStyle(prompt, manifest) {
15832
16476
  return matchStyleByPattern(prompt, manifest);
15833
16477
  }
15834
16478
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
15835
- const own = path31.resolve(ownManifestPath);
16479
+ const own = path32.resolve(ownManifestPath);
15836
16480
  const siblingManifestPaths = [
15837
16481
  .../* @__PURE__ */ new Set([
15838
16482
  ...workspaceManifestPaths,
15839
- ...claimPaths.map((c) => path31.join(path31.dirname(path31.resolve(c)), "pixelkiln.manifest.json"))
16483
+ ...claimPaths.map((c) => path32.join(path32.dirname(path32.resolve(c)), "pixelkiln.manifest.json"))
15840
16484
  ])
15841
16485
  ];
15842
16486
  const siblings = [];
15843
16487
  for (const siblingManifestPath of siblingManifestPaths) {
15844
- if (path31.resolve(siblingManifestPath) === own) continue;
15845
- if (!existsSync24(siblingManifestPath)) continue;
16488
+ if (path32.resolve(siblingManifestPath) === own) continue;
16489
+ if (!existsSync25(siblingManifestPath)) continue;
15846
16490
  try {
15847
16491
  const { manifest } = await loadManifest(siblingManifestPath);
15848
- siblings.push({ label: path31.basename(path31.dirname(siblingManifestPath)), manifest });
16492
+ siblings.push({ label: path32.basename(path32.dirname(siblingManifestPath)), manifest });
15849
16493
  } catch {
15850
16494
  }
15851
16495
  }
@@ -15961,8 +16605,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
15961
16605
  }
15962
16606
 
15963
16607
  // src/pick/salvage-server.ts
15964
- import { mkdir as mkdir9, writeFile as writeFile12, readFile as readFile25 } from "fs/promises";
15965
- import path32 from "path";
16608
+ import { mkdir as mkdir10, writeFile as writeFile13, readFile as readFile26 } from "fs/promises";
16609
+ import path33 from "path";
15966
16610
 
15967
16611
  // src/pick/salvage-sheet.ts
15968
16612
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -16127,7 +16771,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16127
16771
  });
16128
16772
  const html = renderSalvageSheet(orphans, {
16129
16773
  styleId: ctx.styleId,
16130
- importDir: path32.relative(process.cwd(), ctx.importDir) || "."
16774
+ importDir: path33.relative(process.cwd(), ctx.importDir) || "."
16131
16775
  });
16132
16776
  const byId = new Map(orphans.map((o) => [o.id, o]));
16133
16777
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -16156,10 +16800,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16156
16800
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
16157
16801
  decodePng(buf);
16158
16802
  const assetId = idFromPrompt(orphan.prompt, taken);
16159
- const rel = path32.join("_salvaged", `${assetId}.png`);
16160
- const outFile = path32.resolve(ctx.importDir, rel);
16161
- await mkdir9(path32.dirname(outFile), { recursive: true });
16162
- await writeFile12(outFile, buf);
16803
+ const rel = path33.join("_salvaged", `${assetId}.png`);
16804
+ const outFile = path33.resolve(ctx.importDir, rel);
16805
+ await mkdir10(path33.dirname(outFile), { recursive: true });
16806
+ await writeFile13(outFile, buf);
16163
16807
  ctx.manifest.assets[assetId] = {
16164
16808
  prompt: orphan.prompt,
16165
16809
  promptByStyle: {},
@@ -16191,7 +16835,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16191
16835
  sourceUrl: durableSource,
16192
16836
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
16193
16837
  outputs: [{
16194
- path: portableOutputPath(outFile, path32.dirname(ctx.manifestPath)),
16838
+ path: portableOutputPath(outFile, path33.dirname(ctx.manifestPath)),
16195
16839
  sha256: sha256(buf)
16196
16840
  }],
16197
16841
  submittedAt: orphan.createdAt,
@@ -16215,9 +16859,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16215
16859
  }
16216
16860
  }
16217
16861
  await applyTags(provider, decisions, existingTags, { onProgress: log });
16218
- const raw = JSON.parse(await readFile25(ctx.manifestPath, "utf8"));
16862
+ const raw = JSON.parse(await readFile26(ctx.manifestPath, "utf8"));
16219
16863
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
16220
- await writeFile12(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
16864
+ await writeFile13(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
16221
16865
  await saveLock(ctx.lockPath, ctx.lock);
16222
16866
  return result;
16223
16867
  }
@@ -16346,6 +16990,10 @@ export {
16346
16990
  EXIT_CODES,
16347
16991
  EditorInstallError,
16348
16992
  FAKE_PNG,
16993
+ FONT_COST_GENERATIONS,
16994
+ FONT_GLYPH_SIZES,
16995
+ FONT_WEIGHTS,
16996
+ FRAME_SET_REVISION_MODES,
16349
16997
  FakeProvider,
16350
16998
  GenerateRequestError,
16351
16999
  GenerateRequestSchema,
@@ -16362,6 +17010,7 @@ export {
16362
17010
  MAX_DOWNLOAD_BYTES,
16363
17011
  MAX_HAND_EDIT_BYTES,
16364
17012
  MAX_RETRIES,
17013
+ MEMBER_SET_REVISION_MODES,
16365
17014
  MIRRORED_DIRECTION,
16366
17015
  ManifestDriftError,
16367
17016
  ManifestEditError,
@@ -16391,6 +17040,8 @@ export {
16391
17040
  ScenarioProvider,
16392
17041
  StyleInputSchema,
16393
17042
  StyleSchema,
17043
+ UNZOOM_MAX_AREA,
17044
+ UNZOOM_MIN_SIDE,
16394
17045
  UiElementSchema,
16395
17046
  UiPieceSchema,
16396
17047
  UnsupportedCapabilityError,
@@ -16425,6 +17076,7 @@ export {
16425
17076
  currentEntryOutputPath,
16426
17077
  currentOutputPath,
16427
17078
  defaultOpenCommand,
17079
+ defaultUnzoomOut,
16428
17080
  detachHandEdit,
16429
17081
  detectMediaType,
16430
17082
  doctor,
@@ -16442,10 +17094,12 @@ export {
16442
17094
  fetchAssets,
16443
17095
  fetchWithRetry,
16444
17096
  findOrphans,
17097
+ fontOutputPaths,
16445
17098
  formatCost,
16446
17099
  frameSetFps,
16447
17100
  galleryMediaId,
16448
17101
  galleryMediaRoute,
17102
+ generateFont,
16449
17103
  generationCost,
16450
17104
  groupOrphansByStyle,
16451
17105
  handEditBase,
@@ -16581,6 +17235,7 @@ export {
16581
17235
  tilesCost,
16582
17236
  toPortablePath,
16583
17237
  totalSpend,
17238
+ unzoomFile,
16584
17239
  upsert,
16585
17240
  validateCostEstimate,
16586
17241
  validateMedia,