pixelkiln 0.63.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
@@ -8373,8 +8658,8 @@ var PixelLabProvider = class _PixelLabProvider {
8373
8658
  const sources = [];
8374
8659
  const terrainTiles = [];
8375
8660
  for (const [index, tile] of tileset.tiles.entries()) {
8376
- const slug = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
8377
- const role = `tile-${String(index).padStart(2, "0")}-${slug}`;
8661
+ const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
8662
+ const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
8378
8663
  const file = path12.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
8379
8664
  writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
8380
8665
  sources.push({ url: `file://${file}`, role });
@@ -8493,6 +8778,9 @@ var PixelLabProvider = class _PixelLabProvider {
8493
8778
  async delete(assetId) {
8494
8779
  await this.client.deleteObject(assetId);
8495
8780
  }
8781
+ async deleteCharacter(id) {
8782
+ await this.client.deleteCharacter(id);
8783
+ }
8496
8784
  async *listCharacters() {
8497
8785
  for await (const character of this.client.iterateCharacters(100)) {
8498
8786
  yield remoteCharacter(character);
@@ -9748,7 +10036,7 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
9748
10036
  return { manifest: parsed.data, root: path13.dirname(abs), path: abs, ...loopFamilies };
9749
10037
  }
9750
10038
  function parentAssetId(asset) {
9751
- 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;
9752
10040
  }
9753
10041
  function formatManifestIssues(issues) {
9754
10042
  return issues.flatMap((issue) => {
@@ -9915,9 +10203,9 @@ async function resolveSpecs(loaded, filter) {
9915
10203
  height = styleImageDimensions.length ? Math.max(...styleImageDimensions.map((img) => img.height)) : style.tileSize ?? 32;
9916
10204
  size = Math.max(width, height);
9917
10205
  } else if (generator === "character") {
9918
- size = asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
9919
- width = asset.state?.canvas?.width ?? size;
9920
- 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;
9921
10209
  } else if (generator === "terrain") {
9922
10210
  size = style.terrainTileSize ?? 16;
9923
10211
  width = size;
@@ -9944,12 +10232,17 @@ async function resolveSpecs(loaded, filter) {
9944
10232
  `assets.${assetId}: ${asset.state ? "state" : "animation"} needs a character or objectPro style; "${styleId}" generates ${generator}`
9945
10233
  );
9946
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
+ }
9947
10240
  if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
9948
10241
  throw new Error(
9949
10242
  `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
9950
10243
  );
9951
10244
  }
9952
- const characterKind = asset.animation ? "animation" : asset.state ? "state" : "base";
10245
+ const characterKind = asset.animation ? "animation" : asset.state ? "state" : asset.portrait ? "portrait" : "base";
9953
10246
  const subject = asset.promptByStyle[styleId] ?? asset.prompt ?? "";
9954
10247
  let terrainLowerDescription;
9955
10248
  let terrainUpperDescription;
@@ -10041,7 +10334,9 @@ async function resolveSpecs(loaded, filter) {
10041
10334
  // its parent's pixels, not a new character: giving it a base shape
10042
10335
  // would have packing, the gallery, and adoption treat it as one.
10043
10336
  ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
10044
- ...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 }) } : {},
10045
10340
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
10046
10341
  costUnit: "generations",
10047
10342
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -10152,7 +10447,7 @@ async function resolveSpecs(loaded, filter) {
10152
10447
  styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: existsSync8(file) ? await sha256File(file) : null }
10153
10448
  };
10154
10449
  }
10155
- const characterParent = asset.state?.of ?? asset.animation?.of;
10450
+ const characterParent = asset.state?.of ?? asset.animation?.of ?? asset.portrait?.of;
10156
10451
  if (resolved.character && characterParent) {
10157
10452
  const parentSpec = await finalize(characterParent);
10158
10453
  const parentDirections = parentSpec.character?.directions ?? 8;
@@ -10185,6 +10480,38 @@ async function resolveSpecs(loaded, filter) {
10185
10480
  directions: resolved.objectPro.kind === "base" ? resolved.objectPro.directions : parentSpec.objectPro.directions
10186
10481
  };
10187
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
+ }
10188
10515
  if (asset.revision) {
10189
10516
  if (!activeProvider.supportsRevision?.(asset.revision.mode)) {
10190
10517
  throw new Error(
@@ -10455,6 +10782,9 @@ async function resolveCharacterShape(asset, style, kind, files) {
10455
10782
  ...asset.state.canvas ? { canvas: asset.state.canvas } : {}
10456
10783
  };
10457
10784
  }
10785
+ if (asset.portrait) {
10786
+ shape.portrait = { size: asset.portrait.size };
10787
+ }
10458
10788
  if (asset.animation) {
10459
10789
  const animationMode = asset.animation.mode ?? (asset.animation.template ? "template" : "v3");
10460
10790
  const pose = async (rel, what) => {
@@ -11406,6 +11736,9 @@ async function loadClaims(lockPaths, opts = {}) {
11406
11736
  add(entry.objectId);
11407
11737
  add(entry.reviewObjectId);
11408
11738
  add(entry.jobId);
11739
+ for (const id of [entry.objectId, entry.jobId]) {
11740
+ if (id?.includes("#")) add(id.split("#")[0]);
11741
+ }
11409
11742
  }
11410
11743
  }
11411
11744
  return claimed;
@@ -11429,6 +11762,122 @@ async function findOrphans(provider, claimed, opts = {}) {
11429
11762
  })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
11430
11763
  return { orphans, total: all.length };
11431
11764
  }
11765
+ async function findOrphanCharacters(provider, claimed, opts = {}) {
11766
+ const log2 = opts.onProgress ?? (() => {
11767
+ });
11768
+ if (!provider.listCharacters) return { orphans: [], total: 0, partlyClaimed: 0 };
11769
+ const all = [];
11770
+ for await (const character of provider.listCharacters()) all.push(character);
11771
+ log2(` scanned ${all.length} character(s) on the account`);
11772
+ const isClaimed = (id) => claimed.has(id) || claimed.has(providerClaimId(provider.id, id));
11773
+ const groups = /* @__PURE__ */ new Map();
11774
+ for (const character of all) {
11775
+ const key = character.groupId ?? character.id;
11776
+ groups.set(key, [...groups.get(key) ?? [], character]);
11777
+ }
11778
+ let partlyClaimed = 0;
11779
+ const orphans = [];
11780
+ for (const members of groups.values()) {
11781
+ const unclaimed = members.filter((character) => !isClaimed(character.id));
11782
+ if (!unclaimed.length) continue;
11783
+ if (unclaimed.length < members.length) {
11784
+ partlyClaimed++;
11785
+ continue;
11786
+ }
11787
+ const ordered = [...members].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
11788
+ const base = ordered[0];
11789
+ if (base.status !== "completed" || !base.previewUrl) continue;
11790
+ const states = ordered.length - 1;
11791
+ const loops = ordered.reduce((sum, character) => sum + character.animationCount, 0);
11792
+ orphans.push({
11793
+ id: base.id,
11794
+ prompt: base.prompt || base.name || "(no prompt recorded)",
11795
+ width: base.width,
11796
+ height: base.height,
11797
+ createdAt: base.createdAt,
11798
+ previewUrl: base.previewUrl,
11799
+ tags: base.tags ?? [],
11800
+ kind: "character",
11801
+ note: `character \xB7 ${base.directions} directions \xB7 ${states} state${states === 1 ? "" : "s"} \xB7 ${loops} loop${loops === 1 ? "" : "s"}`,
11802
+ members: ordered.map((character) => ({ id: character.id, tags: character.tags ?? [] }))
11803
+ });
11804
+ }
11805
+ orphans.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
11806
+ return { orphans, total: all.length, partlyClaimed };
11807
+ }
11808
+ function isState(character) {
11809
+ return Boolean(character.stateName && character.stateName !== "Idle");
11810
+ }
11811
+ function characterStyleIds(manifest) {
11812
+ return Object.keys(manifest.styles).filter((id) => manifest.styles[id].generator === "character");
11813
+ }
11814
+ function withoutCharacterStyles(manifest) {
11815
+ const characters = new Set(characterStyleIds(manifest));
11816
+ return {
11817
+ ...manifest,
11818
+ styles: Object.fromEntries(Object.entries(manifest.styles).filter(([id]) => !characters.has(id)))
11819
+ };
11820
+ }
11821
+ function routeCharacterOrphans(orphans, manifest) {
11822
+ const styleIds = characterStyleIds(manifest);
11823
+ const matched = /* @__PURE__ */ new Map();
11824
+ const unmatched = [];
11825
+ const only = { ...manifest, styles: Object.fromEntries(styleIds.map((id) => [id, manifest.styles[id]])) };
11826
+ for (const orphan of orphans) {
11827
+ const styleId = styleIds.length === 1 ? styleIds[0] : styleIds.length ? matchStyleByPattern(orphan.prompt, only) : null;
11828
+ if (!styleId) {
11829
+ unmatched.push(orphan);
11830
+ continue;
11831
+ }
11832
+ matched.set(styleId, [...matched.get(styleId) ?? [], orphan]);
11833
+ }
11834
+ return { matched, unmatched };
11835
+ }
11836
+ function characterSalvageAssets(characters, ctx) {
11837
+ const ordered = [...characters].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
11838
+ const base = ordered[0];
11839
+ const common = { styles: [ctx.styleId], tags: ["salvaged"] };
11840
+ const assets = {};
11841
+ const skipped = [];
11842
+ const baseId = idFromPrompt(base.name || base.prompt, ctx.taken);
11843
+ const size = base.width === base.height && base.width >= 32 && base.width <= 256 && base.width !== ctx.styleSize ? { size: base.width } : {};
11844
+ assets[baseId] = { prompt: base.prompt || base.name || baseId, remoteId: base.id, ...size, ...common };
11845
+ const assetIdOf = /* @__PURE__ */ new Map([[base.id, baseId]]);
11846
+ for (const state of ordered.slice(1)) {
11847
+ const stateId = uniqueId(`${baseId}_${slug(state.stateName ?? "state")}`, ctx.taken);
11848
+ assetIdOf.set(state.id, stateId);
11849
+ assets[stateId] = { prompt: state.prompt || state.stateName || stateId, state: { of: baseId }, remoteId: state.id, ...common };
11850
+ }
11851
+ for (const character of ordered) {
11852
+ const parentId = assetIdOf.get(character.id);
11853
+ for (const animation of character.animations) {
11854
+ if (!animation.frames.length) continue;
11855
+ const label = animation.name || animation.type;
11856
+ if (!animation.groupId) {
11857
+ skipped.push(`${parentId}: "${label}" ${animation.direction} loop has no animation group id to adopt it by`);
11858
+ continue;
11859
+ }
11860
+ const loopId = uniqueId(`${parentId}.${slug(label)}.${animation.direction}`, ctx.taken);
11861
+ assets[loopId] = {
11862
+ prompt: label,
11863
+ animation: { of: parentId, direction: animation.direction },
11864
+ remoteId: `${character.id}#${animation.groupId}`,
11865
+ ...common
11866
+ };
11867
+ }
11868
+ }
11869
+ return { assets, skipped };
11870
+ }
11871
+ function slug(text) {
11872
+ return text.toLowerCase().replace(/^pixelkiln:.*\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "loop";
11873
+ }
11874
+ function uniqueId(base, taken) {
11875
+ let id = base;
11876
+ let n = 2;
11877
+ while (taken.has(id)) id = `${base}_${n++}`;
11878
+ taken.add(id);
11879
+ return id;
11880
+ }
11432
11881
  var MIN_STYLE_FRAGMENT_LENGTH = 12;
11433
11882
  function matchStyleByPattern(prompt, manifest) {
11434
11883
  const p = prompt.toLowerCase();
@@ -11553,18 +12002,22 @@ async function applyTags(provider, decisions, existing, opts = {}) {
11553
12002
  let tagged = 0;
11554
12003
  let failed = 0;
11555
12004
  for (const { id, action } of decisions) {
11556
- const current = (existing.get(id) ?? []).filter(
11557
- (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
11558
- );
11559
- const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
11560
- const next = [...current.slice(0, 19), decisionTag];
11561
- if (!provider.setTags) return { tagged, failed };
11562
- try {
11563
- await provider.setTags(id, next);
11564
- tagged++;
11565
- } catch (err) {
11566
- failed++;
11567
- log2(` tag failed ${id}: ${err instanceof Error ? err.message : String(err)}`);
12005
+ const group = opts.characters?.get(id);
12006
+ const targets = group ?? [{ id, tags: existing.get(id) ?? [] }];
12007
+ for (const target of targets) {
12008
+ const current = target.tags.filter(
12009
+ (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
12010
+ );
12011
+ const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
12012
+ const next = [...current.slice(0, 19), decisionTag];
12013
+ if (!provider.setTags) return { tagged, failed };
12014
+ try {
12015
+ await provider.setTags(target.id, next, group ? "character" : void 0);
12016
+ tagged++;
12017
+ } catch (err) {
12018
+ failed++;
12019
+ log2(` tag failed ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
12020
+ }
11568
12021
  }
11569
12022
  }
11570
12023
  return { tagged, failed };
@@ -11710,7 +12163,7 @@ import path19 from "path";
11710
12163
  // src/pick/salvage-sheet.ts
11711
12164
  var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11712
12165
  function renderSalvageSheet(orphans, ctx) {
11713
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml(o.prompt) }));
12166
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml(o.prompt), ...o.note ? { note: escapeHtml(o.note) } : {} }));
11714
12167
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
11715
12168
  const styleId = ctx ? escapeHtml(ctx.styleId) : null;
11716
12169
  const importDir = ctx ? escapeHtml(ctx.importDir) : null;
@@ -11796,6 +12249,7 @@ ITEMS.forEach((o, i) => {
11796
12249
  el.innerHTML =
11797
12250
  '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
11798
12251
  '<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
12252
+ (o.note ? '<div class="meta">' + o.note + '</div>' : '') +
11799
12253
  '<div class="prompt">' + o.prompt + '</div>' +
11800
12254
  '<div class="acts">' +
11801
12255
  '<button data-a="import">import</button>' +
@@ -11973,6 +12427,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11973
12427
  });
11974
12428
  const byId = new Map(orphans.map((o) => [o.id, o]));
11975
12429
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
12430
+ const characterMembers = new Map(
12431
+ orphans.flatMap((o) => o.kind === "character" && o.members ? [[o.id, o.members]] : [])
12432
+ );
11976
12433
  return serveReviewPage({
11977
12434
  html,
11978
12435
  port: opts.port,
@@ -11989,10 +12446,34 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11989
12446
  const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
11990
12447
  const taken = new Set(Object.keys(ctx.manifest.assets));
11991
12448
  const importedAssetIds = [];
12449
+ const characterAssetIds = [];
11992
12450
  for (const decision of decisions) {
11993
12451
  const orphan = byId.get(decision.id);
11994
12452
  if (!orphan) continue;
11995
- if (decision.action === "import") {
12453
+ if (decision.action === "import" && orphan.kind === "character") {
12454
+ try {
12455
+ if (!provider.getCharacter) throw new Error(`provider ${provider.id} cannot read characters`);
12456
+ const details = [];
12457
+ for (const member of orphan.members ?? [{ id: orphan.id }]) details.push(await provider.getCharacter(member.id));
12458
+ const style = ctx.manifest.styles[ctx.styleId];
12459
+ const { assets, skipped } = characterSalvageAssets(details, {
12460
+ styleId: ctx.styleId,
12461
+ styleSize: style?.size,
12462
+ taken
12463
+ });
12464
+ for (const [assetId, asset] of Object.entries(assets)) {
12465
+ ctx.manifest.assets[assetId] = asset;
12466
+ importedAssetIds.push(assetId);
12467
+ characterAssetIds.push(assetId);
12468
+ }
12469
+ for (const line of skipped) log2(` not imported: ${line}`);
12470
+ result.imported++;
12471
+ log2(` imported character ${orphan.id} as ${Object.keys(assets).length} asset(s)`);
12472
+ } catch (err) {
12473
+ result.failed++;
12474
+ log2(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
12475
+ }
12476
+ } else if (decision.action === "import") {
11996
12477
  try {
11997
12478
  const buf = await provider.download(orphan.previewUrl);
11998
12479
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
@@ -12056,11 +12537,12 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12056
12537
  result.discarded++;
12057
12538
  }
12058
12539
  }
12059
- await applyTags(provider, decisions, existingTags, { onProgress: log2 });
12540
+ await applyTags(provider, decisions, existingTags, { onProgress: log2, characters: characterMembers });
12060
12541
  const raw = JSON.parse(await readFile14(ctx.manifestPath, "utf8"));
12061
12542
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
12062
12543
  await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12063
12544
  await saveLock(ctx.lockPath, ctx.lock);
12545
+ if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
12064
12546
  return result;
12065
12547
  }
12066
12548
  });
@@ -12502,7 +12984,18 @@ async function runSalvage2(args) {
12502
12984
  const claimed = await loadClaims(lockPaths, { provider: provider.id });
12503
12985
  const { orphans, total } = await findOrphans(provider, claimed, { onProgress: diag });
12504
12986
  diag(` ${claimed.size} claimed \xB7 ${orphans.length} unclaimed of ${total}`);
12505
- if (!orphans.length) {
12987
+ const characterScan = await findOrphanCharacters(provider, claimed, { onProgress: diag });
12988
+ const characterOrphans = characterScan.orphans;
12989
+ if (characterScan.total) {
12990
+ diag(` ${characterOrphans.length} unclaimed character group(s) of ${characterScan.total} character(s)`);
12991
+ }
12992
+ if (characterScan.partlyClaimed) {
12993
+ diag(
12994
+ ` ${characterScan.partlyClaimed} character group(s) are partly claimed (a base or state some project uses);
12995
+ they are left out here. Declare their other states and run \`pixelkiln adopt\` instead.`
12996
+ );
12997
+ }
12998
+ if (!orphans.length && !characterOrphans.length) {
12506
12999
  if (jsonMode) log("[]");
12507
13000
  else log(`
12508
13001
  nothing to triage`);
@@ -12513,9 +13006,10 @@ async function runSalvage2(args) {
12513
13006
  workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
12514
13007
  args.claims
12515
13008
  );
12516
- const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, accountManifest, siblings);
12517
- const multiStyle = Object.keys(accountManifest.styles).length > 1;
12518
- if (multiStyle) {
13009
+ const objectManifest = withoutCharacterStyles(accountManifest);
13010
+ const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, objectManifest, siblings);
13011
+ const multiStyle = Object.keys(objectManifest.styles).length > 1;
13012
+ if (multiStyle && orphans.length) {
12519
13013
  diag(`
12520
13014
  by style (matched against each style's prompt prefix/suffix):`);
12521
13015
  for (const [id, list] of matched) diag(` ${id.padEnd(24)} ${list.length}`);
@@ -12536,16 +13030,33 @@ async function runSalvage2(args) {
12536
13030
  out of the sessions below; force them into one style with --style <id>.`
12537
13031
  );
12538
13032
  }
13033
+ const characterRoutes = routeCharacterOrphans(characterOrphans, accountManifest);
13034
+ if (characterOrphans.length && !characterStyleIds(accountManifest).length) {
13035
+ diag(
13036
+ `
13037
+ ${characterOrphans.length} unclaimed character group(s), but this manifest has no character style
13038
+ to import them into. Add a style with "generator": "character" to triage them.`
13039
+ );
13040
+ } else if (characterRoutes.unmatched.length) {
13041
+ diag(
13042
+ `
13043
+ ${characterRoutes.unmatched.length} character group(s) don't match any character style's pattern;
13044
+ force them into one with --style <character style id>.`
13045
+ );
13046
+ }
12539
13047
  if (args.dryRun) {
13048
+ const listed = [...orphans, ...characterOrphans];
12540
13049
  if (args.json) {
12541
- log(JSON.stringify(orphans, null, 2));
13050
+ log(JSON.stringify(listed, null, 2));
12542
13051
  } else {
12543
- const shown = args.all ? orphans : orphans.slice(0, 30);
13052
+ const shown = args.all ? listed : listed.slice(0, 30);
12544
13053
  for (const o of shown) {
12545
- log(` ${o.id} ${o.width}x${o.height} ${o.createdAt.slice(0, 10)} ${o.prompt.slice(0, 50)}`);
13054
+ log(
13055
+ ` ${o.id} ${o.width}x${o.height} ${o.createdAt.slice(0, 10)} ` + (o.kind === "character" ? `[${o.note}] ` : "") + o.prompt.slice(0, 50)
13056
+ );
12546
13057
  }
12547
- if (!args.all && orphans.length > 30) {
12548
- log(` \u2026 and ${orphans.length - 30} more (--all for the full list, --json for machine-readable)`);
13058
+ if (!args.all && listed.length > 30) {
13059
+ log(` \u2026 and ${listed.length - 30} more (--all for the full list, --json for machine-readable)`);
12549
13060
  }
12550
13061
  }
12551
13062
  diag(`
@@ -12566,6 +13077,12 @@ async function runSalvage2(args) {
12566
13077
  await saveLock(args.lock, lock);
12567
13078
  if (n) log(` baselined ${n} imported asset(s) against the manifest`);
12568
13079
  };
13080
+ const adoptImported = async (assetIds) => {
13081
+ const resolve = async () => (await resolveSpecs(await loadManifest(args.manifest), { assets: assetIds })).filter((spec) => spec.provider === provider.id);
13082
+ const adopted = await adoptCharacters(provider, await resolve(), lock, args.lock, { onProgress: log, resolve });
13083
+ log(` adopted ${adopted.matched} of ${assetIds.length} imported character asset(s)`);
13084
+ for (const line of adopted.unmatched) log(` not adopted: ${line}`);
13085
+ };
12569
13086
  const runOne = async (styleId, list) => {
12570
13087
  const style = accountManifest.styles[styleId];
12571
13088
  const res = await runSalvage(
@@ -12585,10 +13102,19 @@ async function runSalvage2(args) {
12585
13102
  ` imported ${res.imported} \xB7 kept ${res.kept} \xB7 tagged-discard ${res.discarded}` + (res.failed ? ` \xB7 failed ${res.failed}` : "")
12586
13103
  );
12587
13104
  if (res.imported > 0) await rebaseline();
13105
+ if (res.characterAssetIds?.length) await adoptImported(res.characterAssetIds);
12588
13106
  return res;
12589
13107
  };
12590
13108
  if (args.styles.length) {
12591
- const res = await runOne(args.styles[0], orphans);
13109
+ const styleId = args.styles[0];
13110
+ const isCharacterStyle = characterStyleIds(accountManifest).includes(styleId);
13111
+ const list = isCharacterStyle ? characterOrphans : orphans;
13112
+ if (!list.length) {
13113
+ log(`
13114
+ nothing unclaimed to triage into ${styleId}`);
13115
+ return;
13116
+ }
13117
+ const res = await runOne(styleId, list);
12592
13118
  if (res.discarded) {
12593
13119
  log(`
12594
13120
  Nothing was deleted. To actually remove the discarded objects:`);
@@ -12597,17 +13123,18 @@ async function runSalvage2(args) {
12597
13123
  }
12598
13124
  return;
12599
13125
  }
12600
- if (!matched.size) {
13126
+ const sessions = [...matched, ...characterRoutes.matched];
13127
+ if (!sessions.length) {
12601
13128
  log(`
12602
13129
  nothing matched a known style; nothing to triage`);
12603
13130
  return;
12604
13131
  }
12605
13132
  log(`
12606
- ${matched.size} session(s), one style at a time:`);
13133
+ ${sessions.length} session(s), one style at a time:`);
12607
13134
  let totalDiscarded = 0;
12608
- for (const [styleId, list] of matched) {
13135
+ for (const [styleId, list] of sessions) {
12609
13136
  log(`
12610
- ${styleId} (${list.length})`);
13137
+ ${styleId} (${list.length}${list[0]?.kind === "character" ? " character group(s)" : ""})`);
12611
13138
  const res = await runOne(styleId, list);
12612
13139
  totalDiscarded += res.discarded;
12613
13140
  }
@@ -12625,16 +13152,30 @@ async function runPurge(args) {
12625
13152
  const doomed = [];
12626
13153
  for await (const obj of requireList(provider)()) {
12627
13154
  if (obj.tags.includes("pixelkiln:discard")) {
12628
- doomed.push({ id: obj.id, prompt: obj.prompt });
13155
+ doomed.push({ id: obj.id, prompt: obj.prompt, kind: "object" });
13156
+ }
13157
+ }
13158
+ if (provider.listCharacters && provider.deleteCharacter) {
13159
+ for await (const character of provider.listCharacters()) {
13160
+ if (character.tags.includes("pixelkiln:discard")) {
13161
+ const label = character.stateName && character.stateName !== "Idle" ? ` (${character.stateName})` : "";
13162
+ doomed.push({ id: character.id, prompt: `${character.prompt || character.name}${label}`, kind: "character" });
13163
+ }
12629
13164
  }
12630
13165
  }
12631
13166
  if (!doomed.length) {
12632
13167
  log(` nothing tagged pixelkiln:discard; run \`pixelkiln salvage\` first`);
12633
13168
  return;
12634
13169
  }
13170
+ const characters = doomed.filter((d) => d.kind === "character").length;
13171
+ const objects = doomed.length - characters;
13172
+ const counted = [
13173
+ objects ? `${objects} object(s)` : "",
13174
+ characters ? `${characters} character(s)` : ""
13175
+ ].filter(Boolean).join(" and ");
12635
13176
  log(`
12636
- ${doomed.length} object(s) tagged for discard:`);
12637
- for (const d of doomed.slice(0, 20)) log(` ${d.id} ${d.prompt.slice(0, 60)}`);
13177
+ ${counted} tagged for discard:`);
13178
+ for (const d of doomed.slice(0, 20)) log(` ${d.id} ${d.kind === "character" ? "[character] " : ""}${d.prompt.slice(0, 60)}`);
12638
13179
  if (doomed.length > 20) log(` \u2026 and ${doomed.length - 20} more`);
12639
13180
  if (args.dryRun) {
12640
13181
  log(`
@@ -12645,7 +13186,8 @@ async function runPurge(args) {
12645
13186
  This permanently deletes them from your ${provider.id} account.`);
12646
13187
  log(` Any local files already downloaded are untouched, but the objects`);
12647
13188
  log(` and their URLs are gone and cannot be re-downloaded.`);
12648
- if (!await confirm(` Delete ${doomed.length} object(s)?`, args.yes)) {
13189
+ if (characters) log(` A deleted character takes its animations with it.`);
13190
+ if (!await confirm(` Delete ${counted}?`, args.yes)) {
12649
13191
  log(` aborted`);
12650
13192
  return;
12651
13193
  }
@@ -12653,7 +13195,8 @@ async function runPurge(args) {
12653
13195
  let failed = 0;
12654
13196
  for (const d of doomed) {
12655
13197
  try {
12656
- await requireDelete(provider)(d.id);
13198
+ if (d.kind === "character") await provider.deleteCharacter(d.id);
13199
+ else await requireDelete(provider)(d.id);
12657
13200
  deleted++;
12658
13201
  } catch (err) {
12659
13202
  failed++;
@@ -13608,7 +14151,7 @@ function describeCharacter(spec, entry) {
13608
14151
  };
13609
14152
  }
13610
14153
  if (entry?.generator === "character") {
13611
- 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";
13612
14155
  return {
13613
14156
  generator: "character",
13614
14157
  kind,
@@ -13969,7 +14512,9 @@ async function buildGallerySnapshot(opts) {
13969
14512
  characters: style?.generator === "character" || style?.generator === "objectPro" ? {
13970
14513
  bases: styleItems.filter((item) => item.character?.kind === "base").length,
13971
14514
  states: styleItems.filter((item) => item.character?.kind === "state").length,
13972
- 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
13973
14518
  } : null,
13974
14519
  tags: style?.tags ?? [],
13975
14520
  extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
@@ -14746,6 +15291,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14746
15291
  const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
14747
15292
  const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
14748
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;
14749
15295
  upsert(lock, key, {
14750
15296
  styleId: spec.styleId,
14751
15297
  assetId: spec.assetId,
@@ -14771,6 +15317,11 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14771
15317
  ...spec.revision.direction ? { direction: spec.revision.direction } : {},
14772
15318
  ...spec.revision.enhancePrompt == null ? {} : { enhancePrompt: spec.revision.enhancePrompt }
14773
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,
14774
15325
  status: "pending",
14775
15326
  jobId: previousJobId ?? null,
14776
15327
  ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
@@ -14804,10 +15355,15 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
14804
15355
  const anchor = spec.character?.styleAnchor;
14805
15356
  const anchorEntry = anchor ? lock.entries[lockKey(anchor.spec.styleId, anchor.spec.assetId)] : void 0;
14806
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;
14807
15362
  const { jobId, metadata } = await provider.submit(spec, refs, {
14808
15363
  ...previousJobId ? { previousJobId } : {},
14809
15364
  ...parentEntry?.objectId ? { parentObjectId: parentEntry.objectId } : {},
14810
15365
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
15366
+ ...parentOutputs ? { parentOutputs } : {},
14811
15367
  ...replacedMetadata ? { replacedMetadata } : {},
14812
15368
  ...previousMetadata ? { previousMetadata } : {},
14813
15369
  checkpoint: async (checkpoint) => {