pixelkiln 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -703,6 +703,11 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
703
703
  // A mirror's bytes come from its source's recorded outputs; the plan
704
704
  // compares those directly, so only the choice of source is identity.
705
705
  mirror: spec.mirror ? { of: spec.mirror.sourceAssetId } : void 0,
706
+ // The whole group's ordered descriptions, not just this asset's own —
707
+ // any sibling's prompt changing, or a member being added or removed,
708
+ // must mark every member stale together, since they all ride on one
709
+ // submitted job with no way to amend it after the fact.
710
+ batch: spec.batch ? { role: spec.batch.role, itemDescriptions: spec.batch.itemDescriptions, index: spec.batch.index } : void 0,
706
711
  revision: spec.revision ? {
707
712
  mode: spec.revision.mode,
708
713
  from: spec.revision.sourceAssetId,
@@ -712,7 +717,12 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
712
717
  numColors: spec.revision.numColors,
713
718
  paletteImageSha256: spec.revision.paletteImageSha256,
714
719
  dithering: spec.revision.dithering,
715
- ditheringStrength: spec.revision.ditheringStrength
720
+ ditheringStrength: spec.revision.ditheringStrength,
721
+ frames: spec.revision.frames,
722
+ fps: spec.revision.fps,
723
+ lastFrameSha256: spec.revision.lastFrameSha256,
724
+ direction: spec.revision.direction,
725
+ enhancePrompt: spec.revision.enhancePrompt
716
726
  } : void 0
717
727
  })
718
728
  );
@@ -732,7 +742,15 @@ import { z } from "zod";
732
742
  var MediaTypeSchema = z.enum(["image/png", "image/gif"]);
733
743
  var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro"]);
734
744
  var GridConfidenceSchema = z.enum(["low", "medium", "high"]);
735
- var RevisionModeSchema = z.enum(["image-to-image", "inpaint", "outpaint", "reduce-colors", "correct-pixelart"]);
745
+ var RevisionModeSchema = z.enum([
746
+ "image-to-image",
747
+ "inpaint",
748
+ "outpaint",
749
+ "reduce-colors",
750
+ "correct-pixelart",
751
+ "animate",
752
+ "animate-pixminimax"
753
+ ]);
736
754
  function tileVariationCount(descriptions) {
737
755
  return Math.max(1, descriptions) * 4;
738
756
  }
@@ -844,6 +862,12 @@ var CharacterProportionsSchema = z.union([
844
862
  }).strict()
845
863
  ]);
846
864
  var CharacterAnimationModeSchema = z.enum(["template", "v3", "pro"]);
