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.cjs CHANGED
@@ -86,6 +86,12 @@ __export(index_exports, {
86
86
  MapScenePlacementSchema: () => MapScenePlacementSchema,
87
87
  MapSceneSchema: () => MapSceneSchema,
88
88
  MediaType: () => MediaType,
89
+ NORMAL_MAP_CONVENTIONS: () => NORMAL_MAP_CONVENTIONS,
90
+ NORMAL_MAP_COST_GENERATIONS: () => NORMAL_MAP_COST_GENERATIONS,
91
+ NORMAL_MAP_MAX_FRAMES: () => NORMAL_MAP_MAX_FRAMES,
92
+ NORMAL_MAP_MAX_SIDE: () => NORMAL_MAP_MAX_SIDE,
93
+ NORMAL_MAP_MIN_SIDE: () => NORMAL_MAP_MIN_SIDE,
94
+ NormalMapConventionSchema: () => NormalMapConventionSchema,
89
95
  OverwriteRefusedError: () => OverwriteRefusedError,
90
96
  PIXELATE_COST_GENERATIONS: () => PIXELATE_COST_GENERATIONS,
91
97
  PIXELATE_MAX_SIDE: () => PIXELATE_MAX_SIDE,
@@ -113,12 +119,17 @@ __export(index_exports, {
113
119
  ScenarioProvider: () => ScenarioProvider,
114
120
  StyleInputSchema: () => StyleInputSchema,
115
121
  StyleSchema: () => StyleSchema,
122
+ TerrainConnectSchema: () => TerrainConnectSchema,
123
+ TerrainLinkSchema: () => TerrainLinkSchema,
124
+ TerrainSideSchema: () => TerrainSideSchema,
116
125
  UNZOOM_MAX_AREA: () => UNZOOM_MAX_AREA,
117
126
  UNZOOM_MIN_SIDE: () => UNZOOM_MIN_SIDE,
118
127
  UiElementSchema: () => UiElementSchema,
119
128
  UiPieceSchema: () => UiPieceSchema,
120
129
  UnsupportedCapabilityError: () => UnsupportedCapabilityError,
121
130
  UsageError: () => UsageError,
131
+ VFX_ARTISTS: () => VFX_ARTISTS,
132
+ VFX_ARTIST_MAX_SPRITE: () => VFX_ARTIST_MAX_SPRITE,
122
133
  VFX_TOOL: () => VFX_TOOL,
123
134
  VfxPartSchema: () => VfxPartSchema,
124
135
  WorkspaceProjectSchema: () => WorkspaceProjectSchema,
@@ -151,6 +162,7 @@ __export(index_exports, {
151
162
  createProvider: () => createProvider,
152
163
  currentEntryOutputPath: () => currentEntryOutputPath,
153
164
  currentOutputPath: () => currentOutputPath,
165
+ defaultNormalMapOut: () => defaultNormalMapOut,
154
166
  defaultOpenCommand: () => defaultOpenCommand,
155
167
  defaultPixelateOut: () => defaultPixelateOut,
156
168
  defaultUnzoomOut: () => defaultUnzoomOut,
@@ -212,6 +224,7 @@ __export(index_exports, {
212
224
  loadWorkspace: () => loadWorkspace,
213
225
  lockKey: () => lockKey,
214
226
  makeVfx: () => makeVfx,
227
+ makeVfxWithArtist: () => makeVfxWithArtist,
215
228
  matchOrphanStyle: () => matchOrphanStyle,
216
229
  mcpClientFromEnv: () => mcpClientFromEnv,
217
230
  measureBalanceChange: () => measureBalanceChange,
@@ -223,6 +236,9 @@ __export(index_exports, {
223
236
  mergePalettes: () => mergePalettes,
224
237
  mountSprites: () => mountSprites,
225
238
  mountStyle: () => mountStyle,
239
+ normalMapFile: () => normalMapFile,
240
+ normalMapResultPng: () => normalMapResultPng,
241
+ normalMapSizeProblem: () => normalMapSizeProblem,
226
242
  normalizeLockOutputPaths: () => normalizeLockOutputPaths,
227
243
  normalizeRefinementPalette: () => normalizeRefinementPalette,
228
244
  normalizeTerrainTiles: () => normalizeTerrainTiles,
@@ -240,6 +256,7 @@ __export(index_exports, {
240
256
  parseMcpBody: () => parseMcpBody,
241
257
  parseProFlashQuote: () => parseProFlashQuote,
242
258
  parseTerrainDescriptions: () => parseTerrainDescriptions,
259
+ parseVfxArtistResult: () => parseVfxArtistResult,
243
260
  parseVfxExport: () => parseVfxExport,
244
261
  parseVfxRecipe: () => parseVfxRecipe,
245
262
  parseVfxStep: () => parseVfxStep,
@@ -263,6 +280,7 @@ __export(index_exports, {
263
280
  readHandEditCompanion: () => readHandEditCompanion,
264
281
  readQualityBaseline: () => readQualityBaseline,
265
282
  readSavedVfxRecipe: () => readSavedVfxRecipe,
283
+ readVfxConversation: () => readVfxConversation,
266
284
  readVfxRecipe: () => readVfxRecipe,
267
285
  recipeDigest: () => recipeDigest,
268
286
  referencedHashes: () => referencedHashes,
@@ -325,6 +343,7 @@ __export(index_exports, {
325
343
  submit: () => submit,
326
344
  summarize: () => summarize,
327
345
  tagAdopted: () => tagAdopted,
346
+ terrainCost: () => terrainCost,
328
347
  terrainTileCount: () => terrainTileCount,
329
348
  tileFeatureOutputCount: () => tileFeatureOutputCount,
330
349
  tileVariationCount: () => tileVariationCount,
@@ -338,6 +357,13 @@ __export(index_exports, {
338
357
  validateWorkspace: () => validateWorkspace,
339
358
  verifyArtifactBundle: () => verifyArtifactBundle,
340
359
  verifyRecipe: () => verifyRecipe,
360
+ vfxArtistCeiling: () => vfxArtistCeiling,
361
+ vfxArtistFiles: () => vfxArtistFiles,
362
+ vfxArtistFrameFacts: () => vfxArtistFrameFacts,
363
+ vfxArtistQuote: () => vfxArtistQuote,
364
+ vfxArtistSprite: () => vfxArtistSprite,
365
+ vfxAtlasFrames: () => vfxAtlasFrames,
366
+ vfxJobFailure: () => vfxJobFailure,
341
367
  vfxOutputPaths: () => vfxOutputPaths,
342
368
  vfxRecipeSha256: () => vfxRecipeSha256,
343
369
  withArtifactManifest: () => withArtifactManifest,
@@ -368,8 +394,10 @@ var RevisionModeSchema = import_zod.z.enum([
368
394
  "animate-pixminimax",
369
395
  "animate-skeleton",
370
396
  "interpolate",
371
- "edit-animation"
397
+ "edit-animation",
398
+ "normal-map"
372
399
  ]);
400
+ var NormalMapConventionSchema = import_zod.z.enum(["opengl", "directx"]);
373
401
  function tileVariationCount(descriptions) {
374
402
  return Math.max(1, descriptions) * 4;
375
403
  }
@@ -412,9 +440,16 @@ function tilesCost(tileSize, variations) {
412
440
  function isometricTileCost() {
413
441
  return 1;
414
442
  }
415
- function terrainTileCount(transitionSize) {
443
+ function terrainTileCount(transitionSize, mode2, orientation) {
444
+ if (orientation === "sidescroller") return 16;
445
+ if (mode2 === "pro") return transitionSize != null && transitionSize > 0.4 ? 25 : 16;
416
446
  return transitionSize === 1 ? 25 : 16;
417
447
  }
448
+ function terrainCost(mode2, tileSize, orientation) {
449
+ if (orientation === "sidescroller") return 3;
450
+ if (mode2 === "pro") return tileSize <= 32 ? 15 : 25;
451
+ return 4;
452
+ }
418
453
  function parseTerrainDescriptions(prompt) {
419
454
  const parts = prompt.split(/\d+\s*\)\s*\./).map((part) => part.trim()).filter(Boolean);
420
455
  if (parts.length < 2) return null;
@@ -550,6 +585,17 @@ var CharacterStateSchema = import_zod.z.object({
550
585
  height: import_zod.z.number().int().min(16).max(256)
551
586
  }).strict().optional()
552
587
  }).strict();
588
+ var TerrainSideSchema = import_zod.z.enum(["lower", "upper"]);
589
+ var TerrainLinkSchema = import_zod.z.object({
590
+ /** A terrain asset in the same style. */
591
+ of: import_zod.z.string().min(1),
592
+ /** Which of that set's terrains: its `lower` or its `upper`. */
593
+ terrain: TerrainSideSchema
594
+ }).strict();
595
+ var TerrainConnectSchema = import_zod.z.object({
596
+ lower: TerrainLinkSchema.optional(),
597
+ upper: TerrainLinkSchema.optional()
598
+ }).strict().refine((connect) => connect.lower || connect.upper, { message: "connect needs lower, upper, or both" });
553
599
  var CharacterPortraitSizeSchema = import_zod.z.union([
554
600
  import_zod.z.literal(16),
555
601
  import_zod.z.literal(32),
@@ -580,13 +626,38 @@ var AssetVfxSchema = import_zod.z.object({
580
626
  parts: import_zod.z.array(VfxPartSchema).optional(),
581
627
  sprite: import_zod.z.string().min(1).optional(),
582
628
  fps: import_zod.z.number().int().min(1).max(60).optional(),
583
- name: import_zod.z.string().min(1).optional()
629
+ name: import_zod.z.string().min(1).optional(),
630
+ description: import_zod.z.string().trim().min(1).max(8e3).optional(),
631
+ artist: import_zod.z.enum(["lite", "pro"]).optional()
584
632
  }).strict().superRefine((vfx, context) => {
633
+ if (vfx.description !== void 0) {
634
+ for (const key of ["preset", "recipe", "parts", "fps"]) {
635
+ if (vfx[key] !== void 0) {
636
+ context.addIssue({
637
+ code: import_zod.z.ZodIssueCode.custom,
638
+ 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`,
639
+ path: [key]
640
+ });
641
+ }
642
+ }
643
+ if (vfx.sprite && /^(character|object|gallery):/.test(vfx.sprite)) {
644
+ context.addIssue({
645
+ code: import_zod.z.ZodIssueCode.custom,
646
+ message: "the VFX artist takes a sprite's pixels, not a PixelLab reference; point sprite at a PNG file (another asset's output works)",
647
+ path: ["sprite"]
648
+ });
649
+ }
650
+ if (vfx.name && vfx.name.length > 80) {
651
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "the VFX artist takes a name of at most 80 characters", path: ["name"] });
652
+ }
653
+ } else if (vfx.artist) {
654
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "artist picks who draws a vfx description; add a description", path: ["artist"] });
655
+ }
585
656
  if (vfx.preset && vfx.recipe) {
586
657
  context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "start a vfx from a preset or a recipe, not both", path: ["recipe"] });
587
658
  }
588
- if (!vfx.preset && !vfx.recipe && !vfx.parts?.length) {
589
- context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "a vfx needs a preset, a recipe, or at least one part", path: ["preset"] });
659
+ if (vfx.description === void 0 && !vfx.preset && !vfx.recipe && !vfx.parts?.length) {
660
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "a vfx needs a preset, a recipe, or at least one part, or a description", path: ["preset"] });
590
661
  }
591
662
  if (vfx.sprite && /^(https?|data):/i.test(vfx.sprite)) {
592
663
  context.addIssue({
@@ -771,6 +842,8 @@ var RevisionSchema = import_zod.z.object({
771
842
  skeletonTemplate: import_zod.z.string().min(1).optional(),
772
843
  /** `remove-background` only: `simple` is faster and suits flat backgrounds; `complex` handles detailed edges. PixelLab defaults to `simple`. */
773
844
  removalTask: import_zod.z.enum(["simple", "complex"]).optional(),
845
+ /** `normal-map` only: `opengl` (green up; PixelLab's default) or `directx` (green down, for Unreal). */
846
+ convention: NormalMapConventionSchema.optional(),
774
847
  /**
775
848
  * `animate-skeleton`: what the character *looks like*, a noun
776
849
  * phrase (colours, clothing, held items) — distinct from the asset's
@@ -917,13 +990,20 @@ var RevisionSchema = import_zod.z.object({
917
990
  path: ["removalTask"]
918
991
  });
919
992
  }
920
- if (revision.strength !== void 0 && revision.mode === "remove-background") {
993
+ if (revision.strength !== void 0 && (revision.mode === "remove-background" || revision.mode === "normal-map")) {
921
994
  context.addIssue({
922
995
  code: import_zod.z.ZodIssueCode.custom,
923
- message: "remove-background revisions do not take a strength",
996
+ message: `${revision.mode} revisions do not take a strength`,
924
997
  path: ["strength"]
925
998
  });
926
999
  }
1000
+ if (revision.convention !== void 0 && revision.mode !== "normal-map") {
1001
+ context.addIssue({
1002
+ code: import_zod.z.ZodIssueCode.custom,
1003
+ message: "convention applies to normal-map revisions only",
1004
+ path: ["convention"]
1005
+ });
1006
+ }
927
1007
  if (revision.description !== void 0 && revision.mode !== "animate-skeleton" && revision.mode !== "remove-background") {
928
1008
  context.addIssue({
929
1009
  code: import_zod.z.ZodIssueCode.custom,
@@ -942,7 +1022,7 @@ var RevisionSchema = import_zod.z.object({
942
1022
  }
943
1023
  });
944
1024
  var FRAME_SET_REVISION_MODES = ["animate", "animate-pixminimax", "animate-skeleton", "interpolate", "edit-animation"];
945
- var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation"];
1025
+ var MEMBER_SET_REVISION_MODES = ["reduce-colors", "correct-pixelart", "edit-animation", "normal-map"];
946
1026
  var StyleObjectSchema = import_zod.z.object({
947
1027
  /** Generation backend for this style. Omit to inherit the manifest default. */
948
1028
  provider: import_zod.z.string().min(1).optional(),
@@ -1055,9 +1135,10 @@ var StyleObjectSchema = import_zod.z.object({
1055
1135
  terrainTileSize: import_zod.z.union([import_zod.z.literal(16), import_zod.z.literal(32), import_zod.z.literal(64)]).optional(),
1056
1136
  /**
1057
1137
  * `terrain` generator only. `standard` is the classic Wang tileset
1058
- * pipeline; `pro` is a newer corner-pair pipeline with its own shape
1059
- * controls (`terrainSpreadX`, `terrainSlopeSize`, `terrainRaggedness`)
1060
- * in place of `terrainShapeStyle`. The API default is `standard`.
1138
+ * pipeline; `pro` is a newer pipeline with its own shape controls
1139
+ * (`terrainRaggedness`, `terrainRoundness`, `terrainBoundaryAmplitude`,
1140
+ * `terrainStepSlope`, `terrainConnectDiagonals`) in place of
1141
+ * `terrainShapeStyle`. The API default is `standard`.
1061
1142
  */
1062
1143
  terrainMode: import_zod.z.enum(["standard", "pro"]).optional(),
1063
1144
  /**
@@ -1069,31 +1150,86 @@ var StyleObjectSchema = import_zod.z.object({
1069
1150
  /**
1070
1151
  * `terrain` generator only, `terrainMode: "standard"`. Procedural
1071
1152
  * boundary geometry: `square` or `round`, 16px or 32px tiles only.
1072
- * Rejected together with `terrainMode: "pro"`, whose own shape controls
1073
- * are `terrainSpreadX`/`terrainSlopeSize`/`terrainRaggedness`.
1153
+ * Rejected together with `terrainMode: "pro"`, which has its own shape
1154
+ * controls.
1074
1155
  */
1075
1156
  terrainShapeStyle: import_zod.z.enum(["square", "round"]).optional(),
1076
- /** `terrain` generator only, `terrainMode: "pro"`. Boundary spread
1077
- * between terrains (0 = steep, 1 = gradual). API default 0.5. */
1157
+ /**
1158
+ * Deprecated: no effect. PixelLab's Pro pipeline no longer reads
1159
+ * `spread_x`; the API keeps accepting it so existing calls work, and so
1160
+ * does pixelkiln. Use `terrainBoundaryAmplitude` or `terrainStepSlope`.
1161
+ */
1078
1162
  terrainSpreadX: import_zod.z.number().min(0).max(1).optional(),
1079
- /** `terrain` generator only, `terrainMode: "pro"`. Slope on the N/W/E
1080
- * sides as a fraction of wall height. API default 0. */
1163
+ /**
1164
+ * Deprecated: no effect. PixelLab's Pro pipeline no longer reads
1165
+ * `slope_size`; the API keeps accepting it so existing calls work, and
1166
+ * so does pixelkiln. `terrainStepSlope` is the slope control now.
1167
+ */
1081
1168
  terrainSlopeSize: import_zod.z.number().min(0).max(1).optional(),
1082
1169
  /** `terrain` generator only, `terrainMode: "pro"`. Terrain boundary
1083
- * noise (0 = smooth, 1 = rough). API default 0. */
1170
+ * noise (0 = smooth, 1 = rough), half strength on tall sets
1171
+ * (`terrainTransitionSize` above 0.4). API default 0. */
1084
1172
  terrainRaggedness: import_zod.z.number().min(0).max(1).optional(),
1173
+ /**
1174
+ * `terrain` generator only, `terrainMode: "pro"` or `terrainShapeStyle`.
1175
+ * Corner shape of the border between the terrains: 0 square, 1 round,
1176
+ * 2 corners cut at 45° (values between blend). Above 1 needs pro. The API
1177
+ * default is 0.85 on pro and 0.6 with a shape style. With
1178
+ * `terrainConnectDiagonals`, 2 is the fix for 45° paths and coastlines
1179
+ * that otherwise break into one island per step.
1180
+ */
1181
+ terrainRoundness: import_zod.z.number().min(0).max(2).optional(),
1182
+ /**
1183
+ * `terrain` generator only, `terrainMode: "pro"` (any transition size)
1184
+ * or `terrainShapeStyle` (transition size up to 0.5). How far the border
1185
+ * meanders instead of running straight: 0 straight (the default) to 0.3.
1186
+ * Tiles still connect seamlessly.
1187
+ */
1188
+ terrainBoundaryAmplitude: import_zod.z.number().min(0).max(0.3).optional(),
1189
+ /**
1190
+ * `terrain` generator only, with `terrainBoundaryAmplitude`. Picks the
1191
+ * meander's shape: another number gives a different border at the same
1192
+ * amplitude, the same number the same border. API default 0.
1193
+ */
1194
+ terrainBoundarySeed: import_zod.z.number().int().min(0).optional(),
1195
+ /**
1196
+ * `terrain` generator only, `terrainMode: "pro"` with
1197
+ * `terrainTransitionSize: 0.25`. How the bank between the terrains
1198
+ * falls: 0 a straight cliff (the default) to 2 a gentle slope spread
1199
+ * over more ground.
1200
+ */
1201
+ terrainStepSlope: import_zod.z.number().min(0).max(2).optional(),
1202
+ /**
1203
+ * `terrain` generator only, `terrainMode: "pro"`. On the two diagonal
1204
+ * tiles (upper terrain in opposite corners), true joins the upper
1205
+ * corners through the middle into one area; false (the default) runs the
1206
+ * lower terrain between them.
1207
+ */
1208
+ terrainConnectDiagonals: import_zod.z.boolean().optional(),
1085
1209
  /**
1086
1210
  * `terrain` generator only. Visual height of the step between lower and
1087
1211
  * upper terrain. Without `terrainShapeStyle`, only 0, 0.25, 0.5, or 1 are
1088
- * accepted (1 switches to the 25-tile cliff layout, where corners take a
1089
- * third "transition" value); with it, any value from 0 to 1 works, but
1090
- * above 0.5 switches to an extended 32-tile layout this adapter does not
1091
- * model. API default 0.
1212
+ * accepted. Standard switches to the 25-tile cliff layout at 1, where
1213
+ * corners take a third "transition" value; Pro reads it as wall height
1214
+ * and switches to the 25-tile tall-wall layout above 0.4, so 0.5 is
1215
+ * already 25 tiles there. With `terrainShapeStyle`, any value from 0 to 1
1216
+ * works, but above 0.5 switches to an extended 32-tile layout this
1217
+ * adapter does not model. API default 0.
1092
1218
  */
1093
1219
  terrainTransitionSize: import_zod.z.number().min(0).max(1).optional(),
1094
1220
  /** `terrain` generator only. Camera angle; the API default is `high
1095
1221
  * top-down`. */
1096
1222
  terrainView: import_zod.z.enum(["low top-down", "high top-down"]).optional(),
1223
+ /**
1224
+ * `terrain` generator only. `top-down` (the default) is a two-terrain
1225
+ * map set from `/create-tileset`; `sidescroller` is a platform set from
1226
+ * `/create-tileset-sidescroller`, seen from the side: the prompt's 1).
1227
+ * is the platform body and 2). the top layer on it (grass, snow, moss),
1228
+ * and the set's other terrain is empty air. 16 or 32px tiles,
1229
+ * `terrainTransitionSize` 0, 0.25, 0.5, or 1 (the top layer's height),
1230
+ * and none of the top-down mode, view, or shape controls.
1231
+ */
1232
+ terrainOrientation: import_zod.z.enum(["top-down", "sidescroller"]).optional(),
1097
1233
  /**
1098
1234
  * `isometricTile` generator only. `/create-isometric-tile`'s own tile
1099
1235
  * grid size, distinct from `size` (the generation canvas, 16-64px — the
@@ -1173,7 +1309,9 @@ var StyleObjectSchema = import_zod.z.object({
1173
1309
  /** Fixed seed for reproducibility where the endpoint supports it. */
1174
1310
  seed: import_zod.z.number().int().optional(),
1175
1311
  /**
1176
- * Forced palette, as `#rrggbb` values. `pixflux` only.
1312
+ * Forced palette, as `#rrggbb` values. Sent as a swatch to `pixflux`
1313
+ * and the generators that take one, and to `imagePro` as its
1314
+ * `target_palette.colors` (2 to 256 colours).
1177
1315
  *
1178
1316
  * Unlike prose, this is a hard constraint. The API is handed a swatch
1179
1317
  * image and the output is limited to those colours. Verified: a four-colour
@@ -1183,6 +1321,10 @@ var StyleObjectSchema = import_zod.z.object({
1183
1321
  * chocolate bar.
1184
1322
  */
1185
1323
  palette: import_zod.z.array(HexColorSchema).default([]),
1324
+ /** `imagePro` only: the palette's name ("Game Boy", "PICO-8"), which PixelLab uses to help the look. Needs `palette`. */
1325
+ paletteName: import_zod.z.string().min(1).max(100).optional(),
1326
+ /** `imagePro` only: at most this many colours in each candidate, 2 to 64 (drawn from `palette` when one is set). */
1327
+ maxColors: import_zod.z.number().int().min(2).max(64).optional(),
1186
1328
  /**
1187
1329
  * Snap every visible pixel of the downloaded art to the nearest `palette`
1188
1330
  * colour, without dithering. The palette is sent to providers that take
@@ -1236,6 +1378,22 @@ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImag
1236
1378
  path: ["quality"]
1237
1379
  });
1238
1380
  }
1381
+ for (const field of ["paletteName", "maxColors"]) {
1382
+ if (style[field] !== void 0 && style.generator !== "imagePro") {
1383
+ ctx.addIssue({
1384
+ code: import_zod.z.ZodIssueCode.custom,
1385
+ message: `${field} applies to the imagePro generator only`,
1386
+ path: [field]
1387
+ });
1388
+ }
1389
+ }
1390
+ if (style.paletteName !== void 0 && style.palette.length === 0) {
1391
+ ctx.addIssue({
1392
+ code: import_zod.z.ZodIssueCode.custom,
1393
+ message: "paletteName names the palette's colours; set palette too",
1394
+ path: ["paletteName"]
1395
+ });
1396
+ }
1239
1397
  if (style.enforcePalette && style.palette.length < 2) {
1240
1398
  ctx.addIssue({
1241
1399
  code: import_zod.z.ZodIssueCode.custom,
@@ -1361,6 +1519,8 @@ var AssetSchema = import_zod.z.object({
1361
1519
  scene: MapSceneSchema.optional(),
1362
1520
  /** `vfx` styles only: the effect to render. See `AssetVfxSchema`. */
1363
1521
  vfx: AssetVfxSchema.optional(),
1522
+ /** `terrain` styles only: share a terrain with another set. See `TerrainConnectSchema`. */
1523
+ connect: TerrainConnectSchema.optional(),
1364
1524
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
1365
1525
  proportions: CharacterProportionsSchema.optional(),
1366
1526
  /**
@@ -1437,7 +1597,7 @@ var AssetSchema = import_zod.z.object({
1437
1597
  path: ["revision"]
1438
1598
  });
1439
1599
  }
1440
- 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);
1600
+ 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);
1441
1601
  if (shapes.length > 1) {
1442
1602
  context.addIssue({
1443
1603
  code: import_zod.z.ZodIssueCode.custom,
@@ -1529,6 +1689,10 @@ var LockSourceSchema = import_zod.z.object({
1529
1689
  role: import_zod.z.string().optional(),
1530
1690
  mediaType: MediaTypeSchema.optional()
1531
1691
  });
1692
+ var LockTerrainConnectSchema = import_zod.z.record(
1693
+ TerrainSideSchema,
1694
+ import_zod.z.object({ sourceAssetId: import_zod.z.string().min(1), sourceObjectId: import_zod.z.string().min(1) }).strict()
1695
+ );
1532
1696
  var LockHistoryEntrySchema = import_zod.z.object({
1533
1697
  specHash: import_zod.z.string(),
1534
1698
  generator: GeneratorSchema,
@@ -1551,6 +1715,8 @@ var LockHistoryEntrySchema = import_zod.z.object({
1551
1715
  billed: import_zod.z.object({ amount: import_zod.z.number(), unit: import_zod.z.string() }).nullable().default(null),
1552
1716
  provider: import_zod.z.string().default("pixellab"),
1553
1717
  postprocess: PostprocessSchema.optional(),
1718
+ /** A connected terrain set's other sets; see LockEntrySchema.terrainConnect. */
1719
+ terrainConnect: LockTerrainConnectSchema.optional(),
1554
1720
  /** When the replacement was submitted. */
1555
1721
  retiredAt: import_zod.z.string()
1556
1722
  });
@@ -1589,6 +1755,7 @@ var LockEntrySchema = import_zod.z.object({
1589
1755
  skeletonTemplate: import_zod.z.string().min(1).optional(),
1590
1756
  description: import_zod.z.string().min(1).optional(),
1591
1757
  removalTask: import_zod.z.enum(["simple", "complex"]).optional(),
1758
+ convention: NormalMapConventionSchema.optional(),
1592
1759
  driftThreshold: import_zod.z.number().min(0).optional(),
1593
1760
  subjectDescription: import_zod.z.string().min(1).max(300).optional(),
1594
1761
  initialPose: import_zod.z.string().min(1).max(300).optional(),
@@ -1616,6 +1783,14 @@ var LockEntrySchema = import_zod.z.object({
1616
1783
  sourceSha256: import_zod.z.string().regex(/^[0-9a-f]{64}$/),
1617
1784
  referenceSha256: import_zod.z.string().regex(/^[0-9a-f]{64}$/)
1618
1785
  }).strict().nullable().default(null),
1786
+ /**
1787
+ * For a connected terrain set: per side of this set, the set it shares
1788
+ * that terrain with and that set's provider id when this was submitted.
1789
+ * Tracked the way `outfit` is, not through `specHash`: the other set's
1790
+ * id lives in the lockfile, which manifest resolution does not read.
1791
+ * Absent (not null) otherwise, so other entries do not grow the key.
1792
+ */
1793
+ terrainConnect: LockTerrainConnectSchema.optional(),
1619
1794
  /**
1620
1795
  * Output hashes owned by the previous generation while its replacement is
1621
1796
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -1940,7 +2115,16 @@ var TilesetGetSchema = import_zod3.z.object({
1940
2115
  terrain_types: import_zod3.z.array(import_zod3.z.string()),
1941
2116
  tiles: import_zod3.z.array(TilesetTileSchema).min(1)
1942
2117
  }).passthrough(),
1943
- usage: UsageSchema
2118
+ usage: UsageSchema,
2119
+ /**
2120
+ * `terrain_ids` names each terrain's base tile id: the id another set
2121
+ * sends as `lower_base_tile_id`/`upper_base_tile_id` to share that
2122
+ * terrain. A sidescroller set has only `lower` (its upper is empty air).
2123
+ */
2124
+ metadata: import_zod3.z.object({
2125
+ terrain_ids: import_zod3.z.record(import_zod3.z.string()).nullable().optional(),
2126
+ view: import_zod3.z.string().nullable().optional()
2127
+ }).passthrough().nullable().optional()
1944
2128
  }).passthrough();
1945
2129
  var IsometricTileSubmitSchema = import_zod3.z.object({
1946
2130
  tile_id: import_zod3.z.string().min(1),
@@ -2029,6 +2213,12 @@ var ProFlashImageSubmitSchema = import_zod3.z.object({
2029
2213
  estimated_generations: import_zod3.z.number().nullable().optional(),
2030
2214
  usage: import_zod3.z.unknown().optional()
2031
2215
  }).passthrough();
2216
+ var VfxSubmitSchema = import_zod3.z.object({
2217
+ background_job_id: import_zod3.z.string().min(1),
2218
+ status: import_zod3.z.string().default("processing"),
2219
+ quoted_generations: import_zod3.z.number().nullable().optional(),
2220
+ usage: import_zod3.z.unknown().optional()
2221
+ }).passthrough();
2032
2222
  var UnzoomResponseSchema = import_zod3.z.object({
2033
2223
  image: import_zod3.z.object({ base64: import_zod3.z.string().min(1) }).passthrough(),
2034
2224
  original_size: import_zod3.z.object({ width: import_zod3.z.number().int(), height: import_zod3.z.number().int() }).passthrough(),
@@ -2369,12 +2559,19 @@ var PixelLabClient = class {
2369
2559
  upper_description: args.upperDescription
2370
2560
  };
2371
2561
  if (args.transitionDescription) body.transition_description = args.transitionDescription;
2562
+ if (args.lowerBaseTileId) body.lower_base_tile_id = args.lowerBaseTileId;
2563
+ if (args.upperBaseTileId) body.upper_base_tile_id = args.upperBaseTileId;
2372
2564
  if (args.tileSize != null) body.tile_size = { width: args.tileSize, height: args.tileSize };
2373
2565
  if (args.mode) body.mode = args.mode;
2374
2566
  if (args.shapeStyle) body.shape_style = args.shapeStyle;
2375
2567
  if (args.spreadX != null) body.spread_x = args.spreadX;
2376
2568
  if (args.slopeSize != null) body.slope_size = args.slopeSize;
2377
2569
  if (args.raggedness != null) body.raggedness = args.raggedness;
2570
+ if (args.roundness != null) body.roundness = args.roundness;
2571
+ if (args.boundaryAmplitude != null) body.boundary_amplitude = args.boundaryAmplitude;
2572
+ if (args.boundarySeed != null) body.boundary_seed = args.boundarySeed;
2573
+ if (args.stepSlope != null) body.step_slope = args.stepSlope;
2574
+ if (args.connectDiagonals != null) body.connect_diagonals = args.connectDiagonals;
2378
2575
  if (args.transitionSize != null) body.transition_size = args.transitionSize;
2379
2576
  if (args.view) body.view = args.view;
2380
2577
  if (args.outline) body.outline = args.outline;
@@ -2387,14 +2584,48 @@ var PixelLabClient = class {
2387
2584
  "create tileset"
2388
2585
  );
2389
2586
  }
2390
- /** Throws PixelLabError(423) while the set is still drawing; see Tileset. */
2587
+ /**
2588
+ * `/create-tileset-sidescroller`: a platform set seen from the side. One
2589
+ * terrain (`lower_description`, the platform body) and the decorative
2590
+ * layer on top of it (`transition_description`); the other side of every
2591
+ * edge is empty air, so there is no upper description, mode, view, or
2592
+ * shape control. 16 or 32px tiles only. The result is read through
2593
+ * `getTileset` like a top-down set.
2594
+ */
2595
+ async createTilesetSidescroller(args) {
2596
+ const body = { lower_description: args.lowerDescription };
2597
+ if (args.transitionDescription) body.transition_description = args.transitionDescription;
2598
+ if (args.lowerBaseTileId) body.lower_base_tile_id = args.lowerBaseTileId;
2599
+ if (args.tileSize != null) body.tile_size = { width: args.tileSize, height: args.tileSize };
2600
+ if (args.transitionSize != null) body.transition_size = args.transitionSize;
2601
+ if (args.outline) body.outline = args.outline;
2602
+ if (args.shading) body.shading = args.shading;
2603
+ if (args.detail) body.detail = args.detail;
2604
+ if (args.seed != null) body.seed = args.seed;
2605
+ return validateResponse(
2606
+ TilesetSubmitSchema,
2607
+ await this.request("/create-tileset-sidescroller", { method: "POST", body: JSON.stringify(body) }),
2608
+ "create sidescroller tileset"
2609
+ );
2610
+ }
2611
+ /**
2612
+ * Throws PixelLabError(423) while the set is still drawing; see Tileset.
2613
+ * Serves top-down and sidescroller sets alike.
2614
+ */
2391
2615
  async getTileset(tilesetId) {
2392
2616
  const res = await validateResponse(
2393
2617
  TilesetGetSchema,
2394
2618
  await this.request(`/tilesets/${tilesetId}`),
2395
2619
  "get tileset"
2396
2620
  );
2397
- return { tileset: res.tileset, usage: res.usage ?? null };
2621
+ return {
2622
+ tileset: res.tileset,
2623
+ usage: res.usage ?? null,
2624
+ metadata: {
2625
+ terrainIds: res.metadata?.terrain_ids ?? {},
2626
+ view: res.metadata?.view ?? null
2627
+ }
2628
+ };
2398
2629
  }
2399
2630
  /** A single standalone isometric tile — no connectable set, no candidates. */
2400
2631
  async createIsometricTile(args) {
@@ -2931,6 +3162,13 @@ var PixelLabClient = class {
2931
3162
  *
2932
3163
  * Reference images and a style image (`reference_images`, `style_image` +
2933
3164
  * `style_options`) exist on this endpoint but are not modeled here yet.
3165
+ *
3166
+ * `targetPalette` is the beta `target_palette` (PixelLab 0.4.130): with
3167
+ * `colors`, the image is drawn for them and every result pixel is one of
3168
+ * them; `name` ("PICO-8") helps the look; `maxColors` caps the colours in
3169
+ * each candidate. Its `smart` flag is not modeled: it is documented as
3170
+ * "single images only", and this endpoint returns a grid of candidates
3171
+ * below 171px.
2934
3172
  */
2935
3173
  async createImagePro(args) {
2936
3174
  const body = {
@@ -2939,6 +3177,14 @@ var PixelLabClient = class {
2939
3177
  };
2940
3178
  if (args.noBackground != null) body.no_background = args.noBackground;
2941
3179
  if (args.seed != null) body.seed = args.seed;
3180
+ const palette = args.targetPalette;
3181
+ if (palette && (palette.colors?.length || palette.maxColors != null)) {
3182
+ body.target_palette = {
3183
+ ...palette.colors?.length ? { colors: palette.colors } : {},
3184
+ ...palette.name ? { name: palette.name } : {},
3185
+ ...palette.maxColors != null ? { max_colors: palette.maxColors } : {}
3186
+ };
3187
+ }
2942
3188
  return validateResponse(
2943
3189
  RevisionJobSubmitSchema,
2944
3190
  await this.request("/generate-image-v2", { method: "POST", body: JSON.stringify(body) }),
@@ -3129,6 +3375,27 @@ var PixelLabClient = class {
3129
3375
  );
3130
3376
  return { png: Buffer.from(res.image.base64, "base64"), usage: res.usage };
3131
3377
  }
3378
+ /**
3379
+ * `/normal-map` (PixelLab 0.4.130): the normal map of a sprite, so a game
3380
+ * engine can light it. RGB is the way each pixel faces, alpha is 255 where
3381
+ * the sprite shows and 0 elsewhere, at the input's size. An animation goes
3382
+ * as one horizontal strip of `frames` equal frames (up to 32, each 8 to 512
3383
+ * px per side) and comes back as a strip, planned once on the first frame
3384
+ * so the light stays steady. `opengl` (the default) is green up, for
3385
+ * Godot, Unity, and Phaser; `directx` is green down, for Unreal. A
3386
+ * background job whose map is `last_response.image`; 0.5 generation,
3387
+ * charged only when the map is delivered.
3388
+ */
3389
+ async normalMap(args) {
3390
+ const body = { image: args.image };
3391
+ if (args.frames != null && args.frames !== 1) body.frames = args.frames;
3392
+ if (args.convention) body.convention = args.convention;
3393
+ return validateResponse(
3394
+ RevisionJobSubmitSchema,
3395
+ await this.request("/normal-map", { method: "POST", body: JSON.stringify(body) }),
3396
+ "normal-map"
3397
+ );
3398
+ }
3132
3399
  /**
3133
3400
  * `/unzoom`: recover native-resolution pixel art from an upscaled image (a
3134
3401
  * 32x32 sprite saved at 512x512). Synchronous like the Cleanup tier.
@@ -3219,6 +3486,28 @@ var PixelLabClient = class {
3219
3486
  "generate-font-pro"
3220
3487
  );
3221
3488
  }
3489
+ /**
3490
+ * `POST /vfx`: PixelLab's effects artist builds an animated effect from a
3491
+ * description, around an optional sprite, and saves it to My effects. With
3492
+ * `effectId` it changes that saved effect in place; adding the effect's
3493
+ * `conversation` prices the change as a change. A background job whose
3494
+ * 202 carries `quoted_generations`; PixelLab bills on delivery, up to
3495
+ * twice the quote. A 503 (renderer busy) is retried by the shared policy.
3496
+ */
3497
+ async createVfx(args) {
3498
+ const body = { description: args.description };
3499
+ if (args.artist) body.artist = args.artist;
3500
+ if (args.sprite) body.sprite = { type: "base64", base64: args.sprite.base64, format: args.sprite.format };
3501
+ if (args.effectId) body.effect_id = args.effectId;
3502
+ if (args.conversation) body.conversation = args.conversation;
3503
+ if (args.name) body.name = args.name;
3504
+ if (args.gamePack) body.game_pack = true;
3505
+ return validateResponse(
3506
+ VfxSubmitSchema,
3507
+ await this.request("/vfx", { method: "POST", body: JSON.stringify(body) }),
3508
+ "vfx"
3509
+ );
3510
+ }
3222
3511
  /** `GET /generate-font-pro/{job_id}`: status, then no-auth atlas and `.ttf` URLs once completed. */
3223
3512
  async getFontJob(jobId) {
3224
3513
  const raw = await this.request(`/generate-font-pro/${encodeURIComponent(jobId)}`);
@@ -3818,6 +4107,41 @@ function parseHex(hex2) {
3818
4107
  const n = parseInt(m[1], 16);
3819
4108
  return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
3820
4109
  }
4110
+ function joinStripPng(frames) {
4111
+ const decoded = frames.map((frame) => decodePng(frame));
4112
+ const first = decoded[0];
4113
+ if (!first) throw new Error("a strip needs at least one frame");
4114
+ const { width, height } = first;
4115
+ for (const [index, frame] of decoded.entries()) {
4116
+ if (frame.width !== width || frame.height !== height) {
4117
+ throw new Error(`strip frame ${index} is ${frame.width}x${frame.height}; frame 0 is ${width}x${height}`);
4118
+ }
4119
+ }
4120
+ const stride = width * 4;
4121
+ const out = Buffer.alloc(stride * decoded.length * height);
4122
+ for (let y = 0; y < height; y++) {
4123
+ for (const [index, frame] of decoded.entries()) {
4124
+ frame.pixels.copy(out, (y * decoded.length + index) * stride, y * stride, (y + 1) * stride);
4125
+ }
4126
+ }
4127
+ return encodeRgbaPng(width * decoded.length, height, out);
4128
+ }
4129
+ function splitStripPng(strip, frames) {
4130
+ const image = decodePng(strip);
4131
+ if (frames < 1 || image.width % frames !== 0) {
4132
+ throw new Error(`a ${image.width}x${image.height} strip does not split into ${frames} equal frames`);
4133
+ }
4134
+ const width = image.width / frames;
4135
+ const stride = width * 4;
4136
+ return Array.from({ length: frames }, (_, index) => {
4137
+ const out = Buffer.alloc(stride * image.height);
4138
+ for (let y = 0; y < image.height; y++) {
4139
+ const from = (y * image.width + index * width) * 4;
4140
+ image.pixels.copy(out, y * stride, from, from + stride);
4141
+ }
4142
+ return encodeRgbaPng(width, image.height, out);
4143
+ });
4144
+ }
3821
4145
 
3822
4146
  // src/media.ts
3823
4147
  var MediaType = {
@@ -3933,10 +4257,10 @@ function imageMetadata(buf) {
3933
4257
  }
3934
4258
 
3935
4259
  // src/providers/pixellab.ts
3936
- var import_node_fs3 = require("fs");
4260
+ var import_node_fs4 = require("fs");
3937
4261
  var import_node_crypto2 = require("crypto");
3938
4262
  var import_node_os = __toESM(require("os"), 1);
3939
- var import_node_path2 = __toESM(require("path"), 1);
4263
+ var import_node_path3 = __toESM(require("path"), 1);
3940
4264
 
3941
4265
  // src/hash.ts
3942
4266
  var import_node_crypto = require("crypto");
@@ -3972,6 +4296,9 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3972
4296
  detail: effect ? void 0 : spec.detail ?? null,
3973
4297
  seed: effect ? void 0 : spec.seed ?? null,
3974
4298
  palette: effect ? void 0 : spec.palette,
4299
+ // `imagePro` only, and absent unless the style sets them.
4300
+ paletteName: spec.paletteName,
4301
+ maxColors: spec.maxColors,
3975
4302
  // `noBackground` only reaches the wire for pixflux and uiAsset; the
3976
4303
  // tile fields are undefined for every other generator. `tileSize` is
3977
4304
  // intentionally absent; width/height are derived from it, so it is
@@ -3986,6 +4313,25 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3986
4313
  uiPieces: spec.uiPieces,
3987
4314
  uiElements: spec.uiElements,
3988
4315
  uiColorPalette: spec.uiColorPalette,
4316
+ // The older terrain knobs (terrainMode, terrainTransitionSize, and the
4317
+ // rest) were never part of this hash, and adding them now would make
4318
+ // every existing terrain entry stale. The Pro shape controls added
4319
+ // after them are identity, each only when set.
4320
+ terrainRoundness: spec.terrainRoundness,
4321
+ terrainBoundaryAmplitude: spec.terrainBoundaryAmplitude,
4322
+ terrainBoundarySeed: spec.terrainBoundarySeed,
4323
+ terrainStepSlope: spec.terrainStepSlope,
4324
+ terrainConnectDiagonals: spec.terrainConnectDiagonals,
4325
+ // "top-down" is the default and draws the same set as leaving it out.
4326
+ terrainOrientation: spec.terrainOrientation === "sidescroller" ? spec.terrainOrientation : void 0,
4327
+ // Which set and which of its terrains, not that terrain's id: the id
4328
+ // is in the lockfile, and the plan compares it there (like a mirror).
4329
+ terrainConnect: spec.terrainConnect ? Object.fromEntries(
4330
+ ["lower", "upper"].flatMap((side) => {
4331
+ const link = spec.terrainConnect[side];
4332
+ return link ? [[side, { of: link.assetId, terrain: link.terrain }]] : [];
4333
+ })
4334
+ ) : void 0,
3989
4335
  // Scene and mask enter as their bytes' hashes, never their paths.
3990
4336
  scene: spec.scene ? {
3991
4337
  image: spec.scene.sha256,
@@ -4047,13 +4393,17 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
4047
4393
  reference: spec.objectPro.reference?.sha256
4048
4394
  } : void 0,
4049
4395
  // A recipe and a local sprite enter as their contents' hashes, never
4050
- // their paths; the My effects name is not identity.
4396
+ // their paths; the My effects name is not identity. `description` and
4397
+ // `artist` exist only for the paid VFX artist and stay absent for a
4398
+ // workbench effect, so its hash is the one it always had.
4051
4399
  vfx: spec.vfx ? {
4052
4400
  preset: spec.vfx.preset,
4053
4401
  recipe: spec.vfx.recipe?.sha256,
4054
4402
  parts: spec.vfx.parts,
4055
4403
  fps: spec.vfx.fps,
4056
- sprite: spec.vfx.sprite ? "reference" in spec.vfx.sprite ? spec.vfx.sprite.reference : spec.vfx.sprite.sha256 : void 0
4404
+ sprite: spec.vfx.sprite ? "reference" in spec.vfx.sprite ? spec.vfx.sprite.reference : spec.vfx.sprite.sha256 : void 0,
4405
+ description: spec.vfx.description,
4406
+ artist: spec.vfx.description ? spec.vfx.artist : void 0
4057
4407
  } : void 0,
4058
4408
  // A mirror's bytes come from its source's recorded outputs; the plan
4059
4409
  // compares those directly, so only the choice of source is identity.
@@ -4083,6 +4433,7 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
4083
4433
  skeletonTemplate: spec.revision.skeletonTemplate,
4084
4434
  description: spec.revision.description,
4085
4435
  removalTask: spec.revision.removalTask,
4436
+ convention: spec.revision.convention,
4086
4437
  driftThreshold: spec.revision.driftThreshold,
4087
4438
  subjectDescription: spec.revision.subjectDescription,
4088
4439
  initialPose: spec.revision.initialPose,
@@ -4229,7 +4580,8 @@ function vfxOutputPaths(out) {
4229
4580
  gif: `${base}.gif`,
4230
4581
  atlas: `${base}.json`,
4231
4582
  provenance: `${base}.vfx.json`,
4232
- gamePack: `${base}-game.zip`
4583
+ gamePack: `${base}-game.zip`,
4584
+ normals: `${base}-normals.png`
4233
4585
  };
4234
4586
  }
4235
4587
  function isVfxReference(sprite) {
@@ -4319,10 +4671,17 @@ function vfxRecipeSha256(recipe) {
4319
4671
  const { name: _name, notes: _notes, ...rest } = recipe;
4320
4672
  return sha256(JSON.stringify(rest));
4321
4673
  }
4674
+ function vfxAtlasFrames(atlas) {
4675
+ const listed = atlas?.frames;
4676
+ const entries = Array.isArray(listed) ? listed : listed && typeof listed === "object" ? Object.values(listed) : null;
4677
+ if (!entries?.length) return null;
4678
+ const rects = entries.map((entry) => entry?.frame);
4679
+ return rects.every((rect) => rect && [rect.x, rect.y, rect.w, rect.h].every((n) => typeof n === "number")) ? rects : null;
4680
+ }
4322
4681
  function sliceVfxSheet(sheet, facts, atlas) {
4323
4682
  const image = decodePng(sheet);
4324
- const listed = atlas?.frames;
4325
- const rects = Array.isArray(listed) && listed.length === facts.frames ? listed.map((entry) => entry.frame) : Array.from({ length: facts.frames }, (_, index) => {
4683
+ const listed = vfxAtlasFrames(atlas);
4684
+ const rects = listed && listed.length === facts.frames ? listed : Array.from({ length: facts.frames }, (_, index) => {
4326
4685
  const columns = Math.max(1, Math.floor(image.width / facts.width));
4327
4686
  return { x: index % columns * facts.width, y: Math.floor(index / columns) * facts.height, w: facts.width, h: facts.height };
4328
4687
  });
@@ -4382,45 +4741,516 @@ async function makeVfx(client2, opts) {
4382
4741
  bytes = Buffer.from(`${JSON.stringify(atlas, null, 2)}
4383
4742
  `);
4384
4743
  }
4385
- await (0, import_promises.writeFile)(target, bytes);
4386
- if (target === paths.gamePack) written.gamePack = target;
4744
+ await (0, import_promises.writeFile)(target, bytes);
4745
+ if (target === paths.gamePack) written.gamePack = target;
4746
+ }
4747
+ for (const file of [paths.sheet, paths.gif, paths.atlas]) {
4748
+ if (!(0, import_node_fs2.existsSync)(file)) throw new Error(`PixelLab's export listed no file for ${import_node_path.default.basename(file)}: ${exported.files.join(", ")}`);
4749
+ }
4750
+ const spriteRecord = !opts.sprite || /^data:/.test(opts.sprite) ? void 0 : isVfxReference(opts.sprite) ? opts.sprite : import_node_path.default.relative(import_node_path.default.dirname(paths.provenance), import_node_path.default.resolve(opts.sprite)).split(import_node_path.default.sep).join("/");
4751
+ const provenance = {
4752
+ version: 1,
4753
+ provider: "pixellab",
4754
+ tool: VFX_TOOL,
4755
+ name,
4756
+ effectId: exported.effectId,
4757
+ editUrl: exported.editUrl,
4758
+ source: {
4759
+ ...opts.preset ? { preset: opts.preset } : {},
4760
+ ...opts.effect ? { effect: opts.effect } : {},
4761
+ ...opts.recipe ? { recipeFile: true } : {},
4762
+ ...opts.parts?.length ? { parts: opts.parts } : {},
4763
+ ...spriteRecord ? { sprite: spriteRecord } : {}
4764
+ },
4765
+ width: exported.width,
4766
+ height: exported.height,
4767
+ frames: exported.frames,
4768
+ fps: exported.fps,
4769
+ loop: exported.loop,
4770
+ anchor: exported.anchor,
4771
+ files: {
4772
+ sheet: import_node_path.default.basename(paths.sheet),
4773
+ gif: import_node_path.default.basename(paths.gif),
4774
+ atlas: import_node_path.default.basename(paths.atlas),
4775
+ ...written.gamePack ? { gamePack: import_node_path.default.basename(written.gamePack) } : {}
4776
+ },
4777
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4778
+ recipe
4779
+ };
4780
+ await (0, import_promises.writeFile)(paths.provenance, `${JSON.stringify(provenance, null, 2)}
4781
+ `);
4782
+ return { ...exported, paths: written, recipe };
4783
+ }
4784
+ var VFX_ARTISTS = ["lite", "pro"];
4785
+ var VFX_ARTIST_MAX_SPRITE = 512;
4786
+ function vfxArtistQuote(artist, sprite, change = false) {
4787
+ const table = {
4788
+ lite: { new: [1, 1, 3, 5], change: [1, 1, 2, 2] },
4789
+ pro: { new: [6, 8, 14, 28], change: [4, 4, 5, 8] }
4790
+ };
4791
+ const tier = sprite === "none" ? 0 : sprite === "unknown" ? 3 : sprite <= 128 ? 1 : sprite <= 256 ? 2 : 3;
4792
+ return table[artist][change ? "change" : "new"][tier];
4793
+ }
4794
+ function vfxArtistCeiling(quote) {
4795
+ return quote * 2;
4796
+ }
4797
+ function vfxArtistSprite(bytes, label) {
4798
+ const metadata = imageMetadata(bytes);
4799
+ if (metadata?.format !== "png") throw new Error(`${label}: PixelLab's VFX artist takes a PNG sprite`);
4800
+ const side = Math.max(metadata.width, metadata.height);
4801
+ if (side > VFX_ARTIST_MAX_SPRITE) {
4802
+ throw new Error(
4803
+ `${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)`
4804
+ );
4805
+ }
4806
+ return { image: { base64: bytes.toString("base64"), format: "png" }, side };
4807
+ }
4808
+ function vfxArtistFiles(raw) {
4809
+ const urlOf = (value) => {
4810
+ if (typeof value === "string") return /^https?:\/\//i.test(value) ? value : null;
4811
+ if (!value || typeof value !== "object") return null;
4812
+ for (const key of ["url", "download_url", "href"]) {
4813
+ const url = value[key];
4814
+ if (typeof url === "string" && /^https?:\/\//i.test(url)) return url;
4815
+ }
4816
+ return null;
4817
+ };
4818
+ const nameOf = (value, url, key) => {
4819
+ if (value && typeof value === "object") {
4820
+ for (const field of ["name", "filename", "file"]) {
4821
+ const name = value[field];
4822
+ if (typeof name === "string" && name) return name;
4823
+ }
4824
+ }
4825
+ if (key && /\.[a-z0-9]+$/i.test(key)) return key;
4826
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
4827
+ };
4828
+ const entries = Array.isArray(raw) ? raw.map((value) => [void 0, value]) : raw && typeof raw === "object" ? Object.entries(raw) : [];
4829
+ const files = [];
4830
+ for (const [key, value] of entries) {
4831
+ const url = urlOf(value);
4832
+ if (url) files.push({ name: nameOf(value, url, key), url });
4833
+ }
4834
+ return files;
4835
+ }
4836
+ function parseVfxArtistResult(done, jobId) {
4837
+ const files = vfxArtistFiles(done.files);
4838
+ const pick = (test, skip) => files.find((file) => test.test(file.name) && !skip?.test(file.name)) ?? null;
4839
+ const normals = /-normals?\.png$/i;
4840
+ const sheet = pick(/\.png$/i, normals);
4841
+ const effectId = typeof done.effect_id === "string" && done.effect_id ? done.effect_id : null;
4842
+ if (!effectId || !sheet) {
4843
+ throw new Error(
4844
+ `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`
4845
+ );
4846
+ }
4847
+ const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
4848
+ const text = (value) => typeof value === "string" && value ? value : null;
4849
+ const canvas = done.canvas ?? {};
4850
+ const anchor = done.anchor;
4851
+ return {
4852
+ effectId,
4853
+ name: text(done.name),
4854
+ editUrl: text(done.edit_url),
4855
+ message: text(done.message),
4856
+ width: number(canvas.w),
4857
+ height: number(canvas.h),
4858
+ frames: number(done.frames),
4859
+ fps: number(done.fps),
4860
+ loop: typeof done.loop === "boolean" ? done.loop : null,
4861
+ anchor: anchor && number(anchor.x) !== null && number(anchor.y) !== null ? { kind: text(anchor.kind), x: anchor.x, y: anchor.y } : null,
4862
+ parts: done.parts ?? null,
4863
+ conversation: text(done.conversation),
4864
+ files,
4865
+ sheet,
4866
+ atlas: pick(/\.json$/i),
4867
+ gif: pick(/\.gif$/i),
4868
+ gamePack: pick(/\.zip$/i),
4869
+ normals: pick(normals)
4870
+ };
4871
+ }
4872
+ function vfxArtistFrameFacts(result, atlas) {
4873
+ const rects = vfxAtlasFrames(atlas);
4874
+ const frames = result.frames ?? rects?.length ?? null;
4875
+ const width = result.width ?? rects?.[0]?.w ?? null;
4876
+ const height = result.height ?? rects?.[0]?.h ?? null;
4877
+ if (!frames || !width || !height) {
4878
+ throw new Error(`PixelLab vfx effect ${result.effectId} came back with no frame size or count, in the result or its atlas`);
4879
+ }
4880
+ return { width, height, frames };
4881
+ }
4882
+ async function makeVfxWithArtist(client2, opts) {
4883
+ const paths = vfxOutputPaths(opts.out);
4884
+ if (!opts.force) {
4885
+ for (const file of [paths.sheet, paths.gif, paths.atlas, paths.provenance, ...opts.gamePack ? [paths.gamePack] : []]) {
4886
+ if ((0, import_node_fs2.existsSync)(file)) throw new Error(`${file} already exists; pass --force to replace it`);
4887
+ }
4888
+ }
4889
+ const progress = opts.onProgress ?? (() => {
4890
+ });
4891
+ const sprite = opts.sprite ? vfxArtistSprite(await (0, import_promises.readFile)(opts.sprite), opts.sprite).image : void 0;
4892
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
4893
+ const interval = opts.intervalMs ?? 6e3;
4894
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
4895
+ const submitted = await client2.createVfx({
4896
+ description: opts.description,
4897
+ artist: opts.artist,
4898
+ sprite,
4899
+ effectId: opts.effect,
4900
+ conversation: opts.effect ? opts.conversation : void 0,
4901
+ name: opts.name,
4902
+ gamePack: opts.gamePack
4903
+ });
4904
+ const jobId = submitted.background_job_id;
4905
+ progress(`submitted job ${jobId}, quoted ${submitted.quoted_generations ?? "?"} generations`);
4906
+ const started = Date.now();
4907
+ let job;
4908
+ for (; ; ) {
4909
+ job = await client2.getBackgroundJob(jobId);
4910
+ if (job.status === "completed") break;
4911
+ if (job.status === "failed") throw new Error(`PixelLab vfx job ${jobId} failed upstream${vfxJobFailure(job.last_response)}`);
4912
+ if (Date.now() - started > timeout) {
4913
+ throw new Error(
4914
+ `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`
4915
+ );
4916
+ }
4917
+ progress(job.status);
4918
+ await sleep3(interval);
4919
+ }
4920
+ const result = parseVfxArtistResult(job.last_response ?? {}, jobId);
4921
+ await (0, import_promises.mkdir)(import_node_path.default.dirname(paths.sheet), { recursive: true });
4922
+ const written = { sheet: paths.sheet, provenance: paths.provenance };
4923
+ await (0, import_promises.writeFile)(paths.sheet, await client2.download(result.sheet.url));
4924
+ let atlas;
4925
+ if (result.atlas) {
4926
+ atlas = JSON.parse((await client2.download(result.atlas.url)).toString("utf8"));
4927
+ const meta = atlas.meta;
4928
+ if (meta) meta.image = import_node_path.default.basename(paths.sheet);
4929
+ await (0, import_promises.writeFile)(paths.atlas, `${JSON.stringify(atlas, null, 2)}
4930
+ `);
4931
+ written.atlas = paths.atlas;
4932
+ }
4933
+ for (const [file, target, key] of [
4934
+ [result.gif, paths.gif, "gif"],
4935
+ [result.gamePack, paths.gamePack, "gamePack"],
4936
+ [result.normals, paths.normals, "normals"]
4937
+ ]) {
4938
+ if (!file) continue;
4939
+ await (0, import_promises.writeFile)(target, await client2.download(file.url));
4940
+ written[key] = target;
4941
+ }
4942
+ const facts = vfxArtistFrameFacts(result, atlas);
4943
+ const usage = job.usage ?? null;
4944
+ const provenance = {
4945
+ version: 1,
4946
+ provider: "pixellab",
4947
+ tool: "vfx",
4948
+ artist: opts.artist ?? "lite",
4949
+ description: opts.description,
4950
+ name: result.name,
4951
+ effectId: result.effectId,
4952
+ editUrl: result.editUrl,
4953
+ message: result.message,
4954
+ source: {
4955
+ ...opts.sprite ? { sprite: import_node_path.default.relative(import_node_path.default.dirname(paths.provenance), import_node_path.default.resolve(opts.sprite)).split(import_node_path.default.sep).join("/") } : {},
4956
+ ...opts.effect ? { changed: opts.effect } : {}
4957
+ },
4958
+ ...facts,
4959
+ fps: result.fps,
4960
+ loop: result.loop,
4961
+ anchor: result.anchor,
4962
+ parts: result.parts,
4963
+ files: Object.fromEntries(
4964
+ ["sheet", "gif", "atlas", "gamePack", "normals"].filter((key) => written[key]).map((key) => [key, import_node_path.default.basename(written[key])])
4965
+ ),
4966
+ quotedGenerations: submitted.quoted_generations ?? null,
4967
+ usage,
4968
+ conversation: result.conversation,
4969
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString()
4970
+ };
4971
+ await (0, import_promises.writeFile)(paths.provenance, `${JSON.stringify(provenance, null, 2)}
4972
+ `);
4973
+ return {
4974
+ jobId,
4975
+ effectId: result.effectId,
4976
+ editUrl: result.editUrl,
4977
+ name: result.name,
4978
+ message: result.message,
4979
+ ...facts,
4980
+ fps: result.fps,
4981
+ loop: result.loop,
4982
+ anchor: result.anchor,
4983
+ quotedGenerations: submitted.quoted_generations ?? null,
4984
+ usage,
4985
+ paths: written
4986
+ };
4987
+ }
4988
+ function vfxJobFailure(done) {
4989
+ for (const key of ["error", "detail", "message"]) {
4990
+ const value = done?.[key];
4991
+ if (typeof value === "string" && value) return `: ${value}`;
4992
+ }
4993
+ return "";
4994
+ }
4995
+ async function readVfxConversation(file, effectId) {
4996
+ if (!(0, import_node_fs2.existsSync)(file)) return void 0;
4997
+ try {
4998
+ const record = JSON.parse(await (0, import_promises.readFile)(file, "utf8"));
4999
+ return record.effectId === effectId && typeof record.conversation === "string" ? record.conversation : void 0;
5000
+ } catch {
5001
+ return void 0;
5002
+ }
5003
+ }
5004
+
5005
+ // src/source-url.ts
5006
+ var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
5007
+ "access_token",
5008
+ "apikey",
5009
+ "api_key",
5010
+ "auth",
5011
+ "authorization",
5012
+ "awsaccesskeyid",
5013
+ "googleaccessid",
5014
+ "key",
5015
+ "sig"
5016
+ ]);
5017
+ function isSensitiveSourceUrl(value) {
5018
+ let parsed;
5019
+ try {
5020
+ parsed = new URL(value);
5021
+ } catch {
5022
+ return false;
5023
+ }
5024
+ if (parsed.username || parsed.password) return true;
5025
+ for (const key of parsed.searchParams.keys()) {
5026
+ const normalized = key.toLowerCase();
5027
+ if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
5028
+ }
5029
+ return false;
5030
+ }
5031
+ function shouldPersistSourceUrl(value) {
5032
+ let parsed;
5033
+ try {
5034
+ parsed = new URL(value);
5035
+ } catch {
5036
+ return true;
5037
+ }
5038
+ if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
5039
+ return !isSensitiveSourceUrl(value);
5040
+ }
5041
+
5042
+ // src/pixellab-utils.ts
5043
+ var import_node_fs3 = require("fs");
5044
+ var import_promises2 = require("fs/promises");
5045
+ var import_node_path2 = __toESM(require("path"), 1);
5046
+ var UNZOOM_MIN_SIDE = 256;
5047
+ var UNZOOM_MAX_AREA = 2048 * 2048;
5048
+ function defaultUnzoomOut(input) {
5049
+ const ext = import_node_path2.default.extname(input);
5050
+ return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
5051
+ }
5052
+ async function unzoomFile(client2, input, opts = {}) {
5053
+ const out = import_node_path2.default.resolve(opts.out ?? defaultUnzoomOut(input));
5054
+ if (import_node_path2.default.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
5055
+ if ((0, import_node_fs3.existsSync)(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
5056
+ const bytes = await (0, import_promises2.readFile)(input);
5057
+ const metadata = imageMetadata(bytes);
5058
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
5059
+ const { width, height, format } = metadata;
5060
+ if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
5061
+ throw new Error(
5062
+ `${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`
5063
+ );
5064
+ }
5065
+ if (width * height > UNZOOM_MAX_AREA) {
5066
+ throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
5067
+ }
5068
+ const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
5069
+ await (0, import_promises2.mkdir)(import_node_path2.default.dirname(out), { recursive: true });
5070
+ await (0, import_promises2.writeFile)(out, res.png);
5071
+ return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
5072
+ }
5073
+ var FONT_WEIGHTS = ["Bold", "Regular"];
5074
+ var FONT_GLYPH_SIZES = [8, 16, 32, 64];
5075
+ var FONT_COST_GENERATIONS = 15;
5076
+ function fontOutputPaths(out) {
5077
+ const base = import_node_path2.default.resolve(out.replace(/\.(ttf|png)$/i, ""));
5078
+ return { ttf: `${base}.ttf`, atlas: `${base}.png` };
5079
+ }
5080
+ async function generateFont(client2, opts) {
5081
+ const { ttf, atlas } = fontOutputPaths(opts.out);
5082
+ if (!opts.force) {
5083
+ for (const file of [ttf, atlas]) {
5084
+ if ((0, import_node_fs3.existsSync)(file)) throw new Error(`${file} already exists; pass --force to replace it`);
5085
+ }
5086
+ }
5087
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
5088
+ const interval = opts.intervalMs ?? 5e3;
5089
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
5090
+ const submitted = await client2.generateFontPro({
5091
+ description: opts.description,
5092
+ weight: opts.weight,
5093
+ glyphPx: opts.glyphPx,
5094
+ seed: opts.seed,
5095
+ fontName: opts.fontName
5096
+ });
5097
+ const jobId = submitted.background_job_id;
5098
+ const started = Date.now();
5099
+ for (; ; ) {
5100
+ const job = await client2.getFontJob(jobId);
5101
+ opts.onProgress?.(job.status);
5102
+ if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
5103
+ if (job.status === "completed") {
5104
+ if (!job.download_ttf_url || !job.download_atlas_url) {
5105
+ throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
5106
+ }
5107
+ const [ttfBytes, atlasBytes] = await Promise.all([
5108
+ client2.download(job.download_ttf_url),
5109
+ client2.download(job.download_atlas_url)
5110
+ ]);
5111
+ await (0, import_promises2.mkdir)(import_node_path2.default.dirname(ttf), { recursive: true });
5112
+ await (0, import_promises2.writeFile)(ttf, ttfBytes);
5113
+ await (0, import_promises2.writeFile)(atlas, atlasBytes);
5114
+ return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
5115
+ }
5116
+ if (Date.now() - started > timeout) {
5117
+ throw new Error(
5118
+ `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
5119
+ );
5120
+ }
5121
+ await sleep3(interval);
5122
+ }
5123
+ }
5124
+ var PIXELATE_COST_GENERATIONS = 6;
5125
+ var PIXELATE_MAX_SIDE = 2048;
5126
+ function defaultPixelateOut(input) {
5127
+ const ext = import_node_path2.default.extname(input);
5128
+ return `${ext ? input.slice(0, -ext.length) : input}.pixel.png`;
5129
+ }
5130
+ async function pixelateFile(client2, input, opts = {}) {
5131
+ const out = import_node_path2.default.resolve(opts.out ?? defaultPixelateOut(input));
5132
+ if (import_node_path2.default.resolve(input) === out) throw new Error("pixelate will not overwrite its own input; pass a different --out");
5133
+ if ((0, import_node_fs3.existsSync)(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
5134
+ const bytes = await (0, import_promises2.readFile)(input);
5135
+ const metadata = imageMetadata(bytes);
5136
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
5137
+ const { width, height, format } = metadata;
5138
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
5139
+ const interval = opts.intervalMs ?? 5e3;
5140
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
5141
+ const submitted = await client2.imageToPixelartProFlash({
5142
+ image: { base64: bytes.toString("base64"), format },
5143
+ description: opts.description,
5144
+ seed: opts.seed
5145
+ });
5146
+ const jobId = submitted.background_job_id;
5147
+ const started = Date.now();
5148
+ for (; ; ) {
5149
+ const job = await client2.getBackgroundJob(jobId);
5150
+ opts.onProgress?.(job.status);
5151
+ if (job.status === "failed") throw new Error(`PixelLab pixelate job ${jobId} failed upstream`);
5152
+ if (job.status === "completed") {
5153
+ const images = job.last_response?.images;
5154
+ const first = Array.isArray(images) ? images[0] : void 0;
5155
+ if (!first || typeof first.base64 !== "string" || !first.base64) {
5156
+ throw new Error(`PixelLab pixelate job ${jobId} completed without an image at last_response.images[0]`);
5157
+ }
5158
+ const png = Buffer.from(first.base64, "base64");
5159
+ const size = imageMetadata(png);
5160
+ await (0, import_promises2.mkdir)(import_node_path2.default.dirname(out), { recursive: true });
5161
+ await (0, import_promises2.writeFile)(out, png);
5162
+ return {
5163
+ jobId,
5164
+ out,
5165
+ original: { width, height },
5166
+ pixelated: { width: size?.width ?? Number(first.width), height: size?.height ?? Number(first.height) },
5167
+ imageId: submitted.image_id,
5168
+ usage: job.usage ?? null
5169
+ };
5170
+ }
5171
+ if (Date.now() - started > timeout) {
5172
+ throw new Error(
5173
+ `PixelLab pixelate job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
5174
+ );
5175
+ }
5176
+ await sleep3(interval);
4387
5177
  }
4388
- for (const file of [paths.sheet, paths.gif, paths.atlas]) {
4389
- if (!(0, import_node_fs2.existsSync)(file)) throw new Error(`PixelLab's export listed no file for ${import_node_path.default.basename(file)}: ${exported.files.join(", ")}`);
5178
+ }
5179
+ var NORMAL_MAP_COST_GENERATIONS = 0.5;
5180
+ var NORMAL_MAP_MIN_SIDE = 8;
5181
+ var NORMAL_MAP_MAX_SIDE = 512;
5182
+ var NORMAL_MAP_MAX_FRAMES = 32;
5183
+ var NORMAL_MAP_CONVENTIONS = ["opengl", "directx"];
5184
+ function normalMapSizeProblem(width, height, frames = 1) {
5185
+ if (!Number.isInteger(frames) || frames < 1 || frames > NORMAL_MAP_MAX_FRAMES) {
5186
+ return `PixelLab's normal map takes 1 to ${NORMAL_MAP_MAX_FRAMES} frames, not ${frames}`;
5187
+ }
5188
+ if (width % frames !== 0) return `a ${width}px-wide strip does not split into ${frames} equal frames`;
5189
+ const frameWidth = width / frames;
5190
+ if (frameWidth < NORMAL_MAP_MIN_SIDE || height < NORMAL_MAP_MIN_SIDE || frameWidth > NORMAL_MAP_MAX_SIDE || height > NORMAL_MAP_MAX_SIDE) {
5191
+ 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`;
5192
+ }
5193
+ return null;
5194
+ }
5195
+ function defaultNormalMapOut(input) {
5196
+ const ext = import_node_path2.default.extname(input);
5197
+ return `${ext ? input.slice(0, -ext.length) : input}.normal.png`;
5198
+ }
5199
+ function normalMapResultPng(lastResponse) {
5200
+ const image = lastResponse?.image;
5201
+ const base64 = typeof image === "string" ? image : image?.base64;
5202
+ if (typeof base64 !== "string" || !base64) return null;
5203
+ return Buffer.from(base64.replace(/^data:image\/\w+;base64,/, ""), "base64");
5204
+ }
5205
+ async function normalMapFile(client2, input, opts = {}) {
5206
+ const out = import_node_path2.default.resolve(opts.out ?? defaultNormalMapOut(input));
5207
+ if (import_node_path2.default.resolve(input) === out) throw new Error("normal-map will not overwrite its own input; pass a different --out");
5208
+ if ((0, import_node_fs3.existsSync)(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
5209
+ const bytes = await (0, import_promises2.readFile)(input);
5210
+ const metadata = imageMetadata(bytes);
5211
+ if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
5212
+ if (metadata.format !== "png") throw new Error(`${input} is a JPEG; normal-map reads a PNG, whose alpha marks where the sprite is`);
5213
+ const { width, height } = metadata;
5214
+ const frames = opts.frames ?? 1;
5215
+ const problem = normalMapSizeProblem(width, height, frames);
5216
+ if (problem) throw new Error(`${input}: ${problem}`);
5217
+ const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
5218
+ const interval = opts.intervalMs ?? 5e3;
5219
+ const timeout = opts.timeoutMs ?? 10 * 6e4;
5220
+ const submitted = await client2.normalMap({
5221
+ image: { base64: bytes.toString("base64"), format: "png" },
5222
+ frames,
5223
+ convention: opts.convention
5224
+ });
5225
+ const jobId = submitted.background_job_id;
5226
+ const started = Date.now();
5227
+ for (; ; ) {
5228
+ const job = await client2.getBackgroundJob(jobId);
5229
+ opts.onProgress?.(job.status);
5230
+ if (job.status === "failed") throw new Error(`PixelLab normal-map job ${jobId} failed upstream`);
5231
+ if (job.status === "completed") {
5232
+ const png = normalMapResultPng(job.last_response);
5233
+ if (!png) throw new Error(`PixelLab normal-map job ${jobId} completed without an image at last_response.image`);
5234
+ await (0, import_promises2.mkdir)(import_node_path2.default.dirname(out), { recursive: true });
5235
+ await (0, import_promises2.writeFile)(out, png);
5236
+ const size = imageMetadata(png);
5237
+ return {
5238
+ jobId,
5239
+ out,
5240
+ width: size?.width ?? width,
5241
+ height: size?.height ?? height,
5242
+ frames,
5243
+ convention: opts.convention ?? "opengl",
5244
+ usage: job.usage ?? null
5245
+ };
5246
+ }
5247
+ if (Date.now() - started > timeout) {
5248
+ throw new Error(
5249
+ `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`
5250
+ );
5251
+ }
5252
+ await sleep3(interval);
4390
5253
  }
4391
- const spriteRecord = !opts.sprite || /^data:/.test(opts.sprite) ? void 0 : isVfxReference(opts.sprite) ? opts.sprite : import_node_path.default.relative(import_node_path.default.dirname(paths.provenance), import_node_path.default.resolve(opts.sprite)).split(import_node_path.default.sep).join("/");
4392
- const provenance = {
4393
- version: 1,
4394
- provider: "pixellab",
4395
- tool: VFX_TOOL,
4396
- name,
4397
- effectId: exported.effectId,
4398
- editUrl: exported.editUrl,
4399
- source: {
4400
- ...opts.preset ? { preset: opts.preset } : {},
4401
- ...opts.effect ? { effect: opts.effect } : {},
4402
- ...opts.recipe ? { recipeFile: true } : {},
4403
- ...opts.parts?.length ? { parts: opts.parts } : {},
4404
- ...spriteRecord ? { sprite: spriteRecord } : {}
4405
- },
4406
- width: exported.width,
4407
- height: exported.height,
4408
- frames: exported.frames,
4409
- fps: exported.fps,
4410
- loop: exported.loop,
4411
- anchor: exported.anchor,
4412
- files: {
4413
- sheet: import_node_path.default.basename(paths.sheet),
4414
- gif: import_node_path.default.basename(paths.gif),
4415
- atlas: import_node_path.default.basename(paths.atlas),
4416
- ...written.gamePack ? { gamePack: import_node_path.default.basename(written.gamePack) } : {}
4417
- },
4418
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4419
- recipe
4420
- };
4421
- await (0, import_promises.writeFile)(paths.provenance, `${JSON.stringify(provenance, null, 2)}
4422
- `);
4423
- return { ...exported, paths: written, recipe };
4424
5254
  }
4425
5255
 
4426
5256
  // src/providers/pixellab.ts
@@ -4440,6 +5270,20 @@ function pixelLabObjectUrl(generator, objectId) {
4440
5270
  if (generator === "vfx") return `https://pixellab.ai/create-vfx?effect=${encodeURIComponent(objectId)}`;
4441
5271
  return null;
4442
5272
  }
5273
+ function vfxArtistSpecQuote(spec) {
5274
+ const sprite = spec.vfx?.sprite;
5275
+ const side = !sprite ? "none" : "file" in sprite && sprite.width != null && sprite.height != null ? Math.max(sprite.width, sprite.height) : "unknown";
5276
+ return vfxArtistQuote(spec.vfx?.artist ?? "lite", side);
5277
+ }
5278
+ function workbenchVfxUrls(exported) {
5279
+ const sheetFile = exported.files.find((file) => /\.png$/i.test(file));
5280
+ const atlasFile = exported.files.find((file) => /\.json$/i.test(file));
5281
+ if (!sheetFile) throw new Error(`PixelLab's vfx export listed no spritesheet: ${exported.files.join(", ")}`);
5282
+ return {
5283
+ sheet: `${exported.downloadBase}${encodeURIComponent(sheetFile)}`,
5284
+ ...atlasFile ? { atlas: `${exported.downloadBase}${encodeURIComponent(atlasFile)}` } : {}
5285
+ };
5286
+ }
4443
5287
  function billedFromUsage(usage) {
4444
5288
  if (!usage) return null;
4445
5289
  if (usage.type === "usd" || usage.type === void 0 && typeof usage.usd === "number") {
@@ -4594,13 +5438,14 @@ var PixelLabProvider = class _PixelLabProvider {
4594
5438
  * source from a supplied per-frame skeleton instead of a text description)
4595
5439
  * — and `interpolate` (`/interpolation-v2`, the in-betweens
4596
5440
  * from one keyframe to another) and `edit-animation`
4597
- * (`/edit-animation-v2`, one text edit applied across a whole frame set).
5441
+ * (`/edit-animation-v2`, one text edit applied across a whole frame set),
5442
+ * and `normal-map` (`/normal-map`, lighting data for a sprite or a loop).
4598
5443
  * `outpaint` does not: there is no canvas-expansion endpoint in the API,
4599
5444
  * matching docs/REVISIONS.md's note that no provider ships a tested
4600
5445
  * outpaint path yet.
4601
5446
  */
4602
5447
  supportsRevision(mode2) {
4603
- 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";
5448
+ 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";
4604
5449
  }
4605
5450
  /** PixelLab's own constraints: submissions must be >2s apart, and
4606
5451
  * background jobs in flight are capped by subscription tier (Tier 1=8,
@@ -4620,11 +5465,12 @@ var PixelLabProvider = class _PixelLabProvider {
4620
5465
  * is a file read.
4621
5466
  */
4622
5467
  static cacheDir(namespace = "pixflux") {
4623
- const dir = import_node_path2.default.join(import_node_os.default.tmpdir(), `pixelkiln-${namespace}`);
4624
- (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
5468
+ const dir = import_node_path3.default.join(import_node_os.default.tmpdir(), `pixelkiln-${namespace}`);
5469
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
4625
5470
  return dir;
4626
5471
  }
4627
5472
  estimate(spec) {
5473
+ if (spec.vfx?.description) return { unit: "generations", amount: vfxArtistCeiling(vfxArtistSpecQuote(spec)), candidates: 1 };
4628
5474
  if (spec.vfx) return { unit: "generations", amount: 0, candidates: 1 };
4629
5475
  if (spec.batch?.role === "member") {
4630
5476
  return { unit: "generations", amount: 0, candidates: 1 };
@@ -4632,6 +5478,9 @@ var PixelLabProvider = class _PixelLabProvider {
4632
5478
  if (spec.revision?.mode === "remove-background") {
4633
5479
  return { unit: "generations", amount: 1, candidates: 1 };
4634
5480
  }
5481
+ if (spec.revision?.mode === "normal-map") {
5482
+ return { unit: "generations", amount: NORMAL_MAP_COST_GENERATIONS, candidates: 1 };
5483
+ }
4635
5484
  if (spec.revision?.mode === "reduce-colors" || spec.revision?.mode === "correct-pixelart") {
4636
5485
  return { unit: "generations", amount: 0.1, candidates: 1 };
4637
5486
  }
@@ -4686,12 +5535,40 @@ var PixelLabProvider = class _PixelLabProvider {
4686
5535
  if (spec.generator === "vfx" && styleImages.length) {
4687
5536
  throw new Error("PixelLab vfx takes no style images; an effect's look comes from its preset, parts, or recipe");
4688
5537
  }
5538
+ const sprite = spec.vfx?.description ? spec.vfx.sprite : void 0;
5539
+ if (sprite && "reference" in sprite) {
5540
+ throw new Error("PixelLab's VFX artist takes a sprite's pixels, not a PixelLab reference; point vfx.sprite at a PNG file");
5541
+ }
5542
+ if (sprite?.width != null && sprite.height != null && Math.max(sprite.width, sprite.height) > VFX_ARTIST_MAX_SPRITE) {
5543
+ throw new Error(
5544
+ `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)`
5545
+ );
5546
+ }
4689
5547
  const members = spec.revision?.sourceMembers;
4690
5548
  if (spec.revision && members && !MEMBER_SET_REVISION_MODES.includes(spec.revision.mode)) {
4691
5549
  throw new Error(
4692
- `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`
5550
+ `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`
4693
5551
  );
4694
5552
  }
5553
+ if (spec.revision?.mode === "normal-map") {
5554
+ const { sourceWidth: width, sourceHeight: height, sourceFormat, sourceAssetId } = spec.revision;
5555
+ if (members && !members.every((member) => member.role.startsWith("frame-"))) {
5556
+ throw new Error(
5557
+ `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`
5558
+ );
5559
+ }
5560
+ if (members && members.length > NORMAL_MAP_MAX_FRAMES) {
5561
+ throw new Error(
5562
+ `PixelLab normal-map takes at most ${NORMAL_MAP_MAX_FRAMES} frames in one strip; ${sourceAssetId} has ${members.length}`
5563
+ );
5564
+ }
5565
+ if (sourceFormat === "jpeg") {
5566
+ throw new Error(`PixelLab normal-map reads a PNG, whose alpha marks where the sprite is; ${sourceAssetId} is a JPEG`);
5567
+ }
5568
+ const frames = members?.length ?? 1;
5569
+ const problem = width != null && height != null ? normalMapSizeProblem(width * frames, height, frames) : null;
5570
+ if (problem) throw new Error(`PixelLab normal-map source ${sourceAssetId}: ${problem}`);
5571
+ }
4695
5572
  if (spec.revision?.engine === "pro-flash") {
4696
5573
  const { sourceWidth: width, sourceHeight: height, mode: mode2 } = spec.revision;
4697
5574
  if (width != null && height != null && (width < 32 || height < 32 || width > 256 || height > 256 || width % 4 || height % 4)) {
@@ -4799,6 +5676,9 @@ var PixelLabProvider = class _PixelLabProvider {
4799
5676
  `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)`
4800
5677
  );
4801
5678
  }
5679
+ if (spec.generator === "imagePro" && !spec.revision && spec.palette.length && (spec.palette.length < 2 || spec.palette.length > 256)) {
5680
+ throw new Error(`PixelLab imagePro's target palette takes 2 to 256 colours; the style's palette has ${spec.palette.length}`);
5681
+ }
4802
5682
  if (spec.generator === "uiAsset" && (spec.width < 192 || spec.height < 192 || spec.width > 688 || spec.height > 688)) {
4803
5683
  throw new Error(
4804
5684
  `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)`
@@ -4865,24 +5745,8 @@ var PixelLabProvider = class _PixelLabProvider {
4865
5745
  if (spec.detail) {
4866
5746
  requirePixelLabOption("terrain", "detail", spec.detail, ["low detail", "medium detail", "highly detailed"]);
4867
5747
  }
4868
- if (spec.terrainShapeStyle && spec.terrainMode === "pro") {
4869
- throw new Error(
4870
- "PixelLab terrain: terrainShapeStyle is standard-mode only; pro's own shape controls are terrainSpreadX/terrainSlopeSize/terrainRaggedness"
4871
- );
4872
- }
4873
- if (spec.terrainTileSize === 64 && spec.terrainMode !== "pro") {
4874
- throw new Error('PixelLab terrain: a 64px tile needs terrainMode: "pro"');
4875
- }
4876
- if (spec.terrainShapeStyle && spec.terrainTransitionSize != null && spec.terrainTransitionSize > 0.5) {
4877
- throw new Error(
4878
- "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"
4879
- );
4880
- }
4881
- if (!spec.terrainShapeStyle && spec.terrainTransitionSize != null && ![0, 0.25, 0.5, 1].includes(spec.terrainTransitionSize)) {
4882
- throw new Error(
4883
- "PixelLab terrain: terrainTransitionSize must be 0, 0.25, 0.5, or 1 unless terrainShapeStyle is set"
4884
- );
4885
- }
5748
+ if (spec.terrainOrientation === "sidescroller") this.validateSidescrollerTerrain(spec);
5749
+ else this.validateTopDownTerrain(spec);
4886
5750
  }
4887
5751
  if (spec.generator === "isometricTile") {
4888
5752
  if (spec.outline) {
@@ -4941,6 +5805,87 @@ var PixelLabProvider = class _PixelLabProvider {
4941
5805
  );
4942
5806
  }
4943
5807
  }
5808
+ /** `/create-tileset`'s own rules, beyond the enums both terrain endpoints share. */
5809
+ validateTopDownTerrain(spec) {
5810
+ if (spec.terrainShapeStyle && spec.terrainMode === "pro") {
5811
+ throw new Error(
5812
+ "PixelLab terrain: terrainShapeStyle is standard-mode only; pro's own shape controls are terrainRaggedness/terrainRoundness/terrainBoundaryAmplitude/terrainStepSlope"
5813
+ );
5814
+ }
5815
+ const pro = spec.terrainMode === "pro";
5816
+ if (spec.terrainRoundness != null && !pro && !spec.terrainShapeStyle) {
5817
+ throw new Error('PixelLab terrain: terrainRoundness needs terrainMode: "pro" or a terrainShapeStyle');
5818
+ }
5819
+ if (spec.terrainRoundness != null && spec.terrainRoundness > 1 && !pro) {
5820
+ throw new Error('PixelLab terrain: terrainRoundness above 1 (corners cut toward 45\xB0) needs terrainMode: "pro"');
5821
+ }
5822
+ if (spec.terrainBoundaryAmplitude != null && !pro && !spec.terrainShapeStyle) {
5823
+ throw new Error('PixelLab terrain: terrainBoundaryAmplitude needs terrainMode: "pro" or a terrainShapeStyle');
5824
+ }
5825
+ if (spec.terrainBoundarySeed != null && spec.terrainBoundaryAmplitude == null) {
5826
+ throw new Error("PixelLab terrain: terrainBoundarySeed shapes terrainBoundaryAmplitude's border; set both or neither");
5827
+ }
5828
+ if (spec.terrainStepSlope != null && !(pro && spec.terrainTransitionSize === 0.25)) {
5829
+ throw new Error('PixelLab terrain: terrainStepSlope needs terrainMode: "pro" with terrainTransitionSize 0.25');
5830
+ }
5831
+ if (spec.terrainConnectDiagonals != null && !pro) {
5832
+ throw new Error('PixelLab terrain: terrainConnectDiagonals needs terrainMode: "pro"');
5833
+ }
5834
+ if (spec.terrainTileSize === 64 && spec.terrainMode !== "pro") {
5835
+ throw new Error('PixelLab terrain: a 64px tile needs terrainMode: "pro"');
5836
+ }
5837
+ if (spec.terrainShapeStyle && spec.terrainTransitionSize != null && spec.terrainTransitionSize > 0.5) {
5838
+ throw new Error(
5839
+ "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"
5840
+ );
5841
+ }
5842
+ if (!spec.terrainShapeStyle && spec.terrainTransitionSize != null && ![0, 0.25, 0.5, 1].includes(spec.terrainTransitionSize)) {
5843
+ throw new Error(
5844
+ "PixelLab terrain: terrainTransitionSize must be 0, 0.25, 0.5, or 1 unless terrainShapeStyle is set"
5845
+ );
5846
+ }
5847
+ }
5848
+ /**
5849
+ * `/create-tileset-sidescroller` takes a platform, its top layer, and the
5850
+ * layer's height, and nothing of the top-down endpoint's mode, camera, or
5851
+ * shape controls; sending one would be silently dropped, so it is refused.
5852
+ */
5853
+ validateSidescrollerTerrain(spec) {
5854
+ const topDownOnly = {
5855
+ terrainMode: spec.terrainMode,
5856
+ terrainShapeStyle: spec.terrainShapeStyle,
5857
+ terrainView: spec.terrainView,
5858
+ terrainSpreadX: spec.terrainSpreadX,
5859
+ terrainSlopeSize: spec.terrainSlopeSize,
5860
+ terrainRaggedness: spec.terrainRaggedness,
5861
+ terrainRoundness: spec.terrainRoundness,
5862
+ terrainBoundaryAmplitude: spec.terrainBoundaryAmplitude,
5863
+ terrainBoundarySeed: spec.terrainBoundarySeed,
5864
+ terrainStepSlope: spec.terrainStepSlope,
5865
+ terrainConnectDiagonals: spec.terrainConnectDiagonals
5866
+ };
5867
+ const set = Object.entries(topDownOnly).filter(([, value]) => value != null).map(([field]) => field);
5868
+ if (set.length) {
5869
+ throw new Error(`PixelLab terrain: a sidescroller set takes no ${set.join(", ")}; those are top-down controls`);
5870
+ }
5871
+ if (spec.terrainTileSize !== 16 && spec.terrainTileSize !== 32) {
5872
+ throw new Error(`PixelLab terrain: a sidescroller set's tiles are 16 or 32px, got ${spec.terrainTileSize}`);
5873
+ }
5874
+ if (spec.terrainTransitionSize != null && ![0, 0.25, 0.5, 1].includes(spec.terrainTransitionSize)) {
5875
+ throw new Error("PixelLab terrain: a sidescroller set's terrainTransitionSize must be 0, 0.25, 0.5, or 1");
5876
+ }
5877
+ if (spec.terrainConnect?.upper) {
5878
+ throw new Error(
5879
+ "PixelLab terrain: a sidescroller set's upper terrain is empty air; only its lower (platform) terrain can connect"
5880
+ );
5881
+ }
5882
+ const link = spec.terrainConnect?.lower;
5883
+ if (link?.terrain === "upper") {
5884
+ throw new Error(
5885
+ `PixelLab terrain: ${link.assetId}'s upper terrain is empty air; connect to its lower (platform) terrain`
5886
+ );
5887
+ }
5888
+ }
4944
5889
  validateCharacter(spec, styleImages = []) {
4945
5890
  const character = spec.character;
4946
5891
  if (!character) throw new Error(`${spec.styleId}/${spec.assetId} has no character shape`);
@@ -4970,7 +5915,7 @@ var PixelLabProvider = class _PixelLabProvider {
4970
5915
  }
4971
5916
  }
4972
5917
  if (endFrame) {
4973
- const start = startFrame ?? (character.parentFile && (0, import_node_fs3.existsSync)(character.parentFile) ? imageMetadata((0, import_node_fs3.readFileSync)(character.parentFile)) : null);
5918
+ const start = startFrame ?? (character.parentFile && (0, import_node_fs4.existsSync)(character.parentFile) ? imageMetadata((0, import_node_fs4.readFileSync)(character.parentFile)) : null);
4974
5919
  if (start && (start.width !== endFrame.width || start.height !== endFrame.height)) {
4975
5920
  throw new Error(
4976
5921
  `${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`
@@ -5128,7 +6073,7 @@ var PixelLabProvider = class _PixelLabProvider {
5128
6073
  }
5129
6074
  }
5130
6075
  if (endFrame) {
5131
- const start = startFrame ?? (object.parentFile && (0, import_node_fs3.existsSync)(object.parentFile) ? imageMetadata((0, import_node_fs3.readFileSync)(object.parentFile)) : null);
6076
+ const start = startFrame ?? (object.parentFile && (0, import_node_fs4.existsSync)(object.parentFile) ? imageMetadata((0, import_node_fs4.readFileSync)(object.parentFile)) : null);
5132
6077
  if (start && (start.width !== endFrame.width || start.height !== endFrame.height)) {
5133
6078
  throw new Error(
5134
6079
  `${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`
@@ -5183,13 +6128,13 @@ var PixelLabProvider = class _PixelLabProvider {
5183
6128
  );
5184
6129
  }
5185
6130
  const frames = parentOutputs.slice(0, 16).map((output) => {
5186
- const bytes = (0, import_node_fs3.readFileSync)(output.path);
6131
+ const bytes = (0, import_node_fs4.readFileSync)(output.path);
5187
6132
  const dims = imageMetadata(bytes);
5188
6133
  if (!dims) throw new Error(`${spec.styleId}/${spec.assetId}: outfit source frame is not a readable PNG: ${output.path}`);
5189
6134
  return { base64: bytes.toString("base64"), format: "png", width: dims.width, height: dims.height };
5190
6135
  });
5191
6136
  const outfit = character.outfit;
5192
- const referenceBytes = (0, import_node_fs3.readFileSync)(outfit.reference.path);
6137
+ const referenceBytes = (0, import_node_fs4.readFileSync)(outfit.reference.path);
5193
6138
  if (sha256(referenceBytes) !== outfit.reference.sha256) {
5194
6139
  throw new Error(`${spec.styleId}/${spec.assetId}: outfit reference changed after the manifest was resolved: ${outfit.reference.path}`);
5195
6140
  }
@@ -5232,7 +6177,7 @@ var PixelLabProvider = class _PixelLabProvider {
5232
6177
  }
5233
6178
  if (character.kind === "portrait") {
5234
6179
  const parentFile = character.parentFile;
5235
- const bytes = (0, import_node_fs3.readFileSync)(parentFile);
6180
+ const bytes = (0, import_node_fs4.readFileSync)(parentFile);
5236
6181
  if (character.parentSha256 && sha256(bytes) !== character.parentSha256) {
5237
6182
  throw new Error(`${spec.styleId}/${spec.assetId}: portrait source changed after the manifest was resolved: ${parentFile}`);
5238
6183
  }
@@ -5438,8 +6383,8 @@ var PixelLabProvider = class _PixelLabProvider {
5438
6383
  const sources = images.map((image, index) => {
5439
6384
  if (!image.base64) throw new Error(`outfit transfer frame ${index} has no image data`);
5440
6385
  const role = meta?.frameRoles?.[index] ?? void 0;
5441
- const file = import_node_path2.default.join(dir, `${jobId}-${index}.png`);
5442
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(image.base64, "base64"));
6386
+ const file = import_node_path3.default.join(dir, `${jobId}-${index}.png`);
6387
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(image.base64, "base64"));
5443
6388
  return { url: `file://${file}`, ...role ? { role } : {} };
5444
6389
  });
5445
6390
  return {
@@ -5644,7 +6589,7 @@ var PixelLabProvider = class _PixelLabProvider {
5644
6589
  seed: spec.seed
5645
6590
  });
5646
6591
  const jobId = (0, import_node_crypto2.randomUUID)();
5647
- (0, import_node_fs3.writeFileSync)(import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), png);
6592
+ (0, import_node_fs4.writeFileSync)(import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), png);
5648
6593
  return { jobId };
5649
6594
  }
5650
6595
  if (spec.generator === "tiles") {
@@ -5673,17 +6618,46 @@ var PixelLabProvider = class _PixelLabProvider {
5673
6618
  });
5674
6619
  return { jobId: res2.tile_id, metadata: { backgroundJobId: res2.background_job_id } };
5675
6620
  }
6621
+ if (spec.generator === "terrain" && spec.terrainOrientation === "sidescroller") {
6622
+ const baseTileIds = await this.terrainBaseTileIds(context?.terrainLinks);
6623
+ const res2 = await this.client.createTilesetSidescroller({
6624
+ lowerDescription: spec.terrainLowerDescription,
6625
+ transitionDescription: spec.terrainTransitionDescription,
6626
+ lowerBaseTileId: baseTileIds.lower,
6627
+ tileSize: spec.terrainTileSize,
6628
+ transitionSize: spec.terrainTransitionSize,
6629
+ outline: spec.outline,
6630
+ shading: spec.shading,
6631
+ detail: spec.detail,
6632
+ seed: spec.seed
6633
+ });
6634
+ return {
6635
+ jobId: res2.tileset_id,
6636
+ metadata: {
6637
+ backgroundJobId: res2.background_job_id,
6638
+ ...Object.keys(baseTileIds).length ? { baseTileIds } : {}
6639
+ }
6640
+ };
6641
+ }
5676
6642
  if (spec.generator === "terrain") {
6643
+ const baseTileIds = await this.terrainBaseTileIds(context?.terrainLinks);
5677
6644
  const res2 = await this.client.createTileset({
5678
6645
  lowerDescription: spec.terrainLowerDescription,
5679
6646
  upperDescription: spec.terrainUpperDescription,
5680
6647
  transitionDescription: spec.terrainTransitionDescription,
6648
+ lowerBaseTileId: baseTileIds.lower,
6649
+ upperBaseTileId: baseTileIds.upper,
5681
6650
  tileSize: spec.terrainTileSize,
5682
6651
  mode: spec.terrainMode,
5683
6652
  shapeStyle: spec.terrainShapeStyle,
5684
6653
  spreadX: spec.terrainSpreadX,
5685
6654
  slopeSize: spec.terrainSlopeSize,
5686
6655
  raggedness: spec.terrainRaggedness,
6656
+ roundness: spec.terrainRoundness,
6657
+ boundaryAmplitude: spec.terrainBoundaryAmplitude,
6658
+ boundarySeed: spec.terrainBoundarySeed,
6659
+ stepSlope: spec.terrainStepSlope,
6660
+ connectDiagonals: spec.terrainConnectDiagonals,
5687
6661
  transitionSize: spec.terrainTransitionSize,
5688
6662
  view: spec.terrainView,
5689
6663
  outline: spec.outline,
@@ -5691,7 +6665,13 @@ var PixelLabProvider = class _PixelLabProvider {
5691
6665
  detail: spec.detail,
5692
6666
  seed: spec.seed
5693
6667
  });
5694
- return { jobId: res2.tileset_id, metadata: { backgroundJobId: res2.background_job_id } };
6668
+ return {
6669
+ jobId: res2.tileset_id,
6670
+ metadata: {
6671
+ backgroundJobId: res2.background_job_id,
6672
+ ...Object.keys(baseTileIds).length ? { baseTileIds } : {}
6673
+ }
6674
+ };
5695
6675
  }
5696
6676
  if (spec.generator === "isometricTile") {
5697
6677
  const res2 = await this.client.createIsometricTile({
@@ -5713,7 +6693,8 @@ var PixelLabProvider = class _PixelLabProvider {
5713
6693
  width: spec.width,
5714
6694
  height: spec.height,
5715
6695
  noBackground: spec.noBackground,
5716
- seed: spec.seed
6696
+ seed: spec.seed,
6697
+ targetPalette: { colors: spec.palette, name: spec.paletteName, maxColors: spec.maxColors }
5717
6698
  });
5718
6699
  return { jobId: res2.background_job_id };
5719
6700
  }
@@ -5764,7 +6745,7 @@ var PixelLabProvider = class _PixelLabProvider {
5764
6745
  }
5765
6746
  const sourceFiles = revision.sourceMembers ?? [{ file: revision.sourceFile, sha256: revision.sourceSha256, format: revision.sourceFormat }];
5766
6747
  const images = sourceFiles.map((member) => {
5767
- const bytes = (0, import_node_fs3.readFileSync)(member.file);
6748
+ const bytes = (0, import_node_fs4.readFileSync)(member.file);
5768
6749
  if (sha256(bytes) !== member.sha256) {
5769
6750
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
5770
6751
  }
@@ -5779,7 +6760,7 @@ var PixelLabProvider = class _PixelLabProvider {
5779
6760
  if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
5780
6761
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
5781
6762
  }
5782
- const maskBytes = (0, import_node_fs3.readFileSync)(revision.maskFile);
6763
+ const maskBytes = (0, import_node_fs4.readFileSync)(revision.maskFile);
5783
6764
  if (sha256(maskBytes) !== revision.maskSha256) {
5784
6765
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask changed after the manifest was resolved`);
5785
6766
  }
@@ -5804,7 +6785,7 @@ var PixelLabProvider = class _PixelLabProvider {
5804
6785
  if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
5805
6786
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
5806
6787
  }
5807
- const maskBytes = (0, import_node_fs3.readFileSync)(revision.maskFile);
6788
+ const maskBytes = (0, import_node_fs4.readFileSync)(revision.maskFile);
5808
6789
  if (sha256(maskBytes) !== revision.maskSha256) {
5809
6790
  throw new Error(`${spec.styleId}/${spec.assetId}: revision mask changed after the manifest was resolved`);
5810
6791
  }
@@ -5825,7 +6806,7 @@ var PixelLabProvider = class _PixelLabProvider {
5825
6806
  if (!revision.paletteImageSha256 || !revision.paletteImageFormat) {
5826
6807
  throw new Error(`${spec.styleId}/${spec.assetId}: revision palette image is not ready`);
5827
6808
  }
5828
- const paletteBytes = (0, import_node_fs3.readFileSync)(revision.paletteImageFile);
6809
+ const paletteBytes = (0, import_node_fs4.readFileSync)(revision.paletteImageFile);
5829
6810
  if (sha256(paletteBytes) !== revision.paletteImageSha256) {
5830
6811
  throw new Error(`${spec.styleId}/${spec.assetId}: revision palette image changed after the manifest was resolved`);
5831
6812
  }
@@ -5856,6 +6837,14 @@ var PixelLabProvider = class _PixelLabProvider {
5856
6837
  });
5857
6838
  return this.cacheCleanupResult([res2.png], res2.usage, roles, revision.sourceFps);
5858
6839
  }
6840
+ if (revision.mode === "normal-map") {
6841
+ const strip = roles ? { base64: joinStripPng(images.map((member) => Buffer.from(member.base64, "base64"))).toString("base64"), format: "png" } : image;
6842
+ const res2 = await this.client.normalMap({ image: strip, frames: roles?.length ?? 1, convention: revision.convention });
6843
+ return {
6844
+ jobId: res2.background_job_id,
6845
+ metadata: roles ? { revisionMembers: roles, ...revision.sourceFps != null ? { revisionFps: revision.sourceFps } : {} } : {}
6846
+ };
6847
+ }
5859
6848
  if (revision.mode === "interpolate") {
5860
6849
  const lastFrame = this.readLastFrame(spec);
5861
6850
  if (!lastFrame) throw new Error(`${spec.styleId}/${spec.assetId}: interpolate needs lastFrame, the ending keyframe`);
@@ -5916,7 +6905,7 @@ var PixelLabProvider = class _PixelLabProvider {
5916
6905
  if (!revision.keypointsFile || !revision.keypointsSha256 || !revision.skeleton) {
5917
6906
  throw new Error(`${spec.styleId}/${spec.assetId}: revision keypoints are not ready`);
5918
6907
  }
5919
- const keypointsBytes = (0, import_node_fs3.readFileSync)(revision.keypointsFile);
6908
+ const keypointsBytes = (0, import_node_fs4.readFileSync)(revision.keypointsFile);
5920
6909
  if (sha256(keypointsBytes) !== revision.keypointsSha256) {
5921
6910
  throw new Error(`${spec.styleId}/${spec.assetId}: revision keypoints changed after the manifest was resolved`);
5922
6911
  }
@@ -5958,7 +6947,7 @@ var PixelLabProvider = class _PixelLabProvider {
5958
6947
  if (!revision.lastFrameSha256 || !revision.lastFrameFormat) {
5959
6948
  throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame is not ready`);
5960
6949
  }
5961
- const bytes = (0, import_node_fs3.readFileSync)(revision.lastFrameFile);
6950
+ const bytes = (0, import_node_fs4.readFileSync)(revision.lastFrameFile);
5962
6951
  if (sha256(bytes) !== revision.lastFrameSha256) {
5963
6952
  throw new Error(`${spec.styleId}/${spec.assetId}: revision last frame changed after the manifest was resolved`);
5964
6953
  }
@@ -5973,11 +6962,11 @@ var PixelLabProvider = class _PixelLabProvider {
5973
6962
  cacheCleanupResult(pngs, usage, roles, fps) {
5974
6963
  const jobId = (0, import_node_crypto2.randomUUID)();
5975
6964
  if (!roles) {
5976
- (0, import_node_fs3.writeFileSync)(import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
6965
+ (0, import_node_fs4.writeFileSync)(import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), pngs[0]);
5977
6966
  return { jobId, metadata: { revisionUsage: usage } };
5978
6967
  }
5979
6968
  for (const [index, png] of pngs.entries()) {
5980
- (0, import_node_fs3.writeFileSync)(import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
6969
+ (0, import_node_fs4.writeFileSync)(import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`), png);
5981
6970
  }
5982
6971
  return { jobId, metadata: { revisionUsage: usage, revisionMembers: roles, ...fps != null ? { revisionFps: fps } : {} } };
5983
6972
  }
@@ -6001,6 +6990,7 @@ var PixelLabProvider = class _PixelLabProvider {
6001
6990
  if (revisionMode === "reduce-colors" || revisionMode === "correct-pixelart" || revisionMode === "remove-background") {
6002
6991
  return this.pollCachedRevision(jobId, context);
6003
6992
  }
6993
+ if (revisionMode === "normal-map") return this.pollNormalMap(jobId, context);
6004
6994
  if (revisionMode === "animate" || revisionMode === "animate-pixminimax" || revisionMode === "animate-skeleton" || revisionMode === "interpolate" || revisionMode === "edit-animation") {
6005
6995
  return this.pollAnimateRevision(jobId, context);
6006
6996
  }
@@ -6008,8 +6998,8 @@ var PixelLabProvider = class _PixelLabProvider {
6008
6998
  if (generator === "vfx") return this.pollVfx(jobId, context);
6009
6999
  if (generator === "imageProFlash") return this.pollRevision(jobId);
6010
7000
  if (generator === "pixflux") {
6011
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6012
- if ((0, import_node_fs3.existsSync)(file)) {
7001
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7002
+ if ((0, import_node_fs4.existsSync)(file)) {
6013
7003
  const sourceUrl = `file://${file}`;
6014
7004
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }] };
6015
7005
  }
@@ -6065,14 +7055,15 @@ var PixelLabProvider = class _PixelLabProvider {
6065
7055
  */
6066
7056
  async submitVfx(spec, context) {
6067
7057
  const vfx = spec.vfx;
7058
+ if (vfx.description) return this.submitVfxArtist(spec, context);
6068
7059
  if (!this.mcp) throw new Error("PixelLab vfx renders over PixelLab's MCP server, which this provider was built without");
6069
7060
  let sprite;
6070
7061
  if (vfx.sprite && "reference" in vfx.sprite) {
6071
7062
  sprite = vfx.sprite.reference;
6072
7063
  } else if (vfx.sprite) {
6073
7064
  const { file } = vfx.sprite;
6074
- if (!(0, import_node_fs3.existsSync)(file)) throw new Error(`vfx sprite does not exist yet: ${file}`);
6075
- const bytes = (0, import_node_fs3.readFileSync)(file);
7065
+ if (!(0, import_node_fs4.existsSync)(file)) throw new Error(`vfx sprite does not exist yet: ${file}`);
7066
+ const bytes = (0, import_node_fs4.readFileSync)(file);
6076
7067
  if (sha256(bytes) !== vfx.sprite.sha256) {
6077
7068
  throw new Error(`vfx sprite changed after the manifest was resolved: ${file}; run the command again`);
6078
7069
  }
@@ -6119,50 +7110,137 @@ var PixelLabProvider = class _PixelLabProvider {
6119
7110
  // The effect exists from here on; record it before anything else can fail.
6120
7111
  onExported: (done, effectName) => context?.checkpoint({ jobId, complete: true, metadata: { vfx: facts(done, effectName) } })
6121
7112
  });
6122
- await this.cacheVfxFrames(jobId, exported);
7113
+ await this.cacheVfxFrames(jobId, exported, workbenchVfxUrls(exported));
6123
7114
  return { jobId, metadata: { vfx: { ...facts(exported, name), recipe } } };
6124
7115
  }
7116
+ /**
7117
+ * Ask PixelLab's VFX artist (`POST /vfx`) for a `vfx` description: a
7118
+ * plain background job, polled by `pollVfxArtist`. Every submission is a
7119
+ * new effect. The manifest's description is the whole effect, not a change
7120
+ * to an earlier one, so it is never sent with `effect_id`; the effect this
7121
+ * asset made last time stays in My effects and its id is recorded here.
7122
+ */
7123
+ async submitVfxArtist(spec, context) {
7124
+ const vfx = spec.vfx;
7125
+ let sprite;
7126
+ if (vfx.sprite && "reference" in vfx.sprite) {
7127
+ throw new Error("PixelLab's VFX artist takes a sprite's pixels, not a PixelLab reference; point vfx.sprite at a PNG file");
7128
+ } else if (vfx.sprite) {
7129
+ const { file } = vfx.sprite;
7130
+ if (!(0, import_node_fs4.existsSync)(file)) throw new Error(`vfx sprite does not exist yet: ${file}`);
7131
+ const bytes = (0, import_node_fs4.readFileSync)(file);
7132
+ if (sha256(bytes) !== vfx.sprite.sha256) {
7133
+ throw new Error(`vfx sprite changed after the manifest was resolved: ${file}; run the command again`);
7134
+ }
7135
+ sprite = vfxArtistSprite(bytes, `vfx sprite ${file}`).image;
7136
+ }
7137
+ const previous = context?.replacedMetadata?.vfx ?? context?.lastGenerationMetadata?.vfx;
7138
+ const artist = vfx.artist ?? "lite";
7139
+ const submitted = await this.client.createVfx({ description: vfx.description, artist, sprite, name: vfx.name });
7140
+ return {
7141
+ jobId: submitted.background_job_id,
7142
+ metadata: {
7143
+ vfx: {
7144
+ artist,
7145
+ quotedGenerations: submitted.quoted_generations ?? null,
7146
+ ...typeof previous?.effectId === "string" ? { previousEffect: previous.effectId } : {}
7147
+ }
7148
+ }
7149
+ };
7150
+ }
6125
7151
  /** Cut an export's sheet into frames, with a sidecar `poll` reads without any lock metadata. */
6126
- async cacheVfxFrames(jobId, exported) {
6127
- const sheetFile = exported.files.find((file) => /\.png$/i.test(file));
6128
- const atlasFile = exported.files.find((file) => /\.json$/i.test(file));
6129
- if (!sheetFile) throw new Error(`PixelLab's vfx export listed no spritesheet: ${exported.files.join(", ")}`);
6130
- const sheet = await this.client.download(`${exported.downloadBase}${encodeURIComponent(sheetFile)}`);
6131
- const atlas = atlasFile ? JSON.parse((await this.client.download(`${exported.downloadBase}${encodeURIComponent(atlasFile)}`)).toString("utf8")) : void 0;
6132
- const frames = sliceVfxSheet(sheet, exported, atlas);
7152
+ async cacheVfxFrames(jobId, facts, urls, atlas) {
7153
+ const sheet = await this.client.download(urls.sheet);
7154
+ const frameAtlas = atlas ?? (urls.atlas ? JSON.parse((await this.client.download(urls.atlas)).toString("utf8")) : void 0);
7155
+ const frames = sliceVfxSheet(sheet, facts, frameAtlas);
6133
7156
  const dir = _PixelLabProvider.cacheDir("vfx");
6134
- frames.forEach((frame, index) => (0, import_node_fs3.writeFileSync)(import_node_path2.default.join(dir, `${jobId}-${index}.png`), frame));
6135
- (0, import_node_fs3.writeFileSync)(
6136
- import_node_path2.default.join(dir, `${jobId}.json`),
6137
- JSON.stringify({ effectId: exported.effectId, count: frames.length, fps: exported.fps, loop: exported.loop })
7157
+ frames.forEach((frame, index) => (0, import_node_fs4.writeFileSync)(import_node_path3.default.join(dir, `${jobId}-${index}.png`), frame));
7158
+ (0, import_node_fs4.writeFileSync)(
7159
+ import_node_path3.default.join(dir, `${jobId}.json`),
7160
+ JSON.stringify({ effectId: facts.effectId, count: frames.length, fps: facts.fps, loop: facts.loop })
6138
7161
  );
6139
7162
  }
6140
7163
  async pollVfx(jobId, context) {
6141
7164
  const dir = _PixelLabProvider.cacheDir("vfx");
6142
- const sidecar = import_node_path2.default.join(dir, `${jobId}.json`);
7165
+ const sidecar = import_node_path3.default.join(dir, `${jobId}.json`);
6143
7166
  const cached = () => {
6144
- if (!(0, import_node_fs3.existsSync)(sidecar)) return null;
6145
- const facts2 = JSON.parse((0, import_node_fs3.readFileSync)(sidecar, "utf8"));
6146
- const files2 = Array.from({ length: facts2.count }, (_, index) => import_node_path2.default.join(dir, `${jobId}-${index}.png`));
6147
- return files2.every((file) => (0, import_node_fs3.existsSync)(file)) ? { facts: facts2, files: files2 } : null;
7167
+ if (!(0, import_node_fs4.existsSync)(sidecar)) return null;
7168
+ const facts2 = JSON.parse((0, import_node_fs4.readFileSync)(sidecar, "utf8"));
7169
+ const files2 = Array.from({ length: facts2.count }, (_, index) => import_node_path3.default.join(dir, `${jobId}-${index}.png`));
7170
+ return files2.every((file) => (0, import_node_fs4.existsSync)(file)) ? { facts: facts2, files: files2 } : null;
6148
7171
  };
6149
7172
  let found = cached();
6150
7173
  const recorded = context?.metadata?.vfx;
6151
- if (!found && recorded?.downloadBase && Array.isArray(recorded.files)) {
6152
- await this.cacheVfxFrames(jobId, recorded);
7174
+ let artistDone;
7175
+ if (!found && recorded?.artist) {
7176
+ if (recorded.fileUrls?.sheet && recorded.effectId) {
7177
+ await this.cacheVfxFrames(jobId, recorded, {
7178
+ sheet: recorded.fileUrls.sheet,
7179
+ atlas: recorded.fileUrls.atlas
7180
+ });
7181
+ } else {
7182
+ const job = await this.client.getBackgroundJob(jobId);
7183
+ if (job.status === "failed") return { status: "failed", error: `vfx job failed upstream${vfxJobFailure(job.last_response)}` };
7184
+ if (job.status !== "completed") return { status: "processing" };
7185
+ try {
7186
+ artistDone = await this.cacheVfxArtistResult(jobId, job.last_response ?? {}, recorded);
7187
+ } catch (error) {
7188
+ return { status: "failed", error: error instanceof Error ? error.message : String(error) };
7189
+ }
7190
+ artistDone.billed = billedFromUsage(job.usage);
7191
+ }
7192
+ found = cached();
7193
+ } else if (!found && recorded?.downloadBase && Array.isArray(recorded.files)) {
7194
+ const exported = recorded;
7195
+ await this.cacheVfxFrames(jobId, exported, workbenchVfxUrls(exported));
6153
7196
  found = cached();
6154
7197
  }
6155
7198
  if (!found) {
6156
- return { status: "failed", error: "vfx frames are no longer cached locally; re-run submit for this asset (free)" };
7199
+ return {
7200
+ status: "failed",
7201
+ 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)"
7202
+ };
6157
7203
  }
6158
7204
  const { facts, files } = found;
6159
7205
  const sources = files.length === 1 ? [{ url: `file://${files[0]}` }] : files.map((file, index) => ({ url: `file://${file}`, role: `frame-${String(index).padStart(2, "0")}` }));
7206
+ const metadata = {
7207
+ ...files.length > 1 ? { frameSet: { fps: facts.fps, count: files.length, loop: facts.loop } } : {},
7208
+ ...artistDone ? { vfx: artistDone.vfx } : {}
7209
+ };
6160
7210
  return {
6161
7211
  status: "ready",
6162
7212
  objectId: facts.effectId,
6163
7213
  sourceUrl: sources[0].url,
6164
7214
  sources,
6165
- ...files.length > 1 ? { metadata: { frameSet: { fps: facts.fps, count: files.length, loop: facts.loop } } } : {},
7215
+ ...Object.keys(metadata).length ? { metadata } : {},
7216
+ billed: artistDone?.billed ?? null
7217
+ };
7218
+ }
7219
+ /**
7220
+ * A delivered VFX artist job: cut its sheet into the cache and return the
7221
+ * effect's facts, merged over what submit recorded (poll replaces the
7222
+ * whole `vfx` key). Download links are kept only when they carry no
7223
+ * credential, so a committed lockfile never holds a signed URL.
7224
+ */
7225
+ async cacheVfxArtistResult(jobId, done, recorded) {
7226
+ const result = parseVfxArtistResult(done, jobId);
7227
+ const atlas = result.atlas ? JSON.parse((await this.client.download(result.atlas.url)).toString("utf8")) : void 0;
7228
+ const size = vfxArtistFrameFacts(result, atlas);
7229
+ const facts = { effectId: result.effectId, ...size, fps: result.fps ?? 12, loop: result.loop ?? false };
7230
+ await this.cacheVfxFrames(jobId, facts, { sheet: result.sheet.url }, atlas);
7231
+ const urls = [result.sheet.url, ...result.atlas ? [result.atlas.url] : []];
7232
+ return {
7233
+ vfx: {
7234
+ ...recorded,
7235
+ ...facts,
7236
+ editUrl: result.editUrl,
7237
+ name: result.name,
7238
+ message: result.message,
7239
+ anchor: result.anchor,
7240
+ parts: result.parts,
7241
+ files: result.files.map((file) => file.name),
7242
+ ...urls.every(shouldPersistSourceUrl) ? { fileUrls: { sheet: result.sheet.url, ...result.atlas ? { atlas: result.atlas.url } : {} } } : {}
7243
+ },
6166
7244
  billed: null
6167
7245
  };
6168
7246
  }
@@ -6175,10 +7253,10 @@ var PixelLabProvider = class _PixelLabProvider {
6175
7253
  };
6176
7254
  if (Array.isArray(roles) && roles.length) {
6177
7255
  const sources = roles.map((role, index) => ({
6178
- url: `file://${import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
7256
+ url: `file://${import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`)}`,
6179
7257
  role: String(role)
6180
7258
  }));
6181
- if (sources.some((source) => !(0, import_node_fs3.existsSync)(source.url.slice("file://".length)))) return gone;
7259
+ if (sources.some((source) => !(0, import_node_fs4.existsSync)(source.url.slice("file://".length)))) return gone;
6182
7260
  const fps = context?.metadata?.revisionFps;
6183
7261
  const frames = sources.every((source) => source.role.startsWith("frame-"));
6184
7262
  return {
@@ -6190,11 +7268,58 @@ var PixelLabProvider = class _PixelLabProvider {
6190
7268
  billed: billedFromUsage(usage)
6191
7269
  };
6192
7270
  }
6193
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6194
- if (!(0, import_node_fs3.existsSync)(file)) return gone;
7271
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7272
+ if (!(0, import_node_fs4.existsSync)(file)) return gone;
6195
7273
  const sourceUrl = `file://${file}`;
6196
7274
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed: billedFromUsage(usage) };
6197
7275
  }
7276
+ /**
7277
+ * A `normal-map` revision: a background job whose map is
7278
+ * `last_response.image`. A loop's map comes back as the strip it was sent
7279
+ * as, and is cut into one file per frame under the parent's roles. Nothing
7280
+ * downstream palette-snaps it: the manifest refuses a normal map in a
7281
+ * style with `enforcePalette` or `quality`.
7282
+ */
7283
+ async pollNormalMap(jobId, context) {
7284
+ const job = await this.client.getBackgroundJob(jobId);
7285
+ if (job.status === "failed") return { status: "failed", error: "normal-map job failed upstream" };
7286
+ if (job.status !== "completed") return { status: "processing" };
7287
+ const billed = billedFromUsage(job.usage);
7288
+ const png = normalMapResultPng(job.last_response);
7289
+ if (!png) {
7290
+ return {
7291
+ status: "failed",
7292
+ 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"})`
7293
+ };
7294
+ }
7295
+ const roles = context?.metadata?.revisionMembers;
7296
+ if (Array.isArray(roles) && roles.length) {
7297
+ let frames;
7298
+ try {
7299
+ frames = splitStripPng(png, roles.length);
7300
+ } catch (error) {
7301
+ return { status: "failed", error: `normal-map job ${jobId}: ${error.message}` };
7302
+ }
7303
+ const sources = frames.map((frame, index) => {
7304
+ const file2 = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7305
+ (0, import_node_fs4.writeFileSync)(file2, frame);
7306
+ return { url: `file://${file2}`, role: String(roles[index]) };
7307
+ });
7308
+ const fps = context?.metadata?.revisionFps;
7309
+ return {
7310
+ status: "ready",
7311
+ objectId: jobId,
7312
+ sourceUrl: sources[0].url,
7313
+ sources,
7314
+ ...typeof fps === "number" ? { metadata: { frameSet: { fps, count: sources.length } } } : {},
7315
+ billed
7316
+ };
7317
+ }
7318
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7319
+ (0, import_node_fs4.writeFileSync)(file, png);
7320
+ const sourceUrl = `file://${file}`;
7321
+ return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
7322
+ }
6198
7323
  /**
6199
7324
  * `animate`/`animate-pixminimax` revisions: a plain background job, like
6200
7325
  * `pollRevision` above, but completing with an ORDERED FRAME LIST instead
@@ -6247,8 +7372,8 @@ var PixelLabProvider = class _PixelLabProvider {
6247
7372
  }
6248
7373
  const base64 = extractBase64(item);
6249
7374
  if (base64) {
6250
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6251
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(base64, "base64"));
7375
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7376
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(base64, "base64"));
6252
7377
  urls.push(`file://${file}`);
6253
7378
  continue;
6254
7379
  }
@@ -6298,8 +7423,8 @@ var PixelLabProvider = class _PixelLabProvider {
6298
7423
  const candidate = done[key];
6299
7424
  const base64 = extractBase64(candidate);
6300
7425
  if (base64) {
6301
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6302
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(base64, "base64"));
7426
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7427
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(base64, "base64"));
6303
7428
  const sourceUrl = `file://${file}`;
6304
7429
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
6305
7430
  }
@@ -6307,8 +7432,8 @@ var PixelLabProvider = class _PixelLabProvider {
6307
7432
  if (Array.isArray(done.images) && done.images.length) {
6308
7433
  const base64 = extractBase64(done.images[0]);
6309
7434
  if (base64) {
6310
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6311
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(base64, "base64"));
7435
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
7436
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(base64, "base64"));
6312
7437
  const sourceUrl = `file://${file}`;
6313
7438
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
6314
7439
  }
@@ -6341,8 +7466,8 @@ var PixelLabProvider = class _PixelLabProvider {
6341
7466
  for (const [index, candidate] of done.images.entries()) {
6342
7467
  const base64 = extractBase64(candidate);
6343
7468
  if (!base64) continue;
6344
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6345
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(base64, "base64"));
7469
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7470
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(base64, "base64"));
6346
7471
  urls.push(`file://${file}`);
6347
7472
  }
6348
7473
  if (urls.length) return { status: "review", candidateUrls: urls, billed };
@@ -6371,8 +7496,8 @@ var PixelLabProvider = class _PixelLabProvider {
6371
7496
  for (const [index, candidate] of images.entries()) {
6372
7497
  const base64 = extractBase64(candidate);
6373
7498
  if (!base64) continue;
6374
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6375
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(base64, "base64"));
7499
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7500
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(base64, "base64"));
6376
7501
  urls.push(`file://${file}`);
6377
7502
  }
6378
7503
  if (urls.length === 1) return { status: "ready", objectId: jobId, sourceUrl: urls[0], sources: [{ url: urls[0] }], billed };
@@ -6450,6 +7575,26 @@ var PixelLabProvider = class _PixelLabProvider {
6450
7575
  throw err;
6451
7576
  }
6452
7577
  }
7578
+ /**
7579
+ * The base tile id of each terrain a connected set shares: the other
7580
+ * set's recorded `terrainIds`, or, for a set downloaded before pixelkiln
7581
+ * recorded them, read from PixelLab's own record of it (a free GET).
7582
+ */
7583
+ async terrainBaseTileIds(links) {
7584
+ const ids = {};
7585
+ for (const side of ["lower", "upper"]) {
7586
+ const link = links?.[side];
7587
+ if (!link) continue;
7588
+ const id = link.terrainId ?? (await this.client.getTileset(link.objectId)).metadata.terrainIds[link.terrain];
7589
+ if (!id) {
7590
+ throw new Error(
7591
+ `PixelLab terrain: tileset ${link.objectId} has no ${link.terrain} terrain id to connect this set's ${side} terrain to`
7592
+ );
7593
+ }
7594
+ ids[side] = id;
7595
+ }
7596
+ return ids;
7597
+ }
6453
7598
  /**
6454
7599
  * `/create-tileset` reports progress the same way `tiles` does (423 while
6455
7600
  * drawing, 200 once finished), but each tile's image comes back embedded
@@ -6461,7 +7606,8 @@ var PixelLabProvider = class _PixelLabProvider {
6461
7606
  */
6462
7607
  async pollTerrain(tilesetId, context) {
6463
7608
  try {
6464
- const { tileset, usage } = await this.client.getTileset(tilesetId);
7609
+ const { tileset, usage, metadata } = await this.client.getTileset(tilesetId);
7610
+ const sidescroller = metadata.view === "side" || context?.spec?.terrainOrientation === "sidescroller";
6465
7611
  const backgroundJobId = context?.metadata?.backgroundJobId;
6466
7612
  const billed = billedFromUsage(usage) ?? await this.billedForJob(backgroundJobId);
6467
7613
  const sources = [];
@@ -6469,8 +7615,8 @@ var PixelLabProvider = class _PixelLabProvider {
6469
7615
  for (const [index, tile] of tileset.tiles.entries()) {
6470
7616
  const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
6471
7617
  const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
6472
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
6473
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(tile.image.base64, "base64"));
7618
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
7619
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(tile.image.base64, "base64"));
6474
7620
  sources.push({ url: `file://${file}`, role });
6475
7621
  terrainTiles.push({ role, corners: tile.corners, pattern4x4: tile.pattern_4x4 });
6476
7622
  }
@@ -6479,7 +7625,13 @@ var PixelLabProvider = class _PixelLabProvider {
6479
7625
  objectId: tilesetId,
6480
7626
  sourceUrl: sources[0]?.url ?? null,
6481
7627
  sources,
6482
- metadata: { terrainTypes: tileset.terrain_types, terrainTiles },
7628
+ metadata: {
7629
+ terrainTypes: sidescroller ? ["lower", "upper"] : tileset.terrain_types,
7630
+ ...sidescroller ? { providerTerrainTypes: tileset.terrain_types, view: "side" } : {},
7631
+ terrainTiles,
7632
+ // Each terrain's base tile id, which a connected set sends.
7633
+ ...Object.keys(metadata.terrainIds).length ? { terrainIds: metadata.terrainIds } : {}
7634
+ },
6483
7635
  billed
6484
7636
  };
6485
7637
  } catch (err) {
@@ -6499,8 +7651,8 @@ var PixelLabProvider = class _PixelLabProvider {
6499
7651
  const { image, usage } = await this.client.getIsometricTile(tileId);
6500
7652
  const backgroundJobId = context?.metadata?.backgroundJobId;
6501
7653
  const billed = billedFromUsage(usage) ?? await this.billedForJob(backgroundJobId);
6502
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${tileId}.png`);
6503
- (0, import_node_fs3.writeFileSync)(file, Buffer.from(image.base64, "base64"));
7654
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${tileId}.png`);
7655
+ (0, import_node_fs4.writeFileSync)(file, Buffer.from(image.base64, "base64"));
6504
7656
  return {
6505
7657
  status: "ready",
6506
7658
  objectId: tileId,
@@ -6543,8 +7695,8 @@ var PixelLabProvider = class _PixelLabProvider {
6543
7695
  return { objectId: `${jobId}#${index}`, sourceUrl: url };
6544
7696
  }
6545
7697
  if (generator === "imagePro" || generator === "uiElement") {
6546
- const file = import_node_path2.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
6547
- if (!(0, import_node_fs3.existsSync)(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
7698
+ const file = import_node_path3.default.join(_PixelLabProvider.cacheDir(), `${jobId}-${index}.png`);
7699
+ if (!(0, import_node_fs4.existsSync)(file)) throw new Error(`${generator} job ${jobId} has no cached candidate at index ${index}`);
6548
7700
  return { objectId: `${jobId}#${index}`, sourceUrl: `file://${file}` };
6549
7701
  }
6550
7702
  const promoted = await this.client.selectFrames(jobId, [index], commonTag);
@@ -6556,7 +7708,7 @@ var PixelLabProvider = class _PixelLabProvider {
6556
7708
  return { objectId, sourceUrl: firstUrl(obj?.rotation_urls) ?? obj?.preview_url ?? null };
6557
7709
  }
6558
7710
  async download(url) {
6559
- if (url.startsWith("file://")) return (0, import_node_fs3.readFileSync)(url.slice("file://".length));
7711
+ if (url.startsWith("file://")) return (0, import_node_fs4.readFileSync)(url.slice("file://".length));
6560
7712
  return this.client.download(url);
6561
7713
  }
6562
7714
  async balance() {
@@ -6678,7 +7830,7 @@ function extractBase64(value) {
6678
7830
  function readReference(spec, reference) {
6679
7831
  const images = {};
6680
7832
  for (const [direction, image] of Object.entries(reference)) {
6681
- const bytes = (0, import_node_fs3.readFileSync)(image.path);
7833
+ const bytes = (0, import_node_fs4.readFileSync)(image.path);
6682
7834
  if (sha256(bytes) !== image.sha256) {
6683
7835
  throw new Error(`${spec.styleId}/${spec.assetId}: reference (${direction}) changed after the manifest was resolved: ${image.path}`);
6684
7836
  }
@@ -6695,7 +7847,7 @@ function requireStyleObjectId(spec, context) {
6695
7847
  return context.styleObjectId;
6696
7848
  }
6697
7849
  function readPose(spec, what, image) {
6698
- const bytes = (0, import_node_fs3.readFileSync)(image.path);
7850
+ const bytes = (0, import_node_fs4.readFileSync)(image.path);
6699
7851
  if (sha256(bytes) !== image.sha256) {
6700
7852
  throw new Error(`${spec.styleId}/${spec.assetId}: ${what} changed after the manifest was resolved: ${image.path}`);
6701
7853
  }
@@ -6760,7 +7912,7 @@ function findObjectAnimation(object, name, direction, animationId) {
6760
7912
  function mapScenePayload(spec) {
6761
7913
  const scene = spec.scene;
6762
7914
  const read = (file, hash, label) => {
6763
- const bytes = (0, import_node_fs3.readFileSync)(file);
7915
+ const bytes = (0, import_node_fs4.readFileSync)(file);
6764
7916
  if (!hash || sha256(bytes) !== hash) {
6765
7917
  throw new Error(`${spec.styleId}/${spec.assetId}: ${label} changed after the manifest was resolved`);
6766
7918
  }
@@ -7133,9 +8285,9 @@ function retroError(value) {
7133
8285
 
7134
8286
  // src/providers/scenario.ts
7135
8287
  var import_node_crypto3 = require("crypto");
7136
- var import_node_fs4 = require("fs");
8288
+ var import_node_fs5 = require("fs");
7137
8289
  var import_node_os2 = __toESM(require("os"), 1);
7138
- var import_node_path3 = __toESM(require("path"), 1);
8290
+ var import_node_path4 = __toESM(require("path"), 1);
7139
8291
  var DEFAULT_BASE_URL2 = "https://api.cloud.scenario.com/v1";
7140
8292
  var SOURCE_PROTOCOL2 = "scenario:";
7141
8293
  var SUBMIT_RETRIES = 8;
@@ -7518,7 +8670,7 @@ var ScenarioProvider = class _ScenarioProvider {
7518
8670
  }
7519
8671
  };
7520
8672
  function uploadCachePath() {
7521
- return process.env.SCENARIO_UPLOAD_CACHE ?? import_node_path3.default.join(import_node_os2.default.tmpdir(), "pixelkiln-scenario", "uploads.json");
8673
+ return process.env.SCENARIO_UPLOAD_CACHE ?? import_node_path4.default.join(import_node_os2.default.tmpdir(), "pixelkiln-scenario", "uploads.json");
7522
8674
  }
7523
8675
  function uploadCacheKey(baseUrl, projectId, digest2) {
7524
8676
  return (0, import_node_crypto3.createHash)("sha256").update(`${baseUrl}
@@ -7527,7 +8679,7 @@ ${digest2}`).digest("hex");
7527
8679
  }
7528
8680
  function readUploadCache() {
7529
8681
  try {
7530
- const value = JSON.parse((0, import_node_fs4.readFileSync)(uploadCachePath(), "utf8"));
8682
+ const value = JSON.parse((0, import_node_fs5.readFileSync)(uploadCachePath(), "utf8"));
7531
8683
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
7532
8684
  } catch {
7533
8685
  return {};
@@ -7536,10 +8688,10 @@ function readUploadCache() {
7536
8688
  function writeUploadCache(key, assetId) {
7537
8689
  try {
7538
8690
  const file = uploadCachePath();
7539
- (0, import_node_fs4.mkdirSync)(import_node_path3.default.dirname(file), { recursive: true });
8691
+ (0, import_node_fs5.mkdirSync)(import_node_path4.default.dirname(file), { recursive: true });
7540
8692
  const tmp = `${file}.${process.pid}.tmp`;
7541
- (0, import_node_fs4.writeFileSync)(tmp, JSON.stringify({ ...readUploadCache(), [key]: assetId }));
7542
- (0, import_node_fs4.renameSync)(tmp, file);
8693
+ (0, import_node_fs5.writeFileSync)(tmp, JSON.stringify({ ...readUploadCache(), [key]: assetId }));
8694
+ (0, import_node_fs5.renameSync)(tmp, file);
7543
8695
  } catch {
7544
8696
  }
7545
8697
  }
@@ -7566,7 +8718,7 @@ function readRevisionSource(spec) {
7566
8718
  if (!revision.sourceFile || !revision.sourceSha256 || !revision.sourceFormat) {
7567
8719
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
7568
8720
  }
7569
- const bytes = (0, import_node_fs4.readFileSync)(revision.sourceFile);
8721
+ const bytes = (0, import_node_fs5.readFileSync)(revision.sourceFile);
7570
8722
  if (sha256(bytes) !== revision.sourceSha256) {
7571
8723
  throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
7572
8724
  }
@@ -7736,8 +8888,8 @@ function redactScenarioError(value, secrets) {
7736
8888
 
7737
8889
  // src/providers/comfyui.ts
7738
8890
  var import_node_crypto4 = require("crypto");
7739
- var import_promises2 = require("fs/promises");
7740
- var import_node_path4 = __toESM(require("path"), 1);
8891
+ var import_promises3 = require("fs/promises");
8892
+ var import_node_path5 = __toESM(require("path"), 1);
7741
8893
  var DEFAULT_BASE_URL3 = "http://127.0.0.1:8188";
7742
8894
  var BUILTIN_BINDINGS = /* @__PURE__ */ new Set([
7743
8895
  "prompt",
@@ -7874,10 +9026,10 @@ var ComfyUIProvider = class _ComfyUIProvider {
7874
9026
  }
7875
9027
  async resolveOptions(value, context) {
7876
9028
  const options = parseOptions(value);
7877
- const workflowPath = import_node_path4.default.resolve(context.root, options.workflowFile);
9029
+ const workflowPath = import_node_path5.default.resolve(context.root, options.workflowFile);
7878
9030
  let raw;
7879
9031
  try {
7880
- raw = await (0, import_promises2.readFile)(workflowPath, "utf8");
9032
+ raw = await (0, import_promises3.readFile)(workflowPath, "utf8");
7881
9033
  } catch (err) {
7882
9034
  throw new Error(
7883
9035
  `ComfyUI workflow for style "${context.styleId}" could not be read at ${workflowPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -7929,10 +9081,10 @@ var ComfyUIProvider = class _ComfyUIProvider {
7929
9081
  if (typeof item !== "string") {
7930
9082
  throw new Error(`ComfyUI ${label} must be a manifest-relative PNG or JPEG path`);
7931
9083
  }
7932
- const file = import_node_path4.default.resolve(context.root, item);
9084
+ const file = import_node_path5.default.resolve(context.root, item);
7933
9085
  let bytes;
7934
9086
  try {
7935
- bytes = await (0, import_promises2.readFile)(file);
9087
+ bytes = await (0, import_promises3.readFile)(file);
7936
9088
  } catch (error) {
7937
9089
  throw new Error(
7938
9090
  `ComfyUI ${label} could not be read at ${file}: ${error instanceof Error ? error.message : String(error)}`
@@ -8067,6 +9219,11 @@ var ComfyUIProvider = class _ComfyUIProvider {
8067
9219
  "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."
8068
9220
  );
8069
9221
  }
9222
+ if (spec.revision.convention) {
9223
+ throw new Error(
9224
+ "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."
9225
+ );
9226
+ }
8070
9227
  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)) {
8071
9228
  throw new Error(
8072
9229
  "ComfyUI revisions without width/height bindings must keep the source dimensions"
@@ -8089,7 +9246,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
8089
9246
  setBinding(workflow2, binding, input);
8090
9247
  return;
8091
9248
  }
8092
- const bytes = await (0, import_promises2.readFile)(input.path);
9249
+ const bytes = await (0, import_promises3.readFile)(input.path);
8093
9250
  if (sha256(bytes) !== input.sha256) {
8094
9251
  throw new Error(`ComfyUI provider input "${name}" changed before upload`);
8095
9252
  }
@@ -8156,7 +9313,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
8156
9313
  if (!spec.revision.sourceSha256 || !spec.revision.sourceFormat) {
8157
9314
  throw new Error("ComfyUI revision source is not ready");
8158
9315
  }
8159
- const sourceBytes = await (0, import_promises2.readFile)(spec.revision.sourceFile);
9316
+ const sourceBytes = await (0, import_promises3.readFile)(spec.revision.sourceFile);
8160
9317
  if (sha256(sourceBytes) !== spec.revision.sourceSha256) {
8161
9318
  throw new Error("ComfyUI revision source changed before upload");
8162
9319
  }
@@ -8170,7 +9327,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
8170
9327
  if (!spec.revision.maskSha256 || !spec.revision.maskFormat) {
8171
9328
  throw new Error("ComfyUI revision mask is not ready");
8172
9329
  }
8173
- const maskBytes = await (0, import_promises2.readFile)(spec.revision.maskFile);
9330
+ const maskBytes = await (0, import_promises3.readFile)(spec.revision.maskFile);
8174
9331
  if (sha256(maskBytes) !== spec.revision.maskSha256) {
8175
9332
  throw new Error("ComfyUI revision mask changed before upload");
8176
9333
  }
@@ -8890,9 +10047,9 @@ registerProvider({
8890
10047
  });
8891
10048
 
8892
10049
  // src/manifest.ts
8893
- var import_promises4 = require("fs/promises");
8894
- var import_node_fs7 = require("fs");
8895
- var import_node_path7 = __toESM(require("path"), 1);
10050
+ var import_promises5 = require("fs/promises");
10051
+ var import_node_fs8 = require("fs");
10052
+ var import_node_path8 = __toESM(require("path"), 1);
8896
10053
 
8897
10054
  // src/loop-directions.ts
8898
10055
  var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
@@ -8980,17 +10137,17 @@ function isObject2(value) {
8980
10137
  }
8981
10138
 
8982
10139
  // src/outputs.ts
8983
- var import_node_fs6 = require("fs");
8984
- var import_node_path6 = __toESM(require("path"), 1);
10140
+ var import_node_fs7 = require("fs");
10141
+ var import_node_path7 = __toESM(require("path"), 1);
8985
10142
 
8986
10143
  // src/lock.ts
8987
- var import_promises3 = require("fs/promises");
8988
- var import_node_fs5 = require("fs");
8989
- var import_node_path5 = __toESM(require("path"), 1);
10144
+ var import_promises4 = require("fs/promises");
10145
+ var import_node_fs6 = require("fs");
10146
+ var import_node_path6 = __toESM(require("path"), 1);
8990
10147
  async function loadLock(lockPath) {
8991
- if (!(0, import_node_fs5.existsSync)(lockPath)) return { version: 2, entries: {} };
10148
+ if (!(0, import_node_fs6.existsSync)(lockPath)) return { version: 2, entries: {} };
8992
10149
  try {
8993
- return parseLock(JSON.parse(await (0, import_promises3.readFile)(lockPath, "utf8")));
10150
+ return parseLock(JSON.parse(await (0, import_promises4.readFile)(lockPath, "utf8")));
8994
10151
  } catch (err) {
8995
10152
  throw new ProjectError(
8996
10153
  `Lockfile at ${lockPath} is malformed:
@@ -9003,7 +10160,7 @@ var saveQueues = /* @__PURE__ */ new Map();
9003
10160
  var dirtyPatches = /* @__PURE__ */ new WeakMap();
9004
10161
  var dirtyDeletes = /* @__PURE__ */ new WeakMap();
9005
10162
  function saveLock(lockPath, lock) {
9006
- const queueKey = import_node_path5.default.resolve(lockPath);
10163
+ const queueKey = import_node_path6.default.resolve(lockPath);
9007
10164
  const previous = saveQueues.get(queueKey) ?? Promise.resolve();
9008
10165
  const next = previous.catch(() => {
9009
10166
  }).then(() => writeLockNow(lockPath, lock));
@@ -9015,7 +10172,7 @@ function saveLock(lockPath, lock) {
9015
10172
  return next;
9016
10173
  }
9017
10174
  async function writeLockNow(lockPath, lock) {
9018
- await (0, import_promises3.mkdir)(import_node_path5.default.dirname(import_node_path5.default.resolve(lockPath)), { recursive: true });
10175
+ await (0, import_promises4.mkdir)(import_node_path6.default.dirname(import_node_path6.default.resolve(lockPath)), { recursive: true });
9019
10176
  const release = await acquireFileLock(lockPath);
9020
10177
  try {
9021
10178
  await writeLockWhileHeld(lockPath, lock);
@@ -9025,9 +10182,9 @@ async function writeLockNow(lockPath, lock) {
9025
10182
  }
9026
10183
  async function writeLockWhileHeld(file, lock) {
9027
10184
  let disk = { version: 2, entries: {} };
9028
- if ((0, import_node_fs5.existsSync)(file)) {
10185
+ if ((0, import_node_fs6.existsSync)(file)) {
9029
10186
  try {
9030
- disk = parseLock(JSON.parse(await (0, import_promises3.readFile)(file, "utf8")));
10187
+ disk = parseLock(JSON.parse(await (0, import_promises4.readFile)(file, "utf8")));
9031
10188
  } catch (err) {
9032
10189
  throw new ProjectError(
9033
10190
  `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`,
@@ -9056,10 +10213,10 @@ async function writeLockWhileHeld(file, lock) {
9056
10213
  }
9057
10214
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
9058
10215
  try {
9059
- await (0, import_promises3.writeFile)(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
9060
- await (0, import_promises3.rename)(tmp, file);
10216
+ await (0, import_promises4.writeFile)(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
10217
+ await (0, import_promises4.rename)(tmp, file);
9061
10218
  } finally {
9062
- await (0, import_promises3.rm)(tmp, { force: true });
10219
+ await (0, import_promises4.rm)(tmp, { force: true });
9063
10220
  }
9064
10221
  const currentDirty = dirtyPatches.get(lock);
9065
10222
  if (currentDirty) {
@@ -9122,18 +10279,18 @@ async function acquireFileLock(file) {
9122
10279
  const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
9123
10280
  for (; ; ) {
9124
10281
  try {
9125
- await (0, import_promises3.mkdir)(lockDir);
10282
+ await (0, import_promises4.mkdir)(lockDir);
9126
10283
  return async () => {
9127
- await (0, import_promises3.rmdir)(lockDir).catch(() => {
10284
+ await (0, import_promises4.rmdir)(lockDir).catch(() => {
9128
10285
  });
9129
10286
  };
9130
10287
  } catch (err) {
9131
10288
  const code = err.code;
9132
10289
  if (code !== "EEXIST") throw err;
9133
10290
  try {
9134
- const age = Date.now() - (await (0, import_promises3.stat)(lockDir)).mtimeMs;
10291
+ const age = Date.now() - (await (0, import_promises4.stat)(lockDir)).mtimeMs;
9135
10292
  if (age > STALE_FILE_LOCK_MS) {
9136
- await (0, import_promises3.rmdir)(lockDir);
10293
+ await (0, import_promises4.rmdir)(lockDir);
9137
10294
  continue;
9138
10295
  }
9139
10296
  } catch (lockErr) {
@@ -9160,22 +10317,22 @@ function totalSpend(lock, unit = "generations") {
9160
10317
 
9161
10318
  // src/outputs.ts
9162
10319
  function portableOutputPath(file, manifestDir) {
9163
- const root = import_node_path6.default.resolve(manifestDir);
9164
- const absolute = import_node_path6.default.isAbsolute(file) ? import_node_path6.default.normalize(file) : import_node_path6.default.resolve(root, file);
9165
- const relative = import_node_path6.default.relative(root, absolute);
9166
- const value = import_node_path6.default.isAbsolute(relative) ? absolute : relative;
9167
- return value.split(import_node_path6.default.sep).join("/");
10320
+ const root = import_node_path7.default.resolve(manifestDir);
10321
+ const absolute = import_node_path7.default.isAbsolute(file) ? import_node_path7.default.normalize(file) : import_node_path7.default.resolve(root, file);
10322
+ const relative = import_node_path7.default.relative(root, absolute);
10323
+ const value = import_node_path7.default.isAbsolute(relative) ? absolute : relative;
10324
+ return value.split(import_node_path7.default.sep).join("/");
9168
10325
  }
9169
10326
  function resolveOutputPath(recordedPath, manifestDir) {
9170
- if (import_node_path6.default.isAbsolute(recordedPath)) return import_node_path6.default.normalize(recordedPath);
9171
- if (import_node_path6.default.win32.isAbsolute(recordedPath)) return recordedPath;
9172
- return import_node_path6.default.resolve(manifestDir, recordedPath.split(/[\\/]/).join(import_node_path6.default.sep));
10327
+ if (import_node_path7.default.isAbsolute(recordedPath)) return import_node_path7.default.normalize(recordedPath);
10328
+ if (import_node_path7.default.win32.isAbsolute(recordedPath)) return recordedPath;
10329
+ return import_node_path7.default.resolve(manifestDir, recordedPath.split(/[\\/]/).join(import_node_path7.default.sep));
9173
10330
  }
9174
10331
  function expectedOutputPath(spec, role, index, total, mediaType) {
9175
10332
  return memberPath(spec.outFile, role, index, total, mediaType);
9176
10333
  }
9177
10334
  function memberPath(file, role, index, total, mediaType) {
9178
- const originalExt = import_node_path6.default.extname(file);
10335
+ const originalExt = import_node_path7.default.extname(file);
9179
10336
  const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
9180
10337
  const stem = originalExt ? file.slice(0, -originalExt.length) : file;
9181
10338
  if (total === 1) return `${stem}${ext}`;
@@ -9189,10 +10346,10 @@ function isFrameSetEntry(entry) {
9189
10346
  return entry.generator === "frames" && isMemberSetEntry(entry);
9190
10347
  }
9191
10348
  function sourceIsStem(source, entry, manifestDir) {
9192
- return isMemberSetEntry(entry) && !(0, import_node_fs6.existsSync)(import_node_path6.default.resolve(manifestDir, source));
10349
+ return isMemberSetEntry(entry) && !(0, import_node_fs7.existsSync)(import_node_path7.default.resolve(manifestDir, source));
9193
10350
  }
9194
10351
  function sourceOutputPath(source, entry, index, manifestDir) {
9195
- const absolute = import_node_path6.default.resolve(manifestDir, source);
10352
+ const absolute = import_node_path7.default.resolve(manifestDir, source);
9196
10353
  if (!sourceIsStem(source, entry, manifestDir)) return absolute;
9197
10354
  const output = entry.outputs[index];
9198
10355
  return memberPath(absolute, output.role, index, entry.outputs.length, output.mediaType);
@@ -9279,7 +10436,7 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
9279
10436
  id: spec.assetId,
9280
10437
  index: 0,
9281
10438
  path: spec.outFile,
9282
- absolutePath: import_node_path6.default.resolve(spec.outFile),
10439
+ absolutePath: import_node_path7.default.resolve(spec.outFile),
9283
10440
  sha256: ""
9284
10441
  }];
9285
10442
  }
@@ -9300,9 +10457,9 @@ function frameSetLoop(entry) {
9300
10457
 
9301
10458
  // src/manifest.ts
9302
10459
  async function loadManifest(manifestPath) {
9303
- const abs = import_node_path7.default.resolve(manifestPath);
9304
- if (!(0, import_node_fs7.existsSync)(abs)) throw new ProjectError(`No manifest at ${abs}`);
9305
- const expansion = expandLoopDirections(JSON.parse(await (0, import_promises4.readFile)(abs, "utf8")));
10460
+ const abs = import_node_path8.default.resolve(manifestPath);
10461
+ if (!(0, import_node_fs8.existsSync)(abs)) throw new ProjectError(`No manifest at ${abs}`);
10462
+ const expansion = expandLoopDirections(JSON.parse(await (0, import_promises5.readFile)(abs, "utf8")));
9306
10463
  if (expansion.issues.length) {
9307
10464
  throw new ProjectError(`Manifest at ${abs} is invalid:
9308
10465
  ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
@@ -9399,12 +10556,38 @@ ${issues}`);
9399
10556
  visited.add(assetId);
9400
10557
  };
9401
10558
  for (const assetId of Object.keys(parsed.data.assets)) visitParents(assetId, []);
10559
+ const connectVisiting = [];
10560
+ const connectVisited = /* @__PURE__ */ new Set();
10561
+ const visitConnections = (assetId) => {
10562
+ if (connectVisited.has(assetId)) return;
10563
+ const cycleStart = connectVisiting.indexOf(assetId);
10564
+ if (cycleStart >= 0) {
10565
+ unknownReferences.push(
10566
+ `assets.${assetId}.connect: connection cycle ${[...connectVisiting.slice(cycleStart), assetId].join(" -> ")}`
10567
+ );
10568
+ return;
10569
+ }
10570
+ connectVisiting.push(assetId);
10571
+ for (const [side, link] of Object.entries(parsed.data.assets[assetId]?.connect ?? {})) {
10572
+ if (!link) continue;
10573
+ if (!parsed.data.assets[link.of]) {
10574
+ unknownReferences.push(`assets.${assetId}.connect.${side}.of: unknown asset "${link.of}"`);
10575
+ } else if (link.of === assetId) {
10576
+ unknownReferences.push(`assets.${assetId}.connect.${side}.of: a set cannot connect to itself`);
10577
+ } else {
10578
+ visitConnections(link.of);
10579
+ }
10580
+ }
10581
+ connectVisiting.pop();
10582
+ connectVisited.add(assetId);
10583
+ };
10584
+ for (const assetId of Object.keys(parsed.data.assets)) visitConnections(assetId);
9402
10585
  if (unknownReferences.length) {
9403
10586
  throw new ProjectError(`Manifest at ${abs} is invalid:
9404
10587
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
9405
10588
  }
9406
10589
  const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
9407
- return { manifest: parsed.data, root: import_node_path7.default.dirname(abs), path: abs, ...loopFamilies };
10590
+ return { manifest: parsed.data, root: import_node_path8.default.dirname(abs), path: abs, ...loopFamilies };
9408
10591
  }
9409
10592
  function assetInStyle(asset, styleId, style) {
9410
10593
  if (asset.styles.length) return asset.styles.includes(styleId);
@@ -9413,6 +10596,13 @@ function assetInStyle(asset, styleId, style) {
9413
10596
  function parentAssetId(asset) {
9414
10597
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of ?? asset?.portrait?.of ?? asset?.outfit?.of;
9415
10598
  }
10599
+ function dependencyAssetIds(asset) {
10600
+ const parent = parentAssetId(asset);
10601
+ return [
10602
+ ...parent ? [parent] : [],
10603
+ ...[asset?.connect?.lower?.of, asset?.connect?.upper?.of].filter((id) => Boolean(id))
10604
+ ];
10605
+ }
9416
10606
  function formatManifestIssues(issues) {
9417
10607
  return issues.flatMap((issue) => {
9418
10608
  if (issue.code === "invalid_union") {
@@ -9500,11 +10690,12 @@ async function resolveSpecs(loaded, filter) {
9500
10690
  }
9501
10691
  const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
9502
10692
  const resolutionAssetIds = new Set(requestedAssetIds);
9503
- for (const assetId of [...requestedAssetIds]) {
9504
- let current = parentAssetId(manifest.assets[assetId]);
9505
- while (current && !resolutionAssetIds.has(current)) {
9506
- resolutionAssetIds.add(current);
9507
- current = parentAssetId(manifest.assets[current]);
10693
+ const pending = [...requestedAssetIds];
10694
+ while (pending.length) {
10695
+ for (const dependency of dependencyAssetIds(manifest.assets[pending.pop()])) {
10696
+ if (resolutionAssetIds.has(dependency) || !manifest.assets[dependency]) continue;
10697
+ resolutionAssetIds.add(dependency);
10698
+ pending.push(dependency);
9508
10699
  }
9509
10700
  }
9510
10701
  const styleDependencies = /* @__PURE__ */ new Map();
@@ -9542,11 +10733,11 @@ async function resolveSpecs(loaded, filter) {
9542
10733
  const styleFinalizers = /* @__PURE__ */ new Map();
9543
10734
  const styleImageCache = /* @__PURE__ */ new Map();
9544
10735
  async function loadStyleImage(rel, what = "Style image") {
9545
- const abs = import_node_path7.default.resolve(root, rel);
10736
+ const abs = import_node_path8.default.resolve(root, rel);
9546
10737
  let hit = styleImageCache.get(abs);
9547
10738
  if (!hit) {
9548
- if (!(0, import_node_fs7.existsSync)(abs)) throw new Error(`${what} not found: ${abs}`);
9549
- const buf = await (0, import_promises4.readFile)(abs);
10739
+ if (!(0, import_node_fs8.existsSync)(abs)) throw new Error(`${what} not found: ${abs}`);
10740
+ const buf = await (0, import_promises5.readFile)(abs);
9550
10741
  const metadata = imageMetadata(buf);
9551
10742
  if (!metadata) throw new Error(`${what} is not a readable PNG or JPEG: ${abs}`);
9552
10743
  if (metadata.width < 1 || metadata.height < 1) {
@@ -9572,7 +10763,7 @@ async function resolveSpecs(loaded, filter) {
9572
10763
  styleImageDimensions.push(loadedImage);
9573
10764
  }
9574
10765
  const resolvedImages = style.styleImages.map((image) => {
9575
- const hit = styleImageCache.get(import_node_path7.default.resolve(root, image.path));
10766
+ const hit = styleImageCache.get(import_node_path8.default.resolve(root, image.path));
9576
10767
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
9577
10768
  });
9578
10769
  const styleSpecs = /* @__PURE__ */ new Map();
@@ -9659,6 +10850,9 @@ async function resolveSpecs(loaded, filter) {
9659
10850
  `assets.${assetId}: ${asset.portrait ? "portrait" : "outfit"} needs a character style; "${styleId}" generates ${generator}`
9660
10851
  );
9661
10852
  }
10853
+ if (asset.connect && generator !== "terrain") {
10854
+ throw new Error(`assets.${assetId}: connect needs a terrain style; "${styleId}" generates ${generator}`);
10855
+ }
9662
10856
  if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
9663
10857
  throw new Error(
9664
10858
  `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
@@ -9670,7 +10864,18 @@ async function resolveSpecs(loaded, filter) {
9670
10864
  let terrainUpperDescription;
9671
10865
  let terrainTransitionDescription;
9672
10866
  let prompt;
9673
- if (generator === "terrain") {
10867
+ if (generator === "terrain" && style.terrainOrientation === "sidescroller") {
10868
+ const parsed = parseTerrainDescriptions(subject);
10869
+ if (!parsed || parsed.transition) {
10870
+ throw new Error(
10871
+ `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)`
10872
+ );
10873
+ }
10874
+ const wrap = (text) => [style.promptPrefix, text, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
10875
+ terrainLowerDescription = wrap(parsed.lower);
10876
+ terrainTransitionDescription = wrap(parsed.upper);
10877
+ prompt = subject.trim();
10878
+ } else if (generator === "terrain") {
9674
10879
  const parsed = parseTerrainDescriptions(subject);
9675
10880
  if (!parsed) {
9676
10881
  throw new Error(
@@ -9687,12 +10892,12 @@ async function resolveSpecs(loaded, filter) {
9687
10892
  } else {
9688
10893
  prompt = [style.promptPrefix, subject, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
9689
10894
  }
9690
- const relFile = asset.file ?? import_node_path7.default.join(asset.category ?? "", `${assetId}.png`);
9691
- const outFile = import_node_path7.default.resolve(root, style.outDir, relFile);
9692
- const qualityOutFile = style.quality ? import_node_path7.default.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
10895
+ const relFile = asset.file ?? import_node_path8.default.join(asset.category ?? "", `${assetId}.png`);
10896
+ const outFile = import_node_path8.default.resolve(root, style.outDir, relFile);
10897
+ const qualityOutFile = style.quality ? import_node_path8.default.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
9693
10898
  const tileSize = generator === "tiles" ? size : style.tileSize ?? 32;
9694
10899
  const tileVariations = tileFeatureOutputCount(generator === "tiles" ? style.tileFeature : void 0) ?? tileVariationCount(countNumberedDescriptions(prompt));
9695
- const terrainTiles = generator === "terrain" ? terrainTileCount(style.terrainTransitionSize) : 0;
10900
+ const terrainTiles = generator === "terrain" ? terrainTileCount(style.terrainTransitionSize, style.terrainMode, style.terrainOrientation) : 0;
9696
10901
  const base = {
9697
10902
  styleId,
9698
10903
  assetId,
@@ -9715,6 +10920,8 @@ async function resolveSpecs(loaded, filter) {
9715
10920
  uiColorPalette: generator === "uiAsset" || generator === "uiElement" ? style.uiColorPalette : void 0,
9716
10921
  ...generator === "imageProFlash" && style.styleTraits ? { styleTraits: style.styleTraits } : {},
9717
10922
  palette: style.palette,
10923
+ ...generator === "imagePro" && style.paletteName ? { paletteName: style.paletteName } : {},
10924
+ ...generator === "imagePro" && style.maxColors != null ? { maxColors: style.maxColors } : {},
9718
10925
  enforcePalette: style.enforcePalette,
9719
10926
  noBackground: style.noBackground,
9720
10927
  tileSize: generator === "tiles" ? tileSize : void 0,
@@ -9749,8 +10956,14 @@ async function resolveSpecs(loaded, filter) {
9749
10956
  terrainSpreadX: generator === "terrain" ? style.terrainSpreadX : void 0,
9750
10957
  terrainSlopeSize: generator === "terrain" ? style.terrainSlopeSize : void 0,
9751
10958
  terrainRaggedness: generator === "terrain" ? style.terrainRaggedness : void 0,
10959
+ terrainRoundness: generator === "terrain" ? style.terrainRoundness : void 0,
10960
+ terrainBoundaryAmplitude: generator === "terrain" ? style.terrainBoundaryAmplitude : void 0,
10961
+ terrainBoundarySeed: generator === "terrain" ? style.terrainBoundarySeed : void 0,
10962
+ terrainStepSlope: generator === "terrain" ? style.terrainStepSlope : void 0,
10963
+ terrainConnectDiagonals: generator === "terrain" ? style.terrainConnectDiagonals : void 0,
9752
10964
  terrainTransitionSize: generator === "terrain" ? style.terrainTransitionSize : void 0,
9753
10965
  terrainView: generator === "terrain" ? style.terrainView : void 0,
10966
+ terrainOrientation: generator === "terrain" ? style.terrainOrientation : void 0,
9754
10967
  isometricTileSize: generator === "isometricTile" ? style.isometricTileSize : void 0,
9755
10968
  isometricTileShape: generator === "isometricTile" ? style.isometricTileShape : void 0,
9756
10969
  // A revision in a character or objectPro style is a provider edit of
@@ -9761,7 +10974,7 @@ async function resolveSpecs(loaded, filter) {
9761
10974
  // objectPro asset is only ever a base, state, or animation.
9762
10975
  ...generator === "objectPro" && !asset.revision && characterKind !== "portrait" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
9763
10976
  ...vfx ? { vfx } : {},
9764
- cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
10977
+ cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? terrainCost(style.terrainMode, size, style.terrainOrientation) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
9765
10978
  costUnit: "generations",
9766
10979
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
9767
10980
  };
@@ -9785,7 +10998,7 @@ async function resolveSpecs(loaded, filter) {
9785
10998
  minGridConfidence: style.quality.minGridConfidence,
9786
10999
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
9787
11000
  ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
9788
- ...style.quality.fixerPython ? { fixerPython: import_node_path7.default.resolve(root, style.quality.fixerPython) } : {},
11001
+ ...style.quality.fixerPython ? { fixerPython: import_node_path8.default.resolve(root, style.quality.fixerPython) } : {},
9789
11002
  ...style.quality.fps == null ? {} : { fps: style.quality.fps }
9790
11003
  }
9791
11004
  } : {},
@@ -9822,6 +11035,11 @@ async function resolveSpecs(loaded, filter) {
9822
11035
  if (sourceSpec.generator === "tiles" || sourceSpec.generator === "terrain") {
9823
11036
  throw new Error(`assets.${assetId}: a tile set cannot be mirrored; its edges carry meaning`);
9824
11037
  }
11038
+ if (sourceSpec.revision?.mode === "normal-map") {
11039
+ throw new Error(
11040
+ `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`
11041
+ );
11042
+ }
9825
11043
  if (sourceSpec.character?.kind === "animation") {
9826
11044
  const facing = sourceSpec.character.animation.direction;
9827
11045
  const mirrored = MIRRORED_DIRECTION[facing];
@@ -9868,7 +11086,7 @@ async function resolveSpecs(loaded, filter) {
9868
11086
  const file = memberPath(anchorSpec.outFile, "south", 0, anchorSpec.character.directions, MediaType.PNG);
9869
11087
  resolved.character = {
9870
11088
  ...resolved.character,
9871
- styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: (0, import_node_fs7.existsSync)(file) ? await sha256File(file) : null }
11089
+ styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: (0, import_node_fs8.existsSync)(file) ? await sha256File(file) : null }
9872
11090
  };
9873
11091
  }
9874
11092
  const characterParent = asset.state?.of ?? asset.animation?.of ?? asset.portrait?.of;
@@ -9881,7 +11099,7 @@ async function resolveSpecs(loaded, filter) {
9881
11099
  parentAssetId: characterParent,
9882
11100
  parentSpec,
9883
11101
  parentFile,
9884
- parentSha256: (0, import_node_fs7.existsSync)(parentFile) ? await sha256File(parentFile) : null,
11102
+ parentSha256: (0, import_node_fs8.existsSync)(parentFile) ? await sha256File(parentFile) : null,
9885
11103
  // A state and its animations keep the engine and rotations of the base.
9886
11104
  mode: resolved.character.kind === "base" ? resolved.character.mode : parentSpec.character?.mode ?? resolved.character.mode,
9887
11105
  directions: resolved.character.kind === "base" ? resolved.character.directions : parentSpec.character?.directions ?? resolved.character.directions
@@ -9899,7 +11117,7 @@ async function resolveSpecs(loaded, filter) {
9899
11117
  parentAssetId: characterParent,
9900
11118
  parentSpec,
9901
11119
  parentFile,
9902
- parentSha256: (0, import_node_fs7.existsSync)(parentFile) ? await sha256File(parentFile) : null,
11120
+ parentSha256: (0, import_node_fs8.existsSync)(parentFile) ? await sha256File(parentFile) : null,
9903
11121
  // A state and its animations keep the base's own rotation count.
9904
11122
  directions: resolved.objectPro.kind === "base" ? resolved.objectPro.directions : parentSpec.objectPro.directions
9905
11123
  };
@@ -9921,7 +11139,7 @@ async function resolveSpecs(loaded, filter) {
9921
11139
  animation: sourceSpec.character.animation,
9922
11140
  outfit: {
9923
11141
  reference: {
9924
- path: import_node_path7.default.resolve(root, asset.outfit.reference),
11142
+ path: import_node_path8.default.resolve(root, asset.outfit.reference),
9925
11143
  sha256: referenceImage.hash,
9926
11144
  width: referenceImage.width,
9927
11145
  height: referenceImage.height,
@@ -9942,25 +11160,30 @@ async function resolveSpecs(loaded, filter) {
9942
11160
  `Provider "${activeProvider.id}" does not support ${asset.revision.mode} revisions`
9943
11161
  );
9944
11162
  }
11163
+ if (asset.revision.mode === "normal-map" && (style.enforcePalette || style.quality)) {
11164
+ throw new Error(
11165
+ `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`
11166
+ );
11167
+ }
9945
11168
  const sourceStyleId = asset.revision.fromStyle && asset.revision.fromStyle !== styleId ? asset.revision.fromStyle : void 0;
9946
11169
  const sourceSpec = sourceStyleId ? await styleFinalizers.get(sourceStyleId)(asset.revision.from) : await finalize(asset.revision.from);
9947
- const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? import_node_path7.default.resolve(root, sourceSpec.source) : sourceSpec.outFile);
11170
+ const sourceStem = sourceSpec.quality?.outFile ?? (sourceSpec.source ? import_node_path8.default.resolve(root, sourceSpec.source) : sourceSpec.outFile);
9948
11171
  const sourceMembers = await revisionSourceMembers(sourceStem, `revision source ${asset.revision.from}`);
9949
11172
  const sourceFile = sourceMembers?.[0]?.file ?? sourceStem;
9950
11173
  const sourceImage = sourceMembers ? { ...sourceMembers[0], hash: sha256(JSON.stringify(sourceMembers.map((member) => member.sha256))) } : await optionalRevisionImage(sourceFile, "revision source");
9951
11174
  const sourceFps = sourceSpec.character?.animation?.fps ?? sourceSpec.objectPro?.animation?.fps ?? sourceSpec.revision?.fps ?? sourceSpec.revision?.sourceFps;
9952
- const maskFile = asset.revision.mask ? import_node_path7.default.resolve(root, asset.revision.mask) : void 0;
11175
+ const maskFile = asset.revision.mask ? import_node_path8.default.resolve(root, asset.revision.mask) : void 0;
9953
11176
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
9954
11177
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
9955
11178
  throw new Error(
9956
11179
  `Revision mask for ${styleId}/${assetId} is ${maskImage.width}x${maskImage.height}; source ${asset.revision.from} is ${sourceImage.width}x${sourceImage.height}`
9957
11180
  );
9958
11181
  }
9959
- const paletteImageFile = asset.revision.paletteImage ? import_node_path7.default.resolve(root, asset.revision.paletteImage) : void 0;
11182
+ const paletteImageFile = asset.revision.paletteImage ? import_node_path8.default.resolve(root, asset.revision.paletteImage) : void 0;
9960
11183
  const paletteImage = paletteImageFile ? await optionalRevisionImage(paletteImageFile, "revision palette image", "png") : null;
9961
- const lastFrameFile = asset.revision.lastFrame ? import_node_path7.default.resolve(root, asset.revision.lastFrame) : void 0;
11184
+ const lastFrameFile = asset.revision.lastFrame ? import_node_path8.default.resolve(root, asset.revision.lastFrame) : void 0;
9962
11185
  const lastFrame = lastFrameFile ? await optionalRevisionImage(lastFrameFile, "revision last frame") : null;
9963
- const keypointsFile = asset.revision.keypointsFile ? import_node_path7.default.resolve(root, asset.revision.keypointsFile) : void 0;
11186
+ const keypointsFile = asset.revision.keypointsFile ? import_node_path8.default.resolve(root, asset.revision.keypointsFile) : void 0;
9964
11187
  const keypoints = keypointsFile ? await optionalSkeletonSet(keypointsFile) : null;
9965
11188
  resolved.revision = {
9966
11189
  mode: asset.revision.mode,
@@ -10016,6 +11239,7 @@ async function resolveSpecs(loaded, filter) {
10016
11239
  ...asset.revision.skeletonTemplate ? { skeletonTemplate: asset.revision.skeletonTemplate } : {},
10017
11240
  ...asset.revision.description ? { description: asset.revision.description } : {},
10018
11241
  ...asset.revision.removalTask ? { removalTask: asset.revision.removalTask } : {},
11242
+ ...asset.revision.convention ? { convention: asset.revision.convention } : {},
10019
11243
  ...asset.revision.driftThreshold == null ? {} : { driftThreshold: asset.revision.driftThreshold },
10020
11244
  ...asset.revision.subjectDescription ? { subjectDescription: asset.revision.subjectDescription } : {},
10021
11245
  ...asset.revision.initialPose ? { initialPose: asset.revision.initialPose } : {},
@@ -10072,6 +11296,24 @@ async function resolveSpecs(loaded, filter) {
10072
11296
  };
10073
11297
  }
10074
11298
  }
11299
+ if (asset.connect) {
11300
+ const links = {};
11301
+ for (const side of ["lower", "upper"]) {
11302
+ const link = asset.connect[side];
11303
+ if (!link) continue;
11304
+ if (!styleSpecs.has(link.of)) {
11305
+ throw new Error(
11306
+ `assets.${assetId}.connect.${side}.of: "${link.of}" is not in style "${styleId}"; connected terrain sets share one style`
11307
+ );
11308
+ }
11309
+ const linkedSpec = await finalize(link.of);
11310
+ if (linkedSpec.generator !== "terrain") {
11311
+ throw new Error(`assets.${assetId}.connect.${side}.of: "${link.of}" is not a terrain set`);
11312
+ }
11313
+ links[side] = { assetId: link.of, terrain: link.terrain, spec: linkedSpec };
11314
+ }
11315
+ resolved.terrainConnect = links;
11316
+ }
10075
11317
  if (asset.scene) resolved.scene = await resolveMapScene(root, styleId, assetId, resolved.generator, asset.scene, activeProvider);
10076
11318
  resolved.specHash = specHash(
10077
11319
  resolved,
@@ -10130,7 +11372,7 @@ async function resolveSpecs(loaded, filter) {
10130
11372
  return specs;
10131
11373
  }
10132
11374
  function pngPath(file) {
10133
- const extension = import_node_path7.default.extname(file);
11375
+ const extension = import_node_path8.default.extname(file);
10134
11376
  return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
10135
11377
  }
10136
11378
  async function resolveMapScene(root, styleId, assetId, generator, scene, provider) {
@@ -10140,12 +11382,12 @@ async function resolveMapScene(root, styleId, assetId, generator, scene, provide
10140
11382
  if (!provider.supportsMapScene) {
10141
11383
  throw new Error(`assets.${assetId}.scene: provider "${provider.id}" cannot draw into a scene; PixelLab's /map-objects can`);
10142
11384
  }
10143
- const file = import_node_path7.default.resolve(root, scene.image);
11385
+ const file = import_node_path8.default.resolve(root, scene.image);
10144
11386
  const image = await optionalRevisionImage(file, "scene image", "png");
10145
11387
  const base = { file, sha256: image?.hash ?? null, width: image?.width ?? null, height: image?.height ?? null };
10146
11388
  const placement = scene.placement;
10147
11389
  if ("mask" in placement) {
10148
- const maskFile = import_node_path7.default.resolve(root, placement.mask);
11390
+ const maskFile = import_node_path8.default.resolve(root, placement.mask);
10149
11391
  const mask = await optionalRevisionImage(maskFile, "scene mask", "png");
10150
11392
  if (image && mask && (image.width !== mask.width || image.height !== mask.height)) {
10151
11393
  throw new Error(
@@ -10163,20 +11405,20 @@ async function resolveVfxShape(root, assetId, vfx, defaultName) {
10163
11405
  let recipe;
10164
11406
  let recordedSprite;
10165
11407
  if (vfx.recipe) {
10166
- const file = import_node_path7.default.resolve(root, vfx.recipe);
10167
- if (!(0, import_node_fs7.existsSync)(file)) throw new Error(`assets.${assetId}.vfx.recipe: VFX recipe not found: ${file}`);
11408
+ const file = import_node_path8.default.resolve(root, vfx.recipe);
11409
+ if (!(0, import_node_fs8.existsSync)(file)) throw new Error(`assets.${assetId}.vfx.recipe: VFX recipe not found: ${file}`);
10168
11410
  const read = await readVfxRecipe(file).catch((error) => {
10169
11411
  throw new Error(`assets.${assetId}.vfx.recipe: ${error instanceof Error ? error.message : String(error)}`);
10170
11412
  });
10171
11413
  recipe = { file, sha256: vfxRecipeSha256(read.recipe), recipe: read.recipe };
10172
11414
  recordedSprite = read.sprite;
10173
11415
  }
10174
- const spriteRef = vfx.sprite ? isVfxReference(vfx.sprite) ? vfx.sprite : import_node_path7.default.resolve(root, vfx.sprite) : recordedSprite;
11416
+ const spriteRef = vfx.sprite ? isVfxReference(vfx.sprite) ? vfx.sprite : import_node_path8.default.resolve(root, vfx.sprite) : recordedSprite;
10175
11417
  let sprite;
10176
11418
  if (spriteRef && isVfxReference(spriteRef)) {
10177
11419
  sprite = { reference: spriteRef };
10178
11420
  } else if (spriteRef) {
10179
- const image = await optionalRevisionImage(spriteRef, `assets.${assetId}.vfx.sprite`);
11421
+ const image = await optionalRevisionImage(spriteRef, `assets.${assetId}.vfx.sprite`, vfx.description ? "png" : void 0);
10180
11422
  sprite = { file: spriteRef, sha256: image?.hash ?? null, width: image?.width ?? null, height: image?.height ?? null, format: image?.format ?? null };
10181
11423
  }
10182
11424
  return {
@@ -10185,12 +11427,14 @@ async function resolveVfxShape(root, assetId, vfx, defaultName) {
10185
11427
  ...vfx.parts?.length ? { parts: vfx.parts } : {},
10186
11428
  ...sprite ? { sprite } : {},
10187
11429
  ...vfx.fps !== void 0 ? { fps: vfx.fps } : {},
10188
- name: vfx.name ?? defaultName
11430
+ // The artist caps a name at 80 characters; a long default is cut, not refused.
11431
+ name: vfx.name ?? (vfx.description ? defaultName.slice(0, 80) : defaultName),
11432
+ ...vfx.description ? { description: vfx.description, artist: vfx.artist ?? "lite" } : {}
10189
11433
  };
10190
11434
  }
10191
11435
  async function optionalRevisionImage(file, label, requiredFormat) {
10192
- if (!(0, import_node_fs7.existsSync)(file)) return null;
10193
- const bytes = await (0, import_promises4.readFile)(file);
11436
+ if (!(0, import_node_fs8.existsSync)(file)) return null;
11437
+ const bytes = await (0, import_promises5.readFile)(file);
10194
11438
  const metadata = imageMetadata(bytes);
10195
11439
  if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
10196
11440
  if (requiredFormat && metadata.format !== requiredFormat) {
@@ -10199,8 +11443,8 @@ async function optionalRevisionImage(file, label, requiredFormat) {
10199
11443
  return { hash: sha256(bytes), ...metadata };
10200
11444
  }
10201
11445
  async function optionalSkeletonSet(file) {
10202
- if (!(0, import_node_fs7.existsSync)(file)) return null;
10203
- const bytes = await (0, import_promises4.readFile)(file);
11446
+ if (!(0, import_node_fs8.existsSync)(file)) return null;
11447
+ const bytes = await (0, import_promises5.readFile)(file);
10204
11448
  let json;
10205
11449
  try {
10206
11450
  json = JSON.parse(bytes.toString("utf8"));
@@ -10210,17 +11454,17 @@ async function optionalSkeletonSet(file) {
10210
11454
  return { hash: sha256(bytes), skeleton: parseSkeletonSet(json, file) };
10211
11455
  }
10212
11456
  async function revisionSourceMembers(stem, label) {
10213
- if ((0, import_node_fs7.existsSync)(stem)) return null;
11457
+ if ((0, import_node_fs8.existsSync)(stem)) return null;
10214
11458
  const at = (role) => memberPath(stem, role, 0, 2, MediaType.PNG);
10215
11459
  let roles = [];
10216
11460
  for (const set of [CHARACTER_DIRECTIONS_8, CHARACTER_DIRECTIONS_4]) {
10217
- if (set.every((direction) => (0, import_node_fs7.existsSync)(at(direction)))) {
11461
+ if (set.every((direction) => (0, import_node_fs8.existsSync)(at(direction)))) {
10218
11462
  roles = [...set];
10219
11463
  break;
10220
11464
  }
10221
11465
  }
10222
11466
  if (!roles.length) {
10223
- for (let index = 0; (0, import_node_fs7.existsSync)(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
11467
+ for (let index = 0; (0, import_node_fs8.existsSync)(at(`frame-${String(index).padStart(2, "0")}`)); index++) {
10224
11468
  roles.push(`frame-${String(index).padStart(2, "0")}`);
10225
11469
  }
10226
11470
  }
@@ -10246,8 +11490,8 @@ async function resolveStyleImages(loaded, styleId) {
10246
11490
  if (!style) throw new Error(`Unknown style "${styleId}"`);
10247
11491
  const out = [];
10248
11492
  for (const img of style.styleImages) {
10249
- const file = import_node_path7.default.resolve(loaded.root, img.path);
10250
- const buf = await (0, import_promises4.readFile)(file);
11493
+ const file = import_node_path8.default.resolve(loaded.root, img.path);
11494
+ const buf = await (0, import_promises5.readFile)(file);
10251
11495
  const metadata = imageMetadata(buf);
10252
11496
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
10253
11497
  if (metadata.width < 1 || metadata.height < 1) {
@@ -10277,7 +11521,7 @@ async function resolveCharacterShape(asset, style, kind, files) {
10277
11521
  };
10278
11522
  if (asset.concept) {
10279
11523
  const image = await files.load(asset.concept, "Concept image");
10280
- shape.concept = { path: import_node_path7.default.resolve(files.root, asset.concept), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11524
+ shape.concept = { path: import_node_path8.default.resolve(files.root, asset.concept), sha256: image.hash, width: image.width, height: image.height, format: image.format };
10281
11525
  }
10282
11526
  if (asset.reference) {
10283
11527
  const byDirection = typeof asset.reference === "string" ? { south: asset.reference } : asset.reference;
@@ -10285,7 +11529,7 @@ async function resolveCharacterShape(asset, style, kind, files) {
10285
11529
  for (const [direction, rel] of Object.entries(byDirection)) {
10286
11530
  const image = await files.load(rel, `Reference sprite (${direction})`);
10287
11531
  shape.reference[direction] = {
10288
- path: import_node_path7.default.resolve(files.root, rel),
11532
+ path: import_node_path8.default.resolve(files.root, rel),
10289
11533
  sha256: image.hash,
10290
11534
  width: image.width,
10291
11535
  height: image.height,
@@ -10306,7 +11550,7 @@ async function resolveCharacterShape(asset, style, kind, files) {
10306
11550
  const animationMode = asset.animation.mode ?? (asset.animation.template ? "template" : "v3");
10307
11551
  const pose = async (rel, what) => {
10308
11552
  const image = await files.load(rel, what);
10309
- return { path: import_node_path7.default.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11553
+ return { path: import_node_path8.default.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
10310
11554
  };
10311
11555
  shape.animation = {
10312
11556
  mode: animationMode,
@@ -10337,7 +11581,7 @@ async function resolveObjectProShape(assetId, asset, style, kind, files) {
10337
11581
  const south = typeof asset.reference === "string" ? asset.reference : asset.reference.south;
10338
11582
  if (south) {
10339
11583
  const image = await files.load(south, "Reference sprite");
10340
- shape.reference = { path: import_node_path7.default.resolve(files.root, south), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11584
+ shape.reference = { path: import_node_path8.default.resolve(files.root, south), sha256: image.hash, width: image.width, height: image.height, format: image.format };
10341
11585
  }
10342
11586
  }
10343
11587
  if (asset.state) {
@@ -10365,7 +11609,7 @@ async function resolveObjectProShape(assetId, asset, style, kind, files) {
10365
11609
  }
10366
11610
  const pose = async (rel, what) => {
10367
11611
  const image = await files.load(rel, what);
10368
- return { path: import_node_path7.default.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
11612
+ return { path: import_node_path8.default.resolve(files.root, rel), sha256: image.hash, width: image.width, height: image.height, format: image.format };
10369
11613
  };
10370
11614
  shape.animation = {
10371
11615
  mode: asset.animation.mode ?? "v3",
@@ -10385,11 +11629,11 @@ function characterDirections(directions) {
10385
11629
  }
10386
11630
 
10387
11631
  // src/project.ts
10388
- var import_node_path15 = __toESM(require("path"), 1);
11632
+ var import_node_path16 = __toESM(require("path"), 1);
10389
11633
 
10390
11634
  // src/env.ts
10391
- var import_node_fs8 = require("fs");
10392
- var import_node_path8 = __toESM(require("path"), 1);
11635
+ var import_node_fs9 = require("fs");
11636
+ var import_node_path9 = __toESM(require("path"), 1);
10393
11637
  function loadEnvFiles(dir) {
10394
11638
  const loaded = [];
10395
11639
  for (const [file, vars] of envFiles(dir)) {
@@ -10403,10 +11647,10 @@ function loadEnvFiles(dir) {
10403
11647
  function envFiles(dir) {
10404
11648
  const out = [];
10405
11649
  for (const name of [".env.local", ".env"]) {
10406
- const file = import_node_path8.default.join(dir, name);
10407
- if (!(0, import_node_fs8.existsSync)(file)) continue;
11650
+ const file = import_node_path9.default.join(dir, name);
11651
+ if (!(0, import_node_fs9.existsSync)(file)) continue;
10408
11652
  try {
10409
- out.push([file, parseEnv((0, import_node_fs8.readFileSync)(file, "utf8"))]);
11653
+ out.push([file, parseEnv((0, import_node_fs9.readFileSync)(file, "utf8"))]);
10410
11654
  } catch {
10411
11655
  }
10412
11656
  }
@@ -10432,12 +11676,12 @@ function parseEnv(contents) {
10432
11676
  }
10433
11677
 
10434
11678
  // src/pipeline/plan.ts
10435
- var import_node_fs11 = require("fs");
10436
- var import_node_path14 = __toESM(require("path"), 1);
11679
+ var import_node_fs12 = require("fs");
11680
+ var import_node_path15 = __toESM(require("path"), 1);
10437
11681
 
10438
11682
  // src/pipeline/mirror.ts
10439
- var import_promises5 = require("fs/promises");
10440
- var import_node_path9 = __toESM(require("path"), 1);
11683
+ var import_promises6 = require("fs/promises");
11684
+ var import_node_path10 = __toESM(require("path"), 1);
10441
11685
 
10442
11686
  // src/palette.ts
10443
11687
  function normalizePalette(values) {
@@ -10574,12 +11818,12 @@ async function deriveMirror(spec, lock, lockPath, opts = {}) {
10574
11818
  const outputs = [];
10575
11819
  const written = [];
10576
11820
  for (const [index, member] of members.entries()) {
10577
- const bytes = flipHorizontal(await (0, import_promises5.readFile)(member.file));
11821
+ const bytes = flipHorizontal(await (0, import_promises6.readFile)(member.file));
10578
11822
  const target = expectedOutputPath(spec, member.role, index, members.length, MediaType.PNG);
10579
- await (0, import_promises5.mkdir)(import_node_path9.default.dirname(target), { recursive: true });
11823
+ await (0, import_promises6.mkdir)(import_node_path10.default.dirname(target), { recursive: true });
10580
11824
  const tmp = `${target}.pixelkiln.tmp`;
10581
- await (0, import_promises5.writeFile)(tmp, bytes);
10582
- await (0, import_promises5.rename)(tmp, target);
11825
+ await (0, import_promises6.writeFile)(tmp, bytes);
11826
+ await (0, import_promises6.rename)(tmp, target);
10583
11827
  written.push(target);
10584
11828
  outputs.push({
10585
11829
  path: portableOutputPath(target, spec.root),
@@ -10638,20 +11882,20 @@ async function deriveMirror(spec, lock, lockPath, opts = {}) {
10638
11882
  }
10639
11883
 
10640
11884
  // src/pipeline/quality-profile.ts
10641
- var import_node_fs9 = require("fs");
10642
- var import_node_path12 = __toESM(require("path"), 1);
11885
+ var import_node_fs10 = require("fs");
11886
+ var import_node_path13 = __toESM(require("path"), 1);
10643
11887
 
10644
11888
  // src/pipeline/refine.ts
10645
- var import_node_path11 = __toESM(require("path"), 1);
11889
+ var import_node_path12 = __toESM(require("path"), 1);
10646
11890
  var import_node_child_process2 = require("child_process");
10647
- var import_promises7 = require("fs/promises");
11891
+ var import_promises8 = require("fs/promises");
10648
11892
  var import_node_os3 = require("os");
10649
11893
  var import_node_util = require("util");
10650
11894
 
10651
11895
  // src/artifacts.ts
10652
- var import_node_path10 = __toESM(require("path"), 1);
11896
+ var import_node_path11 = __toESM(require("path"), 1);
10653
11897
  var import_node_crypto6 = require("crypto");
10654
- var import_promises6 = require("fs/promises");
11898
+ var import_promises7 = require("fs/promises");
10655
11899
  var activeTransactions = /* @__PURE__ */ new Set();
10656
11900
  function message(error) {
10657
11901
  return error instanceof Error ? error.message : String(error);
@@ -10660,7 +11904,7 @@ function digest(data) {
10660
11904
  return (0, import_node_crypto6.createHash)("sha256").update(data).digest("hex");
10661
11905
  }
10662
11906
  function portableRelative(from, to) {
10663
- return import_node_path10.default.relative(from, import_node_path10.default.resolve(to)).split(import_node_path10.default.sep).join("/") || ".";
11907
+ return import_node_path11.default.relative(from, import_node_path11.default.resolve(to)).split(import_node_path11.default.sep).join("/") || ".";
10664
11908
  }
10665
11909
  function canonical2(value) {
10666
11910
  if (value === null || typeof value === "string" || typeof value === "boolean") return value;
@@ -10710,12 +11954,12 @@ function parseArtifactManifest(absolute, data) {
10710
11954
  return raw;
10711
11955
  }
10712
11956
  async function readArtifactBundleManifest(manifestPath) {
10713
- const absolute = import_node_path10.default.resolve(manifestPath);
10714
- return parseArtifactManifest(absolute, await (0, import_promises6.readFile)(absolute, "utf8"));
11957
+ const absolute = import_node_path11.default.resolve(manifestPath);
11958
+ return parseArtifactManifest(absolute, await (0, import_promises7.readFile)(absolute, "utf8"));
10715
11959
  }
10716
11960
  async function readOptional(file) {
10717
11961
  try {
10718
- return await (0, import_promises6.readFile)(file);
11962
+ return await (0, import_promises7.readFile)(file);
10719
11963
  } catch (error) {
10720
11964
  if (isCode(error, "ENOENT")) return null;
10721
11965
  throw error;
@@ -10734,8 +11978,8 @@ function processIsAlive(pid) {
10734
11978
  }
10735
11979
  }
10736
11980
  function validTemporaryPath(candidate, destination, type) {
10737
- return import_node_path10.default.dirname(candidate) === import_node_path10.default.dirname(destination) && import_node_path10.default.basename(candidate).startsWith(
10738
- `.${import_node_path10.default.basename(destination)}.pixelkiln-${type}-`
11981
+ return import_node_path11.default.dirname(candidate) === import_node_path11.default.dirname(destination) && import_node_path11.default.basename(candidate).startsWith(
11982
+ `.${import_node_path11.default.basename(destination)}.pixelkiln-${type}-`
10739
11983
  );
10740
11984
  }
10741
11985
  function parseTransaction(journal, data) {
@@ -10751,14 +11995,14 @@ function parseTransaction(journal, data) {
10751
11995
  return raw;
10752
11996
  }
10753
11997
  async function removeTransactionFiles(journal) {
10754
- await (0, import_promises6.rm)(journal, { force: true });
10755
- await (0, import_promises6.rm)(transactionMarker(journal), { force: true });
11998
+ await (0, import_promises7.rm)(journal, { force: true });
11999
+ await (0, import_promises7.rm)(transactionMarker(journal), { force: true });
10756
12000
  }
10757
12001
  async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10758
- const journal = import_node_path10.default.resolve(recoveryFile);
12002
+ const journal = import_node_path11.default.resolve(recoveryFile);
10759
12003
  const bytes = await readOptional(journal);
10760
12004
  if (!bytes) {
10761
- await (0, import_promises6.rm)(transactionMarker(journal), { force: true });
12005
+ await (0, import_promises7.rm)(transactionMarker(journal), { force: true });
10762
12006
  return;
10763
12007
  }
10764
12008
  if (activeTransactions.has(journal)) {
@@ -10771,7 +12015,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10771
12015
  );
10772
12016
  }
10773
12017
  for (const entry of transaction.entries) {
10774
- if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(import_node_path10.default.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
12018
+ if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(import_node_path11.default.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
10775
12019
  throw new Error(
10776
12020
  `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
10777
12021
  );
@@ -10797,7 +12041,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10797
12041
  }
10798
12042
  await remove2(entry.destination, errors);
10799
12043
  try {
10800
- await (0, import_promises6.rename)(entry.backup, entry.destination);
12044
+ await (0, import_promises7.rename)(entry.backup, entry.destination);
10801
12045
  } catch (error) {
10802
12046
  errors.push(
10803
12047
  `could not restore ${entry.destination}; previous file remains at ${entry.backup}: ${message(error)}`
@@ -10817,7 +12061,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
10817
12061
  await removeTransactionFiles(journal);
10818
12062
  }
10819
12063
  function createArtifactBundleManifest(manifestPath, outputs, provenance) {
10820
- const root = import_node_path10.default.dirname(import_node_path10.default.resolve(manifestPath));
12064
+ const root = import_node_path11.default.dirname(import_node_path11.default.resolve(manifestPath));
10821
12065
  const sources = provenance.sources.map((source) => ({
10822
12066
  ...source,
10823
12067
  path: portableRelative(root, source.path)
@@ -10847,11 +12091,11 @@ function withArtifactManifest(manifestPath, outputs, provenance) {
10847
12091
  ];
10848
12092
  }
10849
12093
  async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
10850
- const absoluteManifest = import_node_path10.default.resolve(manifestPath);
12094
+ const absoluteManifest = import_node_path11.default.resolve(manifestPath);
10851
12095
  const recoveryFile = `${absoluteManifest}.transaction`;
10852
12096
  const allowedDestinations = /* @__PURE__ */ new Set([
10853
12097
  absoluteManifest,
10854
- ...outputs.map((output) => import_node_path10.default.resolve(output.path))
12098
+ ...outputs.map((output) => import_node_path11.default.resolve(output.path))
10855
12099
  ]);
10856
12100
  await recoverArtifactTransaction(recoveryFile, allowedDestinations);
10857
12101
  const existingManifestBytes = await readOptional(absoluteManifest);
@@ -10869,13 +12113,13 @@ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, opt
10869
12113
  }
10870
12114
  }
10871
12115
  if (!options.force) {
10872
- const root = import_node_path10.default.dirname(absoluteManifest);
12116
+ const root = import_node_path11.default.dirname(absoluteManifest);
10873
12117
  const recorded = new Map(
10874
- previous?.outputs.map((output) => [import_node_path10.default.resolve(root, output.path), output.sha256]) ?? []
12118
+ previous?.outputs.map((output) => [import_node_path11.default.resolve(root, output.path), output.sha256]) ?? []
10875
12119
  );
10876
12120
  const conflicts = [];
10877
12121
  for (const output of outputs) {
10878
- const destination = import_node_path10.default.resolve(output.path);
12122
+ const destination = import_node_path11.default.resolve(output.path);
10879
12123
  const current = await readOptional(destination);
10880
12124
  if (!current || digest(current) === digest(output.data)) continue;
10881
12125
  const expected = recorded.get(destination);
@@ -10897,15 +12141,15 @@ Pass { force: true } (CLI: --force) to take ownership and replace the complete b
10897
12141
  );
10898
12142
  }
10899
12143
  async function verifyArtifactBundle(manifestPath) {
10900
- const absolute = import_node_path10.default.resolve(manifestPath);
12144
+ const absolute = import_node_path11.default.resolve(manifestPath);
10901
12145
  const raw = await readArtifactBundleManifest(absolute);
10902
- const root = import_node_path10.default.dirname(absolute);
12146
+ const root = import_node_path11.default.dirname(absolute);
10903
12147
  const changedSources = [];
10904
12148
  const changedOutputs = [];
10905
12149
  for (const source of raw.sources) {
10906
12150
  let actual = null;
10907
12151
  try {
10908
- actual = digest(await (0, import_promises6.readFile)(import_node_path10.default.resolve(root, source.path)));
12152
+ actual = digest(await (0, import_promises7.readFile)(import_node_path11.default.resolve(root, source.path)));
10909
12153
  } catch (error) {
10910
12154
  if (!isCode(error, "ENOENT")) throw error;
10911
12155
  }
@@ -10913,7 +12157,7 @@ async function verifyArtifactBundle(manifestPath) {
10913
12157
  }
10914
12158
  for (const output of raw.outputs) {
10915
12159
  try {
10916
- if (digest(await (0, import_promises6.readFile)(import_node_path10.default.resolve(root, output.path))) !== output.sha256) {
12160
+ if (digest(await (0, import_promises7.readFile)(import_node_path11.default.resolve(root, output.path))) !== output.sha256) {
10917
12161
  changedOutputs.push(output.path);
10918
12162
  }
10919
12163
  } catch (error) {
@@ -10939,7 +12183,7 @@ function isCode(error, code) {
10939
12183
  }
10940
12184
  async function remove2(file, errors) {
10941
12185
  try {
10942
- await (0, import_promises6.rm)(file, { force: true });
12186
+ await (0, import_promises7.rm)(file, { force: true });
10943
12187
  } catch (error) {
10944
12188
  errors?.push(`could not remove ${file}: ${message(error)}`);
10945
12189
  }
@@ -10952,7 +12196,7 @@ async function rollback(prepared) {
10952
12196
  for (const artifact of [...prepared].reverse()) {
10953
12197
  if (!artifact.backup || !artifact.backedUp) continue;
10954
12198
  try {
10955
- await (0, import_promises6.rename)(artifact.backup, artifact.destination);
12199
+ await (0, import_promises7.rename)(artifact.backup, artifact.destination);
10956
12200
  artifact.backedUp = false;
10957
12201
  } catch (error) {
10958
12202
  errors.push(
@@ -10969,7 +12213,7 @@ async function writeArtifactBundle(files, options = {}) {
10969
12213
  if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
10970
12214
  const normalized = files.map((file) => {
10971
12215
  if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
10972
- return { destination: import_node_path10.default.resolve(file.path), data: Buffer.from(file.data) };
12216
+ return { destination: import_node_path11.default.resolve(file.path), data: Buffer.from(file.data) };
10973
12217
  });
10974
12218
  const destinations = /* @__PURE__ */ new Set();
10975
12219
  for (const artifact of normalized) {
@@ -10985,7 +12229,7 @@ async function writeArtifactBundle(files, options = {}) {
10985
12229
  const unchanged = [];
10986
12230
  for (const artifact of normalized) {
10987
12231
  try {
10988
- const current = await (0, import_promises6.readFile)(artifact.destination);
12232
+ const current = await (0, import_promises7.readFile)(artifact.destination);
10989
12233
  if (current.equals(artifact.data)) {
10990
12234
  unchanged.push(artifact.destination);
10991
12235
  } else {
@@ -10999,21 +12243,21 @@ async function writeArtifactBundle(files, options = {}) {
10999
12243
  if (!changed.length) return { changed: [], unchanged };
11000
12244
  const token = (0, import_node_crypto6.randomUUID)();
11001
12245
  for (const [index, artifact] of changed.entries()) {
11002
- const basename = import_node_path10.default.basename(artifact.destination);
11003
- artifact.stage = import_node_path10.default.join(
11004
- import_node_path10.default.dirname(artifact.destination),
12246
+ const basename = import_node_path11.default.basename(artifact.destination);
12247
+ artifact.stage = import_node_path11.default.join(
12248
+ import_node_path11.default.dirname(artifact.destination),
11005
12249
  `.${basename}.pixelkiln-stage-${token}-${index}`
11006
12250
  );
11007
12251
  if (artifact.existed) {
11008
- artifact.backup = import_node_path10.default.join(
11009
- import_node_path10.default.dirname(artifact.destination),
12252
+ artifact.backup = import_node_path11.default.join(
12253
+ import_node_path11.default.dirname(artifact.destination),
11010
12254
  `.${basename}.pixelkiln-backup-${token}-${index}`
11011
12255
  );
11012
12256
  }
11013
12257
  }
11014
- const journal = options.recoveryFile ? import_node_path10.default.resolve(options.recoveryFile) : null;
12258
+ const journal = options.recoveryFile ? import_node_path11.default.resolve(options.recoveryFile) : null;
11015
12259
  if (journal) {
11016
- await (0, import_promises6.mkdir)(import_node_path10.default.dirname(journal), { recursive: true });
12260
+ await (0, import_promises7.mkdir)(import_node_path11.default.dirname(journal), { recursive: true });
11017
12261
  const transaction = {
11018
12262
  format: "pixelkiln-artifact-transaction",
11019
12263
  version: 1,
@@ -11027,7 +12271,7 @@ async function writeArtifactBundle(files, options = {}) {
11027
12271
  }))
11028
12272
  };
11029
12273
  try {
11030
- await (0, import_promises6.writeFile)(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
12274
+ await (0, import_promises7.writeFile)(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
11031
12275
  activeTransactions.add(journal);
11032
12276
  } catch (error) {
11033
12277
  throw new Error(
@@ -11038,9 +12282,9 @@ async function writeArtifactBundle(files, options = {}) {
11038
12282
  }
11039
12283
  try {
11040
12284
  for (const [index, artifact] of changed.entries()) {
11041
- await (0, import_promises6.mkdir)(import_node_path10.default.dirname(artifact.destination), { recursive: true });
12285
+ await (0, import_promises7.mkdir)(import_node_path11.default.dirname(artifact.destination), { recursive: true });
11042
12286
  await options.beforeStage?.(artifact.destination, index);
11043
- await (0, import_promises6.writeFile)(artifact.stage, artifact.data, { flag: "wx" });
12287
+ await (0, import_promises7.writeFile)(artifact.stage, artifact.data, { flag: "wx" });
11044
12288
  }
11045
12289
  } catch (error) {
11046
12290
  const cleanupErrors2 = await rollback(changed);
@@ -11053,17 +12297,17 @@ async function writeArtifactBundle(files, options = {}) {
11053
12297
  try {
11054
12298
  for (const artifact of changed) {
11055
12299
  if (!artifact.existed) continue;
11056
- await (0, import_promises6.rename)(artifact.destination, artifact.backup);
12300
+ await (0, import_promises7.rename)(artifact.destination, artifact.backup);
11057
12301
  artifact.backedUp = true;
11058
12302
  }
11059
12303
  for (const [index, artifact] of changed.entries()) {
11060
12304
  await options.beforePromote?.(artifact.destination, index);
11061
- await (0, import_promises6.rename)(artifact.stage, artifact.destination);
12305
+ await (0, import_promises7.rename)(artifact.stage, artifact.destination);
11062
12306
  artifact.stage = void 0;
11063
12307
  artifact.promoted = true;
11064
12308
  }
11065
12309
  if (journal) {
11066
- await (0, import_promises6.writeFile)(transactionMarker(journal), `${token}
12310
+ await (0, import_promises7.writeFile)(transactionMarker(journal), `${token}
11067
12311
  `, { flag: "wx" });
11068
12312
  durableCommit = true;
11069
12313
  await options.afterCommit?.();
@@ -11304,16 +12548,16 @@ function sameFrameGrid(first, next) {
11304
12548
  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;
11305
12549
  }
11306
12550
  async function refineAsset(options) {
11307
- const source = import_node_path11.default.resolve(options.source);
11308
- const output = import_node_path11.default.resolve(options.output);
12551
+ const source = import_node_path12.default.resolve(options.source);
12552
+ const output = import_node_path12.default.resolve(options.output);
11309
12553
  if (source === output) throw new Error("Refinement source and output must be different files.");
11310
- const record = import_node_path11.default.resolve(qualityRecordPath(output));
12554
+ const record = import_node_path12.default.resolve(qualityRecordPath(output));
11311
12555
  const palette = normalizePalette(options.palette).map((color) => color.hex);
11312
12556
  const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
11313
12557
  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.");
11314
12558
  let sourcePng;
11315
12559
  try {
11316
- sourcePng = decodePng(await (0, import_promises7.readFile)(source));
12560
+ sourcePng = decodePng(await (0, import_promises8.readFile)(source));
11317
12561
  } catch (error) {
11318
12562
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
11319
12563
  }
@@ -11330,8 +12574,8 @@ async function refineAsset(options) {
11330
12574
  );
11331
12575
  }
11332
12576
  }
11333
- const temp = await (0, import_promises7.mkdtemp)(import_node_path11.default.join((0, import_node_os3.tmpdir)(), "pixelkiln-refine-"));
11334
- const recovered = import_node_path11.default.join(temp, "native.png");
12577
+ const temp = await (0, import_promises8.mkdtemp)(import_node_path12.default.join((0, import_node_os3.tmpdir)(), "pixelkiln-refine-"));
12578
+ const recovered = import_node_path12.default.join(temp, "native.png");
11335
12579
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
11336
12580
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
11337
12581
  try {
@@ -11352,7 +12596,7 @@ async function refineAsset(options) {
11352
12596
  );
11353
12597
  }
11354
12598
  const detection = parseDetection(firstJsonObject(stdout));
11355
- const native = decodePng(await (0, import_promises7.readFile)(recovered));
12599
+ const native = decodePng(await (0, import_promises8.readFile)(recovered));
11356
12600
  const quantized = quantizeToPalette(native, palette);
11357
12601
  const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
11358
12602
  const audit = auditRefinement(
@@ -11402,7 +12646,7 @@ async function refineAsset(options) {
11402
12646
  );
11403
12647
  return { source, output, record, detection, palette, audit };
11404
12648
  } finally {
11405
- await (0, import_promises7.rm)(temp, { recursive: true, force: true });
12649
+ await (0, import_promises8.rm)(temp, { recursive: true, force: true });
11406
12650
  }
11407
12651
  }
11408
12652
  async function refineFrameSet(options) {
@@ -11416,18 +12660,18 @@ async function refineFrameSet(options) {
11416
12660
  if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
11417
12661
  throw new Error("Frame-set roles must be non-empty and unique.");
11418
12662
  }
11419
- const output = import_node_path11.default.resolve(options.output);
11420
- const record = import_node_path11.default.resolve(qualityRecordPath(output));
12663
+ const output = import_node_path12.default.resolve(options.output);
12664
+ const record = import_node_path12.default.resolve(qualityRecordPath(output));
11421
12665
  const palette = normalizeRefinementPalette(options.palette);
11422
12666
  const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
11423
12667
  if (new Set(finalOutputs).size !== finalOutputs.length) {
11424
12668
  throw new Error("Frame-set roles resolve to duplicate output filenames.");
11425
12669
  }
11426
- const sourceSet = new Set(options.sources.map((source) => import_node_path11.default.resolve(source.path)));
12670
+ const sourceSet = new Set(options.sources.map((source) => import_node_path12.default.resolve(source.path)));
11427
12671
  if (finalOutputs.some((file) => sourceSet.has(file))) {
11428
12672
  throw new Error("Frame-set refinement sources and outputs must be different files.");
11429
12673
  }
11430
- const temp = await (0, import_promises7.mkdtemp)(import_node_path11.default.join((0, import_node_os3.tmpdir)(), "pixelkiln-refine-frames-"));
12674
+ const temp = await (0, import_promises8.mkdtemp)(import_node_path12.default.join((0, import_node_os3.tmpdir)(), "pixelkiln-refine-frames-"));
11431
12675
  try {
11432
12676
  const frames = [];
11433
12677
  const outputs = [];
@@ -11435,8 +12679,8 @@ async function refineFrameSet(options) {
11435
12679
  const sourcePaths = [];
11436
12680
  let firstOptions = null;
11437
12681
  for (const [index, sourceEntry] of options.sources.entries()) {
11438
- const source = import_node_path11.default.resolve(sourceEntry.path);
11439
- const temporaryOutput = import_node_path11.default.join(temp, `${String(index).padStart(2, "0")}.png`);
12682
+ const source = import_node_path12.default.resolve(sourceEntry.path);
12683
+ const temporaryOutput = import_node_path12.default.join(temp, `${String(index).padStart(2, "0")}.png`);
11440
12684
  const refined = await refineAsset({
11441
12685
  source,
11442
12686
  output: temporaryOutput,
@@ -11468,7 +12712,7 @@ async function refineFrameSet(options) {
11468
12712
  });
11469
12713
  outputs.push({
11470
12714
  path: finalOutputs[index],
11471
- data: await (0, import_promises7.readFile)(temporaryOutput)
12715
+ data: await (0, import_promises8.readFile)(temporaryOutput)
11472
12716
  });
11473
12717
  }
11474
12718
  const first = frames[0];
@@ -11511,23 +12755,23 @@ async function refineFrameSet(options) {
11511
12755
  );
11512
12756
  return {
11513
12757
  sources: sourcePaths,
11514
- outputs: outputs.map((item) => import_node_path11.default.resolve(item.path)),
12758
+ outputs: outputs.map((item) => import_node_path12.default.resolve(item.path)),
11515
12759
  record,
11516
12760
  frames,
11517
12761
  palette,
11518
12762
  audit
11519
12763
  };
11520
12764
  } finally {
11521
- await (0, import_promises7.rm)(temp, { recursive: true, force: true });
12765
+ await (0, import_promises8.rm)(temp, { recursive: true, force: true });
11522
12766
  }
11523
12767
  }
11524
12768
  async function checkQualityRecord(recordPath) {
11525
- const record = import_node_path11.default.resolve(recordPath);
12769
+ const record = import_node_path12.default.resolve(recordPath);
11526
12770
  const manifest = await readArtifactBundleManifest(record);
11527
12771
  const options = parseRecordOptions(manifest, record);
11528
12772
  const verification = await verifyArtifactBundle(record);
11529
- const sources = manifest.sources.map((source2) => import_node_path11.default.resolve(import_node_path11.default.dirname(record), source2.path));
11530
- const outputs = manifest.outputs.map((output2) => import_node_path11.default.resolve(import_node_path11.default.dirname(record), output2.path));
12773
+ const sources = manifest.sources.map((source2) => import_node_path12.default.resolve(import_node_path12.default.dirname(record), source2.path));
12774
+ const outputs = manifest.outputs.map((output2) => import_node_path12.default.resolve(import_node_path12.default.dirname(record), output2.path));
11531
12775
  const source = sources[0] ?? "";
11532
12776
  const output = outputs[0] ?? "";
11533
12777
  const expectedCount = options.frameSet?.count ?? 1;
@@ -11560,7 +12804,7 @@ async function checkQualityRecord(recordPath) {
11560
12804
  async function approveQualityRecord(recordPath, approval) {
11561
12805
  const reviewer = approval.reviewer.trim();
11562
12806
  if (!reviewer) throw new Error("A human reviewer name is required.");
11563
- const record = import_node_path11.default.resolve(recordPath);
12807
+ const record = import_node_path12.default.resolve(recordPath);
11564
12808
  const manifest = await readArtifactBundleManifest(record);
11565
12809
  const options = parseRecordOptions(manifest, record);
11566
12810
  const verification = await verifyArtifactBundle(record);
@@ -11581,14 +12825,14 @@ async function approveQualityRecord(recordPath, approval) {
11581
12825
  `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
11582
12826
  );
11583
12827
  }
11584
- const root = import_node_path11.default.dirname(record);
12828
+ const root = import_node_path12.default.dirname(record);
11585
12829
  const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
11586
- const outputPath = import_node_path11.default.resolve(root, entry.path);
11587
- return { path: outputPath, data: await (0, import_promises7.readFile)(outputPath) };
12830
+ const outputPath = import_node_path12.default.resolve(root, entry.path);
12831
+ return { path: outputPath, data: await (0, import_promises8.readFile)(outputPath) };
11588
12832
  }));
11589
12833
  const sources = manifest.sources.map((source) => ({
11590
12834
  ...source,
11591
- path: import_node_path11.default.resolve(root, source.path)
12835
+ path: import_node_path12.default.resolve(root, source.path)
11592
12836
  }));
11593
12837
  const approved = {
11594
12838
  ...options,
@@ -11620,8 +12864,8 @@ async function qualitySource(spec, lock) {
11620
12864
  if (spec.generator === "frames") {
11621
12865
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
11622
12866
  }
11623
- const source = import_node_path12.default.resolve(spec.root, spec.source);
11624
- return (0, import_node_fs9.existsSync)(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
12867
+ const source = import_node_path13.default.resolve(spec.root, spec.source);
12868
+ return (0, import_node_fs10.existsSync)(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
11625
12869
  }
11626
12870
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
11627
12871
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -11650,7 +12894,7 @@ async function qualitySource(spec, lock) {
11650
12894
  }
11651
12895
  if (role) roles.add(role);
11652
12896
  const source = currentEntryOutputPath(entry, spec, index);
11653
- if (!(0, import_node_fs9.existsSync)(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
12897
+ if (!(0, import_node_fs10.existsSync)(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
11654
12898
  if (await sha256File(source) !== output.sha256) {
11655
12899
  return { items: null, reason: "raw provider output was modified after download" };
11656
12900
  }
@@ -11669,7 +12913,7 @@ async function inspectQualityProfile(spec, lock) {
11669
12913
  }
11670
12914
  const sourcePaths = source.items.map((item) => item.path);
11671
12915
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
11672
- if (!(0, import_node_fs9.existsSync)(record)) {
12916
+ if (!(0, import_node_fs10.existsSync)(record)) {
11673
12917
  return {
11674
12918
  key,
11675
12919
  state: "needs-refinement",
@@ -11687,7 +12931,7 @@ async function inspectQualityProfile(spec, lock) {
11687
12931
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
11688
12932
  const expectedTransparency = spec.quality.minTransparency ?? null;
11689
12933
  const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
11690
- const contractChanged = JSON.stringify(check.sources.map((item) => import_node_path12.default.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => import_node_path12.default.resolve(item))) || JSON.stringify(check.outputs.map((item) => import_node_path12.default.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => import_node_path12.default.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);
12934
+ const contractChanged = JSON.stringify(check.sources.map((item) => import_node_path13.default.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => import_node_path13.default.resolve(item))) || JSON.stringify(check.outputs.map((item) => import_node_path13.default.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => import_node_path13.default.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);
11691
12935
  if (contractChanged) {
11692
12936
  return {
11693
12937
  key,
@@ -11850,8 +13094,8 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
11850
13094
  }
11851
13095
 
11852
13096
  // src/pipeline/revision.ts
11853
- var import_node_fs10 = require("fs");
11854
- var import_node_path13 = __toESM(require("path"), 1);
13097
+ var import_node_fs11 = require("fs");
13098
+ var import_node_path14 = __toESM(require("path"), 1);
11855
13099
  async function inspectRevisionReadiness(spec, lock) {
11856
13100
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
11857
13101
  if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
@@ -11860,6 +13104,7 @@ async function inspectRevisionReadiness(spec, lock) {
11860
13104
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
11861
13105
  if (spec.scene) return inspectScene(spec);
11862
13106
  if (spec.vfx?.sprite && "file" in spec.vfx.sprite) return inspectVfxSprite(spec);
13107
+ if (spec.terrainConnect) return inspectTerrainConnect(spec, lock, /* @__PURE__ */ new Set());
11863
13108
  if (!spec.revision) return null;
11864
13109
  return inspectRevision(spec, lock, /* @__PURE__ */ new Set());
11865
13110
  }
@@ -11868,7 +13113,7 @@ async function inspectStyleAnchor(spec, lock, seen) {
11868
13113
  const label = `style anchor ${spec.styleId}/${anchor.assetId}`;
11869
13114
  const dependency = await inspectParent(anchor.spec, lock, seen);
11870
13115
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11871
- if (!anchor.sha256 || !(0, import_node_fs10.existsSync)(anchor.file)) return { ready: false, reason: `${label} has no generated south-facing file yet` };
13116
+ if (!anchor.sha256 || !(0, import_node_fs11.existsSync)(anchor.file)) return { ready: false, reason: `${label} has no generated south-facing file yet` };
11872
13117
  if (await sha256File(anchor.file) !== anchor.sha256) {
11873
13118
  return { ready: false, reason: `${label} changed after the manifest was resolved` };
11874
13119
  }
@@ -11877,19 +13122,31 @@ async function inspectStyleAnchor(spec, lock, seen) {
11877
13122
  }
11878
13123
  return { ready: true, reason: `${label} is current` };
11879
13124
  }
13125
+ async function inspectTerrainConnect(spec, lock, seen) {
13126
+ for (const link of [spec.terrainConnect.lower, spec.terrainConnect.upper]) {
13127
+ if (!link) continue;
13128
+ const label = `connected set ${spec.styleId}/${link.assetId}`;
13129
+ const dependency = await inspectParent(link.spec, lock, seen);
13130
+ if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
13131
+ if (!lock.entries[lockKey(link.spec.styleId, link.spec.assetId)]?.objectId) {
13132
+ return { ready: false, reason: `${label} has no PixelLab tileset id recorded` };
13133
+ }
13134
+ }
13135
+ return { ready: true, reason: "connected sets are current" };
13136
+ }
11880
13137
  async function inspectScene(spec) {
11881
13138
  const scene = spec.scene;
11882
13139
  const files = [{ label: "scene image", file: scene.file, sha256: scene.sha256 }];
11883
13140
  if (scene.placement.type === "mask") files.push({ label: "scene mask", file: scene.placement.file, sha256: scene.placement.sha256 });
11884
13141
  for (const { label, file, sha256: sha2562 } of files) {
11885
- if (!sha2562 || !(0, import_node_fs10.existsSync)(file)) return { ready: false, reason: `${label} does not exist yet: ${file}` };
13142
+ if (!sha2562 || !(0, import_node_fs11.existsSync)(file)) return { ready: false, reason: `${label} does not exist yet: ${file}` };
11886
13143
  if (await sha256File(file) !== sha2562) return { ready: false, reason: `${label} changed after the manifest was resolved` };
11887
13144
  }
11888
13145
  return { ready: true, reason: "scene is current" };
11889
13146
  }
11890
13147
  async function inspectVfxSprite(spec) {
11891
13148
  const sprite = spec.vfx.sprite;
11892
- if (!sprite.sha256 || !(0, import_node_fs10.existsSync)(sprite.file)) return { ready: false, reason: `vfx sprite does not exist yet: ${sprite.file}` };
13149
+ if (!sprite.sha256 || !(0, import_node_fs11.existsSync)(sprite.file)) return { ready: false, reason: `vfx sprite does not exist yet: ${sprite.file}` };
11893
13150
  if (await sha256File(sprite.file) !== sprite.sha256) return { ready: false, reason: "vfx sprite changed after the manifest was resolved" };
11894
13151
  return { ready: true, reason: "vfx sprite is current" };
11895
13152
  }
@@ -11905,7 +13162,7 @@ async function inspectOutfit(spec, lock, seen) {
11905
13162
  const label = `outfit source ${spec.styleId}/${character.parentAssetId}`;
11906
13163
  const dependency = await inspectParent(character.parentSpec, lock, seen);
11907
13164
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11908
- if (!(0, import_node_fs10.existsSync)(outfit.reference.path)) {
13165
+ if (!(0, import_node_fs11.existsSync)(outfit.reference.path)) {
11909
13166
  return { ready: false, reason: `outfit reference is missing: ${outfit.reference.path}` };
11910
13167
  }
11911
13168
  if (await sha256File(outfit.reference.path) !== outfit.reference.sha256) {
@@ -11919,7 +13176,7 @@ async function inspectCharacterParent(spec, lock, seen) {
11919
13176
  const label = `${character.kind} parent ${spec.styleId}/${character.parentAssetId}`;
11920
13177
  const dependency = await inspectParent(parentSpec, lock, seen);
11921
13178
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11922
- if (!character.parentSha256 || !character.parentFile || !(0, import_node_fs10.existsSync)(character.parentFile)) {
13179
+ if (!character.parentSha256 || !character.parentFile || !(0, import_node_fs11.existsSync)(character.parentFile)) {
11923
13180
  return { ready: false, reason: `${label} has no generated south-facing file yet` };
11924
13181
  }
11925
13182
  if (await sha256File(character.parentFile) !== character.parentSha256) {
@@ -11937,7 +13194,7 @@ async function inspectObjectProParent(spec, lock, seen) {
11937
13194
  const label = `${object.kind} parent ${spec.styleId}/${object.parentAssetId}`;
11938
13195
  const dependency = await inspectParent(parentSpec, lock, seen);
11939
13196
  if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
11940
- if (!object.parentSha256 || !object.parentFile || !(0, import_node_fs10.existsSync)(object.parentFile)) {
13197
+ if (!object.parentSha256 || !object.parentFile || !(0, import_node_fs11.existsSync)(object.parentFile)) {
11941
13198
  return { ready: false, reason: `${label} has no generated south-facing file yet` };
11942
13199
  }
11943
13200
  if (await sha256File(object.parentFile) !== object.parentSha256) {
@@ -11963,7 +13220,7 @@ async function inspectRevision(spec, lock, seen) {
11963
13220
  const members = await inspectSourceMembers(spec, lock);
11964
13221
  if (!members.ready) return members;
11965
13222
  } else {
11966
- if (!revision.sourceSha256 || !(0, import_node_fs10.existsSync)(revision.sourceFile)) {
13223
+ if (!revision.sourceSha256 || !(0, import_node_fs11.existsSync)(revision.sourceFile)) {
11967
13224
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
11968
13225
  }
11969
13226
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
@@ -11971,7 +13228,7 @@ async function inspectRevision(spec, lock, seen) {
11971
13228
  }
11972
13229
  }
11973
13230
  if (revision.maskFile) {
11974
- if (!revision.maskSha256 || !(0, import_node_fs10.existsSync)(revision.maskFile)) {
13231
+ if (!revision.maskSha256 || !(0, import_node_fs11.existsSync)(revision.maskFile)) {
11975
13232
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
11976
13233
  }
11977
13234
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -11979,7 +13236,7 @@ async function inspectRevision(spec, lock, seen) {
11979
13236
  }
11980
13237
  }
11981
13238
  if (revision.lastFrameFile) {
11982
- if (!revision.lastFrameSha256 || !(0, import_node_fs10.existsSync)(revision.lastFrameFile)) {
13239
+ if (!revision.lastFrameSha256 || !(0, import_node_fs11.existsSync)(revision.lastFrameFile)) {
11983
13240
  return { ready: false, reason: `revision last frame is missing: ${revision.lastFrameFile}` };
11984
13241
  }
11985
13242
  if (await sha256File(revision.lastFrameFile) !== revision.lastFrameSha256) {
@@ -11992,7 +13249,7 @@ async function inspectSourceMembers(spec, lock) {
11992
13249
  const revision = spec.revision;
11993
13250
  const members = revision.sourceMembers;
11994
13251
  for (const member of members) {
11995
- if (!(0, import_node_fs10.existsSync)(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
13252
+ if (!(0, import_node_fs11.existsSync)(member.file)) return { ready: false, reason: `revision source member is missing: ${member.file}` };
11996
13253
  if (await sha256File(member.file) !== member.sha256) {
11997
13254
  return { ready: false, reason: `revision source member changed after the manifest was resolved: ${member.file}` };
11998
13255
  }
@@ -12001,8 +13258,8 @@ async function inspectSourceMembers(spec, lock) {
12001
13258
  if (parent.source) return { ready: true, reason: "declared source set is present" };
12002
13259
  const entry = lock.entries[lockKey(parent.styleId, parent.assetId)];
12003
13260
  if (!entry) return { ready: false, reason: "revision source is not in the lockfile" };
12004
- const recorded = entry.outputs.map((_, index) => import_node_path13.default.resolve(currentEntryOutputPath(entry, parent, index))).sort();
12005
- const found = members.map((member) => import_node_path13.default.resolve(member.file)).sort();
13261
+ const recorded = entry.outputs.map((_, index) => import_node_path14.default.resolve(currentEntryOutputPath(entry, parent, index))).sort();
13262
+ const found = members.map((member) => import_node_path14.default.resolve(member.file)).sort();
12006
13263
  if (recorded.length !== found.length || recorded.some((file, index) => file !== found[index])) {
12007
13264
  return {
12008
13265
  ready: false,
@@ -12043,6 +13300,10 @@ async function inspectParent(spec, lock, seen) {
12043
13300
  const inputs = await inspectObjectProParent(spec, lock, nextSeen);
12044
13301
  if (!inputs.ready) return inputs;
12045
13302
  }
13303
+ if (spec.terrainConnect) {
13304
+ const inputs = await inspectTerrainConnect(spec, lock, nextSeen);
13305
+ if (!inputs.ready) return inputs;
13306
+ }
12046
13307
  if (spec.quality) {
12047
13308
  const quality = await inspectQualityProfile(spec, lock);
12048
13309
  return quality?.state === "approved" ? { ready: true, reason: "approved quality output is current" } : {
@@ -12051,8 +13312,8 @@ async function inspectParent(spec, lock, seen) {
12051
13312
  };
12052
13313
  }
12053
13314
  if (spec.source) {
12054
- const source = import_node_path13.default.resolve(spec.root, spec.source);
12055
- return (0, import_node_fs10.existsSync)(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
13315
+ const source = import_node_path14.default.resolve(spec.root, spec.source);
13316
+ return (0, import_node_fs11.existsSync)(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
12056
13317
  }
12057
13318
  const entry = lock.entries[key];
12058
13319
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -12066,7 +13327,7 @@ async function inspectParent(spec, lock, seen) {
12066
13327
  for (let index = 0; index < entry.outputs.length; index++) {
12067
13328
  const output = entry.outputs[index];
12068
13329
  const file = currentEntryOutputPath(entry, spec, index);
12069
- if (!(0, import_node_fs10.existsSync)(file)) return { ready: false, reason: `parent output is missing: ${file}` };
13330
+ if (!(0, import_node_fs11.existsSync)(file)) return { ready: false, reason: `parent output is missing: ${file}` };
12070
13331
  if (await sha256File(file) !== output.sha256) {
12071
13332
  return { ready: false, reason: `parent output was modified: ${file}` };
12072
13333
  }
@@ -12088,9 +13349,21 @@ function dependencies(spec) {
12088
13349
  spec.revision?.sourceSpec,
12089
13350
  spec.character?.parentSpec,
12090
13351
  spec.character?.styleAnchor?.spec,
12091
- spec.objectPro?.parentSpec
13352
+ spec.objectPro?.parentSpec,
13353
+ spec.terrainConnect?.lower?.spec,
13354
+ spec.terrainConnect?.upper?.spec
12092
13355
  ].filter((parent) => Boolean(parent));
12093
13356
  }
13357
+ function changedTerrainConnection(spec, entry, lock) {
13358
+ for (const side of ["lower", "upper"]) {
13359
+ const link = spec.terrainConnect?.[side];
13360
+ if (!link) continue;
13361
+ const other = lock.entries[lockKey(link.spec.styleId, link.spec.assetId)];
13362
+ const recorded = entry.terrainConnect?.[side];
13363
+ if (!recorded || recorded.sourceObjectId !== other?.objectId) return link.assetId;
13364
+ }
13365
+ return null;
13366
+ }
12094
13367
  function resumeCommandForStatus(status) {
12095
13368
  if (status === "pending" || status === "processing") return "poll";
12096
13369
  if (status === "review") return "pick";
@@ -12122,6 +13395,7 @@ async function buildPlan(specs, lock, opts = {}) {
12122
13395
  const entry = lock.entries[key];
12123
13396
  let state;
12124
13397
  let reason;
13398
+ const changedConnection = entry ? changedTerrainConnection(spec, entry, lock) : null;
12125
13399
  const revision = await inspectRevisionReadiness(spec, lock);
12126
13400
  if (revision && !revision.ready) {
12127
13401
  state = "blocked";
@@ -12130,7 +13404,7 @@ async function buildPlan(specs, lock, opts = {}) {
12130
13404
  state = "missing";
12131
13405
  reason = "--force";
12132
13406
  } else if (!entry && spec.source && spec.generator !== "frames" && spec.generator !== "vfx") {
12133
- if ((0, import_node_fs11.existsSync)(import_node_path14.default.resolve(spec.root, spec.source))) {
13407
+ if ((0, import_node_fs12.existsSync)(import_node_path15.default.resolve(spec.root, spec.source))) {
12134
13408
  state = "ok";
12135
13409
  reason = `placed from ${spec.source}; not generated`;
12136
13410
  } else {
@@ -12138,7 +13412,7 @@ async function buildPlan(specs, lock, opts = {}) {
12138
13412
  reason = `declared source is missing: ${spec.source}`;
12139
13413
  }
12140
13414
  } else if (!entry) {
12141
- if ((0, import_node_fs11.existsSync)(spec.outFile)) {
13415
+ if ((0, import_node_fs12.existsSync)(spec.outFile)) {
12142
13416
  state = "untracked";
12143
13417
  reason = "file on disk, no provenance recorded";
12144
13418
  } else {
@@ -12151,12 +13425,15 @@ async function buildPlan(specs, lock, opts = {}) {
12151
13425
  } else if (spec.mirror && !mirrorCurrent(entry, lock.entries[lockKey(spec.styleId, spec.mirror.sourceAssetId)])) {
12152
13426
  state = "stale";
12153
13427
  reason = `mirror source ${spec.mirror.sourceAssetId} changed; flip it again (no generation cost)`;
12154
- } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !(0, import_node_fs11.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
13428
+ } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !(0, import_node_fs12.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
12155
13429
  state = "stale";
12156
13430
  reason = "mirror output missing on disk; flip it again (no generation cost)";
12157
13431
  } else if (spec.character?.kind === "outfit" && entry.outfit?.sourceSha256 !== mirrorSourceHash(lock.entries[lockKey(spec.styleId, spec.character.parentAssetId)] ?? { outputs: [] })) {
12158
13432
  state = "stale";
12159
13433
  reason = `outfit source ${spec.character.parentAssetId} changed; re-clothe the loop again`;
13434
+ } else if (changedConnection) {
13435
+ state = "stale";
13436
+ reason = `connected set ${changedConnection} was regenerated; draw this set from its new terrain`;
12160
13437
  } else if (entry.status === "download-failed") {
12161
13438
  state = "recoverable";
12162
13439
  const force = entry.error?.includes("pass --force") ? " --force" : "";
@@ -12179,9 +13456,9 @@ async function buildPlan(specs, lock, opts = {}) {
12179
13456
  } else if (entry.status !== "downloaded") {
12180
13457
  state = "in-flight";
12181
13458
  reason = `awaiting ${entry.status}`;
12182
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !(0, import_node_fs11.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
13459
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !(0, import_node_fs12.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
12183
13460
  state = "orphaned";
12184
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !(0, import_node_fs11.existsSync)(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
13461
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !(0, import_node_fs12.existsSync)(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
12185
13462
  } else if (await anyOutputModified(entry, spec)) {
12186
13463
  state = "orphaned";
12187
13464
  reason = "output modified since download";
@@ -12272,13 +13549,13 @@ function summarize(plan) {
12272
13549
 
12273
13550
  // src/project.ts
12274
13551
  async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {}) {
12275
- const absolute = import_node_path15.default.resolve(manifestPath);
12276
- const root = import_node_path15.default.dirname(absolute);
13552
+ const absolute = import_node_path16.default.resolve(manifestPath);
13553
+ const root = import_node_path16.default.dirname(absolute);
12277
13554
  if (opts.env !== false) {
12278
13555
  loadEnvFiles(root);
12279
- if (import_node_path15.default.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
13556
+ if (import_node_path16.default.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
12280
13557
  }
12281
- const lockPath = import_node_path15.default.resolve(opts.lockPath ?? import_node_path15.default.join(root, "pixelkiln.lock.json"));
13558
+ const lockPath = import_node_path16.default.resolve(opts.lockPath ?? import_node_path16.default.join(root, "pixelkiln.lock.json"));
12282
13559
  const loaded = await loadManifest(absolute);
12283
13560
  const specs = await resolveSpecs(loaded, { styles: opts.styles ?? [], assets: opts.assets ?? [] });
12284
13561
  const lock = await loadLock(lockPath);
@@ -12307,47 +13584,10 @@ async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {})
12307
13584
  };
12308
13585
  }
12309
13586
 
12310
- // src/source-url.ts
12311
- var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
12312
- "access_token",
12313
- "apikey",
12314
- "api_key",
12315
- "auth",
12316
- "authorization",
12317
- "awsaccesskeyid",
12318
- "googleaccessid",
12319
- "key",
12320
- "sig"
12321
- ]);
12322
- function isSensitiveSourceUrl(value) {
12323
- let parsed;
12324
- try {
12325
- parsed = new URL(value);
12326
- } catch {
12327
- return false;
12328
- }
12329
- if (parsed.username || parsed.password) return true;
12330
- for (const key of parsed.searchParams.keys()) {
12331
- const normalized = key.toLowerCase();
12332
- if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
12333
- }
12334
- return false;
12335
- }
12336
- function shouldPersistSourceUrl(value) {
12337
- let parsed;
12338
- try {
12339
- parsed = new URL(value);
12340
- } catch {
12341
- return true;
12342
- }
12343
- if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
12344
- return !isSensitiveSourceUrl(value);
12345
- }
12346
-
12347
13587
  // src/recipes.ts
12348
- var import_node_path16 = __toESM(require("path"), 1);
12349
- var import_node_fs12 = require("fs");
12350
- var import_promises8 = require("fs/promises");
13588
+ var import_node_path17 = __toESM(require("path"), 1);
13589
+ var import_node_fs13 = require("fs");
13590
+ var import_promises9 = require("fs/promises");
12351
13591
  var import_node_url = require("url");
12352
13592
  var import_zod4 = require("zod");
12353
13593
  var SHA256_RE = /^[0-9a-f]{64}$/;
@@ -12356,7 +13596,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
12356
13596
  var RECIPE_FILE = "pixelkiln.recipe.json";
12357
13597
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
12358
13598
  var PortablePathSchema = import_zod4.z.string().min(1).refine((value) => {
12359
- if (value.includes("\\") || import_node_path16.default.posix.isAbsolute(value)) return false;
13599
+ if (value.includes("\\") || import_node_path17.default.posix.isAbsolute(value)) return false;
12360
13600
  const parts = value.split("/");
12361
13601
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
12362
13602
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -12532,10 +13772,10 @@ function message3(error) {
12532
13772
  return error instanceof Error ? error.message : String(error);
12533
13773
  }
12534
13774
  async function readRecipeFile(recipePath, bundled) {
12535
- const absolute = import_node_path16.default.resolve(recipePath);
13775
+ const absolute = import_node_path17.default.resolve(recipePath);
12536
13776
  let raw;
12537
13777
  try {
12538
- raw = JSON.parse(await (0, import_promises8.readFile)(absolute, "utf8"));
13778
+ raw = JSON.parse(await (0, import_promises9.readFile)(absolute, "utf8"));
12539
13779
  } catch (error) {
12540
13780
  throw new Error(`Could not read recipe ${absolute}: ${message3(error)}`, { cause: error });
12541
13781
  }
@@ -12546,13 +13786,13 @@ async function readRecipeFile(recipePath, bundled) {
12546
13786
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
12547
13787
  );
12548
13788
  }
12549
- return { recipe: parsed.data, authored: raw, path: absolute, dir: import_node_path16.default.dirname(absolute), bundled };
13789
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: import_node_path17.default.dirname(absolute), bundled };
12550
13790
  }
12551
13791
  async function walkRecipeFiles(root) {
12552
- if (!(0, import_node_fs12.existsSync)(root)) return [];
13792
+ if (!(0, import_node_fs13.existsSync)(root)) return [];
12553
13793
  const out = [];
12554
- for (const entry of await (0, import_promises8.readdir)(root, { withFileTypes: true })) {
12555
- const resolved = import_node_path16.default.join(root, entry.name);
13794
+ for (const entry of await (0, import_promises9.readdir)(root, { withFileTypes: true })) {
13795
+ const resolved = import_node_path17.default.join(root, entry.name);
12556
13796
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
12557
13797
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
12558
13798
  }
@@ -12563,7 +13803,7 @@ function bundledRecipeRoot() {
12563
13803
  }
12564
13804
  async function listBundledRecipes(root = bundledRecipeRoot()) {
12565
13805
  const recipes = await Promise.all(
12566
- (await walkRecipeFiles(import_node_path16.default.resolve(root))).map((file) => readRecipeFile(file, true))
13806
+ (await walkRecipeFiles(import_node_path17.default.resolve(root))).map((file) => readRecipeFile(file, true))
12567
13807
  );
12568
13808
  return recipes.sort(
12569
13809
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -12586,9 +13826,9 @@ function parseSelector(selector) {
12586
13826
  return { id: match[1], version: match[2] };
12587
13827
  }
12588
13828
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12589
- const local = import_node_path16.default.resolve(target);
12590
- if ((0, import_node_fs12.existsSync)(local)) {
12591
- const recipePath = import_node_path16.default.basename(local) === RECIPE_FILE ? local : import_node_path16.default.join(local, RECIPE_FILE);
13829
+ const local = import_node_path17.default.resolve(target);
13830
+ if ((0, import_node_fs13.existsSync)(local)) {
13831
+ const recipePath = import_node_path17.default.basename(local) === RECIPE_FILE ? local : import_node_path17.default.join(local, RECIPE_FILE);
12592
13832
  return readRecipeFile(recipePath, false);
12593
13833
  }
12594
13834
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -12602,8 +13842,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
12602
13842
  return matches[0];
12603
13843
  }
12604
13844
  async function verifyFile(root, file) {
12605
- const absolute = import_node_path16.default.resolve(root, ...file.path.split("/"));
12606
- if (!(0, import_node_fs12.existsSync)(absolute)) {
13845
+ const absolute = import_node_path17.default.resolve(root, ...file.path.split("/"));
13846
+ if (!(0, import_node_fs13.existsSync)(absolute)) {
12607
13847
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
12608
13848
  }
12609
13849
  const actualSha256 = await sha256File(absolute);
@@ -12618,7 +13858,7 @@ async function verifyRecipe(target, options = {}) {
12618
13858
  const loaded = await resolveRecipe(target, options.bundledRoot);
12619
13859
  const actualIntegrity = recipeDigest(loaded.authored);
12620
13860
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
12621
- const modelRoot = options.modelRoot ? import_node_path16.default.resolve(options.modelRoot) : null;
13861
+ const modelRoot = options.modelRoot ? import_node_path17.default.resolve(options.modelRoot) : null;
12622
13862
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
12623
13863
  ...await verifyFile(modelRoot, model),
12624
13864
  source: model.source,
@@ -12652,7 +13892,7 @@ async function verifyRecipe(target, options = {}) {
12652
13892
  };
12653
13893
  }
12654
13894
  function renderStyle(style, destination, cwd) {
12655
- const portableDir = import_node_path16.default.relative(cwd, destination).split(import_node_path16.default.sep).join("/") || ".";
13895
+ const portableDir = import_node_path17.default.relative(cwd, destination).split(import_node_path17.default.sep).join("/") || ".";
12656
13896
  const replace = (value) => {
12657
13897
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
12658
13898
  if (Array.isArray(value)) return value.map(replace);
@@ -12664,25 +13904,25 @@ function renderStyle(style, destination, cwd) {
12664
13904
  return replace(style);
12665
13905
  }
12666
13906
  async function installRecipe(target, options = {}) {
12667
- const cwd = import_node_path16.default.resolve(options.cwd ?? process.cwd());
13907
+ const cwd = import_node_path17.default.resolve(options.cwd ?? process.cwd());
12668
13908
  const loaded = await resolveRecipe(target, options.bundledRoot);
12669
13909
  const verification = await verifyRecipe(loaded.path);
12670
13910
  if (!verification.ok) {
12671
13911
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
12672
13912
  }
12673
- const destination = options.out ? import_node_path16.default.resolve(cwd, options.out) : import_node_path16.default.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
13913
+ const destination = options.out ? import_node_path17.default.resolve(cwd, options.out) : import_node_path17.default.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
12674
13914
  const sources = [
12675
13915
  { path: RECIPE_FILE, source: loaded.path },
12676
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: import_node_path16.default.join(loaded.dir, ...file.path.split("/")) }))
13916
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: import_node_path17.default.join(loaded.dir, ...file.path.split("/")) }))
12677
13917
  ];
12678
13918
  const files = await Promise.all(sources.map(async (file) => ({
12679
- path: import_node_path16.default.join(destination, ...file.path.split("/")),
12680
- data: await (0, import_promises8.readFile)(file.source)
13919
+ path: import_node_path17.default.join(destination, ...file.path.split("/")),
13920
+ data: await (0, import_promises9.readFile)(file.source)
12681
13921
  })));
12682
13922
  if (!options.force) {
12683
13923
  for (const file of files) {
12684
- if (!(0, import_node_fs12.existsSync)(file.path)) continue;
12685
- const current = await (0, import_promises8.readFile)(file.path);
13924
+ if (!(0, import_node_fs13.existsSync)(file.path)) continue;
13925
+ const current = await (0, import_promises9.readFile)(file.path);
12686
13926
  if (!current.equals(file.data)) {
12687
13927
  throw new OverwriteRefusedError(
12688
13928
  `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
@@ -12706,9 +13946,9 @@ async function installRecipe(target, options = {}) {
12706
13946
  }
12707
13947
 
12708
13948
  // src/pipeline/audit.ts
12709
- var import_promises9 = require("fs/promises");
12710
- var import_node_fs13 = require("fs");
12711
- var import_node_path17 = __toESM(require("path"), 1);
13949
+ var import_promises10 = require("fs/promises");
13950
+ var import_node_fs14 = require("fs");
13951
+ var import_node_path18 = __toESM(require("path"), 1);
12712
13952
  function paletteDistance(asset, reference) {
12713
13953
  if (!asset.length || !reference.length) return 0;
12714
13954
  let total = 0;
@@ -12744,12 +13984,12 @@ async function auditStyle(loaded, specs, styleId, lock) {
12744
13984
  const unreadable = [];
12745
13985
  for (const spec of mine) {
12746
13986
  for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
12747
- if (!(0, import_node_fs13.existsSync)(output.absolutePath)) {
13987
+ if (!(0, import_node_fs14.existsSync)(output.absolutePath)) {
12748
13988
  missing.push(output.id);
12749
13989
  continue;
12750
13990
  }
12751
13991
  try {
12752
- const png = decodePng(await (0, import_promises9.readFile)(output.absolutePath));
13992
+ const png = decodePng(await (0, import_promises10.readFile)(output.absolutePath));
12753
13993
  const palette = extractPalette(png, 12);
12754
13994
  assets.push({
12755
13995
  assetId: spec.assetId,
@@ -12773,10 +14013,10 @@ async function auditStyle(loaded, specs, styleId, lock) {
12773
14013
  let referenceFromStyleImages = false;
12774
14014
  const refPalettes = [];
12775
14015
  for (const img of style.styleImages) {
12776
- const abs = import_node_path17.default.resolve(loaded.root, img.path);
12777
- if (!(0, import_node_fs13.existsSync)(abs)) continue;
14016
+ const abs = import_node_path18.default.resolve(loaded.root, img.path);
14017
+ if (!(0, import_node_fs14.existsSync)(abs)) continue;
12778
14018
  try {
12779
- refPalettes.push(extractPalette(decodePng(await (0, import_promises9.readFile)(abs)), 12));
14019
+ refPalettes.push(extractPalette(decodePng(await (0, import_promises10.readFile)(abs)), 12));
12780
14020
  } catch {
12781
14021
  }
12782
14022
  }
@@ -12851,144 +14091,6 @@ function hex(c) {
12851
14091
  return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
12852
14092
  }
12853
14093
 
12854
- // src/pixellab-utils.ts
12855
- var import_node_fs14 = require("fs");
12856
- var import_promises10 = require("fs/promises");
12857
- var import_node_path18 = __toESM(require("path"), 1);
12858
- var UNZOOM_MIN_SIDE = 256;
12859
- var UNZOOM_MAX_AREA = 2048 * 2048;
12860
- function defaultUnzoomOut(input) {
12861
- const ext = import_node_path18.default.extname(input);
12862
- return `${ext ? input.slice(0, -ext.length) : input}.unzoomed.png`;
12863
- }
12864
- async function unzoomFile(client2, input, opts = {}) {
12865
- const out = import_node_path18.default.resolve(opts.out ?? defaultUnzoomOut(input));
12866
- if (import_node_path18.default.resolve(input) === out) throw new Error("unzoom will not overwrite its own input; pass a different --out");
12867
- if ((0, import_node_fs14.existsSync)(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
12868
- const bytes = await (0, import_promises10.readFile)(input);
12869
- const metadata = imageMetadata(bytes);
12870
- if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
12871
- const { width, height, format } = metadata;
12872
- if (width < UNZOOM_MIN_SIDE || height < UNZOOM_MIN_SIDE) {
12873
- throw new Error(
12874
- `${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`
12875
- );
12876
- }
12877
- if (width * height > UNZOOM_MAX_AREA) {
12878
- throw new Error(`${input} is ${width}x${height}; PixelLab's unzoom takes at most 2048x2048 worth of pixels`);
12879
- }
12880
- const res = await client2.unzoom({ image: { base64: bytes.toString("base64"), format }, quantize: opts.quantize });
12881
- await (0, import_promises10.mkdir)(import_node_path18.default.dirname(out), { recursive: true });
12882
- await (0, import_promises10.writeFile)(out, res.png);
12883
- return { out, original: res.originalSize, unzoomed: res.unzoomedSize, zoomFactor: res.zoomFactor, usage: res.usage };
12884
- }
12885
- var FONT_WEIGHTS = ["Bold", "Regular"];
12886
- var FONT_GLYPH_SIZES = [8, 16, 32, 64];
12887
- var FONT_COST_GENERATIONS = 15;
12888
- function fontOutputPaths(out) {
12889
- const base = import_node_path18.default.resolve(out.replace(/\.(ttf|png)$/i, ""));
12890
- return { ttf: `${base}.ttf`, atlas: `${base}.png` };
12891
- }
12892
- async function generateFont(client2, opts) {
12893
- const { ttf, atlas } = fontOutputPaths(opts.out);
12894
- if (!opts.force) {
12895
- for (const file of [ttf, atlas]) {
12896
- if ((0, import_node_fs14.existsSync)(file)) throw new Error(`${file} already exists; pass --force to replace it`);
12897
- }
12898
- }
12899
- const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
12900
- const interval = opts.intervalMs ?? 5e3;
12901
- const timeout = opts.timeoutMs ?? 10 * 6e4;
12902
- const submitted = await client2.generateFontPro({
12903
- description: opts.description,
12904
- weight: opts.weight,
12905
- glyphPx: opts.glyphPx,
12906
- seed: opts.seed,
12907
- fontName: opts.fontName
12908
- });
12909
- const jobId = submitted.background_job_id;
12910
- const started = Date.now();
12911
- for (; ; ) {
12912
- const job = await client2.getFontJob(jobId);
12913
- opts.onProgress?.(job.status);
12914
- if (job.status === "failed") throw new Error(`PixelLab font job ${jobId} failed upstream`);
12915
- if (job.status === "completed") {
12916
- if (!job.download_ttf_url || !job.download_atlas_url) {
12917
- throw new Error(`PixelLab font job ${jobId} completed without both download URLs`);
12918
- }
12919
- const [ttfBytes, atlasBytes] = await Promise.all([
12920
- client2.download(job.download_ttf_url),
12921
- client2.download(job.download_atlas_url)
12922
- ]);
12923
- await (0, import_promises10.mkdir)(import_node_path18.default.dirname(ttf), { recursive: true });
12924
- await (0, import_promises10.writeFile)(ttf, ttfBytes);
12925
- await (0, import_promises10.writeFile)(atlas, atlasBytes);
12926
- return { jobId, ttf, atlas, glyphPx: job.glyph_px ?? null, usage: job.usage };
12927
- }
12928
- if (Date.now() - started > timeout) {
12929
- throw new Error(
12930
- `PixelLab font job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
12931
- );
12932
- }
12933
- await sleep3(interval);
12934
- }
12935
- }
12936
- var PIXELATE_COST_GENERATIONS = 6;
12937
- var PIXELATE_MAX_SIDE = 2048;
12938
- function defaultPixelateOut(input) {
12939
- const ext = import_node_path18.default.extname(input);
12940
- return `${ext ? input.slice(0, -ext.length) : input}.pixel.png`;
12941
- }
12942
- async function pixelateFile(client2, input, opts = {}) {
12943
- const out = import_node_path18.default.resolve(opts.out ?? defaultPixelateOut(input));
12944
- if (import_node_path18.default.resolve(input) === out) throw new Error("pixelate will not overwrite its own input; pass a different --out");
12945
- if ((0, import_node_fs14.existsSync)(out) && !opts.force) throw new Error(`${out} already exists; pass --force to replace it`);
12946
- const bytes = await (0, import_promises10.readFile)(input);
12947
- const metadata = imageMetadata(bytes);
12948
- if (!metadata) throw new Error(`${input} is not a readable PNG or JPEG`);
12949
- const { width, height, format } = metadata;
12950
- const sleep3 = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
12951
- const interval = opts.intervalMs ?? 5e3;
12952
- const timeout = opts.timeoutMs ?? 10 * 6e4;
12953
- const submitted = await client2.imageToPixelartProFlash({
12954
- image: { base64: bytes.toString("base64"), format },
12955
- description: opts.description,
12956
- seed: opts.seed
12957
- });
12958
- const jobId = submitted.background_job_id;
12959
- const started = Date.now();
12960
- for (; ; ) {
12961
- const job = await client2.getBackgroundJob(jobId);
12962
- opts.onProgress?.(job.status);
12963
- if (job.status === "failed") throw new Error(`PixelLab pixelate job ${jobId} failed upstream`);
12964
- if (job.status === "completed") {
12965
- const images = job.last_response?.images;
12966
- const first = Array.isArray(images) ? images[0] : void 0;
12967
- if (!first || typeof first.base64 !== "string" || !first.base64) {
12968
- throw new Error(`PixelLab pixelate job ${jobId} completed without an image at last_response.images[0]`);
12969
- }
12970
- const png = Buffer.from(first.base64, "base64");
12971
- const size = imageMetadata(png);
12972
- await (0, import_promises10.mkdir)(import_node_path18.default.dirname(out), { recursive: true });
12973
- await (0, import_promises10.writeFile)(out, png);
12974
- return {
12975
- jobId,
12976
- out,
12977
- original: { width, height },
12978
- pixelated: { width: size?.width ?? Number(first.width), height: size?.height ?? Number(first.height) },
12979
- imageId: submitted.image_id,
12980
- usage: job.usage ?? null
12981
- };
12982
- }
12983
- if (Date.now() - started > timeout) {
12984
- throw new Error(
12985
- `PixelLab pixelate job ${jobId} is still ${job.status} after ${Math.round(timeout / 1e3)} s; it keeps running upstream, but this command has stopped waiting`
12986
- );
12987
- }
12988
- await sleep3(interval);
12989
- }
12990
- }
12991
-
12992
14094
  // src/pipeline/quality-regression.ts
12993
14095
  var import_node_path19 = __toESM(require("path"), 1);
12994
14096
  var import_node_fs15 = require("fs");
@@ -13800,6 +14902,7 @@ function retireGeneration(entry, retiredAt) {
13800
14902
  billed: entry.billed,
13801
14903
  provider: entry.provider,
13802
14904
  postprocess: entry.postprocess,
14905
+ ...entry.terrainConnect ? { terrainConnect: entry.terrainConnect } : {},
13803
14906
  retiredAt
13804
14907
  };
13805
14908
  }
@@ -13891,6 +14994,9 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
13891
14994
  billed: generation.billed,
13892
14995
  provider: generation.provider,
13893
14996
  postprocess: generation.postprocess,
14997
+ // A connected set comes back with the connection it was drawn from, so
14998
+ // the plan compares the restored generation, not the one it replaced.
14999
+ terrainConnect: generation.terrainConnect,
13894
15000
  history
13895
15001
  });
13896
15002
  await saveLock(lockPath, lock);
@@ -14911,6 +16017,22 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14911
16017
  const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
14912
16018
  const history = historyAfterReplacing(previousEntry, historyLimit(loaded.manifest), submittedAt ?? (/* @__PURE__ */ new Date()).toISOString());
14913
16019
  const outfitParentEntry = spec.character?.kind === "outfit" && spec.character.parentSpec ? lock.entries[lockKey(spec.character.parentSpec.styleId, spec.character.parentSpec.assetId)] : void 0;
16020
+ const terrainLinks = {};
16021
+ const terrainConnect = {};
16022
+ for (const side of ["lower", "upper"]) {
16023
+ const link = spec.terrainConnect?.[side];
16024
+ if (!link) continue;
16025
+ const other = lock.entries[lockKey(link.spec.styleId, link.spec.assetId)];
16026
+ const objectId = other.objectId;
16027
+ const terrainIds = other.providerMetadata?.[provider.id]?.terrainIds;
16028
+ const terrainId = terrainIds?.[link.terrain];
16029
+ terrainLinks[side] = {
16030
+ objectId,
16031
+ terrain: link.terrain,
16032
+ ...typeof terrainId === "string" && terrainId ? { terrainId } : {}
16033
+ };
16034
+ terrainConnect[side] = { sourceAssetId: link.assetId, sourceObjectId: objectId };
16035
+ }
14914
16036
  upsert(lock, key, {
14915
16037
  styleId: spec.styleId,
14916
16038
  assetId: spec.assetId,
@@ -14940,6 +16062,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14940
16062
  ...spec.revision.skeletonTemplate ? { skeletonTemplate: spec.revision.skeletonTemplate } : {},
14941
16063
  ...spec.revision.description ? { description: spec.revision.description } : {},
14942
16064
  ...spec.revision.removalTask ? { removalTask: spec.revision.removalTask } : {},
16065
+ ...spec.revision.convention ? { convention: spec.revision.convention } : {},
14943
16066
  ...spec.revision.driftThreshold == null ? {} : { driftThreshold: spec.revision.driftThreshold },
14944
16067
  ...spec.revision.subjectDescription ? { subjectDescription: spec.revision.subjectDescription } : {},
14945
16068
  ...spec.revision.initialPose ? { initialPose: spec.revision.initialPose } : {},
@@ -14950,6 +16073,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14950
16073
  sourceSha256: mirrorSourceHash(outfitParentEntry),
14951
16074
  referenceSha256: spec.character.outfit.reference.sha256
14952
16075
  } : null,
16076
+ terrainConnect: spec.terrainConnect ? terrainConnect : void 0,
14953
16077
  status: "pending",
14954
16078
  jobId: previousJobId ?? null,
14955
16079
  ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
@@ -14996,6 +16120,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14996
16120
  ...parentEntry?.objectId ? { parentObjectId: parentEntry.objectId } : {},
14997
16121
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
14998
16122
  ...parentOutputs ? { parentOutputs } : {},
16123
+ ...spec.terrainConnect ? { terrainLinks } : {},
14999
16124
  ...replacedMetadata ? { replacedMetadata } : {},
15000
16125
  ...lastGenerationMetadata ? { lastGenerationMetadata } : {},
15001
16126
  ...previousMetadata ? { previousMetadata } : {},
@@ -16494,6 +17619,7 @@ var NewAssetSchema = import_zod8.z.object({
16494
17619
  /** A short description of the foreground, sent as PixelLab's text hint. */
16495
17620
  description: import_zod8.z.string().min(1).optional()
16496
17621
  }).strict(),
17622
+ import_zod8.z.object({ mode: import_zod8.z.literal("normal-map"), from: import_zod8.z.string().min(1), convention: NormalMapConventionSchema.optional() }).strict(),
16497
17623
  import_zod8.z.object({
16498
17624
  mode: import_zod8.z.enum(["animate", "animate-pixminimax"]),
16499
17625
  from: import_zod8.z.string().min(1),
@@ -19973,6 +21099,12 @@ async function workspaceStatus(ws, dir) {
19973
21099
  MapScenePlacementSchema,
19974
21100
  MapSceneSchema,
19975
21101
  MediaType,
21102
+ NORMAL_MAP_CONVENTIONS,
21103
+ NORMAL_MAP_COST_GENERATIONS,
21104
+ NORMAL_MAP_MAX_FRAMES,
21105
+ NORMAL_MAP_MAX_SIDE,
21106
+ NORMAL_MAP_MIN_SIDE,
21107
+ NormalMapConventionSchema,
19976
21108
  OverwriteRefusedError,
19977
21109
  PIXELATE_COST_GENERATIONS,
19978
21110
  PIXELATE_MAX_SIDE,
@@ -20000,12 +21132,17 @@ async function workspaceStatus(ws, dir) {
20000
21132
  ScenarioProvider,
20001
21133
  StyleInputSchema,
20002
21134
  StyleSchema,
21135
+ TerrainConnectSchema,
21136
+ TerrainLinkSchema,
21137
+ TerrainSideSchema,
20003
21138
  UNZOOM_MAX_AREA,
20004
21139
  UNZOOM_MIN_SIDE,
20005
21140
  UiElementSchema,
20006
21141
  UiPieceSchema,
20007
21142
  UnsupportedCapabilityError,
20008
21143
  UsageError,
21144
+ VFX_ARTISTS,
21145
+ VFX_ARTIST_MAX_SPRITE,
20009
21146
  VFX_TOOL,
20010
21147
  VfxPartSchema,
20011
21148
  WorkspaceProjectSchema,
@@ -20038,6 +21175,7 @@ async function workspaceStatus(ws, dir) {
20038
21175
  createProvider,
20039
21176
  currentEntryOutputPath,
20040
21177
  currentOutputPath,
21178
+ defaultNormalMapOut,
20041
21179
  defaultOpenCommand,
20042
21180
  defaultPixelateOut,
20043
21181
  defaultUnzoomOut,
@@ -20099,6 +21237,7 @@ async function workspaceStatus(ws, dir) {
20099
21237
  loadWorkspace,
20100
21238
  lockKey,
20101
21239
  makeVfx,
21240
+ makeVfxWithArtist,
20102
21241
  matchOrphanStyle,
20103
21242
  mcpClientFromEnv,
20104
21243
  measureBalanceChange,
@@ -20110,6 +21249,9 @@ async function workspaceStatus(ws, dir) {
20110
21249
  mergePalettes,
20111
21250
  mountSprites,
20112
21251
  mountStyle,
21252
+ normalMapFile,
21253
+ normalMapResultPng,
21254
+ normalMapSizeProblem,
20113
21255
  normalizeLockOutputPaths,
20114
21256
  normalizeRefinementPalette,
20115
21257
  normalizeTerrainTiles,
@@ -20127,6 +21269,7 @@ async function workspaceStatus(ws, dir) {
20127
21269
  parseMcpBody,
20128
21270
  parseProFlashQuote,
20129
21271
  parseTerrainDescriptions,
21272
+ parseVfxArtistResult,
20130
21273
  parseVfxExport,
20131
21274
  parseVfxRecipe,
20132
21275
  parseVfxStep,
@@ -20150,6 +21293,7 @@ async function workspaceStatus(ws, dir) {
20150
21293
  readHandEditCompanion,
20151
21294
  readQualityBaseline,
20152
21295
  readSavedVfxRecipe,
21296
+ readVfxConversation,
20153
21297
  readVfxRecipe,
20154
21298
  recipeDigest,
20155
21299
  referencedHashes,
@@ -20212,6 +21356,7 @@ async function workspaceStatus(ws, dir) {
20212
21356
  submit,
20213
21357
  summarize,
20214
21358
  tagAdopted,
21359
+ terrainCost,
20215
21360
  terrainTileCount,
20216
21361
  tileFeatureOutputCount,
20217
21362
  tileVariationCount,
@@ -20225,6 +21370,13 @@ async function workspaceStatus(ws, dir) {
20225
21370
  validateWorkspace,
20226
21371
  verifyArtifactBundle,
20227
21372
  verifyRecipe,
21373
+ vfxArtistCeiling,
21374
+ vfxArtistFiles,
21375
+ vfxArtistFrameFacts,
21376
+ vfxArtistQuote,
21377
+ vfxArtistSprite,
21378
+ vfxAtlasFrames,
21379
+ vfxJobFailure,
20228
21380
  vfxOutputPaths,
20229
21381
  vfxRecipeSha256,
20230
21382
  withArtifactManifest,