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/index.js CHANGED
@@ -186,6 +186,27 @@ var CharacterStateSchema = z.object({
186
186
  height: z.number().int().min(16).max(256)
187
187
  }).strict().optional()
188
188
  }).strict();
189
+ var CharacterPortraitSizeSchema = z.union([
190
+ z.literal(16),
191
+ z.literal(32),
192
+ z.literal(48),
193
+ z.literal(64),
194
+ z.literal(128),
195
+ z.literal(160)
196
+ ]);
197
+ var CharacterPortraitSchema = z.object({
198
+ /** The base or state to portray, in the same style. */
199
+ of: z.string().min(1),
200
+ size: CharacterPortraitSizeSchema.default(64)
201
+ }).strict();
202
+ var CharacterOutfitSchema = z.object({
203
+ /** The loop (a character animation asset) to re-clothe, in the same style. */
204
+ of: z.string().min(1),
205
+ /** Manifest-relative image of the outfit to transfer. */
206
+ reference: z.string().min(1),
207
+ /** Extra guidance PixelLab passes through as written, e.g. view/direction hints. */
208
+ additionalInstructions: z.string().max(2e3).optional()
209
+ }).strict();
189
210
  var CharacterAnimationSchema = z.object({
190
211
  /** The character to animate: a base or a state in the same style. */
191
212
  of: z.string().min(1),
@@ -807,6 +828,10 @@ var AssetSchema = z.object({
807
828
  pieces: z.array(UiPieceSchema).min(1).optional(),
808
829
  /** `uiAsset` styles: named, auto-positioned UI element scaffolds. Combine with `pieces`; omit both for a default full-canvas panel. */
809
830
  elements: z.array(UiElementSchema).min(1).optional(),
831
+ /** `character` styles: this asset is a bust portrait of another character asset's south sprite. */
832
+ portrait: CharacterPortraitSchema.optional(),
833
+ /** `character` styles: this asset re-clothes an existing loop's frames with a reference outfit. */
834
+ outfit: CharacterOutfitSchema.optional(),
810
835
  /** `character` styles, `standard` humanoid bases: this character's proportions, over the style's. */
811
836
  proportions: CharacterProportionsSchema.optional(),
812
837
  /**
@@ -883,7 +908,7 @@ var AssetSchema = z.object({
883
908
  path: ["revision"]
884
909
  });
885
910
  }
886
- const shapes = [asset.revision && "revision", asset.state && "state", asset.animation && "animation", asset.mirror && "mirror", asset.batch && "batch"].filter(Boolean);
911
+ 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);
887
912
  if (shapes.length > 1) {
888
913
  context.addIssue({
889
914
  code: z.ZodIssueCode.custom,
@@ -898,24 +923,24 @@ var AssetSchema = z.object({
898
923
  path: ["mirror"]
899
924
  });
900
925
  }
901
- if (asset.prompt === void 0 && !asset.mirror) {
926
+ if (asset.prompt === void 0 && !asset.mirror && !asset.portrait && !asset.outfit) {
902
927
  context.addIssue({
903
928
  code: z.ZodIssueCode.custom,
904
929
  message: "Required",
905
930
  path: ["prompt"]
906
931
  });
907
932
  }
908
- if (asset.reference && (asset.state || asset.animation || asset.mirror)) {
933
+ if (asset.reference && (asset.state || asset.animation || asset.mirror || asset.portrait || asset.outfit)) {
909
934
  context.addIssue({
910
935
  code: z.ZodIssueCode.custom,
911
- message: "a reference sprite belongs on a base; a state, animation, or mirror takes its look from its parent",
936
+ message: "a reference sprite belongs on a base; a state, animation, mirror, portrait, or outfit takes its look from its parent",
912
937
  path: ["reference"]
913
938
  });
914
939
  }
915
- if (asset.concept && (asset.state || asset.animation || asset.mirror)) {
940
+ if (asset.concept && (asset.state || asset.animation || asset.mirror || asset.portrait || asset.outfit)) {
916
941
  context.addIssue({
917
942
  code: z.ZodIssueCode.custom,
918
- message: "a concept image belongs on a base; a state, animation, or mirror takes its look from its parent",
943
+ message: "a concept image belongs on a base; a state, animation, mirror, portrait, or outfit takes its look from its parent",
919
944
  path: ["concept"]
920
945
  });
921
946
  }
@@ -1040,6 +1065,17 @@ var LockEntrySchema = z.object({
1040
1065
  leaderAssetId: z.string().min(1).optional(),
1041
1066
  index: z.number().int().min(1).optional()
1042
1067
  }).strict().nullable().default(null),
1068
+ /**
1069
+ * For an outfit: the loop it re-clothed, a hash over that loop's output
1070
+ * hashes when this was made, and the reference image's own hash. Tracked
1071
+ * the way `mirror` is, not through `specHash`: manifest resolution has no
1072
+ * lock to hash the parent's actual current frame bytes against.
1073
+ */
1074
+ outfit: z.object({
1075
+ sourceAssetId: z.string().min(1),
1076
+ sourceSha256: z.string().regex(/^[0-9a-f]{64}$/),
1077
+ referenceSha256: z.string().regex(/^[0-9a-f]{64}$/)
1078
+ }).strict().nullable().default(null),
1043
1079
  /**
1044
1080
  * Output hashes owned by the previous generation while its replacement is
1045
1081
  * pending. They authorize replacing only unchanged PixelKiln-owned files.
@@ -1443,6 +1479,28 @@ var AnimateObjectSubmitSchema = z2.object({
1443
1479
  submissions: z2.array(DirectionSubmissionSchema),
1444
1480
  usage: UsageSchema
1445
1481
  }).passthrough();
1482
+ var PortraitSubmitSchema = z2.object({
1483
+ background_job_id: z2.string().min(1),
1484
+ status: z2.string().default("processing"),
1485
+ usage: UsageSchema
1486
+ }).passthrough();
1487
+ var PortraitJobSchema = z2.object({
1488
+ status: z2.string(),
1489
+ download_url: z2.string().nullable().optional(),
1490
+ width: z2.number().int().nullable().optional(),
1491
+ height: z2.number().int().nullable().optional()
1492
+ }).passthrough();
1493
+ var SetPortraitSchema = z2.object({
1494
+ character_id: z2.string().min(1),
1495
+ size: z2.number().int().positive(),
1496
+ url: z2.string().min(1),
1497
+ usage: UsageSchema
1498
+ }).passthrough();
1499
+ var TransferOutfitSubmitSchema = z2.object({
1500
+ background_job_id: z2.string().min(1),
1501
+ status: z2.string().default("processing"),
1502
+ usage: UsageSchema
1503
+ }).passthrough();
1446
1504
  var CharacterAnimationDirectionSchema = z2.object({
1447
1505
  direction: z2.string(),
1448
1506
  frame_count: z2.number().int().min(0),
@@ -2058,6 +2116,69 @@ var PixelLabClient = class {
2058
2116
  });
2059
2117
  return validateResponse(AnimateSubmitSchema, raw, "animate-character");
2060
2118
  }
2119
+ /**
2120
+ * A full-body sprite in, a bust portrait out (or the reverse, unused
2121
+ * today). Job-based like a state or `1dir`, not the couple-generations
2122
+ * "conversion" the name suggests: the smallest `resultSize` (16px) billed
2123
+ * 20 generations live (docs/ENDPOINTS.md, "Characters, measured").
2124
+ */
2125
+ async createPortraitCharacterPro(args) {
2126
+ const raw = await this.request("/portrait-character-pro", {
2127
+ method: "POST",
2128
+ body: JSON.stringify({
2129
+ direction: args.direction,
2130
+ image: { type: "base64", base64: args.image.base64, format: args.image.format },
2131
+ ...args.view ? { view: args.view } : {},
2132
+ ...args.resultSize != null ? { result_size: args.resultSize } : {},
2133
+ ...args.seed != null ? { seed: args.seed } : {}
2134
+ })
2135
+ });
2136
+ return validateResponse(PortraitSubmitSchema, raw, "portrait-character-pro");
2137
+ }
2138
+ /** Throws PixelLabError(423) while the job is still drawing, like tiles and map objects. */
2139
+ async getPortraitCharacterJob(jobId) {
2140
+ const raw = await this.request(`/portrait-character-pro/${encodeURIComponent(jobId)}`);
2141
+ return validateResponse(PortraitJobSchema, raw, "portrait-character-pro/{id}");
2142
+ }
2143
+ /**
2144
+ * Attaches a portrait image to a character record. Synchronous and free
2145
+ * (0 generations, live); PixelLab does not link this to
2146
+ * `createPortraitCharacterPro` automatically, and `GET /characters/{id}`
2147
+ * never grows a portrait field, so this response's `url` is the only place
2148
+ * it appears.
2149
+ */
2150
+ async setCharacterPortrait(characterId, image) {
2151
+ const raw = await this.request(`/characters/${encodeURIComponent(characterId)}/portrait`, {
2152
+ method: "POST",
2153
+ body: JSON.stringify({ image: { type: "base64", base64: image.base64, format: image.format } })
2154
+ });
2155
+ return validateResponse(SetPortraitSchema, raw, "characters/{id}/portrait");
2156
+ }
2157
+ /**
2158
+ * Applies a reference outfit to 2–16 existing frames. Job-based like
2159
+ * everything else here, but the completed job returns the result frames
2160
+ * inline as base64 (`last_response.quantized_images`), not as storage
2161
+ * URLs — the one PixelLab character tool that works this way. A live
2162
+ * 2-frame, 92x92 job billed 20 generations (docs/ENDPOINTS.md).
2163
+ */
2164
+ async transferOutfitV2(args) {
2165
+ const image = (img) => ({
2166
+ image: { type: "base64", base64: img.base64, format: img.format },
2167
+ size: { width: img.width, height: img.height }
2168
+ });
2169
+ const raw = await this.request("/transfer-outfit-v2", {
2170
+ method: "POST",
2171
+ body: JSON.stringify({
2172
+ reference_image: image(args.referenceImage),
2173
+ frames: args.frames.map(image),
2174
+ image_size: args.imageSize,
2175
+ ...args.seed != null ? { seed: args.seed } : {},
2176
+ ...args.noBackground !== void 0 ? { no_background: args.noBackground } : {},
2177
+ ...args.additionalInstructions ? { additional_instructions: args.additionalInstructions } : {}
2178
+ })
2179
+ });
2180
+ return validateResponse(TransferOutfitSubmitSchema, raw, "transfer-outfit-v2");
2181
+ }
2061
2182
  async getCharacter(characterId) {
2062
2183
  const raw = await this.request(`/characters/${encodeURIComponent(characterId)}`);
2063
2184
  return validateResponse(CharacterSchema, raw, "characters/{id}");
@@ -3074,7 +3195,16 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3074
3195
  styleTraits: spec.character.styleTraits,
3075
3196
  reference: spec.character.reference ? Object.fromEntries(Object.entries(spec.character.reference).map(([direction, image]) => [direction, image.sha256])) : void 0,
3076
3197
  concept: spec.character.concept?.sha256,
3077
- styleAnchor: spec.character.styleAnchor ? { of: spec.character.styleAnchor.assetId, sha256: spec.character.styleAnchor.sha256 } : void 0
3198
+ styleAnchor: spec.character.styleAnchor ? { of: spec.character.styleAnchor.assetId, sha256: spec.character.styleAnchor.sha256 } : void 0,
3199
+ // The parent's own bytes come from its recorded outputs; the plan
3200
+ // compares those directly (like a mirror), so only the choice of
3201
+ // parent, the reference image's bytes, and any extra guidance
3202
+ // are identity here.
3203
+ outfit: spec.character.outfit ? {
3204
+ of: spec.character.parentAssetId,
3205
+ reference: spec.character.outfit.reference.sha256,
3206
+ additionalInstructions: spec.character.outfit.additionalInstructions
3207
+ } : void 0
3078
3208
  } : void 0,
3079
3209
  // An objectPro family: same shape as character above, minus the
3080
3210
  // fields it has none of (mode/template/proportions/isometric/concept/
@@ -3155,7 +3285,10 @@ function characterCost(spec) {
3155
3285
  if (animation.mode === "v3") return Math.max(1, Math.ceil(px * animation.frames / 65536));
3156
3286
  return generationCost(spec.width, spec.height, "1dir");
3157
3287
  }
3158
- if (character.kind === "state") return generationCost(spec.width, spec.height, "1dir");
3288
+ if (character.kind === "state" || character.kind === "portrait") return generationCost(spec.width, spec.height, "1dir");
3289
+ if (character.kind === "outfit") {
3290
+ return 20;
3291
+ }
3159
3292
  if (character.mode === "standard") return 1;
3160
3293
  if (character.mode === "v3") return 1 + Math.max(1, Math.ceil(px * 8 / 65536));
3161
3294
  if (character.mode === "pro-flash") {
@@ -3272,15 +3405,18 @@ var PixelLabProvider = class _PixelLabProvider {
3272
3405
  return { spacingMs: 2500, maxInFlight: 8 };
3273
3406
  }
3274
3407
  /**
3275
- * Where synchronous pixflux results are parked between `submit` and `fetch`.
3408
+ * Where synchronous pixflux results (or an outfit transfer's inline
3409
+ * frames) are parked between `submit`/poll and `fetch`.
3276
3410
  *
3277
- * pixflux returns the PNG inline rather than a job id, but the pipeline is
3278
- * built around submit → poll → fetch running as separate commands. Writing
3279
- * the bytes to a known path keeps that model intact: the "job id" is the
3280
- * filename, polling is an existence check, and downloading is a file read.
3411
+ * pixflux returns the PNG inline rather than a job id, and an outfit
3412
+ * transfer's completed job returns its frames inline too, but the
3413
+ * pipeline is built around submit → poll → fetch running as separate
3414
+ * commands. Writing the bytes to a known path keeps that model intact:
3415
+ * the id is the filename, polling is an existence check, and downloading
3416
+ * is a file read.
3281
3417
  */
3282
- static cacheDir() {
3283
- const dir = path.join(os.tmpdir(), "pixelkiln-pixflux");
3418
+ static cacheDir(namespace = "pixflux") {
3419
+ const dir = path.join(os.tmpdir(), `pixelkiln-${namespace}`);
3284
3420
  mkdirSync(dir, { recursive: true });
3285
3421
  return dir;
3286
3422
  }
@@ -3540,6 +3676,12 @@ var PixelLabProvider = class _PixelLabProvider {
3540
3676
  const character = spec.character;
3541
3677
  if (!character) throw new Error(`${spec.styleId}/${spec.assetId} has no character shape`);
3542
3678
  const label = `${spec.styleId}/${spec.assetId}`;
3679
+ if (character.kind === "outfit") {
3680
+ const reference = character.outfit.reference;
3681
+ if (reference.width < 32 || reference.width > 256 || reference.height < 32 || reference.height > 256) {
3682
+ throw new Error(`${label}: outfit reference is ${reference.width}x${reference.height}; PixelLab transfer-outfit-v2 takes 32 to 256px per side`);
3683
+ }
3684
+ }
3543
3685
  if (character.kind === "base" && character.proportions !== void 0) {
3544
3686
  if (character.mode !== "standard") {
3545
3687
  throw new Error(`${label}: proportions apply to standard bases; the ${character.mode} engine has none to set`);
@@ -3551,7 +3693,7 @@ var PixelLabProvider = class _PixelLabProvider {
3551
3693
  if (character.kind === "base" && character.enhancePrompt !== void 0 && character.mode !== "v3") {
3552
3694
  throw new Error(`${label}: enhancePrompt applies to v3 bases; the ${character.mode} engine does not take it`);
3553
3695
  }
3554
- if (character.animation) {
3696
+ if (character.animation && character.kind !== "outfit") {
3555
3697
  const { startFrame, endFrame } = character.animation;
3556
3698
  for (const [name, image] of [["start frame", startFrame], ["end frame", endFrame]]) {
3557
3699
  if (image && (image.width > 256 || image.height > 256)) {
@@ -3632,9 +3774,11 @@ var PixelLabProvider = class _PixelLabProvider {
3632
3774
  if (character.styleTraits && character.mode !== "pro-flash") {
3633
3775
  throw new Error(`${label}: styleTraits choose what a pro-flash style image lends; the ${character.mode} engine does not take them`);
3634
3776
  }
3635
- const maxSize = character.mode === "v3" || character.mode === "pro-flash" ? 256 : 128;
3636
- if (spec.width < 16 || spec.height < 16 || spec.width > maxSize || spec.height > maxSize) {
3637
- throw new Error(`PixelLab ${character.mode} characters must be between 16 and ${maxSize} pixels`);
3777
+ if (character.kind !== "portrait") {
3778
+ const maxSize = character.mode === "v3" || character.mode === "pro-flash" ? 256 : 128;
3779
+ if (spec.width < 16 || spec.height < 16 || spec.width > maxSize || spec.height > maxSize) {
3780
+ throw new Error(`PixelLab ${character.mode} characters must be between 16 and ${maxSize} pixels`);
3781
+ }
3638
3782
  }
3639
3783
  if (character.mode === "pro-flash" && character.kind === "base" && (spec.width % 4 || spec.height % 4)) {
3640
3784
  throw new Error(`PixelLab pro-flash characters are sized in multiples of 4 pixels; ${spec.width}x${spec.height} is not`);
@@ -3643,9 +3787,11 @@ var PixelLabProvider = class _PixelLabProvider {
3643
3787
  if (!templates.includes(character.template)) {
3644
3788
  throw new Error(`PixelLab ${character.mode} character template must be one of: ${templates.join(", ")}`);
3645
3789
  }
3646
- const views = character.mode === "standard" ? ["low top-down", "high top-down", "side", "perspective", "oblique"] : ["low top-down", "high top-down", "side"];
3790
+ 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"];
3647
3791
  if (!views.includes(spec.view)) {
3648
- throw new Error(`PixelLab ${character.mode} character view must be one of: ${views.join(", ")}`);
3792
+ throw new Error(
3793
+ character.kind === "portrait" ? `PixelLab portrait view must be one of: ${views.join(", ")}` : `PixelLab ${character.mode} character view must be one of: ${views.join(", ")}`
3794
+ );
3649
3795
  }
3650
3796
  if (spec.view === "oblique" && character.directions !== 4) {
3651
3797
  throw new Error("PixelLab oblique characters are 4-direction only");
@@ -3653,7 +3799,7 @@ var PixelLabProvider = class _PixelLabProvider {
3653
3799
  if (character.kind !== "base" && !character.parentAssetId) {
3654
3800
  throw new Error(`${spec.styleId}/${spec.assetId} is a ${character.kind} with no parent`);
3655
3801
  }
3656
- if (character.animation) {
3802
+ if (character.animation && character.kind !== "outfit") {
3657
3803
  const allowed = character.directions === 4 ? CHARACTER_DIRECTIONS_4 : CHARACTER_DIRECTIONS_8;
3658
3804
  if (!allowed.includes(character.animation.direction)) {
3659
3805
  throw new Error(
@@ -3759,6 +3905,43 @@ var PixelLabProvider = class _PixelLabProvider {
3759
3905
  });
3760
3906
  return { jobId: res2.character_id, metadata: { character: { kind: "base", characterId: res2.character_id, mode: character.mode, directions: character.directions, backgroundJobId: res2.background_job_id } } };
3761
3907
  }
3908
+ if (character.kind === "outfit") {
3909
+ const parentOutputs = context?.parentOutputs ?? [];
3910
+ if (parentOutputs.length < 2) {
3911
+ throw new Error(
3912
+ `${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`
3913
+ );
3914
+ }
3915
+ const frames = parentOutputs.slice(0, 16).map((output) => {
3916
+ const bytes = readFileSync(output.path);
3917
+ const dims = imageMetadata(bytes);
3918
+ if (!dims) throw new Error(`${spec.styleId}/${spec.assetId}: outfit source frame is not a readable PNG: ${output.path}`);
3919
+ return { base64: bytes.toString("base64"), format: "png", width: dims.width, height: dims.height };
3920
+ });
3921
+ const outfit = character.outfit;
3922
+ const referenceBytes = readFileSync(outfit.reference.path);
3923
+ if (sha256(referenceBytes) !== outfit.reference.sha256) {
3924
+ throw new Error(`${spec.styleId}/${spec.assetId}: outfit reference changed after the manifest was resolved: ${outfit.reference.path}`);
3925
+ }
3926
+ const res2 = await this.client.transferOutfitV2({
3927
+ referenceImage: { base64: referenceBytes.toString("base64"), format: "png", width: outfit.reference.width, height: outfit.reference.height },
3928
+ frames,
3929
+ imageSize: { width: spec.width, height: spec.height },
3930
+ seed: spec.seed,
3931
+ noBackground: spec.noBackground,
3932
+ additionalInstructions: outfit.additionalInstructions
3933
+ });
3934
+ return {
3935
+ jobId: res2.background_job_id,
3936
+ metadata: {
3937
+ character: {
3938
+ kind: "outfit",
3939
+ backgroundJobId: res2.background_job_id,
3940
+ frameRoles: parentOutputs.slice(0, 16).map((output) => output.role ?? null)
3941
+ }
3942
+ }
3943
+ };
3944
+ }
3762
3945
  const parentId = context?.parentObjectId;
3763
3946
  if (!parentId) {
3764
3947
  throw new Error(
@@ -3777,6 +3960,24 @@ var PixelLabProvider = class _PixelLabProvider {
3777
3960
  });
3778
3961
  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 } } };
3779
3962
  }
3963
+ if (character.kind === "portrait") {
3964
+ const parentFile = character.parentFile;
3965
+ const bytes = readFileSync(parentFile);
3966
+ if (character.parentSha256 && sha256(bytes) !== character.parentSha256) {
3967
+ throw new Error(`${spec.styleId}/${spec.assetId}: portrait source changed after the manifest was resolved: ${parentFile}`);
3968
+ }
3969
+ const res2 = await this.client.createPortraitCharacterPro({
3970
+ direction: "character_to_portrait",
3971
+ image: { base64: bytes.toString("base64"), format: "png" },
3972
+ view: spec.view,
3973
+ resultSize: character.portrait.size,
3974
+ seed: spec.seed
3975
+ });
3976
+ return {
3977
+ jobId: res2.background_job_id,
3978
+ metadata: { character: { kind: "portrait", parentCharacterId: parentId, backgroundJobId: res2.background_job_id } }
3979
+ };
3980
+ }
3780
3981
  const animation = character.animation;
3781
3982
  const name = characterAnimationName(spec);
3782
3983
  const replaced = context?.replacedMetadata?.character ?? {};
@@ -3890,6 +4091,9 @@ var PixelLabProvider = class _PixelLabProvider {
3890
4091
  async pollCharacter(jobId, context) {
3891
4092
  const animation = decodeAnimationJob(jobId);
3892
4093
  if (animation) return this.pollCharacterAnimation(animation, context);
4094
+ const kind = context?.metadata?.character?.kind ?? context?.spec?.character?.kind;
4095
+ if (kind === "portrait") return this.pollCharacterPortrait(jobId, context);
4096
+ if (kind === "outfit") return this.pollCharacterOutfit(jobId, context);
3893
4097
  const character = await this.client.getCharacter(jobId);
3894
4098
  if (character.status === "failed") return { status: "failed", error: "character generation failed upstream" };
3895
4099
  if (character.status !== "completed" || !character.rotation_urls) return { status: "processing" };
@@ -3915,6 +4119,66 @@ var PixelLabProvider = class _PixelLabProvider {
3915
4119
  billed: await this.billedForJob(backgroundJobId)
3916
4120
  };
3917
4121
  }
4122
+ /**
4123
+ * `jobId` here is `portrait-character-pro`'s own background job id, not a
4124
+ * character id: the endpoint converts an arbitrary image and never touches
4125
+ * the character record. Once the portrait itself is drawn, this also
4126
+ * attaches it to the parent's character record with `SetPortrait` — a
4127
+ * separate, free call PixelLab does not make automatically (docs/PIXELLAB.md).
4128
+ */
4129
+ async pollCharacterPortrait(jobId, context) {
4130
+ const meta = context?.metadata?.character;
4131
+ try {
4132
+ const job = await this.client.getPortraitCharacterJob(jobId);
4133
+ if (job.status === "failed") return { status: "failed", error: "portrait generation failed upstream" };
4134
+ if (job.status !== "completed" || !job.download_url) return { status: "processing" };
4135
+ if (meta?.parentCharacterId) {
4136
+ const bytes = await this.client.download(job.download_url);
4137
+ await this.client.setCharacterPortrait(meta.parentCharacterId, { base64: bytes.toString("base64"), format: "png" });
4138
+ }
4139
+ return {
4140
+ status: "ready",
4141
+ objectId: jobId,
4142
+ sourceUrl: job.download_url,
4143
+ sources: [{ url: job.download_url }],
4144
+ billed: await this.billedForJob(meta?.backgroundJobId)
4145
+ };
4146
+ } catch (err) {
4147
+ if (err instanceof PixelLabError && err.status === 423) return { status: "processing" };
4148
+ throw err;
4149
+ }
4150
+ }
4151
+ /**
4152
+ * No dedicated status endpoint exists for `transfer-outfit-v2`, unlike
4153
+ * portraits and tiles: the background job is the only place to poll. A
4154
+ * completed job returns its frames inline as base64
4155
+ * (`last_response.quantized_images`), not as storage URLs, so they are
4156
+ * cached locally the way a synchronous pixflux result is, and matched back
4157
+ * to the source frames' own roles recorded at submit time.
4158
+ */
4159
+ async pollCharacterOutfit(jobId, context) {
4160
+ const meta = context?.metadata?.character;
4161
+ const job = await this.client.getBackgroundJob(jobId);
4162
+ if (job.status === "failed") return { status: "failed", error: "outfit transfer failed upstream" };
4163
+ if (job.status !== "completed") return { status: "processing" };
4164
+ const images = job.last_response?.quantized_images;
4165
+ if (!images?.length) return { status: "failed", error: "outfit transfer completed with no result frames" };
4166
+ const dir = _PixelLabProvider.cacheDir("outfit");
4167
+ const sources = images.map((image, index) => {
4168
+ if (!image.base64) throw new Error(`outfit transfer frame ${index} has no image data`);
4169
+ const role = meta?.frameRoles?.[index] ?? void 0;
4170
+ const file = path.join(dir, `${jobId}-${index}.png`);
4171
+ writeFileSync(file, Buffer.from(image.base64, "base64"));
4172
+ return { url: `file://${file}`, ...role ? { role } : {} };
4173
+ });
4174
+ return {
4175
+ status: "ready",
4176
+ objectId: jobId,
4177
+ sourceUrl: sources[0].url,
4178
+ sources,
4179
+ billed: await this.billedForJob(meta?.backgroundJobId)
4180
+ };
4181
+ }
3918
4182
  /**
3919
4183
  * The background job is the authoritative record of one direction: it
3920
4184
  * completes with the frame URLs, the animation id, and the direction, and
@@ -4734,8 +4998,8 @@ var PixelLabProvider = class _PixelLabProvider {
4734
4998
  const sources = [];
4735
4999
  const terrainTiles = [];
4736
5000
  for (const [index, tile] of tileset.tiles.entries()) {
4737
- const slug = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
4738
- const role = `tile-${String(index).padStart(2, "0")}-${slug}`;
5001
+ const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
5002
+ const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
4739
5003
  const file = path.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
4740
5004
  writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
4741
5005
  sources.push({ url: `file://${file}`, role });
@@ -4854,6 +5118,9 @@ var PixelLabProvider = class _PixelLabProvider {
4854
5118
  async delete(assetId) {
4855
5119
  await this.client.deleteObject(assetId);
4856
5120
  }
5121
+ async deleteCharacter(id) {
5122
+ await this.client.deleteCharacter(id);
5123
+ }
4857
5124
  async *listCharacters() {
4858
5125
  for await (const character of this.client.iterateCharacters(100)) {
4859
5126
  yield remoteCharacter(character);
@@ -6877,6 +7144,8 @@ var FakeProvider = class {
6877
7144
  this.tags.set(objectId, [...tags]);
6878
7145
  const asset = this.assets.get(objectId);
6879
7146
  if (asset) asset.tags = [...tags];
7147
+ const character = this.characters.get(objectId.split("#")[0]);
7148
+ if (character) character.tags = [...tags];
6880
7149
  }
6881
7150
  /**
6882
7151
  * Assigned in the constructor rather than declared as a method, so a test can
@@ -6911,6 +7180,10 @@ var FakeProvider = class {
6911
7180
  this.deleted.push(assetId);
6912
7181
  this.assets.delete(assetId);
6913
7182
  }
7183
+ async deleteCharacter(id) {
7184
+ this.deleted.push(id);
7185
+ this.characters.delete(id);
7186
+ }
6914
7187
  /** Seed a pre-existing remote asset, for adopt / salvage tests. */
6915
7188
  seed(asset) {
6916
7189
  const full = {
@@ -7504,7 +7777,7 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
7504
7777
  return { manifest: parsed.data, root: path5.dirname(abs), path: abs, ...loopFamilies };
7505
7778
  }
7506
7779
  function parentAssetId(asset) {
7507
- return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
7780
+ return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of ?? asset?.portrait?.of ?? asset?.outfit?.of;
7508
7781
  }
7509
7782
  function formatManifestIssues(issues) {
7510
7783
  return issues.flatMap((issue) => {
@@ -7671,9 +7944,9 @@ async function resolveSpecs(loaded, filter) {
7671
7944
  height = styleImageDimensions.length ? Math.max(...styleImageDimensions.map((img) => img.height)) : style.tileSize ?? 32;
7672
7945
  size = Math.max(width, height);
7673
7946
  } else if (generator === "character") {
7674
- size = asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
7675
- width = asset.state?.canvas?.width ?? size;
7676
- height = asset.state?.canvas?.height ?? size;
7947
+ size = asset.portrait ? asset.portrait.size : asset.state?.canvas ? Math.max(asset.state.canvas.width, asset.state.canvas.height) : asset.size ?? style.size ?? 64;
7948
+ width = asset.portrait?.size ?? asset.state?.canvas?.width ?? size;
7949
+ height = asset.portrait?.size ?? asset.state?.canvas?.height ?? size;
7677
7950
  } else if (generator === "terrain") {
7678
7951
  size = style.terrainTileSize ?? 16;
7679
7952
  width = size;
@@ -7700,12 +7973,17 @@ async function resolveSpecs(loaded, filter) {
7700
7973
  `assets.${assetId}: ${asset.state ? "state" : "animation"} needs a character or objectPro style; "${styleId}" generates ${generator}`
7701
7974
  );
7702
7975
  }
7976
+ if ((asset.portrait || asset.outfit) && generator !== "character") {
7977
+ throw new Error(
7978
+ `assets.${assetId}: ${asset.portrait ? "portrait" : "outfit"} needs a character style; "${styleId}" generates ${generator}`
7979
+ );
7980
+ }
7703
7981
  if ((asset.pieces || asset.elements) && generator !== "uiAsset") {
7704
7982
  throw new Error(
7705
7983
  `assets.${assetId}: ${asset.pieces ? "pieces" : "elements"} needs a uiAsset style; "${styleId}" generates ${generator}`
7706
7984
  );
7707
7985
  }
7708
- const characterKind = asset.animation ? "animation" : asset.state ? "state" : "base";
7986
+ const characterKind = asset.animation ? "animation" : asset.state ? "state" : asset.portrait ? "portrait" : "base";
7709
7987
  const subject = asset.promptByStyle[styleId] ?? asset.prompt ?? "";
7710
7988
  let terrainLowerDescription;
7711
7989
  let terrainUpperDescription;
@@ -7797,7 +8075,9 @@ async function resolveSpecs(loaded, filter) {
7797
8075
  // its parent's pixels, not a new character: giving it a base shape
7798
8076
  // would have packing, the gallery, and adoption treat it as one.
7799
8077
  ...generator === "character" && !asset.revision ? { character: await resolveCharacterShape(asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7800
- ...generator === "objectPro" && !asset.revision ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
8078
+ // A portrait needs a character style (refused above), so an
8079
+ // objectPro asset is only ever a base, state, or animation.
8080
+ ...generator === "objectPro" && !asset.revision && characterKind !== "portrait" ? { objectPro: await resolveObjectProShape(assetId, asset, style, characterKind, { root, load: loadStyleImage }) } : {},
7801
8081
  cost: generator === "tiles" ? tilesCost(tileSize, tileVariations) : generator === "terrain" ? tilesCost(size, terrainTiles) : generator === "isometricTile" ? isometricTileCost() : generationCost(width, height, generator),
7802
8082
  costUnit: "generations",
7803
8083
  candidates: generator === "tiles" ? tileVariations : generator === "terrain" ? terrainTiles : generator === "1dir" || generator === "imagePro" ? candidateCount(size) : 1
@@ -7908,7 +8188,7 @@ async function resolveSpecs(loaded, filter) {
7908
8188
  styleAnchor: { assetId: anchorId, spec: anchorSpec, file, sha256: existsSync4(file) ? await sha256File(file) : null }
7909
8189
  };
7910
8190
  }
7911
- const characterParent = asset.state?.of ?? asset.animation?.of;
8191
+ const characterParent = asset.state?.of ?? asset.animation?.of ?? asset.portrait?.of;
7912
8192
  if (resolved.character && characterParent) {
7913
8193
  const parentSpec = await finalize(characterParent);
7914
8194
  const parentDirections = parentSpec.character?.directions ?? 8;
@@ -7941,6 +8221,38 @@ async function resolveSpecs(loaded, filter) {
7941
8221
  directions: resolved.objectPro.kind === "base" ? resolved.objectPro.directions : parentSpec.objectPro.directions
7942
8222
  };
7943
8223
  }
8224
+ if (asset.outfit) {
8225
+ if (asset.outfit.of === assetId) throw new Error(`assets.${assetId}: an asset cannot re-clothe itself`);
8226
+ const sourceSpec = await finalize(asset.outfit.of);
8227
+ if (sourceSpec.character?.kind !== "animation") {
8228
+ throw new Error(`assets.${assetId}: outfit "${asset.outfit.of}" is not a character loop`);
8229
+ }
8230
+ const referenceImage = await loadStyleImage(asset.outfit.reference, "Outfit reference image");
8231
+ resolved.character = {
8232
+ kind: "outfit",
8233
+ mode: sourceSpec.character.mode,
8234
+ directions: sourceSpec.character.directions,
8235
+ template: sourceSpec.character.template,
8236
+ parentAssetId: asset.outfit.of,
8237
+ parentSpec: sourceSpec,
8238
+ animation: sourceSpec.character.animation,
8239
+ outfit: {
8240
+ reference: {
8241
+ path: path5.resolve(root, asset.outfit.reference),
8242
+ sha256: referenceImage.hash,
8243
+ width: referenceImage.width,
8244
+ height: referenceImage.height,
8245
+ format: referenceImage.format
8246
+ },
8247
+ ...asset.outfit.additionalInstructions ? { additionalInstructions: asset.outfit.additionalInstructions } : {}
8248
+ }
8249
+ };
8250
+ resolved.generator = sourceSpec.generator;
8251
+ resolved.width = sourceSpec.width;
8252
+ resolved.height = sourceSpec.height;
8253
+ resolved.size = sourceSpec.size;
8254
+ resolved.prompt = "";
8255
+ }
7944
8256
  if (asset.revision) {
7945
8257
  if (!activeProvider.supportsRevision?.(asset.revision.mode)) {
7946
8258
  throw new Error(
@@ -8214,6 +8526,9 @@ async function resolveCharacterShape(asset, style, kind, files) {
8214
8526
  ...asset.state.canvas ? { canvas: asset.state.canvas } : {}
8215
8527
  };
8216
8528
  }
8529
+ if (asset.portrait) {
8530
+ shape.portrait = { size: asset.portrait.size };
8531
+ }
8217
8532
  if (asset.animation) {
8218
8533
  const animationMode = asset.animation.mode ?? (asset.animation.template ? "template" : "v3");
8219
8534
  const pose = async (rel, what) => {
@@ -9766,6 +10081,7 @@ import { existsSync as existsSync7 } from "fs";
9766
10081
  import path11 from "path";
9767
10082
  async function inspectRevisionReadiness(spec, lock) {
9768
10083
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
10084
+ if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
9769
10085
  if (spec.character?.parentSpec) return inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
9770
10086
  if (spec.character?.styleAnchor) return inspectStyleAnchor(spec, lock, /* @__PURE__ */ new Set());
9771
10087
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
@@ -9792,6 +10108,20 @@ async function inspectMirror(spec, lock, seen) {
9792
10108
  const dependency = await inspectParent(source, lock, seen);
9793
10109
  return dependency.ready ? { ready: true, reason: `${label} is current` } : { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
9794
10110
  }
10111
+ async function inspectOutfit(spec, lock, seen) {
10112
+ const character = spec.character;
10113
+ const outfit = character.outfit;
10114
+ const label = `outfit source ${spec.styleId}/${character.parentAssetId}`;
10115
+ const dependency = await inspectParent(character.parentSpec, lock, seen);
10116
+ if (!dependency.ready) return { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
10117
+ if (!existsSync7(outfit.reference.path)) {
10118
+ return { ready: false, reason: `outfit reference is missing: ${outfit.reference.path}` };
10119
+ }
10120
+ if (await sha256File(outfit.reference.path) !== outfit.reference.sha256) {
10121
+ return { ready: false, reason: "outfit reference changed after the manifest was resolved" };
10122
+ }
10123
+ return { ready: true, reason: `${label} is current` };
10124
+ }
9795
10125
  async function inspectCharacterParent(spec, lock, seen) {
9796
10126
  const character = spec.character;
9797
10127
  const parentSpec = character.parentSpec;
@@ -9907,7 +10237,10 @@ async function inspectParent(spec, lock, seen) {
9907
10237
  const inputs = await inspectRevision(spec, lock, nextSeen);
9908
10238
  if (!inputs.ready) return inputs;
9909
10239
  }
9910
- if (spec.character?.parentSpec) {
10240
+ if (spec.character?.kind === "outfit") {
10241
+ const inputs = await inspectOutfit(spec, lock, nextSeen);
10242
+ if (!inputs.ready) return inputs;
10243
+ } else if (spec.character?.parentSpec) {
9911
10244
  const inputs = await inspectCharacterParent(spec, lock, nextSeen);
9912
10245
  if (!inputs.ready) return inputs;
9913
10246
  }
@@ -10030,6 +10363,9 @@ async function buildPlan(specs, lock, opts = {}) {
10030
10363
  } else if (spec.mirror && entry.status === "downloaded" && entry.outputs.some((_output, index) => !existsSync8(currentEntryOutputPath(entry, spec, index)))) {
10031
10364
  state = "stale";
10032
10365
  reason = "mirror output missing on disk; flip it again (no generation cost)";
10366
+ } else if (spec.character?.kind === "outfit" && entry.outfit?.sourceSha256 !== mirrorSourceHash(lock.entries[lockKey(spec.styleId, spec.character.parentAssetId)] ?? { outputs: [] })) {
10367
+ state = "stale";
10368
+ reason = `outfit source ${spec.character.parentAssetId} changed; re-clothe the loop again`;
10033
10369
  } else if (entry.status === "download-failed") {
10034
10370
  state = "recoverable";
10035
10371
  const force = entry.error?.includes("pass --force") ? " --force" : "";
@@ -12727,6 +13063,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
12727
13063
  const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
12728
13064
  const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
12729
13065
  const history = historyAfterReplacing(previousEntry, historyLimit(loaded.manifest), submittedAt ?? (/* @__PURE__ */ new Date()).toISOString());
13066
+ const outfitParentEntry = spec.character?.kind === "outfit" && spec.character.parentSpec ? lock.entries[lockKey(spec.character.parentSpec.styleId, spec.character.parentSpec.assetId)] : void 0;
12730
13067
  upsert(lock, key, {
12731
13068
  styleId: spec.styleId,
12732
13069
  assetId: spec.assetId,
@@ -12752,6 +13089,11 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
12752
13089
  ...spec.revision.direction ? { direction: spec.revision.direction } : {},
12753
13090
  ...spec.revision.enhancePrompt == null ? {} : { enhancePrompt: spec.revision.enhancePrompt }
12754
13091
  } : null,
13092
+ outfit: spec.character?.kind === "outfit" && outfitParentEntry ? {
13093
+ sourceAssetId: spec.character.parentAssetId,
13094
+ sourceSha256: mirrorSourceHash(outfitParentEntry),
13095
+ referenceSha256: spec.character.outfit.reference.sha256
13096
+ } : null,
12755
13097
  status: "pending",
12756
13098
  jobId: previousJobId ?? null,
12757
13099
  ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
@@ -12785,10 +13127,15 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
12785
13127
  const anchor = spec.character?.styleAnchor;
12786
13128
  const anchorEntry = anchor ? lock.entries[lockKey(anchor.spec.styleId, anchor.spec.assetId)] : void 0;
12787
13129
  const replacedMetadata = previousEntry?.providerMetadata?.[provider.id];
13130
+ const parentOutputs = spec.character?.kind === "outfit" && parentEntry ? parentEntry.outputs.map((output, index) => ({
13131
+ path: currentEntryOutputPath(parentEntry, spec.character.parentSpec, index),
13132
+ role: output.role
13133
+ })) : void 0;
12788
13134
  const { jobId, metadata } = await provider.submit(spec, refs, {
12789
13135
  ...previousJobId ? { previousJobId } : {},
12790
13136
  ...parentEntry?.objectId ? { parentObjectId: parentEntry.objectId } : {},
12791
13137
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
13138
+ ...parentOutputs ? { parentOutputs } : {},
12792
13139
  ...replacedMetadata ? { replacedMetadata } : {},
12793
13140
  ...previousMetadata ? { previousMetadata } : {},
12794
13141
  checkpoint: async (checkpoint) => {
@@ -14907,7 +15254,7 @@ function describeCharacter(spec, entry) {
14907
15254
  };
14908
15255
  }
14909
15256
  if (entry?.generator === "character") {
14910
- const kind = recordedCharacter?.kind === "animation" || entry.outputs.some((o) => o.role?.startsWith("frame-")) ? "animation" : "base";
15257
+ const kind = recordedCharacter?.kind === "portrait" ? "portrait" : recordedCharacter?.kind === "outfit" ? "outfit" : recordedCharacter?.kind === "animation" || entry.outputs.some((o) => o.role?.startsWith("frame-")) ? "animation" : "base";
14911
15258
  return {
14912
15259
  generator: "character",
14913
15260
  kind,
@@ -15268,7 +15615,9 @@ async function buildGallerySnapshot(opts) {
15268
15615
  characters: style?.generator === "character" || style?.generator === "objectPro" ? {
15269
15616
  bases: styleItems.filter((item) => item.character?.kind === "base").length,
15270
15617
  states: styleItems.filter((item) => item.character?.kind === "state").length,
15271
- animations: styleItems.filter((item) => item.character?.kind === "animation").length
15618
+ animations: styleItems.filter((item) => item.character?.kind === "animation").length,
15619
+ portraits: styleItems.filter((item) => item.character?.kind === "portrait").length,
15620
+ outfits: styleItems.filter((item) => item.character?.kind === "outfit").length
15272
15621
  } : null,
15273
15622
  tags: style?.tags ?? [],
15274
15623
  extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
@@ -16435,6 +16784,9 @@ async function loadClaims(lockPaths, opts = {}) {
16435
16784
  add(entry.objectId);
16436
16785
  add(entry.reviewObjectId);
16437
16786
  add(entry.jobId);
16787
+ for (const id of [entry.objectId, entry.jobId]) {
16788
+ if (id?.includes("#")) add(id.split("#")[0]);
16789
+ }
16438
16790
  }
16439
16791
  }
16440
16792
  return claimed;
@@ -16458,6 +16810,54 @@ async function findOrphans(provider, claimed, opts = {}) {
16458
16810
  })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
16459
16811
  return { orphans, total: all.length };
16460
16812
  }
16813
+ function isState(character) {
16814
+ return Boolean(character.stateName && character.stateName !== "Idle");
16815
+ }
16816
+ function characterSalvageAssets(characters, ctx) {
16817
+ const ordered = [...characters].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
16818
+ const base = ordered[0];
16819
+ const common = { styles: [ctx.styleId], tags: ["salvaged"] };
16820
+ const assets = {};
16821
+ const skipped = [];
16822
+ const baseId = idFromPrompt(base.name || base.prompt, ctx.taken);
16823
+ const size = base.width === base.height && base.width >= 32 && base.width <= 256 && base.width !== ctx.styleSize ? { size: base.width } : {};
16824
+ assets[baseId] = { prompt: base.prompt || base.name || baseId, remoteId: base.id, ...size, ...common };
16825
+ const assetIdOf = /* @__PURE__ */ new Map([[base.id, baseId]]);
16826
+ for (const state of ordered.slice(1)) {
16827
+ const stateId = uniqueId(`${baseId}_${slug(state.stateName ?? "state")}`, ctx.taken);
16828
+ assetIdOf.set(state.id, stateId);
16829
+ assets[stateId] = { prompt: state.prompt || state.stateName || stateId, state: { of: baseId }, remoteId: state.id, ...common };
16830
+ }
16831
+ for (const character of ordered) {
16832
+ const parentId = assetIdOf.get(character.id);
16833
+ for (const animation of character.animations) {
16834
+ if (!animation.frames.length) continue;
16835
+ const label = animation.name || animation.type;
16836
+ if (!animation.groupId) {
16837
+ skipped.push(`${parentId}: "${label}" ${animation.direction} loop has no animation group id to adopt it by`);
16838
+ continue;
16839
+ }
16840
+ const loopId = uniqueId(`${parentId}.${slug(label)}.${animation.direction}`, ctx.taken);
16841
+ assets[loopId] = {
16842
+ prompt: label,
16843
+ animation: { of: parentId, direction: animation.direction },
16844
+ remoteId: `${character.id}#${animation.groupId}`,
16845
+ ...common
16846
+ };
16847
+ }
16848
+ }
16849
+ return { assets, skipped };
16850
+ }
16851
+ function slug(text) {
16852
+ return text.toLowerCase().replace(/^pixelkiln:.*\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "loop";
16853
+ }
16854
+ function uniqueId(base, taken) {
16855
+ let id = base;
16856
+ let n = 2;
16857
+ while (taken.has(id)) id = `${base}_${n++}`;
16858
+ taken.add(id);
16859
+ return id;
16860
+ }
16461
16861
  var MIN_STYLE_FRAGMENT_LENGTH = 12;
16462
16862
  function matchStyleByPattern(prompt, manifest) {
16463
16863
  const p = prompt.toLowerCase();
@@ -16587,18 +16987,22 @@ async function applyTags(provider, decisions, existing, opts = {}) {
16587
16987
  let tagged = 0;
16588
16988
  let failed = 0;
16589
16989
  for (const { id, action } of decisions) {
16590
- const current = (existing.get(id) ?? []).filter(
16591
- (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
16592
- );
16593
- const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
16594
- const next = [...current.slice(0, 19), decisionTag];
16595
- if (!provider.setTags) return { tagged, failed };
16596
- try {
16597
- await provider.setTags(id, next);
16598
- tagged++;
16599
- } catch (err) {
16600
- failed++;
16601
- log(` tag failed ${id}: ${err instanceof Error ? err.message : String(err)}`);
16990
+ const group = opts.characters?.get(id);
16991
+ const targets = group ?? [{ id, tags: existing.get(id) ?? [] }];
16992
+ for (const target of targets) {
16993
+ const current = target.tags.filter(
16994
+ (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
16995
+ );
16996
+ const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
16997
+ const next = [...current.slice(0, 19), decisionTag];
16998
+ if (!provider.setTags) return { tagged, failed };
16999
+ try {
17000
+ await provider.setTags(target.id, next, group ? "character" : void 0);
17001
+ tagged++;
17002
+ } catch (err) {
17003
+ failed++;
17004
+ log(` tag failed ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
17005
+ }
16602
17006
  }
16603
17007
  }
16604
17008
  return { tagged, failed };
@@ -16611,7 +17015,7 @@ import path33 from "path";
16611
17015
  // src/pick/salvage-sheet.ts
16612
17016
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
16613
17017
  function renderSalvageSheet(orphans, ctx) {
16614
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt) }));
17018
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt), ...o.note ? { note: escapeHtml3(o.note) } : {} }));
16615
17019
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
16616
17020
  const styleId = ctx ? escapeHtml3(ctx.styleId) : null;
16617
17021
  const importDir = ctx ? escapeHtml3(ctx.importDir) : null;
@@ -16697,6 +17101,7 @@ ITEMS.forEach((o, i) => {
16697
17101
  el.innerHTML =
16698
17102
  '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
16699
17103
  '<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
17104
+ (o.note ? '<div class="meta">' + o.note + '</div>' : '') +
16700
17105
  '<div class="prompt">' + o.prompt + '</div>' +
16701
17106
  '<div class="acts">' +
16702
17107
  '<button data-a="import">import</button>' +
@@ -16775,6 +17180,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16775
17180
  });
16776
17181
  const byId = new Map(orphans.map((o) => [o.id, o]));
16777
17182
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
17183
+ const characterMembers = new Map(
17184
+ orphans.flatMap((o) => o.kind === "character" && o.members ? [[o.id, o.members]] : [])
17185
+ );
16778
17186
  return serveReviewPage({
16779
17187
  html,
16780
17188
  port: opts.port,
@@ -16791,10 +17199,34 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16791
17199
  const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
16792
17200
  const taken = new Set(Object.keys(ctx.manifest.assets));
16793
17201
  const importedAssetIds = [];
17202
+ const characterAssetIds = [];
16794
17203
  for (const decision of decisions) {
16795
17204
  const orphan = byId.get(decision.id);
16796
17205
  if (!orphan) continue;
16797
- if (decision.action === "import") {
17206
+ if (decision.action === "import" && orphan.kind === "character") {
17207
+ try {
17208
+ if (!provider.getCharacter) throw new Error(`provider ${provider.id} cannot read characters`);
17209
+ const details = [];
17210
+ for (const member of orphan.members ?? [{ id: orphan.id }]) details.push(await provider.getCharacter(member.id));
17211
+ const style = ctx.manifest.styles[ctx.styleId];
17212
+ const { assets, skipped } = characterSalvageAssets(details, {
17213
+ styleId: ctx.styleId,
17214
+ styleSize: style?.size,
17215
+ taken
17216
+ });
17217
+ for (const [assetId, asset] of Object.entries(assets)) {
17218
+ ctx.manifest.assets[assetId] = asset;
17219
+ importedAssetIds.push(assetId);
17220
+ characterAssetIds.push(assetId);
17221
+ }
17222
+ for (const line of skipped) log(` not imported: ${line}`);
17223
+ result.imported++;
17224
+ log(` imported character ${orphan.id} as ${Object.keys(assets).length} asset(s)`);
17225
+ } catch (err) {
17226
+ result.failed++;
17227
+ log(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
17228
+ }
17229
+ } else if (decision.action === "import") {
16798
17230
  try {
16799
17231
  const buf = await provider.download(orphan.previewUrl);
16800
17232
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
@@ -16858,11 +17290,12 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16858
17290
  result.discarded++;
16859
17291
  }
16860
17292
  }
16861
- await applyTags(provider, decisions, existingTags, { onProgress: log });
17293
+ await applyTags(provider, decisions, existingTags, { onProgress: log, characters: characterMembers });
16862
17294
  const raw = JSON.parse(await readFile26(ctx.manifestPath, "utf8"));
16863
17295
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
16864
17296
  await writeFile13(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
16865
17297
  await saveLock(ctx.lockPath, ctx.lock);
17298
+ if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
16866
17299
  return result;
16867
17300
  }
16868
17301
  });
@@ -16978,6 +17411,9 @@ export {
16978
17411
  CharacterAnimationSchema,
16979
17412
  CharacterDirectionSchema,
16980
17413
  CharacterModeSchema,
17414
+ CharacterOutfitSchema,
17415
+ CharacterPortraitSchema,
17416
+ CharacterPortraitSizeSchema,
16981
17417
  CharacterProportionsSchema,
16982
17418
  CharacterStateSchema,
16983
17419
  ComfyUIProvider,