pixelkiln 0.88.0 → 0.89.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
@@ -14,8 +14,10 @@ var RevisionModeSchema = z.enum([
14
14
  "animate-pixminimax",
15
15
  "animate-skeleton",
16
16
  "interpolate",
17
- "edit-animation"
17
+ "edit-animation",
18
+ "normal-map"
18
19
  ]);
20
+ var NormalMapConventionSchema = z.enum(["opengl", "directx"]);
19
21
  function tileVariationCount(descriptions) {
20
22
  return Math.max(1, descriptions) * 4;
21
23
  }
@@ -58,9 +60,16 @@ function tilesCost(tileSize, variations) {
58
60
  function isometricTileCost() {
59
61
  return 1;
60
62
  }
61
- function terrainTileCount(transitionSize) {
63
+ function terrainTileCount(transitionSize, mode2, orientation) {
64
+ if (orientation === "sidescroller") return 16;
65
+ if (mode2 === "pro") return transitionSize != null && transitionSize > 0.4 ? 25 : 16;
62
66
  return transitionSize === 1 ? 25 : 16;
63
67
  }
68
+ function terrainCost(mode2, tileSize, orientation) {
69
+ if (orientation === "sidescroller") return 3;
70
+ if (mode2 === "pro") return tileSize <= 32 ? 15 : 25;
71
+ return 4;
72
+ }
64
73
  function parseTerrainDescriptions(prompt) {
65
74
  const parts = prompt.split(/\d+\s*\)\s*\./).map((part) => part.trim()).filter(Boolean);
66
75
  if (parts.length < 2) return null;
@@ -196,6 +205,17 @@ var CharacterStateSchema = z.object({
196
205
  height: z.number().int().min(16).max(256)
197
206
  }).strict().optional()
198
207
  }).strict();
208
+ var TerrainSideSchema = z.enum(["lower", "upper"]);
209
+ var TerrainLinkSchema = z.object({
210
+ /** A terrain asset in the same style. */
211
+ of: z.string().min(1),
212
+ /** Which of that set's terrains: its `lower` or its `upper`. */
213
+ terrain: TerrainSideSchema
214
+ }).strict();
215
+ var TerrainConnectSchema = z.object({
216
+ lower: TerrainLinkSchema.optional(),
217
+ upper: TerrainLinkSchema.optional()
218
+ }).strict().refine((connect) => connect.lower || connect.upper, { message: "connect needs lower, upper, or both" });
199
219
  var CharacterPortraitSizeSchema = z.union([
200
220
  z.literal(16),
201
221
  z.literal(32),
@@ -226,13 +246,38 @@ var AssetVfxSchema = z.object({
226
246
  parts: z.array(VfxPartSchema).optional(),
227
247
  sprite: z.string().min(1).optional(),
228
248
  fps: z.number().int().min(1).max(60).optional(),
229
- name: z.string().min(1).optional()
249
+ name: z.string().min(1).optional(),
250
+ description: z.string().trim().min(1).max(8e3).optional(),
251
+ artist: z.enum(["lite", "pro"]).optional()
230
252
  }).strict().superRefine((vfx, context) => {
253
+ if (vfx.description !== void 0) {
254
+ for (const key of ["preset", "recipe", "parts", "fps"]) {
255
+ if (vfx[key] !== void 0) {
256
+ context.addIssue({
257
+ code: z.ZodIssueCode.custom,
258
+ message: `a vfx description goes to PixelLab's VFX artist, which takes no ${key}; use a description or the workbench's preset/recipe/parts/fps, not both`,
259
+ path: [key]
260
+ });
261
+ }
262
+ }
263
+ if (vfx.sprite && /^(character|object|gallery):/.test(vfx.sprite)) {
264
+ context.addIssue({
265
+ code: z.ZodIssueCode.custom,
266
+ message: "the VFX artist takes a sprite's pixels, not a PixelLab reference; point sprite at a PNG file (another asset's output works)",
267
+ path: ["sprite"]
268
+ });
269
+ }
270
+ if (vfx.name && vfx.name.length > 80) {
271
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "the VFX artist takes a name of at most 80 characters", path: ["name"] });
272
+ }
273
+ } else if (vfx.artist) {
274
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "artist picks who draws a vfx description; add a description", path: ["artist"] });
275
+ }
231
276
  if (vfx.preset && vfx.recipe) {
232
277
  context.addIssue({ code: z.ZodIssueCode.custom, message: "start a vfx from a preset or a recipe, not both", path: ["recipe"] });
233
278
  }
234
- if (!vfx.preset && !vfx.recipe && !vfx.parts?.length) {
235
- context.addIssue({ code: z.ZodIssueCode.custom, message: "a vfx needs a preset, a recipe, or at least one part", path: ["preset"] });
279
+ if (vfx.description === void 0 && !vfx.preset && !vfx.recipe && !vfx.parts?.length) {
280
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "a vfx needs a preset, a recipe, or at least one part, or a description", path: ["preset"] });
236
281
  }
