pixelkiln 0.58.0 → 0.60.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
@@ -645,16 +645,20 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
645
645
  detail: spec.detail ?? null,
646
646
  seed: spec.seed ?? null,
647
647
  palette: spec.palette,
648
- // `noBackground` only reaches the wire for pixflux; the tile fields are
649
- // undefined for every other generator. `tileSize` is intentionally
650
- // absent; width/height are derived from it, so it is already covered.
651
- noBackground: spec.generator === "pixflux" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
648
+ // `noBackground` only reaches the wire for pixflux and uiAsset; the
649
+ // tile fields are undefined for every other generator. `tileSize` is
650
+ // intentionally absent; width/height are derived from it, so it is
651
+ // already covered.
652
+ noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
652
653
  tileType: spec.tileType,
653
654
  tileView: spec.tileView,
654
655
  tileFeature: spec.tileFeature,
655
656
  outlineMode: spec.outlineMode,
656
657
  isometricTileSize: spec.isometricTileSize,
657
658
  isometricTileShape: spec.isometricTileShape,
659
+ uiPieces: spec.uiPieces,
660
+ uiElements: spec.uiElements,
661
+ uiColorPalette: spec.uiColorPalette,
658
662
  styleImages: styleImageHashes,
659
663
  // A character family: the engine and rotations of a base, the parent's
660
664
  // generated bytes for a state or animation (so a regenerated parent
@@ -703,6 +707,11 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
703
707
  // A mirror's bytes come from its source's recorded outputs; the plan
704
708
  // compares those directly, so only the choice of source is identity.
705
709
  mirror: spec.mirror ? { of: spec.mirror.sourceAssetId } : void 0,
