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/cli.js CHANGED
@@ -705,7 +705,16 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
705
705
  styleTraits: spec.character.styleTraits,
706
706
  reference: spec.character.reference ? Object.fromEntries(Object.entries(spec.character.reference).map(([direction, image]) => [direction, image.sha256])) : void 0,
707
707
  concept: spec.character.concept?.sha256,
708
- styleAnchor: spec.character.styleAnchor ? { of: spec.character.styleAnchor.assetId, sha256: spec.character.styleAnchor.sha256 } : void 0
708
+ styleAnchor: spec.character.styleAnchor ? { of: spec.character.styleAnchor.assetId, sha256: spec.character.styleAnchor.sha256 } : void 0,
709
+ // The parent's own bytes come from its recorded outputs; the plan
710
+ // compares those directly (like a mirror), so only the choice of
711
+ // parent, the reference image's bytes, and any extra guidance
712
+ // are identity here.
713
+ outfit: spec.character.outfit ? {
714
+ of: spec.character.parentAssetId,
715
+ reference: spec.character.outfit.reference.sha256,
716
+ additionalInstructions: spec.character.outfit.additionalInstructions
717
+ } : void 0
709
718
  } : void 0,
710
719
  // An objectPro family: same shape as character above, minus the
711
720
  // fields it has none of (mode/template/proportions/isometric/concept/
@@ -949,6 +958,27 @@ var CharacterStateSchema = z.object({
949
958
  height: z.number().int().min(16).max(256)
950
959
  }).strict().optional()
951
960
  }).strict();
