pixelkiln 0.62.0 → 0.64.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
@@ -956,6 +956,14 @@ var CharacterAnimationSchema = z.object({
956
956
  template: z.string().min(1).optional(),
957
957
  /** Direction to animate. */
958
958
  direction: CharacterDirectionSchema.default("south"),
959
+ /**
960
+ * Shorthand for one loop in several directions, instead of `direction`.
961
+ * Expanded when the manifest loads into one loop per named direction
962
+ * (`<id>.<direction>`) plus a mirror for each direction whose flip is
963
+ * not named, exactly as if each had been written out. See
964
+ * `src/loop-directions.ts`.
965
+ */
966
+ directions: z.array(CharacterDirectionSchema).min(1).optional(),
959
967
  /** Frames to generate without a template (v3). Even, 4 to 16. */
960
968
  frames: z.number().int().min(4).max(16).optional(),
961
969
  /** Playback rate recorded with the frames; PixelLab does not store one. */
@@ -984,6 +992,13 @@ var CharacterAnimationSchema = z.object({
984
992
  /** v3 text loops only: let PixelLab expand the action into a fuller motion description first. */
985
993
  enhancePrompt: z.boolean().optional()
986
994
  }).strict().superRefine((animation, context) => {
995
+ if (animation.directions !== void 0) {
996
+ context.addIssue({
997
+ code: z.ZodIssueCode.custom,
998
+ message: "directions is expanded when the manifest loads; validate the loaded manifest, not the raw file",
999
+ path: ["directions"]
1000
+ });
1001
+ }
987
1002
  if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
988
1003
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
989
1004
  }
@@ -8358,8 +8373,8 @@ var PixelLabProvider = class _PixelLabProvider {
8358
8373
  const sources = [];
8359
8374
  const terrainTiles = [];
8360
8375
  for (const [index, tile] of tileset.tiles.entries()) {
8361
- const slug = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
8362
- const role = `tile-${String(index).padStart(2, "0")}-${slug}`;
8376
+ const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
8377
+ const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
8363
8378
  const file = path12.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
8364
8379
  writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
8365
8380
  sources.push({ url: `file://${file}`, role });
@@ -8478,6 +8493,9 @@ var PixelLabProvider = class _PixelLabProvider {
8478
8493
  async delete(assetId) {
8479
8494
  await this.client.deleteObject(assetId);
8480
8495
  }
8496
+ async deleteCharacter(id) {
8497
+ await this.client.deleteCharacter(id);
8498
+ }
8481
8499
  async *listCharacters() {
8482
8500
  for await (const character of this.client.iterateCharacters(100)) {
8483
8501
  yield remoteCharacter(character);
@@ -9549,10 +9567,102 @@ registerProvider({
9549
9567
  import { readFile as readFile6 } from "fs/promises";
9550
9568
  import { existsSync as existsSync8 } from "fs";
9551
9569
  import path13 from "path";
9570
+
9571
+ // src/loop-directions.ts
9572
+ var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
9573
+ var MIRROR_FIELDS = ["category", "tags", "styles"];
9574
+ function loopDirectionId(shorthandId, direction) {
9575
+ return `${shorthandId}.${direction}`;
9576
+ }
9577
+ function expandLoopDirections(raw) {
9578
+ const families = {};
9579
+ const issues = [];
9580
+ if (!isObject2(raw) || !isObject2(raw.assets)) return { raw, families, issues };
9581
+ const assets = raw.assets;
9582
+ if (!Object.values(assets).some((asset) => isObject2(asset) && isObject2(asset.animation) && "directions" in asset.animation)) {
9583
+ return { raw, families, issues };
9584
+ }
9585
+ const expanded = {};
9586
+ const claimed = new Set(Object.keys(assets));
9587
+ for (const [id, asset] of Object.entries(assets)) {
9588
+ if (!isObject2(asset) || !isObject2(asset.animation) || !("directions" in asset.animation)) {
9589
+ expanded[id] = asset;
9590
+ continue;
9591
+ }
9592
+ const where = `assets.${id}.animation.directions`;
9593
+ const { directions: rawDirections, ...animation } = asset.animation;
9594
+ const parsed = CharacterDirectionSchema.array().min(1).safeParse(rawDirections);
9595
+ if (!parsed.success) {
9596
+ issues.push(`${where}: expected a non-empty list of directions (${CharacterDirectionSchema.options.join(", ")})`);
9597
+ continue;
9598
+ }
9599
+ const directions = parsed.data;
9600
+ const duplicate = directions.find((direction, index) => directions.indexOf(direction) !== index);
9601
+ if (duplicate) {
9602
+ issues.push(`${where}: "${duplicate}" is listed twice`);
9603
+ continue;
9604
+ }
9605
+ if ("direction" in animation) {
9606
+ issues.push(`assets.${id}.animation: direction and directions are mutually exclusive; directions names every loop to make`);
9607
+ continue;
9608
+ }
9609
+ const single = SINGLE_OUTPUT_FIELDS.filter((field) => field in asset);
9610
+ if (single.length) {
9611
+ issues.push(
9612
+ `assets.${id}: ${single.join(", ")} name${single.length === 1 ? "s" : ""} one output, but directions makes ${directions.length} loops; declare the loops explicitly to set ${single.length === 1 ? "it" : "them"} per direction`
9613
+ );
9614
+ continue;
9615
+ }
9616
+ const loops = directions.map((direction) => ({ id: loopDirectionId(id, direction), direction }));
9617
+ const named = new Set(directions);
9618
+ const mirrors = [];
9619
+ for (const { id: loopId, direction } of loops) {
9620
+ const flipped = MIRRORED_DIRECTION[direction];
9621
+ if (flipped === direction || named.has(flipped)) continue;
9622
+ named.add(flipped);
9623
+ mirrors.push({ id: loopDirectionId(id, flipped), of: loopId });
9624
+ }
9625
+ const ids = [...loops.map((loop) => loop.id), ...mirrors.map((mirror) => mirror.id)];
9626
+ const collision = ids.find((expandedId) => claimed.has(expandedId));
9627
+ if (collision) {
9628
+ issues.push(`${where}: expands to "${collision}", which is already declared`);
9629
+ continue;
9630
+ }
9631
+ for (const expandedId of ids) claimed.add(expandedId);
9632
+ for (const loop of loops) {
9633
+ expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
9634
+ }
9635
+ for (const mirror of mirrors) {
9636
+ const inherited = {};
9637
+ for (const field of MIRROR_FIELDS) if (field in asset) inherited[field] = asset[field];
9638
+ expanded[mirror.id] = { mirror: mirror.of, ...inherited };
9639
+ }
9640
+ families[id] = ids;
9641
+ }
9642
+ return { raw: { ...raw, assets: expanded }, families, issues };
9643
+ }
9644
+ function expandAssetFilter(ids, families) {
9645
+ if (!families) return [...ids];
9646
+ return [...new Set(ids.flatMap((id) => families[id] ?? [id]))];
9647
+ }
9648
+ function loopShorthandOf(assetId, families) {
9649
+ if (!families) return void 0;
9650
+ return Object.keys(families).find((id) => families[id].includes(assetId));
9651
+ }
9652
+ function isObject2(value) {
9653
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9654
+ }
9655
+
9656
+ // src/manifest.ts
9552
9657
  async function loadManifest(manifestPath) {
9553
9658
  const abs = path13.resolve(manifestPath);
9554
9659
  if (!existsSync8(abs)) throw new ProjectError(`No manifest at ${abs}`);
9555
- const input = ManifestInputSchema.safeParse(JSON.parse(await readFile6(abs, "utf8")));
9660
+ const expansion = expandLoopDirections(JSON.parse(await readFile6(abs, "utf8")));
9661
+ if (expansion.issues.length) {
9662
+ throw new ProjectError(`Manifest at ${abs} is invalid:
9663
+ ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
9664
+ }
9665
+ const input = ManifestInputSchema.safeParse(expansion.raw);
9556
9666
  if (!input.success) {
9557
9667
  const issues = formatManifestIssues(input.error.issues);
9558
9668
  throw new ProjectError(`Manifest at ${abs} is invalid:
@@ -9637,7 +9747,8 @@ ${issues}`);
9637
9747
  throw new ProjectError(`Manifest at ${abs} is invalid:
9638
9748
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
9639
9749
  }
9640
- return { manifest: parsed.data, root: path13.dirname(abs), path: abs };
9750
+ const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
9751
+ return { manifest: parsed.data, root: path13.dirname(abs), path: abs, ...loopFamilies };
9641
9752
  }
9642
9753
  function parentAssetId(asset) {
9643
9754
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
@@ -9721,12 +9832,13 @@ async function resolveSpecs(loaded, filter) {
9721
9832
  );
9722
9833
  }
9723
9834
  }
9724
- for (const unknownAsset of filter?.assets ?? []) {
9835
+ const assetFilter = expandAssetFilter(filter?.assets ?? [], loaded.loopFamilies);
9836
+ for (const unknownAsset of assetFilter) {
9725
9837
  if (!manifest.assets[unknownAsset]) {
9726
9838
  throw new UsageError(`Unknown asset "${unknownAsset}".`);
9727
9839
  }
9728
9840
  }
9729
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
9841
+ const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
9730
9842
  const resolutionAssetIds = new Set(requestedAssetIds);
9731
9843
  for (const assetId of [...requestedAssetIds]) {
9732
9844
  let current = parentAssetId(manifest.assets[assetId]);
@@ -11297,6 +11409,9 @@ async function loadClaims(lockPaths, opts = {}) {
11297
11409
  add(entry.objectId);
11298
11410
  add(entry.reviewObjectId);
11299
11411
  add(entry.jobId);
11412
+ for (const id of [entry.objectId, entry.jobId]) {
11413
+ if (id?.includes("#")) add(id.split("#")[0]);
11414
+ }
11300
11415
  }
11301
11416
  }
11302
11417
  return claimed;
@@ -11320,6 +11435,122 @@ async function findOrphans(provider, claimed, opts = {}) {
11320
11435
  })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
11321
11436
  return { orphans, total: all.length };
11322
11437
  }
11438
+ async function findOrphanCharacters(provider, claimed, opts = {}) {
11439
+ const log2 = opts.onProgress ?? (() => {
11440
+ });
11441
+ if (!provider.listCharacters) return { orphans: [], total: 0, partlyClaimed: 0 };
11442
+ const all = [];
11443
+ for await (const character of provider.listCharacters()) all.push(character);
11444
+ log2(` scanned ${all.length} character(s) on the account`);
11445
+ const isClaimed = (id) => claimed.has(id) || claimed.has(providerClaimId(provider.id, id));
11446
+ const groups = /* @__PURE__ */ new Map();
11447
+ for (const character of all) {
11448
+ const key = character.groupId ?? character.id;
11449
+ groups.set(key, [...groups.get(key) ?? [], character]);
11450
+ }
11451
+ let partlyClaimed = 0;
11452
+ const orphans = [];
11453
+ for (const members of groups.values()) {
11454
+ const unclaimed = members.filter((character) => !isClaimed(character.id));
11455
+ if (!unclaimed.length) continue;
11456
+ if (unclaimed.length < members.length) {
11457
+ partlyClaimed++;
11458
+ continue;
11459
+ }
11460
+ const ordered = [...members].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
11461
+ const base = ordered[0];
11462
+ if (base.status !== "completed" || !base.previewUrl) continue;
11463
+ const states = ordered.length - 1;
11464
+ const loops = ordered.reduce((sum, character) => sum + character.animationCount, 0);
11465
+ orphans.push({
11466
+ id: base.id,
11467
+ prompt: base.prompt || base.name || "(no prompt recorded)",
11468
+ width: base.width,
11469
+ height: base.height,
11470
+ createdAt: base.createdAt,
11471
+ previewUrl: base.previewUrl,
11472
+ tags: base.tags ?? [],
11473
+ kind: "character",
11474
+ note: `character \xB7 ${base.directions} directions \xB7 ${states} state${states === 1 ? "" : "s"} \xB7 ${loops} loop${loops === 1 ? "" : "s"}`,
11475
+ members: ordered.map((character) => ({ id: character.id, tags: character.tags ?? [] }))
11476
+ });
11477
+ }
11478
+ orphans.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
11479
+ return { orphans, total: all.length, partlyClaimed };
11480
+ }
11481
+ function isState(character) {
11482
+ return Boolean(character.stateName && character.stateName !== "Idle");
11483
+ }
11484
+ function characterStyleIds(manifest) {
11485
+ return Object.keys(manifest.styles).filter((id) => manifest.styles[id].generator === "character");
11486
+ }
11487
+ function withoutCharacterStyles(manifest) {
11488
+ const characters = new Set(characterStyleIds(manifest));
11489
+ return {
11490
+ ...manifest,
11491
+ styles: Object.fromEntries(Object.entries(manifest.styles).filter(([id]) => !characters.has(id)))
11492
+ };
11493
+ }
11494
+ function routeCharacterOrphans(orphans, manifest) {
11495
+ const styleIds = characterStyleIds(manifest);
11496
+ const matched = /* @__PURE__ */ new Map();
11497
+ const unmatched = [];
11498
+ const only = { ...manifest, styles: Object.fromEntries(styleIds.map((id) => [id, manifest.styles[id]])) };
11499
+ for (const orphan of orphans) {
11500
+ const styleId = styleIds.length === 1 ? styleIds[0] : styleIds.length ? matchStyleByPattern(orphan.prompt, only) : null;
11501
+ if (!styleId) {
11502
+ unmatched.push(orphan);
11503
+ continue;
11504
+ }
11505
+ matched.set(styleId, [...matched.get(styleId) ?? [], orphan]);
11506
+ }
11507
+ return { matched, unmatched };
11508
+ }
11509
+ function characterSalvageAssets(characters, ctx) {
11510
+ const ordered = [...characters].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
11511
+ const base = ordered[0];
11512
+ const common = { styles: [ctx.styleId], tags: ["salvaged"] };
11513
+ const assets = {};
11514
+ const skipped = [];
11515
+ const baseId = idFromPrompt(base.name || base.prompt, ctx.taken);
11516
+ const size = base.width === base.height && base.width >= 32 && base.width <= 256 && base.width !== ctx.styleSize ? { size: base.width } : {};
11517
+ assets[baseId] = { prompt: base.prompt || base.name || baseId, remoteId: base.id, ...size, ...common };
11518
+ const assetIdOf = /* @__PURE__ */ new Map([[base.id, baseId]]);
11519
+ for (const state of ordered.slice(1)) {
11520
+ const stateId = uniqueId(`${baseId}_${slug(state.stateName ?? "state")}`, ctx.taken);
11521
+ assetIdOf.set(state.id, stateId);
11522
+ assets[stateId] = { prompt: state.prompt || state.stateName || stateId, state: { of: baseId }, remoteId: state.id, ...common };
11523
+ }
11524
+ for (const character of ordered) {
11525
+ const parentId = assetIdOf.get(character.id);
11526
+ for (const animation of character.animations) {
11527
+ if (!animation.frames.length) continue;
11528
+ const label = animation.name || animation.type;
11529
+ if (!animation.groupId) {
11530
+ skipped.push(`${parentId}: "${label}" ${animation.direction} loop has no animation group id to adopt it by`);
11531
+ continue;
11532
+ }
11533
+ const loopId = uniqueId(`${parentId}.${slug(label)}.${animation.direction}`, ctx.taken);
11534
+ assets[loopId] = {
11535
+ prompt: label,
11536
+ animation: { of: parentId, direction: animation.direction },
11537
+ remoteId: `${character.id}#${animation.groupId}`,
11538
+ ...common
11539
+ };
11540
+ }
11541
+ }
11542
+ return { assets, skipped };
11543
+ }
11544
+ function slug(text) {
11545
+ return text.toLowerCase().replace(/^pixelkiln:.*\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "loop";
11546
+ }
11547
+ function uniqueId(base, taken) {
11548
+ let id = base;
11549
+ let n = 2;
11550
+ while (taken.has(id)) id = `${base}_${n++}`;
11551
+ taken.add(id);
11552
+ return id;
11553
+ }
11323
11554
  var MIN_STYLE_FRAGMENT_LENGTH = 12;
11324
11555
  function matchStyleByPattern(prompt, manifest) {
11325
11556
  const p = prompt.toLowerCase();
@@ -11444,18 +11675,22 @@ async function applyTags(provider, decisions, existing, opts = {}) {
11444
11675
  let tagged = 0;
11445
11676
  let failed = 0;
11446
11677
  for (const { id, action } of decisions) {
11447
- const current = (existing.get(id) ?? []).filter(
11448
- (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
11449
- );
11450
- const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
11451
- const next = [...current.slice(0, 19), decisionTag];
11452
- if (!provider.setTags) return { tagged, failed };
11453
- try {
11454
- await provider.setTags(id, next);
11455
- tagged++;
11456
- } catch (err) {
11457
- failed++;
11458
- log2(` tag failed ${id}: ${err instanceof Error ? err.message : String(err)}`);
11678
+ const group = opts.characters?.get(id);
11679
+ const targets = group ?? [{ id, tags: existing.get(id) ?? [] }];
11680
+ for (const target of targets) {
11681
+ const current = target.tags.filter(
11682
+ (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
11683
+ );
11684
+ const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
11685
+ const next = [...current.slice(0, 19), decisionTag];
11686
+ if (!provider.setTags) return { tagged, failed };
11687
+ try {
11688
+ await provider.setTags(target.id, next, group ? "character" : void 0);
11689
+ tagged++;
11690
+ } catch (err) {
11691
+ failed++;
11692
+ log2(` tag failed ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
11693
+ }
11459
11694
  }
11460
11695
  }
11461
11696
  return { tagged, failed };
@@ -11601,7 +11836,7 @@ import path19 from "path";
11601
11836
  // src/pick/salvage-sheet.ts
11602
11837
  var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11603
11838
  function renderSalvageSheet(orphans, ctx) {
11604
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml(o.prompt) }));
11839
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml(o.prompt), ...o.note ? { note: escapeHtml(o.note) } : {} }));
11605
11840
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
11606
11841
  const styleId = ctx ? escapeHtml(ctx.styleId) : null;
11607
11842
  const importDir = ctx ? escapeHtml(ctx.importDir) : null;
@@ -11687,6 +11922,7 @@ ITEMS.forEach((o, i) => {
11687
11922
  el.innerHTML =
11688
11923
  '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
11689
11924
  '<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
11925
+ (o.note ? '<div class="meta">' + o.note + '</div>' : '') +
11690
11926
  '<div class="prompt">' + o.prompt + '</div>' +
11691
11927
  '<div class="acts">' +
11692
11928
  '<button data-a="import">import</button>' +
@@ -11864,6 +12100,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11864
12100
  });
11865
12101
  const byId = new Map(orphans.map((o) => [o.id, o]));
11866
12102
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
12103
+ const characterMembers = new Map(
12104
+ orphans.flatMap((o) => o.kind === "character" && o.members ? [[o.id, o.members]] : [])
12105
+ );
11867
12106
  return serveReviewPage({
11868
12107
  html,
11869
12108
  port: opts.port,
@@ -11880,10 +12119,34 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11880
12119
  const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
11881
12120
  const taken = new Set(Object.keys(ctx.manifest.assets));
11882
12121
  const importedAssetIds = [];
12122
+ const characterAssetIds = [];
11883
12123
  for (const decision of decisions) {
11884
12124
  const orphan = byId.get(decision.id);
11885
12125
  if (!orphan) continue;
11886
- if (decision.action === "import") {
12126
+ if (decision.action === "import" && orphan.kind === "character") {
12127
+ try {
12128
+ if (!provider.getCharacter) throw new Error(`provider ${provider.id} cannot read characters`);
12129
+ const details = [];
12130
+ for (const member of orphan.members ?? [{ id: orphan.id }]) details.push(await provider.getCharacter(member.id));
12131
+ const style = ctx.manifest.styles[ctx.styleId];
12132
+ const { assets, skipped } = characterSalvageAssets(details, {
12133
+ styleId: ctx.styleId,
12134
+ styleSize: style?.size,
12135
+ taken
12136
+ });
12137
+ for (const [assetId, asset] of Object.entries(assets)) {
12138
+ ctx.manifest.assets[assetId] = asset;
12139
+ importedAssetIds.push(assetId);
12140
+ characterAssetIds.push(assetId);
12141
+ }
12142
+ for (const line of skipped) log2(` not imported: ${line}`);
12143
+ result.imported++;
12144
+ log2(` imported character ${orphan.id} as ${Object.keys(assets).length} asset(s)`);
12145
+ } catch (err) {
12146
+ result.failed++;
12147
+ log2(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
12148
+ }
12149
+ } else if (decision.action === "import") {
11887
12150
  try {
11888
12151
  const buf = await provider.download(orphan.previewUrl);
11889
12152
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
@@ -11947,11 +12210,12 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11947
12210
  result.discarded++;
11948
12211
  }
11949
12212
  }
11950
- await applyTags(provider, decisions, existingTags, { onProgress: log2 });
12213
+ await applyTags(provider, decisions, existingTags, { onProgress: log2, characters: characterMembers });
11951
12214
  const raw = JSON.parse(await readFile14(ctx.manifestPath, "utf8"));
11952
12215
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
11953
12216
  await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
11954
12217
  await saveLock(ctx.lockPath, ctx.lock);
12218
+ if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
11955
12219
  return result;
11956
12220
  }
11957
12221
  });
@@ -12393,7 +12657,18 @@ async function runSalvage2(args) {
12393
12657
  const claimed = await loadClaims(lockPaths, { provider: provider.id });
12394
12658
  const { orphans, total } = await findOrphans(provider, claimed, { onProgress: diag });
12395
12659
  diag(` ${claimed.size} claimed \xB7 ${orphans.length} unclaimed of ${total}`);
12396
- if (!orphans.length) {
12660
+ const characterScan = await findOrphanCharacters(provider, claimed, { onProgress: diag });
12661
+ const characterOrphans = characterScan.orphans;
12662
+ if (characterScan.total) {
12663
+ diag(` ${characterOrphans.length} unclaimed character group(s) of ${characterScan.total} character(s)`);
12664
+ }
12665
+ if (characterScan.partlyClaimed) {
12666
+ diag(
12667
+ ` ${characterScan.partlyClaimed} character group(s) are partly claimed (a base or state some project uses);
12668
+ they are left out here. Declare their other states and run \`pixelkiln adopt\` instead.`
12669
+ );
12670
+ }
12671
+ if (!orphans.length && !characterOrphans.length) {
12397
12672
  if (jsonMode) log("[]");
12398
12673
  else log(`
12399
12674
  nothing to triage`);
@@ -12404,9 +12679,10 @@ async function runSalvage2(args) {
12404
12679
  workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
12405
12680
  args.claims
12406
12681
  );
12407
- const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, accountManifest, siblings);
12408
- const multiStyle = Object.keys(accountManifest.styles).length > 1;
12409
- if (multiStyle) {
12682
+ const objectManifest = withoutCharacterStyles(accountManifest);
12683
+ const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, objectManifest, siblings);
12684
+ const multiStyle = Object.keys(objectManifest.styles).length > 1;
12685
+ if (multiStyle && orphans.length) {
12410
12686
  diag(`
12411
12687
  by style (matched against each style's prompt prefix/suffix):`);
12412
12688
  for (const [id, list] of matched) diag(` ${id.padEnd(24)} ${list.length}`);
@@ -12427,16 +12703,33 @@ async function runSalvage2(args) {
12427
12703
  out of the sessions below; force them into one style with --style <id>.`
12428
12704
  );
12429
12705
  }
12706
+ const characterRoutes = routeCharacterOrphans(characterOrphans, accountManifest);
12707
+ if (characterOrphans.length && !characterStyleIds(accountManifest).length) {
12708
+ diag(
12709
+ `
12710
+ ${characterOrphans.length} unclaimed character group(s), but this manifest has no character style
12711
+ to import them into. Add a style with "generator": "character" to triage them.`
12712
+ );
12713
+ } else if (characterRoutes.unmatched.length) {
12714
+ diag(
12715
+ `
12716
+ ${characterRoutes.unmatched.length} character group(s) don't match any character style's pattern;
12717
+ force them into one with --style <character style id>.`
12718
+ );
12719
+ }
12430
12720
  if (args.dryRun) {
12721
+ const listed = [...orphans, ...characterOrphans];
12431
12722
  if (args.json) {
12432
- log(JSON.stringify(orphans, null, 2));
12723
+ log(JSON.stringify(listed, null, 2));
12433
12724
  } else {
12434
- const shown = args.all ? orphans : orphans.slice(0, 30);
12725
+ const shown = args.all ? listed : listed.slice(0, 30);
12435
12726
  for (const o of shown) {
12436
- log(` ${o.id} ${o.width}x${o.height} ${o.createdAt.slice(0, 10)} ${o.prompt.slice(0, 50)}`);
12727
+ log(
12728
+ ` ${o.id} ${o.width}x${o.height} ${o.createdAt.slice(0, 10)} ` + (o.kind === "character" ? `[${o.note}] ` : "") + o.prompt.slice(0, 50)
12729
+ );
12437
12730
  }
12438
- if (!args.all && orphans.length > 30) {
12439
- log(` \u2026 and ${orphans.length - 30} more (--all for the full list, --json for machine-readable)`);
12731
+ if (!args.all && listed.length > 30) {
12732
+ log(` \u2026 and ${listed.length - 30} more (--all for the full list, --json for machine-readable)`);
12440
12733
  }
12441
12734
  }
12442
12735
  diag(`
@@ -12457,6 +12750,12 @@ async function runSalvage2(args) {
12457
12750
  await saveLock(args.lock, lock);
12458
12751
  if (n) log(` baselined ${n} imported asset(s) against the manifest`);
12459
12752
  };
12753
+ const adoptImported = async (assetIds) => {
12754
+ const resolve = async () => (await resolveSpecs(await loadManifest(args.manifest), { assets: assetIds })).filter((spec) => spec.provider === provider.id);
12755
+ const adopted = await adoptCharacters(provider, await resolve(), lock, args.lock, { onProgress: log, resolve });
12756
+ log(` adopted ${adopted.matched} of ${assetIds.length} imported character asset(s)`);
12757
+ for (const line of adopted.unmatched) log(` not adopted: ${line}`);
12758
+ };
12460
12759
  const runOne = async (styleId, list) => {
12461
12760
  const style = accountManifest.styles[styleId];
12462
12761
  const res = await runSalvage(
@@ -12476,10 +12775,19 @@ async function runSalvage2(args) {
12476
12775
  ` imported ${res.imported} \xB7 kept ${res.kept} \xB7 tagged-discard ${res.discarded}` + (res.failed ? ` \xB7 failed ${res.failed}` : "")
12477
12776
  );
12478
12777
  if (res.imported > 0) await rebaseline();
12778
+ if (res.characterAssetIds?.length) await adoptImported(res.characterAssetIds);
12479
12779
  return res;
12480
12780
  };
12481
12781
  if (args.styles.length) {
12482
- const res = await runOne(args.styles[0], orphans);
12782
+ const styleId = args.styles[0];
12783
+ const isCharacterStyle = characterStyleIds(accountManifest).includes(styleId);
12784
+ const list = isCharacterStyle ? characterOrphans : orphans;
12785
+ if (!list.length) {
12786
+ log(`
12787
+ nothing unclaimed to triage into ${styleId}`);
12788
+ return;
12789
+ }
12790
+ const res = await runOne(styleId, list);
12483
12791
  if (res.discarded) {
12484
12792
  log(`
12485
12793
  Nothing was deleted. To actually remove the discarded objects:`);
@@ -12488,17 +12796,18 @@ async function runSalvage2(args) {
12488
12796
  }
12489
12797
  return;
12490
12798
  }
12491
- if (!matched.size) {
12799
+ const sessions = [...matched, ...characterRoutes.matched];
12800
+ if (!sessions.length) {
12492
12801
  log(`
12493
12802
  nothing matched a known style; nothing to triage`);
12494
12803
  return;
12495
12804
  }
12496
12805
  log(`
12497
- ${matched.size} session(s), one style at a time:`);
12806
+ ${sessions.length} session(s), one style at a time:`);
12498
12807
  let totalDiscarded = 0;
12499
- for (const [styleId, list] of matched) {
12808
+ for (const [styleId, list] of sessions) {
12500
12809
  log(`
12501
- ${styleId} (${list.length})`);
12810
+ ${styleId} (${list.length}${list[0]?.kind === "character" ? " character group(s)" : ""})`);
12502
12811
  const res = await runOne(styleId, list);
12503
12812
  totalDiscarded += res.discarded;
12504
12813
  }
@@ -12516,16 +12825,30 @@ async function runPurge(args) {
12516
12825
  const doomed = [];
12517
12826
  for await (const obj of requireList(provider)()) {
12518
12827
  if (obj.tags.includes("pixelkiln:discard")) {
12519
- doomed.push({ id: obj.id, prompt: obj.prompt });
12828
+ doomed.push({ id: obj.id, prompt: obj.prompt, kind: "object" });
12829
+ }
12830
+ }
12831
+ if (provider.listCharacters && provider.deleteCharacter) {
12832
+ for await (const character of provider.listCharacters()) {
12833
+ if (character.tags.includes("pixelkiln:discard")) {
12834
+ const label = character.stateName && character.stateName !== "Idle" ? ` (${character.stateName})` : "";
12835
+ doomed.push({ id: character.id, prompt: `${character.prompt || character.name}${label}`, kind: "character" });
12836
+ }
12520
12837
  }
12521
12838
  }
12522
12839
  if (!doomed.length) {
12523
12840
  log(` nothing tagged pixelkiln:discard; run \`pixelkiln salvage\` first`);
12524
12841
  return;
12525
12842
  }
12843
+ const characters = doomed.filter((d) => d.kind === "character").length;
12844
+ const objects = doomed.length - characters;
12845
+ const counted = [
12846
+ objects ? `${objects} object(s)` : "",
12847
+ characters ? `${characters} character(s)` : ""
12848
+ ].filter(Boolean).join(" and ");
12526
12849
  log(`
12527
- ${doomed.length} object(s) tagged for discard:`);
12528
- for (const d of doomed.slice(0, 20)) log(` ${d.id} ${d.prompt.slice(0, 60)}`);
12850
+ ${counted} tagged for discard:`);
12851
+ for (const d of doomed.slice(0, 20)) log(` ${d.id} ${d.kind === "character" ? "[character] " : ""}${d.prompt.slice(0, 60)}`);
12529
12852
  if (doomed.length > 20) log(` \u2026 and ${doomed.length - 20} more`);
12530
12853
  if (args.dryRun) {
12531
12854
  log(`
@@ -12536,7 +12859,8 @@ async function runPurge(args) {
12536
12859
  This permanently deletes them from your ${provider.id} account.`);
12537
12860
  log(` Any local files already downloaded are untouched, but the objects`);
12538
12861
  log(` and their URLs are gone and cannot be re-downloaded.`);
12539
- if (!await confirm(` Delete ${doomed.length} object(s)?`, args.yes)) {
12862
+ if (characters) log(` A deleted character takes its animations with it.`);
12863
+ if (!await confirm(` Delete ${counted}?`, args.yes)) {
12540
12864
  log(` aborted`);
12541
12865
  return;
12542
12866
  }
@@ -12544,7 +12868,8 @@ async function runPurge(args) {
12544
12868
  let failed = 0;
12545
12869
  for (const d of doomed) {
12546
12870
  try {
12547
- await requireDelete(provider)(d.id);
12871
+ if (d.kind === "character") await provider.deleteCharacter(d.id);
12872
+ else await requireDelete(provider)(d.id);
12548
12873
  deleted++;
12549
12874
  } catch (err) {
12550
12875
  failed++;
@@ -12733,11 +13058,21 @@ function assetStyles(raw, asset) {
12733
13058
  const own = Array.isArray(asset.styles) ? asset.styles : [];
12734
13059
  return own.length ? own : Object.keys(raw.styles ?? {});
12735
13060
  }
13061
+ function declaredAssets(raw) {
13062
+ const expanded = expandLoopDirections(raw).raw;
13063
+ return new Set(Object.keys(expanded.assets ?? {}));
13064
+ }
13065
+ function undeclaredAsset(raw, assetId) {
13066
+ const shorthand = loopShorthandOf(assetId, expandLoopDirections(raw).families);
13067
+ return new ManifestEditError(
13068
+ shorthand ? `asset "${assetId}" is expanded from "${shorthand}"'s animation.directions; edit "${shorthand}" (it applies to every direction), or write this direction out as its own asset to change it alone` : `asset "${assetId}" is not declared by the manifest`
13069
+ );
13070
+ }
12736
13071
  function applyEdit(raw, edit) {
12737
13072
  raw.assets ??= {};
12738
13073
  if (edit.action === "set-source" || edit.action === "clear-source") {
12739
13074
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
12740
- if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
13075
+ if (!asset2) throw undeclaredAsset(raw, edit.assetId);
12741
13076
  if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
12742
13077
  throw new ManifestEditError(`unknown style "${edit.styleId}"`);
12743
13078
  }
@@ -12789,7 +13124,7 @@ function applyEdit(raw, edit) {
12789
13124
  return;
12790
13125
  }
12791
13126
  if (edit.action === "add-asset") {
12792
- if (Object.hasOwn(raw.assets, edit.assetId)) {
13127
+ if (declaredAssets(raw).has(edit.assetId)) {
12793
13128
  throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
12794
13129
  }
12795
13130
  const { styles, tags, ...rest } = edit.asset;
@@ -12803,20 +13138,20 @@ function applyEdit(raw, edit) {
12803
13138
  }
12804
13139
  asset2.styles = styles;
12805
13140
  }
12806
- if (edit.asset.revision && !Object.hasOwn(raw.assets, edit.asset.revision.from)) {
13141
+ if (edit.asset.revision && !declaredAssets(raw).has(edit.asset.revision.from)) {
12807
13142
  throw new ManifestEditError(`revision parent "${edit.asset.revision.from}" is not declared by the manifest`);
12808
13143
  }
12809
- if (edit.asset.state && !Object.hasOwn(raw.assets, edit.asset.state.of)) {
13144
+ if (edit.asset.state && !declaredAssets(raw).has(edit.asset.state.of)) {
12810
13145
  throw new ManifestEditError(`state parent "${edit.asset.state.of}" is not declared by the manifest`);
12811
13146
  }
12812
- if (edit.asset.animation && !Object.hasOwn(raw.assets, edit.asset.animation.of)) {
13147
+ if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
12813
13148
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
12814
13149
  }
12815
13150
  raw.assets[edit.assetId] = asset2;
12816
13151
  return;
12817
13152
  }
12818
13153
  const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
12819
- if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
13154
+ if (!asset) throw undeclaredAsset(raw, edit.assetId);
12820
13155
  const { patch } = edit;
12821
13156
  if (patch.prompt !== void 0) asset.prompt = patch.prompt;
12822
13157
  if (patch.promptForStyle) {
@@ -13604,6 +13939,7 @@ async function describeQuality(media, root, inspection) {
13604
13939
  }
13605
13940
  async function buildGallerySnapshot(opts) {
13606
13941
  const { loaded, specs, lock } = opts;
13942
+ const filter = opts.filter?.assets?.length ? { ...opts.filter, assets: expandAssetFilter(opts.filter.assets, loaded.loopFamilies) } : opts.filter;
13607
13943
  const root = loaded.root;
13608
13944
  const media = /* @__PURE__ */ new Map();
13609
13945
  const plan = await buildPlan(specs, lock);
@@ -13748,7 +14084,7 @@ async function buildGallerySnapshot(opts) {
13748
14084
  for (const [key, entry] of Object.entries(lock.entries)) {
13749
14085
  if (shown.has(key)) continue;
13750
14086
  if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
13751
- if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
14087
+ if (!matchesFilter(filter, entry.styleId, entry.assetId)) continue;
13752
14088
  const outputs = await Promise.all(
13753
14089
  entry.outputs.map(
13754
14090
  (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
@@ -13914,7 +14250,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
13914
14250
  try {
13915
14251
  const loaded = await loadManifest(manifestPath);
13916
14252
  const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
13917
- const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
14253
+ const assetFilter = expandAssetFilter(filter.assets, loaded.loopFamilies).filter((id) => loaded.manifest.assets[id]);
13918
14254
  const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
13919
14255
  const lock = await loadLock(lockPath);
13920
14256
  let projectItems = [];