pixelkiln 0.64.0 → 0.65.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
@@ -40,6 +40,9 @@ __export(index_exports, {
40
40
  CharacterAnimationSchema: () => CharacterAnimationSchema,
41
41
  CharacterDirectionSchema: () => CharacterDirectionSchema,
42
42
  CharacterModeSchema: () => CharacterModeSchema,
43
+ CharacterOutfitSchema: () => CharacterOutfitSchema,
44
+ CharacterPortraitSchema: () => CharacterPortraitSchema,
45
+ CharacterPortraitSizeSchema: () => CharacterPortraitSizeSchema,
43
46
  CharacterProportionsSchema: () => CharacterProportionsSchema,
44
47
  CharacterStateSchema: () => CharacterStateSchema,
45
48
  ComfyUIProvider: () => ComfyUIProvider,
@@ -504,6 +507,27 @@ var CharacterStateSchema = import_zod.z.object({
504
507
  height: import_zod.z.number().int().min(16).max(256)
505
508
  }).strict().optional()
506
509
  }).strict();
510
+ var CharacterPortraitSizeSchema = import_zod.z.union([
511
+ import_zod.z.literal(16),
512
+ import_zod.z.literal(32),
513
+ import_zod.z.literal(48),
514
+ import_zod.z.literal(64),
515
+ import_zod.z.literal(128),
516
+ import_zod.z.literal(160)
517
+ ]);
518
+ var CharacterPortraitSchema = import_zod.z.object({
519
+ /** The base or state to portray, in the same style. */
520
+ of: import_zod.z.string().min(1),
521
+ size: CharacterPortraitSizeSchema.default(64)
522
+ }).strict();
523
+ var CharacterOutfitSchema = import_zod.z.object({
524
+ /** The loop (a character animation asset) to re-clothe, in the same style. */
525
+ of: import_zod.z.string().min(1),
526
+ /** Manifest-relative image of the outfit to transfer. */
527
+ reference: import_zod.z.string().min(1),
528
+ /** Extra guidance PixelLab passes through as written, e.g. view/direction hints. */
529
+ additionalInstructions: import_zod.z.string().max(2e3).optional()
530
+ }).strict();
507
531
  var CharacterAnimationSchema = import_zod.z.object({
508
532
  /** The character to animate: a base or a state in the same style. */
509
533
  of: import_zod.z.string().min(1),
@@ -1125,6 +1149,10 @@ var AssetSchema = import_zod.z.object({
1125
1149
  pieces: import_zod.z.array(UiPieceSchema).min(1).optional(),
1126
1150
  /** `uiAsset` styles: named, auto-positioned UI element scaffolds. Combine with `pieces`; omit both for a default full-canvas panel. */
1127
1151
  elements: import_zod.z.array(UiElementSchema).min(1).optional(),
1152
+ /** `character` styles: this asset is a bust portrait of another character asset's south sprite. */
1153
+ portrait: CharacterPortraitSchema.optional(),
1154
+ /** `character` styles: this asset re-clothes an existing loop's frames with a reference outfit. */
1155
+ outfit: CharacterOutfitSchema.optional(),
1128
1156
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
1129
1157
  proportions: CharacterProportionsSchema.optional(),
1130
1158
  /**
@@ -1201,7 +1229,7 @@ var AssetSchema = import_zod.z.object({
1201
1229
  path: ["revision"]
1202
1230
  });
1203
1231
  }
1204
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch"].filter(Boolean);
1232
+ const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch", asset.portrait && "portrait", asset.outfit && "outfit"].filter(Boolean);
1205
1233
  if (shapes.length > 1) {
1206
1234
  context.addIssue({
1207
1235
  code: import_zod.z.ZodIssueCode.custom,
@@ -1216,24 +1244,24 @@ var AssetSchema = import_zod.z.object({
1216
1244
  path: ["mirror"]
1217
1245
  });
1218
1246
  }
1219
- if (asset.prompt === void 0 && !asset.mirror) {
1247
+ if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit) {
1220
1248
  context.addIssue({
1221
1249
  code: import_zod.z.ZodIssueCode.custom,
1222
1250
  message: "Required",
1223
1251
  path: ["prompt"]
1224
1252
  });
1225
1253
  }
1226
- if (asset.reference && (asset.state || asset.animation || asset.mirror)) {
1254
+ if (asset.reference && (asset.state || asset.animation || asset.mirror || asset.portrait || asset.outfit)) {
1227
1255
  context.addIssue({
1228
1256
  code: import_zod.z.ZodIssueCode.custom,
1229
- message: "a reference sprite belongs on a base; a state, animation, or mirror takes its look from its parent",
1257
+ message: "a reference sprite belongs on a base; a state, animation, mirror, portrait, or outfit takes its look from its parent",
1230
1258
  path: ["reference"]
1231
1259
  });
1232
1260
  }
1233
- if (asset.concept && (asset.state || asset.animation || asset.mirror)) {
1261
+ if (asset.concept && (asset.state || asset.animation || asset.mirror || asset.portrait || asset.outfit)) {
1234
1262
  context.addIssue({
1235
1263
  code: import_zod.z.ZodIssueCode.custom,
1236
- message: "a concept image belongs on a base; a state, animation, or mirror takes its look from its parent",
1264
+ message: "a concept image belongs on a base; a state, animation, mirror, portrait, or outfit takes its look from its parent",
1237
1265
  path: ["concept"]
1238
1266
  });
1239
1267
  }
@@ -1358,6 +1386,17 @@ var LockEntrySchema = import_zod.z.object({
1358
1386
  leaderAssetId: import_zod.z.string().min(1).optional(),
1359
1387
  index: import_zod.z.number().int().min(1).optional()
1360
1388
  }).strict().nullable().default(null),
1389
+ /**
1390
+ * For an outfit: the loop it re-clothed, a hash over that loop's output
1391
+ * hashes when this was made, and the reference image's own hash. Tracked
1392
+ * the way `mirror` is, not through `specHash`: manifest resolution has no
1393
+ * lock to hash the parent's actual current frame bytes against.
1394
+ */
1395
+ outfit: import_zod.z.object({
1396
+ sourceAssetId: import_zod.z.string().min(1),
1397
+ sourceSha256: import_zod.z.string().regex(/^[0-9a-f]{64}$/),
1398
+ referenceSha256: import_zod.z.string().regex(/^[0-9a-f]{64}$/)
1399
+ }).strict().nullable().default(null),
1361
1400
  /**
1362
1401
  * Output hashes owned by the previous generation while its replacement is
1363
1402
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -1761,6 +1800,28 @@ var AnimateObjectSubmitSchema = import_zod2.z.object({
1761
1800
  submissions: import_zod2.z.array(DirectionSubmissionSchema),
1762
1801
  usage: UsageSchema
1763
1802
  }).passthrough();
1803
+ var PortraitSubmitSchema = import_zod2.z.object({
1804
+ background_job_id: import_zod2.z.string().min(1),
1805
+ status: import_zod2.z.string().default("processing"),
1806
+ usage: UsageSchema
1807
+ }).passthrough();
1808
+ var PortraitJobSchema = import_zod2.z.object({
1809
+ status: import_zod2.z.string(),
1810
+ download_url: import_zod2.z.string().nullable().optional(),
1811
+ width: import_zod2.z.number().int().nullable().optional(),
1812
+ height: import_zod2.z.number().int().nullable().optional()
1813
+ }).passthrough();
1814
+ var SetPortraitSchema = import_zod2.z.object({
1815
+ character_id: import_zod2.z.string().min(1),
1816
+ size: import_zod2.z.number().int().positive(),
1817
+ url: import_zod2.z.string().min(1),
1818
+ usage: UsageSchema
1819
+ }).passthrough();
1820
+ var TransferOutfitSubmitSchema = import_zod2.z.object({
1821
+ background_job_id: import_zod2.z.string().min(1),
1822
+ status: import_zod2.z.string().default("processing"),
1823
+ usage: UsageSchema
1824
+ }).passthrough();
1764
1825
  var CharacterAnimationDirectionSchema = import_zod2.z.object({
1765
1826
  direction: import_zod2.z.string(),
1766
1827
  frame_count: import_zod2.z.number().int().min(0),
@@ -2376,6 +2437,69 @@ var PixelLabClient = class {
2376
2437
  });
2377
2438
  return validateResponse(AnimateSubmitSchema, raw, "animate-character");
2378
2439
  }
2440
+ /**
2441
+ * A full-body sprite in, a bust portrait out (or the reverse, unused
2442
+ * today). Job-based like a state or `1dir`, not the couple-generations
2443
+ * "conversion" the name suggests: the smallest `resultSize` (16px) billed
2444
+ * 20 generations live (docs/ENDPOINTS.md, "Characters, measured").
2445
+ */
2446
+ async createPortraitCharacterPro(args) {
2447
+ const raw = await this.request("/portrait-character-pro", {
2448
+ method: "POST",
2449
+ body: JSON.stringify({
2450
+ direction: args.direction,
2451
+ image: { type: "base64", base64: args.image.base64, format: args.image.format },
2452
+ ...args.view ? { view: args.view } : {},
2453
+ ...args.resultSize != null ? { result_size: args.resultSize } : {},
2454
+ ...args.seed != null ? { seed: args.seed } : {}
2455
+ })
2456
+ });
2457
+ return validateResponse(PortraitSubmitSchema, raw, "portrait-character-pro");
2458
+ }
2459
+ /** Throws PixelLabError(423) while the job is still drawing, like tiles and map objects. */
2460
+ async getPortraitCharacterJob(jobId) {
2461
+ const raw = await this.request(`/portrait-character-pro/${encodeURIComponent(jobId)}`);
2462
+ return validateResponse(PortraitJobSchema, raw, "portrait-character-pro/{id}");
2463
+ }
2464
+ /**
2465
+ * Attaches a portrait image to a character record. Synchronous and free
2466
+ * (0 generations, live); PixelLab does not link this to
2467
+ * `createPortraitCharacterPro` automatically, and `GET /characters/{id}`
2468
+ * never grows a portrait field, so this response's `url` is the only place
2469
+ * it appears.
2470
+ */
2471
+ async setCharacterPortrait(characterId, image) {
2472
+ const raw = await this.request(`/characters/${encodeURIComponent(characterId)}/portrait`, {
2473
+ method: "POST",
2474
+ body: JSON.stringify({ image: { type: "base64", base64: image.base64, format: image.format } })
2475
+ });
2476
+ return validateResponse(SetPortraitSchema, raw, "characters/{id}/portrait");
2477
+ }
2478
+ /**
2479
+ * Applies a reference outfit to 2–16 existing frames. Job-based like
2480
+ * everything else here, but the completed job returns the result frames
2481
+ * inline as base64 (`last_response.quantized_images`), not as storage
2482
+ * URLs — the one PixelLab character tool that works this way. A live
2483
+ * 2-frame, 92x92 job billed 20 generations (docs/ENDPOINTS.md).
2484
+ */
2485
+ async transferOutfitV2(args) {
2486
+ const image = (img) => ({
2487
+ image: { type: "base64", base64: img.base64, format: img.format },
2488
+ size: { width: img.width, height: img.height }
2489
+ });
2490
+ const raw = await this.request("/transfer-outfit-v2", {
2491
+ method: "POST",
2492
+ body: JSON.stringify({
2493
+ reference_image: image(args.referenceImage),
2494
+ frames: args.frames.map(image),
2495
+ image_size: args.imageSize,
2496
+ ...args.seed != null ? { seed: args.seed } : {},
2497
+ ...args.noBackground !== void 0 ? { no_background: args.noBackground } : {},
2498
+ ...args.additionalInstructions ? { additional_instructions: args.additionalInstructions } : {}
2499
+ })
2500
+ });
2501
+ return validateResponse(TransferOutfitSubmitSchema, raw, "transfer-outfit-v2");
2502
+ }
2379
2503
  async getCharacter(characterId) {
2380
2504
  const raw = await this.request(`/characters/${encodeURIComponent(characterId)}`);
2381
2505
  return validateResponse(CharacterSchema, raw, "characters/{id}");
@@ -3392,7 +3516,16 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3392
3516
  styleTraits: spec.character.styleTraits,
3393
3517
  reference: spec.character.reference ? Object.fromEntries(Object.entries(spec.character.reference).map(([direction, image]) => [direction, image.sha256])) : void 0,
3394
3518
  concept: spec.character.concept?.sha256,
3395
- styleAnchor: spec.character.styleAnchor ? { of: spec.character.styleAnchor.assetId, sha256: spec.character.styleAnchor.sha256 } : void 0
3519
+ styleAnchor: spec.character.styleAnchor ? { of: spec.character.styleAnchor.assetId, sha256: spec.character.styleAnchor.sha256 } : void 0,
3520
+ // The parent's own bytes come from its recorded outputs; the plan
3521
+ // compares those directly (like a mirror), so only the choice of
3522
+ // parent, the reference image's bytes, and any extra guidance
3523
+ // are identity here.
3524
+ outfit: spec.character.outfit ? {
3525
+ of: spec.character.parentAssetId,
3526
+ reference: spec.character.outfit.reference.sha256,
3527
+ additionalInstructions: spec.character.outfit.additionalInstructions
3528
+ } : void 0
3396
3529
  } : void 0,
3397
3530
  // An objectPro family: same shape as character above, minus the
3398
3531
  // fields it has none of (mode/template/proportions/isometric/concept/
@@ -3473,7 +3606,10 @@ function characterCost(spec) {
3473
3606
  if (animation.mode === "v3") return Math.max(1, Math.ceil(px * animation.frames / 65536));
3474
3607
  return generationCost(spec.width, spec.height, "1dir");
3475
3608
  }
3476
- if (character.kind === "state") return generationCost(spec.width, spec.height, "1dir");
3609
+ if (character.kind === "state" || character.kind === "portrait") return generationCost(spec.width, spec.height, "1dir");
3610
+ if (character.kind === "outfit") {
3611
+ return 20;
3612
+ }
3477
3613
  if (character.mode === "standard") return 1;
3478
3614
  if (character.mode === "v3") return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
3479
3615
  if (character.mode === "pro-flash") {
@@ -3590,15 +3726,18 @@ var PixelLabProvider = class _PixelLabProvider {
3590
3726
  return { spacingMs: 2500, maxInFlight: 8 };
3591
3727
  }
3592
3728
  /**
3593
- * Where synchronous pixflux results are parked between `submit` and `fetch`.
3729
+ * Where synchronous pixflux results (or an outfit transfer's inline
3730
+ * frames) are parked between `submit`/poll and `fetch`.
3594
3731
  *
3595
- * pixflux returns the PNG inline rather than a job id, but the pipeline is
3596
- * built around submit → poll → fetch running as separate commands. Writing
3597
- * the bytes to a known path keeps that model intact: the "job id" is the
3598
- * filename, polling is an existence check, and downloading is a file read.
3732
+ * pixflux returns the PNG inline rather than a job id, and an outfit
3733
+ * transfer's completed job returns its frames inline too, but the
3734
+ * pipeline is built around submit → poll → fetch running as separate
3735
+ * commands. Writing the bytes to a known path keeps that model intact:
3736
+ * the id is the filename, polling is an existence check, and downloading
3737
+ * is a file read.
3599
3738
  */
3600
- static cacheDir() {
3601
- const dir = import_node_path.default.join(import_node_os.default.tmpdir(), "pixelkiln-pixflux");
3739
+ static cacheDir(namespace = "pixflux") {
3740
+ const dir = import_node_path.default.join(import_node_os.default.tmpdir(), `pixelkiln-${namespace}`);
3602
3741
  (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
3603
3742
  return dir;
3604
3743
  }
@@ -3858,6 +3997,12 @@ var PixelLabProvider = class _PixelLabProvider {
3858
3997
  const character = spec.character;
3859
3998
  if (!character) throw new Error(`${spec.styleId}/${spec.assetId} has no character shape`);
3860
3999
  const label = `${spec.styleId}/${spec.assetId}`;
4000
+ if (character.kind === "outfit") {
4001
+ const reference = character.outfit.reference;
4002
+ if (reference.width < 32 || reference.width > 256 || reference.height < 32 || reference.height > 256) {
4003
+ throw new Error(`${label}: outfit reference is ${reference.width}x${reference.height}; PixelLab transfer-outfit-v2 takes 32 to 256px per side`);
4004
+ }
4005
+ }
3861
4006
  if (character.kind === "base" && character.proportions !== void 0) {
3862
4007
  if (character.mode !== "standard") {
3863
4008
  throw new Error(`${label}: proportions apply to standard bases; the ${character.mode} engine has none to set`);
@@ -3869,7 +4014,7 @@ var PixelLabProvider = class _PixelLabProvider {
3869
4014
  if (character.kind === "base" && character.enhancePrompt !== void 0 && character.mode !== "v3") {
3870
4015
  throw new Error(`${label}: enhancePrompt applies to v3 bases; the ${character.mode} engine does not take it`);
3871
4016
  }
3872
- if (character.animation) {
4017
+ if (character.animation && character.kind !== "outfit") {
3873
4018
  const { startFrame, endFrame } = character.animation;
3874
4019
  for (const [name, image] of [["start frame", startFrame], ["end frame", endFrame]]) {
3875
4020
  if (image && (image.width > 256 || image.height > 256)) {
@@ -3950,9 +4095,11 @@ var PixelLabProvider = class _PixelLabProvider {
3950
4095
  if (character.styleTraits && character.mode !== "pro-flash") {
3951
4096
  throw new Error(`${label}: styleTraits choose what a pro-flash style image lends; the ${character.mode} engine does not take them`);
3952
4097
  }
3953
- const maxSize = character.mode === "v3" || character.mode === "pro-flash" ? 256 : 128;
3954
- if (spec.width < 16 || spec.height < 16 || spec.width > maxSize || spec.height > maxSize) {
3955
- throw new Error(`PixelLab ${character.mode} characters must be between 16 and ${maxSize} pixels`);
4098
+ if (character.kind !== "portrait") {
4099
+ const maxSize = character.mode === "v3" || character.mode === "pro-flash" ? 256 : 128;
4100
+ if (spec.width < 16 || spec.height < 16 || spec.width > maxSize || spec.height > maxSize) {
4101
+ throw new Error(`PixelLab ${character.mode} characters must be between 16 and ${maxSize} pixels`);
4102
+ }
3956
4103
  }
3957
4104
  if (character.mode === "pro-flash" && character.kind === "base" && (spec.width % 4 || spec.height % 4)) {
3958
4105
  throw new Error(`PixelLab pro-flash characters are sized in multiples of 4 pixels; ${spec.width}x${spec.height} is not`);
@@ -3961,9 +4108,11 @@ var PixelLabProvider = class _PixelLabProvider {
3961
4108
  if (!templates.includes(character.template)) {
3962
4109
  throw new Error(`PixelLab ${character.mode} character template must be one of: ${templates.join(", ")}`);
3963
4110
  }
3964
- const views = character.mode === "standard" ? ["low top-down", "high top-down", "side", "perspective", "oblique"] : ["low top-down", "high top-down", "side"];
4111
+ const views = character.kind === "portrait" ? ["low top-down", "high top-down", "side"] : character.mode === "standard" ? ["low top-down", "high top-down", "side", "perspective", "oblique"] : ["low top-down", "high top-down", "side"];
3965
4112
  if (!views.includes(spec.view)) {
3966
- throw new Error(`PixelLab ${character.mode} character view must be one of: ${views.join(", ")}`);
4113
+ throw new Error(
4114
+ character.kind === "portrait" ? `PixelLab portrait view must be one of: ${views.join(", ")}` : `PixelLab ${character.mode} character view must be one of: ${views.join(", ")}`
4115
+ );
3967
4116
  }
3968
4117
  if (spec.view === "oblique" && character.directions !== 4) {
3969
4118
  throw new Error("PixelLab oblique characters are 4-direction only");
@@ -3971,7 +4120,7 @@ var PixelLabProvider = class _PixelLabProvider {
3971
4120
  if (character.kind !== "base" && !character.parentAssetId) {
3972
4121
  throw new Error(`${spec.styleId}/${spec.assetId} is a ${character.kind} with no parent`);
3973
4122
  }
3974
- if (character.animation) {
4123
+ if (character.animation && character.kind !== "outfit") {
3975
4124
  const allowed = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
3976
4125
  if (!allowed.includes(character.animation.direction)) {
3977
4126
  throw new Error(
@@ -4077,6 +4226,43 @@ var PixelLabProvider = class _PixelLabProvider {
4077
4226
  });
4078
4227
  return { jobId: res2.character_id, metadata: { character: { kind: "base", characterId: res2.character_id, mode: character.mode, directions: character.directions, backgroundJobId: res2.background_job_id } } };
4079
4228
  }
4229
+ if (character.kind === "outfit") {
4230
+ const parentOutputs = context?.parentOutputs ?? [];
4231
+ if (parentOutputs.length < 2) {
4232
+ throw new Error(
4233
+ `${spec.styleId}/${spec.assetId} is an outfit of ${character.parentAssetId}, which has ${parentOutputs.length} downloaded frame(s); PixelLab transfer-outfit-v2 needs 2 to 16`
4234
+ );
4235
+ }
4236
+ const frames = parentOutputs.slice(0, 16).map((output) => {
4237
+ const bytes = (0, import_node_fs2.readFileSync)(output.path);
4238
+ const dims = imageMetadata(bytes);
4239
+ if (!dims) throw new Error(`${spec.styleId}/${spec.assetId}: outfit source frame is not a readable PNG: ${output.path}`);
4240
+ return { base64: bytes.toString("base64"), format: "png", width: dims.width, height: dims.height };
4241
+ });
4242
+ const outfit = character.outfit;
4243
+ const referenceBytes = (0, import_node_fs2.readFileSync)(outfit.reference.path);
4244
+ if (sha256(referenceBytes) !== outfit.reference.sha256) {
4245
+ throw new Error(`${spec.styleId}/${spec.assetId}: outfit reference changed after the manifest was resolved: ${outfit.reference.path}`);
4246
+ }
4247
+ const res2 = await this.client.transferOutfitV2({
4248
+ referenceImage: { base64: referenceBytes.toString("base64"), format: "png", width: outfit.reference.width, height: outfit.reference.height },
4249
+ frames,
4250
+ imageSize: { width: spec.width, height: spec.height },
4251
+ seed: spec.seed,
4252
+ noBackground: spec.noBackground,
4253
+ additionalInstructions: outfit.additionalInstructions
4254
+ });
4255
+ return {
4256
+ jobId: res2.background_job_id,
4257
+ metadata: {
4258
+ character: {
4259
+ kind: "outfit",
4260
+ backgroundJobId: res2.background_job_id,
4261
+ frameRoles: parentOutputs.slice(0, 16).map((output) => output.role ?? null)
4262
+ }
4263
+ }
4264
+ };
4265
+ }
4080
4266
  const parentId = context?.parentObjectId;
4081
4267
  if (!parentId) {
4082
4268
  throw new Error(
@@ -4095,6 +4281,24 @@ var PixelLabProvider = class _PixelLabProvider {
4095
4281
  });
4096
4282
  return { jobId: res2.character_id, metadata: { character: { kind: "state", characterId: res2.character_id, parentCharacterId: parentId, mode: character.mode, directions: character.directions, backgroundJobId: res2.background_job_id } } };
4097
4283
  }
4284
+ if (character.kind === "portrait") {
4285
+ const parentFile = character.parentFile;
4286
+ const bytes = (0, import_node_fs2.readFileSync)(parentFile);
4287
+ if (character.parentSha256 && sha256(bytes) !== character.parentSha256) {
4288
+ throw new Error(`${spec.styleId}/${spec.assetId}: portrait source changed after the manifest was resolved: ${parentFile}`);
4289
+ }
4290
+ const res2 = await this.client.createPortraitCharacterPro({
4291
+ direction: "character_to_portrait",
4292
+ image: { base64: bytes.toString("base64"), format: "png" },
4293
+ view: spec.view,
4294
+ resultSize: character.portrait.size,
4295
+ seed: spec.seed
4296
+ });
4297
+ return {
4298
+ jobId: res2.background_job_id,
4299
+ metadata: { character: { kind: "portrait", parentCharacterId: parentId, backgroundJobId: res2.background_job_id } }
4300
+ };
4301
+ }
4098
4302
  const animation = character.animation;
4099
4303
  const name = characterAnimationName(spec);
4100
4304
  const replaced = context?.replacedMetadata?.character ?? {};
@@ -4208,6 +4412,9 @@ var PixelLabProvider = class _PixelLabProvider {
4208
4412
  async pollCharacter(jobId, context) {
4209
4413
  const animation = decodeAnimationJob(jobId);
4210
4414
  if (animation) return this.pollCharacterAnimation(animation, context);
4415
+ const kind = context?.metadata?.character?.kind ?? context?.spec?.character?.kind;
4416
+ if (kind === "portrait") return this.pollCharacterPortrait(jobId, context);
4417
+ if (kind === "outfit") return this.pollCharacterOutfit(jobId, context);
4211
4418
  const character = await this.client.getCharacter(jobId);
4212
4419
  if (character.status === "failed") return { status: "failed", error: "character generation failed upstream" };
4213
4420
  if (character.status !== "completed" || !character.rotation_urls) return { status: "processing" };
@@ -4233,6 +4440,66 @@ var PixelLabProvider = class _PixelLabProvider {
4233
4440
  billed: await this.billedForJob(backgroundJobId)
4234
4441
  };
4235
4442
  }
4443
+ /**
4444
+ * `jobId` here is `portrait-character-pro`'s own background job id, not a
4445
+ * character id: the endpoint converts an arbitrary image and never touches
4446
+ * the character record. Once the portrait itself is drawn, this also
4447
+ * attaches it to the parent's character record with `SetPortrait` — a
4448
+ * separate, free call PixelLab does not make automatically (docs/PIXELLAB.md).
4449
+ */
4450
+ async pollCharacterPortrait(jobId, context) {
4451
+ const meta = context?.metadata?.character;
4452
+ try {
4453
+ const job = await this.client.getPortraitCharacterJob(jobId);
4454
+ if (job.status === "failed") return { status: "failed", error: "portrait generation failed upstream" };
4455
+ if (job.status !== "completed" || !job.download_url) return { status: "processing" };
4456
+ if (meta?.parentCharacterId) {
4457
+ const bytes = await this.client.download(job.download_url);
4458
+ await this.client.setCharacterPortrait(meta.parentCharacterId, { base64: bytes.toString("base64"), format: "png" });
4459
+ }
4460
+ return {
4461
+ status: "ready",
4462
+ objectId: jobId,
4463
+ sourceUrl: job.download_url,
4464
+ sources: [{ url: job.download_url }],
4465
+ billed: await this.billedForJob(meta?.backgroundJobId)
4466
+ };
4467
+ } catch (err) {
4468
+ if (err instanceof PixelLabError && err.status === 423) return { status: "processing" };
4469
+ throw err;
4470
+ }
4471
+ }
4472
+ /**
4473
+ * No dedicated status endpoint exists for `transfer-outfit-v2`, unlike
4474
+ * portraits and tiles: the background job is the only place to poll. A
4475
+ * completed job returns its frames inline as base64
4476
+ * (`last_response.quantized_images`), not as storage URLs, so they are
4477
+ * cached locally the way a synchronous pixflux result is, and matched back
4478
+ * to the source frames' own roles recorded at submit time.
4479
+ */
4480
+ async pollCharacterOutfit(jobId, context) {
4481
+ const meta = context?.metadata?.character;
4482
+ const job = await this.client.getBackgroundJob(jobId);
4483
+ if (job.status === "failed") return { status: "failed", error: "outfit transfer failed upstream" };
4484
+ if (job.status !== "completed") return { status: "processing" };
4485
+ const images = job.last_response?.quantized_images;
4486
+ if (!images?.length) return { status: "failed", error: "outfit transfer completed with no result frames" };
4487
+ const dir = _PixelLabProvider.cacheDir("outfit");
4488
+ const sources = images.map((image, index) => {
4489
+ if (!image.base64) throw new Error(`outfit transfer frame ${index} has no image data`);
4490
+ const role = meta?.frameRoles?.[index] ?? void 0;
4491
+ const file = import_node_path.default.join(dir, `${jobId}-${index}.png`);
4492
+ (0, import_node_fs2.writeFileSync)(file, Buffer.from(image.base64, "base64"));
4493
+ return { url: `file://${file}`, ...role ? { role } : {} };
4494
+ });
4495
+ return {
4496
+ status: "ready",
4497
+ objectId: jobId,
4498
+ sourceUrl: sources[0].url,
4499
+ sources,
4500
+ billed: await this.billedForJob(meta?.backgroundJobId)
4501
+ };
4502
+ }
4236
4503
  /**
4237
4504
  * The background job is the authoritative record of one direction: it
4238
4505
  * completes with the frame URLs, the animation id, and the direction, and
@@ -7831,7 +8098,7 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
7831
8098
  return { manifest: parsed.data, root: import_node_path5.default.dirname(abs), path: abs, ...loopFamilies };
7832
8099
  }
7833
8100
  function parentAssetId(asset) {
7834
- return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
8101
+ return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of ?? asset?.portrait?.of ?? asset?.outfit?.of;
7835
8102
  }
7836
8103
  function formatManifestIssues(issues) {
7837
8104
  return issues.flatMap((issue) => {
@@ -7998,9 +8265,9 @@ async function resolveSpecs(loaded, filter) {
7998
8265
  height = styleImageDimensions.length ? Math.max(...styleImageDimensions.map((img) => img.height)) : style.tileSize ?? 32;
7999
8266
  size = Math.max(width, height);
8000
8267
  } else if (generator === "character") {
8001
- size = asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
8002
- width = asset.state?.canvas?.width ?? size;
8003
- height = asset.state?.canvas?.height ?? size;
8268
+ size = asset.portrait ? asset.portrait.size : asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
8269
+ width = asset.portrait?.size ?? asset.state?.canvas?.width ?? size;
8270
+ height = asset.portrait?.size ?? asset.state?.canvas?.height ?? size;
8004
8271
  } else if (generator === "terrain") {
8005
8272
  size = style.terrainTileSize ?? 16;
8006
8273
  width = size;
@@ -8027,12 +8294,17 @@ async function resolveSpecs(loaded, filter) {
8027
8294
  `assets.${assetId}: ${asset.state ? "state" : "animation"} needs a character or objectPro style; "${styleId}" generates ${generator}`
8028
8295
  );
8029
8296
  }
8297
+ if ((asset.portrait || asset.outfit) && generator !== "character") {
8298
+ throw new Error(
8299
+ `assets.${assetId}: ${asset.portrait ? "portrait" : "outfit"} needs a character style; "${styleId}" generates ${generator}`
8300
+ );
8301
+ }
8030
8302
  if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
8031
8303
  throw new Error(
8032
8304
  `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
8033
8305
  );
8034
8306
  }
8035
- const characterKind = asset.animation ? "animation" : asset.state ? "state" : "base";
8307
+ const characterKind = asset.animation ? "animation" : asset.state ? "state" : asset.portrait ? "portrait" : "base";
8036
8308
  const subject = asset.promptByStyle[styleId] ?? asset.prompt ?? "";
8037
8309
  let terrainLowerDescription;
8038
8310
  let terrainUpperDescription;
@@ -8124,7 +8396,9 @@ async function resolveSpecs(loaded, filter) {
8124
8396
  // its parent's pixels, not a new character: giving it a base shape
8125
8397
  // would have packing, the gallery, and adoption treat it as one.
8126
8398
  ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
8127
- ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
8399
+ // A portrait needs a character style (refused above), so an
8400
+ // objectPro asset is only ever a base, state, or animation.
8401
+ ...generator === "objectPro" && !asset.revision && characterKind !== "portrait" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
8128
8402
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
8129
8403
  costUnit: "generations",
8130
8404
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -8235,7 +8509,7 @@ async function resolveSpecs(loaded, filter) {
8235
8509
  styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: (0, import_node_fs5.existsSync)(file) ? await sha256File(file) : null }
8236
8510
  };
8237
8511
  }
8238
- const characterParent = asset.state?.of ?? asset.animation?.of;
8512
+ const characterParent = asset.state?.of ?? asset.animation?.of ?? asset.portrait?.of;
8239
8513
  if (resolved.character && characterParent) {
8240
8514
  const parentSpec = await finalize(characterParent);
8241
8515
  const parentDirections = parentSpec.character?.directions ?? 8;
@@ -8268,6 +8542,38 @@ async function resolveSpecs(loaded, filter) {
8268
8542
  directions: resolved.objectPro.kind === "base" ? resolved.objectPro.directions : parentSpec.objectPro.directions
8269
8543
  };
8270
8544
  }
8545
+ if (asset.outfit) {
8546
+ if (asset.outfit.of === assetId) throw new Error(`assets.${assetId}: an asset cannot re-clothe itself`);
8547
+ const sourceSpec = await finalize(asset.outfit.of);
8548
+ if (sourceSpec.character?.kind !== "animation") {
8549
+ throw new Error(`assets.${assetId}: outfit "${asset.outfit.of}" is not a character loop`);
8550
+ }
8551
+ const referenceImage = await loadStyleImage(asset.outfit.reference, "Outfit reference image");
8552
+ resolved.character = {
8553
+ kind: "outfit",
8554
+ mode: sourceSpec.character.mode,
8555
+ directions: sourceSpec.character.directions,
8556
+ template: sourceSpec.character.template,
8557
+ parentAssetId: asset.outfit.of,
8558
+ parentSpec: sourceSpec,
8559
+ animation: sourceSpec.character.animation,
8560
+ outfit: {
8561
+ reference: {
8562
+ path: import_node_path5.default.resolve(root, asset.outfit.reference),
8563
+ sha256: referenceImage.hash,
8564
+ width: referenceImage.width,
8565
+ height: referenceImage.height,
8566
+ format: referenceImage.format
8567
+ },
8568
+ ...asset.outfit.additionalInstructions ? { additionalInstructions: asset.outfit.additionalInstructions } : {}
8569
+ }
8570
+ };
8571
+ resolved.generator = sourceSpec.generator;
8572
+ resolved.width = sourceSpec.width;
8573
+ resolved.height = sourceSpec.height;
8574
+ resolved.size = sourceSpec.size;
8575
+ resolved.prompt = "";
8576
+ }
8271
8577
  if (asset.revision) {
8272
8578
  if (!activeProvider.supportsRevision?.(asset.revision.mode)) {
8273
8579
  throw new Error(
@@ -8541,6 +8847,9 @@ async function resolveCharacterShape(asset, style, kind, files) {
8541
8847
  ...asset.state.canvas ? { canvas: asset.state.canvas } : {}
8542
8848
  };
8543
8849
  }
8850
+ if (asset.portrait) {
8851
+ shape.portrait = { size: asset.portrait.size };
8852
+ }
8544
8853
  if (asset.animation) {
8545
8854
  const animationMode = asset.animation.mode ?? (asset.animation.template ? "template" : "v3");
8546
8855
  const pose = async (rel, what) => {
@@ -10093,6 +10402,7 @@ var import_node_fs8 = require("fs");
10093
10402
  var import_node_path11 = __toESM(require("path"), 1);
10094
10403
  async function inspectRevisionReadiness(spec, lock) {
10095
10404
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
10405
+ if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
10096
10406
  if (spec.character?.parentSpec) return inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
10097
10407
  if (spec.character?.styleAnchor) return inspectStyleAnchor(spec, lock, /* @__PURE__ */ new Set());
10098
10408
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
@@ -10119,6 +10429,20 @@ async function inspectMirror(spec, lock, seen) {
10119
10429
  const dependency = await inspectParent(source, lock, seen);
10120
10430
  return dependency.ready ? { ready: true, reason: `${label} is current` } : { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
10121
10431
  }
10432
+ async function inspectOutfit(spec, lock, seen) {
10433
+ const character = spec.character;
10434
+ const outfit = character.outfit;
10435
+ const label = `outfit source ${spec.styleId}/${character.parentAssetId}`;
10436
+ const dependency = await inspectParent(character.parentSpec, lock, seen);
10437
+ if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
10438
+ if (!(0, import_node_fs8.existsSync)(outfit.reference.path)) {
10439
+ return { ready: false, reason: `outfit reference is missing: ${outfit.reference.path}` };
10440
+ }
10441
+ if (await sha256File(outfit.reference.path) !== outfit.reference.sha256) {
10442
+ return { ready: false, reason: "outfit reference changed after the manifest was resolved" };
10443
+ }
10444
+ return { ready: true, reason: `${label} is current` };
10445
+ }
10122
10446
  async function inspectCharacterParent(spec, lock, seen) {
10123
10447
  const character = spec.character;
10124
10448
  const parentSpec = character.parentSpec;
@@ -10234,7 +10558,10 @@ async function inspectParent(spec, lock, seen) {
10234
10558
  const inputs = await inspectRevision(spec, lock, nextSeen);
10235
10559
  if (!inputs.ready) return inputs;
10236
10560
  }
10237
- if (spec.character?.parentSpec) {
10561
+ if (spec.character?.kind === "outfit") {
10562
+ const inputs = await inspectOutfit(spec, lock, nextSeen);
10563
+ if (!inputs.ready) return inputs;
10564
+ } else if (spec.character?.parentSpec) {
10238
10565
  const inputs = await inspectCharacterParent(spec, lock, nextSeen);
10239
10566
  if (!inputs.ready) return inputs;
10240
10567
  }
@@ -10357,6 +10684,9 @@ async function buildPlan(specs, lock, opts = {}) {
10357
10684
  } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !(0, import_node_fs9.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
10358
10685
  state = "stale";
10359
10686
  reason = "mirror output missing on disk; flip it again (no generation cost)";
10687
+ } else if (spec.character?.kind === "outfit" && entry.outfit?.sourceSha256 !== mirrorSourceHash(lock.entries[lockKey(spec.styleId, spec.character.parentAssetId)] ?? { outputs: [] })) {
10688
+ state = "stale";
10689
+ reason = `outfit source ${spec.character.parentAssetId} changed; re-clothe the loop again`;
10360
10690
  } else if (entry.status === "download-failed") {
10361
10691
  state = "recoverable";
10362
10692
  const force = entry.error?.includes("pass --force") ? " --force" : "";
@@ -13054,6 +13384,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13054
13384
  const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
13055
13385
  const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
13056
13386
  const history = historyAfterReplacing(previousEntry, historyLimit(loaded.manifest), submittedAt ?? (/* @__PURE__ */ new Date()).toISOString());
13387
+ const outfitParentEntry = spec.character?.kind === "outfit" && spec.character.parentSpec ? lock.entries[lockKey(spec.character.parentSpec.styleId, spec.character.parentSpec.assetId)] : void 0;
13057
13388
  upsert(lock, key, {
13058
13389
  styleId: spec.styleId,
13059
13390
  assetId: spec.assetId,
@@ -13079,6 +13410,11 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13079
13410
  ...spec.revision.direction ? { direction: spec.revision.direction } : {},
13080
13411
  ...spec.revision.enhancePrompt == null ? {} : { enhancePrompt: spec.revision.enhancePrompt }
13081
13412
  } : null,
13413
+ outfit: spec.character?.kind === "outfit" && outfitParentEntry ? {
13414
+ sourceAssetId: spec.character.parentAssetId,
13415
+ sourceSha256: mirrorSourceHash(outfitParentEntry),
13416
+ referenceSha256: spec.character.outfit.reference.sha256
13417
+ } : null,
13082
13418
  status: "pending",
13083
13419
  jobId: previousJobId ?? null,
13084
13420
  ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
@@ -13112,10 +13448,15 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
13112
13448
  const anchor = spec.character?.styleAnchor;
13113
13449
  const anchorEntry = anchor ? lock.entries[lockKey(anchor.spec.styleId, anchor.spec.assetId)] : void 0;
13114
13450
  const replacedMetadata = previousEntry?.providerMetadata?.[provider.id];
13451
+ const parentOutputs = spec.character?.kind === "outfit" && parentEntry ? parentEntry.outputs.map((output, index) => ({
13452
+ path: currentEntryOutputPath(parentEntry, spec.character.parentSpec, index),
13453
+ role: output.role
13454
+ })) : void 0;
13115
13455
  const { jobId, metadata } = await provider.submit(spec, refs, {
13116
13456
  ...previousJobId ? { previousJobId } : {},
13117
13457
  ...parentEntry?.objectId ? { parentObjectId: parentEntry.objectId } : {},
13118
13458
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
13459
+ ...parentOutputs ? { parentOutputs } : {},
13119
13460
  ...replacedMetadata ? { replacedMetadata } : {},
13120
13461
  ...previousMetadata ? { previousMetadata } : {},
13121
13462
  checkpoint: async (checkpoint) => {
@@ -15234,7 +15575,7 @@ function describeCharacter(spec, entry) {
15234
15575
  };
15235
15576
  }
15236
15577
  if (entry?.generator === "character") {
15237
- const kind = recordedCharacter?.kind === "animation" || entry.outputs.some((o) => o.role?.startsWith("frame-")) ? "animation" : "base";
15578
+ const kind = recordedCharacter?.kind === "portrait" ? "portrait" : recordedCharacter?.kind === "outfit" ? "outfit" : recordedCharacter?.kind === "animation" || entry.outputs.some((o) => o.role?.startsWith("frame-")) ? "animation" : "base";
15238
15579
  return {
15239
15580
  generator: "character",
15240
15581
  kind,
@@ -15595,7 +15936,9 @@ async function buildGallerySnapshot(opts) {
15595
15936
  characters: style?.generator === "character" || style?.generator === "objectPro" ? {
15596
15937
  bases: styleItems.filter((item) => item.character?.kind === "base").length,
15597
15938
  states: styleItems.filter((item) => item.character?.kind === "state").length,
15598
- animations: styleItems.filter((item) => item.character?.kind === "animation").length
15939
+ animations: styleItems.filter((item) => item.character?.kind === "animation").length,
15940
+ portraits: styleItems.filter((item) => item.character?.kind === "portrait").length,
15941
+ outfits: styleItems.filter((item) => item.character?.kind === "outfit").length
15599
15942
  } : null,
15600
15943
  tags: style?.tags ?? [],
15601
15944
  extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
@@ -17390,6 +17733,9 @@ async function workspaceStatus(ws, dir) {
17390
17733
  CharacterAnimationSchema,
17391
17734
  CharacterDirectionSchema,
17392
17735
  CharacterModeSchema,
17736
+ CharacterOutfitSchema,
17737
+ CharacterPortraitSchema,
17738
+ CharacterPortraitSizeSchema,
17393
17739
  CharacterProportionsSchema,
17394
17740
  CharacterStateSchema,
17395
17741
  ComfyUIProvider,