237
282
  if (vfx.sprite && /^(https?|data):/i.test(vfx.sprite)) {
238
283
  context.addIssue({
@@ -417,6 +462,8 @@ var RevisionSchema = z.object({
417
462
  skeletonTemplate: z.string().min(1).optional(),
418
463
  /** `remove-background` only: `simple` is faster and suits flat backgrounds; `complex` handles detailed edges. PixelLab defaults to `simple`. */
419
464
  removalTask: z.enum(["simple", "complex"]).optional(),
465
+ /** `normal-map` only: `opengl` (green up; PixelLab's default) or `directx` (green down, for Unreal). */
466
+ convention: NormalMapConventionSchema.optional(),
420
467
  /**
421
468
  * `animate-skeleton`: what the character *looks like*, a noun
422
469
  * phrase (colours, clothing, held items) — distinct from the asset's
@@ -563,13 +610,20 @@ var RevisionSchema = z.object({
563
610
  path: ["removalTask"]
564
611
  });
565
612
  }
566
- if (revision.strength !== void 0 && revision.mode === "remove-background") {
613
+ if (revision.strength !== void 0 && (revision.mode === "remove-background" || revision.mode === "normal-map")) {
567
614
  context.addIssue({
568
615
  code: z.ZodIssueCode.custom,
569
- message: "remove-background revisions do not take a strength",
616
+ message: `${revision.mode} revisions do not take a strength`,
570
617
  path: ["strength"]
571
618
  });
572
619
  }
620
+ if (revision.convention !== void 0 && revision.mode !== "normal-map") {
621
+ context.addIssue({
622
+ code: z.ZodIssueCode.custom,
623
+ message: "convention applies to normal-map revisions only",
624
+ path: ["convention"]
625
+ });
626
+ }
573
627
  if (revision.description !== void 0 && revision.mode !== "animate-skeleton" && revision.mode !== "remove-background") {
574
628
  context.addIssue({
575
629
  code: z.ZodIssueCode.custom,
@@ -588,7 +642,7 @@ var RevisionSchema = z.object({
588
642
  }
589
643
  });
590
644
  var FRAME_SET_REVISION_MODES = ["animate", "animate-pixminimax", "animate-skeleton", "interpolate", "edit-animation"];
591
- var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation"];
645
+ var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation", "normal-map"];
592
646
  var StyleObjectSchema = z.object({
593
647
  /** Generation backend for this style. Omit to inherit the manifest default. */
594
648
  provider: z.string().min(1).optional(),
@@ -701,9 +755,10 @@ var StyleObjectSchema = z.object({
701
755
  terrainTileSize: z.union([z.literal(16), z.literal(32), z.literal(64)]).optional(),
702
756
  /**
703
757
  * `terrain` generator only. `standard` is the classic Wang tileset
704
- * pipeline; `pro` is a newer corner-pair pipeline with its own shape
705
- * controls (`terrainSpreadX`, `terrainSlopeSize`, `terrainRaggedness`)
706
- * in place of `terrainShapeStyle`. The API default is `standard`.
758
+ * pipeline; `pro` is a newer pipeline with its own shape controls
759
+ * (`terrainRaggedness`, `terrainRoundness`, `terrainBoundaryAmplitude`,
760
+ * `terrainStepSlope`, `terrainConnectDiagonals`) in place of
761
+ * `terrainShapeStyle`. The API default is `standard`.
707
762
  */
708
763
  terrainMode: z.enum(["standard", "pro"]).optional(),
709
764
  /**
@@ -715,31 +770,86 @@ var StyleObjectSchema = z.object({
715
770
  /**
716
771
  * `terrain` generator only, `terrainMode: "standard"`. Procedural
717
772
  * boundary geometry: `square` or `round`, 16px or 32px tiles only.
718
- * Rejected together with `terrainMode: "pro"`, whose own shape controls
719
- * are `terrainSpreadX`/`terrainSlopeSize`/`terrainRaggedness`.
773
+ * Rejected together with `terrainMode: "pro"`, which has its own shape
774
+ * controls.
720
775
  */
721
776
  terrainShapeStyle: z.enum(["square", "round"]).optional(),
722
- /** `terrain` generator only, `terrainMode: "pro"`. Boundary spread
723
- * between terrains (0 = steep, 1 = gradual). API default 0.5. */
777
+ /**
778
+ * Deprecated: no effect. PixelLab's Pro pipeline no longer reads
779
+ * `spread_x`; the API keeps accepting it so existing calls work, and so
780
+ * does pixelkiln. Use `terrainBoundaryAmplitude` or `terrainStepSlope`.
781
+ */
724
782
  terrainSpreadX: z.number().min(0).max(1).optional(),
725
- /** `terrain` generator only, `terrainMode: "pro"`. Slope on the N/W/E
726
- * sides as a fraction of wall height. API default 0. */
783
+ /**
784
+ * Deprecated: no effect. PixelLab's Pro pipeline no longer reads
785
+ * `slope_size`; the API keeps accepting it so existing calls work, and
786
+ * so does pixelkiln. `terrainStepSlope` is the slope control now.
787
+ */
727
788
  terrainSlopeSize: z.number().min(0).max(1).optional(),
728
789
  /** `terrain` generator only, `terrainMode: "pro"`. Terrain boundary
729
- * noise (0 = smooth, 1 = rough). API default 0. */
790
+ * noise (0 = smooth, 1 = rough), half strength on tall sets
791
+ * (`terrainTransitionSize` above 0.4). API default 0. */
730
792
  terrainRaggedness: z.number().min(0).max(1).optional(),
793
+ /**
794
+ * `terrain` generator only, `terrainMode: "pro"` or `terrainShapeStyle`.
795
+ * Corner shape of the border between the terrains: 0 square, 1 round,
796
+ * 2 corners cut at 45° (values between blend). Above 1 needs pro. The API
797
+ * default is 0.85 on pro and 0.6 with a shape style. With
798
+ * `terrainConnectDiagonals`, 2 is the fix for 45° paths and coastlines
799
+ * that otherwise break into one island per step.
800
+ */
801
+ terrainRoundness: z.number().min(0).max(2).optional(),
802
+ /**
803
+ * `terrain` generator only, `terrainMode: "pro"` (any transition size)
804
+ * or `terrainShapeStyle` (transition size up to 0.5). How far the border
805
+ * meanders instead of running straight: 0 straight (the default) to 0.3.
806
+ * Tiles still connect seamlessly.
807
+ */
808
+ terrainBoundaryAmplitude: z.number().min(0).max(0.3).optional(),
809
+ /**
810
+ * `terrain` generator only, with `terrainBoundaryAmplitude`. Picks the
811
+ * meander's shape: another number gives a different border at the same
812
+ * amplitude, the same number the same border. API default 0.
813
+ */
814
+ terrainBoundarySeed: z.number().int().min(0).optional(),
815
+ /**
816
+ * `terrain` generator only, `terrainMode: "pro"` with
817
+ * `terrainTransitionSize: 0.25`. How the bank between the terrains
818
+ * falls: 0 a straight cliff (the default) to 2 a gentle slope spread
819
+ * over more ground.
820
+ */
821
+ terrainStepSlope: z.number().min(0).max(2).optional(),
822
+ /**
823
+ * `terrain` generator only, `terrainMode: "pro"`. On the two diagonal
824
+ * tiles (upper terrain in opposite corners), true joins the upper
825
+ * corners through the middle into one area; false (the default) runs the
826
+ * lower terrain between them.
827
+ */
828
+ terrainConnectDiagonals: z.boolean().optional(),
731
829
  /**
732
830
  * `terrain` generator only. Visual height of the step between lower and
733
831
  * upper terrain. Without `terrainShapeStyle`, only 0, 0.25, 0.5, or 1 are
734
- * accepted (1 switches to the 25-tile cliff layout, where corners take a
735
- * third "transition" value); with it, any value from 0 to 1 works, but
736
- * above 0.5 switches to an extended 32-tile layout this adapter does not
737
- * model. API default 0.
832
+ * accepted. Standard switches to the 25-tile cliff layout at 1, where
833
+ * corners take a third "transition" value; Pro reads it as wall height
834
+ * and switches to the 25-tile tall-wall layout above 0.4, so 0.5 is
835
+ * already 25 tiles there. With `terrainShapeStyle`, any value from 0 to 1
836
+ * works, but above 0.5 switches to an extended 32-tile layout this
837
+ * adapter does not model. API default 0.
738
838
  */
739
839
  terrainTransitionSize: z.number().min(0).max(1).optional(),
740
840
  /** `terrain` generator only. Camera angle; the API default is `high
741
841
  * top-down`. */
742
842
  terrainView: z.enum(["low top-down", "high top-down"]).optional(),
843
+ /**
844
+ * `terrain` generator only. `top-down` (the default) is a two-terrain
845
+ * map set from `/create-tileset`; `sidescroller` is a platform set from
846
+ * `/create-tileset-sidescroller`, seen from the side: the prompt's 1).
847
+ * is the platform body and 2). the top layer on it (grass, snow, moss),
848
+ * and the set's other terrain is empty air. 16 or 32px tiles,
849
+ * `terrainTransitionSize` 0, 0.25, 0.5, or 1 (the top layer's height),
850
+ * and none of the top-down mode, view, or shape controls.
851
+ */
852
+ terrainOrientation: z.enum(["top-down", "sidescroller"]).optional(),
743
853
  /**
744
854
  * `isometricTile` generator only. `/create-isometric-tile`'s own tile
745
855
  * grid size, distinct from `size` (the generation canvas, 16-64px — the
@@ -819,7 +929,9 @@ var StyleObjectSchema = z.object({
819
929
  /** Fixed seed for reproducibility where the endpoint supports it. */
820
930
  seed: z.number().int().optional(),
821
931
  /**
822
- * Forced palette, as `#rrggbb` values. `pixflux` only.
932
+ * Forced palette, as `#rrggbb` values. Sent as a swatch to `pixflux`
933
+ * and the generators that take one, and to `imagePro` as its
934
+ * `target_palette.colors` (2 to 256 colours).
823
935
  *
824
936
  * Unlike prose, this is a hard constraint. The API is handed a swatch
825
937
  * image and the output is limited to those colours. Verified: a four-colour
@@ -829,6 +941,10 @@ var StyleObjectSchema = z.object({
829
941
  * chocolate bar.
830
942
  */
831
943
  palette: z.array(HexColorSchema).default([]),
944
+ /** `imagePro` only: the palette's name ("Game Boy", "PICO-8"), which PixelLab uses to help the look. Needs `palette`. */
945
+ paletteName: z.string().min(1).max(100).optional(),
946
+ /** `imagePro` only: at most this many colours in each candidate, 2 to 64 (drawn from `palette` when one is set). */
947
+ maxColors: z.number().int().min(2).max(64).optional(),
832
948
  /**
833
949
  * Snap every visible pixel of the downloaded art to the nearest `palette`
834
950
  * colour, without dithering. The palette is sent to providers that take
@@ -882,6 +998,22 @@ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImag
882
998
  path: ["quality"]
883
999
  });
884
1000
  }
1001
+ for (const field of ["paletteName", "maxColors"]) {
1002
+ if (style[field] !== void 0 && style.generator !== "imagePro") {
1003
+ ctx.addIssue({
1004
+ code: z.ZodIssueCode.custom,
1005
+ message: `${field} applies to the imagePro generator only`,
1006
+ path: [field]
1007
+ });
1008
+ }
1009
+ }
1010
+ if (style.paletteName !== void 0 && style.palette.length === 0) {
1011
+ ctx.addIssue({
1012
+ code: z.ZodIssueCode.custom,
1013
+ message: "paletteName names the palette's colours; set palette too",
1014
+ path: ["paletteName"]
1015
+ });
1016
+ }
885
1017
  if (style.enforcePalette && style.palette.length < 2) {
886
1018
  ctx.addIssue({
887
1019
  code: z.ZodIssueCode.custom,
@@ -1007,6 +1139,8 @@ var AssetSchema = z.object({
1007
1139
  scene: MapSceneSchema.optional(),
1008
1140
  /** `vfx` styles only: the effect to render. See `AssetVfxSchema`. */
1009
1141
  vfx: AssetVfxSchema.optional(),
1142
+ /** `terrain` styles only: share a terrain with another set. See `TerrainConnectSchema`. */
1143
+ connect: TerrainConnectSchema.optional(),
1010
1144
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
1011
1145
  proportions: CharacterProportionsSchema.optional(),
1012
1146
  /**
@@ -1083,7 +1217,7 @@ var AssetSchema = z.object({
1083
1217
  path: ["revision"]
1084
1218
  });
1085
1219
  }
1086
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch", asset.portrait && "portrait", asset.outfit && "outfit", asset.scene && "scene", asset.vfx && "vfx"].filter(Boolean);
1220
+ const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch", asset.portrait && "portrait", asset.outfit && "outfit", asset.scene && "scene", asset.vfx && "vfx", asset.connect && "connect"].filter(Boolean);
1087
1221
  if (shapes.length > 1) {
1088
1222
  context.addIssue({
1089
1223
  code: z.ZodIssueCode.custom,
@@ -1175,6 +1309,10 @@ var LockSourceSchema = z.object({
1175
1309
  role: z.string().optional(),
1176
1310
  mediaType: MediaTypeSchema.optional()
1177
1311
  });
1312
+ var LockTerrainConnectSchema = z.record(
1313
+ TerrainSideSchema,
1314
+ z.object({ sourceAssetId: z.string().min(1), sourceObjectId: z.string().min(1) }).strict()
1315
+ );
1178
1316
  var LockHistoryEntrySchema = z.object({
1179
1317
  specHash: z.string(),
1180
1318
  generator: GeneratorSchema,
@@ -1197,6 +1335,8 @@ var LockHistoryEntrySchema = z.object({
1197
1335
  billed: z.object({ amount: z.number(), unit: z.string() }).nullable().default(null),
1198
1336
  provider: z.string().default("pixellab"),
1199
1337
  postprocess: PostprocessSchema.optional(),
1338
+ /** A connected terrain set's other sets; see LockEntrySchema.terrainConnect. */
1339
+ terrainConnect: LockTerrainConnectSchema.optional(),
1200
1340
  /** When the replacement was submitted. */
1201
1341
  retiredAt: z.string()
1202
1342
  });
@@ -1235,6 +1375,7 @@ var LockEntrySchema = z.object({
1235
1375
  skeletonTemplate: z.string().min(1).optional(),
1236
1376
  description: z.string().min(1).optional(),
1237
1377
  removalTask: z.enum(["simple", "complex"]).optional(),
1378
+ convention: NormalMapConventionSchema.optional(),
1238
1379
  driftThreshold: z.number().min(0).optional(),
1239
1380
  subjectDescription: z.string().min(1).max(300).optional(),
1240
1381
  initialPose: z.string().min(1).max(300).optional(),
@@ -1262,6 +1403,14 @@ var LockEntrySchema = z.object({
1262
1403
  sourceSha256: z.string().regex(/^[0-9a-f]{64}$/),
1263
1404
  referenceSha256: z.string().regex(/^[0-9a-f]{64}$/)
1264
1405
  }).strict().nullable().default(null),
1406
+ /**
1407
+ * For a connected terrain set: per side of this set, the set it shares
1408
+ * that terrain with and that set's provider id when this was submitted.
1409
+ * Tracked the way `outfit` is, not through `specHash`: the other set's
1410
+ * id lives in the lockfile, which manifest resolution does not read.
1411
+ * Absent (not null) otherwise, so other entries do not grow the key.
1412
+ */
1413
+ terrainConnect: LockTerrainConnectSchema.optional(),
1265
1414
  /**
1266
1415
  * Output hashes owned by the previous generation while its replacement is
1267
1416
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -1586,7 +1735,16 @@ var TilesetGetSchema = z3.object({
1586
1735
  terrain_types: z3.array(z3.string()),
1587
1736
  tiles: z3.array(TilesetTileSchema).min(1)
1588
1737
  }).passthrough(),
1589
- usage: UsageSchema
1738
+ usage: UsageSchema,
1739
+ /**
1740
+ * `terrain_ids` names each terrain's base tile id: the id another set
1741
+ * sends as `lower_base_tile_id`/`upper_base_tile_id` to share that
1742
+ * terrain. A sidescroller set has only `lower` (its upper is empty air).
1743
+ */
1744
+ metadata: z3.object({
1745
+ terrain_ids: z3.record(z3.string()).nullable().optional(),
1746
+ view: z3.string().nullable().optional()
1747
+ }).passthrough().nullable().optional()
1590
1748
  }).passthrough();
1591
1749
  var IsometricTileSubmitSchema = z3.object({
1592
1750
  tile_id: z3.string().min(1),
@@ -1675,6 +1833,12 @@ var ProFlashImageSubmitSchema = z3.object({
1675
1833
  estimated_generations: z3.number().nullable().optional(),
1676
1834
  usage: z3.unknown().optional()
1677
1835
  }).passthrough();
1836
+ var VfxSubmitSchema = z3.object({
1837
+ background_job_id: z3.string().min(1),
1838
+ status: z3.string().default("processing"),
1839
+ quoted_generations: z3.number().nullable().optional(),
1840
+ usage: z3.unknown().optional()
1841
+ }).passthrough();
1678
1842
  var UnzoomResponseSchema = z3.object({
1679
1843
  image: z3.object({ base64: z3.string().min(1) }).passthrough(),
1680
1844
  original_size: z3.object({ width: z3.number().int(), height: z3.number().int() }).passthrough(),
@@ -2015,12 +2179,19 @@ var PixelLabClient = class {
2015
2179
  upper_description: args.upperDescription
2016
2180
  };
2017
2181
  if (args.transitionDescription) body.transition_description = args.transitionDescription;
2182
+ if (args.lowerBaseTileId) body.lower_base_tile_id = args.lowerBaseTileId;
2183
+ if (args.upperBaseTileId) body.upper_base_tile_id = args.upperBaseTileId;
2018
2184
  if (args.tileSize != null) body.tile_size = { width: args.tileSize, height: args.tileSize };
2019
2185
  if (args.mode) body.mode = args.mode;
2020
2186
  if (args.shapeStyle) body.shape_style = args.shapeStyle;
2021
2187
  if (args.spreadX != null) body.spread_x = args.spreadX;
2022
2188
  if (args.slopeSize != null) body.slope_size = args.slopeSize;
2023
2189
  if (args.raggedness != null) body.raggedness = args.raggedness;
2190
+ if (args.roundness != null) body.roundness = args.roundness;
2191
+ if (args.boundaryAmplitude != null) body.boundary_amplitude = args.boundaryAmplitude;
2192
+ if (args.boundarySeed != null) body.boundary_seed = args.boundarySeed;
2193
+ if (args.stepSlope != null) body.step_slope = args.stepSlope;
2194
+ if (args.connectDiagonals != null) body.connect_diagonals = args.connectDiagonals;
2024
2195
  if (args.transitionSize != null) body.transition_size = args.transitionSize;
2025
2196
  if (args.view) body.view = args.view;
2026
2197
  if (args.outline) body.outline = args.outline;
@@ -2033,14 +2204,48 @@ var PixelLabClient = class {
2033
2204
  "create tileset"
2034
2205
  );
2035
2206
  }
2036
- /** Throws PixelLabError(423) while the set is still drawing; see Tileset. */
2207
+ /**
2208
+ * `/create-tileset-sidescroller`: a platform set seen from the side. One
2209
+ * terrain (`lower_description`, the platform body) and the decorative
2210
+ * layer on top of it (`transition_description`); the other side of every
2211
+ * edge is empty air, so there is no upper description, mode, view, or
2212
+ * shape control. 16 or 32px tiles only. The result is read through
2213
+ * `getTileset` like a top-down set.
2214
+ */
2215
+ async createTilesetSidescroller(args) {
2216
+ const body = { lower_description: args.lowerDescription };
2217
+ if (args.transitionDescription) body.transition_description = args.transitionDescription;
2218
+ if (args.lowerBaseTileId) body.lower_base_tile_id = args.lowerBaseTileId;
2219
+ if (args.tileSize != null) body.tile_size = { width: args.tileSize, height: args.tileSize };
2220
+ if (args.transitionSize != null) body.transition_size = args.transitionSize;
2221
+ if (args.outline) body.outline = args.outline;
2222
+ if (args.shading) body.shading = args.shading;
2223
+ if (args.detail) body.detail = args.detail;
2224
+ if (args.seed != null) body.seed = args.seed;
2225
+ return validateResponse(
2226
+ TilesetSubmitSchema,
2227
+ await this.request("/create-tileset-sidescroller", { method: "POST", body: JSON.stringify(body) }),
2228
+ "create sidescroller tileset"
2229
+ );
2230
+ }
2231
+ /**
2232
+ * Throws PixelLabError(423) while the set is still drawing; see Tileset.
2233
+ * Serves top-down and sidescroller sets alike.
2234
+ */
2037
2235
  async getTileset(tilesetId) {
2038
2236
  const res = await validateResponse(
2039
2237
  TilesetGetSchema,
2040
2238
  await this.request(`/tilesets/${tilesetId}`),
2041
2239
  "get tileset"
2042
2240
  );
2043
- return { tileset: res.tileset, usage: res.usage ?? null };
2241
+ return {
2242
+ tileset: res.tileset,
2243
+ usage: res.usage ?? null,
2244
+ metadata: {
2245
+ terrainIds: res.metadata?.terrain_ids ?? {},
2246
+ view: res.metadata?.view ?? null
2247
+ }
2248
+ };
2044
2249
  }
2045
2250
  /** A single standalone isometric tile — no connectable set, no candidates. */
2046
2251
  async createIsometricTile(args) {
@@ -2577,6 +2782,13 @@ var PixelLabClient = class {
2577
2782
  *
2578
2783
  * Reference images and a style image (`reference_images`, `style_image` +
2579
2784
  * `style_options`) exist on this endpoint but are not modeled here yet.
2785
+ *
2786
+ * `targetPalette` is the beta `target_palette` (PixelLab 0.4.130): with
2787
+ * `colors`, the image is drawn for them and every result pixel is one of
2788
+ * them; `name` ("PICO-8") helps the look; `maxColors` caps the colours in
2789
+ * each candidate. Its `smart` flag is not modeled: it is documented as
2790
+ * "single images only", and this endpoint returns a grid of candidates
2791
+ * below 171px.
2580
2792
  */
2581
2793
  async createImagePro(args) {
2582
2794
  const body = {
@@ -2585,6 +2797,14 @@ var PixelLabClient = class {
2585
2797
  };
2586
2798
  if (args.noBackground != null) body.no_background = args.noBackground;
2587
2799
  if (args.seed != null) body.seed = args.seed;
2800
+ const palette = args.targetPalette;
2801
+ if (palette && (palette.colors?.length || palette.maxColors != null)) {
2802
+ body.target_palette = {
2803
+ ...palette.colors?.length ? { colors: palette.colors } : {},
2804
+ ...palette.name ? { name: palette.name } : {},
2805
+ ...palette.maxColors != null ? { max_colors: palette.maxColors } : {}
2806
+ };
2807
+ }
2588
2808
  return validateResponse(
2589
2809
  RevisionJobSubmitSchema,
2590
2810
  await this.request("/generate-image-v2", { method: "POST", body: JSON.stringify(body) }),
@@ -2775,6 +2995,27 @@ var PixelLabClient = class {
2775
2995
  );
2776
2996
  return { png: Buffer.from(res.image.base64, "base64"), usage: res.usage };
2777
2997
  }
2998
+ /**
2999
+ * `/normal-map` (PixelLab 0.4.130): the normal map of a sprite, so a game
3000
+ * engine can light it. RGB is the way each pixel faces, alpha is 255 where
3001
+ * the sprite shows and 0 elsewhere, at the input's size. An animation goes
3002
+ * as one horizontal strip of `frames` equal frames (up to 32, each 8 to 512
3003
+ * px per side) and comes back as a strip, planned once on the first frame
3004
+ * so the light stays steady. `opengl` (the default) is green up, for
3005
+ * Godot, Unity, and Phaser; `directx` is green down, for Unreal. A
3006
+ * background job whose map is `last_response.image`; 0.5 generation,
3007
+ * charged only when the map is delivered.
3008
+ */
3009
+ async normalMap(args) {
3010
+ const body = { image: args.image };
3011
+ if (args.frames != null && args.frames !== 1) body.frames = args.frames;
3012
+ if (args.convention) body.convention = args.convention;
3013
+ return validateResponse(
3014
+ RevisionJobSubmitSchema,
3015
+ await this.request("/normal-map", { method: "POST", body: JSON.stringify(body) }),
3016
+ "normal-map"
3017
+ );
3018
+ }
2778
3019
  /**
2779
3020
  * `/unzoom`: recover native-resolution pixel art from an upscaled image (a
2780
3021
  * 32x32 sprite saved at 512x512). Synchronous like the Cleanup tier.
@@ -2865,6 +3106,28 @@ var PixelLabClient = class {
2865
3106
  "generate-font-pro"
2866
3107
  );
2867
3108
  }
3109
+ /**
3110
+ * `POST /vfx`: PixelLab's effects artist builds an animated effect from a
3111
+ * description, around an optional sprite, and saves it to My effects. With
3112
+ * `effectId` it changes that saved effect in place; adding the effect's
3113
+ * `conversation` prices the change as a change. A background job whose
3114
+ * 202 carries `quoted_generations`; PixelLab bills on delivery, up to
3115
+ * twice the quote. A 503 (renderer busy) is retried by the shared policy.
3116
+ */
3117
+ async createVfx(args) {
3118
+ const body = { description: args.description };
3119
+ if (args.artist) body.artist = args.artist;
3120
+ if (args.sprite) body.sprite = { type: "base64", base64: args.sprite.base64, format: args.sprite.format };
3121
+ if (args.effectId) body.effect_id = args.effectId;
3122
+ if (args.conversation) body.conversation = args.conversation;
3123
+ if (args.name) body.name = args.name;
3124
+ if (args.gamePack) body.game_pack = true;
3125
+ return validateResponse(
3126
+ VfxSubmitSchema,
3127
+ await this.request("/vfx", { method: "POST", body: JSON.stringify(body) }),
3128
+ "vfx"
3129
+ );
3130
+ }
2868
3131
  /** `GET /generate-font-pro/{job_id}`: status, then no-auth atlas and `.ttf` URLs once completed. */
2869
3132
  async getFontJob(jobId) {
2870
3133
  const raw = await this.request(`/generate-font-pro/${encodeURIComponent(jobId)}`);
@@ -3464,6 +3727,41 @@ function parseHex(hex2) {
3464
3727
  const n = parseInt(m[1], 16);
3465
3728
  return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
3466
3729
  }
3730
+ function joinStripPng(frames) {
3731
+ const decoded = frames.map((frame) => decodePng(frame));
3732
+ const first = decoded[0];
3733
+ if (!first) throw new Error("a strip needs at least one frame");
3734
+ const { width, height } = first;
3735
+ for (const [index, frame] of decoded.entries()) {
3736
+ if (frame.width !== width || frame.height !== height) {
3737
+ throw new Error(`strip frame ${index} is ${frame.width}x${frame.height}; frame 0 is ${width}x${height}`);
3738
+ }
3739
+ }
3740
+ const stride = width * 4;
3741
+ const out = Buffer.alloc(stride * decoded.length * height);
3742
+ for (let y = 0; y < height; y++) {
3743
+ for (const [index, frame] of decoded.entries()) {
3744
+ frame.pixels.copy(out, (y * decoded.length + index) * stride, y * stride, (y + 1) * stride);
3745
+ }
3746
+ }
3747
+ return encodeRgbaPng(width * decoded.length, height, out);
3748
+ }
3749
+ function splitStripPng(strip, frames) {
3750
+ const image = decodePng(strip);
3751
+ if (frames < 1 || image.width % frames !== 0) {
3752
+ throw new Error(`a ${image.width}x${image.height} strip does not split into ${frames} equal frames`);
3753
+ }
3754
+ const width = image.width / frames;
3755
+ const stride = width * 4;
3756
+ return Array.from({ length: frames }, (_, index) => {
3757
+ const out = Buffer.alloc(stride * image.height);
3758
+ for (let y = 0; y < image.height; y++) {
3759
+ const from = (y * image.width + index * width) * 4;
3760
+ image.pixels.copy(out, y * stride, from, from + stride);
3761
+ }
3762
+ return encodeRgbaPng(width, image.height, out);
3763
+ });
3764
+ }
3467
3765
 
3468
3766
  // src/media.ts
3469
3767
  var MediaType = {
@@ -3579,10 +3877,10 @@ function imageMetadata(buf) {
3579
3877
  }
3580
3878
 
3581
3879
  // src/providers/pixellab.ts
3582
- import { mkdirSync, existsSync as existsSync2, readFileSync, writeFileSync } from "fs";
3880
+ import { mkdirSync, existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
3583
3881
  import { randomUUID } from "crypto";
3584
3882
  import os from "os";
3585
- import path2 from "path";
3883
+ import path3 from "path";
3586
3884
 
3587
3885
  // src/hash.ts
3588
3886
  import { createHash } from "crypto";
@@ -3618,6 +3916,9 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3618
3916
  detail: effect ? void 0 : spec.detail ?? null,
3619
3917
  seed: effect ? void 0 : spec.seed ?? null,
3620
3918
  palette: effect ? void 0 : spec.palette,
3919
+ // `imagePro` only, and absent unless the style sets them.
3920
+ paletteName: spec.paletteName,
3921
+ maxColors: spec.maxColors,
3621
3922
  // `noBackground` only reaches the wire for pixflux and uiAsset; the
3622
3923
  // tile fields are undefined for every other generator. `tileSize` is
3623
3924
  // intentionally absent; width/height are derived from it, so it is
@@ -3632,6 +3933,25 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3632
3933
  uiPieces: spec.uiPieces,
3633
3934
  uiElements: spec.uiElements,
3634
3935
  uiColorPalette: spec.uiColorPalette,
3936
+ // The older terrain knobs (terrainMode, terrainTransitionSize, and the
3937
+ // rest) were never part of this hash, and adding them now would make
3938
+ // every existing terrain entry stale. The Pro shape controls added
3939
+ // after them are identity, each only when set.
3940
+ terrainRoundness: spec.terrainRoundness,
3941
+ terrainBoundaryAmplitude: spec.terrainBoundaryAmplitude,
3942
+ terrainBoundarySeed: spec.terrainBoundarySeed,
3943
+ terrainStepSlope: spec.terrainStepSlope,
3944
+ terrainConnectDiagonals: spec.terrainConnectDiagonals,
3945
+ // "top-down" is the default and draws the same set as leaving it out.
3946
+ terrainOrientation: spec.terrainOrientation === "sidescroller" ? spec.terrainOrientation : void 0,
3947
+ // Which set and which of its terrains, not that terrain's id: the id
3948
+ // is in the lockfile, and the plan compares it there (like a mirror).
3949
+ terrainConnect: spec.terrainConnect ? Object.fromEntries(
3950
+ ["lower", "upper"].flatMap((side) => {
3951
+ const link = spec.terrainConnect[side];
3952
+ return link ? [[side, { of: link.assetId, terrain: link.terrain }]] : [];
3953
+ })
3954
+ ) : void 0,
3635
3955
  // Scene and mask enter as their bytes' hashes, never their paths.
3636
3956
  scene: spec.scene ? {
3637
3957
  image: spec.scene.sha256,
@@ -3693,13 +4013,17 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3693
4013
  reference: spec.objectPro.reference?.sha256
3694
4014
  } : void 0,
3695
4015
  // A recipe and a local sprite enter as their contents' hashes, never
3696
- // their paths; the My effects name is not identity.
4016
+ // their paths; the My effects name is not identity. `description` and
4017
+ // `artist` exist only for the paid VFX artist and stay absent for a
4018
+ // workbench effect, so its hash is the one it always had.
3697
4019
  vfx: spec.vfx ? {
3698
4020
  preset: spec.vfx.preset,
3699
4021
  recipe: spec.vfx.recipe?.sha256,
3700
4022
  parts: spec.vfx.parts,
3701
4023
  fps: spec.vfx.fps,
3702
- sprite: spec.vfx.sprite ? "reference" in spec.vfx.sprite ? spec.vfx.sprite.reference : spec.vfx.sprite.sha256 : void 0
4024
+ sprite: spec.vfx.sprite ? "reference" in spec.vfx.sprite ? spec.vfx.sprite.reference : spec.vfx.sprite.sha256 : void 0,
4025
+ description: spec.vfx.description,
4026
+ artist: spec.vfx.description ? spec.vfx.artist : void 0
3703
4027
  } : void 0,
3704
4028
  // A mirror's bytes come from its source's recorded outputs; the plan
3705
4029
  // compares those directly, so only the choice of source is identity.
@@ -3729,6 +4053,7 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3729
4053
  skeletonTemplate: spec.revision.skeletonTemplate,
3730
4054
  description: spec.revision.description,
3731
4055
  removalTask: spec.revision.removalTask,
4056
+ convention: spec.revision.convention,
3732
4057
  driftThreshold: spec.revision.driftThreshold,
3733
4058
  subjectDescription: spec.revision.subjectDescription,
3734
4059
  initialPose: spec.revision.initialPose,
@@ -3875,7 +4200,8 @@ function vfxOutputPaths(out) {
3875
4200
  gif: `${base}.gif`,
3876
4201
  atlas: `${base}.json`,
3877
4202
  provenance: `${base}.vfx.json`,
3878
- gamePack: `${base}-game.zip`
4203
+ gamePack: `${base}-game.zip`,
4204
+ normals: `${base}-normals.png`
3879
4205
  };
3880
4206
  }
3881
4207
  function isVfxReference(sprite) {
@@ -3965,10 +4291,17 @@ function vfxRecipeSha256(recipe) {
3965
4291
  const { name: _name, notes: _notes, ...rest } = recipe;
3966
4292
  return sha256(JSON.stringify(rest));
3967
4293
  }
4294
+ function vfxAtlasFrames(atlas) {
4295
+ const listed = atlas?.frames;
4296
+ const entries = Array.isArray(listed) ? listed : listed && typeof listed === "object" ? Object.values(listed) : null;
4297
+ if (!entries?.length) return null;
4298
+ const rects = entries.map((entry) => entry?.frame);
4299
+ return rects.every((rect) => rect && [rect.x, rect.y, rect.w, rect.h].every((n) => typeof n === "number")) ? rects : null;
4300
+ }
3968
4301
  function sliceVfxSheet(sheet, facts, atlas) {
3969
4302
  const image = decodePng(sheet);
3970
- const listed = atlas?.frames;
3971
- const rects = Array.isArray(listed) && listed.length === facts.frames ? listed.map((entry) => entry.frame) : Array.from({ length: facts.frames }, (_, index) => {
4303
+ const listed = vfxAtlasFrames(atlas);
4304
+ const rects = listed && listed.length === facts.frames ? listed : Array.from({ length: facts.frames }, (_, index) => {
3972
4305
  const columns = Math.max(1, Math.floor(image.width / facts.width));
3973
4306
  return { x: index % columns * facts.width, y: Math.floor(index / columns) * facts.height, w: facts.width, h: facts.height };
3974
4307
  });
@@ -4068,6 +4401,477 @@ async function makeVfx(client2, opts) {
4068
4401
  `);
4069
4402
  return { ...exported, paths: written, recipe };
4070
4403
  }
4404
+ var VFX_ARTISTS = ["lite", "pro"];
4405
+ var VFX_ARTIST_MAX_SPRITE = 512;
4406
+ function vfxArtistQuote(artist, sprite, change = false) {
4407
+ const table = {
4408
+ lite: { new: [1, 1, 3, 5], change: [1, 1, 2, 2] },
4409
+ pro: { new: [6, 8, 14, 28], change: [4, 4, 5, 8] }
4410
+ };
4411
+ const tier = sprite === "none" ? 0 : sprite === "unknown" ? 3 : sprite <= 128 ? 1 : sprite <= 256 ? 2 : 3;
4412
+ return table[artist][change ? "change" : "new"][tier];
4413
+ }
4414
+ function vfxArtistCeiling(quote) {
4415
+ return quote * 2;
4416
+ }
4417
+ function vfxArtistSprite(bytes, label) {
4418
+ const metadata = imageMetadata(bytes);
4419
+ if (metadata?.format !== "png") throw new Error(`${label}: PixelLab's VFX artist takes a PNG sprite`);
4420
+ const side = Math.max(metadata.width, metadata.height);
4421
+ if (side > VFX_ARTIST_MAX_SPRITE) {
4422
+ throw new Error(
4423
+ `${label} is ${metadata.width}x${metadata.height}; PixelLab's VFX artist takes up to ${VFX_ARTIST_MAX_SPRITE}x${VFX_ARTIST_MAX_SPRITE} (crop or pad it; never rescale pixel art)`
4424
+ );
4425
+ }
4426
+ return { image: { base64: bytes.toString("base64"), format: "png" }, side };
4427
+ }
4428
+ function vfxArtistFiles(raw) {
4429
+ const urlOf = (value) => {
4430
+ if (typeof value === "string") return /^https?:\/\//i.test(value) ? value : null;
4431
+ if (!value || typeof value !== "object") return null;
4432
+ for (const key of ["url", "download_url", "href"]) {
4433
+ const url = value[key];
4434
+ if (typeof url === "string" && /^https?:\/\//i.test(url)) return url;
4435
+ }
4436
+ return null;
4437
+ };
4438
+ const nameOf = (value, url, key) => {
4439
+ if (value && typeof value === "object") {
4440
+ for (const field of ["name", "filename", "file"]) {
4441
+ const name = value[field];
4442
+ if (typeof name === "string" && name) return name;
4443
+ }
4444
+ }
4445
+ if (key && /\.[a-z0-9]+$/i.test(key)) return key;
4446
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
4447
+ };
4448
+ const entries = Array.isArray(raw) ? raw.map((value) => [void 0, value]) : raw && typeof raw === "object" ? Object.entries(raw) : [];
4449
+ const files = [];
4450
+ for (const [key, value] of entries) {
4451
+ const url = urlOf(value);
4452
+ if (url) files.push({ name: nameOf(value, url, key), url });
4453
+ }
4454
+ return files;
4455
+ }
4456
+ function parseVfxArtistResult(done, jobId) {
4457
+ const files = vfxArtistFiles(done.files);
4458
+ const pick = (test, skip) => files.find((file) => test.test(file.name) && !skip?.test(file.name)) ?? null;
4459
+ const normals = /-normals?\.png$/i;
4460
+ const sheet = pick(/\.png$/i, normals);
4461
+ const effectId = typeof done.effect_id === "string" && done.effect_id ? done.effect_id : null;
4462
+ if (!effectId || !sheet) {
4463
+ throw new Error(
4464
+ `Invalid PixelLab response for vfx job ${jobId}: completed without ${effectId ? "a spritesheet among its files" : "an effect_id"} (last_response keys: ${Object.keys(done).join(", ") || "none"}; files: ${JSON.stringify(done.files ?? null).slice(0, 400)}); update parseVfxArtistResult in src/pixellab-vfx.ts with the real shape`
4465
+ );
4466
+ }
4467
+ const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
4468
+ const text = (value) => typeof value === "string" && value ? value : null;
4469
+ const canvas = done.canvas ?? {};
4470
+ const anchor = done.anchor;
4471
+ return {
4472
+ effectId,
4473
+ name: text(done.name),
4474
+ editUrl: text(done.edit_url),
4475
+ message: text(done.message),
4476
+ width: number(canvas.w),
4477
+ height: number(canvas.h),
4478
+ frames: number(done.frames),
4479
+ fps: number(done.fps),
4480
+ loop: typeof done.loop === "boolean" ? done.loop : null,
4481
+ anchor: anchor && number(anchor.x) !== null && number(anchor.y) !== null ? { kind: text(anchor.kind), x: anchor.x, y: anchor.y } : null,
4482
+ parts: done.parts ?? null,
4483
+ conversation: text(done.conversation),
4484
+ files,
4485
+ sheet,
4486
+ atlas: pick(/\.json$/i),
4487
+ gif: pick(/\.gif$/i),
4488
+ gamePack: pick(/\.zip$/i),
4489
+ normals: pick(normals)
4490
+ };
4491
+ }
4492
+ function vfxArtistFrameFacts(result, atlas) {
4493
+ const rects = vfxAtlasFrames(atlas);
4494
+ const frames = result.frames ?? rects?.length ?? null;
4495
+ const width = result.width ?? rects?.[0]?.w ?? null;
4496
+ const height = result.height ?? rects?.[0]?.h ?? null;
4497
+ if (!frames || !width || !height) {
4498
+ throw new Error(`PixelLab vfx effect ${result.effectId} came back with no frame size or count, in the result or its atlas`);
4499
+ }
4500
+ return { width, height, frames };
4501
+ }
4502
+ async function makeVfxWithArtist(client2, opts) {
4503
+ const paths = vfxOutputPaths(opts.out);
4504
+ if (!opts.force) {
4505
+ for (const file of [paths.sheet, paths.gif, paths.atlas, paths.provenance, ...opts.gamePack ? [paths.gamePack] : []]) {
4506
+ if (existsSync(file)) throw new Error(`${file} already exists; pass --force to replace it`);
4507
+ }
4508
+ }
4509
+ const progress = opts.onProgress ?? (() => {
4510
+ });
4511
+ const sprite = opts.sprite ? vfxArtistSprite(await readFile(opts.sprite), opts.sprite).image : void 0;
4512
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
4513
+ const interval = opts.intervalMs ?? 6e3;
4514
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
4515
+ const submitted = await client2.createVfx({
4516
+ description: opts.description,
4517
+ artist: opts.artist,
4518
+ sprite,
4519
+ effectId: opts.effect,
4520
+ conversation: opts.effect ? opts.conversation : void 0,
4521
+ name: opts.name,
4522
+ gamePack: opts.gamePack
4523
+ });
4524
+ const jobId = submitted.background_job_id;
4525
+ progress(`submitted job ${jobId}, quoted ${submitted.quoted_generations ?? "?"} generations`);
4526
+ const started = Date.now();
4527
+ let job;
4528
+ for (; ; ) {
4529
+ job = await client2.getBackgroundJob(jobId);
4530
+ if (job.status === "completed") break;
4531
+ if (job.status === "failed") throw new Error(`PixelLab vfx job ${jobId} failed upstream${vfxJobFailure(job.last_response)}`);
4532
+ if (Date.now() - started > timeout) {
4533
+ throw new Error(
4534
+ `PixelLab vfx job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream (and bills when delivered), but this command has stopped waiting`
4535
+ );
4536
+ }
4537
+ progress(job.status);
4538
+ await sleep3(interval);
4539
+ }
4540
+ const result = parseVfxArtistResult(job.last_response ?? {}, jobId);
4541
+ await mkdir(path.dirname(paths.sheet), { recursive: true });
4542
+ const written = { sheet: paths.sheet, provenance: paths.provenance };
4543
+ await writeFile(paths.sheet, await client2.download(result.sheet.url));
4544
+ let atlas;
4545
+ if (result.atlas) {
4546
+ atlas = JSON.parse((await client2.download(result.atlas.url)).toString("utf8"));
4547
+ const meta = atlas.meta;
4548
+ if (meta) meta.image = path.basename(paths.sheet);
4549
+ await writeFile(paths.atlas, `${JSON.stringify(atlas, null, 2)}
4550
+ `);
4551
+ written.atlas = paths.atlas;
4552
+ }
4553
+ for (const [file, target, key] of [
4554
+ [result.gif, paths.gif, "gif"],
4555
+ [result.gamePack, paths.gamePack, "gamePack"],
4556
+ [result.normals, paths.normals, "normals"]
4557
+ ]) {
4558
+ if (!file) continue;
4559
+ await writeFile(target, await client2.download(file.url));
4560
+ written[key] = target;
4561
+ }
4562
+ const facts = vfxArtistFrameFacts(result, atlas);
4563
+ const usage = job.usage ?? null;
4564
+ const provenance = {
4565
+ version: 1,
4566
+ provider: "pixellab",
4567
+ tool: "vfx",
4568
+ artist: opts.artist ?? "lite",
4569
+ description: opts.description,
4570
+ name: result.name,
4571
+ effectId: result.effectId,
4572
+ editUrl: result.editUrl,
4573
+ message: result.message,
4574
+ source: {
4575
+ ...opts.sprite ? { sprite: path.relative(path.dirname(paths.provenance), path.resolve(opts.sprite)).split(path.sep).join("/") } : {},
4576
+ ...opts.effect ? { changed: opts.effect } : {}
4577
+ },
4578
+ ...facts,
4579
+ fps: result.fps,
4580
+ loop: result.loop,
4581
+ anchor: result.anchor,
4582
+ parts: result.parts,
4583
+ files: Object.fromEntries(
4584
+ ["sheet", "gif", "atlas", "gamePack", "normals"].filter((key) => written[key]).map((key) => [key, path.basename(written[key])])
4585
+ ),
4586
+ quotedGenerations: submitted.quoted_generations ?? null,
4587
+ usage,
4588
+ conversation: result.conversation,
4589
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString()
4590
+ };
4591
+ await writeFile(paths.provenance, `${JSON.stringify(provenance, null, 2)}
4592
+ `);
4593
+ return {
4594
+ jobId,
4595
+ effectId: result.effectId,
4596
+ editUrl: result.editUrl,
4597
+ name: result.name,
4598
+ message: result.message,
4599
+ ...facts,
4600
+ fps: result.fps,
4601
+ loop: result.loop,
4602
+ anchor: result.anchor,
4603
+ quotedGenerations: submitted.quoted_generations ?? null,
4604
+ usage,
4605
+ paths: written
4606
+ };
4607
+ }
4608
+ function vfxJobFailure(done) {
4609
+ for (const key of ["error", "detail", "message"]) {
4610
+ const value = done?.[key];
4611
+ if (typeof value === "string" && value) return `: ${value}`;
4612
+ }
4613
+ return "";
4614
+ }
4615
+ async function readVfxConversation(file, effectId) {
4616
+ if (!existsSync(file)) return void 0;
4617
+ try {
4618
+ const record = JSON.parse(await readFile(file, "utf8"));
4619
+ return record.effectId === effectId && typeof record.conversation === "string" ? record.conversation : void 0;
4620
+ } catch {
4621
+ return void 0;
4622
+ }
4623
+ }
4624
+
4625
+ // src/source-url.ts
4626
+ var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
4627
+ "access_token",
4628
+ "apikey",
4629
+ "api_key",
4630
+ "auth",
4631
+ "authorization",
4632
+ "awsaccesskeyid",
4633
+ "googleaccessid",
4634
+ "key",
4635
+ "sig"
4636
+ ]);
4637
+ function isSensitiveSourceUrl(value) {
4638
+ let parsed;
4639
+ try {
4640
+ parsed = new URL(value);
4641
+ } catch {
4642
+ return false;
4643
+ }
4644
+ if (parsed.username || parsed.password) return true;
4645
+ for (const key of parsed.searchParams.keys()) {
4646
+ const normalized = key.toLowerCase();
4647
+ if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
4648
+ }
4649
+ return false;
4650
+ }
4651
+ function shouldPersistSourceUrl(value) {
4652
+ let parsed;
4653
+ try {
4654
+ parsed = new URL(value);
4655
+ } catch {
4656
+ return true;
4657
+ }
4658
+ if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
4659
+ return !isSensitiveSourceUrl(value);
4660
+ }
4661
+
4662
+ // src/pixellab-utils.ts
4663
+ import { existsSync as existsSync2 } from "fs";
4664
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
4665
+ import path2 from "path";
4666
+ var UNZOOM_MIN_SIDE = 256;
4667
+ var UNZOOM_MAX_AREA = 2048 * 2048;
4668
+ function defaultUnzoomOut(input) {
4669
+ const ext = path2.extname(input);
4670
+ return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
4671
+ }
4672
+ async function unzoomFile(client2, input, opts = {}) {
4673
+ const out = path2.resolve(opts.out ?? defaultUnzoomOut(input));
4674
+ if (path2.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
4675
+ if (existsSync2(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
4676
+ const bytes = await readFile2(input);
4677
+ const metadata = imageMetadata(bytes);
4678
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
4679
+ const { width, height, format } = metadata;
4680
+ if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
4681
+ throw new Error(
4682
+ `${input} is ${width}x${height}; PixelLab's unzoom needs at least ${UNZOOM_MIN_SIDE}x${UNZOOM_MIN_SIDE} to find the pixel grid, so this is probably already at its native size`
4683
+ );
4684
+ }
4685
+ if (width * height > UNZOOM_MAX_AREA) {
4686
+ throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
4687
+ }
4688
+ const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
4689
+ await mkdir2(path2.dirname(out), { recursive: true });
4690
+ await writeFile2(out, res.png);
4691
+ return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
4692
+ }
4693
+ var FONT_WEIGHTS = ["Bold", "Regular"];
4694
+ var FONT_GLYPH_SIZES = [8, 16, 32, 64];
4695
+ var FONT_COST_GENERATIONS = 15;
4696
+ function fontOutputPaths(out) {
4697
+ const base = path2.resolve(out.replace(/\.(ttf|png)$/i, ""));
4698
+ return { ttf: `${base}.ttf`, atlas: `${base}.png` };
4699
+ }
4700
+ async function generateFont(client2, opts) {
4701
+ const { ttf, atlas } = fontOutputPaths(opts.out);
4702
+ if (!opts.force) {
4703
+ for (const file of [ttf, atlas]) {
4704
+ if (existsSync2(file)) throw new Error(`${file} already exists; pass --force to replace it`);
4705
+ }
4706
+ }
4707
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
4708
+ const interval = opts.intervalMs ?? 5e3;
4709
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
4710
+ const submitted = await client2.generateFontPro({
4711
+ description: opts.description,
4712
+ weight: opts.weight,
4713
+ glyphPx: opts.glyphPx,
4714
+ seed: opts.seed,
4715
+ fontName: opts.fontName
4716
+ });
4717
+ const jobId = submitted.background_job_id;
4718
+ const started = Date.now();
4719
+ for (; ; ) {
4720
+ const job = await client2.getFontJob(jobId);
4721
+ opts.onProgress?.(job.status);
4722
+ if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
4723
+ if (job.status === "completed") {
4724
+ if (!job.download_ttf_url || !job.download_atlas_url) {
4725
+ throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
4726
+ }
4727
+ const [ttfBytes, atlasBytes] = await Promise.all([
4728
+ client2.download(job.download_ttf_url),
4729
+ client2.download(job.download_atlas_url)
4730
+ ]);
4731
+ await mkdir2(path2.dirname(ttf), { recursive: true });
4732
+ await writeFile2(ttf, ttfBytes);
4733
+ await writeFile2(atlas, atlasBytes);
4734
+ return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
4735
+ }
4736
+ if (Date.now() - started > timeout) {
4737
+ throw new Error(
4738
+ `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
4739
+ );
4740
+ }
4741
+ await sleep3(interval);
4742
+ }
4743
+ }
4744
+ var PIXELATE_COST_GENERATIONS = 6;
4745
+ var PIXELATE_MAX_SIDE = 2048;
4746
+ function defaultPixelateOut(input) {
4747
+ const ext = path2.extname(input);
4748
+ return `${ext ? input.slice(0, -ext.length) : input}.pixel.png`;
4749
+ }
4750
+ async function pixelateFile(client2, input, opts = {}) {
4751
+ const out = path2.resolve(opts.out ?? defaultPixelateOut(input));
4752
+ if (path2.resolve(input) === out) throw new Error("pixelate will not overwrite its own input; pass a different --out");
4753
+ if (existsSync2(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
4754
+ const bytes = await readFile2(input);
4755
+ const metadata = imageMetadata(bytes);
4756
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
4757
+ const { width, height, format } = metadata;
4758
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
4759
+ const interval = opts.intervalMs ?? 5e3;
4760
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
4761
+ const submitted = await client2.imageToPixelartProFlash({
4762
+ image: { base64: bytes.toString("base64"), format },
4763
+ description: opts.description,
4764
+ seed: opts.seed
4765
+ });
4766
+ const jobId = submitted.background_job_id;
4767
+ const started = Date.now();
4768
+ for (; ; ) {
4769
+ const job = await client2.getBackgroundJob(jobId);
4770
+ opts.onProgress?.(job.status);
4771
+ if (job.status === "failed") throw new Error(`PixelLab pixelate job ${jobId} failed upstream`);
4772
+ if (job.status === "completed") {
4773
+ const images = job.last_response?.images;
4774
+ const first = Array.isArray(images) ? images[0] : void 0;
4775
+ if (!first || typeof first.base64 !== "string" || !first.base64) {
4776
+ throw new Error(`PixelLab pixelate job ${jobId} completed without an image at last_response.images[0]`);
4777
+ }
4778
+ const png = Buffer.from(first.base64, "base64");
4779
+ const size = imageMetadata(png);
4780
+ await mkdir2(path2.dirname(out), { recursive: true });
4781
+ await writeFile2(out, png);
4782
+ return {
4783
+ jobId,
4784
+ out,
4785
+ original: { width, height },
4786
+ pixelated: { width: size?.width ?? Number(first.width), height: size?.height ?? Number(first.height) },
4787
+ imageId: submitted.image_id,
4788
+ usage: job.usage ?? null
4789
+ };
4790
+ }
4791
+ if (Date.now() - started > timeout) {
4792
+ throw new Error(
4793
+ `PixelLab pixelate job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
4794
+ );
4795
+ }
4796
+ await sleep3(interval);
4797
+ }
4798
+ }
4799
+ var NORMAL_MAP_COST_GENERATIONS = 0.5;
4800
+ var NORMAL_MAP_MIN_SIDE = 8;
4801
+ var NORMAL_MAP_MAX_SIDE = 512;
4802
+ var NORMAL_MAP_MAX_FRAMES = 32;
4803
+ var NORMAL_MAP_CONVENTIONS = ["opengl", "directx"];
4804
+ function normalMapSizeProblem(width, height, frames = 1) {
4805
+ if (!Number.isInteger(frames) || frames < 1 || frames > NORMAL_MAP_MAX_FRAMES) {
4806
+ return `PixelLab's normal map takes 1 to ${NORMAL_MAP_MAX_FRAMES} frames, not ${frames}`;
4807
+ }
4808
+ if (width % frames !== 0) return `a ${width}px-wide strip does not split into ${frames} equal frames`;
4809
+ const frameWidth = width / frames;
4810
+ if (frameWidth < NORMAL_MAP_MIN_SIDE || height < NORMAL_MAP_MIN_SIDE || frameWidth > NORMAL_MAP_MAX_SIDE || height > NORMAL_MAP_MAX_SIDE) {
4811
+ return `${frames > 1 ? "each frame is " : "it is "}${frameWidth}x${height}; PixelLab's normal map takes ${NORMAL_MAP_MIN_SIDE} to ${NORMAL_MAP_MAX_SIDE} px per side`;
4812
+ }
4813
+ return null;
4814
+ }
4815
+ function defaultNormalMapOut(input) {
4816
+ const ext = path2.extname(input);
4817
+ return `${ext ? input.slice(0, -ext.length) : input}.normal.png`;
4818
+ }
4819
+ function normalMapResultPng(lastResponse) {
4820
+ const image = lastResponse?.image;
4821
+ const base64 = typeof image === "string" ? image : image?.base64;
4822
+ if (typeof base64 !== "string" || !base64) return null;
4823
+ return Buffer.from(base64.replace(/^data:image\/\w+;base64,/, ""), "base64");
4824
+ }
4825
+ async function normalMapFile(client2, input, opts = {}) {
4826
+ const out = path2.resolve(opts.out ?? defaultNormalMapOut(input));
4827
+ if (path2.resolve(input) === out) throw new Error("normal-map will not overwrite its own input; pass a different --out");
4828
+ if (existsSync2(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
4829
+ const bytes = await readFile2(input);
4830
+ const metadata = imageMetadata(bytes);
4831
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
4832
+ if (metadata.format !== "png") throw new Error(`${input} is a JPEG; normal-map reads a PNG, whose alpha marks where the sprite is`);
4833
+ const { width, height } = metadata;
4834
+ const frames = opts.frames ?? 1;
4835
+ const problem = normalMapSizeProblem(width, height, frames);
4836
+ if (problem) throw new Error(`${input}: ${problem}`);
4837
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
4838
+ const interval = opts.intervalMs ?? 5e3;
4839
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
4840
+ const submitted = await client2.normalMap({
4841
+ image: { base64: bytes.toString("base64"), format: "png" },
4842
+ frames,
4843
+ convention: opts.convention
4844
+ });
4845
+ const jobId = submitted.background_job_id;
4846
+ const started = Date.now();
4847
+ for (; ; ) {
4848
+ const job = await client2.getBackgroundJob(jobId);
4849
+ opts.onProgress?.(job.status);
4850
+ if (job.status === "failed") throw new Error(`PixelLab normal-map job ${jobId} failed upstream`);
4851
+ if (job.status === "completed") {
4852
+ const png = normalMapResultPng(job.last_response);
4853
+ if (!png) throw new Error(`PixelLab normal-map job ${jobId} completed without an image at last_response.image`);
4854
+ await mkdir2(path2.dirname(out), { recursive: true });
4855
+ await writeFile2(out, png);
4856
+ const size = imageMetadata(png);
4857
+ return {
4858
+ jobId,
4859
+ out,
4860
+ width: size?.width ?? width,
4861
+ height: size?.height ?? height,
4862
+ frames,
4863
+ convention: opts.convention ?? "opengl",
4864
+ usage: job.usage ?? null
4865
+ };
4866
+ }
4867
+ if (Date.now() - started > timeout) {
4868
+ throw new Error(
4869
+ `PixelLab normal-map job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
4870
+ );
4871
+ }
4872
+ await sleep3(interval);
4873
+ }
4874
+ }
4071
4875
 
4072
4876
  // src/providers/pixellab.ts
4073
4877
  function pixelLabObjectUrl(generator, objectId) {
@@ -4086,6 +4890,20 @@ function pixelLabObjectUrl(generator, objectId) {
4086
4890
  if (generator === "vfx") return `https://pixellab.ai/create-vfx?effect=${encodeURIComponent(objectId)}`;
4087
4891
  return null;
4088
4892
  }
4893
+ function vfxArtistSpecQuote(spec) {
4894
+ const sprite = spec.vfx?.sprite;
4895
+ const side = !sprite ? "none" : "file" in sprite && sprite.width != null && sprite.height != null ? Math.max(sprite.width, sprite.height) : "unknown";
4896
+ return vfxArtistQuote(spec.vfx?.artist ?? "lite", side);
4897
+ }
4898
+ function workbenchVfxUrls(exported) {
4899
+ const sheetFile = exported.files.find((file) => /\.png$/i.test(file));
4900
+ const atlasFile = exported.files.find((file) => /\.json$/i.test(file));
4901
+ if (!sheetFile) throw new Error(`PixelLab's vfx export listed no spritesheet: ${exported.files.join(", ")}`);
4902
+ return {
4903
+ sheet: `${exported.downloadBase}${encodeURIComponent(sheetFile)}`,
4904
+ ...atlasFile ? { atlas: `${exported.downloadBase}${encodeURIComponent(atlasFile)}` } : {}
4905
+ };
4906
+ }
4089
4907
  function billedFromUsage(usage) {
4090
4908
  if (!usage) return null;
4091
4909
  if (usage.type === "usd" || usage.type === void 0 && typeof usage.usd === "number") {
@@ -4240,13 +5058,14 @@ var PixelLabProvider = class _PixelLabProvider {
4240
5058
  * source from a supplied per-frame skeleton instead of a text description)
4241
5059
  * — and `interpolate` (`/interpolation-v2`, the in-betweens
4242
5060
  * from one keyframe to another) and `edit-animation`
4243
- * (`/edit-animation-v2`, one text edit applied across a whole frame set).
5061
+ * (`/edit-animation-v2`, one text edit applied across a whole frame set),
5062
+ * and `normal-map` (`/normal-map`, lighting data for a sprite or a loop).
4244
5063
  * `outpaint` does not: there is no canvas-expansion endpoint in the API,
4245
5064
  * matching docs/REVISIONS.md's note that no provider ships a tested
4246
5065
  * outpaint path yet.
4247
5066
  */
4248
5067
  supportsRevision(mode2) {
4249
- return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "remove-background" || mode2 === "animate" || mode2 === "animate-pixminimax" || mode2 === "animate-skeleton" || mode2 === "interpolate" || mode2 === "edit-animation";
5068
+ return mode2 === "inpaint" || mode2 === "image-to-image" || mode2 === "reduce-colors" || mode2 === "correct-pixelart" || mode2 === "remove-background" || mode2 === "normal-map" || mode2 === "animate" || mode2 === "animate-pixminimax" || mode2 === "animate-skeleton" || mode2 === "interpolate" || mode2 === "edit-animation";
4250
5069
  }
4251
5070
  /** PixelLab's own constraints: submissions must be >2s apart, and
4252
5071
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -4266,11 +5085,12 @@ var PixelLabProvider = class _PixelLabProvider {
4266
5085
  * is a file read.
4267
5086
  */
4268
5087
  static cacheDir(namespace = "pixflux") {
4269
- const dir = path2.join(os.tmpdir(), `pixelkiln-${namespace}`);
5088
+ const dir = path3.join(os.tmpdir(), `pixelkiln-${namespace}`);
4270
5089
  mkdirSync(dir, { recursive: true });
4271
5090
  return dir;
4272
5091
  }
4273
5092
  estimate(spec) {
5093
+ if (spec.vfx?.description) return { unit: "generations", amount: vfxArtistCeiling(vfxArtistSpecQuote(spec)), candidates: 1 };
4274
5094
  if (spec.vfx) return { unit: "generations", amount: 0, candidates: 1 };
4275
5095
  if (spec.batch?.role === "member") {
4276
5096
  return { unit: "generations", amount: 0, candidates: 1 };
@@ -4278,6 +5098,9 @@ var PixelLabProvider = class _PixelLabProvider {
4278
5098
  if (spec.revision?.mode === "remove-background") {
4279
5099
  return { unit: "generations", amount: 1, candidates: 1 };
4280
5100
  }
5101
+ if (spec.revision?.mode === "normal-map") {
5102
+ return { unit: "generations", amount: NORMAL_MAP_COST_GENERATIONS, candidates: 1 };
5103
+ }
4281
5104
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
4282
5105
  return { unit: "generations", amount: 0.1, candidates: 1 };
4283
5106
  }
@@ -4332,12 +5155,40 @@ var PixelLabProvider = class _PixelLabProvider {
4332
5155
  if (spec.generator === "vfx" && styleImages.length) {
4333
5156
  throw new Error("PixelLab vfx takes no style images; an effect's look comes from its preset, parts, or recipe");
4334
5157
  }
5158
+ const sprite = spec.vfx?.description ? spec.vfx.sprite : void 0;
5159
+ if (sprite && "reference" in sprite) {
5160
+ throw new Error("PixelLab's VFX artist takes a sprite's pixels, not a PixelLab reference; point vfx.sprite at a PNG file");
5161
+ }
5162
+ if (sprite?.width != null && sprite.height != null && Math.max(sprite.width, sprite.height) > VFX_ARTIST_MAX_SPRITE) {
5163
+ throw new Error(
5164
+ `vfx sprite ${sprite.file} is ${sprite.width}x${sprite.height}; PixelLab's VFX artist takes up to ${VFX_ARTIST_MAX_SPRITE}x${VFX_ARTIST_MAX_SPRITE} (crop or pad it; never rescale pixel art)`
5165
+ );
5166
+ }
4335
5167
  const members = spec.revision?.sourceMembers;
4336
5168
  if (spec.revision && members && !MEMBER_SET_REVISION_MODES.includes(spec.revision.mode)) {
4337
5169
  throw new Error(
4338
- `PixelLab ${spec.revision.mode} reads one source image; ${spec.revision.sourceAssetId} is a ${members.length}-member set (${members[0].role}, ...). Revise one member through a declared source, or use reduce-colors, correct-pixelart, or edit-animation, which take the whole set`
5170
+ `PixelLab ${spec.revision.mode} reads one source image; ${spec.revision.sourceAssetId} is a ${members.length}-member set (${members[0].role}, ...). Revise one member through a declared source, or use reduce-colors, correct-pixelart, edit-animation, or normal-map, which take the whole set`
4339
5171
  );
4340
5172
  }
5173
+ if (spec.revision?.mode === "normal-map") {
5174
+ const { sourceWidth: width, sourceHeight: height, sourceFormat, sourceAssetId } = spec.revision;
5175
+ if (members && !members.every((member) => member.role.startsWith("frame-"))) {
5176
+ throw new Error(
5177
+ `PixelLab normal-map reads one sprite or one animation loop; ${sourceAssetId} is a set of directions (${members.map((member) => member.role).join(", ")}). Make one normal-map revision per direction, each from an asset whose \`source\` is that direction's file`
5178
+ );
5179
+ }
5180
+ if (members && members.length > NORMAL_MAP_MAX_FRAMES) {
5181
+ throw new Error(
5182
+ `PixelLab normal-map takes at most ${NORMAL_MAP_MAX_FRAMES} frames in one strip; ${sourceAssetId} has ${members.length}`
5183
+ );
5184
+ }
5185
+ if (sourceFormat === "jpeg") {
5186
+ throw new Error(`PixelLab normal-map reads a PNG, whose alpha marks where the sprite is; ${sourceAssetId} is a JPEG`);
5187
+ }
5188
+ const frames = members?.length ?? 1;
5189
+ const problem = width != null && height != null ? normalMapSizeProblem(width * frames, height, frames) : null;
5190
+ if (problem) throw new Error(`PixelLab normal-map source ${sourceAssetId}: ${problem}`);
5191
+ }
4341
5192
  if (spec.revision?.engine === "pro-flash") {
4342
5193
  const { sourceWidth: width, sourceHeight: height, mode: mode2 } = spec.revision;
4343
5194
  if (width != null && height != null && (width < 32 || height < 32 || width > 256 || height > 256 || width % 4 || height % 4)) {
@@ -4445,6 +5296,9 @@ var PixelLabProvider = class _PixelLabProvider {
4445
5296
  `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)`
4446
5297
  );
4447
5298
  }
5299
+ if (spec.generator === "imagePro" && !spec.revision && spec.palette.length && (spec.palette.length < 2 || spec.palette.length > 256)) {
5300
+ throw new Error(`PixelLab imagePro's target palette takes 2 to 256 colours; the style's palette has ${spec.palette.length}`);
5301
+ }
4448
5302
  if (spec.generator === "uiAsset" && (spec.width < 192 || spec.height < 192 || spec.width > 688 || spec.height > 688)) {
4449
5303
  throw new Error(
4450
5304
  `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)`
@@ -4511,24 +5365,8 @@ var PixelLabProvider = class _PixelLabProvider {
4511
5365
  if (spec.detail) {
4512
5366
  requirePixelLabOption("terrain", "detail", spec.detail, ["low detail", "medium detail", "highly detailed"]);
4513
5367
  }
4514
- if (spec.terrainShapeStyle && spec.terrainMode === "pro") {
4515
- throw new Error(
4516
- "PixelLab terrain: terrainShapeStyle is standard-mode only; pro's own shape controls are terrainSpreadX/terrainSlopeSize/terrainRaggedness"
4517
- );
4518
- }
4519
- if (spec.terrainTileSize === 64 && spec.terrainMode !== "pro") {
4520
- throw new Error('PixelLab terrain: a 64px tile needs terrainMode: "pro"');
4521
- }
4522
- if (spec.terrainShapeStyle && spec.terrainTransitionSize != null && spec.terrainTransitionSize > 0.5) {
4523
- throw new Error(
4524
- "PixelLab terrain: terrainShapeStyle with terrainTransitionSize above 0.5 uses an extended 32-tile layout pixelkiln does not model; use 0, 0.25, or 0.5"
4525
- );
4526
- }
4527
- if (!spec.terrainShapeStyle && spec.terrainTransitionSize != null && ![0, 0.25, 0.5, 1].includes(spec.terrainTransitionSize)) {
4528
- throw new Error(
4529
- "PixelLab terrain: terrainTransitionSize must be 0, 0.25, 0.5, or 1 unless terrainShapeStyle is set"
4530
- );
4531
- }
5368
+ if (spec.terrainOrientation === "sidescroller") this.validateSidescrollerTerrain(spec);
5369
+ else this.validateTopDownTerrain(spec);
4532
5370
  }
4533
5371
  if (spec.generator === "isometricTile") {
4534
5372
  if (spec.outline) {
@@ -4587,6 +5425,87 @@ var PixelLabProvider = class _PixelLabProvider {
4587
5425
  );
4588
5426
  }
4589
5427
  }
5428
+ /** `/create-tileset`'s own rules, beyond the enums both terrain endpoints share. */
5429
+ validateTopDownTerrain(spec) {
5430
+ if (spec.terrainShapeStyle && spec.terrainMode === "pro") {
5431
+ throw new Error(
5432
+ "PixelLab terrain: terrainShapeStyle is standard-mode only; pro's own shape controls are terrainRaggedness/terrainRoundness/terrainBoundaryAmplitude/terrainStepSlope"
5433
+ );
5434
+ }
5435
+ const pro = spec.terrainMode === "pro";
5436
+ if (spec.terrainRoundness != null && !pro && !spec.terrainShapeStyle) {
5437
+ throw new Error('PixelLab terrain: terrainRoundness needs terrainMode: "pro" or a terrainShapeStyle');
5438
+ }
5439
+ if (spec.terrainRoundness != null && spec.terrainRoundness > 1 && !pro) {
5440
+ throw new Error('PixelLab terrain: terrainRoundness above 1 (corners cut toward 45\xB0) needs terrainMode: "pro"');
5441
+ }
5442
+ if (spec.terrainBoundaryAmplitude != null && !pro && !spec.terrainShapeStyle) {
5443
+ throw new Error('PixelLab terrain: terrainBoundaryAmplitude needs terrainMode: "pro" or a terrainShapeStyle');
5444
+ }
5445
+ if (spec.terrainBoundarySeed != null && spec.terrainBoundaryAmplitude == null) {
5446
+ throw new Error("PixelLab terrain: terrainBoundarySeed shapes terrainBoundaryAmplitude's border; set both or neither");
5447
+ }
5448
+ if (spec.terrainStepSlope != null && !(pro && spec.terrainTransitionSize === 0.25)) {
5449
+ throw new Error('PixelLab terrain: terrainStepSlope needs terrainMode: "pro" with terrainTransitionSize 0.25');
5450
+ }
5451
+ if (spec.terrainConnectDiagonals != null && !pro) {
5452
+ throw new Error('PixelLab terrain: terrainConnectDiagonals needs terrainMode: "pro"');
5453
+ }
5454
+ if (spec.terrainTileSize === 64 && spec.terrainMode !== "pro") {
5455
+ throw new Error('PixelLab terrain: a 64px tile needs terrainMode: "pro"');
5456
+ }
5457
+ if (spec.terrainShapeStyle && spec.terrainTransitionSize != null && spec.terrainTransitionSize > 0.5) {
5458
+ throw new Error(
5459
+ "PixelLab terrain: terrainShapeStyle with terrainTransitionSize above 0.5 uses an extended 32-tile layout pixelkiln does not model; use 0, 0.25, or 0.5"
5460
+ );
5461
+ }
5462
+ if (!spec.terrainShapeStyle && spec.terrainTransitionSize != null && ![0, 0.25, 0.5, 1].includes(spec.terrainTransitionSize)) {
5463
+ throw new Error(
5464
+ "PixelLab terrain: terrainTransitionSize must be 0, 0.25, 0.5, or 1 unless terrainShapeStyle is set"
5465
+ );
5466
+ }
5467
+ }
5468
+ /**
5469
+ * `/create-tileset-sidescroller` takes a platform, its top layer, and the
5470
+ * layer's height, and nothing of the top-down endpoint's mode, camera, or
5471
+ * shape controls; sending one would be silently dropped, so it is refused.
5472
+ */
5473
+ validateSidescrollerTerrain(spec) {
5474
+ const topDownOnly = {
5475
+ terrainMode: spec.terrainMode,
5476
+ terrainShapeStyle: spec.terrainShapeStyle,
5477
+ terrainView: spec.terrainView,
5478
+ terrainSpreadX: spec.terrainSpreadX,
5479
+ terrainSlopeSize: spec.terrainSlopeSize,
5480
+ terrainRaggedness: spec.terrainRaggedness,
5481
+ terrainRoundness: spec.terrainRoundness,
5482
+ terrainBoundaryAmplitude: spec.terrainBoundaryAmplitude,
5483
+ terrainBoundarySeed: spec.terrainBoundarySeed,
5484
+ terrainStepSlope: spec.terrainStepSlope,
5485
+ terrainConnectDiagonals: spec.terrainConnectDiagonals
5486
+ };
5487
+ const set = Object.entries(topDownOnly).filter(([, value]) => value != null).map(([field]) => field);
5488
+ if (set.length) {
5489
+ throw new Error(`PixelLab terrain: a sidescroller set takes no ${set.join(", ")}; those are top-down controls`);
5490
+ }
5491
+ if (spec.terrainTileSize !== 16 && spec.terrainTileSize !== 32) {
5492
+ throw new Error(`PixelLab terrain: a sidescroller set's tiles are 16 or 32px, got ${spec.terrainTileSize}`);
5493
+ }
5494
+ if (spec.terrainTransitionSize != null && ![0, 0.25, 0.5, 1].includes(spec.terrainTransitionSize)) {
5495
+ throw new Error("PixelLab terrain: a sidescroller set's terrainTransitionSize must be 0, 0.25, 0.5, or 1");
5496
+ }
5497
+ if (spec.terrainConnect?.upper) {
5498
+ throw new Error(
5499
+ "PixelLab terrain: a sidescroller set's upper terrain is empty air; only its lower (platform) terrain can connect"
5500
+ );
5501
+ }
5502
+ const link = spec.terrainConnect?.lower;
5503
+ if (link?.terrain === "upper") {
5504
+ throw new Error(
5505
+ `PixelLab terrain: ${link.assetId}'s upper terrain is empty air; connect to its lower (platform) terrain`
5506
+ );
5507
+ }
5508
+ }
4590
5509
  validateCharacter(spec, styleImages = []) {
4591
5510
  const character = spec.character;
4592
5511
  if (!character) throw new Error(`${spec.styleId}/${spec.assetId} has no character shape`);
@@ -4616,7 +5535,7 @@ var PixelLabProvider = class _PixelLabProvider {
4616
5535
  }
4617
5536
  }
4618
5537
  if (endFrame) {
4619
- const start = startFrame ?? (character.parentFile && existsSync2(character.parentFile) ? imageMetadata(readFileSync(character.parentFile)) : null);
5538
+ const start = startFrame ?? (character.parentFile && existsSync3(character.parentFile) ? imageMetadata(readFileSync(character.parentFile)) : null);
4620
5539
  if (start && (start.width !== endFrame.width || start.height !== endFrame.height)) {
4621
5540
  throw new Error(
4622
5541
  `${label}: end frame is ${endFrame.width}x${endFrame.height} but the ${startFrame ? "start frame" : "character's rotation"} is ${start.width}x${start.height}; PixelLab interpolates between frames of one size`
@@ -4774,7 +5693,7 @@ var PixelLabProvider = class _PixelLabProvider {
4774
5693
  }
4775
5694
  }
4776
5695
  if (endFrame) {
4777
- const start = startFrame ?? (object.parentFile && existsSync2(object.parentFile) ? imageMetadata(readFileSync(object.parentFile)) : null);
5696
+ const start = startFrame ?? (object.parentFile && existsSync3(object.parentFile) ? imageMetadata(readFileSync(object.parentFile)) : null);
4778
5697
  if (start && (start.width !== endFrame.width || start.height !== endFrame.height)) {
4779
5698
  throw new Error(
4780
5699
  `${label}: end frame is ${endFrame.width}x${endFrame.height} but the ${startFrame ? "start frame" : "object's rotation"} is ${start.width}x${start.height}; PixelLab interpolates between frames of one size`
@@ -5084,7 +6003,7 @@ var PixelLabProvider = class _PixelLabProvider {
5084
6003
  const sources = images.map((image, index) => {
5085
6004
  if (!image.base64) throw new Error(`outfit transfer frame ${index} has no image data`);
5086
6005
  const role = meta?.frameRoles?.[index] ?? void 0;
5087
- const file = path2.join(dir, `${jobId}-${index}.png`);
6006
+ const file = path3.join(dir, `${jobId}-${index}.png`);
5088
6007
  writeFileSync(file, Buffer.from(image.base64, "base64"));
5089
6008
  return { url: `file://${file}`, ...role ? { role } : {} };
5090
6009
  });
@@ -5290,7 +6209,7 @@ var PixelLabProvider = class _PixelLabProvider {
5290
6209
  seed: spec.seed
5291
6210
  });
5292
6211
  const jobId = randomUUID();
5293
- writeFileSync(path2.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), png);
6212
+ writeFileSync(path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), png);
5294
6213
  return { jobId };
5295
6214
  }
5296
6215
  if (spec.generator === "tiles") {
@@ -5319,17 +6238,46 @@ var PixelLabProvider = class _PixelLabProvider {
5319
6238
  });
5320
6239
  return { jobId: res2.tile_id, metadata: { backgroundJobId: res2.background_job_id } };
5321
6240
  }
6241
+ if (spec.generator === "terrain" && spec.terrainOrientation === "sidescroller") {
6242
+ const baseTileIds = await this.terrainBaseTileIds(context?.terrainLinks);
6243
+ const res2 = await this.client.createTilesetSidescroller({
6244
+ lowerDescription: spec.terrainLowerDescription,
6245
+ transitionDescription: spec.terrainTransitionDescription,
6246
+ lowerBaseTileId: baseTileIds.lower,
6247
+ tileSize: spec.terrainTileSize,
6248
+ transitionSize: spec.terrainTransitionSize,
6249
+ outline: spec.outline,
6250
+ shading: spec.shading,
6251
+ detail: spec.detail,
6252
+ seed: spec.seed
6253
+ });
6254
+ return {
6255
+ jobId: res2.tileset_id,
6256
+ metadata: {
6257
+ backgroundJobId: res2.background_job_id,
6258
+ ...Object.keys(baseTileIds).length ? { baseTileIds } : {}
6259
+ }
6260
+ };
6261
+ }
5322
6262
  if (spec.generator === "terrain") {
6263
+ const baseTileIds = await this.terrainBaseTileIds(context?.terrainLinks);
5323
6264
  const res2 = await this.client.createTileset({
5324
6265
  lowerDescription: spec.terrainLowerDescription,
5325
6266
  upperDescription: spec.terrainUpperDescription,
5326
6267
  transitionDescription: spec.terrainTransitionDescription,
6268
+ lowerBaseTileId: baseTileIds.lower,
6269
+ upperBaseTileId: baseTileIds.upper,
5327
6270
  tileSize: spec.terrainTileSize,
5328
6271
  mode: spec.terrainMode,
5329
6272
  shapeStyle: spec.terrainShapeStyle,
5330
6273
  spreadX: spec.terrainSpreadX,
5331
6274
  slopeSize: spec.terrainSlopeSize,
5332
6275
  raggedness: spec.terrainRaggedness,
6276
+ roundness: spec.terrainRoundness,
6277
+ boundaryAmplitude: spec.terrainBoundaryAmplitude,
6278
+ boundarySeed: spec.terrainBoundarySeed,
6279
+ stepSlope: spec.terrainStepSlope,
6280
+ connectDiagonals: spec.terrainConnectDiagonals,
5333
6281
  transitionSize: spec.terrainTransitionSize,
5334
6282
  view: spec.terrainView,
5335
6283
  outline: spec.outline,
@@ -5337,7 +6285,13 @@ var PixelLabProvider = class _PixelLabProvider {
5337
6285
  detail: spec.detail,
5338
6286
  seed: spec.seed
5339
6287
  });
5340
- return { jobId: res2.tileset_id, metadata: { backgroundJobId: res2.background_job_id } };
6288
+ return {
6289
+ jobId: res2.tileset_id,
6290
+ metadata: {
6291
+ backgroundJobId: res2.background_job_id,
6292
+ ...Object.keys(baseTileIds).length ? { baseTileIds } : {}
6293
+ }
6294
+ };
5341
6295
  }
5342
6296
  if (spec.generator === "isometricTile") {
5343
6297
  const res2 = await this.client.createIsometricTile({
@@ -5359,7 +6313,8 @@ var PixelLabProvider = class _PixelLabProvider {
5359
6313
  width: spec.width,
5360
6314
  height: spec.height,
5361
6315
  noBackground: spec.noBackground,
5362
- seed: spec.seed
6316
+ seed: spec.seed,
6317
+ targetPalette: { colors: spec.palette, name: spec.paletteName, maxColors: spec.maxColors }
5363
6318
  });
5364
6319
  return { jobId: res2.background_job_id };
5365
6320
  }
@@ -5502,6 +6457,14 @@ var PixelLabProvider = class _PixelLabProvider {
5502
6457
  });
5503
6458
  return this.cacheCleanupResult([res2.png], res2.usage, roles, revision.sourceFps);
5504
6459
  }
6460
+ if (revision.mode === "normal-map") {
6461
+ const strip = roles ? { base64: joinStripPng(images.map((member) => Buffer.from(member.base64, "base64"))).toString("base64"), format: "png" } : image;
6462
+ const res2 = await this.client.normalMap({ image: strip, frames: roles?.length ?? 1, convention: revision.convention });
6463
+ return {
6464
+ jobId: res2.background_job_id,
6465
+ metadata: roles ? { revisionMembers: roles, ...revision.sourceFps != null ? { revisionFps: revision.sourceFps } : {} } : {}
6466
+ };
6467
+ }
5505
6468
  if (revision.mode === "interpolate") {
5506
6469
  const lastFrame = this.readLastFrame(spec);
5507
6470
  if (!lastFrame) throw new Error(`${spec.styleId}/${spec.assetId}: interpolate needs lastFrame, the ending keyframe`);
@@ -5619,11 +6582,11 @@ var PixelLabProvider = class _PixelLabProvider {
5619
6582
  cacheCleanupResult(pngs, usage, roles, fps) {
5620
6583
  const jobId = randomUUID();
5621
6584
  if (!roles) {
5622
- writeFileSync(path2.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
6585
+ writeFileSync(path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
5623
6586
  return { jobId, metadata: { revisionUsage: usage } };
5624
6587
  }
5625
6588
  for (const [index, png] of pngs.entries()) {
5626
- writeFileSync(path2.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
6589
+ writeFileSync(path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
5627
6590
  }
5628
6591
  return { jobId, metadata: { revisionUsage: usage, revisionMembers: roles, ...fps != null ? { revisionFps: fps } : {} } };
5629
6592
  }
@@ -5647,6 +6610,7 @@ var PixelLabProvider = class _PixelLabProvider {
5647
6610
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart" || revisionMode === "remove-background") {
5648
6611
  return this.pollCachedRevision(jobId, context);
5649
6612
  }
6613
+ if (revisionMode === "normal-map") return this.pollNormalMap(jobId, context);
5650
6614
  if (revisionMode === "animate" || revisionMode === "animate-pixminimax" || revisionMode === "animate-skeleton" || revisionMode === "interpolate" || revisionMode === "edit-animation") {
5651
6615
  return this.pollAnimateRevision(jobId, context);
5652
6616
  }
@@ -5654,8 +6618,8 @@ var PixelLabProvider = class _PixelLabProvider {
5654
6618
  if (generator === "vfx") return this.pollVfx(jobId, context);
5655
6619
  if (generator === "imageProFlash") return this.pollRevision(jobId);
5656
6620
  if (generator === "pixflux") {
5657
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
5658
- if (existsSync2(file)) {
6621
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6622
+ if (existsSync3(file)) {
5659
6623
  const sourceUrl = `file://${file}`;
5660
6624
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }] };
5661
6625
  }
@@ -5711,13 +6675,14 @@ var PixelLabProvider = class _PixelLabProvider {
5711
6675
  */
5712
6676
  async submitVfx(spec, context) {
5713
6677
  const vfx = spec.vfx;
6678
+ if (vfx.description) return this.submitVfxArtist(spec, context);
5714
6679
  if (!this.mcp) throw new Error("PixelLab vfx renders over PixelLab's MCP server, which this provider was built without");
5715
6680
  let sprite;
5716
6681
  if (vfx.sprite && "reference" in vfx.sprite) {
5717
6682
  sprite = vfx.sprite.reference;
5718
6683
  } else if (vfx.sprite) {
5719
6684
  const { file } = vfx.sprite;
5720
- if (!existsSync2(file)) throw new Error(`vfx sprite does not exist yet: ${file}`);
6685
+ if (!existsSync3(file)) throw new Error(`vfx sprite does not exist yet: ${file}`);
5721
6686
  const bytes = readFileSync(file);
5722
6687
  if (sha256(bytes) !== vfx.sprite.sha256) {
5723
6688
  throw new Error(`vfx sprite changed after the manifest was resolved: ${file}; run the command again`);
@@ -5765,50 +6730,137 @@ var PixelLabProvider = class _PixelLabProvider {
5765
6730
  // The effect exists from here on; record it before anything else can fail.
5766
6731
  onExported: (done, effectName) => context?.checkpoint({ jobId, complete: true, metadata: { vfx: facts(done, effectName) } })
5767
6732
  });
5768
- await this.cacheVfxFrames(jobId, exported);
6733
+ await this.cacheVfxFrames(jobId, exported, workbenchVfxUrls(exported));
5769
6734
  return { jobId, metadata: { vfx: { ...facts(exported, name), recipe } } };
5770
6735
  }
6736
+ /**
6737
+ * Ask PixelLab's VFX artist (`POST /vfx`) for a `vfx` description: a
6738
+ * plain background job, polled by `pollVfxArtist`. Every submission is a
6739
+ * new effect. The manifest's description is the whole effect, not a change
6740
+ * to an earlier one, so it is never sent with `effect_id`; the effect this
6741
+ * asset made last time stays in My effects and its id is recorded here.
6742
+ */
6743
+ async submitVfxArtist(spec, context) {
6744
+ const vfx = spec.vfx;
6745
+ let sprite;
6746
+ if (vfx.sprite && "reference" in vfx.sprite) {
6747
+ throw new Error("PixelLab's VFX artist takes a sprite's pixels, not a PixelLab reference; point vfx.sprite at a PNG file");
6748
+ } else if (vfx.sprite) {
6749
+ const { file } = vfx.sprite;
6750
+ if (!existsSync3(file)) throw new Error(`vfx sprite does not exist yet: ${file}`);
6751
+ const bytes = readFileSync(file);
6752
+ if (sha256(bytes) !== vfx.sprite.sha256) {
6753
+ throw new Error(`vfx sprite changed after the manifest was resolved: ${file}; run the command again`);
6754
+ }
6755
+ sprite = vfxArtistSprite(bytes, `vfx sprite ${file}`).image;
6756
+ }
6757
+ const previous = context?.replacedMetadata?.vfx ?? context?.lastGenerationMetadata?.vfx;
6758
+ const artist = vfx.artist ?? "lite";
6759
+ const submitted = await this.client.createVfx({ description: vfx.description, artist, sprite, name: vfx.name });
6760
+ return {
6761
+ jobId: submitted.background_job_id,
6762
+ metadata: {
6763
+ vfx: {
6764
+ artist,
6765
+ quotedGenerations: submitted.quoted_generations ?? null,
6766
+ ...typeof previous?.effectId === "string" ? { previousEffect: previous.effectId } : {}
6767
+ }
6768
+ }
6769
+ };
6770
+ }
5771
6771
  /** Cut an export's sheet into frames, with a sidecar `poll` reads without any lock metadata. */
5772
- async cacheVfxFrames(jobId, exported) {
5773
- const sheetFile = exported.files.find((file) => /\.png$/i.test(file));
5774
- const atlasFile = exported.files.find((file) => /\.json$/i.test(file));
5775
- if (!sheetFile) throw new Error(`PixelLab's vfx export listed no spritesheet: ${exported.files.join(", ")}`);
5776
- const sheet = await this.client.download(`${exported.downloadBase}${encodeURIComponent(sheetFile)}`);
5777
- const atlas = atlasFile ? JSON.parse((await this.client.download(`${exported.downloadBase}${encodeURIComponent(atlasFile)}`)).toString("utf8")) : void 0;
5778
- const frames = sliceVfxSheet(sheet, exported, atlas);
6772
+ async cacheVfxFrames(jobId, facts, urls, atlas) {
6773
+ const sheet = await this.client.download(urls.sheet);
6774
+ const frameAtlas = atlas ?? (urls.atlas ? JSON.parse((await this.client.download(urls.atlas)).toString("utf8")) : void 0);
6775
+ const frames = sliceVfxSheet(sheet, facts, frameAtlas);
5779
6776
  const dir = _PixelLabProvider.cacheDir("vfx");
5780
- frames.forEach((frame, index) => writeFileSync(path2.join(dir, `${jobId}-${index}.png`), frame));
6777
+ frames.forEach((frame, index) => writeFileSync(path3.join(dir, `${jobId}-${index}.png`), frame));
5781
6778
  writeFileSync(
5782
- path2.join(dir, `${jobId}.json`),
5783
- JSON.stringify({ effectId: exported.effectId, count: frames.length, fps: exported.fps, loop: exported.loop })
6779
+ path3.join(dir, `${jobId}.json`),
6780
+ JSON.stringify({ effectId: facts.effectId, count: frames.length, fps: facts.fps, loop: facts.loop })
5784
6781
  );
5785
6782
  }
5786
6783
  async pollVfx(jobId, context) {
5787
6784
  const dir = _PixelLabProvider.cacheDir("vfx");
5788
- const sidecar = path2.join(dir, `${jobId}.json`);
6785
+ const sidecar = path3.join(dir, `${jobId}.json`);
5789
6786
  const cached = () => {
5790
- if (!existsSync2(sidecar)) return null;
6787
+ if (!existsSync3(sidecar)) return null;
5791
6788
  const facts2 = JSON.parse(readFileSync(sidecar, "utf8"));
5792
- const files2 = Array.from({ length: facts2.count }, (_, index) => path2.join(dir, `${jobId}-${index}.png`));
5793
- return files2.every((file) => existsSync2(file)) ? { facts: facts2, files: files2 } : null;
6789
+ const files2 = Array.from({ length: facts2.count }, (_, index) => path3.join(dir, `${jobId}-${index}.png`));
6790
+ return files2.every((file) => existsSync3(file)) ? { facts: facts2, files: files2 } : null;
5794
6791
  };
5795
6792
  let found = cached();
5796
6793
  const recorded = context?.metadata?.vfx;
5797
- if (!found && recorded?.downloadBase && Array.isArray(recorded.files)) {
5798
- await this.cacheVfxFrames(jobId, recorded);
6794
+ let artistDone;
6795
+ if (!found && recorded?.artist) {
6796
+ if (recorded.fileUrls?.sheet && recorded.effectId) {
6797
+ await this.cacheVfxFrames(jobId, recorded, {
6798
+ sheet: recorded.fileUrls.sheet,
6799
+ atlas: recorded.fileUrls.atlas
6800
+ });
6801
+ } else {
6802
+ const job = await this.client.getBackgroundJob(jobId);
6803
+ if (job.status === "failed") return { status: "failed", error: `vfx job failed upstream${vfxJobFailure(job.last_response)}` };
6804
+ if (job.status !== "completed") return { status: "processing" };
6805
+ try {
6806
+ artistDone = await this.cacheVfxArtistResult(jobId, job.last_response ?? {}, recorded);
6807
+ } catch (error) {
6808
+ return { status: "failed", error: error instanceof Error ? error.message : String(error) };
6809
+ }
6810
+ artistDone.billed = billedFromUsage(job.usage);
6811
+ }
6812
+ found = cached();
6813
+ } else if (!found && recorded?.downloadBase && Array.isArray(recorded.files)) {
6814
+ const exported = recorded;
6815
+ await this.cacheVfxFrames(jobId, exported, workbenchVfxUrls(exported));
5799
6816
  found = cached();
5800
6817
  }
5801
6818
  if (!found) {
5802
- return { status: "failed", error: "vfx frames are no longer cached locally; re-run submit for this asset (free)" };
6819
+ return {
6820
+ status: "failed",
6821
+ error: recorded?.artist ? "vfx frames are no longer cached locally and their download links were not recorded; re-run submit for this asset (paid)" : "vfx frames are no longer cached locally; re-run submit for this asset (free)"
6822
+ };
5803
6823
  }
5804
6824
  const { facts, files } = found;
5805
6825
  const sources = files.length === 1 ? [{ url: `file://${files[0]}` }] : files.map((file, index) => ({ url: `file://${file}`, role: `frame-${String(index).padStart(2, "0")}` }));
6826
+ const metadata = {
6827
+ ...files.length > 1 ? { frameSet: { fps: facts.fps, count: files.length, loop: facts.loop } } : {},
6828
+ ...artistDone ? { vfx: artistDone.vfx } : {}
6829
+ };
5806
6830
  return {
5807
6831
  status: "ready",
5808
6832
  objectId: facts.effectId,
5809
6833
  sourceUrl: sources[0].url,
5810
6834
  sources,
5811
- ...files.length > 1 ? { metadata: { frameSet: { fps: facts.fps, count: files.length, loop: facts.loop } } } : {},
6835
+ ...Object.keys(metadata).length ? { metadata } : {},
6836
+ billed: artistDone?.billed ?? null
6837
+ };
6838
+ }
6839
+ /**
6840
+ * A delivered VFX artist job: cut its sheet into the cache and return the
6841
+ * effect's facts, merged over what submit recorded (poll replaces the
6842
+ * whole `vfx` key). Download links are kept only when they carry no
6843
+ * credential, so a committed lockfile never holds a signed URL.
6844
+ */
6845
+ async cacheVfxArtistResult(jobId, done, recorded) {
6846
+ const result = parseVfxArtistResult(done, jobId);
6847
+ const atlas = result.atlas ? JSON.parse((await this.client.download(result.atlas.url)).toString("utf8")) : void 0;
6848
+ const size = vfxArtistFrameFacts(result, atlas);
6849
+ const facts = { effectId: result.effectId, ...size, fps: result.fps ?? 12, loop: result.loop ?? false };
6850
+ await this.cacheVfxFrames(jobId, facts, { sheet: result.sheet.url }, atlas);
6851
+ const urls = [result.sheet.url, ...result.atlas ? [result.atlas.url] : []];
6852
+ return {
6853
+ vfx: {
6854
+ ...recorded,
6855
+ ...facts,
6856
+ editUrl: result.editUrl,
6857
+ name: result.name,
6858
+ message: result.message,
6859
+ anchor: result.anchor,
6860
+ parts: result.parts,
6861
+ files: result.files.map((file) => file.name),
6862
+ ...urls.every(shouldPersistSourceUrl) ? { fileUrls: { sheet: result.sheet.url, ...result.atlas ? { atlas: result.atlas.url } : {} } } : {}
6863
+ },
5812
6864
  billed: null
5813
6865
  };
5814
6866
  }
@@ -5821,10 +6873,10 @@ var PixelLabProvider = class _PixelLabProvider {
5821
6873
  };
5822
6874
  if (Array.isArray(roles) && roles.length) {
5823
6875
  const sources = roles.map((role, index) => ({
5824
- url: `file://${path2.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
6876
+ url: `file://${path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
5825
6877
  role: String(role)
5826
6878
  }));
5827
- if (sources.some((source) => !existsSync2(source.url.slice("file://".length)))) return gone;
6879
+ if (sources.some((source) => !existsSync3(source.url.slice("file://".length)))) return gone;
5828
6880
  const fps = context?.metadata?.revisionFps;
5829
6881
  const frames = sources.every((source) => source.role.startsWith("frame-"));
5830
6882
  return {
@@ -5836,11 +6888,58 @@ var PixelLabProvider = class _PixelLabProvider {
5836
6888
  billed: billedFromUsage(usage)
5837
6889
  };
5838
6890
  }
5839
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
5840
- if (!existsSync2(file)) return gone;
6891
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6892
+ if (!existsSync3(file)) return gone;
5841
6893
  const sourceUrl = `file://${file}`;
5842
6894
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
5843
6895
  }
6896
+ /**
6897
+ * A `normal-map` revision: a background job whose map is
6898
+ * `last_response.image`. A loop's map comes back as the strip it was sent
6899
+ * as, and is cut into one file per frame under the parent's roles. Nothing
6900
+ * downstream palette-snaps it: the manifest refuses a normal map in a
6901
+ * style with `enforcePalette` or `quality`.
6902
+ */
6903
+ async pollNormalMap(jobId, context) {
6904
+ const job = await this.client.getBackgroundJob(jobId);
6905
+ if (job.status === "failed") return { status: "failed", error: "normal-map job failed upstream" };
6906
+ if (job.status !== "completed") return { status: "processing" };
6907
+ const billed = billedFromUsage(job.usage);
6908
+ const png = normalMapResultPng(job.last_response);
6909
+ if (!png) {
6910
+ return {
6911
+ status: "failed",
6912
+ error: `Invalid PixelLab response for normal-map job ${jobId}: completed with no image at last_response.image (got: ${Object.keys(job.last_response ?? {}).join(", ") || "no keys"})`
6913
+ };
6914
+ }
6915
+ const roles = context?.metadata?.revisionMembers;
6916
+ if (Array.isArray(roles) && roles.length) {
6917
+ let frames;
6918
+ try {
6919
+ frames = splitStripPng(png, roles.length);
6920
+ } catch (error) {
6921
+ return { status: "failed", error: `normal-map job ${jobId}: ${error.message}` };
6922
+ }
6923
+ const sources = frames.map((frame, index) => {
6924
+ const file2 = path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6925
+ writeFileSync(file2, frame);
6926
+ return { url: `file://${file2}`, role: String(roles[index]) };
6927
+ });
6928
+ const fps = context?.metadata?.revisionFps;
6929
+ return {
6930
+ status: "ready",
6931
+ objectId: jobId,
6932
+ sourceUrl: sources[0].url,
6933
+ sources,
6934
+ ...typeof fps === "number" ? { metadata: { frameSet: { fps, count: sources.length } } } : {},
6935
+ billed
6936
+ };
6937
+ }
6938
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6939
+ writeFileSync(file, png);
6940
+ const sourceUrl = `file://${file}`;
6941
+ return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
6942
+ }
5844
6943
  /**
5845
6944
  * `animate`/`animate-pixminimax` revisions: a plain background job, like
5846
6945
  * `pollRevision` above, but completing with an ORDERED FRAME LIST instead
@@ -5893,7 +6992,7 @@ var PixelLabProvider = class _PixelLabProvider {
5893
6992
  }
5894
6993
  const base64 = extractBase64(item);
5895
6994
  if (base64) {
5896
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6995
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
5897
6996
  writeFileSync(file, Buffer.from(base64, "base64"));
5898
6997
  urls.push(`file://${file}`);
5899
6998
  continue;
@@ -5944,7 +7043,7 @@ var PixelLabProvider = class _PixelLabProvider {
5944
7043
  const candidate = done[key];
5945
7044
  const base64 = extractBase64(candidate);
5946
7045
  if (base64) {
5947
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7046
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
5948
7047
  writeFileSync(file, Buffer.from(base64, "base64"));
5949
7048
  const sourceUrl = `file://${file}`;
5950
7049
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
@@ -5953,7 +7052,7 @@ var PixelLabProvider = class _PixelLabProvider {
5953
7052
  if (Array.isArray(done.images) && done.images.length) {
5954
7053
  const base64 = extractBase64(done.images[0]);
5955
7054
  if (base64) {
5956
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7055
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
5957
7056
  writeFileSync(file, Buffer.from(base64, "base64"));
5958
7057
  const sourceUrl = `file://${file}`;
5959
7058
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
@@ -5987,7 +7086,7 @@ var PixelLabProvider = class _PixelLabProvider {
5987
7086
  for (const [index, candidate] of done.images.entries()) {
5988
7087
  const base64 = extractBase64(candidate);
5989
7088
  if (!base64) continue;
5990
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7089
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
5991
7090
  writeFileSync(file, Buffer.from(base64, "base64"));
5992
7091
  urls.push(`file://${file}`);
5993
7092
  }
@@ -6017,7 +7116,7 @@ var PixelLabProvider = class _PixelLabProvider {
6017
7116
  for (const [index, candidate] of images.entries()) {
6018
7117
  const base64 = extractBase64(candidate);
6019
7118
  if (!base64) continue;
6020
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7119
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6021
7120
  writeFileSync(file, Buffer.from(base64, "base64"));
6022
7121
  urls.push(`file://${file}`);
6023
7122
  }
@@ -6096,6 +7195,26 @@ var PixelLabProvider = class _PixelLabProvider {
6096
7195
  throw err;
6097
7196
  }
6098
7197
  }
7198
+ /**
7199
+ * The base tile id of each terrain a connected set shares: the other
7200
+ * set's recorded `terrainIds`, or, for a set downloaded before pixelkiln
7201
+ * recorded them, read from PixelLab's own record of it (a free GET).
7202
+ */
7203
+ async terrainBaseTileIds(links) {
7204
+ const ids = {};
7205
+ for (const side of ["lower", "upper"]) {
7206
+ const link = links?.[side];
7207
+ if (!link) continue;
7208
+ const id = link.terrainId ?? (await this.client.getTileset(link.objectId)).metadata.terrainIds[link.terrain];
7209
+ if (!id) {
7210
+ throw new Error(
7211
+ `PixelLab terrain: tileset ${link.objectId} has no ${link.terrain} terrain id to connect this set's ${side} terrain to`
7212
+ );
7213
+ }
7214
+ ids[side] = id;
7215
+ }
7216
+ return ids;
7217
+ }
6099
7218
  /**
6100
7219
  * `/create-tileset` reports progress the same way `tiles` does (423 while
6101
7220
  * drawing, 200 once finished), but each tile's image comes back embedded
@@ -6107,7 +7226,8 @@ var PixelLabProvider = class _PixelLabProvider {
6107
7226
  */
6108
7227
  async pollTerrain(tilesetId, context) {
6109
7228
  try {
6110
- const { tileset, usage } = await this.client.getTileset(tilesetId);
7229
+ const { tileset, usage, metadata } = await this.client.getTileset(tilesetId);
7230
+ const sidescroller = metadata.view === "side" || context?.spec?.terrainOrientation === "sidescroller";
6111
7231
  const backgroundJobId = context?.metadata?.backgroundJobId;
6112
7232
  const billed = billedFromUsage(usage) ?? await this.billedForJob(backgroundJobId);
6113
7233
  const sources = [];
@@ -6115,7 +7235,7 @@ var PixelLabProvider = class _PixelLabProvider {
6115
7235
  for (const [index, tile] of tileset.tiles.entries()) {
6116
7236
  const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
6117
7237
  const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
6118
- const file = path2.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
7238
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
6119
7239
  writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
6120
7240
  sources.push({ url: `file://${file}`, role });
6121
7241
  terrainTiles.push({ role, corners: tile.corners, pattern4x4: tile.pattern_4x4 });
@@ -6125,7 +7245,13 @@ var PixelLabProvider = class _PixelLabProvider {
6125
7245
  objectId: tilesetId,
6126
7246
  sourceUrl: sources[0]?.url ?? null,
6127
7247
  sources,
6128
- metadata: { terrainTypes: tileset.terrain_types, terrainTiles },
7248
+ metadata: {
7249
+ terrainTypes: sidescroller ? ["lower", "upper"] : tileset.terrain_types,
7250
+ ...sidescroller ? { providerTerrainTypes: tileset.terrain_types, view: "side" } : {},
7251
+ terrainTiles,
7252
+ // Each terrain's base tile id, which a connected set sends.
7253
+ ...Object.keys(metadata.terrainIds).length ? { terrainIds: metadata.terrainIds } : {}
7254
+ },
6129
7255
  billed
6130
7256
  };
6131
7257
  } catch (err) {
@@ -6145,7 +7271,7 @@ var PixelLabProvider = class _PixelLabProvider {
6145
7271
  const { image, usage } = await this.client.getIsometricTile(tileId);
6146
7272
  const backgroundJobId = context?.metadata?.backgroundJobId;
6147
7273
  const billed = billedFromUsage(usage) ?? await this.billedForJob(backgroundJobId);
6148
- const file = path2.join(_PixelLabProvider.cacheDir(), `${tileId}.png`);
7274
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${tileId}.png`);
6149
7275
  writeFileSync(file, Buffer.from(image.base64, "base64"));
6150
7276
  return {
6151
7277
  status: "ready",
@@ -6189,8 +7315,8 @@ var PixelLabProvider = class _PixelLabProvider {
6189
7315
  return { objectId: `${jobId}#${index}`, sourceUrl: url };
6190
7316
  }
6191
7317
  if (generator === "imagePro" || generator === "uiElement") {
6192
- const file = path2.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6193
- if (!existsSync2(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
7318
+ const file = path3.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7319
+ if (!existsSync3(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
6194
7320
  return { objectId: `${jobId}#${index}`, sourceUrl: `file://${file}` };
6195
7321
  }
6196
7322
  const promoted = await this.client.selectFrames(jobId, [index], commonTag);
@@ -6781,7 +7907,7 @@ function retroError(value) {
6781
7907
  import { createHash as createHash2 } from "crypto";
6782
7908
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
6783
7909
  import os2 from "os";
6784
- import path3 from "path";
7910
+ import path4 from "path";
6785
7911
  var DEFAULT_BASE_URL2 = "https://api.cloud.scenario.com/v1";
6786
7912
  var SOURCE_PROTOCOL2 = "scenario:";
6787
7913
  var SUBMIT_RETRIES = 8;
@@ -7164,7 +8290,7 @@ var ScenarioProvider = class _ScenarioProvider {
7164
8290
  }
7165
8291
  };
7166
8292
  function uploadCachePath() {
7167
- return process.env.SCENARIO_UPLOAD_CACHE ?? path3.join(os2.tmpdir(), "pixelkiln-scenario", "uploads.json");
8293
+ return process.env.SCENARIO_UPLOAD_CACHE ?? path4.join(os2.tmpdir(), "pixelkiln-scenario", "uploads.json");
7168
8294
  }
7169
8295
  function uploadCacheKey(baseUrl, projectId, digest2) {
7170
8296
  return createHash2("sha256").update(`${baseUrl}
@@ -7182,7 +8308,7 @@ function readUploadCache() {
7182
8308
  function writeUploadCache(key, assetId) {
7183
8309
  try {
7184
8310
  const file = uploadCachePath();
7185
- mkdirSync2(path3.dirname(file), { recursive: true });
8311
+ mkdirSync2(path4.dirname(file), { recursive: true });
7186
8312
  const tmp = `${file}.${process.pid}.tmp`;
7187
8313
  writeFileSync2(tmp, JSON.stringify({ ...readUploadCache(), [key]: assetId }));
7188
8314
  renameSync(tmp, file);
@@ -7382,8 +8508,8 @@ function redactScenarioError(value, secrets) {
7382
8508
 
7383
8509
  // src/providers/comfyui.ts
7384
8510
  import { randomUUID as randomUUID2 } from "crypto";
7385
- import { readFile as readFile2 } from "fs/promises";
7386
- import path4 from "path";
8511
+ import { readFile as readFile3 } from "fs/promises";
8512
+ import path5 from "path";
7387
8513
  var DEFAULT_BASE_URL3 = "http://127.0.0.1:8188";
7388
8514
  var BUILTIN_BINDINGS = /* @__PURE__ */ new Set([
7389
8515
  "prompt",
@@ -7520,10 +8646,10 @@ var ComfyUIProvider = class _ComfyUIProvider {
7520
8646
  }
7521
8647
  async resolveOptions(value, context) {
7522
8648
  const options = parseOptions(value);
7523
- const workflowPath = path4.resolve(context.root, options.workflowFile);
8649
+ const workflowPath = path5.resolve(context.root, options.workflowFile);
7524
8650
  let raw;
7525
8651
  try {
7526
- raw = await readFile2(workflowPath, "utf8");
8652
+ raw = await readFile3(workflowPath, "utf8");
7527
8653
  } catch (err) {
7528
8654
  throw new Error(
7529
8655
  `ComfyUI workflow for style "${context.styleId}" could not be read at ${workflowPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -7575,10 +8701,10 @@ var ComfyUIProvider = class _ComfyUIProvider {
7575
8701
  if (typeof item !== "string") {
7576
8702
  throw new Error(`ComfyUI ${label} must be a manifest-relative PNG or JPEG path`);
7577
8703
  }
7578
- const file = path4.resolve(context.root, item);
8704
+ const file = path5.resolve(context.root, item);
7579
8705
  let bytes;
7580
8706
  try {
7581
- bytes = await readFile2(file);
8707
+ bytes = await readFile3(file);
7582
8708
  } catch (error) {
7583
8709
  throw new Error(
7584
8710
  `ComfyUI ${label} could not be read at ${file}: ${error instanceof Error ? error.message : String(error)}`
@@ -7713,6 +8839,11 @@ var ComfyUIProvider = class _ComfyUIProvider {
7713
8839
  "ComfyUI has no binding for numColors/paletteImage/dithering \u2014 those are PixelLab's /reduce-colors parameters. A reduce-colors revision on ComfyUI is whatever your own workflow does with bindings.sourceImage; write that behavior into the graph directly."
7714
8840
  );
7715
8841
  }
8842
+ if (spec.revision.convention) {
8843
+ throw new Error(
8844
+ "ComfyUI has no binding for convention \u2014 that is PixelLab's /normal-map parameter. A normal-map revision on ComfyUI is whatever your own workflow does; build the green-channel convention into the graph."
8845
+ );
8846
+ }
7716
8847
  if ((!options.bindings.width || !options.bindings.height) && spec.revision.sourceWidth != null && spec.revision.sourceHeight != null && (spec.width !== spec.revision.sourceWidth || spec.height !== spec.revision.sourceHeight)) {
7717
8848
  throw new Error(
7718
8849
  "ComfyUI revisions without width/height bindings must keep the source dimensions"
@@ -7735,7 +8866,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
7735
8866
  setBinding(workflow2, binding, input);
7736
8867
  return;
7737
8868
  }
7738
- const bytes = await readFile2(input.path);
8869
+ const bytes = await readFile3(input.path);
7739
8870
  if (sha256(bytes) !== input.sha256) {
7740
8871
  throw new Error(`ComfyUI provider input "${name}" changed before upload`);
7741
8872
  }
@@ -7802,7 +8933,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
7802
8933
  if (!spec.revision.sourceSha256 || !spec.revision.sourceFormat) {
7803
8934
  throw new Error("ComfyUI revision source is not ready");
7804
8935
  }
7805
- const sourceBytes = await readFile2(spec.revision.sourceFile);
8936
+ const sourceBytes = await readFile3(spec.revision.sourceFile);
7806
8937
  if (sha256(sourceBytes) !== spec.revision.sourceSha256) {
7807
8938
  throw new Error("ComfyUI revision source changed before upload");
7808
8939
  }
@@ -7816,7 +8947,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
7816
8947
  if (!spec.revision.maskSha256 || !spec.revision.maskFormat) {
7817
8948
  throw new Error("ComfyUI revision mask is not ready");
7818
8949
  }
7819
- const maskBytes = await readFile2(spec.revision.maskFile);
8950
+ const maskBytes = await readFile3(spec.revision.maskFile);
7820
8951
  if (sha256(maskBytes) !== spec.revision.maskSha256) {
7821
8952
  throw new Error("ComfyUI revision mask changed before upload");
7822
8953
  }
@@ -8536,9 +9667,9 @@ registerProvider({
8536
9667
  });
8537
9668
 
8538
9669
  // src/manifest.ts
8539
- import { readFile as readFile4 } from "fs/promises";
8540
- import { existsSync as existsSync5 } from "fs";
8541
- import path7 from "path";
9670
+ import { readFile as readFile5 } from "fs/promises";
9671
+ import { existsSync as existsSync6 } from "fs";
9672
+ import path8 from "path";
8542
9673
 
8543
9674
  // src/loop-directions.ts
8544
9675
  var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
@@ -8626,17 +9757,17 @@ function isObject2(value) {
8626
9757
  }
8627
9758
 
8628
9759
  // src/outputs.ts
8629
- import { existsSync as existsSync4 } from "fs";
8630
- import path6 from "path";
9760
+ import { existsSync as existsSync5 } from "fs";
9761
+ import path7 from "path";
8631
9762
 
8632
9763
  // src/lock.ts
8633
- import { readFile as readFile3, writeFile as writeFile2, rename, mkdir as mkdir2, rmdir, rm, stat } from "fs/promises";
8634
- import { existsSync as existsSync3 } from "fs";
8635
- import path5 from "path";
9764
+ import { readFile as readFile4, writeFile as writeFile3, rename, mkdir as mkdir3, rmdir, rm, stat } from "fs/promises";
9765
+ import { existsSync as existsSync4 } from "fs";
9766
+ import path6 from "path";
8636
9767
  async function loadLock(lockPath) {
8637
- if (!existsSync3(lockPath)) return { version: 2, entries: {} };
9768
+ if (!existsSync4(lockPath)) return { version: 2, entries: {} };
8638
9769
  try {
8639
- return parseLock(JSON.parse(await readFile3(lockPath, "utf8")));
9770
+ return parseLock(JSON.parse(await readFile4(lockPath, "utf8")));
8640
9771
  } catch (err) {
8641
9772
  throw new ProjectError(
8642
9773
  `Lockfile at ${lockPath} is malformed:
@@ -8649,7 +9780,7 @@ var saveQueues = /* @__PURE__ */ new Map();
8649
9780
  var dirtyPatches = /* @__PURE__ */ new WeakMap();
8650
9781
  var dirtyDeletes = /* @__PURE__ */ new WeakMap();
8651
9782
  function saveLock(lockPath, lock) {
8652
- const queueKey = path5.resolve(lockPath);
9783
+ const queueKey = path6.resolve(lockPath);
8653
9784
  const previous = saveQueues.get(queueKey) ?? Promise.resolve();
8654
9785
  const next = previous.catch(() => {
8655
9786
  }).then(() => writeLockNow(lockPath, lock));
@@ -8661,7 +9792,7 @@ function saveLock(lockPath, lock) {
8661
9792
  return next;
8662
9793
  }
8663
9794
  async function writeLockNow(lockPath, lock) {
8664
- await mkdir2(path5.dirname(path5.resolve(lockPath)), { recursive: true });
9795
+ await mkdir3(path6.dirname(path6.resolve(lockPath)), { recursive: true });
8665
9796
  const release = await acquireFileLock(lockPath);
8666
9797
  try {
8667
9798
  await writeLockWhileHeld(lockPath, lock);
@@ -8671,9 +9802,9 @@ async function writeLockNow(lockPath, lock) {
8671
9802
  }
8672
9803
  async function writeLockWhileHeld(file, lock) {
8673
9804
  let disk = { version: 2, entries: {} };
8674
- if (existsSync3(file)) {
9805
+ if (existsSync4(file)) {
8675
9806
  try {
8676
- disk = parseLock(JSON.parse(await readFile3(file, "utf8")));
9807
+ disk = parseLock(JSON.parse(await readFile4(file, "utf8")));
8677
9808
  } catch (err) {
8678
9809
  throw new ProjectError(
8679
9810
  `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`,
@@ -8702,7 +9833,7 @@ async function writeLockWhileHeld(file, lock) {
8702
9833
  }
8703
9834
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
8704
9835
  try {
8705
- await writeFile2(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
9836
+ await writeFile3(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
8706
9837
  await rename(tmp, file);
8707
9838
  } finally {
8708
9839
  await rm(tmp, { force: true });
@@ -8768,7 +9899,7 @@ async function acquireFileLock(file) {
8768
9899
  const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
8769
9900
  for (; ; ) {
8770
9901
  try {
8771
- await mkdir2(lockDir);
9902
+ await mkdir3(lockDir);
8772
9903
  return async () => {
8773
9904
  await rmdir(lockDir).catch(() => {
8774
9905
  });
@@ -8806,22 +9937,22 @@ function totalSpend(lock, unit = "generations") {
8806
9937
 
8807
9938
  // src/outputs.ts
8808
9939
  function portableOutputPath(file, manifestDir) {
8809
- const root = path6.resolve(manifestDir);
8810
- const absolute = path6.isAbsolute(file) ? path6.normalize(file) : path6.resolve(root, file);
8811
- const relative = path6.relative(root, absolute);
8812
- const value = path6.isAbsolute(relative) ? absolute : relative;
8813
- return value.split(path6.sep).join("/");
9940
+ const root = path7.resolve(manifestDir);
9941
+ const absolute = path7.isAbsolute(file) ? path7.normalize(file) : path7.resolve(root, file);
9942
+ const relative = path7.relative(root, absolute);
9943
+ const value = path7.isAbsolute(relative) ? absolute : relative;
9944
+ return value.split(path7.sep).join("/");
8814
9945
  }
8815
9946
  function resolveOutputPath(recordedPath, manifestDir) {
8816
- if (path6.isAbsolute(recordedPath)) return path6.normalize(recordedPath);
8817
- if (path6.win32.isAbsolute(recordedPath)) return recordedPath;
8818
- return path6.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path6.sep));
9947
+ if (path7.isAbsolute(recordedPath)) return path7.normalize(recordedPath);
9948
+ if (path7.win32.isAbsolute(recordedPath)) return recordedPath;
9949
+ return path7.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path7.sep));
8819
9950
  }
8820
9951
  function expectedOutputPath(spec, role, index, total, mediaType) {
8821
9952
  return memberPath(spec.outFile, role, index, total, mediaType);
8822
9953
  }
8823
9954
  function memberPath(file, role, index, total, mediaType) {
8824
- const originalExt = path6.extname(file);
9955
+ const originalExt = path7.extname(file);
8825
9956
  const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
8826
9957
  const stem = originalExt ? file.slice(0, -originalExt.length) : file;
8827
9958
  if (total === 1) return `${stem}${ext}`;
@@ -8835,10 +9966,10 @@ function isFrameSetEntry(entry) {
8835
9966
  return entry.generator === "frames" && isMemberSetEntry(entry);
8836
9967
  }
8837
9968
  function sourceIsStem(source, entry, manifestDir) {
8838
- return isMemberSetEntry(entry) && !existsSync4(path6.resolve(manifestDir, source));
9969
+ return isMemberSetEntry(entry) && !existsSync5(path7.resolve(manifestDir, source));
8839
9970
  }
8840
9971
  function sourceOutputPath(source, entry, index, manifestDir) {
8841
- const absolute = path6.resolve(manifestDir, source);
9972
+ const absolute = path7.resolve(manifestDir, source);
8842
9973
  if (!sourceIsStem(source, entry, manifestDir)) return absolute;
8843
9974
  const output = entry.outputs[index];
8844
9975
  return memberPath(absolute, output.role, index, entry.outputs.length, output.mediaType);
@@ -8925,7 +10056,7 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
8925
10056
  id: spec.assetId,
8926
10057
  index: 0,
8927
10058
  path: spec.outFile,
8928
- absolutePath: path6.resolve(spec.outFile),
10059
+ absolutePath: path7.resolve(spec.outFile),
8929
10060
  sha256: ""
8930
10061
  }];
8931
10062
  }
@@ -8946,9 +10077,9 @@ function frameSetLoop(entry) {
8946
10077
 
8947
10078
  // src/manifest.ts
8948
10079
  async function loadManifest(manifestPath) {
8949
- const abs = path7.resolve(manifestPath);
8950
- if (!existsSync5(abs)) throw new ProjectError(`No manifest at ${abs}`);
8951
- const expansion = expandLoopDirections(JSON.parse(await readFile4(abs, "utf8")));
10080
+ const abs = path8.resolve(manifestPath);
10081
+ if (!existsSync6(abs)) throw new ProjectError(`No manifest at ${abs}`);
10082
+ const expansion = expandLoopDirections(JSON.parse(await readFile5(abs, "utf8")));
8952
10083
  if (expansion.issues.length) {
8953
10084
  throw new ProjectError(`Manifest at ${abs} is invalid:
8954
10085
  ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
@@ -9045,12 +10176,38 @@ ${issues}`);
9045
10176
  visited.add(assetId);
9046
10177
  };
9047
10178
  for (const assetId of Object.keys(parsed.data.assets)) visitParents(assetId, []);
10179
+ const connectVisiting = [];
10180
+ const connectVisited = /* @__PURE__ */ new Set();
10181
+ const visitConnections = (assetId) => {
10182
+ if (connectVisited.has(assetId)) return;
10183
+ const cycleStart = connectVisiting.indexOf(assetId);
10184
+ if (cycleStart >= 0) {
10185
+ unknownReferences.push(
10186
+ `assets.${assetId}.connect: connection cycle ${[...connectVisiting.slice(cycleStart), assetId].join(" -> ")}`
10187
+ );
10188
+ return;
10189
+ }
10190
+ connectVisiting.push(assetId);
10191
+ for (const [side, link] of Object.entries(parsed.data.assets[assetId]?.connect ?? {})) {
10192
+ if (!link) continue;
10193
+ if (!parsed.data.assets[link.of]) {
10194
+ unknownReferences.push(`assets.${assetId}.connect.${side}.of: unknown asset "${link.of}"`);
10195
+ } else if (link.of === assetId) {
10196
+ unknownReferences.push(`assets.${assetId}.connect.${side}.of: a set cannot connect to itself`);
10197
+ } else {
10198
+ visitConnections(link.of);
10199
+ }
10200
+ }
10201
+ connectVisiting.pop();
10202
+ connectVisited.add(assetId);
10203
+ };
10204
+ for (const assetId of Object.keys(parsed.data.assets)) visitConnections(assetId);
9048
10205
  if (unknownReferences.length) {
9049
10206
  throw new ProjectError(`Manifest at ${abs} is invalid:
9050
10207
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
9051
10208
  }
9052
10209
  const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
9053
- return { manifest: parsed.data, root: path7.dirname(abs), path: abs, ...loopFamilies };
10210
+ return { manifest: parsed.data, root: path8.dirname(abs), path: abs, ...loopFamilies };
9054
10211
  }
9055
10212
  function assetInStyle(asset, styleId, style) {
9056
10213
  if (asset.styles.length) return asset.styles.includes(styleId);
@@ -9059,6 +10216,13 @@ function assetInStyle(asset, styleId, style) {
9059
10216
  function parentAssetId(asset) {
9060
10217
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of ?? asset?.portrait?.of ?? asset?.outfit?.of;
9061
10218
  }
10219
+ function dependencyAssetIds(asset) {
10220
+ const parent = parentAssetId(asset);
10221
+ return [
10222
+ ...parent ? [parent] : [],
10223
+ ...[asset?.connect?.lower?.of, asset?.connect?.upper?.of].filter((id) => Boolean(id))
10224
+ ];
10225
+ }
9062
10226
  function formatManifestIssues(issues) {
9063
10227
  return issues.flatMap((issue) => {
9064
10228
  if (issue.code === "invalid_union") {
@@ -9146,11 +10310,12 @@ async function resolveSpecs(loaded, filter) {
9146
10310
  }
9147
10311
  const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
9148
10312
  const resolutionAssetIds = new Set(requestedAssetIds);
9149
- for (const assetId of [...requestedAssetIds]) {
9150
- let current = parentAssetId(manifest.assets[assetId]);
9151
- while (current && !resolutionAssetIds.has(current)) {
9152
- resolutionAssetIds.add(current);
9153
- current = parentAssetId(manifest.assets[current]);
10313
+ const pending = [...requestedAssetIds];
10314
+ while (pending.length) {
10315
+ for (const dependency of dependencyAssetIds(manifest.assets[pending.pop()])) {
10316
+ if (resolutionAssetIds.has(dependency) || !manifest.assets[dependency]) continue;
10317
+ resolutionAssetIds.add(dependency);
10318
+ pending.push(dependency);
9154
10319
  }
9155
10320
  }
9156
10321
  const styleDependencies = /* @__PURE__ */ new Map();
@@ -9188,11 +10353,11 @@ async function resolveSpecs(loaded, filter) {
9188
10353
  const styleFinalizers = /* @__PURE__ */ new Map();
9189
10354
  const styleImageCache = /* @__PURE__ */ new Map();
9190
10355
  async function loadStyleImage(rel, what = "Style image") {
9191
- const abs = path7.resolve(root, rel);
10356
+ const abs = path8.resolve(root, rel);
9192
10357
  let hit = styleImageCache.get(abs);
9193
10358
  if (!hit) {
9194
- if (!existsSync5(abs)) throw new Error(`${what} not found: ${abs}`);
9195
- const buf = await readFile4(abs);
10359
+ if (!existsSync6(abs)) throw new Error(`${what} not found: ${abs}`);
10360
+ const buf = await readFile5(abs);
9196
10361
  const metadata = imageMetadata(buf);
9197
10362
  if (!metadata) throw new Error(`${what} is not a readable PNG or JPEG: ${abs}`);
9198
10363
  if (metadata.width < 1 || metadata.height < 1) {
@@ -9218,7 +10383,7 @@ async function resolveSpecs(loaded, filter) {
9218
10383
  styleImageDimensions.push(loadedImage);
9219
10384
  }
9220
10385
  const resolvedImages = style.styleImages.map((image) => {
9221
- const hit = styleImageCache.get(path7.resolve(root, image.path));
10386
+ const hit = styleImageCache.get(path8.resolve(root, image.path));
9222
10387
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
9223
10388
  });
9224
10389
  const styleSpecs = /* @__PURE__ */ new Map();
@@ -9305,6 +10470,9 @@ async function resolveSpecs(loaded, filter) {
9305
10470
  `assets.${assetId}: ${asset.portrait ? "portrait" : "outfit"} needs a character style; "${styleId}" generates ${generator}`
9306
10471
  );
9307
10472
  }
10473
+ if (asset.connect && generator !== "terrain") {
10474
+ throw new Error(`assets.${assetId}: connect needs a terrain style; "${styleId}" generates ${generator}`);
10475
+ }
9308
10476
  if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
9309
10477
  throw new Error(
9310
10478
  `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
@@ -9316,7 +10484,18 @@ async function resolveSpecs(loaded, filter) {
9316
10484
  let terrainUpperDescription;
9317
10485
  let terrainTransitionDescription;
9318
10486
  let prompt;
9319
- if (generator === "terrain") {
10487
+ if (generator === "terrain" && style.terrainOrientation === "sidescroller") {
10488
+ const parsed = parseTerrainDescriptions(subject);
10489
+ if (!parsed || parsed.transition) {
10490
+ throw new Error(
10491
+ `assets.${assetId}: a sidescroller terrain needs exactly "1). <platform body> 2). <top layer>" in its prompt (e.g. 1). stone brick texture 2). green grass texture)`
10492
+ );
10493
+ }
10494
+ const wrap = (text) => [style.promptPrefix, text, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
10495
+ terrainLowerDescription = wrap(parsed.lower);
10496
+ terrainTransitionDescription = wrap(parsed.upper);
10497
+ prompt = subject.trim();
10498
+ } else if (generator === "terrain") {
9320
10499
  const parsed = parseTerrainDescriptions(subject);
9321
10500
  if (!parsed) {
9322
10501
  throw new Error(
@@ -9333,12 +10512,12 @@ async function resolveSpecs(loaded, filter) {
9333
10512
  } else {
9334
10513
  prompt = [style.promptPrefix, subject, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
9335
10514
  }
9336
- const relFile = asset.file ?? path7.join(asset.category ?? "", `${assetId}.png`);
9337
- const outFile = path7.resolve(root, style.outDir, relFile);
9338
- const qualityOutFile = style.quality ? path7.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
10515
+ const relFile = asset.file ?? path8.join(asset.category ?? "", `${assetId}.png`);
10516
+ const outFile = path8.resolve(root, style.outDir, relFile);
10517
+ const qualityOutFile = style.quality ? path8.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
9339
10518
  const tileSize = generator === "tiles" ? size : style.tileSize ?? 32;
9340
10519
  const tileVariations = tileFeatureOutputCount(generator === "tiles" ? style.tileFeature : void 0) ?? tileVariationCount(countNumberedDescriptions(prompt));
9341
- const terrainTiles = generator === "terrain" ? terrainTileCount(style.terrainTransitionSize) : 0;
10520
+ const terrainTiles = generator === "terrain" ? terrainTileCount(style.terrainTransitionSize, style.terrainMode, style.terrainOrientation) : 0;
9342
10521
  const base = {
9343
10522
  styleId,
9344
10523
  assetId,
@@ -9361,6 +10540,8 @@ async function resolveSpecs(loaded, filter) {
9361
10540
  uiColorPalette: generator === "uiAsset" || generator === "uiElement" ? style.uiColorPalette : void 0,
9362
10541
  ...generator === "imageProFlash" && style.styleTraits ? { styleTraits: style.styleTraits } : {},
9363
10542
  palette: style.palette,
10543
+ ...generator === "imagePro" && style.paletteName ? { paletteName: style.paletteName } : {},
10544
+ ...generator === "imagePro" && style.maxColors != null ? { maxColors: style.maxColors } : {},
9364
10545
  enforcePalette: style.enforcePalette,
9365
10546
  noBackground: style.noBackground,
9366
10547
  tileSize: generator === "tiles" ? tileSize : void 0,
@@ -9395,8 +10576,14 @@ async function resolveSpecs(loaded, filter) {
9395
10576
  terrainSpreadX: generator === "terrain" ? style.terrainSpreadX : void 0,
9396
10577
  terrainSlopeSize: generator === "terrain" ? style.terrainSlopeSize : void 0,
9397
10578
  terrainRaggedness: generator === "terrain" ? style.terrainRaggedness : void 0,
10579
+ terrainRoundness: generator === "terrain" ? style.terrainRoundness : void 0,
10580
+ terrainBoundaryAmplitude: generator === "terrain" ? style.terrainBoundaryAmplitude : void 0,
10581
+ terrainBoundarySeed: generator === "terrain" ? style.terrainBoundarySeed : void 0,
10582
+ terrainStepSlope: generator === "terrain" ? style.terrainStepSlope : void 0,
10583
+ terrainConnectDiagonals: generator === "terrain" ? style.terrainConnectDiagonals : void 0,
9398
10584
  terrainTransitionSize: generator === "terrain" ? style.terrainTransitionSize : void 0,
9399
10585
  terrainView: generator === "terrain" ? style.terrainView : void 0,
10586
+ terrainOrientation: generator === "terrain" ? style.terrainOrientation : void 0,
9400
10587
  isometricTileSize: generator === "isometricTile" ? style.isometricTileSize : void 0,
9401
10588
  isometricTileShape: generator === "isometricTile" ? style.isometricTileShape : void 0,
9402
10589
  // A revision in a character or objectPro style is a provider edit of
@@ -9407,7 +10594,7 @@ async function resolveSpecs(loaded, filter) {
9407
10594
  // objectPro asset is only ever a base, state, or animation.
9408
10595
  ...generator === "objectPro" && !asset.revision && characterKind !== "portrait" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9409
10596
  ...vfx ? { vfx } : {},
9410
- cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
10597
+ cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? terrainCost(style.terrainMode, size, style.terrainOrientation) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
9411
10598
  costUnit: "generations",
9412
10599
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
9413
10600
  };
@@ -9431,7 +10618,7 @@ async function resolveSpecs(loaded, filter) {
9431
10618
  minGridConfidence: style.quality.minGridConfidence,
9432
10619
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
9433
10620
  ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
9434
- ...style.quality.fixerPython ? { fixerPython: path7.resolve(root, style.quality.fixerPython) } : {},
10621
+ ...style.quality.fixerPython ? { fixerPython: path8.resolve(root, style.quality.fixerPython) } : {},
9435
10622
  ...style.quality.fps == null ? {} : { fps: style.quality.fps }
9436
10623
  }
9437
10624
  } : {},
@@ -9468,6 +10655,11 @@ async function resolveSpecs(loaded, filter) {
9468
10655
  if (sourceSpec.generator === "tiles" || sourceSpec.generator === "terrain") {
9469
10656
  throw new Error(`assets.${assetId}: a tile set cannot be mirrored; its edges carry meaning`);
9470
10657
  }
10658
+ if (sourceSpec.revision?.mode === "normal-map") {
10659
+ throw new Error(
10660
+ `assets.${assetId}: a normal map cannot be mirrored by flipping its pixels; every red channel would still point the old way. Make a normal-map revision of the mirrored art instead`
10661
+ );
10662
+ }
9471
10663
  if (sourceSpec.character?.kind === "animation") {
9472
10664
  const facing = sourceSpec.character.animation.direction;
9473
10665
  const mirrored = MIRRORED_DIRECTION[facing];
@@ -9514,7 +10706,7 @@ async function resolveSpecs(loaded, filter) {
9514
10706
  const file = memberPath(anchorSpec.outFile, "south", 0, anchorSpec.character.directions, MediaType.PNG);
9515
10707
  resolved.character = {
9516
10708
  ...resolved.character,
9517
- styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: existsSync5(file) ? await sha256File(file) : null }
10709
+ styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: existsSync6(file) ? await sha256File(file) : null }
9518
10710
  };
9519
10711
  }
9520
10712
  const characterParent = asset.state?.of ?? asset.animation?.of ?? asset.portrait?.of;
@@ -9527,7 +10719,7 @@ async function resolveSpecs(loaded, filter) {
9527
10719
  parentAssetId: characterParent,
9528
10720
  parentSpec,
9529
10721
  parentFile,
9530
- parentSha256: existsSync5(parentFile) ? await sha256File(parentFile) : null,
10722
+ parentSha256: existsSync6(parentFile) ? await sha256File(parentFile) : null,
9531
10723
  // A state and its animations keep the engine and rotations of the base.
9532
10724
  mode: resolved.character.kind === "base" ? resolved.character.mode : parentSpec.character?.mode ?? resolved.character.mode,
9533
10725
  directions: resolved.character.kind === "base" ? resolved.character.directions : parentSpec.character?.directions ?? resolved.character.directions
@@ -9545,7 +10737,7 @@ async function resolveSpecs(loaded, filter) {
9545
10737
  parentAssetId: characterParent,
9546
10738
  parentSpec,
9547
10739
  parentFile,
9548
- parentSha256: existsSync5(parentFile) ? await sha256File(parentFile) : null,
10740
+ parentSha256: existsSync6(parentFile) ? await sha256File(parentFile) : null,
9549
10741
  // A state and its animations keep the base's own rotation count.
9550
10742
  directions: resolved.objectPro.kind === "base" ? resolved.objectPro.directions : parentSpec.objectPro.directions
9551
10743
  };
@@ -9567,7 +10759,7 @@ async function resolveSpecs(loaded, filter) {
9567
10759
  animation: sourceSpec.character.animation,
9568
10760
  outfit: {
9569
10761
  reference: {
9570
- path: path7.resolve(root, asset.outfit.reference),
10762
+ path: path8.resolve(root, asset.outfit.reference),
9571
10763
  sha256: referenceImage.hash,
9572
10764
  width: referenceImage.width,
9573
10765
  height: referenceImage.height,
@@ -9588,25 +10780,30 @@ async function resolveSpecs(loaded, filter) {
9588
10780
  `Provider "${activeProvider.id}" does not support ${asset.revision.mode} revisions`
9589
10781
  );
9590
10782
  }
10783
+ if (asset.revision.mode === "normal-map" && (style.enforcePalette || style.quality)) {
10784
+ throw new Error(
10785
+ `assets.${assetId}: a normal-map revision is lighting data, not art, and style "${styleId}" sets ${style.enforcePalette ? "enforcePalette" : "quality"}, which would recolour it. Put it in a style without enforcePalette or quality, and name the parent's style with revision.fromStyle`
10786
+ );
10787
+ }
9591
10788
  const sourceStyleId = asset.revision.fromStyle && asset.revision.fromStyle !== styleId ? asset.revision.fromStyle : void 0;
9592
10789
  const sourceSpec = sourceStyleId ? await styleFinalizers.get(sourceStyleId)(asset.revision.from) : await finalize(asset.revision.from);
9593
- const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path7.resolve(root, sourceSpec.source) : sourceSpec.outFile);
10790
+ const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path8.resolve(root, sourceSpec.source) : sourceSpec.outFile);
9594
10791
  const sourceMembers = await revisionSourceMembers(sourceStem, `revision source ${asset.revision.from}`);
9595
10792
  const sourceFile = sourceMembers?.[0]?.file ?? sourceStem;
9596
10793
  const sourceImage = sourceMembers ? { ...sourceMembers[0], hash: sha256(JSON.stringify(sourceMembers.map((member) => member.sha256))) } : await optionalRevisionImage(sourceFile, "revision source");
9597
10794
  const sourceFps = sourceSpec.character?.animation?.fps ?? sourceSpec.objectPro?.animation?.fps ?? sourceSpec.revision?.fps ?? sourceSpec.revision?.sourceFps;
9598
- const maskFile = asset.revision.mask ? path7.resolve(root, asset.revision.mask) : void 0;
10795
+ const maskFile = asset.revision.mask ? path8.resolve(root, asset.revision.mask) : void 0;
9599
10796
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
9600
10797
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
9601
10798
  throw new Error(
9602
10799
  `Revision mask for ${styleId}/${assetId} is ${maskImage.width}x${maskImage.height}; source ${asset.revision.from} is ${sourceImage.width}x${sourceImage.height}`
9603
10800
  );
9604
10801
  }
9605
- const paletteImageFile = asset.revision.paletteImage ? path7.resolve(root, asset.revision.paletteImage) : void 0;
10802
+ const paletteImageFile = asset.revision.paletteImage ? path8.resolve(root, asset.revision.paletteImage) : void 0;
9606
10803
  const paletteImage = paletteImageFile ? await optionalRevisionImage(paletteImageFile, "revision palette image", "png") : null;
9607
- const lastFrameFile = asset.revision.lastFrame ? path7.resolve(root, asset.revision.lastFrame) : void 0;
10804
+ const lastFrameFile = asset.revision.lastFrame ? path8.resolve(root, asset.revision.lastFrame) : void 0;
9608
10805
  const lastFrame = lastFrameFile ? await optionalRevisionImage(lastFrameFile, "revision last frame") : null;
9609
- const keypointsFile = asset.revision.keypointsFile ? path7.resolve(root, asset.revision.keypointsFile) : void 0;
10806
+ const keypointsFile = asset.revision.keypointsFile ? path8.resolve(root, asset.revision.keypointsFile) : void 0;
9610
10807
  const keypoints = keypointsFile ? await optionalSkeletonSet(keypointsFile) : null;
9611
10808
  resolved.revision = {
9612
10809
  mode: asset.revision.mode,
@@ -9662,6 +10859,7 @@ async function resolveSpecs(loaded, filter) {
9662
10859
  ...asset.revision.skeletonTemplate ? { skeletonTemplate: asset.revision.skeletonTemplate } : {},
9663
10860
  ...asset.revision.description ? { description: asset.revision.description } : {},
9664
10861
  ...asset.revision.removalTask ? { removalTask: asset.revision.removalTask } : {},
10862
+ ...asset.revision.convention ? { convention: asset.revision.convention } : {},
9665
10863
  ...asset.revision.driftThreshold == null ? {} : { driftThreshold: asset.revision.driftThreshold },
9666
10864
  ...asset.revision.subjectDescription ? { subjectDescription: asset.revision.subjectDescription } : {},
9667
10865
  ...asset.revision.initialPose ? { initialPose: asset.revision.initialPose } : {},
@@ -9718,6 +10916,24 @@ async function resolveSpecs(loaded, filter) {
9718
10916
  };
9719
10917
  }
9720
10918
  }
10919
+ if (asset.connect) {
10920
+ const links = {};
10921
+ for (const side of ["lower", "upper"]) {
10922
+ const link = asset.connect[side];
10923
+ if (!link) continue;
10924
+ if (!styleSpecs.has(link.of)) {
10925
+ throw new Error(
10926
+ `assets.${assetId}.connect.${side}.of: "${link.of}" is not in style "${styleId}"; connected terrain sets share one style`
10927
+ );
10928
+ }
10929
+ const linkedSpec = await finalize(link.of);
10930
+ if (linkedSpec.generator !== "terrain") {
10931
+ throw new Error(`assets.${assetId}.connect.${side}.of: "${link.of}" is not a terrain set`);
10932
+ }
10933
+ links[side] = { assetId: link.of, terrain: link.terrain, spec: linkedSpec };
10934
+ }
10935
+ resolved.terrainConnect = links;
10936
+ }
9721
10937
  if (asset.scene) resolved.scene = await resolveMapScene(root, styleId, assetId, resolved.generator, asset.scene, activeProvider);
9722
10938
  resolved.specHash = specHash(
9723
10939
  resolved,
@@ -9776,7 +10992,7 @@ async function resolveSpecs(loaded, filter) {
9776
10992
  return specs;
9777
10993
  }
9778
10994
  function pngPath(file) {
9779
- const extension = path7.extname(file);
10995
+ const extension = path8.extname(file);
9780
10996
  return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
9781
10997
  }
9782
10998
  async function resolveMapScene(root, styleId, assetId, generator, scene, provider) {
@@ -9786,12 +11002,12 @@ async function resolveMapScene(root, styleId, assetId, generator, scene, provide
9786
11002
  if (!provider.supportsMapScene) {
9787
11003
  throw new Error(`assets.${assetId}.scene: provider "${provider.id}" cannot draw into a scene; PixelLab's /map-objects can`);
9788
11004
  }
9789
- const file = path7.resolve(root, scene.image);
11005
+ const file = path8.resolve(root, scene.image);
9790
11006
  const image = await optionalRevisionImage(file, "scene image", "png");
9791
11007
  const base = { file, sha256: image?.hash ?? null, width: image?.width ?? null, height: image?.height ?? null };
9792
11008
  const placement = scene.placement;
9793
11009
  if ("mask" in placement) {
9794
- const maskFile = path7.resolve(root, placement.mask);
11010
+ const maskFile = path8.resolve(root, placement.mask);
9795
11011
  const mask = await optionalRevisionImage(maskFile, "scene mask", "png");
9796
11012
  if (image && mask && (image.width !== mask.width || image.height !== mask.height)) {
9797
11013
  throw new Error(
@@ -9809,20 +11025,20 @@ async function resolveVfxShape(root, assetId, vfx, defaultName) {
9809
11025
  let recipe;
9810
11026
  let recordedSprite;
9811
11027
  if (vfx.recipe) {
9812
- const file = path7.resolve(root, vfx.recipe);
9813
- if (!existsSync5(file)) throw new Error(`assets.${assetId}.vfx.recipe: VFX recipe not found: ${file}`);
11028
+ const file = path8.resolve(root, vfx.recipe);
11029
+ if (!existsSync6(file)) throw new Error(`assets.${assetId}.vfx.recipe: VFX recipe not found: ${file}`);
9814
11030
  const read = await readVfxRecipe(file).catch((error) => {
9815
11031
  throw new Error(`assets.${assetId}.vfx.recipe: ${error instanceof Error ? error.message : String(error)}`);
9816
11032
  });
9817
11033
  recipe = { file, sha256: vfxRecipeSha256(read.recipe), recipe: read.recipe };
9818
11034
  recordedSprite = read.sprite;
9819
11035
  }
9820
- const spriteRef = vfx.sprite ? isVfxReference(vfx.sprite) ? vfx.sprite : path7.resolve(root, vfx.sprite) : recordedSprite;
11036
+ const spriteRef = vfx.sprite ? isVfxReference(vfx.sprite) ? vfx.sprite : path8.resolve(root, vfx.sprite) : recordedSprite;
9821
11037
  let sprite;
9822
11038
  if (spriteRef && isVfxReference(spriteRef)) {
9823
11039
  sprite = { reference: spriteRef };
9824
11040
  } else if (spriteRef) {
9825
- const image = await optionalRevisionImage(spriteRef, `assets.${assetId}.vfx.sprite`);
11041
+ const image = await optionalRevisionImage(spriteRef, `assets.${assetId}.vfx.sprite`, vfx.description ? "png" : void 0);
9826
11042
  sprite = { file: spriteRef, sha256: image?.hash ?? null, width: image?.width ?? null, height: image?.height ?? null, format: image?.format ?? null };
9827
11043
  }
9828
11044
  return {
@@ -9831,12 +11047,14 @@ async function resolveVfxShape(root, assetId, vfx, defaultName) {
9831
11047
  ...vfx.parts?.length ? { parts: vfx.parts } : {},
9832
11048
  ...sprite ? { sprite } : {},
9833
11049
  ...vfx.fps !== void 0 ? { fps: vfx.fps } : {},
9834
- name: vfx.name ?? defaultName
11050
+ // The artist caps a name at 80 characters; a long default is cut, not refused.
11051
+ name: vfx.name ?? (vfx.description ? defaultName.slice(0, 80) : defaultName),
11052
+ ...vfx.description ? { description: vfx.description, artist: vfx.artist ?? "lite" } : {}
9835
11053
  };
9836
11054
  }
9837
11055
  async function optionalRevisionImage(file, label, requiredFormat) {
9838
- if (!existsSync5(file)) return null;
9839
- const bytes = await readFile4(file);
11056
+ if (!existsSync6(file)) return null;
11057
+ const bytes = await readFile5(file);
9840
11058
  const metadata = imageMetadata(bytes);
9841
11059
  if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
9842
11060
  if (requiredFormat && metadata.format !== requiredFormat) {
@@ -9845,8 +11063,8 @@ async function optionalRevisionImage(file, label, requiredFormat) {
9845
11063
  return { hash: sha256(bytes), ...metadata };
9846
11064
  }
9847
11065
  async function optionalSkeletonSet(file) {
9848
- if (!existsSync5(file)) return null;
9849
- const bytes = await readFile4(file);
11066
+ if (!existsSync6(file)) return null;
11067
+ const bytes = await readFile5(file);
9850
11068
  let json;
9851
11069
  try {
9852
11070
  json = JSON.parse(bytes.toString("utf8"));
@@ -9856,17 +11074,17 @@ async function optionalSkeletonSet(file) {
9856
11074
  return { hash: sha256(bytes), skeleton: parseSkeletonSet(json, file) };
9857
11075
  }
9858
11076
  async function revisionSourceMembers(stem, label) {
9859
- if (existsSync5(stem)) return null;
11077
+ if (existsSync6(stem)) return null;
9860
11078
  const at = (role) => memberPath(stem, role, 0, 2, MediaType.PNG);
9861
11079
  let roles = [];
9862
11080
  for (const set of [CHARACTER_DIRECTIONS_8, CHARACTER_DIRECTIONS_4]) {
9863
- if (set.every((direction) => existsSync5(at(direction)))) {
11081
+ if (set.every((direction) => existsSync6(at(direction)))) {
9864
11082
  roles = [...set];
9865
11083
  break;
9866
11084
  }
9867
11085
  }
9868
11086
  if (!roles.length) {
9869
- for (let index = 0; existsSync5(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
11087
+ for (let index = 0; existsSync6(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
9870
11088
  roles.push(`frame-${String(index).padStart(2, "0")}`);
9871
11089
  }
9872
11090
  }
@@ -9892,8 +11110,8 @@ async function resolveStyleImages(loaded, styleId) {
9892
11110
  if (!style) throw new Error(`Unknown style "${styleId}"`);
9893
11111
  const out = [];
9894
11112
  for (const img of style.styleImages) {
9895
- const file = path7.resolve(loaded.root, img.path);
9896
- const buf = await readFile4(file);
11113
+ const file = path8.resolve(loaded.root, img.path);
11114
+ const buf = await readFile5(file);
9897
11115
  const metadata = imageMetadata(buf);
9898
11116
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
9899
11117
  if (metadata.width < 1 || metadata.height < 1) {
@@ -9923,7 +11141,7 @@ async function resolveCharacterShape(asset, style, kind, files) {
9923
11141
  };
9924
11142
  if (asset.concept) {
9925
11143
  const image = await files.load(asset.concept, "Concept image");
9926
- shape.concept = { path: path7.resolve(files.root, asset.concept), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11144
+ shape.concept = { path: path8.resolve(files.root, asset.concept), sha256: image.hash, width: image.width, height: image.height, format: image.format };
9927
11145
  }
9928
11146
  if (asset.reference) {
9929
11147
  const byDirection = typeof asset.reference === "string" ? { south: asset.reference } : asset.reference;
@@ -9931,7 +11149,7 @@ async function resolveCharacterShape(asset, style, kind, files) {
9931
11149
  for (const [direction, rel] of Object.entries(byDirection)) {
9932
11150
  const image = await files.load(rel, `Reference sprite (${direction})`);
9933
11151
  shape.reference[direction] = {
9934
- path: path7.resolve(files.root, rel),
11152
+ path: path8.resolve(files.root, rel),
9935
11153
  sha256: image.hash,
9936
11154
  width: image.width,
9937
11155
  height: image.height,
@@ -9952,7 +11170,7 @@ async function resolveCharacterShape(asset, style, kind, files) {
9952
11170
  const animationMode = asset.animation.mode ?? (asset.animation.template ? "template" : "v3");
9953
11171
  const pose = async (rel, what) => {
9954
11172
  const image = await files.load(rel, what);
9955
- return { path: path7.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11173
+ return { path: path8.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
9956
11174
  };
9957
11175
  shape.animation = {
9958
11176
  mode: animationMode,
@@ -9983,7 +11201,7 @@ async function resolveObjectProShape(assetId, asset, style, kind, files) {
9983
11201
  const south = typeof asset.reference === "string" ? asset.reference : asset.reference.south;
9984
11202
  if (south) {
9985
11203
  const image = await files.load(south, "Reference sprite");
9986
- shape.reference = { path: path7.resolve(files.root, south), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11204
+ shape.reference = { path: path8.resolve(files.root, south), sha256: image.hash, width: image.width, height: image.height, format: image.format };
9987
11205
  }
9988
11206
  }
9989
11207
  if (asset.state) {
@@ -10011,7 +11229,7 @@ async function resolveObjectProShape(assetId, asset, style, kind, files) {
10011
11229
  }
10012
11230
  const pose = async (rel, what) => {
10013
11231
  const image = await files.load(rel, what);
10014
- return { path: path7.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11232
+ return { path: path8.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
10015
11233
  };
10016
11234
  shape.animation = {
10017
11235
  mode: asset.animation.mode ?? "v3",
@@ -10031,11 +11249,11 @@ function characterDirections(directions) {
10031
11249
  }
10032
11250
 
10033
11251
  // src/project.ts
10034
- import path15 from "path";
11252
+ import path16 from "path";
10035
11253
 
10036
11254
  // src/env.ts
10037
- import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
10038
- import path8 from "path";
11255
+ import { readFileSync as readFileSync3, existsSync as existsSync7 } from "fs";
11256
+ import path9 from "path";
10039
11257
  function loadEnvFiles(dir) {
10040
11258
  const loaded = [];
10041
11259
  for (const [file, vars] of envFiles(dir)) {
@@ -10049,8 +11267,8 @@ function loadEnvFiles(dir) {
10049
11267
  function envFiles(dir) {
10050
11268
  const out = [];
10051
11269
  for (const name of [".env.local", ".env"]) {
10052
- const file = path8.join(dir, name);
10053
- if (!existsSync6(file)) continue;
11270
+ const file = path9.join(dir, name);
11271
+ if (!existsSync7(file)) continue;
10054
11272
  try {
10055
11273
  out.push([file, parseEnv(readFileSync3(file, "utf8"))]);
10056
11274
  } catch {
@@ -10078,12 +11296,12 @@ function parseEnv(contents) {
10078
11296
  }
10079
11297
 
10080
11298
  // src/pipeline/plan.ts
10081
- import { existsSync as existsSync9 } from "fs";
10082
- import path14 from "path";
11299
+ import { existsSync as existsSync10 } from "fs";
11300
+ import path15 from "path";
10083
11301
 
10084
11302
  // src/pipeline/mirror.ts
10085
- import { mkdir as mkdir3, readFile as readFile5, rename as rename2, writeFile as writeFile3 } from "fs/promises";
10086
- import path9 from "path";
11303
+ import { mkdir as mkdir4, readFile as readFile6, rename as rename2, writeFile as writeFile4 } from "fs/promises";
11304
+ import path10 from "path";
10087
11305
 
10088
11306
  // src/palette.ts
10089
11307
  function normalizePalette(values) {
@@ -10220,11 +11438,11 @@ async function deriveMirror(spec, lock, lockPath, opts = {}) {
10220
11438
  const outputs = [];
10221
11439
  const written = [];
10222
11440
  for (const [index, member] of members.entries()) {
10223
- const bytes = flipHorizontal(await readFile5(member.file));
11441
+ const bytes = flipHorizontal(await readFile6(member.file));
10224
11442
  const target = expectedOutputPath(spec, member.role, index, members.length, MediaType.PNG);
10225
- await mkdir3(path9.dirname(target), { recursive: true });
11443
+ await mkdir4(path10.dirname(target), { recursive: true });
10226
11444
  const tmp = `${target}.pixelkiln.tmp`;
10227
- await writeFile3(tmp, bytes);
11445
+ await writeFile4(tmp, bytes);
10228
11446
  await rename2(tmp, target);
10229
11447
  written.push(target);
10230
11448
  outputs.push({
@@ -10284,20 +11502,20 @@ async function deriveMirror(spec, lock, lockPath, opts = {}) {
10284
11502
  }
10285
11503
 
10286
11504
  // src/pipeline/quality-profile.ts
10287
- import { existsSync as existsSync7 } from "fs";
10288
- import path12 from "path";
11505
+ import { existsSync as existsSync8 } from "fs";
11506
+ import path13 from "path";
10289
11507
 
10290
11508
  // src/pipeline/refine.ts
10291
- import path11 from "path";
11509
+ import path12 from "path";
10292
11510
  import { execFile } from "child_process";
10293
- import { mkdtemp, readFile as readFile7, rm as rm3 } from "fs/promises";
11511
+ import { mkdtemp, readFile as readFile8, rm as rm3 } from "fs/promises";
10294
11512
  import { tmpdir } from "os";
10295
11513
  import { promisify } from "util";
10296
11514
 
10297
11515
  // src/artifacts.ts
10298
- import path10 from "path";
11516
+ import path11 from "path";
10299
11517
  import { createHash as createHash4, randomUUID as randomUUID3 } from "crypto";
10300
- import { mkdir as mkdir4, readFile as readFile6, rename as rename3, rm as rm2, writeFile as writeFile4 } from "fs/promises";
11518
+ import { mkdir as mkdir5, readFile as readFile7, rename as rename3, rm as rm2, writeFile as writeFile5 } from "fs/promises";
10301
11519
  var activeTransactions = /* @__PURE__ */ new Set();
10302
11520
  function message(error) {
10303
11521
  return error instanceof Error ? error.message : String(error);
@@ -10306,7 +11524,7 @@ function digest(data) {
10306
11524
  return createHash4("sha256").update(data).digest("hex");
10307
11525
  }
10308
11526
  function portableRelative(from, to) {
10309
- return path10.relative(from, path10.resolve(to)).split(path10.sep).join("/") || ".";
11527
+ return path11.relative(from, path11.resolve(to)).split(path11.sep).join("/") || ".";
10310
11528
  }
10311
11529
  function canonical2(value) {
10312
11530
  if (value === null || typeof value === "string" || typeof value === "boolean") return value;
@@ -10356,12 +11574,12 @@ function parseArtifactManifest(absolute, data) {
10356
11574
  return raw;
10357
11575
  }
10358
11576
  async function readArtifactBundleManifest(manifestPath) {
10359
- const absolute = path10.resolve(manifestPath);
10360
- return parseArtifactManifest(absolute, await readFile6(absolute, "utf8"));
11577
+ const absolute = path11.resolve(manifestPath);
11578
+ return parseArtifactManifest(absolute, await readFile7(absolute, "utf8"));
10361
11579
  }
10362
11580
  async function readOptional(file) {
10363
11581
  try {
10364
- return await readFile6(file);
11582
+ return await readFile7(file);
10365
11583
  } catch (error) {
10366
11584
  if (isCode(error, "ENOENT")) return null;
10367
11585
  throw error;
@@ -10380,8 +11598,8 @@ function processIsAlive(pid) {
10380
11598
  }
10381
11599
  }
10382
11600
  function validTemporaryPath(candidate, destination, type) {
10383
- return path10.dirname(candidate) === path10.dirname(destination) && path10.basename(candidate).startsWith(
10384
- `.${path10.basename(destination)}.pixelkiln-${type}-`
11601
+ return path11.dirname(candidate) === path11.dirname(destination) && path11.basename(candidate).startsWith(
11602
+ `.${path11.basename(destination)}.pixelkiln-${type}-`
10385
11603
  );
10386
11604
  }
10387
11605
  function parseTransaction(journal, data) {
@@ -10401,7 +11619,7 @@ async function removeTransactionFiles(journal) {
10401
11619
  await rm2(transactionMarker(journal), { force: true });
10402
11620
  }
10403
11621
  async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10404
- const journal = path10.resolve(recoveryFile);
11622
+ const journal = path11.resolve(recoveryFile);
10405
11623
  const bytes = await readOptional(journal);
10406
11624
  if (!bytes) {
10407
11625
  await rm2(transactionMarker(journal), { force: true });
@@ -10417,7 +11635,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10417
11635
  );
10418
11636
  }
10419
11637
  for (const entry of transaction.entries) {
10420
- if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path10.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
11638
+ if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path11.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
10421
11639
  throw new Error(
10422
11640
  `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
10423
11641
  );
@@ -10463,7 +11681,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10463
11681
  await removeTransactionFiles(journal);
10464
11682
  }
10465
11683
  function createArtifactBundleManifest(manifestPath, outputs, provenance) {
10466
- const root = path10.dirname(path10.resolve(manifestPath));
11684
+ const root = path11.dirname(path11.resolve(manifestPath));
10467
11685
  const sources = provenance.sources.map((source) => ({
10468
11686
  ...source,
10469
11687
  path: portableRelative(root, source.path)
@@ -10493,11 +11711,11 @@ function withArtifactManifest(manifestPath, outputs, provenance) {
10493
11711
  ];
10494
11712
  }
10495
11713
  async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
10496
- const absoluteManifest = path10.resolve(manifestPath);
11714
+ const absoluteManifest = path11.resolve(manifestPath);
10497
11715
  const recoveryFile = `${absoluteManifest}.transaction`;
10498
11716
  const allowedDestinations = /* @__PURE__ */ new Set([
10499
11717
  absoluteManifest,
10500
- ...outputs.map((output) => path10.resolve(output.path))
11718
+ ...outputs.map((output) => path11.resolve(output.path))
10501
11719
  ]);
10502
11720
  await recoverArtifactTransaction(recoveryFile, allowedDestinations);
10503
11721
  const existingManifestBytes = await readOptional(absoluteManifest);
@@ -10515,13 +11733,13 @@ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, opt
10515
11733
  }
10516
11734
  }
10517
11735
  if (!options.force) {
10518
- const root = path10.dirname(absoluteManifest);
11736
+ const root = path11.dirname(absoluteManifest);
10519
11737
  const recorded = new Map(
10520
- previous?.outputs.map((output) => [path10.resolve(root, output.path), output.sha256]) ?? []
11738
+ previous?.outputs.map((output) => [path11.resolve(root, output.path), output.sha256]) ?? []
10521
11739
  );
10522
11740
  const conflicts = [];
10523
11741
  for (const output of outputs) {
10524
- const destination = path10.resolve(output.path);
11742
+ const destination = path11.resolve(output.path);
10525
11743
  const current = await readOptional(destination);
10526
11744
  if (!current || digest(current) === digest(output.data)) continue;
10527
11745
  const expected = recorded.get(destination);
@@ -10543,15 +11761,15 @@ Pass { force: true } (CLI: --force) to take ownership and replace the complete b
10543
11761
  );
10544
11762
  }
10545
11763
  async function verifyArtifactBundle(manifestPath) {
10546
- const absolute = path10.resolve(manifestPath);
11764
+ const absolute = path11.resolve(manifestPath);
10547
11765
  const raw = await readArtifactBundleManifest(absolute);
10548
- const root = path10.dirname(absolute);
11766
+ const root = path11.dirname(absolute);
10549
11767
  const changedSources = [];
10550
11768
  const changedOutputs = [];
10551
11769
  for (const source of raw.sources) {
10552
11770
  let actual = null;
10553
11771
  try {
10554
- actual = digest(await readFile6(path10.resolve(root, source.path)));
11772
+ actual = digest(await readFile7(path11.resolve(root, source.path)));
10555
11773
  } catch (error) {
10556
11774
  if (!isCode(error, "ENOENT")) throw error;
10557
11775
  }
@@ -10559,7 +11777,7 @@ async function verifyArtifactBundle(manifestPath) {
10559
11777
  }
10560
11778
  for (const output of raw.outputs) {
10561
11779
  try {
10562
- if (digest(await readFile6(path10.resolve(root, output.path))) !== output.sha256) {
11780
+ if (digest(await readFile7(path11.resolve(root, output.path))) !== output.sha256) {
10563
11781
  changedOutputs.push(output.path);
10564
11782
  }
10565
11783
  } catch (error) {
@@ -10615,7 +11833,7 @@ async function writeArtifactBundle(files, options = {}) {
10615
11833
  if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
10616
11834
  const normalized = files.map((file) => {
10617
11835
  if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
10618
- return { destination: path10.resolve(file.path), data: Buffer.from(file.data) };
11836
+ return { destination: path11.resolve(file.path), data: Buffer.from(file.data) };
10619
11837
  });
10620
11838
  const destinations = /* @__PURE__ */ new Set();
10621
11839
  for (const artifact of normalized) {
@@ -10631,7 +11849,7 @@ async function writeArtifactBundle(files, options = {}) {
10631
11849
  const unchanged = [];
10632
11850
  for (const artifact of normalized) {
10633
11851
  try {
10634
- const current = await readFile6(artifact.destination);
11852
+ const current = await readFile7(artifact.destination);
10635
11853
  if (current.equals(artifact.data)) {
10636
11854
  unchanged.push(artifact.destination);
10637
11855
  } else {
@@ -10645,21 +11863,21 @@ async function writeArtifactBundle(files, options = {}) {
10645
11863
  if (!changed.length) return { changed: [], unchanged };
10646
11864
  const token = randomUUID3();
10647
11865
  for (const [index, artifact] of changed.entries()) {
10648
- const basename = path10.basename(artifact.destination);
10649
- artifact.stage = path10.join(
10650
- path10.dirname(artifact.destination),
11866
+ const basename = path11.basename(artifact.destination);
11867
+ artifact.stage = path11.join(
11868
+ path11.dirname(artifact.destination),
10651
11869
  `.${basename}.pixelkiln-stage-${token}-${index}`
10652
11870
  );
10653
11871
  if (artifact.existed) {
10654
- artifact.backup = path10.join(
10655
- path10.dirname(artifact.destination),
11872
+ artifact.backup = path11.join(
11873
+ path11.dirname(artifact.destination),
10656
11874
  `.${basename}.pixelkiln-backup-${token}-${index}`
10657
11875
  );
10658
11876
  }
10659
11877
  }
10660
- const journal = options.recoveryFile ? path10.resolve(options.recoveryFile) : null;
11878
+ const journal = options.recoveryFile ? path11.resolve(options.recoveryFile) : null;
10661
11879
  if (journal) {
10662
- await mkdir4(path10.dirname(journal), { recursive: true });
11880
+ await mkdir5(path11.dirname(journal), { recursive: true });
10663
11881
  const transaction = {
10664
11882
  format: "pixelkiln-artifact-transaction",
10665
11883
  version: 1,
@@ -10673,7 +11891,7 @@ async function writeArtifactBundle(files, options = {}) {
10673
11891
  }))
10674
11892
  };
10675
11893
  try {
10676
- await writeFile4(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
11894
+ await writeFile5(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
10677
11895
  activeTransactions.add(journal);
10678
11896
  } catch (error) {
10679
11897
  throw new Error(
@@ -10684,9 +11902,9 @@ async function writeArtifactBundle(files, options = {}) {
10684
11902
  }
10685
11903
  try {
10686
11904
  for (const [index, artifact] of changed.entries()) {
10687
- await mkdir4(path10.dirname(artifact.destination), { recursive: true });
11905
+ await mkdir5(path11.dirname(artifact.destination), { recursive: true });
10688
11906
  await options.beforeStage?.(artifact.destination, index);
10689
- await writeFile4(artifact.stage, artifact.data, { flag: "wx" });
11907
+ await writeFile5(artifact.stage, artifact.data, { flag: "wx" });
10690
11908
  }
10691
11909
  } catch (error) {
10692
11910
  const cleanupErrors2 = await rollback(changed);
@@ -10709,7 +11927,7 @@ async function writeArtifactBundle(files, options = {}) {
10709
11927
  artifact.promoted = true;
10710
11928
  }
10711
11929
  if (journal) {
10712
- await writeFile4(transactionMarker(journal), `${token}
11930
+ await writeFile5(transactionMarker(journal), `${token}
10713
11931
  `, { flag: "wx" });
10714
11932
  durableCommit = true;
10715
11933
  await options.afterCommit?.();
@@ -10950,16 +12168,16 @@ function sameFrameGrid(first, next) {
10950
12168
  return next.detection.stepX === first.detection.stepX && next.detection.stepY === first.detection.stepY && next.detection.columns === first.detection.columns && next.detection.rows === first.detection.rows && phaseMatches(next.detection.phaseX, first.detection.phaseX, first.detection.stepX) && phaseMatches(next.detection.phaseY, first.detection.phaseY, first.detection.stepY) && next.audit.width === first.audit.width && next.audit.height === first.audit.height && next.sourceWidth === first.sourceWidth && next.sourceHeight === first.sourceHeight;
10951
12169
  }
10952
12170
  async function refineAsset(options) {
10953
- const source = path11.resolve(options.source);
10954
- const output = path11.resolve(options.output);
12171
+ const source = path12.resolve(options.source);
12172
+ const output = path12.resolve(options.output);
10955
12173
  if (source === output) throw new Error("Refinement source and output must be different files.");
10956
- const record = path11.resolve(qualityRecordPath(output));
12174
+ const record = path12.resolve(qualityRecordPath(output));
10957
12175
  const palette = normalizePalette(options.palette).map((color) => color.hex);
10958
12176
  const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
10959
12177
  if (options.minTransparency !== void 0 && (!Number.isFinite(options.minTransparency) || options.minTransparency < 0 || options.minTransparency > 1)) throw new Error("minTransparency must be between 0 and 1.");
10960
12178
  let sourcePng;
10961
12179
  try {
10962
- sourcePng = decodePng(await readFile7(source));
12180
+ sourcePng = decodePng(await readFile8(source));
10963
12181
  } catch (error) {
10964
12182
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
10965
12183
  }
@@ -10976,8 +12194,8 @@ async function refineAsset(options) {
10976
12194
  );
10977
12195
  }
10978
12196
  }
10979
- const temp = await mkdtemp(path11.join(tmpdir(), "pixelkiln-refine-"));
10980
- const recovered = path11.join(temp, "native.png");
12197
+ const temp = await mkdtemp(path12.join(tmpdir(), "pixelkiln-refine-"));
12198
+ const recovered = path12.join(temp, "native.png");
10981
12199
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
10982
12200
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
10983
12201
  try {
@@ -10998,7 +12216,7 @@ async function refineAsset(options) {
10998
12216
  );
10999
12217
  }
11000
12218
  const detection = parseDetection(firstJsonObject(stdout));
11001
- const native = decodePng(await readFile7(recovered));
12219
+ const native = decodePng(await readFile8(recovered));
11002
12220
  const quantized = quantizeToPalette(native, palette);
11003
12221
  const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
11004
12222
  const audit = auditRefinement(
@@ -11062,18 +12280,18 @@ async function refineFrameSet(options) {
11062
12280
  if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
11063
12281
  throw new Error("Frame-set roles must be non-empty and unique.");
11064
12282
  }
11065
- const output = path11.resolve(options.output);
11066
- const record = path11.resolve(qualityRecordPath(output));
12283
+ const output = path12.resolve(options.output);
12284
+ const record = path12.resolve(qualityRecordPath(output));
11067
12285
  const palette = normalizeRefinementPalette(options.palette);
11068
12286
  const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
11069
12287
  if (new Set(finalOutputs).size !== finalOutputs.length) {
11070
12288
  throw new Error("Frame-set roles resolve to duplicate output filenames.");
11071
12289
  }
11072
- const sourceSet = new Set(options.sources.map((source) => path11.resolve(source.path)));
12290
+ const sourceSet = new Set(options.sources.map((source) => path12.resolve(source.path)));
11073
12291
  if (finalOutputs.some((file) => sourceSet.has(file))) {
11074
12292
  throw new Error("Frame-set refinement sources and outputs must be different files.");
11075
12293
  }
11076
- const temp = await mkdtemp(path11.join(tmpdir(), "pixelkiln-refine-frames-"));
12294
+ const temp = await mkdtemp(path12.join(tmpdir(), "pixelkiln-refine-frames-"));
11077
12295
  try {
11078
12296
  const frames = [];
11079
12297
  const outputs = [];
@@ -11081,8 +12299,8 @@ async function refineFrameSet(options) {
11081
12299
  const sourcePaths = [];
11082
12300
  let firstOptions = null;
11083
12301
  for (const [index, sourceEntry] of options.sources.entries()) {
11084
- const source = path11.resolve(sourceEntry.path);
11085
- const temporaryOutput = path11.join(temp, `${String(index).padStart(2, "0")}.png`);
12302
+ const source = path12.resolve(sourceEntry.path);
12303
+ const temporaryOutput = path12.join(temp, `${String(index).padStart(2, "0")}.png`);
11086
12304
  const refined = await refineAsset({
11087
12305
  source,
11088
12306
  output: temporaryOutput,
@@ -11114,7 +12332,7 @@ async function refineFrameSet(options) {
11114
12332
  });
11115
12333
  outputs.push({
11116
12334
  path: finalOutputs[index],
11117
- data: await readFile7(temporaryOutput)
12335
+ data: await readFile8(temporaryOutput)
11118
12336
  });
11119
12337
  }
11120
12338
  const first = frames[0];
@@ -11157,7 +12375,7 @@ async function refineFrameSet(options) {
11157
12375
  );
11158
12376
  return {
11159
12377
  sources: sourcePaths,
11160
- outputs: outputs.map((item) => path11.resolve(item.path)),
12378
+ outputs: outputs.map((item) => path12.resolve(item.path)),
11161
12379
  record,
11162
12380
  frames,
11163
12381
  palette,
@@ -11168,12 +12386,12 @@ async function refineFrameSet(options) {
11168
12386
  }
11169
12387
  }
11170
12388
  async function checkQualityRecord(recordPath) {
11171
- const record = path11.resolve(recordPath);
12389
+ const record = path12.resolve(recordPath);
11172
12390
  const manifest = await readArtifactBundleManifest(record);
11173
12391
  const options = parseRecordOptions(manifest, record);
11174
12392
  const verification = await verifyArtifactBundle(record);
11175
- const sources = manifest.sources.map((source2) => path11.resolve(path11.dirname(record), source2.path));
11176
- const outputs = manifest.outputs.map((output2) => path11.resolve(path11.dirname(record), output2.path));
12393
+ const sources = manifest.sources.map((source2) => path12.resolve(path12.dirname(record), source2.path));
12394
+ const outputs = manifest.outputs.map((output2) => path12.resolve(path12.dirname(record), output2.path));
11177
12395
  const source = sources[0] ?? "";
11178
12396
  const output = outputs[0] ?? "";
11179
12397
  const expectedCount = options.frameSet?.count ?? 1;
@@ -11206,7 +12424,7 @@ async function checkQualityRecord(recordPath) {
11206
12424
  async function approveQualityRecord(recordPath, approval) {
11207
12425
  const reviewer = approval.reviewer.trim();
11208
12426
  if (!reviewer) throw new Error("A human reviewer name is required.");
11209
- const record = path11.resolve(recordPath);
12427
+ const record = path12.resolve(recordPath);
11210
12428
  const manifest = await readArtifactBundleManifest(record);
11211
12429
  const options = parseRecordOptions(manifest, record);
11212
12430
  const verification = await verifyArtifactBundle(record);
@@ -11227,14 +12445,14 @@ async function approveQualityRecord(recordPath, approval) {
11227
12445
  `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
11228
12446
  );
11229
12447
  }
11230
- const root = path11.dirname(record);
12448
+ const root = path12.dirname(record);
11231
12449
  const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
11232
- const outputPath = path11.resolve(root, entry.path);
11233
- return { path: outputPath, data: await readFile7(outputPath) };
12450
+ const outputPath = path12.resolve(root, entry.path);
12451
+ return { path: outputPath, data: await readFile8(outputPath) };
11234
12452
  }));
11235
12453
  const sources = manifest.sources.map((source) => ({
11236
12454
  ...source,
11237
- path: path11.resolve(root, source.path)
12455
+ path: path12.resolve(root, source.path)
11238
12456
  }));
11239
12457
  const approved = {
11240
12458
  ...options,
@@ -11266,8 +12484,8 @@ async function qualitySource(spec, lock) {
11266
12484
  if (spec.generator === "frames") {
11267
12485
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
11268
12486
  }
11269
- const source = path12.resolve(spec.root, spec.source);
11270
- return existsSync7(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
12487
+ const source = path13.resolve(spec.root, spec.source);
12488
+ return existsSync8(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
11271
12489
  }
11272
12490
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
11273
12491
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -11296,7 +12514,7 @@ async function qualitySource(spec, lock) {
11296
12514
  }
11297
12515
  if (role) roles.add(role);
11298
12516
  const source = currentEntryOutputPath(entry, spec, index);
11299
- if (!existsSync7(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
12517
+ if (!existsSync8(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
11300
12518
  if (await sha256File(source) !== output.sha256) {
11301
12519
  return { items: null, reason: "raw provider output was modified after download" };
11302
12520
  }
@@ -11315,7 +12533,7 @@ async function inspectQualityProfile(spec, lock) {
11315
12533
  }
11316
12534
  const sourcePaths = source.items.map((item) => item.path);
11317
12535
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
11318
- if (!existsSync7(record)) {
12536
+ if (!existsSync8(record)) {
11319
12537
  return {
11320
12538
  key,
11321
12539
  state: "needs-refinement",
@@ -11333,7 +12551,7 @@ async function inspectQualityProfile(spec, lock) {
11333
12551
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
11334
12552
  const expectedTransparency = spec.quality.minTransparency ?? null;
11335
12553
  const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
11336
- const contractChanged = JSON.stringify(check.sources.map((item) => path12.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path12.resolve(item))) || JSON.stringify(check.outputs.map((item) => path12.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path12.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
12554
+ const contractChanged = JSON.stringify(check.sources.map((item) => path13.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path13.resolve(item))) || JSON.stringify(check.outputs.map((item) => path13.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path13.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
11337
12555
  if (contractChanged) {
11338
12556
  return {
11339
12557
  key,
@@ -11496,8 +12714,8 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
11496
12714
  }
11497
12715
 
11498
12716
  // src/pipeline/revision.ts
11499
- import { existsSync as existsSync8 } from "fs";
11500
- import path13 from "path";
12717
+ import { existsSync as existsSync9 } from "fs";
12718
+ import path14 from "path";
11501
12719
  async function inspectRevisionReadiness(spec, lock) {
11502
12720
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
11503
12721
  if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
@@ -11506,6 +12724,7 @@ async function inspectRevisionReadiness(spec, lock) {
11506
12724
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
11507
12725
  if (spec.scene) return inspectScene(spec);
11508
12726
  if (spec.vfx?.sprite && "file" in spec.vfx.sprite) return inspectVfxSprite(spec);
12727
+ if (spec.terrainConnect) return inspectTerrainConnect(spec, lock, /* @__PURE__ */ new Set());
11509
12728
  if (!spec.revision) return null;
11510
12729
  return inspectRevision(spec, lock, /* @__PURE__ */ new Set());
11511
12730
  }
@@ -11514,7 +12733,7 @@ async function inspectStyleAnchor(spec, lock, seen) {
11514
12733
  const label = `style anchor ${spec.styleId}/${anchor.assetId}`;
11515
12734
  const dependency = await inspectParent(anchor.spec, lock, seen);
11516
12735
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11517
- if (!anchor.sha256 || !existsSync8(anchor.file)) return { ready: false, reason: `${label} has no generated south-facing file yet` };
12736
+ if (!anchor.sha256 || !existsSync9(anchor.file)) return { ready: false, reason: `${label} has no generated south-facing file yet` };
11518
12737
  if (await sha256File(anchor.file) !== anchor.sha256) {
11519
12738
  return { ready: false, reason: `${label} changed after the manifest was resolved` };
11520
12739
  }
@@ -11523,19 +12742,31 @@ async function inspectStyleAnchor(spec, lock, seen) {
11523
12742
  }
11524
12743
  return { ready: true, reason: `${label} is current` };
11525
12744
  }
12745
+ async function inspectTerrainConnect(spec, lock, seen) {
12746
+ for (const link of [spec.terrainConnect.lower, spec.terrainConnect.upper]) {
12747
+ if (!link) continue;
12748
+ const label = `connected set ${spec.styleId}/${link.assetId}`;
12749
+ const dependency = await inspectParent(link.spec, lock, seen);
12750
+ if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
12751
+ if (!lock.entries[lockKey(link.spec.styleId, link.spec.assetId)]?.objectId) {
12752
+ return { ready: false, reason: `${label} has no PixelLab tileset id recorded` };
12753
+ }
12754
+ }
12755
+ return { ready: true, reason: "connected sets are current" };
12756
+ }
11526
12757
  async function inspectScene(spec) {
11527
12758
  const scene = spec.scene;
11528
12759
  const files = [{ label: "scene image", file: scene.file, sha256: scene.sha256 }];
11529
12760
  if (scene.placement.type === "mask") files.push({ label: "scene mask", file: scene.placement.file, sha256: scene.placement.sha256 });
11530
12761
  for (const { label, file, sha256: sha2562 } of files) {
11531
- if (!sha2562 || !existsSync8(file)) return { ready: false, reason: `${label} does not exist yet: ${file}` };
12762
+ if (!sha2562 || !existsSync9(file)) return { ready: false, reason: `${label} does not exist yet: ${file}` };
11532
12763
  if (await sha256File(file) !== sha2562) return { ready: false, reason: `${label} changed after the manifest was resolved` };
11533
12764
  }
11534
12765
  return { ready: true, reason: "scene is current" };
11535
12766
  }
11536
12767
  async function inspectVfxSprite(spec) {
11537
12768
  const sprite = spec.vfx.sprite;
11538
- if (!sprite.sha256 || !existsSync8(sprite.file)) return { ready: false, reason: `vfx sprite does not exist yet: ${sprite.file}` };
12769
+ if (!sprite.sha256 || !existsSync9(sprite.file)) return { ready: false, reason: `vfx sprite does not exist yet: ${sprite.file}` };
11539
12770
  if (await sha256File(sprite.file) !== sprite.sha256) return { ready: false, reason: "vfx sprite changed after the manifest was resolved" };
11540
12771
  return { ready: true, reason: "vfx sprite is current" };
11541
12772
  }
@@ -11551,7 +12782,7 @@ async function inspectOutfit(spec, lock, seen) {
11551
12782
  const label = `outfit source ${spec.styleId}/${character.parentAssetId}`;
11552
12783
  const dependency = await inspectParent(character.parentSpec, lock, seen);
11553
12784
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11554
- if (!existsSync8(outfit.reference.path)) {
12785
+ if (!existsSync9(outfit.reference.path)) {
11555
12786
  return { ready: false, reason: `outfit reference is missing: ${outfit.reference.path}` };
11556
12787
  }
11557
12788
  if (await sha256File(outfit.reference.path) !== outfit.reference.sha256) {
@@ -11565,7 +12796,7 @@ async function inspectCharacterParent(spec, lock, seen) {
11565
12796
  const label = `${character.kind} parent ${spec.styleId}/${character.parentAssetId}`;
11566
12797
  const dependency = await inspectParent(parentSpec, lock, seen);
11567
12798
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11568
- if (!character.parentSha256 || !character.parentFile || !existsSync8(character.parentFile)) {
12799
+ if (!character.parentSha256 || !character.parentFile || !existsSync9(character.parentFile)) {
11569
12800
  return { ready: false, reason: `${label} has no generated south-facing file yet` };
11570
12801
  }
11571
12802
  if (await sha256File(character.parentFile) !== character.parentSha256) {
@@ -11583,7 +12814,7 @@ async function inspectObjectProParent(spec, lock, seen) {
11583
12814
  const label = `${object.kind} parent ${spec.styleId}/${object.parentAssetId}`;
11584
12815
  const dependency = await inspectParent(parentSpec, lock, seen);
11585
12816
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11586
- if (!object.parentSha256 || !object.parentFile || !existsSync8(object.parentFile)) {
12817
+ if (!object.parentSha256 || !object.parentFile || !existsSync9(object.parentFile)) {
11587
12818
  return { ready: false, reason: `${label} has no generated south-facing file yet` };
11588
12819
  }
11589
12820
  if (await sha256File(object.parentFile) !== object.parentSha256) {
@@ -11609,7 +12840,7 @@ async function inspectRevision(spec, lock, seen) {
11609
12840
  const members = await inspectSourceMembers(spec, lock);
11610
12841
  if (!members.ready) return members;
11611
12842
  } else {
11612
- if (!revision.sourceSha256 || !existsSync8(revision.sourceFile)) {
12843
+ if (!revision.sourceSha256 || !existsSync9(revision.sourceFile)) {
11613
12844
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
11614
12845
  }
11615
12846
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
@@ -11617,7 +12848,7 @@ async function inspectRevision(spec, lock, seen) {
11617
12848
  }
11618
12849
  }
11619
12850
  if (revision.maskFile) {
11620
- if (!revision.maskSha256 || !existsSync8(revision.maskFile)) {
12851
+ if (!revision.maskSha256 || !existsSync9(revision.maskFile)) {
11621
12852
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
11622
12853
  }
11623
12854
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -11625,7 +12856,7 @@ async function inspectRevision(spec, lock, seen) {
11625
12856
  }
11626
12857
  }
11627
12858
  if (revision.lastFrameFile) {
11628
- if (!revision.lastFrameSha256 || !existsSync8(revision.lastFrameFile)) {
12859
+ if (!revision.lastFrameSha256 || !existsSync9(revision.lastFrameFile)) {
11629
12860
  return { ready: false, reason: `revision last frame is missing: ${revision.lastFrameFile}` };
11630
12861
  }
11631
12862
  if (await sha256File(revision.lastFrameFile) !== revision.lastFrameSha256) {
@@ -11638,7 +12869,7 @@ async function inspectSourceMembers(spec, lock) {
11638
12869
  const revision = spec.revision;
11639
12870
  const members = revision.sourceMembers;
11640
12871
  for (const member of members) {
11641
- if (!existsSync8(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
12872
+ if (!existsSync9(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
11642
12873
  if (await sha256File(member.file) !== member.sha256) {
11643
12874
  return { ready: false, reason: `revision source member changed after the manifest was resolved: ${member.file}` };
11644
12875
  }
@@ -11647,8 +12878,8 @@ async function inspectSourceMembers(spec, lock) {
11647
12878
  if (parent.source) return { ready: true, reason: "declared source set is present" };
11648
12879
  const entry = lock.entries[lockKey(parent.styleId, parent.assetId)];
11649
12880
  if (!entry) return { ready: false, reason: "revision source is not in the lockfile" };
11650
- const recorded = entry.outputs.map((_, index) => path13.resolve(currentEntryOutputPath(entry, parent, index))).sort();
11651
- const found = members.map((member) => path13.resolve(member.file)).sort();
12881
+ const recorded = entry.outputs.map((_, index) => path14.resolve(currentEntryOutputPath(entry, parent, index))).sort();
12882
+ const found = members.map((member) => path14.resolve(member.file)).sort();
11652
12883
  if (recorded.length !== found.length || recorded.some((file, index) => file !== found[index])) {
11653
12884
  return {
11654
12885
  ready: false,
@@ -11689,6 +12920,10 @@ async function inspectParent(spec, lock, seen) {
11689
12920
  const inputs = await inspectObjectProParent(spec, lock, nextSeen);
11690
12921
  if (!inputs.ready) return inputs;
11691
12922
  }
12923
+ if (spec.terrainConnect) {
12924
+ const inputs = await inspectTerrainConnect(spec, lock, nextSeen);
12925
+ if (!inputs.ready) return inputs;
12926
+ }
11692
12927
  if (spec.quality) {
11693
12928
  const quality = await inspectQualityProfile(spec, lock);
11694
12929
  return quality?.state === "approved" ? { ready: true, reason: "approved quality output is current" } : {
@@ -11697,8 +12932,8 @@ async function inspectParent(spec, lock, seen) {
11697
12932
  };
11698
12933
  }
11699
12934
  if (spec.source) {
11700
- const source = path13.resolve(spec.root, spec.source);
11701
- return existsSync8(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
12935
+ const source = path14.resolve(spec.root, spec.source);
12936
+ return existsSync9(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
11702
12937
  }
11703
12938
  const entry = lock.entries[key];
11704
12939
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -11712,7 +12947,7 @@ async function inspectParent(spec, lock, seen) {
11712
12947
  for (let index = 0; index < entry.outputs.length; index++) {
11713
12948
  const output = entry.outputs[index];
11714
12949
  const file = currentEntryOutputPath(entry, spec, index);
11715
- if (!existsSync8(file)) return { ready: false, reason: `parent output is missing: ${file}` };
12950
+ if (!existsSync9(file)) return { ready: false, reason: `parent output is missing: ${file}` };
11716
12951
  if (await sha256File(file) !== output.sha256) {
11717
12952
  return { ready: false, reason: `parent output was modified: ${file}` };
11718
12953
  }
@@ -11734,9 +12969,21 @@ function dependencies(spec) {
11734
12969
  spec.revision?.sourceSpec,
11735
12970
  spec.character?.parentSpec,
11736
12971
  spec.character?.styleAnchor?.spec,
11737
- spec.objectPro?.parentSpec
12972
+ spec.objectPro?.parentSpec,
12973
+ spec.terrainConnect?.lower?.spec,
12974
+ spec.terrainConnect?.upper?.spec
11738
12975
  ].filter((parent) => Boolean(parent));
11739
12976
  }
12977
+ function changedTerrainConnection(spec, entry, lock) {
12978
+ for (const side of ["lower", "upper"]) {
12979
+ const link = spec.terrainConnect?.[side];
12980
+ if (!link) continue;
12981
+ const other = lock.entries[lockKey(link.spec.styleId, link.spec.assetId)];
12982
+ const recorded = entry.terrainConnect?.[side];
12983
+ if (!recorded || recorded.sourceObjectId !== other?.objectId) return link.assetId;
12984
+ }
12985
+ return null;
12986
+ }
11740
12987
  function resumeCommandForStatus(status) {
11741
12988
  if (status === "pending" || status === "processing") return "poll";
11742
12989
  if (status === "review") return "pick";
@@ -11768,6 +13015,7 @@ async function buildPlan(specs, lock, opts = {}) {
11768
13015
  const entry = lock.entries[key];
11769
13016
  let state;
11770
13017
  let reason;
13018
+ const changedConnection = entry ? changedTerrainConnection(spec, entry, lock) : null;
11771
13019
  const revision = await inspectRevisionReadiness(spec, lock);
11772
13020
  if (revision && !revision.ready) {
11773
13021
  state = "blocked";
@@ -11776,7 +13024,7 @@ async function buildPlan(specs, lock, opts = {}) {
11776
13024
  state = "missing";
11777
13025
  reason = "--force";
11778
13026
  } else if (!entry && spec.source && spec.generator !== "frames" && spec.generator !== "vfx") {
11779
- if (existsSync9(path14.resolve(spec.root, spec.source))) {
13027
+ if (existsSync10(path15.resolve(spec.root, spec.source))) {
11780
13028
  state = "ok";
11781
13029
  reason = `placed from ${spec.source}; not generated`;
11782
13030
  } else {
@@ -11784,7 +13032,7 @@ async function buildPlan(specs, lock, opts = {}) {
11784
13032
  reason = `declared source is missing: ${spec.source}`;
11785
13033
  }
11786
13034
  } else if (!entry) {
11787
- if (existsSync9(spec.outFile)) {
13035
+ if (existsSync10(spec.outFile)) {
11788
13036
  state = "untracked";
11789
13037
  reason = "file on disk, no provenance recorded";
11790
13038
  } else {
@@ -11797,12 +13045,15 @@ async function buildPlan(specs, lock, opts = {}) {
11797
13045
  } else if (spec.mirror && !mirrorCurrent(entry, lock.entries[lockKey(spec.styleId, spec.mirror.sourceAssetId)])) {
11798
13046
  state = "stale";
11799
13047
  reason = `mirror source ${spec.mirror.sourceAssetId} changed; flip it again (no generation cost)`;
11800
- } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index)))) {
13048
+ } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !existsSync10(currentEntryOutputPath(entry, spec, index)))) {
11801
13049
  state = "stale";
11802
13050
  reason = "mirror output missing on disk; flip it again (no generation cost)";
11803
13051
  } else if (spec.character?.kind === "outfit" && entry.outfit?.sourceSha256 !== mirrorSourceHash(lock.entries[lockKey(spec.styleId, spec.character.parentAssetId)] ?? { outputs: [] })) {
11804
13052
  state = "stale";
11805
13053
  reason = `outfit source ${spec.character.parentAssetId} changed; re-clothe the loop again`;
13054
+ } else if (changedConnection) {
13055
+ state = "stale";
13056
+ reason = `connected set ${changedConnection} was regenerated; draw this set from its new terrain`;
11806
13057
  } else if (entry.status === "download-failed") {
11807
13058
  state = "recoverable";
11808
13059
  const force = entry.error?.includes("pass --force") ? " --force" : "";
@@ -11825,9 +13076,9 @@ async function buildPlan(specs, lock, opts = {}) {
11825
13076
  } else if (entry.status !== "downloaded") {
11826
13077
  state = "in-flight";
11827
13078
  reason = `awaiting ${entry.status}`;
11828
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index)))) {
13079
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync10(currentEntryOutputPath(entry, spec, index)))) {
11829
13080
  state = "orphaned";
11830
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
13081
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync10(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
11831
13082
  } else if (await anyOutputModified(entry, spec)) {
11832
13083
  state = "orphaned";
11833
13084
  reason = "output modified since download";
@@ -11918,13 +13169,13 @@ function summarize(plan) {
11918
13169
 
11919
13170
  // src/project.ts
11920
13171
  async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {}) {
11921
- const absolute = path15.resolve(manifestPath);
11922
- const root = path15.dirname(absolute);
13172
+ const absolute = path16.resolve(manifestPath);
13173
+ const root = path16.dirname(absolute);
11923
13174
  if (opts.env !== false) {
11924
13175
  loadEnvFiles(root);
11925
- if (path15.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
13176
+ if (path16.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
11926
13177
  }
11927
- const lockPath = path15.resolve(opts.lockPath ?? path15.join(root, "pixelkiln.lock.json"));
13178
+ const lockPath = path16.resolve(opts.lockPath ?? path16.join(root, "pixelkiln.lock.json"));
11928
13179
  const loaded = await loadManifest(absolute);
11929
13180
  const specs = await resolveSpecs(loaded, { styles: opts.styles ?? [], assets: opts.assets ?? [] });
11930
13181
  const lock = await loadLock(lockPath);
@@ -11953,47 +13204,10 @@ async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {})
11953
13204
  };
11954
13205
  }
11955
13206
 
11956
- // src/source-url.ts
11957
- var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
11958
- "access_token",
11959
- "apikey",
11960
- "api_key",
11961
- "auth",
11962
- "authorization",
11963
- "awsaccesskeyid",
11964
- "googleaccessid",
11965
- "key",
11966
- "sig"
11967
- ]);
11968
- function isSensitiveSourceUrl(value) {
11969
- let parsed;
11970
- try {
11971
- parsed = new URL(value);
11972
- } catch {
11973
- return false;
11974
- }
11975
- if (parsed.username || parsed.password) return true;
11976
- for (const key of parsed.searchParams.keys()) {
11977
- const normalized = key.toLowerCase();
11978
- if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
11979
- }
11980
- return false;
11981
- }
11982
- function shouldPersistSourceUrl(value) {
11983
- let parsed;
11984
- try {
11985
- parsed = new URL(value);
11986
- } catch {
11987
- return true;
11988
- }
11989
- if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
11990
- return !isSensitiveSourceUrl(value);
11991
- }
11992
-
11993
13207
  // src/recipes.ts
11994
- import path16 from "path";
11995
- import { existsSync as existsSync10 } from "fs";
11996
- import { readdir, readFile as readFile8 } from "fs/promises";
13208
+ import path17 from "path";
13209
+ import { existsSync as existsSync11 } from "fs";
13210
+ import { readdir, readFile as readFile9 } from "fs/promises";
11997
13211
  import { fileURLToPath } from "url";
11998
13212
  import { z as z4 } from "zod";
11999
13213
  var SHA256_RE = /^[0-9a-f]{64}$/;
@@ -12002,7 +13216,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
12002
13216
  var RECIPE_FILE = "pixelkiln.recipe.json";
12003
13217
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
12004
13218
  var PortablePathSchema = z4.string().min(1).refine((value) => {
12005
- if (value.includes("\\") || path16.posix.isAbsolute(value)) return false;
13219
+ if (value.includes("\\") || path17.posix.isAbsolute(value)) return false;
12006
13220
  const parts = value.split("/");
12007
13221
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
12008
13222
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -12178,10 +13392,10 @@ function message3(error) {
12178
13392
  return error instanceof Error ? error.message : String(error);
12179
13393
  }
12180
13394
  async function readRecipeFile(recipePath, bundled) {
12181
- const absolute = path16.resolve(recipePath);
13395
+ const absolute = path17.resolve(recipePath);
12182
13396
  let raw;
12183
13397
  try {
12184
- raw = JSON.parse(await readFile8(absolute, "utf8"));
13398
+ raw = JSON.parse(await readFile9(absolute, "utf8"));
12185
13399
  } catch (error) {
12186
13400
  throw new Error(`Could not read recipe ${absolute}: ${message3(error)}`, { cause: error });
12187
13401
  }
@@ -12192,13 +13406,13 @@ async function readRecipeFile(recipePath, bundled) {
12192
13406
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
12193
13407
  );
12194
13408
  }
12195
- return { recipe: parsed.data, authored: raw, path: absolute, dir: path16.dirname(absolute), bundled };
13409
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: path17.dirname(absolute), bundled };
12196
13410
  }
12197
13411
  async function walkRecipeFiles(root) {
12198
- if (!existsSync10(root)) return [];
13412
+ if (!existsSync11(root)) return [];
12199
13413
  const out = [];
12200
13414
  for (const entry of await readdir(root, { withFileTypes: true })) {
12201
- const resolved = path16.join(root, entry.name);
13415
+ const resolved = path17.join(root, entry.name);
12202
13416
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
12203
13417
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
12204
13418
  }
@@ -12209,7 +13423,7 @@ function bundledRecipeRoot() {
12209
13423
  }
12210
13424
  async function listBundledRecipes(root = bundledRecipeRoot()) {
12211
13425
  const recipes = await Promise.all(
12212
- (await walkRecipeFiles(path16.resolve(root))).map((file) => readRecipeFile(file, true))
13426
+ (await walkRecipeFiles(path17.resolve(root))).map((file) => readRecipeFile(file, true))
12213
13427
  );
12214
13428
  return recipes.sort(
12215
13429
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -12232,9 +13446,9 @@ function parseSelector(selector) {
12232
13446
  return { id: match[1], version: match[2] };
12233
13447
  }
12234
13448
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12235
- const local = path16.resolve(target);
12236
- if (existsSync10(local)) {
12237
- const recipePath = path16.basename(local) === RECIPE_FILE ? local : path16.join(local, RECIPE_FILE);
13449
+ const local = path17.resolve(target);
13450
+ if (existsSync11(local)) {
13451
+ const recipePath = path17.basename(local) === RECIPE_FILE ? local : path17.join(local, RECIPE_FILE);
12238
13452
  return readRecipeFile(recipePath, false);
12239
13453
  }
12240
13454
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -12248,8 +13462,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12248
13462
  return matches[0];
12249
13463
  }
12250
13464
  async function verifyFile(root, file) {
12251
- const absolute = path16.resolve(root, ...file.path.split("/"));
12252
- if (!existsSync10(absolute)) {
13465
+ const absolute = path17.resolve(root, ...file.path.split("/"));
13466
+ if (!existsSync11(absolute)) {
12253
13467
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
12254
13468
  }
12255
13469
  const actualSha256 = await sha256File(absolute);
@@ -12264,7 +13478,7 @@ async function verifyRecipe(target, options = {}) {
12264
13478
  const loaded = await resolveRecipe(target, options.bundledRoot);
12265
13479
  const actualIntegrity = recipeDigest(loaded.authored);
12266
13480
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
12267
- const modelRoot = options.modelRoot ? path16.resolve(options.modelRoot) : null;
13481
+ const modelRoot = options.modelRoot ? path17.resolve(options.modelRoot) : null;
12268
13482
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
12269
13483
  ...await verifyFile(modelRoot, model),
12270
13484
  source: model.source,
@@ -12298,7 +13512,7 @@ async function verifyRecipe(target, options = {}) {
12298
13512
  };
12299
13513
  }
12300
13514
  function renderStyle(style, destination, cwd) {
12301
- const portableDir = path16.relative(cwd, destination).split(path16.sep).join("/") || ".";
13515
+ const portableDir = path17.relative(cwd, destination).split(path17.sep).join("/") || ".";
12302
13516
  const replace = (value) => {
12303
13517
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
12304
13518
  if (Array.isArray(value)) return value.map(replace);
@@ -12310,25 +13524,25 @@ function renderStyle(style, destination, cwd) {
12310
13524
  return replace(style);
12311
13525
  }
12312
13526
  async function installRecipe(target, options = {}) {
12313
- const cwd = path16.resolve(options.cwd ?? process.cwd());
13527
+ const cwd = path17.resolve(options.cwd ?? process.cwd());
12314
13528
  const loaded = await resolveRecipe(target, options.bundledRoot);
12315
13529
  const verification = await verifyRecipe(loaded.path);
12316
13530
  if (!verification.ok) {
12317
13531
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
12318
13532
  }
12319
- const destination = options.out ? path16.resolve(cwd, options.out) : path16.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
13533
+ const destination = options.out ? path17.resolve(cwd, options.out) : path17.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
12320
13534
  const sources = [
12321
13535
  { path: RECIPE_FILE, source: loaded.path },
12322
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path16.join(loaded.dir, ...file.path.split("/")) }))
13536
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path17.join(loaded.dir, ...file.path.split("/")) }))
12323
13537
  ];
12324
13538
  const files = await Promise.all(sources.map(async (file) => ({
12325
- path: path16.join(destination, ...file.path.split("/")),
12326
- data: await readFile8(file.source)
13539
+ path: path17.join(destination, ...file.path.split("/")),
13540
+ data: await readFile9(file.source)
12327
13541
  })));
12328
13542
  if (!options.force) {
12329
13543
  for (const file of files) {
12330
- if (!existsSync10(file.path)) continue;
12331
- const current = await readFile8(file.path);
13544
+ if (!existsSync11(file.path)) continue;
13545
+ const current = await readFile9(file.path);
12332
13546
  if (!current.equals(file.data)) {
12333
13547
  throw new OverwriteRefusedError(
12334
13548
  `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
@@ -12352,9 +13566,9 @@ async function installRecipe(target, options = {}) {
12352
13566
  }
12353
13567
 
12354
13568
  // src/pipeline/audit.ts
12355
- import { readFile as readFile9 } from "fs/promises";
12356
- import { existsSync as existsSync11 } from "fs";
12357
- import path17 from "path";
13569
+ import { readFile as readFile10 } from "fs/promises";
13570
+ import { existsSync as existsSync12 } from "fs";
13571
+ import path18 from "path";
12358
13572
  function paletteDistance(asset, reference) {
12359
13573
  if (!asset.length || !reference.length) return 0;
12360
13574
  let total = 0;
@@ -12390,12 +13604,12 @@ async function auditStyle(loaded, specs, styleId, lock) {
12390
13604
  const unreadable = [];
12391
13605
  for (const spec of mine) {
12392
13606
  for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
12393
- if (!existsSync11(output.absolutePath)) {
13607
+ if (!existsSync12(output.absolutePath)) {
12394
13608
  missing.push(output.id);
12395
13609
  continue;
12396
13610
  }
12397
13611
  try {
12398
- const png = decodePng(await readFile9(output.absolutePath));
13612
+ const png = decodePng(await readFile10(output.absolutePath));
12399
13613
  const palette = extractPalette(png, 12);
12400
13614
  assets.push({
12401
13615
  assetId: spec.assetId,
@@ -12419,10 +13633,10 @@ async function auditStyle(loaded, specs, styleId, lock) {
12419
13633
  let referenceFromStyleImages = false;
12420
13634
  const refPalettes = [];
12421
13635
  for (const img of style.styleImages) {
12422
- const abs = path17.resolve(loaded.root, img.path);
12423
- if (!existsSync11(abs)) continue;
13636
+ const abs = path18.resolve(loaded.root, img.path);
13637
+ if (!existsSync12(abs)) continue;
12424
13638
  try {
12425
- refPalettes.push(extractPalette(decodePng(await readFile9(abs)), 12));
13639
+ refPalettes.push(extractPalette(decodePng(await readFile10(abs)), 12));
12426
13640
  } catch {
12427
13641
  }
12428
13642
  }
@@ -12497,144 +13711,6 @@ function hex(c) {
12497
13711
  return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
12498
13712
  }
12499
13713
 
12500
- // src/pixellab-utils.ts
12501
- import { existsSync as existsSync12 } from "fs";
12502
- import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
12503
- import path18 from "path";
12504
- var UNZOOM_MIN_SIDE = 256;
12505
- var UNZOOM_MAX_AREA = 2048 * 2048;
12506
- function defaultUnzoomOut(input) {
12507
- const ext = path18.extname(input);
12508
- return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
12509
- }
12510
- async function unzoomFile(client2, input, opts = {}) {
12511
- const out = path18.resolve(opts.out ?? defaultUnzoomOut(input));
12512
- if (path18.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
12513
- if (existsSync12(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
12514
- const bytes = await readFile10(input);
12515
- const metadata = imageMetadata(bytes);
12516
- if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
12517
- const { width, height, format } = metadata;
12518
- if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
12519
- throw new Error(
12520
- `${input} is ${width}x${height}; PixelLab's unzoom needs at least ${UNZOOM_MIN_SIDE}x${UNZOOM_MIN_SIDE} to find the pixel grid, so this is probably already at its native size`
12521
- );
12522
- }
12523
- if (width * height > UNZOOM_MAX_AREA) {
12524
- throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
12525
- }
12526
- const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
12527
- await mkdir5(path18.dirname(out), { recursive: true });
12528
- await writeFile5(out, res.png);
12529
- return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
12530
- }
12531
- var FONT_WEIGHTS = ["Bold", "Regular"];
12532
- var FONT_GLYPH_SIZES = [8, 16, 32, 64];
12533
- var FONT_COST_GENERATIONS = 15;
12534
- function fontOutputPaths(out) {
12535
- const base = path18.resolve(out.replace(/\.(ttf|png)$/i, ""));
12536
- return { ttf: `${base}.ttf`, atlas: `${base}.png` };
12537
- }
12538
- async function generateFont(client2, opts) {
12539
- const { ttf, atlas } = fontOutputPaths(opts.out);
12540
- if (!opts.force) {
12541
- for (const file of [ttf, atlas]) {
12542
- if (existsSync12(file)) throw new Error(`${file} already exists; pass --force to replace it`);
12543
- }
12544
- }
12545
- const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
12546
- const interval = opts.intervalMs ?? 5e3;
12547
- const timeout = opts.timeoutMs ?? 10 * 6e4;
12548
- const submitted = await client2.generateFontPro({
12549
- description: opts.description,
12550
- weight: opts.weight,
12551
- glyphPx: opts.glyphPx,
12552
- seed: opts.seed,
12553
- fontName: opts.fontName
12554
- });
12555
- const jobId = submitted.background_job_id;
12556
- const started = Date.now();
12557
- for (; ; ) {
12558
- const job = await client2.getFontJob(jobId);
12559
- opts.onProgress?.(job.status);
12560
- if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
12561
- if (job.status === "completed") {
12562
- if (!job.download_ttf_url || !job.download_atlas_url) {
12563
- throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
12564
- }
12565
- const [ttfBytes, atlasBytes] = await Promise.all([
12566
- client2.download(job.download_ttf_url),
12567
- client2.download(job.download_atlas_url)
12568
- ]);
12569
- await mkdir5(path18.dirname(ttf), { recursive: true });
12570
- await writeFile5(ttf, ttfBytes);
12571
- await writeFile5(atlas, atlasBytes);
12572
- return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
12573
- }
12574
- if (Date.now() - started > timeout) {
12575
- throw new Error(
12576
- `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
12577
- );
12578
- }
12579
- await sleep3(interval);
12580
- }
12581
- }
12582
- var PIXELATE_COST_GENERATIONS = 6;
12583
- var PIXELATE_MAX_SIDE = 2048;
12584
- function defaultPixelateOut(input) {
12585
- const ext = path18.extname(input);
12586
- return `${ext ? input.slice(0, -ext.length) : input}.pixel.png`;
12587
- }
12588
- async function pixelateFile(client2, input, opts = {}) {
12589
- const out = path18.resolve(opts.out ?? defaultPixelateOut(input));
12590
- if (path18.resolve(input) === out) throw new Error("pixelate will not overwrite its own input; pass a different --out");
12591
- if (existsSync12(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
12592
- const bytes = await readFile10(input);
12593
- const metadata = imageMetadata(bytes);
12594
- if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
12595
- const { width, height, format } = metadata;
12596
- const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
12597
- const interval = opts.intervalMs ?? 5e3;
12598
- const timeout = opts.timeoutMs ?? 10 * 6e4;
12599
- const submitted = await client2.imageToPixelartProFlash({
12600
- image: { base64: bytes.toString("base64"), format },
12601
- description: opts.description,
12602
- seed: opts.seed
12603
- });
12604
- const jobId = submitted.background_job_id;
12605
- const started = Date.now();
12606
- for (; ; ) {
12607
- const job = await client2.getBackgroundJob(jobId);
12608
- opts.onProgress?.(job.status);
12609
- if (job.status === "failed") throw new Error(`PixelLab pixelate job ${jobId} failed upstream`);
12610
- if (job.status === "completed") {
12611
- const images = job.last_response?.images;
12612
- const first = Array.isArray(images) ? images[0] : void 0;
12613
- if (!first || typeof first.base64 !== "string" || !first.base64) {
12614
- throw new Error(`PixelLab pixelate job ${jobId} completed without an image at last_response.images[0]`);
12615
- }
12616
- const png = Buffer.from(first.base64, "base64");
12617
- const size = imageMetadata(png);
12618
- await mkdir5(path18.dirname(out), { recursive: true });
12619
- await writeFile5(out, png);
12620
- return {
12621
- jobId,
12622
- out,
12623
- original: { width, height },
12624
- pixelated: { width: size?.width ?? Number(first.width), height: size?.height ?? Number(first.height) },
12625
- imageId: submitted.image_id,
12626
- usage: job.usage ?? null
12627
- };
12628
- }
12629
- if (Date.now() - started > timeout) {
12630
- throw new Error(
12631
- `PixelLab pixelate job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
12632
- );
12633
- }
12634
- await sleep3(interval);
12635
- }
12636
- }
12637
-
12638
13714
  // src/pipeline/quality-regression.ts
12639
13715
  import path19 from "path";
12640
13716
  import { existsSync as existsSync13 } from "fs";
@@ -13446,6 +14522,7 @@ function retireGeneration(entry, retiredAt) {
13446
14522
  billed: entry.billed,
13447
14523
  provider: entry.provider,
13448
14524
  postprocess: entry.postprocess,
14525
+ ...entry.terrainConnect ? { terrainConnect: entry.terrainConnect } : {},
13449
14526
  retiredAt
13450
14527
  };
13451
14528
  }
@@ -13537,6 +14614,9 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
13537
14614
  billed: generation.billed,
13538
14615
  provider: generation.provider,
13539
14616
  postprocess: generation.postprocess,
14617
+ // A connected set comes back with the connection it was drawn from, so
14618
+ // the plan compares the restored generation, not the one it replaced.
14619
+ terrainConnect: generation.terrainConnect,
13540
14620
  history
13541
14621
  });
13542
14622
  await saveLock(lockPath, lock);
@@ -14557,6 +15637,22 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14557
15637
  const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
14558
15638
  const history = historyAfterReplacing(previousEntry, historyLimit(loaded.manifest), submittedAt ?? (/* @__PURE__ */ new Date()).toISOString());
14559
15639
  const outfitParentEntry = spec.character?.kind === "outfit" && spec.character.parentSpec ? lock.entries[lockKey(spec.character.parentSpec.styleId, spec.character.parentSpec.assetId)] : void 0;
15640
+ const terrainLinks = {};
15641
+ const terrainConnect = {};
15642
+ for (const side of ["lower", "upper"]) {
15643
+ const link = spec.terrainConnect?.[side];
15644
+ if (!link) continue;
15645
+ const other = lock.entries[lockKey(link.spec.styleId, link.spec.assetId)];
15646
+ const objectId = other.objectId;
15647
+ const terrainIds = other.providerMetadata?.[provider.id]?.terrainIds;
15648
+ const terrainId = terrainIds?.[link.terrain];
15649
+ terrainLinks[side] = {
15650
+ objectId,
15651
+ terrain: link.terrain,
15652
+ ...typeof terrainId === "string" && terrainId ? { terrainId } : {}
15653
+ };
15654
+ terrainConnect[side] = { sourceAssetId: link.assetId, sourceObjectId: objectId };
15655
+ }
14560
15656
  upsert(lock, key, {
14561
15657
  styleId: spec.styleId,
14562
15658
  assetId: spec.assetId,
@@ -14586,6 +15682,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14586
15682
  ...spec.revision.skeletonTemplate ? { skeletonTemplate: spec.revision.skeletonTemplate } : {},
14587
15683
  ...spec.revision.description ? { description: spec.revision.description } : {},
14588
15684
  ...spec.revision.removalTask ? { removalTask: spec.revision.removalTask } : {},
15685
+ ...spec.revision.convention ? { convention: spec.revision.convention } : {},
14589
15686
  ...spec.revision.driftThreshold == null ? {} : { driftThreshold: spec.revision.driftThreshold },
14590
15687
  ...spec.revision.subjectDescription ? { subjectDescription: spec.revision.subjectDescription } : {},
14591
15688
  ...spec.revision.initialPose ? { initialPose: spec.revision.initialPose } : {},
@@ -14596,6 +15693,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14596
15693
  sourceSha256: mirrorSourceHash(outfitParentEntry),
14597
15694
  referenceSha256: spec.character.outfit.reference.sha256
14598
15695
  } : null,
15696
+ terrainConnect: spec.terrainConnect ? terrainConnect : void 0,
14599
15697
  status: "pending",
14600
15698
  jobId: previousJobId ?? null,
14601
15699
  ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
@@ -14642,6 +15740,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14642
15740
  ...parentEntry?.objectId ? { parentObjectId: parentEntry.objectId } : {},
14643
15741
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
14644
15742
  ...parentOutputs ? { parentOutputs } : {},
15743
+ ...spec.terrainConnect ? { terrainLinks } : {},
14645
15744
  ...replacedMetadata ? { replacedMetadata } : {},
14646
15745
  ...lastGenerationMetadata ? { lastGenerationMetadata } : {},
14647
15746
  ...previousMetadata ? { previousMetadata } : {},
@@ -16140,6 +17239,7 @@ var NewAssetSchema = z8.object({
16140
17239
  /** A short description of the foreground, sent as PixelLab's text hint. */
16141
17240
  description: z8.string().min(1).optional()
16142
17241
  }).strict(),
17242
+ z8.object({ mode: z8.literal("normal-map"), from: z8.string().min(1), convention: NormalMapConventionSchema.optional() }).strict(),
16143
17243
  z8.object({
16144
17244
  mode: z8.enum(["animate", "animate-pixminimax"]),
16145
17245
  from: z8.string().min(1),
@@ -19618,6 +20718,12 @@ export {
19618
20718
  MapScenePlacementSchema,
19619
20719
  MapSceneSchema,
19620
20720
  MediaType,
20721
+ NORMAL_MAP_CONVENTIONS,
20722
+ NORMAL_MAP_COST_GENERATIONS,
20723
+ NORMAL_MAP_MAX_FRAMES,
20724
+ NORMAL_MAP_MAX_SIDE,
20725
+ NORMAL_MAP_MIN_SIDE,
20726
+ NormalMapConventionSchema,
19621
20727
  OverwriteRefusedError,
19622
20728
  PIXELATE_COST_GENERATIONS,
19623
20729
  PIXELATE_MAX_SIDE,
@@ -19645,12 +20751,17 @@ export {
19645
20751
  ScenarioProvider,
19646
20752
  StyleInputSchema,
19647
20753
  StyleSchema,
20754
+ TerrainConnectSchema,
20755
+ TerrainLinkSchema,
20756
+ TerrainSideSchema,
19648
20757
  UNZOOM_MAX_AREA,
19649
20758
  UNZOOM_MIN_SIDE,
19650
20759
  UiElementSchema,
19651
20760
  UiPieceSchema,
19652
20761
  UnsupportedCapabilityError,
19653
20762
  UsageError,
20763
+ VFX_ARTISTS,
20764
+ VFX_ARTIST_MAX_SPRITE,
19654
20765
  VFX_TOOL,
19655
20766
  VfxPartSchema,
19656
20767
  WorkspaceProjectSchema,
@@ -19683,6 +20794,7 @@ export {
19683
20794
  createProvider,
19684
20795
  currentEntryOutputPath,
19685
20796
  currentOutputPath,
20797
+ defaultNormalMapOut,
19686
20798
  defaultOpenCommand,
19687
20799
  defaultPixelateOut,
19688
20800
  defaultUnzoomOut,
@@ -19744,6 +20856,7 @@ export {
19744
20856
  loadWorkspace,
19745
20857
  lockKey,
19746
20858
  makeVfx,
20859
+ makeVfxWithArtist,
19747
20860
  matchOrphanStyle,
19748
20861
  mcpClientFromEnv,
19749
20862
  measureBalanceChange,
@@ -19755,6 +20868,9 @@ export {
19755
20868
  mergePalettes,
19756
20869
  mountSprites,
19757
20870
  mountStyle,
20871
+ normalMapFile,
20872
+ normalMapResultPng,
20873
+ normalMapSizeProblem,
19758
20874
  normalizeLockOutputPaths,
19759
20875
  normalizeRefinementPalette,
19760
20876
  normalizeTerrainTiles,
@@ -19772,6 +20888,7 @@ export {
19772
20888
  parseMcpBody,
19773
20889
  parseProFlashQuote,
19774
20890
  parseTerrainDescriptions,
20891
+ parseVfxArtistResult,
19775
20892
  parseVfxExport,
19776
20893
  parseVfxRecipe,
19777
20894
  parseVfxStep,
@@ -19795,6 +20912,7 @@ export {
19795
20912
  readHandEditCompanion,
19796
20913
  readQualityBaseline,
19797
20914
  readSavedVfxRecipe,
20915
+ readVfxConversation,
19798
20916
  readVfxRecipe,
19799
20917
  recipeDigest,
19800
20918
  referencedHashes,
@@ -19857,6 +20975,7 @@ export {
19857
20975
  submit,
19858
20976
  summarize,
19859
20977
  tagAdopted,
20978
+ terrainCost,
19860
20979
  terrainTileCount,
19861
20980
  tileFeatureOutputCount,
19862
20981
  tileVariationCount,
@@ -19870,6 +20989,13 @@ export {
19870
20989
  validateWorkspace,
19871
20990
  verifyArtifactBundle,
19872
20991
  verifyRecipe,
20992
+ vfxArtistCeiling,
20993
+ vfxArtistFiles,
20994
+ vfxArtistFrameFacts,
20995
+ vfxArtistQuote,
20996
+ vfxArtistSprite,
20997
+ vfxAtlasFrames,
20998
+ vfxJobFailure,
19873
20999
  vfxOutputPaths,
19874
21000
  vfxRecipeSha256,
19875
21001
  withArtifactManifest,