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/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"]);
4
+ var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames", "character", "terrain", "imagePro", "isometricTile", "objectPro", "uiAsset"]);
5
5
  var GridConfidenceSchema = z.enum(["low", "medium", "high"]);
6
6
  var RevisionModeSchema = z.enum([
7
7
  "image-to-image",
@@ -123,6 +123,56 @@ var CharacterProportionsSchema = z.union([
123
123
  }).strict()
124
124
  ]);
125
125
  var CharacterAnimationModeSchema = z.enum(["template", "v3", "pro"]);
126
+ var UiPieceSchema = z.discriminatedUnion("kind", [
127
+ z.object({
128
+ id: z.string().min(1),
129
+ kind: z.literal("rounded_rect"),
130
+ label: z.string().optional(),
131
+ x: z.number(),
132
+ y: z.number(),
133
+ w: z.number(),
134
+ h: z.number(),
135
+ radius: z.number().min(0).optional()
136
+ }).strict(),
137
+ z.object({
138
+ id: z.string().min(1),
139
+ kind: z.literal("circle"),
140
+ label: z.string().optional(),
141
+ x: z.number(),
142
+ y: z.number(),
143
+ r: z.number()
144
+ }).strict(),
145
+ z.object({
146
+ id: z.string().min(1),
147
+ kind: z.literal("polygon"),
148
+ label: z.string().optional(),
149
+ x: z.number(),
150
+ y: z.number(),
151
+ r: z.number(),
152
+ sides: z.number().int().min(3),
153
+ phase: z.number().optional()
154
+ }).strict()
155
+ ]);
156
+ var UiElementSchema = z.enum([
157
+ "button",
158
+ "icon_button",
159
+ "toolbar",
160
+ "tab",
161
+ "panel",
162
+ "window",
163
+ "health_bar",
164
+ "avatar",
165
+ "triangle",
166
+ "pentagon",
167
+ "hexagon",
168
+ "octagon"
169
+ ]);
170
+ var AssetBatchSchema = z.object({
171
+ /** The batch leader: another `1dir` asset in the same style. */
172
+ of: z.string().min(1),
173
+ /** 1-based slot in the batch (the leader is implicitly slot 0). Unique and contiguous among siblings. */
174
+ index: z.number().int().min(1)
175
+ }).strict();
126
176
  var CharacterStateSchema = z.object({
127
177
  /** The base character, or another state, in the same style. */
128
178
  of: z.string().min(1),
@@ -419,6 +469,12 @@ var StyleObjectSchema = z.object({
419
469
  * in place of `terrainShapeStyle`. The API default is `standard`.
420
470
  */
421
471
  terrainMode: z.enum(["standard", "pro"]).optional(),
472
+ /**
473
+ * `uiAsset` generator only. A natural-language palette hint sent as-is
474
+ * (e.g. "brown and gold"), distinct from `palette`'s hex-color array —
475
+ * this is a prompt-level steer, not a local quantization target.
476
+ */
477
+ uiColorPalette: z.string().max(200).optional(),
422
478
  /**
423
479
  * `terrain` generator only, `terrainMode: "standard"`. Procedural
424
480
  * boundary geometry: `square` or `round`, 16px or 32px tiles only.
@@ -696,6 +752,16 @@ var AssetSchema = z.object({
696
752
  * `prompt` instead.
697
753
  */
698
754
  animation: CharacterAnimationSchema.optional(),
755
+ /**
756
+ * `1dir` styles only: this asset rides along on another `1dir` asset's
757
+ * batch submission instead of generating on its own. See
758
+ * `AssetBatchSchema`.
759
+ */
760
+ batch: AssetBatchSchema.optional(),
761
+ /** `uiAsset` styles: precise shape regions the panel is composited from. Combine with `elements`; omit both for a default full-canvas panel. */
762
+ pieces: z.array(UiPieceSchema).min(1).optional(),
763
+ /** `uiAsset` styles: named, auto-positioned UI element scaffolds. Combine with `pieces`; omit both for a default full-canvas panel. */
764
+ elements: z.array(UiElementSchema).min(1).optional(),
699
765
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
700
766
  proportions: CharacterProportionsSchema.optional(),
701
767
  /**
@@ -772,7 +838,7 @@ var AssetSchema = z.object({
772
838
  path: ["revision"]
773
839
  });
774
840
  }
775
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror"].filter(Boolean);
841
+ const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch"].filter(Boolean);
776
842
  if (shapes.length > 1) {
777
843
  context.addIssue({
778
844
  code: z.ZodIssueCode.custom,
@@ -923,6 +989,12 @@ var LockEntrySchema = z.object({
923
989
  sourceAssetId: z.string().min(1),
924
990
  sourceSha256: z.string().regex(/^[0-9a-f]{64}$/)
925
991
  }).strict().nullable().default(null),
992
+ /** For a `1dir` batch member: which leader and slot it rode along on. */
993
+ batch: z.object({
994
+ role: z.enum(["leader", "member"]),
995
+ leaderAssetId: z.string().min(1).optional(),
996
+ index: z.number().int().min(1).optional()
997
+ }).strict().nullable().default(null),
926
998
  /**
927
999
  * Output hashes owned by the previous generation while its replacement is
928
1000
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -1207,6 +1279,19 @@ var IsometricTileGetSchema = z2.object({
1207
1279
  image: z2.object({ base64: z2.string().min(1), format: z2.string().default("png") }).passthrough(),
1208
1280
  usage: UsageSchema
1209
1281
  }).passthrough();
1282
+ var UiAssetSubmitSchema = z2.object({
1283
+ ui_asset_id: z2.string().min(1),
1284
+ background_job_id: z2.string().min(1),
1285
+ status: z2.string().default("processing"),
1286
+ usage: UsageSchema
1287
+ }).passthrough();
1288
+ var UiAssetGetSchema = z2.object({
1289
+ id: z2.string().min(1),
1290
+ status: z2.string().nullable().default(null),
1291
+ image_url: z2.string().nullable().optional(),
1292
+ progress_percent: z2.number().nullable().optional(),
1293
+ eta_seconds: z2.number().nullable().optional()
1294
+ }).passthrough();
1210
1295
  var RevisionJobSubmitSchema = z2.object({
1211
1296
  background_job_id: z2.string().min(1),
1212
1297
  status: z2.string().default("processing")
@@ -1567,6 +1652,48 @@ var PixelLabClient = class {
1567
1652
  );
1568
1653
  return { image: res.image, usage: res.usage ?? null };
1569
1654
  }
1655
+ /**
1656
+ * `/create-ui-asset`: one composited panel image from a shape template —
1657
+ * `pieces` (precise rect/circle/polygon regions on a virtual 0–512
1658
+ * editor canvas) and/or named `elements` (auto-positioned scaffolds:
1659
+ * button, icon_button, toolbar, tab, panel, window, health_bar, avatar,
1660
+ * triangle, pentagon, hexagon, octagon), or neither for a default
1661
+ * full-canvas rounded-rect panel. Unlike every other generator here, the
1662
+ * result is one flat image with no per-piece sub-regions or nine-slice
1663
+ * metadata returned — cropping a `pieces` layout into separate files
1664
+ * would be pixelkiln's own local work, not modeled yet.
1665
+ */
1666
+ async createUiAsset(args) {
1667
+ const body = {
1668
+ description: args.description,
1669
+ image_size: { width: args.width, height: args.height }
1670
+ };
1671
+ if (args.pieces?.length) body.pieces = args.pieces;
1672
+ if (args.elements?.length) body.elements = args.elements;
1673
+ if (args.styleImage) body.style_image = args.styleImage;
1674
+ if (args.colorPalette) body.color_palette = args.colorPalette;
1675
+ if (args.noBackground != null) body.no_background = args.noBackground;
1676
+ if (args.seed != null) body.seed = args.seed;
1677
+ const res = await validateResponse(
1678
+ UiAssetSubmitSchema,
1679
+ await this.request("/create-ui-asset", { method: "POST", body: JSON.stringify(body) }),
1680
+ "create ui asset"
1681
+ );
1682
+ return { ui_asset_id: res.ui_asset_id, background_job_id: res.background_job_id, usage: res.usage };
1683
+ }
1684
+ async getUiAsset(uiAssetId) {
1685
+ const res = await validateResponse(
1686
+ UiAssetGetSchema,
1687
+ await this.request(`/ui-assets/${encodeURIComponent(uiAssetId)}`),
1688
+ "get ui asset"
1689
+ );
1690
+ return {
1691
+ status: res.status,
1692
+ imageUrl: res.image_url ?? null,
1693
+ progressPercent: res.progress_percent ?? null,
1694
+ etaSeconds: res.eta_seconds ?? null
1695
+ };
1696
+ }
1570
1697
  /**
1571
1698
  * Synchronous single-image generation. Returns the PNG inline rather than a
1572
1699
  * job id, and is the only endpoint that honours a forced palette;
@@ -1973,14 +2100,15 @@ var PixelLabClient = class {
1973
2100
  /**
1974
2101
  * `/reduce-colors`, PixelLab's "Cleanup" tier: quantize one image onto a
1975
2102
  * smaller palette, synchronously — no `background_job_id`, the result
1976
- * comes back in this same response, like `createImagePixflux`. Verified
1977
- * against the live OpenAPI schema, not exercised against a live account:
1978
- * the schema's own response example is `usage: {type: "usd", usd: 0.02}`,
1979
- * which — going by this codebase's own repeated experience with PixelLab's
1980
- * documented-vs-billed cost mismatches (`isometricTile`, `objectPro`) —
1981
- * should not be trusted over a real call. `numColors` and `paletteImage`
1982
- * are mutually exclusive upstream; the manifest schema already enforces
1983
- * that before this is ever called.
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.
1984
2112
  */
1985
2113
  async reduceColors(args) {
1986
2114
  const body = { images: [args.image] };
@@ -2003,8 +2131,9 @@ var PixelLabClient = class {
2003
2131
  /**
2004
2132
  * `/correct-pixelart`, PixelLab's "Cleanup" tier: sharpen edges and drop
2005
2133
  * stray pixels without resizing, synchronously — same shape as
2006
- * `reduceColors` above, no background job. Cost is likewise unverified
2007
- * against a live account (schema example: `usage: {type: "usd", usd: 0.02}`).
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
2136
+ * dollar-denominated example (`usage: {type: "usd", usd: 0.02}`).
2008
2137
  */
2009
2138
  async correctPixelart(args) {
2010
2139
  const body = { images: [args.image] };
@@ -2702,16 +2831,20 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
2702
2831
  detail: spec.detail ?? null,
2703
2832
  seed: spec.seed ?? null,
2704
2833
  palette: spec.palette,
2705
- // `noBackground` only reaches the wire for pixflux; the tile fields are
2706
- // undefined for every other generator. `tileSize` is intentionally
2707
- // absent; width/height are derived from it, so it is already covered.
2708
- noBackground: spec.generator === "pixflux" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
2834
+ // `noBackground` only reaches the wire for pixflux and uiAsset; the
2835
+ // tile fields are undefined for every other generator. `tileSize` is
2836
+ // intentionally absent; width/height are derived from it, so it is
2837
+ // already covered.
2838
+ noBackground: spec.generator === "pixflux" || spec.generator === "uiAsset" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
2709
2839
  tileType: spec.tileType,
2710
2840
  tileView: spec.tileView,
2711
2841
  tileFeature: spec.tileFeature,
2712
2842
  outlineMode: spec.outlineMode,
2713
2843
  isometricTileSize: spec.isometricTileSize,
2714
2844
  isometricTileShape: spec.isometricTileShape,
2845
+ uiPieces: spec.uiPieces,
2846
+ uiElements: spec.uiElements,
2847
+ uiColorPalette: spec.uiColorPalette,
2715
2848
  styleImages: styleImageHashes,
2716
2849
  // A character family: the engine and rotations of a base, the parent's
2717
2850
  // generated bytes for a state or animation (so a regenerated parent
@@ -2760,6 +2893,11 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
2760
2893
  // A mirror's bytes come from its source's recorded outputs; the plan
2761
2894
  // compares those directly, so only the choice of source is identity.
2762
2895
  mirror: spec.mirror ? { of: spec.mirror.sourceAssetId } : void 0,
2896
+ // The whole group's ordered descriptions, not just this asset's own —
2897
+ // any sibling's prompt changing, or a member being added or removed,
2898
+ // must mark every member stale together, since they all ride on one
2899
+ // submitted job with no way to amend it after the fact.
2900
+ batch: spec.batch ? { role: spec.batch.role, itemDescriptions: spec.batch.itemDescriptions, index: spec.batch.index } : void 0,
2763
2901
  revision: spec.revision ? {
2764
2902
  mode: spec.revision.mode,
2765
2903
  from: spec.revision.sourceAssetId,
@@ -2904,7 +3042,7 @@ var PixelLabProvider = class _PixelLabProvider {
2904
3042
  return new _PixelLabProvider(new PixelLabClient("download-only"));
2905
3043
  }
2906
3044
  supports(generator) {
2907
- return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro";
3045
+ return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "terrain" || generator === "imagePro" || generator === "character" || generator === "isometricTile" || generator === "objectPro" || generator === "uiAsset";
2908
3046
  }
2909
3047
  /**
2910
3048
  * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
@@ -2941,6 +3079,9 @@ var PixelLabProvider = class _PixelLabProvider {
2941
3079
  return dir;
2942
3080
  }
2943
3081
  estimate(spec) {
3082
+ if (spec.batch?.role === "member") {
3083
+ return { unit: "generations", amount: 0, candidates: 1 };
3084
+ }
2944
3085
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
2945
3086
  return { unit: "generations", amount: 0.1, candidates: 1 };
2946
3087
  }
@@ -3017,6 +3158,11 @@ var PixelLabProvider = class _PixelLabProvider {
3017
3158
  `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)`
3018
3159
  );
3019
3160
  }
3161
+ if (spec.generator === "uiAsset" && (spec.width < 192 || spec.height < 192 || spec.width > 688 || spec.height > 688)) {
3162
+ throw new Error(
3163
+ `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
+ );
3165
+ }
3020
3166
  if (spec.generator === "map") {
3021
3167
  requirePixelLabOption("map", "view", spec.view, ["low top-down", "high top-down", "side"]);
3022
3168
  requirePixelLabOption("map", "outline", spec.outline, [
@@ -3654,6 +3800,20 @@ var PixelLabProvider = class _PixelLabProvider {
3654
3800
  if (spec.revision) return this.submitRevision(spec);
3655
3801
  if (spec.generator === "character") return this.submitCharacter(spec, styleImages, context);
3656
3802
  if (spec.generator === "objectPro") return this.submitObjectPro(spec, styleImages, context);
3803
+ if (spec.generator === "uiAsset") {
3804
+ const res2 = await this.client.createUiAsset({
3805
+ description: spec.prompt,
3806
+ width: spec.width,
3807
+ height: spec.height,
3808
+ pieces: spec.uiPieces,
3809
+ elements: spec.uiElements,
3810
+ styleImage: styleImages[0] ? { base64: styleImages[0].base64, format: styleImages[0].format } : void 0,
3811
+ colorPalette: spec.uiColorPalette,
3812
+ noBackground: spec.noBackground,
3813
+ seed: spec.seed
3814
+ });
3815
+ return { jobId: res2.ui_asset_id, metadata: { backgroundJobId: res2.background_job_id } };
3816
+ }
3657
3817
  if (spec.generator === "pixflux") {
3658
3818
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
3659
3819
  const { png } = await this.client.createImagePixflux({
@@ -3739,11 +3899,21 @@ var PixelLabProvider = class _PixelLabProvider {
3739
3899
  return { jobId: res2.background_job_id };
3740
3900
  }
3741
3901
  if (spec.generator === "1dir") {
3902
+ if (spec.batch?.role === "member") {
3903
+ throw new Error(`${spec.styleId}/${spec.assetId}: a batch member cannot submit on its own`);
3904
+ }
3742
3905
  const res2 = await this.client.create1Direction({
3743
3906
  description: spec.prompt,
3744
3907
  size: spec.size,
3745
3908
  view: spec.view === "sidescroller" ? "sidescroller" : "top-down",
3746
- styleImages
3909
+ styleImages,
3910
+ // The leader's own subject is item_descriptions[0], so `description`
3911
+ // matches it exactly. Confirmed live: item_descriptions[0] owns
3912
+ // candidate slot 0 (a 3-item batch returned exactly the declared
3913
+ // items at frames 0/1/2, in order; docs/GENERATORS.md#1dir), so
3914
+ // sending the same text as `description` too is redundant but
3915
+ // harmless, not a hedge against ambiguity.
3916
+ itemDescriptions: spec.batch?.role === "leader" ? spec.batch.itemDescriptions : void 0
3747
3917
  });
3748
3918
  return { jobId: res2.object_id, metadata: { backgroundJobId: res2.background_job_id } };
3749
3919
  }
@@ -3920,6 +4090,7 @@ var PixelLabProvider = class _PixelLabProvider {
3920
4090
  if (generator === "imagePro") return this.pollImagePro(jobId);
3921
4091
  if (generator === "character") return this.pollCharacter(jobId, context);
3922
4092
  if (generator === "objectPro") return this.pollObjectPro(jobId, context);
4093
+ if (generator === "uiAsset") return this.pollUiAsset(jobId, context);
3923
4094
  const backgroundJobId = context?.metadata?.backgroundJobId;
3924
4095
  const obj = await this.client.getObject(jobId);
3925
4096
  if (obj.status === "review") {
@@ -4239,6 +4410,29 @@ var PixelLabProvider = class _PixelLabProvider {
4239
4410
  throw err;
4240
4411
  }
4241
4412
  }
4413
+ /**
4414
+ * `/ui-assets/{id}` reports progress with its own `status` field
4415
+ * ("processing"/"completed"/"failed") rather than a 423, and its result is
4416
+ * one flat composited image at a hosted `image_url` — no per-piece
4417
+ * sub-images, no nine-slice metadata, confirmed absent from the response
4418
+ * schema. There is nothing to review: one job is one ready image, like
4419
+ * `isometricTile`.
4420
+ */
4421
+ async pollUiAsset(uiAssetId, context) {
4422
+ const asset = await this.client.getUiAsset(uiAssetId);
4423
+ if (asset.status === "failed") return { status: "failed", error: "UI asset generation failed upstream" };
4424
+ if (asset.status !== "completed" || !asset.imageUrl) {
4425
+ return { status: "processing", progressPercent: asset.progressPercent, etaSeconds: asset.etaSeconds };
4426
+ }
4427
+ const backgroundJobId = context?.metadata?.backgroundJobId;
4428
+ return {
4429
+ status: "ready",
4430
+ objectId: uiAssetId,
4431
+ sourceUrl: asset.imageUrl,
4432
+ sources: [{ url: asset.imageUrl }],
4433
+ billed: await this.billedForJob(backgroundJobId)
4434
+ };
4435
+ }
4242
4436
  async selectCandidate(jobId, index, commonTag, generator) {
4243
4437
  if (generator === "tiles") {
4244
4438
  const set = await this.client.getTilesPro(jobId);
@@ -7019,6 +7213,10 @@ async function resolveSpecs(loaded, filter) {
7019
7213
  size = asset.size ?? style.size ?? 64;
7020
7214
  width = size;
7021
7215
  height = size;
7216
+ } else if (generator === "uiAsset") {
7217
+ width = asset.width ?? style.size ?? 256;
7218
+ height = asset.height ?? style.size ?? 256;
7219
+ size = Math.max(width, height);
7022
7220
  } else {
7023
7221
  width = asset.width ?? style.size ?? 64;
7024
7222
  height = asset.height ?? style.size ?? 64;
@@ -7029,6 +7227,11 @@ async function resolveSpecs(loaded, filter) {
7029
7227
  `assets.${assetId}: ${asset.state ? "state" : "animation"} needs a character or objectPro style; "${styleId}" generates ${generator}`
7030
7228
  );
7031
7229
  }
7230
+ if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
7231
+ throw new Error(
7232
+ `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
7233
+ );
7234
+ }
7032
7235
  const characterKind = asset.animation ? "animation" : asset.state ? "state" : "base";
7033
7236
  const subject = asset.promptByStyle[styleId] ?? asset.prompt ?? "";
7034
7237
  let terrainLowerDescription;
@@ -7075,6 +7278,9 @@ async function resolveSpecs(loaded, filter) {
7075
7278
  shading: style.shading,
7076
7279
  detail: style.detail,
7077
7280
  seed: style.seed,
7281
+ uiPieces: generator === "uiAsset" ? asset.pieces : void 0,
7282
+ uiElements: generator === "uiAsset" ? asset.elements : void 0,
7283
+ uiColorPalette: generator === "uiAsset" ? style.uiColorPalette : void 0,
7078
7284
  palette: style.palette,
7079
7285
  enforcePalette: style.enforcePalette,
7080
7286
  noBackground: style.noBackground,
@@ -7153,6 +7359,14 @@ async function resolveSpecs(loaded, filter) {
7153
7359
  };
7154
7360
  styleSpecs.set(assetId, resolved);
7155
7361
  }
7362
+ const batchMembersByLeader = /* @__PURE__ */ new Map();
7363
+ for (const id of styleSpecs.keys()) {
7364
+ const batch = manifest.assets[id].batch;
7365
+ if (!batch) continue;
7366
+ const members = batchMembersByLeader.get(batch.of) ?? [];
7367
+ members.push({ id, index: batch.index });
7368
+ batchMembersByLeader.set(batch.of, members);
7369
+ }
7156
7370
  const finalized = /* @__PURE__ */ new Set();
7157
7371
  const finalize = async (assetId) => {
7158
7372
  const resolved = styleSpecs.get(assetId);
@@ -7311,6 +7525,56 @@ async function resolveSpecs(loaded, filter) {
7311
7525
  ...asset.revision.enhancePrompt == null ? {} : { enhancePrompt: asset.revision.enhancePrompt }
7312
7526
  };
7313
7527
  }
7528
+ if (asset.batch) {
7529
+ if (asset.batch.of === assetId) throw new Error(`assets.${assetId}: a batch cannot name itself as its own leader`);
7530
+ const leaderSpec = await finalize(asset.batch.of);
7531
+ if (!leaderSpec.batch || leaderSpec.batch.role !== "leader") {
7532
+ throw new Error(`assets.${assetId}: ${asset.batch.of} is not a 1dir batch leader`);
7533
+ }
7534
+ if (resolved.size !== leaderSpec.size) {
7535
+ throw new Error(
7536
+ `assets.${assetId}: batch members share the leader's canvas size; ${asset.batch.of} is ${leaderSpec.size}px, this is ${resolved.size}px`
7537
+ );
7538
+ }
7539
+ resolved.batch = {
7540
+ role: "member",
7541
+ itemDescriptions: leaderSpec.batch.itemDescriptions,
7542
+ index: asset.batch.index,
7543
+ leaderAssetId: asset.batch.of,
7544
+ leaderSpec
7545
+ };
7546
+ } else {
7547
+ const members = batchMembersByLeader.get(assetId);
7548
+ if (members) {
7549
+ if (style.generator !== "1dir") {
7550
+ throw new Error(`assets.${assetId}: only a 1dir asset can lead a batch`);
7551
+ }
7552
+ const sorted = [...members].sort((a, b) => a.index - b.index);
7553
+ const seen = /* @__PURE__ */ new Set();
7554
+ for (const { index } of sorted) {
7555
+ if (seen.has(index)) throw new Error(`assets.${assetId}: two batch members both claim index ${index}`);
7556
+ seen.add(index);
7557
+ }
7558
+ const expectedIndices = sorted.map((_, i) => i + 1).join(",");
7559
+ if (sorted.map((m) => m.index).join(",") !== expectedIndices) {
7560
+ throw new Error(
7561
+ `assets.${assetId}: batch member indices must be 1..${sorted.length} with no gaps; got ${sorted.map((m) => m.index).join(",")}`
7562
+ );
7563
+ }
7564
+ const total = 1 + sorted.length;
7565
+ const limit = candidateCount(resolved.size);
7566
+ if (total > limit) {
7567
+ throw new Error(
7568
+ `assets.${assetId}: a ${resolved.size}px batch holds at most ${limit} items (1 leader + ${limit - 1} members); this one declares ${total}`
7569
+ );
7570
+ }
7571
+ resolved.batch = {
7572
+ role: "leader",
7573
+ itemDescriptions: [resolved.prompt, ...sorted.map(({ id }) => styleSpecs.get(id).prompt)],
7574
+ memberAssetIds: sorted.map((m) => m.id)
7575
+ };
7576
+ }
7577
+ }
7314
7578
  resolved.specHash = specHash(
7315
7579
  resolved,
7316
7580
  styleImageHashes,
@@ -11696,6 +11960,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
11696
11960
  for (const styleId of new Set(items.map((i) => i.spec.styleId))) {
11697
11961
  styleImages.set(styleId, await resolveStyleImages(loaded, styleId));
11698
11962
  }
11963
+ const byKey = new Map(items.map((item) => [item.key, item]));
11699
11964
  let submitted = 0;
11700
11965
  let failed = 0;
11701
11966
  let spent = 0;
@@ -11725,8 +11990,62 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
11725
11990
  await pruneInFlight();
11726
11991
  }
11727
11992
  }
11993
+ function writeBatchMembers(leaderSpec, leaderKey, error) {
11994
+ if (leaderSpec.batch?.role !== "leader") return;
11995
+ const leaderEntry = lock.entries[leaderKey];
11996
+ for (const memberAssetId of leaderSpec.batch.memberAssetIds ?? []) {
11997
+ const memberKey = lockKey(leaderSpec.styleId, memberAssetId);
11998
+ const memberItem = byKey.get(memberKey);
11999
+ if (!memberItem) {
12000
+ throw new Error(
12001
+ `${leaderKey}: batch member "${memberAssetId}" must be submitted in the same run as its leader; run without --only, or include every sibling`
12002
+ );
12003
+ }
12004
+ const memberSpec = memberItem.spec;
12005
+ const memberEstimate = estimates.get(memberKey);
12006
+ const previousMemberEntry = lock.entries[memberKey];
12007
+ const memberSubmittedAt = leaderEntry.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString();
12008
+ upsert(lock, memberKey, {
12009
+ styleId: memberSpec.styleId,
12010
+ assetId: memberSpec.assetId,
12011
+ specHash: memberSpec.specHash,
12012
+ generator: memberSpec.generator,
12013
+ prompt: memberSpec.prompt,
12014
+ width: memberSpec.width,
12015
+ height: memberSpec.height,
12016
+ batch: { role: "member", leaderAssetId: leaderSpec.assetId, index: memberSpec.batch.index },
12017
+ status: error ? "failed" : "processing",
12018
+ error,
12019
+ jobId: error ? null : leaderEntry.jobId,
12020
+ submissionComplete: error ? void 0 : true,
12021
+ reviewObjectId: error ? null : leaderEntry.reviewObjectId,
12022
+ objectId: null,
12023
+ candidateIndex: null,
12024
+ outputs: [],
12025
+ supersededOutputs: previousMemberEntry?.outputs.length ? previousMemberEntry.outputs : previousMemberEntry?.supersededOutputs ?? [],
12026
+ providerMetadata: leaderEntry.providerMetadata,
12027
+ sourceUrl: null,
12028
+ sourceUrls: [],
12029
+ submittedAt: memberSubmittedAt,
12030
+ history: historyAfterReplacing(previousMemberEntry, historyLimit(loaded.manifest), memberSubmittedAt),
12031
+ cost: memberEstimate.amount,
12032
+ costUnit: memberEstimate.unit,
12033
+ provider: provider.id,
12034
+ downloadedAt: null
12035
+ });
12036
+ if (error) failed++;
12037
+ else {
12038
+ submitted++;
12039
+ spent += memberEstimate.amount;
12040
+ log(` ${memberKey} \u2192 ${leaderEntry.jobId} (rides on ${leaderKey}'s batch)`);
12041
+ }
12042
+ }
12043
+ }
11728
12044
  for (const { spec, key } of items) {
11729
12045
  const estimate = estimates.get(key);
12046
+ if (spec.batch?.role === "member") {
12047
+ continue;
12048
+ }
11730
12049
  if (spec.mirror) {
11731
12050
  try {
11732
12051
  await requireRevisionReady(spec, lock);
@@ -11759,6 +12078,14 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
11759
12078
  const since = Date.now() - lastSubmitAt;
11760
12079
  if (since < spacing) await sleep(spacing - since);
11761
12080
  await requireRevisionReady(spec, lock);
12081
+ if (spec.batch?.role === "leader") {
12082
+ const missing = (spec.batch.memberAssetIds ?? []).filter((id) => !byKey.has(lockKey(spec.styleId, id)));
12083
+ if (missing.length) {
12084
+ throw new Error(
12085
+ `${key}: batch member(s) ${missing.join(", ")} must be submitted in the same run as this leader; run without --only, or include every sibling`
12086
+ );
12087
+ }
12088
+ }
11762
12089
  const previousEntry = lock.entries[key];
11763
12090
  const resumesCheckpoint = Boolean(
11764
12091
  previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
@@ -11864,6 +12191,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
11864
12191
  inFlight.set(jobId, spec);
11865
12192
  submitted++;
11866
12193
  spent += estimate.amount;
12194
+ writeBatchMembers(spec, key, null);
11867
12195
  log(
11868
12196
  ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
11869
12197
  );
@@ -11875,6 +12203,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
11875
12203
  inFlight.set(entry.jobId, spec);
11876
12204
  submitted++;
11877
12205
  spent += estimate.amount;
12206
+ writeBatchMembers(spec, key, null);
11878
12207
  log(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
11879
12208
  } else {
11880
12209
  failed++;
@@ -11883,6 +12212,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
11883
12212
  error: message6,
11884
12213
  cost: entry.jobId ? estimate.amount : 0
11885
12214
  });
12215
+ writeBatchMembers(spec, key, message6);
11886
12216
  log(` FAILED ${key}: ${message6}`);
11887
12217
  }
11888
12218
  }
@@ -12474,6 +12804,9 @@ function renderSheet(groups, options = {}) {
12474
12804
  .cand.active { border-color:var(--dim); box-shadow:0 0 0 2px color-mix(in srgb, var(--dim) 20%, transparent); }
12475
12805
  .cand.sel { border-color:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
12476
12806
  .cand.set-frame { cursor:pointer; }
12807
+ .cand.recommended:not(.sel) { border-color:var(--accent); }
12808
+ .cand.recommended .badge { position:absolute; top:-1px; left:-1px; font-size:9.5px; font-weight:650;
12809
+ text-transform:uppercase; letter-spacing:.03em; color:var(--bg); background:var(--accent); padding:1px 5px; }
12477
12810
  .cand img { image-rendering:pixelated; display:block;
12478
12811
  background-image:
12479
12812
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
@@ -12631,14 +12964,17 @@ GROUPS.forEach((g, gi) => {
12631
12964
  }).observe(loop);
12632
12965
  }
12633
12966
  }
12967
+ const recommended = g.recommendedIndex ?? 0;
12634
12968
  g.frameUrls.forEach((url, i) => {
12635
12969
  const c = document.createElement('button');
12636
12970
  c.type = 'button';
12637
- c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '');
12971
+ c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '') +
12972
+ (g.recommendedIndex != null && i === recommended ? ' recommended' : '');
12638
12973
  c.tabIndex = -1;
12639
- c.setAttribute('aria-label', g.mode === 'frame-set'
12974
+ c.setAttribute('aria-label', (g.mode === 'frame-set'
12640
12975
  ? 'Accept ordered frame set from frame ' + (i + 1)
12641
- : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
12976
+ : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length) +
12977
+ (g.recommendedIndex != null && i === recommended ? ' (this asset\u2019s declared slot)' : ''));
12642
12978
 
12643
12979
  const preview = document.createElement('img');
12644
12980
  preview.className = 'preview';
@@ -12651,6 +12987,12 @@ GROUPS.forEach((g, gi) => {
12651
12987
  index.textContent = (g.frameLabels?.[i] || String(i + 1)) + ' \xB7 ' +
12652
12988
  g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
12653
12989
  c.append(preview, index);
12990
+ if (g.recommendedIndex != null && i === recommended) {
12991
+ const badge = document.createElement('span');
12992
+ badge.className = 'badge';
12993
+ badge.textContent = 'declared';
12994
+ c.append(badge);
12995
+ }
12654
12996
  if (displayScale > 1 && largestSide <= 96) {
12655
12997
  const actual = document.createElement('img');
12656
12998
  actual.className = 'actual';
@@ -12675,6 +13017,7 @@ GROUPS.forEach((g, gi) => {
12675
13017
  });
12676
13018
  frames.dataset.active = '0';
12677
13019
  frames.querySelector('.cand')?.classList.add('active');
13020
+ if (recommended > 0) activate(recommended, frames);
12678
13021
  root.appendChild(el);
12679
13022
  });
12680
13023
 
@@ -12930,7 +13273,11 @@ async function prepareReview(provider, lock, opts = {}) {
12930
13273
  height: spec.revision.sourceHeight
12931
13274
  }
12932
13275
  } : {},
12933
- ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
13276
+ ...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {},
13277
+ // A batch member's declared slot in the shared candidate set — the
13278
+ // leader's own slot 0 is already the sheet's ordinary default, so
13279
+ // only a member (whose slot is 1+) needs to say so explicitly.
13280
+ ...entry.batch?.index != null ? { recommendedIndex: entry.batch.index } : {}
12934
13281
  });
12935
13282
  } catch (err) {
12936
13283
  log(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);
@@ -15977,6 +16324,7 @@ async function workspaceStatus(ws, dir) {
15977
16324
  };
15978
16325
  }
15979
16326
  export {
16327
+ AssetBatchSchema,
15980
16328
  AssetSchema,
15981
16329
  BudgetError,
15982
16330
  CHARACTER_DIRECTIONS_4,
@@ -16043,6 +16391,8 @@ export {
16043
16391
  ScenarioProvider,
16044
16392
  StyleInputSchema,
16045
16393
  StyleSchema,
16394
+ UiElementSchema,
16395
+ UiPieceSchema,
16046
16396
  UnsupportedCapabilityError,
16047
16397
  UsageError,
16048
16398
  WorkspaceProjectSchema,