961
+ var CharacterPortraitSizeSchema = z.union([
962
+ z.literal(16),
963
+ z.literal(32),
964
+ z.literal(48),
965
+ z.literal(64),
966
+ z.literal(128),
967
+ z.literal(160)
968
+ ]);
969
+ var CharacterPortraitSchema = z.object({
970
+ /** The base or state to portray, in the same style. */
971
+ of: z.string().min(1),
972
+ size: CharacterPortraitSizeSchema.default(64)
973
+ }).strict();
974
+ var CharacterOutfitSchema = z.object({
975
+ /** The loop (a character animation asset) to re-clothe, in the same style. */
976
+ of: z.string().min(1),
977
+ /** Manifest-relative image of the outfit to transfer. */
978
+ reference: z.string().min(1),
979
+ /** Extra guidance PixelLab passes through as written, e.g. view/direction hints. */
980
+ additionalInstructions: z.string().max(2e3).optional()
981
+ }).strict();
952
982
  var CharacterAnimationSchema = z.object({
953
983
  /** The character to animate: a base or a state in the same style. */
954
984
  of: z.string().min(1),
@@ -1570,6 +1600,10 @@ var AssetSchema = z.object({
1570
1600
  pieces: z.array(UiPieceSchema).min(1).optional(),
1571
1601
  /** `uiAsset` styles: named, auto-positioned UI element scaffolds. Combine with `pieces`; omit both for a default full-canvas panel. */
1572
1602
  elements: z.array(UiElementSchema).min(1).optional(),
1603
+ /** `character` styles: this asset is a bust portrait of another character asset's south sprite. */
1604
+ portrait: CharacterPortraitSchema.optional(),
1605
+ /** `character` styles: this asset re-clothes an existing loop's frames with a reference outfit. */
1606
+ outfit: CharacterOutfitSchema.optional(),
1573
1607
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
1574
1608
  proportions: CharacterProportionsSchema.optional(),
1575
1609
  /**
@@ -1646,7 +1680,7 @@ var AssetSchema = z.object({
1646
1680
  path: ["revision"]
1647
1681
  });
1648
1682
  }
1649
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch"].filter(Boolean);
1683
+ 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);
1650
1684
  if (shapes.length > 1) {
1651
1685
  context.addIssue({
1652
1686
  code: z.ZodIssueCode.custom,
@@ -1661,24 +1695,24 @@ var AssetSchema = z.object({
1661
1695
  path: ["mirror"]
1662
1696
  });
1663
1697
  }
1664
- if (asset.prompt === void 0 && !asset.mirror) {
1698
+ if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit) {
1665
1699
  context.addIssue({
1666
1700
  code: z.ZodIssueCode.custom,
1667
1701
  message: "Required",
1668
1702
  path: ["prompt"]
1669
1703
  });
1670
1704
  }
1671
- if (asset.reference && (asset.state || asset.animation || asset.mirror)) {
1705
+ if (asset.reference && (asset.state || asset.animation || asset.mirror || asset.portrait || asset.outfit)) {
1672
1706
  context.addIssue({
1673
1707
  code: z.ZodIssueCode.custom,
1674
- message: "a reference sprite belongs on a base; a state, animation, or mirror takes its look from its parent",
1708
+ message: "a reference sprite belongs on a base; a state, animation, mirror, portrait, or outfit takes its look from its parent",
1675
1709
  path: ["reference"]
1676
1710
  });
1677
1711
  }
1678
- if (asset.concept && (asset.state || asset.animation || asset.mirror)) {
1712
+ if (asset.concept && (asset.state || asset.animation || asset.mirror || asset.portrait || asset.outfit)) {
1679
1713
  context.addIssue({
1680
1714
  code: z.ZodIssueCode.custom,
1681
- message: "a concept image belongs on a base; a state, animation, or mirror takes its look from its parent",
1715
+ message: "a concept image belongs on a base; a state, animation, mirror, portrait, or outfit takes its look from its parent",
1682
1716
  path: ["concept"]
1683
1717
  });
1684
1718
  }
@@ -1803,6 +1837,17 @@ var LockEntrySchema = z.object({
1803
1837
  leaderAssetId: z.string().min(1).optional(),
1804
1838
  index: z.number().int().min(1).optional()
1805
1839
  }).strict().nullable().default(null),
1840
+ /**
1841
+ * For an outfit: the loop it re-clothed, a hash over that loop's output
1842
+ * hashes when this was made, and the reference image's own hash. Tracked
1843
+ * the way `mirror` is, not through `specHash`: manifest resolution has no
1844
+ * lock to hash the parent's actual current frame bytes against.
1845
+ */
1846
+ outfit: z.object({
1847
+ sourceAssetId: z.string().min(1),
1848
+ sourceSha256: z.string().regex(/^[0-9a-f]{64}$/),
1849
+ referenceSha256: z.string().regex(/^[0-9a-f]{64}$/)
1850
+ }).strict().nullable().default(null),
1806
1851
  /**
1807
1852
  * Output hashes owned by the previous generation while its replacement is
1808
1853
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -4080,6 +4125,7 @@ import { existsSync as existsSync4 } from "fs";
4080
4125
  import path8 from "path";
4081
4126
  async function inspectRevisionReadiness(spec, lock) {
4082
4127
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
4128
+ if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
4083
4129
  if (spec.character?.parentSpec) return inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
4084
4130
  if (spec.character?.styleAnchor) return inspectStyleAnchor(spec, lock, /* @__PURE__ */ new Set());
4085
4131
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
@@ -4106,6 +4152,20 @@ async function inspectMirror(spec, lock, seen) {
4106
4152
  const dependency = await inspectParent(source, lock, seen);
4107
4153
  return dependency.ready ? { ready: true, reason: `${label} is current` } : { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
4108
4154
  }
4155
+ async function inspectOutfit(spec, lock, seen) {
4156
+ const character = spec.character;
4157
+ const outfit = character.outfit;
4158
+ const label = `outfit source ${spec.styleId}/${character.parentAssetId}`;
4159
+ const dependency = await inspectParent(character.parentSpec, lock, seen);
4160
+ if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
4161
+ if (!existsSync4(outfit.reference.path)) {
4162
+ return { ready: false, reason: `outfit reference is missing: ${outfit.reference.path}` };
4163
+ }
4164
+ if (await sha256File(outfit.reference.path) !== outfit.reference.sha256) {
4165
+ return { ready: false, reason: "outfit reference changed after the manifest was resolved" };
4166
+ }
4167
+ return { ready: true, reason: `${label} is current` };
4168
+ }
4109
4169
  async function inspectCharacterParent(spec, lock, seen) {
4110
4170
  const character = spec.character;
4111
4171
  const parentSpec = character.parentSpec;
@@ -4221,7 +4281,10 @@ async function inspectParent(spec, lock, seen) {
4221
4281
  const inputs = await inspectRevision(spec, lock, nextSeen);
4222
4282
  if (!inputs.ready) return inputs;
4223
4283
  }
4224
- if (spec.character?.parentSpec) {
4284
+ if (spec.character?.kind === "outfit") {
4285
+ const inputs = await inspectOutfit(spec, lock, nextSeen);
4286
+ if (!inputs.ready) return inputs;
4287
+ } else if (spec.character?.parentSpec) {
4225
4288
  const inputs = await inspectCharacterParent(spec, lock, nextSeen);
4226
4289
  if (!inputs.ready) return inputs;
4227
4290
  }
@@ -4344,6 +4407,9 @@ async function buildPlan(specs, lock, opts = {}) {
4344
4407
  } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !existsSync5(currentEntryOutputPath(entry, spec, index)))) {
4345
4408
  state = "stale";
4346
4409
  reason = "mirror output missing on disk; flip it again (no generation cost)";
4410
+ } else if (spec.character?.kind === "outfit" && entry.outfit?.sourceSha256 !== mirrorSourceHash(lock.entries[lockKey(spec.styleId, spec.character.parentAssetId)] ?? { outputs: [] })) {
4411
+ state = "stale";
4412
+ reason = `outfit source ${spec.character.parentAssetId} changed; re-clothe the loop again`;
4347
4413
  } else if (entry.status === "download-failed") {
4348
4414
  state = "recoverable";
4349
4415
  const force = entry.error?.includes("pass --force") ? " --force" : "";
@@ -5760,6 +5826,28 @@ var AnimateObjectSubmitSchema = z2.object({
5760
5826
  submissions: z2.array(DirectionSubmissionSchema),
5761
5827
  usage: UsageSchema
5762
5828
  }).passthrough();
5829
+ var PortraitSubmitSchema = z2.object({
5830
+ background_job_id: z2.string().min(1),
5831
+ status: z2.string().default("processing"),
5832
+ usage: UsageSchema
5833
+ }).passthrough();
5834
+ var PortraitJobSchema = z2.object({
5835
+ status: z2.string(),
5836
+ download_url: z2.string().nullable().optional(),
5837
+ width: z2.number().int().nullable().optional(),
5838
+ height: z2.number().int().nullable().optional()
5839
+ }).passthrough();
5840
+ var SetPortraitSchema = z2.object({
5841
+ character_id: z2.string().min(1),
5842
+ size: z2.number().int().positive(),
5843
+ url: z2.string().min(1),
5844
+ usage: UsageSchema
5845
+ }).passthrough();
5846
+ var TransferOutfitSubmitSchema = z2.object({
5847
+ background_job_id: z2.string().min(1),
5848
+ status: z2.string().default("processing"),
5849
+ usage: UsageSchema
5850
+ }).passthrough();
5763
5851
  var CharacterAnimationDirectionSchema = z2.object({
5764
5852
  direction: z2.string(),
5765
5853
  frame_count: z2.number().int().min(0),
@@ -6375,6 +6463,69 @@ var PixelLabClient = class {
6375
6463
  });
6376
6464
  return validateResponse(AnimateSubmitSchema, raw, "animate-character");
6377
6465
  }
6466
+ /**
6467
+ * A full-body sprite in, a bust portrait out (or the reverse, unused
6468
+ * today). Job-based like a state or `1dir`, not the couple-generations
6469
+ * "conversion" the name suggests: the smallest `resultSize` (16px) billed
6470
+ * 20 generations live (docs/ENDPOINTS.md, "Characters, measured").
6471
+ */
6472
+ async createPortraitCharacterPro(args) {
6473
+ const raw = await this.request("/portrait-character-pro", {
6474
+ method: "POST",
6475
+ body: JSON.stringify({
6476
+ direction: args.direction,
6477
+ image: { type: "base64", base64: args.image.base64, format: args.image.format },
6478
+ ...args.view ? { view: args.view } : {},
6479
+ ...args.resultSize != null ? { result_size: args.resultSize } : {},
6480
+ ...args.seed != null ? { seed: args.seed } : {}
6481
+ })
6482
+ });
6483
+ return validateResponse(PortraitSubmitSchema, raw, "portrait-character-pro");
6484
+ }
6485
+ /** Throws PixelLabError(423) while the job is still drawing, like tiles and map objects. */
6486
+ async getPortraitCharacterJob(jobId) {
6487
+ const raw = await this.request(`/portrait-character-pro/${encodeURIComponent(jobId)}`);
6488
+ return validateResponse(PortraitJobSchema, raw, "portrait-character-pro/{id}");
6489
+ }
6490
+ /**
6491
+ * Attaches a portrait image to a character record. Synchronous and free
6492
+ * (0 generations, live); PixelLab does not link this to
6493
+ * `createPortraitCharacterPro` automatically, and `GET /characters/{id}`
6494
+ * never grows a portrait field, so this response's `url` is the only place
6495
+ * it appears.
6496
+ */
6497
+ async setCharacterPortrait(characterId, image) {
6498
+ const raw = await this.request(`/characters/${encodeURIComponent(characterId)}/portrait`, {
6499
+ method: "POST",
6500
+ body: JSON.stringify({ image: { type: "base64", base64: image.base64, format: image.format } })
6501
+ });
6502
+ return validateResponse(SetPortraitSchema, raw, "characters/{id}/portrait");
6503
+ }
6504
+ /**
6505
+ * Applies a reference outfit to 2–16 existing frames. Job-based like
6506
+ * everything else here, but the completed job returns the result frames
6507
+ * inline as base64 (`last_response.quantized_images`), not as storage
6508
+ * URLs — the one PixelLab character tool that works this way. A live
6509
+ * 2-frame, 92x92 job billed 20 generations (docs/ENDPOINTS.md).
6510
+ */
6511
+ async transferOutfitV2(args) {
6512
+ const image = (img) => ({
6513
+ image: { type: "base64", base64: img.base64, format: img.format },
6514
+ size: { width: img.width, height: img.height }
6515
+ });
6516
+ const raw = await this.request("/transfer-outfit-v2", {
6517
+ method: "POST",
6518
+ body: JSON.stringify({
6519
+ reference_image: image(args.referenceImage),
6520
+ frames: args.frames.map(image),
6521
+ image_size: args.imageSize,
6522
+ ...args.seed != null ? { seed: args.seed } : {},
6523
+ ...args.noBackground !== void 0 ? { no_background: args.noBackground } : {},
6524
+ ...args.additionalInstructions ? { additional_instructions: args.additionalInstructions } : {}
6525
+ })
6526
+ });
6527
+ return validateResponse(TransferOutfitSubmitSchema, raw, "transfer-outfit-v2");
6528
+ }
6378
6529
  async getCharacter(characterId) {
6379
6530
  const raw = await this.request(`/characters/${encodeURIComponent(characterId)}`);
6380
6531
  return validateResponse(CharacterSchema, raw, "characters/{id}");
@@ -6794,7 +6945,10 @@ function characterCost(spec) {
6794
6945
  if (animation.mode === "v3") return Math.max(1, Math.ceil(px * animation.frames / 65536));
6795
6946
  return generationCost(spec.width, spec.height, "1dir");
6796
6947
  }
6797
- if (character.kind === "state") return generationCost(spec.width, spec.height, "1dir");
6948
+ if (character.kind === "state" || character.kind === "portrait") return generationCost(spec.width, spec.height, "1dir");
6949
+ if (character.kind === "outfit") {
6950
+ return 20;
6951
+ }
6798
6952
  if (character.mode === "standard") return 1;
6799
6953
  if (character.mode === "v3") return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
6800
6954
  if (character.mode === "pro-flash") {
@@ -6911,15 +7065,18 @@ var PixelLabProvider = class _PixelLabProvider {
6911
7065
  return { spacingMs: 2500, maxInFlight: 8 };
6912
7066
  }
6913
7067
  /**
6914
- * Where synchronous pixflux results are parked between `submit` and `fetch`.
7068
+ * Where synchronous pixflux results (or an outfit transfer's inline
7069
+ * frames) are parked between `submit`/poll and `fetch`.
6915
7070
  *
6916
- * pixflux returns the PNG inline rather than a job id, but the pipeline is
6917
- * built around submit → poll → fetch running as separate commands. Writing
6918
- * the bytes to a known path keeps that model intact: the "job id" is the
6919
- * filename, polling is an existence check, and downloading is a file read.
7071
+ * pixflux returns the PNG inline rather than a job id, and an outfit
7072
+ * transfer's completed job returns its frames inline too, but the
7073
+ * pipeline is built around submit → poll → fetch running as separate
7074
+ * commands. Writing the bytes to a known path keeps that model intact:
7075
+ * the id is the filename, polling is an existence check, and downloading
7076
+ * is a file read.
6920
7077
  */
6921
- static cacheDir() {
6922
- const dir = path12.join(os.tmpdir(), "pixelkiln-pixflux");
7078
+ static cacheDir(namespace = "pixflux") {
7079
+ const dir = path12.join(os.tmpdir(), `pixelkiln-${namespace}`);
6923
7080
  mkdirSync(dir, { recursive: true });
6924
7081
  return dir;
6925
7082
  }
@@ -7179,6 +7336,12 @@ var PixelLabProvider = class _PixelLabProvider {
7179
7336
  const character = spec.character;
7180
7337
  if (!character) throw new Error(`${spec.styleId}/${spec.assetId} has no character shape`);
7181
7338
  const label = `${spec.styleId}/${spec.assetId}`;
7339
+ if (character.kind === "outfit") {
7340
+ const reference = character.outfit.reference;
7341
+ if (reference.width < 32 || reference.width > 256 || reference.height < 32 || reference.height > 256) {
7342
+ throw new Error(`${label}: outfit reference is ${reference.width}x${reference.height}; PixelLab transfer-outfit-v2 takes 32 to 256px per side`);
7343
+ }
7344
+ }
7182
7345
  if (character.kind === "base" && character.proportions !== void 0) {
7183
7346
  if (character.mode !== "standard") {
7184
7347
  throw new Error(`${label}: proportions apply to standard bases; the ${character.mode} engine has none to set`);
@@ -7190,7 +7353,7 @@ var PixelLabProvider = class _PixelLabProvider {
7190
7353
  if (character.kind === "base" && character.enhancePrompt !== void 0 && character.mode !== "v3") {
7191
7354
  throw new Error(`${label}: enhancePrompt applies to v3 bases; the ${character.mode} engine does not take it`);
7192
7355
  }
7193
- if (character.animation) {
7356
+ if (character.animation && character.kind !== "outfit") {
7194
7357
  const { startFrame, endFrame } = character.animation;
7195
7358
  for (const [name, image] of [["start frame", startFrame], ["end frame", endFrame]]) {
7196
7359
  if (image && (image.width > 256 || image.height > 256)) {
@@ -7271,9 +7434,11 @@ var PixelLabProvider = class _PixelLabProvider {
7271
7434
  if (character.styleTraits && character.mode !== "pro-flash") {
7272
7435
  throw new Error(`${label}: styleTraits choose what a pro-flash style image lends; the ${character.mode} engine does not take them`);
7273
7436
  }
7274
- const maxSize = character.mode === "v3" || character.mode === "pro-flash" ? 256 : 128;
7275
- if (spec.width < 16 || spec.height < 16 || spec.width > maxSize || spec.height > maxSize) {
7276
- throw new Error(`PixelLab ${character.mode} characters must be between 16 and ${maxSize} pixels`);
7437
+ if (character.kind !== "portrait") {
7438
+ const maxSize = character.mode === "v3" || character.mode === "pro-flash" ? 256 : 128;
7439
+ if (spec.width < 16 || spec.height < 16 || spec.width > maxSize || spec.height > maxSize) {
7440
+ throw new Error(`PixelLab ${character.mode} characters must be between 16 and ${maxSize} pixels`);
7441
+ }
7277
7442
  }
7278
7443
  if (character.mode === "pro-flash" && character.kind === "base" && (spec.width % 4 || spec.height % 4)) {
7279
7444
  throw new Error(`PixelLab pro-flash characters are sized in multiples of 4 pixels; ${spec.width}x${spec.height} is not`);
@@ -7282,9 +7447,11 @@ var PixelLabProvider = class _PixelLabProvider {
7282
7447
  if (!templates.includes(character.template)) {
7283
7448
  throw new Error(`PixelLab ${character.mode} character template must be one of: ${templates.join(", ")}`);
7284
7449
  }
7285
- const views = character.mode === "standard" ? ["low top-down", "high top-down", "side", "perspective", "oblique"] : ["low top-down", "high top-down", "side"];
7450
+ 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"];
7286
7451
  if (!views.includes(spec.view)) {
7287
- throw new Error(`PixelLab ${character.mode} character view must be one of: ${views.join(", ")}`);
7452
+ throw new Error(
7453
+ character.kind === "portrait" ? `PixelLab portrait view must be one of: ${views.join(", ")}` : `PixelLab ${character.mode} character view must be one of: ${views.join(", ")}`
7454
+ );
7288
7455
  }
7289
7456
  if (spec.view === "oblique" && character.directions !== 4) {
7290
7457
  throw new Error("PixelLab oblique characters are 4-direction only");
@@ -7292,7 +7459,7 @@ var PixelLabProvider = class _PixelLabProvider {
7292
7459
  if (character.kind !== "base" && !character.parentAssetId) {
7293
7460
  throw new Error(`${spec.styleId}/${spec.assetId} is a ${character.kind} with no parent`);
7294
7461
  }
7295
- if (character.animation) {
7462
+ if (character.animation && character.kind !== "outfit") {
7296
7463
  const allowed = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
7297
7464
  if (!allowed.includes(character.animation.direction)) {
7298
7465
  throw new Error(
@@ -7398,6 +7565,43 @@ var PixelLabProvider = class _PixelLabProvider {
7398
7565
  });
7399
7566
  return { jobId: res2.character_id, metadata: { character: { kind: "base", characterId: res2.character_id, mode: character.mode, directions: character.directions, backgroundJobId: res2.background_job_id } } };
7400
7567
  }
7568
+ if (character.kind === "outfit") {
7569
+ const parentOutputs = context?.parentOutputs ?? [];
7570
+ if (parentOutputs.length < 2) {
7571
+ throw new Error(
7572
+ `${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`
7573
+ );
7574
+ }
7575
+ const frames = parentOutputs.slice(0, 16).map((output) => {
7576
+ const bytes = readFileSync2(output.path);
7577
+ const dims = imageMetadata(bytes);
7578
+ if (!dims) throw new Error(`${spec.styleId}/${spec.assetId}: outfit source frame is not a readable PNG: ${output.path}`);
7579
+ return { base64: bytes.toString("base64"), format: "png", width: dims.width, height: dims.height };
7580
+ });
7581
+ const outfit = character.outfit;
7582
+ const referenceBytes = readFileSync2(outfit.reference.path);
7583
+ if (sha256(referenceBytes) !== outfit.reference.sha256) {
7584
+ throw new Error(`${spec.styleId}/${spec.assetId}: outfit reference changed after the manifest was resolved: ${outfit.reference.path}`);
7585
+ }
7586
+ const res2 = await this.client.transferOutfitV2({
7587
+ referenceImage: { base64: referenceBytes.toString("base64"), format: "png", width: outfit.reference.width, height: outfit.reference.height },
7588
+ frames,
7589
+ imageSize: { width: spec.width, height: spec.height },
7590
+ seed: spec.seed,
7591
+ noBackground: spec.noBackground,
7592
+ additionalInstructions: outfit.additionalInstructions
7593
+ });
7594
+ return {
7595
+ jobId: res2.background_job_id,
7596
+ metadata: {
7597
+ character: {
7598
+ kind: "outfit",
7599
+ backgroundJobId: res2.background_job_id,
7600
+ frameRoles: parentOutputs.slice(0, 16).map((output) => output.role ?? null)
7601
+ }
7602
+ }
7603
+ };
7604
+ }
7401
7605
  const parentId = context?.parentObjectId;
7402
7606
  if (!parentId) {
7403
7607
  throw new Error(
@@ -7416,6 +7620,24 @@ var PixelLabProvider = class _PixelLabProvider {
7416
7620
  });
7417
7621
  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 } } };
7418
7622
  }
7623
+ if (character.kind === "portrait") {
7624
+ const parentFile = character.parentFile;
7625
+ const bytes = readFileSync2(parentFile);
7626
+ if (character.parentSha256 && sha256(bytes) !== character.parentSha256) {
7627
+ throw new Error(`${spec.styleId}/${spec.assetId}: portrait source changed after the manifest was resolved: ${parentFile}`);
7628
+ }
7629
+ const res2 = await this.client.createPortraitCharacterPro({
7630
+ direction: "character_to_portrait",
7631
+ image: { base64: bytes.toString("base64"), format: "png" },
7632
+ view: spec.view,
7633
+ resultSize: character.portrait.size,
7634
+ seed: spec.seed
7635
+ });
7636
+ return {
7637
+ jobId: res2.background_job_id,
7638
+ metadata: { character: { kind: "portrait", parentCharacterId: parentId, backgroundJobId: res2.background_job_id } }
7639
+ };
7640
+ }
7419
7641
  const animation = character.animation;
7420
7642
  const name = characterAnimationName(spec);
7421
7643
  const replaced = context?.replacedMetadata?.character ?? {};
@@ -7529,6 +7751,9 @@ var PixelLabProvider = class _PixelLabProvider {
7529
7751
  async pollCharacter(jobId, context) {
7530
7752
  const animation = decodeAnimationJob(jobId);
7531
7753
  if (animation) return this.pollCharacterAnimation(animation, context);
7754
+ const kind = context?.metadata?.character?.kind ?? context?.spec?.character?.kind;
7755
+ if (kind === "portrait") return this.pollCharacterPortrait(jobId, context);
7756
+ if (kind === "outfit") return this.pollCharacterOutfit(jobId, context);
7532
7757
  const character = await this.client.getCharacter(jobId);
7533
7758
  if (character.status === "failed") return { status: "failed", error: "character generation failed upstream" };
7534
7759
  if (character.status !== "completed" || !character.rotation_urls) return { status: "processing" };
@@ -7554,6 +7779,66 @@ var PixelLabProvider = class _PixelLabProvider {
7554
7779
  billed: await this.billedForJob(backgroundJobId)
7555
7780
  };
7556
7781
  }
7782
+ /**
7783
+ * `jobId` here is `portrait-character-pro`'s own background job id, not a
7784
+ * character id: the endpoint converts an arbitrary image and never touches
7785
+ * the character record. Once the portrait itself is drawn, this also
7786
+ * attaches it to the parent's character record with `SetPortrait` — a
7787
+ * separate, free call PixelLab does not make automatically (docs/PIXELLAB.md).
7788
+ */
7789
+ async pollCharacterPortrait(jobId, context) {
7790
+ const meta = context?.metadata?.character;
7791
+ try {
7792
+ const job = await this.client.getPortraitCharacterJob(jobId);
7793
+ if (job.status === "failed") return { status: "failed", error: "portrait generation failed upstream" };
7794
+ if (job.status !== "completed" || !job.download_url) return { status: "processing" };
7795
+ if (meta?.parentCharacterId) {
7796
+ const bytes = await this.client.download(job.download_url);
7797
+ await this.client.setCharacterPortrait(meta.parentCharacterId, { base64: bytes.toString("base64"), format: "png" });
7798
+ }
7799
+ return {
7800
+ status: "ready",
7801
+ objectId: jobId,
7802
+ sourceUrl: job.download_url,
7803
+ sources: [{ url: job.download_url }],
7804
+ billed: await this.billedForJob(meta?.backgroundJobId)
7805
+ };
7806
+ } catch (err) {
7807
+ if (err instanceof PixelLabError && err.status === 423) return { status: "processing" };
7808
+ throw err;
7809
+ }
7810
+ }
7811
+ /**
7812
+ * No dedicated status endpoint exists for `transfer-outfit-v2`, unlike
7813
+ * portraits and tiles: the background job is the only place to poll. A
7814
+ * completed job returns its frames inline as base64
7815
+ * (`last_response.quantized_images`), not as storage URLs, so they are
7816
+ * cached locally the way a synchronous pixflux result is, and matched back
7817
+ * to the source frames' own roles recorded at submit time.
7818
+ */
7819
+ async pollCharacterOutfit(jobId, context) {
7820
+ const meta = context?.metadata?.character;
7821
+ const job = await this.client.getBackgroundJob(jobId);
7822
+ if (job.status === "failed") return { status: "failed", error: "outfit transfer failed upstream" };
7823
+ if (job.status !== "completed") return { status: "processing" };
7824
+ const images = job.last_response?.quantized_images;
7825
+ if (!images?.length) return { status: "failed", error: "outfit transfer completed with no result frames" };
7826
+ const dir = _PixelLabProvider.cacheDir("outfit");
7827
+ const sources = images.map((image, index) => {
7828
+ if (!image.base64) throw new Error(`outfit transfer frame ${index} has no image data`);
7829
+ const role = meta?.frameRoles?.[index] ?? void 0;
7830
+ const file = path12.join(dir, `${jobId}-${index}.png`);
7831
+ writeFileSync(file, Buffer.from(image.base64, "base64"));
7832
+ return { url: `file://${file}`, ...role ? { role } : {} };
7833
+ });
7834
+ return {
7835
+ status: "ready",
7836
+ objectId: jobId,
7837
+ sourceUrl: sources[0].url,
7838
+ sources,
7839
+ billed: await this.billedForJob(meta?.backgroundJobId)
7840
+ };
7841
+ }
7557
7842
  /**
7558
7843
  * The background job is the authoritative record of one direction: it
7559
7844
  * completes with the frame URLs, the animation id, and the direction, and
@@ -9751,7 +10036,7 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
9751
10036
  return { manifest: parsed.data, root: path13.dirname(abs), path: abs, ...loopFamilies };
9752
10037
  }
9753
10038
  function parentAssetId(asset) {
9754
- return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
10039
+ return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of ?? asset?.portrait?.of ?? asset?.outfit?.of;
9755
10040
  }
9756
10041
  function formatManifestIssues(issues) {
9757
10042
  return issues.flatMap((issue) => {
@@ -9918,9 +10203,9 @@ async function resolveSpecs(loaded, filter) {
9918
10203
  height = styleImageDimensions.length ? Math.max(...styleImageDimensions.map((img) => img.height)) : style.tileSize ?? 32;
9919
10204
  size = Math.max(width, height);
9920
10205
  } else if (generator === "character") {
9921
- size = asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
9922
- width = asset.state?.canvas?.width ?? size;
9923
- height = asset.state?.canvas?.height ?? size;
10206
+ size = asset.portrait ? asset.portrait.size : asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
10207
+ width = asset.portrait?.size ?? asset.state?.canvas?.width ?? size;
10208
+ height = asset.portrait?.size ?? asset.state?.canvas?.height ?? size;
9924
10209
  } else if (generator === "terrain") {
9925
10210
  size = style.terrainTileSize ?? 16;
9926
10211
  width = size;
@@ -9947,12 +10232,17 @@ async function resolveSpecs(loaded, filter) {
9947
10232
  `assets.${assetId}: ${asset.state ? "state" : "animation"} needs a character or objectPro style; "${styleId}" generates ${generator}`
9948
10233
  );
9949
10234
  }
10235
+ if ((asset.portrait || asset.outfit) && generator !== "character") {
10236
+ throw new Error(
10237
+ `assets.${assetId}: ${asset.portrait ? "portrait" : "outfit"} needs a character style; "${styleId}" generates ${generator}`
10238
+ );
10239
+ }
9950
10240
  if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
9951
10241
  throw new Error(
9952
10242
  `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
9953
10243
  );
9954
10244
  }
9955
- const characterKind = asset.animation ? "animation" : asset.state ? "state" : "base";
10245
+ const characterKind = asset.animation ? "animation" : asset.state ? "state" : asset.portrait ? "portrait" : "base";
9956
10246
  const subject = asset.promptByStyle[styleId] ?? asset.prompt ?? "";
9957
10247
  let terrainLowerDescription;
9958
10248
  let terrainUpperDescription;
@@ -10044,7 +10334,9 @@ async function resolveSpecs(loaded, filter) {
10044
10334
  // its parent's pixels, not a new character: giving it a base shape
10045
10335
  // would have packing, the gallery, and adoption treat it as one.
10046
10336
  ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
10047
- ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
10337
+ // A portrait needs a character style (refused above), so an
10338
+ // objectPro asset is only ever a base, state, or animation.
10339
+ ...generator === "objectPro" && !asset.revision && characterKind !== "portrait" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
10048
10340
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
10049
10341
  costUnit: "generations",
10050
10342
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -10155,7 +10447,7 @@ async function resolveSpecs(loaded, filter) {
10155
10447
  styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: existsSync8(file) ? await sha256File(file) : null }
10156
10448
  };
10157
10449
  }
10158
- const characterParent = asset.state?.of ?? asset.animation?.of;
10450
+ const characterParent = asset.state?.of ?? asset.animation?.of ?? asset.portrait?.of;
10159
10451
  if (resolved.character && characterParent) {
10160
10452
  const parentSpec = await finalize(characterParent);
10161
10453
  const parentDirections = parentSpec.character?.directions ?? 8;
@@ -10188,6 +10480,38 @@ async function resolveSpecs(loaded, filter) {
10188
10480
  directions: resolved.objectPro.kind === "base" ? resolved.objectPro.directions : parentSpec.objectPro.directions
10189
10481
  };
10190
10482
  }
10483
+ if (asset.outfit) {
10484
+ if (asset.outfit.of === assetId) throw new Error(`assets.${assetId}: an asset cannot re-clothe itself`);
10485
+ const sourceSpec = await finalize(asset.outfit.of);
10486
+ if (sourceSpec.character?.kind !== "animation") {
10487
+ throw new Error(`assets.${assetId}: outfit "${asset.outfit.of}" is not a character loop`);
10488
+ }
10489
+ const referenceImage = await loadStyleImage(asset.outfit.reference, "Outfit reference image");
10490
+ resolved.character = {
10491
+ kind: "outfit",
10492
+ mode: sourceSpec.character.mode,
10493
+ directions: sourceSpec.character.directions,
10494
+ template: sourceSpec.character.template,
10495
+ parentAssetId: asset.outfit.of,
10496
+ parentSpec: sourceSpec,
10497
+ animation: sourceSpec.character.animation,
10498
+ outfit: {
10499
+ reference: {
10500
+ path: path13.resolve(root, asset.outfit.reference),
10501
+ sha256: referenceImage.hash,
10502
+ width: referenceImage.width,
10503
+ height: referenceImage.height,
10504
+ format: referenceImage.format
10505
+ },
10506
+ ...asset.outfit.additionalInstructions ? { additionalInstructions: asset.outfit.additionalInstructions } : {}
10507
+ }
10508
+ };
10509
+ resolved.generator = sourceSpec.generator;
10510
+ resolved.width = sourceSpec.width;
10511
+ resolved.height = sourceSpec.height;
10512
+ resolved.size = sourceSpec.size;
10513
+ resolved.prompt = "";
10514
+ }
10191
10515
  if (asset.revision) {
10192
10516
  if (!activeProvider.supportsRevision?.(asset.revision.mode)) {
10193
10517
  throw new Error(
@@ -10458,6 +10782,9 @@ async function resolveCharacterShape(asset, style, kind, files) {
10458
10782
  ...asset.state.canvas ? { canvas: asset.state.canvas } : {}
10459
10783
  };
10460
10784
  }
10785
+ if (asset.portrait) {
10786
+ shape.portrait = { size: asset.portrait.size };
10787
+ }
10461
10788
  if (asset.animation) {
10462
10789
  const animationMode = asset.animation.mode ?? (asset.animation.template ? "template" : "v3");
10463
10790
  const pose = async (rel, what) => {
@@ -13824,7 +14151,7 @@ function describeCharacter(spec, entry) {
13824
14151
  };
13825
14152
  }
13826
14153
  if (entry?.generator === "character") {
13827
- const kind = recordedCharacter?.kind === "animation" || entry.outputs.some((o) => o.role?.startsWith("frame-")) ? "animation" : "base";
14154
+ const kind = recordedCharacter?.kind === "portrait" ? "portrait" : recordedCharacter?.kind === "outfit" ? "outfit" : recordedCharacter?.kind === "animation" || entry.outputs.some((o) => o.role?.startsWith("frame-")) ? "animation" : "base";
13828
14155
  return {
13829
14156
  generator: "character",
13830
14157
  kind,
@@ -14185,7 +14512,9 @@ async function buildGallerySnapshot(opts) {
14185
14512
  characters: style?.generator === "character" || style?.generator === "objectPro" ? {
14186
14513
  bases: styleItems.filter((item) => item.character?.kind === "base").length,
14187
14514
  states: styleItems.filter((item) => item.character?.kind === "state").length,
14188
- animations: styleItems.filter((item) => item.character?.kind === "animation").length
14515
+ animations: styleItems.filter((item) => item.character?.kind === "animation").length,
14516
+ portraits: styleItems.filter((item) => item.character?.kind === "portrait").length,
14517
+ outfits: styleItems.filter((item) => item.character?.kind === "outfit").length
14189
14518
  } : null,
14190
14519
  tags: style?.tags ?? [],
14191
14520
  extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
@@ -14962,6 +15291,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14962
15291
  const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
14963
15292
  const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
14964
15293
  const history = historyAfterReplacing(previousEntry, historyLimit(loaded.manifest), submittedAt ?? (/* @__PURE__ */ new Date()).toISOString());
15294
+ const outfitParentEntry = spec.character?.kind === "outfit" && spec.character.parentSpec ? lock.entries[lockKey(spec.character.parentSpec.styleId, spec.character.parentSpec.assetId)] : void 0;
14965
15295
  upsert(lock, key, {
14966
15296
  styleId: spec.styleId,
14967
15297
  assetId: spec.assetId,
@@ -14987,6 +15317,11 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14987
15317
  ...spec.revision.direction ? { direction: spec.revision.direction } : {},
14988
15318
  ...spec.revision.enhancePrompt == null ? {} : { enhancePrompt: spec.revision.enhancePrompt }
14989
15319
  } : null,
15320
+ outfit: spec.character?.kind === "outfit" && outfitParentEntry ? {
15321
+ sourceAssetId: spec.character.parentAssetId,
15322
+ sourceSha256: mirrorSourceHash(outfitParentEntry),
15323
+ referenceSha256: spec.character.outfit.reference.sha256
15324
+ } : null,
14990
15325
  status: "pending",
14991
15326
  jobId: previousJobId ?? null,
14992
15327
  ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
@@ -15020,10 +15355,15 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
15020
15355
  const anchor = spec.character?.styleAnchor;
15021
15356
  const anchorEntry = anchor ? lock.entries[lockKey(anchor.spec.styleId, anchor.spec.assetId)] : void 0;
15022
15357
  const replacedMetadata = previousEntry?.providerMetadata?.[provider.id];
15358
+ const parentOutputs = spec.character?.kind === "outfit" && parentEntry ? parentEntry.outputs.map((output, index) => ({
15359
+ path: currentEntryOutputPath(parentEntry, spec.character.parentSpec, index),
15360
+ role: output.role
15361
+ })) : void 0;
15023
15362
  const { jobId, metadata } = await provider.submit(spec, refs, {
15024
15363
  ...previousJobId ? { previousJobId } : {},
15025
15364
  ...parentEntry?.objectId ? { parentObjectId: parentEntry.objectId } : {},
15026
15365
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
15366
+ ...parentOutputs ? { parentOutputs } : {},
15027
15367
  ...replacedMetadata ? { replacedMetadata } : {},
15028
15368
  ...previousMetadata ? { previousMetadata } : {},
15029
15369
  checkpoint: async (checkpoint) => {