865
+ var AssetBatchSchema = z.object({
866
+ /** The batch leader: another `1dir` asset in the same style. */
867
+ of: z.string().min(1),
868
+ /** 1-based slot in the batch (the leader is implicitly slot 0). Unique and contiguous among siblings. */
869
+ index: z.number().int().min(1)
870
+ }).strict();
847
871
  var CharacterStateSchema = z.object({
848
872
  /** The base character, or another state, in the same style. */
849
873
  of: z.string().min(1),
@@ -943,7 +967,21 @@ var RevisionSchema = z.object({
943
967
  /** `reduce-colors` only: ordered dithering matrix size. */
944
968
  dithering: RevisionDitheringSchema.optional(),
945
969
  /** `reduce-colors` only: dithering intensity; ignored when `dithering` is "none" or unset. */
946
- ditheringStrength: z.number().min(0).max(10).optional()
970
+ ditheringStrength: z.number().min(0).max(10).optional(),
971
+ /**
972
+ * `animate`/`animate-pixminimax` only: frames to generate, even, 4 to 40
973
+ * (the provider-specific ceiling — 16 for `animate` — is enforced at
974
+ * the provider layer, since it differs by mode).
975
+ */
976
+ frames: z.number().int().min(4).max(40).optional(),
977
+ /** `animate`/`animate-pixminimax` only: playback rate recorded with the frames; PixelLab does not store one. */
978
+ fps: z.number().int().min(1).max(60).optional(),
979
+ /** `animate`/`animate-pixminimax` only: manifest-relative image pinning where the motion ends (interpolation instead of open-ended animation). */
980
+ lastFrame: z.string().min(1).optional(),
981
+ /** `animate-pixminimax` only: facing direction, used only alongside `enhancePrompt` to hold the sprite's facing. */
982
+ direction: CharacterDirectionSchema.optional(),
983
+ /** `animate`/`animate-pixminimax` only: let PixelLab expand the action into a fuller motion description first. */
984
+ enhancePrompt: z.boolean().optional()
947
985
  }).strict().superRefine((revision, context) => {
948
986
  if (revision.mode === "inpaint" && !revision.mask) {
949
987
  context.addIssue({
@@ -982,6 +1020,32 @@ var RevisionSchema = z.object({
982
1020
  path: ["strength"]
983
1021
  });
984
1022
  }
1023
+ if (revision.strength !== void 0 && (revision.mode === "animate" || revision.mode === "animate-pixminimax")) {
1024
+ context.addIssue({
1025
+ code: z.ZodIssueCode.custom,
1026
+ message: `${revision.mode} revisions do not take a strength; use enhancePrompt`,
1027
+ path: ["strength"]
1028
+ });
1029
+ }
1030
+ for (const field of ["frames", "fps", "lastFrame", "enhancePrompt"]) {
1031
+ if (revision[field] !== void 0 && revision.mode !== "animate" && revision.mode !== "animate-pixminimax") {
1032
+ context.addIssue({
1033
+ code: z.ZodIssueCode.custom,
1034
+ message: `${field} applies to animate/animate-pixminimax revisions only`,
1035
+ path: [field]
1036
+ });
1037
+ }
1038
+ }
1039
+ if (revision.direction !== void 0 && revision.mode !== "animate-pixminimax") {
1040
+ context.addIssue({
1041
+ code: z.ZodIssueCode.custom,
1042
+ message: "direction applies to animate-pixminimax revisions only",
1043
+ path: ["direction"]
1044
+ });
1045
+ }
1046
+ if (revision.frames !== void 0 && revision.frames % 2 !== 0) {
1047
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
1048
+ }
985
1049
  });
986
1050
  var StyleObjectSchema = z.object({
987
1051
  /** Generation backend for this style. Omit to inherit the manifest default. */
@@ -1377,6 +1441,12 @@ var AssetSchema = z.object({
1377
1441
  * `prompt` instead.
1378
1442
  */
1379
1443
  animation: CharacterAnimationSchema.optional(),
1444
+ /**
1445
+ * `1dir` styles only: this asset rides along on another `1dir` asset's
1446
+ * batch submission instead of generating on its own. See
1447
+ * `AssetBatchSchema`.
1448
+ */
1449
+ batch: AssetBatchSchema.optional(),
1380
1450
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
1381
1451
  proportions: CharacterProportionsSchema.optional(),
1382
1452
  /**
@@ -1453,7 +1523,7 @@ var AssetSchema = z.object({
1453
1523
  path: ["revision"]
1454
1524
  });
1455
1525
  }
1456
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror"].filter(Boolean);
1526
+ const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch"].filter(Boolean);
1457
1527
  if (shapes.length > 1) {
1458
1528
  context.addIssue({
1459
1529
  code: z.ZodIssueCode.custom,
@@ -1592,13 +1662,24 @@ var LockEntrySchema = z.object({
1592
1662
  numColors: z.number().int().min(2).max(256).optional(),
1593
1663
  paletteImageSha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
1594
1664
  dithering: RevisionDitheringSchema.optional(),
1595
- ditheringStrength: z.number().min(0).max(10).optional()
1665
+ ditheringStrength: z.number().min(0).max(10).optional(),
1666
+ frames: z.number().int().min(4).max(40).optional(),
1667
+ fps: z.number().int().min(1).max(60).optional(),
1668
+ lastFrameSha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
1669
+ direction: CharacterDirectionSchema.optional(),
1670
+ enhancePrompt: z.boolean().optional()
1596
1671
  }).strict().nullable().default(null),
1597
1672
  /** For a mirror: the asset it flips and a hash over that asset's output hashes when this was made. */
1598
1673
  mirror: z.object({
1599
1674
  sourceAssetId: z.string().min(1),
1600
1675
  sourceSha256: z.string().regex(/^[0-9a-f]{64}$/)
1601
1676
  }).strict().nullable().default(null),
1677
+ /** For a `1dir` batch member: which leader and slot it rode along on. */
1678
+ batch: z.object({
1679
+ role: z.enum(["leader", "member"]),
1680
+ leaderAssetId: z.string().min(1).optional(),
1681
+ index: z.number().int().min(1).optional()
1682
+ }).strict().nullable().default(null),
1602
1683
  /**
1603
1684
  * Output hashes owned by the previous generation while its replacement is
1604
1685
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -4667,8 +4748,16 @@ var ComfyUIProvider = class _ComfyUIProvider {
4667
4748
  supports(generator) {
4668
4749
  return generator === "map" || generator === "frames";
4669
4750
  }
4670
- supportsRevision(_mode) {
4671
- return true;
4751
+ /**
4752
+ * Everything else resolves generically to whatever the user's own
4753
+ * workflow does with `bindings.sourceImage`. `animate`/`animate-pixminimax`
4754
+ * are the one exception: they produce an ordered frame set, and this
4755
+ * adapter's revision path always writes a single output image (see
4756
+ * `submit`/`fetch` below) — a real structural gap, not a missing binding,
4757
+ * so it is rejected here rather than only failing once `validate` runs.
4758
+ */
4759
+ supportsRevision(mode2) {
4760
+ return mode2 !== "animate" && mode2 !== "animate-pixminimax";
4672
4761
  }
4673
4762
  estimate(spec) {
4674
4763
  return {
@@ -6153,14 +6242,15 @@ var PixelLabClient = class {
6153
6242
  /**
6154
6243
  * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image onto a
6155
6244
  * smaller palette, synchronously — no `background_job_id`, the result
6156
- * comes back in this same response, like `createImagePixflux`. Verified
6157
- * against the live OpenAPI schema, not exercised against a live account:
6158
- * the schema's own response example is `usage: {type: "usd", usd: 0.02}`,
6159
- * which going by this codebase's own repeated experience with PixelLab's
6160
- * documented-vs-billed cost mismatches (`isometricTile`, `objectPro`) —
6161
- * should not be trusted over a real call. `numColors` and `paletteImage`
6162
- * are mutually exclusive upstream; the manifest schema already enforces
6163
- * that before this is ever called.
6245
+ * comes back in this same response, like `createImagePixflux`. The
6246
+ * schema's own response example is dollar-denominated (`usage: {type:
6247
+ * "usd", usd: 0.02}`), which — matching this codebase's repeated
6248
+ * experience with PixelLab's documented-vs-billed cost mismatches
6249
+ * (`isometricTile`, `objectPro`) — did not hold: confirmed live against a
6250
+ * Tier 2 account at exactly 0.1 generations for a 32x32 source
6251
+ * (docs/REVISIONS.md). `numColors` and `paletteImage` are mutually
6252
+ * exclusive upstream; the manifest schema already enforces that before
6253
+ * this is ever called.
6164
6254
  */
6165
6255
  async reduceColors(args) {
6166
6256
  const body = { images: [args.image] };
@@ -6183,8 +6273,9 @@ var PixelLabClient = class {
6183
6273
  /**
6184
6274
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
6185
6275
  * stray pixels without resizing, synchronously — same shape as
6186
- * `reduceColors` above, no background job. Cost is likewise unverified
6187
- * against a live account (schema example: `usage: {type: "usd", usd: 0.02}`).
6276
+ * `reduceColors` above, no background job. Cost is likewise confirmed
6277
+ * live at 0.1 generations for a 32x32 source, not the schema's own
6278
+ * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
6188
6279
  */
6189
6280
  async correctPixelart(args) {
6190
6281
  const body = { images: [args.image] };
@@ -6196,6 +6287,50 @@ var PixelLabClient = class {
6196
6287
  );
6197
6288
  return { png: Buffer.from(res.images[0].base64, "base64"), usage: res.usage };
6198
6289
  }
6290
+ /**
6291
+ * `/animate-with-text-v3`: animate a loose image from a text description,
6292
+ * no PixelLab character/object resource required — unlike `animateCharacter`
6293
+ * / `animateObject`, `firstFrame` is whatever bytes the caller has on hand.
6294
+ * A plain background job, like every other PixelLab async submission;
6295
+ * unlike `inpaintV3`/`editImagesV2`, its completed shape has not been
6296
+ * exercised against a live account (request/response fields here come from
6297
+ * the live OpenAPI document, not an observed call — `pollAnimateRevision`
6298
+ * in pixellab.ts checks several plausible field names for the frame list
6299
+ * defensively, the same as `pollRevision` already does for image edits).
6300
+ */
6301
+ async animateWithTextV3(args) {
6302
+ const body = { first_frame: args.firstFrame, action: args.action };
6303
+ if (args.lastFrame) body.last_frame = args.lastFrame;
6304
+ if (args.frameCount != null) body.frame_count = args.frameCount;
6305
+ if (args.seed != null) body.seed = args.seed;
6306
+ if (args.noBackground != null) body.no_background = args.noBackground;
6307
+ if (args.enhancePrompt != null) body.enhance_prompt = args.enhancePrompt;
6308
+ return validateResponse(
6309
+ RevisionJobSubmitSchema,
6310
+ await this.request("/animate-with-text-v3", { method: "POST", body: JSON.stringify(body) }),
6311
+ "animate-with-text-v3"
6312
+ );
6313
+ }
6314
+ /**
6315
+ * `/animate-pixminimax`, beta (tier 1 subscription or higher): PixMiniMax's
6316
+ * richer take on the same idea — `direction` and `enhancePrompt` steer
6317
+ * facing for aimed motions. Same unverified-completed-shape caveat as
6318
+ * `animateWithTextV3` above.
6319
+ */
6320
+ async animatePixminimax(args) {
6321
+ const body = { first_frame: args.firstFrame, description: args.description };
6322
+ if (args.lastFrame) body.last_frame = args.lastFrame;
6323
+ if (args.frameCount != null) body.frame_count = args.frameCount;
6324
+ if (args.seed != null) body.seed = args.seed;
6325
+ if (args.noBackground != null) body.no_background = args.noBackground;
6326
+ if (args.enhancePrompt != null) body.enhance_prompt = args.enhancePrompt;
6327
+ if (args.direction) body.direction = args.direction;
6328
+ return validateResponse(
6329
+ RevisionJobSubmitSchema,
6330
+ await this.request("/animate-pixminimax", { method: "POST", body: JSON.stringify(body) }),
6331
+ "animate-pixminimax"
6332
+ );
6333
+ }
6199
6334
  async getBackgroundJob(jobId) {
6200
6335
  const raw = await this.request(`/background-jobs/${encodeURIComponent(jobId)}`);
6201
6336
  return validateResponse(BackgroundJobSchema, raw, "background-jobs/{id}");
@@ -6378,12 +6513,15 @@ var PixelLabProvider = class _PixelLabProvider {
6378
6513
  * no mask) both exist on PixelLab, and so do `reduce-colors`
6379
6514
  * (`/reduce-colors`) and `correct-pixelart` (`/correct-pixelart`) — PixelLab's
6380
6515
  * "Cleanup" tier, a mechanical post-process on the source's own pixels
6381
- * rather than a described edit. `outpaint` does not: there is no
6382
- * canvas-expansion endpoint in the API, matching docs/REVISIONS.md's note
6383
- * that no provider ships a tested outpaint path yet.
6516
+ * rather than a described edit — and `animate`/`animate-pixminimax`
6517
+ * (`/animate-with-text-v3`, `/animate-pixminimax`), which animate any loose
6518
+ * image from a text description with no character/object resource
6519
+ * required. `outpaint` does not: there is no canvas-expansion endpoint in
6520
+ * the API, matching docs/REVISIONS.md's note that no provider ships a
6521
+ * tested outpaint path yet.
6384
6522
  */
6385
6523
  supportsRevision(mode2) {
6386
- return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart";
6524
+ return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "animate" || mode2 === "animate-pixminimax";
6387
6525
  }
6388
6526
  /** PixelLab's own constraints: submissions must be >2s apart, and
6389
6527
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -6405,9 +6543,19 @@ var PixelLabProvider = class _PixelLabProvider {
6405
6543
  return dir;
6406
6544
  }
6407
6545
  estimate(spec) {
6546
+ if (spec.batch?.role === "member") {
6547
+ return { unit: "generations", amount: 0, candidates: 1 };
6548
+ }
6408
6549
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
6409
6550
  return { unit: "generations", amount: 0.1, candidates: 1 };
6410
6551
  }
6552
+ if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
6553
+ const width = spec.revision.sourceWidth ?? spec.width;
6554
+ const height = spec.revision.sourceHeight ?? spec.height;
6555
+ const frames = spec.revision.frames ?? 8;
6556
+ const base = Math.max(1, Math.ceil(width * height * frames / 65536));
6557
+ return { unit: "generations", amount: spec.revision.enhancePrompt ? base + 0.05 : base, candidates: 1 };
6558
+ }
6411
6559
  if (spec.revision) {
6412
6560
  const width = spec.revision.sourceWidth ?? spec.width;
6413
6561
  const height = spec.revision.sourceHeight ?? spec.height;
@@ -6449,6 +6597,20 @@ var PixelLabProvider = class _PixelLabProvider {
6449
6597
  throw new Error(`PixelLab correct-pixelart source is ${width}x${height}; the API takes at most 1024 pixels per side`);
6450
6598
  }
6451
6599
  }
6600
+ if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
6601
+ const { sourceWidth: width, sourceHeight: height, mode: mode2, frames, lastFrameWidth, lastFrameHeight } = spec.revision;
6602
+ if (width != null && height != null && (width > 256 || height > 256)) {
6603
+ throw new Error(`PixelLab ${mode2} source is ${width}x${height}; the API takes at most 256 pixels per side`);
6604
+ }
6605
+ if (mode2 === "animate" && frames != null && frames > 16) {
6606
+ throw new Error(`PixelLab animate takes 4 to 16 frames; ${frames} is too many (use animate-pixminimax for up to 40)`);
6607
+ }
6608
+ if (lastFrameWidth != null && lastFrameHeight != null && width != null && height != null && (lastFrameWidth !== width || lastFrameHeight !== height)) {
6609
+ throw new Error(
6610
+ `PixelLab ${mode2} lastFrame is ${lastFrameWidth}x${lastFrameHeight}; source is ${width}x${height} \u2014 they must match`
6611
+ );
6612
+ }
6613
+ }
6452
6614
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
6453
6615
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
6454
6616
  }
@@ -7182,11 +7344,21 @@ var PixelLabProvider = class _PixelLabProvider {
7182
7344
  return { jobId: res2.background_job_id };
7183
7345
  }
7184
7346
  if (spec.generator === "1dir") {
7347
+ if (spec.batch?.role === "member") {
7348
+ throw new Error(`${spec.styleId}/${spec.assetId}: a batch member cannot submit on its own`);
7349
+ }
7185
7350
  const res2 = await this.client.create1Direction({
7186
7351
  description: spec.prompt,
7187
7352
  size: spec.size,
7188
7353
  view: spec.view === "sidescroller" ? "sidescroller" : "top-down",
7189
- styleImages
7354
+ styleImages,
7355
+ // The leader's own subject is item_descriptions[0], so `description`
7356
+ // matches it exactly. Confirmed live: item_descriptions[0] owns
7357
+ // candidate slot 0 (a 3-item batch returned exactly the declared
7358
+ // items at frames 0/1/2, in order; docs/GENERATORS.md#1dir), so
7359
+ // sending the same text as `description` too is redundant but
7360
+ // harmless, not a hedge against ambiguity.
7361
+ itemDescriptions: spec.batch?.role === "leader" ? spec.batch.itemDescriptions : void 0
7190
7362
  });
7191
7363
  return { jobId: res2.object_id, metadata: { backgroundJobId: res2.background_job_id } };
7192
7364
  }
@@ -7270,6 +7442,42 @@ var PixelLabProvider = class _PixelLabProvider {
7270
7442
  writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), res2.png);
7271
7443
  return { jobId, metadata: { revisionUsage: res2.usage } };
7272
7444
  }
7445
+ if (revision.mode === "animate" || revision.mode === "animate-pixminimax") {
7446
+ let lastFrame;
7447
+ if (revision.lastFrameFile) {
7448
+ if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
7449
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
7450
+ }
7451
+ const lastFrameBytes = readFileSync2(revision.lastFrameFile);
7452
+ if (sha256(lastFrameBytes) !== revision.lastFrameSha256) {
7453
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
7454
+ }
7455
+ lastFrame = { base64: lastFrameBytes.toString("base64"), format: revision.lastFrameFormat };
7456
+ }
7457
+ if (revision.mode === "animate") {
7458
+ const res3 = await this.client.animateWithTextV3({
7459
+ firstFrame: image,
7460
+ lastFrame,
7461
+ action: spec.prompt,
7462
+ frameCount: revision.frames,
7463
+ seed: spec.seed,
7464
+ noBackground: spec.noBackground,
7465
+ enhancePrompt: revision.enhancePrompt
7466
+ });
7467
+ return { jobId: res3.background_job_id };
7468
+ }
7469
+ const res2 = await this.client.animatePixminimax({
7470
+ firstFrame: image,
7471
+ lastFrame,
7472
+ description: spec.prompt,
7473
+ frameCount: revision.frames,
7474
+ seed: spec.seed,
7475
+ noBackground: spec.noBackground,
7476
+ enhancePrompt: revision.enhancePrompt,
7477
+ direction: revision.direction
7478
+ });
7479
+ return { jobId: res2.background_job_id };
7480
+ }
7273
7481
  if (revision.strength != null) {
7274
7482
  throw new Error(
7275
7483
  `${spec.styleId}/${spec.assetId}: PixelLab image-to-image revisions take no strength; edit-images-v2 always applies the full instruction`
@@ -7305,6 +7513,9 @@ var PixelLabProvider = class _PixelLabProvider {
7305
7513
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart") {
7306
7514
  return this.pollCachedRevision(jobId, context);
7307
7515
  }
7516
+ if (revisionMode === "animate" || revisionMode === "animate-pixminimax") {
7517
+ return this.pollAnimateRevision(jobId, context);
7518
+ }
7308
7519
  if (context?.spec?.revision) return this.pollRevision(jobId);
7309
7520
  if (generator === "pixflux") {
7310
7521
  const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
@@ -7361,6 +7572,71 @@ var PixelLabProvider = class _PixelLabProvider {
7361
7572
  const usage = context?.metadata?.revisionUsage;
7362
7573
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
7363
7574
  }
7575
+ /**
7576
+ * `animate`/`animate-pixminimax` revisions: a plain background job, like
7577
+ * `pollRevision` above, but completing with an ORDERED FRAME LIST instead
7578
+ * of a single image — the same `review-set` shape `pollCharacterAnimation`
7579
+ * returns, minus any character/object resource to look the result up on
7580
+ * (there is none; this operates on a loose image). Unlike every other
7581
+ * PixelLab call this adapter makes, the completed shape for
7582
+ * `/animate-with-text-v3`/`/animate-pixminimax` has never been exercised
7583
+ * against a live account — the checks below are an informed guess (hosted
7584
+ * URLs under a plausible key name, the same `storage_urls.frames` shape
7585
+ * character animations use, or inline base64 per frame, decoded to a local
7586
+ * cache file the same way `pollRevision`'s single-image case already is)
7587
+ * rather than an observed shape, and fail loudly, naming the keys actually
7588
+ * received, for whatever the real shape turns out to be.
7589
+ */
7590
+ async pollAnimateRevision(jobId, context) {
7591
+ const job = await this.client.getBackgroundJob(jobId);
7592
+ if (job.status === "failed") return { status: "failed", error: "animation job failed upstream" };
7593
+ if (job.status !== "completed") return { status: "processing" };
7594
+ const billed = billedFromUsage(job.usage);
7595
+ const done = job.last_response ?? {};
7596
+ const fps = context?.spec?.revision?.fps ?? 8;
7597
+ const review = (frameUrls) => ({
7598
+ status: "review-set",
7599
+ objectId: jobId,
7600
+ frameUrls,
7601
+ sources: frameUrls.map((url, index) => ({ url, role: `frame-${String(index).padStart(2, "0")}` })),
7602
+ fps,
7603
+ metadata: { frameSet: { fps, count: frameUrls.length } },
7604
+ billed
7605
+ });
7606
+ for (const key of ["frame_urls", "frames", "images"]) {
7607
+ const list = done[key];
7608
+ if (!Array.isArray(list) || !list.length) continue;
7609
+ const urls = [];
7610
+ for (const [index, item] of list.entries()) {
7611
+ if (typeof item === "string" && item) {
7612
+ urls.push(item);
7613
+ continue;
7614
+ }
7615
+ const url = item?.url;
7616
+ if (typeof url === "string" && url) {
7617
+ urls.push(url);
7618
+ continue;
7619
+ }
7620
+ const base64 = extractBase64(item);
7621
+ if (base64) {
7622
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7623
+ writeFileSync(file, Buffer.from(base64, "base64"));
7624
+ urls.push(`file://${file}`);
7625
+ continue;
7626
+ }
7627
+ break;
7628
+ }
7629
+ if (urls.length === list.length) return review(urls);
7630
+ }
7631
+ const storageFrames = done.storage_urls?.frames;
7632
+ if (Array.isArray(storageFrames) && storageFrames.length && storageFrames.every((f) => typeof f === "string")) {
7633
+ return review(storageFrames);
7634
+ }
7635
+ return {
7636
+ status: "failed",
7637
+ 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`
7638
+ };
7639
+ }
7364
7640
  /**
7365
7641
  * `/inpaint-v3` and `/edit-images-v2` both hand back a plain background
7366
7642
  * job with no resource of its own, polled generically at
@@ -9101,6 +9377,14 @@ async function resolveSpecs(loaded, filter) {
9101
9377
  };
9102
9378
  styleSpecs.set(assetId, resolved);
9103
9379
  }
9380
+ const batchMembersByLeader = /* @__PURE__ */ new Map();
9381
+ for (const id of styleSpecs.keys()) {
9382
+ const batch = manifest.assets[id].batch;
9383
+ if (!batch) continue;
9384
+ const members = batchMembersByLeader.get(batch.of) ?? [];
9385
+ members.push({ id, index: batch.index });
9386
+ batchMembersByLeader.set(batch.of, members);
9387
+ }
9104
9388
  const finalized = /* @__PURE__ */ new Set();
9105
9389
  const finalize = async (assetId) => {
9106
9390
  const resolved = styleSpecs.get(assetId);
@@ -9217,6 +9501,8 @@ async function resolveSpecs(loaded, filter) {
9217
9501
  }
9218
9502
  const paletteImageFile = asset.revision.paletteImage ? path13.resolve(root, asset.revision.paletteImage) : void 0;
9219
9503
  const paletteImage = paletteImageFile ? await optionalRevisionImage(paletteImageFile, "revision palette image", "png") : null;
9504
+ const lastFrameFile = asset.revision.lastFrame ? path13.resolve(root, asset.revision.lastFrame) : void 0;
9505
+ const lastFrame = lastFrameFile ? await optionalRevisionImage(lastFrameFile, "revision last frame") : null;
9220
9506
  resolved.revision = {
9221
9507
  mode: asset.revision.mode,
9222
9508
  sourceAssetId: asset.revision.from,
@@ -9243,9 +9529,70 @@ async function resolveSpecs(loaded, filter) {
9243
9529
  paletteImageFormat: paletteImage?.format ?? null
9244
9530
  } : {},
9245
9531
  ...asset.revision.dithering ? { dithering: asset.revision.dithering } : {},
9246
- ...asset.revision.ditheringStrength == null ? {} : { ditheringStrength: asset.revision.ditheringStrength }
9532
+ ...asset.revision.ditheringStrength == null ? {} : { ditheringStrength: asset.revision.ditheringStrength },
9533
+ ...asset.revision.frames == null ? {} : { frames: asset.revision.frames },
9534
+ ...asset.revision.fps == null ? {} : { fps: asset.revision.fps },
9535
+ ...lastFrameFile ? {
9536
+ lastFrameFile,
9537
+ lastFrameSha256: lastFrame?.hash ?? null,
9538
+ lastFrameWidth: lastFrame?.width ?? null,
9539
+ lastFrameHeight: lastFrame?.height ?? null,
9540
+ lastFrameFormat: lastFrame?.format ?? null
9541
+ } : {},
9542
+ ...asset.revision.direction ? { direction: asset.revision.direction } : {},
9543
+ ...asset.revision.enhancePrompt == null ? {} : { enhancePrompt: asset.revision.enhancePrompt }
9247
9544
  };
9248
9545
  }
9546
+ if (asset.batch) {
9547
+ if (asset.batch.of === assetId) throw new Error(`assets.${assetId}: a batch cannot name itself as its own leader`);
9548
+ const leaderSpec = await finalize(asset.batch.of);
9549
+ if (!leaderSpec.batch || leaderSpec.batch.role !== "leader") {
9550
+ throw new Error(`assets.${assetId}: ${asset.batch.of} is not a 1dir batch leader`);
9551
+ }
9552
+ if (resolved.size !== leaderSpec.size) {
9553
+ throw new Error(
9554
+ `assets.${assetId}: batch members share the leader's canvas size; ${asset.batch.of} is ${leaderSpec.size}px, this is ${resolved.size}px`
9555
+ );
9556
+ }
9557
+ resolved.batch = {
9558
+ role: "member",
9559
+ itemDescriptions: leaderSpec.batch.itemDescriptions,
9560
+ index: asset.batch.index,
9561
+ leaderAssetId: asset.batch.of,
9562
+ leaderSpec
9563
+ };
9564
+ } else {
9565
+ const members = batchMembersByLeader.get(assetId);
9566
+ if (members) {
9567
+ if (style.generator !== "1dir") {
9568
+ throw new Error(`assets.${assetId}: only a 1dir asset can lead a batch`);
9569
+ }
9570
+ const sorted = [...members].sort((a, b) => a.index - b.index);
9571
+ const seen = /* @__PURE__ */ new Set();
9572
+ for (const { index } of sorted) {
9573
+ if (seen.has(index)) throw new Error(`assets.${assetId}: two batch members both claim index ${index}`);
9574
+ seen.add(index);
9575
+ }
9576
+ const expectedIndices = sorted.map((_, i) => i + 1).join(",");
9577
+ if (sorted.map((m) => m.index).join(",") !== expectedIndices) {
9578
+ throw new Error(
9579
+ `assets.${assetId}: batch member indices must be 1..${sorted.length} with no gaps; got ${sorted.map((m) => m.index).join(",")}`
9580
+ );
9581
+ }
9582
+ const total = 1 + sorted.length;
9583
+ const limit = candidateCount(resolved.size);
9584
+ if (total > limit) {
9585
+ throw new Error(
9586
+ `assets.${assetId}: a ${resolved.size}px batch holds at most ${limit} items (1 leader + ${limit - 1} members); this one declares ${total}`
9587
+ );
9588
+ }
9589
+ resolved.batch = {
9590
+ role: "leader",
9591
+ itemDescriptions: [resolved.prompt, ...sorted.map(({ id }) => styleSpecs.get(id).prompt)],
9592
+ memberAssetIds: sorted.map((m) => m.id)
9593
+ };
9594
+ }
9595
+ }
9249
9596
  resolved.specHash = specHash(
9250
9597
  resolved,
9251
9598
  styleImageHashes,
@@ -13513,6 +13860,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13513
13860
  for (const styleId of new Set(items.map((i) => i.spec.styleId))) {
13514
13861
  styleImages.set(styleId, await resolveStyleImages(loaded, styleId));
13515
13862
  }
13863
+ const byKey = new Map(items.map((item) => [item.key, item]));
13516
13864
  let submitted = 0;
13517
13865
  let failed = 0;
13518
13866
  let spent = 0;
@@ -13542,8 +13890,62 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13542
13890
  await pruneInFlight();
13543
13891
  }
13544
13892
  }
13893
+ function writeBatchMembers(leaderSpec, leaderKey, error) {
13894
+ if (leaderSpec.batch?.role !== "leader") return;
13895
+ const leaderEntry = lock.entries[leaderKey];
13896
+ for (const memberAssetId of leaderSpec.batch.memberAssetIds ?? []) {
13897
+ const memberKey = lockKey(leaderSpec.styleId, memberAssetId);
13898
+ const memberItem = byKey.get(memberKey);
13899
+ if (!memberItem) {
13900
+ throw new Error(
13901
+ `${leaderKey}: batch member "${memberAssetId}" must be submitted in the same run as its leader; run without --only, or include every sibling`
13902
+ );
13903
+ }
13904
+ const memberSpec = memberItem.spec;
13905
+ const memberEstimate = estimates.get(memberKey);
13906
+ const previousMemberEntry = lock.entries[memberKey];
13907
+ const memberSubmittedAt = leaderEntry.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString();
13908
+ upsert(lock, memberKey, {
13909
+ styleId: memberSpec.styleId,
13910
+ assetId: memberSpec.assetId,
13911
+ specHash: memberSpec.specHash,
13912
+ generator: memberSpec.generator,
13913
+ prompt: memberSpec.prompt,
13914
+ width: memberSpec.width,
13915
+ height: memberSpec.height,
13916
+ batch: { role: "member", leaderAssetId: leaderSpec.assetId, index: memberSpec.batch.index },
13917
+ status: error ? "failed" : "processing",
13918
+ error,
13919
+ jobId: error ? null : leaderEntry.jobId,
13920
+ submissionComplete: error ? void 0 : true,
13921
+ reviewObjectId: error ? null : leaderEntry.reviewObjectId,
13922
+ objectId: null,
13923
+ candidateIndex: null,
13924
+ outputs: [],
13925
+ supersededOutputs: previousMemberEntry?.outputs.length ? previousMemberEntry.outputs : previousMemberEntry?.supersededOutputs ?? [],
13926
+ providerMetadata: leaderEntry.providerMetadata,
13927
+ sourceUrl: null,
13928
+ sourceUrls: [],
13929
+ submittedAt: memberSubmittedAt,
13930
+ history: historyAfterReplacing(previousMemberEntry, historyLimit(loaded.manifest), memberSubmittedAt),
13931
+ cost: memberEstimate.amount,
13932
+ costUnit: memberEstimate.unit,
13933
+ provider: provider.id,
13934
+ downloadedAt: null
13935
+ });
13936
+ if (error) failed++;
13937
+ else {
13938
+ submitted++;
13939
+ spent += memberEstimate.amount;
13940
+ log2(` ${memberKey} \u2192 ${leaderEntry.jobId} (rides on ${leaderKey}'s batch)`);
13941
+ }
13942
+ }
13943
+ }
13545
13944
  for (const { spec, key } of items) {
13546
13945
  const estimate = estimates.get(key);
13946
+ if (spec.batch?.role === "member") {
13947
+ continue;
13948
+ }
13547
13949
  if (spec.mirror) {
13548
13950
  try {
13549
13951
  await requireRevisionReady(spec, lock);
@@ -13576,6 +13978,14 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13576
13978
  const since = Date.now() - lastSubmitAt;
13577
13979
  if (since < spacing) await sleep2(spacing - since);
13578
13980
  await requireRevisionReady(spec, lock);
13981
+ if (spec.batch?.role === "leader") {
13982
+ const missing = (spec.batch.memberAssetIds ?? []).filter((id) => !byKey.has(lockKey(spec.styleId, id)));
13983
+ if (missing.length) {
13984
+ throw new Error(
13985
+ `${key}: batch member(s) ${missing.join(", ")} must be submitted in the same run as this leader; run without --only, or include every sibling`
13986
+ );
13987
+ }
13988
+ }
13579
13989
  const previousEntry = lock.entries[key];
13580
13990
  const resumesCheckpoint = Boolean(
13581
13991
  previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
@@ -13603,7 +14013,12 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13603
14013
  ...spec.revision.numColors == null ? {} : { numColors: spec.revision.numColors },
13604
14014
  ...spec.revision.paletteImageSha256 ? { paletteImageSha256: spec.revision.paletteImageSha256 } : {},
13605
14015
  ...spec.revision.dithering ? { dithering: spec.revision.dithering } : {},
13606
- ...spec.revision.ditheringStrength == null ? {} : { ditheringStrength: spec.revision.ditheringStrength }
14016
+ ...spec.revision.ditheringStrength == null ? {} : { ditheringStrength: spec.revision.ditheringStrength },
14017
+ ...spec.revision.frames == null ? {} : { frames: spec.revision.frames },
14018
+ ...spec.revision.fps == null ? {} : { fps: spec.revision.fps },
14019
+ ...spec.revision.lastFrameSha256 ? { lastFrameSha256: spec.revision.lastFrameSha256 } : {},
14020
+ ...spec.revision.direction ? { direction: spec.revision.direction } : {},
14021
+ ...spec.revision.enhancePrompt == null ? {} : { enhancePrompt: spec.revision.enhancePrompt }
13607
14022
  } : null,
13608
14023
  status: "pending",
13609
14024
  jobId: previousJobId ?? null,
@@ -13676,6 +14091,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13676
14091
  inFlight.set(jobId, spec);
13677
14092
  submitted++;
13678
14093
  spent += estimate.amount;
14094
+ writeBatchMembers(spec, key, null);
13679
14095
  log2(
13680
14096
  ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
13681
14097
  );
@@ -13687,6 +14103,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13687
14103
  inFlight.set(entry.jobId, spec);
13688
14104
  submitted++;
13689
14105
  spent += estimate.amount;
14106
+ writeBatchMembers(spec, key, null);
13690
14107
  log2(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
13691
14108
  } else {
13692
14109
  failed++;
@@ -13695,6 +14112,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13695
14112
  error: message6,
13696
14113
  cost: entry.jobId ? estimate.amount : 0
13697
14114
  });
14115
+ writeBatchMembers(spec, key, message6);
13698
14116
  log2(` FAILED ${key}: ${message6}`);
13699
14117
  }
13700
14118
  }
@@ -13895,6 +14313,9 @@ function renderSheet(groups, options = {}) {
13895
14313
  .cand.active { border-color:var(--dim); box-shadow:0 0 0 2px color-mix(in srgb, var(--dim) 20%, transparent); }
13896
14314
  .cand.sel { border-color:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
13897
14315
  .cand.set-frame { cursor:pointer; }
14316
+ .cand.recommended:not(.sel) { border-color:var(--accent); }
14317
+ .cand.recommended .badge { position:absolute; top:-1px; left:-1px; font-size:9.5px; font-weight:650;
14318
+ text-transform:uppercase; letter-spacing:.03em; color:var(--bg); background:var(--accent); padding:1px 5px; }
13898
14319
  .cand img { image-rendering:pixelated; display:block;
13899
14320
  background-image:
13900
14321
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
@@ -14052,14 +14473,17 @@ GROUPS.forEach((g, gi) => {
14052
14473
  }).observe(loop);
14053
14474
  }
14054
14475
  }
14476
+ const recommended = g.recommendedIndex ?? 0;
14055
14477
  g.frameUrls.forEach((url, i) => {
14056
14478
  const c = document.createElement('button');
14057
14479
  c.type = 'button';
14058
- c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '');
14480
+ c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '') +
14481
+ (g.recommendedIndex != null && i === recommended ? ' recommended' : '');
14059
14482
  c.tabIndex = -1;
14060
- c.setAttribute('aria-label', g.mode === 'frame-set'
14483
+ c.setAttribute('aria-label', (g.mode === 'frame-set'
14061
14484
  ? 'Accept ordered frame set from frame ' + (i + 1)
14062
- : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
14485
+ : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length) +
14486
+ (g.recommendedIndex != null && i === recommended ? ' (this asset\u2019s declared slot)' : ''));
14063
14487
 
14064
14488
  const preview = document.createElement('img');
14065
14489
  preview.className = 'preview';
@@ -14072,6 +14496,12 @@ GROUPS.forEach((g, gi) => {
14072
14496
  index.textContent = (g.frameLabels?.[i] || String(i + 1)) + ' \xB7 ' +
14073
14497
  g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
14074
14498
  c.append(preview, index);
14499
+ if (g.recommendedIndex != null && i === recommended) {
14500
+ const badge = document.createElement('span');
14501
+ badge.className = 'badge';
14502
+ badge.textContent = 'declared';
14503
+ c.append(badge);
14504
+ }
14075
14505
  if (displayScale > 1 && largestSide <= 96) {
14076
14506
  const actual = document.createElement('img');
14077
14507
  actual.className = 'actual';
@@ -14096,6 +14526,7 @@ GROUPS.forEach((g, gi) => {
14096
14526
  });
14097
14527
  frames.dataset.active = '0';
14098
14528
  frames.querySelector('.cand')?.classList.add('active');
14529
+ if (recommended > 0) activate(recommended, frames);
14099
14530
  root.appendChild(el);
14100
14531
  });
14101
14532
 
@@ -14270,7 +14701,11 @@ async function prepareReview(provider, lock, opts = {}) {
14270
14701
  height: spec.revision.sourceHeight
14271
14702
  }
14272
14703
  } : {},
14273
- ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
14704
+ ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {},
14705
+ // A batch member's declared slot in the shared candidate set — the
14706
+ // leader's own slot 0 is already the sheet's ordinary default, so
14707
+ // only a member (whose slot is 1+) needs to say so explicitly.
14708
+ ...entry.batch?.index != null ? { recommendedIndex: entry.batch.index } : {}
14274
14709
  });
14275
14710
  } catch (err) {
14276
14711
  log2(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);