710
+ // The whole group's ordered descriptions, not just this asset's own —
711
+ // any sibling's prompt changing, or a member being added or removed,
712
+ // must mark every member stale together, since they all ride on one
713
+ // submitted job with no way to amend it after the fact.
714
+ batch: spec.batch ? { role: spec.batch.role, itemDescriptions: spec.batch.itemDescriptions, index: spec.batch.index } : void 0,
706
715
  revision: spec.revision ? {
707
716
  mode: spec.revision.mode,
708
717
  from: spec.revision.sourceAssetId,
@@ -735,7 +744,7 @@ import path2 from "path";
735
744
  // src/types.ts
736
745
  import { z } from "zod";
737
746
  var MediaTypeSchema = z.enum(["image/png", "image/gif"]);
738
- var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro"]);
747
+ var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset"]);
739
748
  var GridConfidenceSchema = z.enum(["low", "medium", "high"]);
740
749
  var RevisionModeSchema = z.enum([
741
750
  "image-to-image",
@@ -857,6 +866,56 @@ var CharacterProportionsSchema = z.union([
857
866
  }).strict()
858
867
  ]);
859
868
  var CharacterAnimationModeSchema = z.enum(["template", "v3", "pro"]);
869
+ var UiPieceSchema = z.discriminatedUnion("kind", [
870
+ z.object({
871
+ id: z.string().min(1),
872
+ kind: z.literal("rounded_rect"),
873
+ label: z.string().optional(),
874
+ x: z.number(),
875
+ y: z.number(),
876
+ w: z.number(),
877
+ h: z.number(),
878
+ radius: z.number().min(0).optional()
879
+ }).strict(),
880
+ z.object({
881
+ id: z.string().min(1),
882
+ kind: z.literal("circle"),
883
+ label: z.string().optional(),
884
+ x: z.number(),
885
+ y: z.number(),
886
+ r: z.number()
887
+ }).strict(),
888
+ z.object({
889
+ id: z.string().min(1),
890
+ kind: z.literal("polygon"),
891
+ label: z.string().optional(),
892
+ x: z.number(),
893
+ y: z.number(),
894
+ r: z.number(),
895
+ sides: z.number().int().min(3),
896
+ phase: z.number().optional()
897
+ }).strict()
898
+ ]);
899
+ var UiElementSchema = z.enum([
900
+ "button",
901
+ "icon_button",
902
+ "toolbar",
903
+ "tab",
904
+ "panel",
905
+ "window",
906
+ "health_bar",
907
+ "avatar",
908
+ "triangle",
909
+ "pentagon",
910
+ "hexagon",
911
+ "octagon"
912
+ ]);
913
+ var AssetBatchSchema = z.object({
914
+ /** The batch leader: another `1dir` asset in the same style. */
915
+ of: z.string().min(1),
916
+ /** 1-based slot in the batch (the leader is implicitly slot 0). Unique and contiguous among siblings. */
917
+ index: z.number().int().min(1)
918
+ }).strict();
860
919
  var CharacterStateSchema = z.object({
861
920
  /** The base character, or another state, in the same style. */
862
921
  of: z.string().min(1),
@@ -1153,6 +1212,12 @@ var StyleObjectSchema = z.object({
1153
1212
  * in place of `terrainShapeStyle`. The API default is `standard`.
1154
1213
  */
1155
1214
  terrainMode: z.enum(["standard", "pro"]).optional(),
1215
+ /**
1216
+ * `uiAsset` generator only. A natural-language palette hint sent as-is
1217
+ * (e.g. "brown and gold"), distinct from `palette`'s hex-color array —
1218
+ * this is a prompt-level steer, not a local quantization target.
1219
+ */
1220
+ uiColorPalette: z.string().max(200).optional(),
1156
1221
  /**
1157
1222
  * `terrain` generator only, `terrainMode: "standard"`. Procedural
1158
1223
  * boundary geometry: `square` or `round`, 16px or 32px tiles only.
@@ -1430,6 +1495,16 @@ var AssetSchema = z.object({
1430
1495
  * `prompt` instead.
1431
1496
  */
1432
1497
  animation: CharacterAnimationSchema.optional(),
1498
+ /**
1499
+ * `1dir` styles only: this asset rides along on another `1dir` asset's
1500
+ * batch submission instead of generating on its own. See
1501
+ * `AssetBatchSchema`.
1502
+ */
1503
+ batch: AssetBatchSchema.optional(),
1504
+ /** `uiAsset` styles: precise shape regions the panel is composited from. Combine with `elements`; omit both for a default full-canvas panel. */
1505
+ pieces: z.array(UiPieceSchema).min(1).optional(),
1506
+ /** `uiAsset` styles: named, auto-positioned UI element scaffolds. Combine with `pieces`; omit both for a default full-canvas panel. */
1507
+ elements: z.array(UiElementSchema).min(1).optional(),
1433
1508
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
1434
1509
  proportions: CharacterProportionsSchema.optional(),
1435
1510
  /**
@@ -1506,7 +1581,7 @@ var AssetSchema = z.object({
1506
1581
  path: ["revision"]
1507
1582
  });
1508
1583
  }
1509
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror"].filter(Boolean);
1584
+ const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch"].filter(Boolean);
1510
1585
  if (shapes.length > 1) {
1511
1586
  context.addIssue({
1512
1587
  code: z.ZodIssueCode.custom,
@@ -1657,6 +1732,12 @@ var LockEntrySchema = z.object({
1657
1732
  sourceAssetId: z.string().min(1),
1658
1733
  sourceSha256: z.string().regex(/^[0-9a-f]{64}$/)
1659
1734
  }).strict().nullable().default(null),
1735
+ /** For a `1dir` batch member: which leader and slot it rode along on. */
1736
+ batch: z.object({
1737
+ role: z.enum(["leader", "member"]),
1738
+ leaderAssetId: z.string().min(1).optional(),
1739
+ index: z.number().int().min(1).optional()
1740
+ }).strict().nullable().default(null),
1660
1741
  /**
1661
1742
  * Output hashes owned by the previous generation while its replacement is
1662
1743
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -5453,6 +5534,19 @@ var IsometricTileGetSchema = z2.object({
5453
5534
  image: z2.object({ base64: z2.string().min(1), format: z2.string().default("png") }).passthrough(),
5454
5535
  usage: UsageSchema
5455
5536
  }).passthrough();
5537
+ var UiAssetSubmitSchema = z2.object({
5538
+ ui_asset_id: z2.string().min(1),
5539
+ background_job_id: z2.string().min(1),
5540
+ status: z2.string().default("processing"),
5541
+ usage: UsageSchema
5542
+ }).passthrough();
5543
+ var UiAssetGetSchema = z2.object({
5544
+ id: z2.string().min(1),
5545
+ status: z2.string().nullable().default(null),
5546
+ image_url: z2.string().nullable().optional(),
5547
+ progress_percent: z2.number().nullable().optional(),
5548
+ eta_seconds: z2.number().nullable().optional()
5549
+ }).passthrough();
5456
5550
  var RevisionJobSubmitSchema = z2.object({
5457
5551
  background_job_id: z2.string().min(1),
5458
5552
  status: z2.string().default("processing")
@@ -5813,6 +5907,48 @@ var PixelLabClient = class {
5813
5907
  );
5814
5908
  return { image: res.image, usage: res.usage ?? null };
5815
5909
  }
5910
+ /**
5911
+ * `/create-ui-asset`: one composited panel image from a shape template —
5912
+ * `pieces` (precise rect/circle/polygon regions on a virtual 0–512
5913
+ * editor canvas) and/or named `elements` (auto-positioned scaffolds:
5914
+ * button, icon_button, toolbar, tab, panel, window, health_bar, avatar,
5915
+ * triangle, pentagon, hexagon, octagon), or neither for a default
5916
+ * full-canvas rounded-rect panel. Unlike every other generator here, the
5917
+ * result is one flat image with no per-piece sub-regions or nine-slice
5918
+ * metadata returned — cropping a `pieces` layout into separate files
5919
+ * would be pixelkiln's own local work, not modeled yet.
5920
+ */
5921
+ async createUiAsset(args) {
5922
+ const body = {
5923
+ description: args.description,
5924
+ image_size: { width: args.width, height: args.height }
5925
+ };
5926
+ if (args.pieces?.length) body.pieces = args.pieces;
5927
+ if (args.elements?.length) body.elements = args.elements;
5928
+ if (args.styleImage) body.style_image = args.styleImage;
5929
+ if (args.colorPalette) body.color_palette = args.colorPalette;
5930
+ if (args.noBackground != null) body.no_background = args.noBackground;
5931
+ if (args.seed != null) body.seed = args.seed;
5932
+ const res = await validateResponse(
5933
+ UiAssetSubmitSchema,
5934
+ await this.request("/create-ui-asset", { method: "POST", body: JSON.stringify(body) }),
5935
+ "create ui asset"
5936
+ );
5937
+ return { ui_asset_id: res.ui_asset_id, background_job_id: res.background_job_id, usage: res.usage };
5938
+ }
5939
+ async getUiAsset(uiAssetId) {
5940
+ const res = await validateResponse(
5941
+ UiAssetGetSchema,
5942
+ await this.request(`/ui-assets/${encodeURIComponent(uiAssetId)}`),
5943
+ "get ui asset"
5944
+ );
5945
+ return {
5946
+ status: res.status,
5947
+ imageUrl: res.image_url ?? null,
5948
+ progressPercent: res.progress_percent ?? null,
5949
+ etaSeconds: res.eta_seconds ?? null
5950
+ };
5951
+ }
5816
5952
  /**
5817
5953
  * Synchronous single-image generation. Returns the PNG inline rather than a
5818
5954
  * job id, and is the only endpoint that honours a forced palette;
@@ -6219,14 +6355,15 @@ var PixelLabClient = class {
6219
6355
  /**
6220
6356
  * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image onto a
6221
6357
  * smaller palette, synchronously — no `background_job_id`, the result
6222
- * comes back in this same response, like `createImagePixflux`. Verified
6223
- * against the live OpenAPI schema, not exercised against a live account:
6224
- * the schema's own response example is `usage: {type: "usd", usd: 0.02}`,
6225
- * which — going by this codebase's own repeated experience with PixelLab's
6226
- * documented-vs-billed cost mismatches (`isometricTile`, `objectPro`) —
6227
- * should not be trusted over a real call. `numColors` and `paletteImage`
6228
- * are mutually exclusive upstream; the manifest schema already enforces
6229
- * that before this is ever called.
6358
+ * comes back in this same response, like `createImagePixflux`. The
6359
+ * schema's own response example is dollar-denominated (`usage: {type:
6360
+ * "usd", usd: 0.02}`), which — matching this codebase's repeated
6361
+ * experience with PixelLab's documented-vs-billed cost mismatches
6362
+ * (`isometricTile`, `objectPro`) — did not hold: confirmed live against a
6363
+ * Tier 2 account at exactly 0.1 generations for a 32x32 source
6364
+ * (docs/REVISIONS.md). `numColors` and `paletteImage` are mutually
6365
+ * exclusive upstream; the manifest schema already enforces that before
6366
+ * this is ever called.
6230
6367
  */
6231
6368
  async reduceColors(args) {
6232
6369
  const body = { images: [args.image] };
@@ -6249,8 +6386,9 @@ var PixelLabClient = class {
6249
6386
  /**
6250
6387
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
6251
6388
  * stray pixels without resizing, synchronously — same shape as
6252
- * `reduceColors` above, no background job. Cost is likewise unverified
6253
- * against a live account (schema example: `usage: {type: "usd", usd: 0.02}`).
6389
+ * `reduceColors` above, no background job. Cost is likewise confirmed
6390
+ * live at 0.1 generations for a 32x32 source, not the schema's own
6391
+ * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
6254
6392
  */
6255
6393
  async correctPixelart(args) {
6256
6394
  const body = { images: [args.image] };
@@ -6481,7 +6619,7 @@ var PixelLabProvider = class _PixelLabProvider {
6481
6619
  return new _PixelLabProvider(new PixelLabClient("download-only"));
6482
6620
  }
6483
6621
  supports(generator) {
6484
- return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro";
6622
+ return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset";
6485
6623
  }
6486
6624
  /**
6487
6625
  * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
@@ -6518,6 +6656,9 @@ var PixelLabProvider = class _PixelLabProvider {
6518
6656
  return dir;
6519
6657
  }
6520
6658
  estimate(spec) {
6659
+ if (spec.batch?.role === "member") {
6660
+ return { unit: "generations", amount: 0, candidates: 1 };
6661
+ }
6521
6662
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
6522
6663
  return { unit: "generations", amount: 0.1, candidates: 1 };
6523
6664
  }
@@ -6594,6 +6735,11 @@ var PixelLabProvider = class _PixelLabProvider {
6594
6735
  `PixelLab imagePro is ${spec.width}x${spec.height}; the API takes 16 to 792 wide and 16 to 688 tall (the exact ceiling also depends on aspect ratio)`
6595
6736
  );
6596
6737
  }
6738
+ if (spec.generator === "uiAsset" && (spec.width < 192 || spec.height < 192 || spec.width > 688 || spec.height > 688)) {
6739
+ throw new Error(
6740
+ `PixelLab uiAsset is ${spec.width}x${spec.height}; the API takes 192 to 688 pixels per side (the exact ceiling also depends on aspect ratio \u2014 square tops out at 512, 16:9 at 688x384, 9:16 at 384x688, 4:3 at 600x448, 3:4 at 448x600)`
6741
+ );
6742
+ }
6597
6743
  if (spec.generator === "map") {
6598
6744
  requirePixelLabOption("map", "view", spec.view, ["low top-down", "high top-down", "side"]);
6599
6745
  requirePixelLabOption("map", "outline", spec.outline, [
@@ -7231,6 +7377,20 @@ var PixelLabProvider = class _PixelLabProvider {
7231
7377
  if (spec.revision) return this.submitRevision(spec);
7232
7378
  if (spec.generator === "character") return this.submitCharacter(spec, styleImages, context);
7233
7379
  if (spec.generator === "objectPro") return this.submitObjectPro(spec, styleImages, context);
7380
+ if (spec.generator === "uiAsset") {
7381
+ const res2 = await this.client.createUiAsset({
7382
+ description: spec.prompt,
7383
+ width: spec.width,
7384
+ height: spec.height,
7385
+ pieces: spec.uiPieces,
7386
+ elements: spec.uiElements,
7387
+ styleImage: styleImages[0] ? { base64: styleImages[0].base64, format: styleImages[0].format } : void 0,
7388
+ colorPalette: spec.uiColorPalette,
7389
+ noBackground: spec.noBackground,
7390
+ seed: spec.seed
7391
+ });
7392
+ return { jobId: res2.ui_asset_id, metadata: { backgroundJobId: res2.background_job_id } };
7393
+ }
7234
7394
  if (spec.generator === "pixflux") {
7235
7395
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
7236
7396
  const { png } = await this.client.createImagePixflux({
@@ -7316,11 +7476,21 @@ var PixelLabProvider = class _PixelLabProvider {
7316
7476
  return { jobId: res2.background_job_id };
7317
7477
  }
7318
7478
  if (spec.generator === "1dir") {
7479
+ if (spec.batch?.role === "member") {
7480
+ throw new Error(`${spec.styleId}/${spec.assetId}: a batch member cannot submit on its own`);
7481
+ }
7319
7482
  const res2 = await this.client.create1Direction({
7320
7483
  description: spec.prompt,
7321
7484
  size: spec.size,
7322
7485
  view: spec.view === "sidescroller" ? "sidescroller" : "top-down",
7323
- styleImages
7486
+ styleImages,
7487
+ // The leader's own subject is item_descriptions[0], so `description`
7488
+ // matches it exactly. Confirmed live: item_descriptions[0] owns
7489
+ // candidate slot 0 (a 3-item batch returned exactly the declared
7490
+ // items at frames 0/1/2, in order; docs/GENERATORS.md#1dir), so
7491
+ // sending the same text as `description` too is redundant but
7492
+ // harmless, not a hedge against ambiguity.
7493
+ itemDescriptions: spec.batch?.role === "leader" ? spec.batch.itemDescriptions : void 0
7324
7494
  });
7325
7495
  return { jobId: res2.object_id, metadata: { backgroundJobId: res2.background_job_id } };
7326
7496
  }
@@ -7497,6 +7667,7 @@ var PixelLabProvider = class _PixelLabProvider {
7497
7667
  if (generator === "imagePro") return this.pollImagePro(jobId);
7498
7668
  if (generator === "character") return this.pollCharacter(jobId, context);
7499
7669
  if (generator === "objectPro") return this.pollObjectPro(jobId, context);
7670
+ if (generator === "uiAsset") return this.pollUiAsset(jobId, context);
7500
7671
  const backgroundJobId = context?.metadata?.backgroundJobId;
7501
7672
  const obj = await this.client.getObject(jobId);
7502
7673
  if (obj.status === "review") {
@@ -7816,6 +7987,29 @@ var PixelLabProvider = class _PixelLabProvider {
7816
7987
  throw err;
7817
7988
  }
7818
7989
  }
7990
+ /**
7991
+ * `/ui-assets/{id}` reports progress with its own `status` field
7992
+ * ("processing"/"completed"/"failed") rather than a 423, and its result is
7993
+ * one flat composited image at a hosted `image_url` — no per-piece
7994
+ * sub-images, no nine-slice metadata, confirmed absent from the response
7995
+ * schema. There is nothing to review: one job is one ready image, like
7996
+ * `isometricTile`.
7997
+ */
7998
+ async pollUiAsset(uiAssetId, context) {
7999
+ const asset = await this.client.getUiAsset(uiAssetId);
8000
+ if (asset.status === "failed") return { status: "failed", error: "UI asset generation failed upstream" };
8001
+ if (asset.status !== "completed" || !asset.imageUrl) {
8002
+ return { status: "processing", progressPercent: asset.progressPercent, etaSeconds: asset.etaSeconds };
8003
+ }
8004
+ const backgroundJobId = context?.metadata?.backgroundJobId;
8005
+ return {
8006
+ status: "ready",
8007
+ objectId: uiAssetId,
8008
+ sourceUrl: asset.imageUrl,
8009
+ sources: [{ url: asset.imageUrl }],
8010
+ billed: await this.billedForJob(backgroundJobId)
8011
+ };
8012
+ }
7819
8013
  async selectCandidate(jobId, index, commonTag, generator) {
7820
8014
  if (generator === "tiles") {
7821
8015
  const set = await this.client.getTilesPro(jobId);
@@ -9205,6 +9399,10 @@ async function resolveSpecs(loaded, filter) {
9205
9399
  size = asset.size ?? style.size ?? 64;
9206
9400
  width = size;
9207
9401
  height = size;
9402
+ } else if (generator === "uiAsset") {
9403
+ width = asset.width ?? style.size ?? 256;
9404
+ height = asset.height ?? style.size ?? 256;
9405
+ size = Math.max(width, height);
9208
9406
  } else {
9209
9407
  width = asset.width ?? style.size ?? 64;
9210
9408
  height = asset.height ?? style.size ?? 64;
@@ -9215,6 +9413,11 @@ async function resolveSpecs(loaded, filter) {
9215
9413
  `assets.${assetId}: ${asset.state ? "state" : "animation"} needs a character or objectPro style; "${styleId}" generates ${generator}`
9216
9414
  );
9217
9415
  }
9416
+ if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
9417
+ throw new Error(
9418
+ `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
9419
+ );
9420
+ }
9218
9421
  const characterKind = asset.animation ? "animation" : asset.state ? "state" : "base";
9219
9422
  const subject = asset.promptByStyle[styleId] ?? asset.prompt ?? "";
9220
9423
  let terrainLowerDescription;
@@ -9261,6 +9464,9 @@ async function resolveSpecs(loaded, filter) {
9261
9464
  shading: style.shading,
9262
9465
  detail: style.detail,
9263
9466
  seed: style.seed,
9467
+ uiPieces: generator === "uiAsset" ? asset.pieces : void 0,
9468
+ uiElements: generator === "uiAsset" ? asset.elements : void 0,
9469
+ uiColorPalette: generator === "uiAsset" ? style.uiColorPalette : void 0,
9264
9470
  palette: style.palette,
9265
9471
  enforcePalette: style.enforcePalette,
9266
9472
  noBackground: style.noBackground,
@@ -9339,6 +9545,14 @@ async function resolveSpecs(loaded, filter) {
9339
9545
  };
9340
9546
  styleSpecs.set(assetId, resolved);
9341
9547
  }
9548
+ const batchMembersByLeader = /* @__PURE__ */ new Map();
9549
+ for (const id of styleSpecs.keys()) {
9550
+ const batch = manifest.assets[id].batch;
9551
+ if (!batch) continue;
9552
+ const members = batchMembersByLeader.get(batch.of) ?? [];
9553
+ members.push({ id, index: batch.index });
9554
+ batchMembersByLeader.set(batch.of, members);
9555
+ }
9342
9556
  const finalized = /* @__PURE__ */ new Set();
9343
9557
  const finalize = async (assetId) => {
9344
9558
  const resolved = styleSpecs.get(assetId);
@@ -9497,6 +9711,56 @@ async function resolveSpecs(loaded, filter) {
9497
9711
  ...asset.revision.enhancePrompt == null ? {} : { enhancePrompt: asset.revision.enhancePrompt }
9498
9712
  };
9499
9713
  }
9714
+ if (asset.batch) {
9715
+ if (asset.batch.of === assetId) throw new Error(`assets.${assetId}: a batch cannot name itself as its own leader`);
9716
+ const leaderSpec = await finalize(asset.batch.of);
9717
+ if (!leaderSpec.batch || leaderSpec.batch.role !== "leader") {
9718
+ throw new Error(`assets.${assetId}: ${asset.batch.of} is not a 1dir batch leader`);
9719
+ }
9720
+ if (resolved.size !== leaderSpec.size) {
9721
+ throw new Error(
9722
+ `assets.${assetId}: batch members share the leader's canvas size; ${asset.batch.of} is ${leaderSpec.size}px, this is ${resolved.size}px`
9723
+ );
9724
+ }
9725
+ resolved.batch = {
9726
+ role: "member",
9727
+ itemDescriptions: leaderSpec.batch.itemDescriptions,
9728
+ index: asset.batch.index,
9729
+ leaderAssetId: asset.batch.of,
9730
+ leaderSpec
9731
+ };
9732
+ } else {
9733
+ const members = batchMembersByLeader.get(assetId);
9734
+ if (members) {
9735
+ if (style.generator !== "1dir") {
9736
+ throw new Error(`assets.${assetId}: only a 1dir asset can lead a batch`);
9737
+ }
9738
+ const sorted = [...members].sort((a, b) => a.index - b.index);
9739
+ const seen = /* @__PURE__ */ new Set();
9740
+ for (const { index } of sorted) {
9741
+ if (seen.has(index)) throw new Error(`assets.${assetId}: two batch members both claim index ${index}`);
9742
+ seen.add(index);
9743
+ }
9744
+ const expectedIndices = sorted.map((_, i) => i + 1).join(",");
9745
+ if (sorted.map((m) => m.index).join(",") !== expectedIndices) {
9746
+ throw new Error(
9747
+ `assets.${assetId}: batch member indices must be 1..${sorted.length} with no gaps; got ${sorted.map((m) => m.index).join(",")}`
9748
+ );
9749
+ }
9750
+ const total = 1 + sorted.length;
9751
+ const limit = candidateCount(resolved.size);
9752
+ if (total > limit) {
9753
+ throw new Error(
9754
+ `assets.${assetId}: a ${resolved.size}px batch holds at most ${limit} items (1 leader + ${limit - 1} members); this one declares ${total}`
9755
+ );
9756
+ }
9757
+ resolved.batch = {
9758
+ role: "leader",
9759
+ itemDescriptions: [resolved.prompt, ...sorted.map(({ id }) => styleSpecs.get(id).prompt)],
9760
+ memberAssetIds: sorted.map((m) => m.id)
9761
+ };
9762
+ }
9763
+ }
9500
9764
  resolved.specHash = specHash(
9501
9765
  resolved,
9502
9766
  styleImageHashes,
@@ -13764,6 +14028,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13764
14028
  for (const styleId of new Set(items.map((i) => i.spec.styleId))) {
13765
14029
  styleImages.set(styleId, await resolveStyleImages(loaded, styleId));
13766
14030
  }
14031
+ const byKey = new Map(items.map((item) => [item.key, item]));
13767
14032
  let submitted = 0;
13768
14033
  let failed = 0;
13769
14034
  let spent = 0;
@@ -13793,8 +14058,62 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13793
14058
  await pruneInFlight();
13794
14059
  }
13795
14060
  }
14061
+ function writeBatchMembers(leaderSpec, leaderKey, error) {
14062
+ if (leaderSpec.batch?.role !== "leader") return;
14063
+ const leaderEntry = lock.entries[leaderKey];
14064
+ for (const memberAssetId of leaderSpec.batch.memberAssetIds ?? []) {
14065
+ const memberKey = lockKey(leaderSpec.styleId, memberAssetId);
14066
+ const memberItem = byKey.get(memberKey);
14067
+ if (!memberItem) {
14068
+ throw new Error(
14069
+ `${leaderKey}: batch member "${memberAssetId}" must be submitted in the same run as its leader; run without --only, or include every sibling`
14070
+ );
14071
+ }
14072
+ const memberSpec = memberItem.spec;
14073
+ const memberEstimate = estimates.get(memberKey);
14074
+ const previousMemberEntry = lock.entries[memberKey];
14075
+ const memberSubmittedAt = leaderEntry.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString();
14076
+ upsert(lock, memberKey, {
14077
+ styleId: memberSpec.styleId,
14078
+ assetId: memberSpec.assetId,
14079
+ specHash: memberSpec.specHash,
14080
+ generator: memberSpec.generator,
14081
+ prompt: memberSpec.prompt,
14082
+ width: memberSpec.width,
14083
+ height: memberSpec.height,
14084
+ batch: { role: "member", leaderAssetId: leaderSpec.assetId, index: memberSpec.batch.index },
14085
+ status: error ? "failed" : "processing",
14086
+ error,
14087
+ jobId: error ? null : leaderEntry.jobId,
14088
+ submissionComplete: error ? void 0 : true,
14089
+ reviewObjectId: error ? null : leaderEntry.reviewObjectId,
14090
+ objectId: null,
14091
+ candidateIndex: null,
14092
+ outputs: [],
14093
+ supersededOutputs: previousMemberEntry?.outputs.length ? previousMemberEntry.outputs : previousMemberEntry?.supersededOutputs ?? [],
14094
+ providerMetadata: leaderEntry.providerMetadata,
14095
+ sourceUrl: null,
14096
+ sourceUrls: [],
14097
+ submittedAt: memberSubmittedAt,
14098
+ history: historyAfterReplacing(previousMemberEntry, historyLimit(loaded.manifest), memberSubmittedAt),
14099
+ cost: memberEstimate.amount,
14100
+ costUnit: memberEstimate.unit,
14101
+ provider: provider.id,
14102
+ downloadedAt: null
14103
+ });
14104
+ if (error) failed++;
14105
+ else {
14106
+ submitted++;
14107
+ spent += memberEstimate.amount;
14108
+ log2(` ${memberKey} \u2192 ${leaderEntry.jobId} (rides on ${leaderKey}'s batch)`);
14109
+ }
14110
+ }
14111
+ }
13796
14112
  for (const { spec, key } of items) {
13797
14113
  const estimate = estimates.get(key);
14114
+ if (spec.batch?.role === "member") {
14115
+ continue;
14116
+ }
13798
14117
  if (spec.mirror) {
13799
14118
  try {
13800
14119
  await requireRevisionReady(spec, lock);
@@ -13827,6 +14146,14 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13827
14146
  const since = Date.now() - lastSubmitAt;
13828
14147
  if (since < spacing) await sleep2(spacing - since);
13829
14148
  await requireRevisionReady(spec, lock);
14149
+ if (spec.batch?.role === "leader") {
14150
+ const missing = (spec.batch.memberAssetIds ?? []).filter((id) => !byKey.has(lockKey(spec.styleId, id)));
14151
+ if (missing.length) {
14152
+ throw new Error(
14153
+ `${key}: batch member(s) ${missing.join(", ")} must be submitted in the same run as this leader; run without --only, or include every sibling`
14154
+ );
14155
+ }
14156
+ }
13830
14157
  const previousEntry = lock.entries[key];
13831
14158
  const resumesCheckpoint = Boolean(
13832
14159
  previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
@@ -13932,6 +14259,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13932
14259
  inFlight.set(jobId, spec);
13933
14260
  submitted++;
13934
14261
  spent += estimate.amount;
14262
+ writeBatchMembers(spec, key, null);
13935
14263
  log2(
13936
14264
  ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
13937
14265
  );
@@ -13943,6 +14271,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13943
14271
  inFlight.set(entry.jobId, spec);
13944
14272
  submitted++;
13945
14273
  spent += estimate.amount;
14274
+ writeBatchMembers(spec, key, null);
13946
14275
  log2(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
13947
14276
  } else {
13948
14277
  failed++;
@@ -13951,6 +14280,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13951
14280
  error: message6,
13952
14281
  cost: entry.jobId ? estimate.amount : 0
13953
14282
  });
14283
+ writeBatchMembers(spec, key, message6);
13954
14284
  log2(` FAILED ${key}: ${message6}`);
13955
14285
  }
13956
14286
  }
@@ -14151,6 +14481,9 @@ function renderSheet(groups, options = {}) {
14151
14481
  .cand.active { border-color:var(--dim); box-shadow:0 0 0 2px color-mix(in srgb, var(--dim) 20%, transparent); }
14152
14482
  .cand.sel { border-color:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
14153
14483
  .cand.set-frame { cursor:pointer; }
14484
+ .cand.recommended:not(.sel) { border-color:var(--accent); }
14485
+ .cand.recommended .badge { position:absolute; top:-1px; left:-1px; font-size:9.5px; font-weight:650;
14486
+ text-transform:uppercase; letter-spacing:.03em; color:var(--bg); background:var(--accent); padding:1px 5px; }
14154
14487
  .cand img { image-rendering:pixelated; display:block;
14155
14488
  background-image:
14156
14489
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
@@ -14308,14 +14641,17 @@ GROUPS.forEach((g, gi) => {
14308
14641
  }).observe(loop);
14309
14642
  }
14310
14643
  }
14644
+ const recommended = g.recommendedIndex ?? 0;
14311
14645
  g.frameUrls.forEach((url, i) => {
14312
14646
  const c = document.createElement('button');
14313
14647
  c.type = 'button';
14314
- c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '');
14648
+ c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '') +
14649
+ (g.recommendedIndex != null && i === recommended ? ' recommended' : '');
14315
14650
  c.tabIndex = -1;
14316
- c.setAttribute('aria-label', g.mode === 'frame-set'
14651
+ c.setAttribute('aria-label', (g.mode === 'frame-set'
14317
14652
  ? 'Accept ordered frame set from frame ' + (i + 1)
14318
- : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
14653
+ : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length) +
14654
+ (g.recommendedIndex != null && i === recommended ? ' (this asset\u2019s declared slot)' : ''));
14319
14655
 
14320
14656
  const preview = document.createElement('img');
14321
14657
  preview.className = 'preview';
@@ -14328,6 +14664,12 @@ GROUPS.forEach((g, gi) => {
14328
14664
  index.textContent = (g.frameLabels?.[i] || String(i + 1)) + ' \xB7 ' +
14329
14665
  g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
14330
14666
  c.append(preview, index);
14667
+ if (g.recommendedIndex != null && i === recommended) {
14668
+ const badge = document.createElement('span');
14669
+ badge.className = 'badge';
14670
+ badge.textContent = 'declared';
14671
+ c.append(badge);
14672
+ }
14331
14673
  if (displayScale > 1 && largestSide <= 96) {
14332
14674
  const actual = document.createElement('img');
14333
14675
  actual.className = 'actual';
@@ -14352,6 +14694,7 @@ GROUPS.forEach((g, gi) => {
14352
14694
  });
14353
14695
  frames.dataset.active = '0';
14354
14696
  frames.querySelector('.cand')?.classList.add('active');
14697
+ if (recommended > 0) activate(recommended, frames);
14355
14698
  root.appendChild(el);
14356
14699
  });
14357
14700
 
@@ -14526,7 +14869,11 @@ async function prepareReview(provider, lock, opts = {}) {
14526
14869
  height: spec.revision.sourceHeight
14527
14870
  }
14528
14871
  } : {},
14529
- ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
14872
+ ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {},
14873
+ // A batch member's declared slot in the shared candidate set — the
14874
+ // leader's own slot 0 is already the sheet's ordinary default, so
14875
+ // only a member (whose slot is 1+) needs to say so explicitly.
14876
+ ...entry.batch?.index != null ? { recommendedIndex: entry.batch.index } : {}
14530
14877
  });
14531
14878
  } catch (err) {
14532
14879
  log2(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);