pixelkiln 0.63.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/README.md CHANGED
@@ -391,8 +391,8 @@ package share one source. Policies: [Contributing](./CONTRIBUTING.md),
391
391
 
392
392
  ## Scope
393
393
 
394
- Salvaging characters, PixelLab's portrait and outfit tools, cross-project
395
- cache reuse, and `workspace find` are not implemented; see the open [issues](https://github.com/gfargo/pixelkiln/issues).
394
+ PixelLab's portrait and outfit tools, cross-project cache reuse, and
395
+ `workspace find` are not implemented; see the open [issues](https://github.com/gfargo/pixelkiln/issues).
396
396
 
397
397
  ## License
398
398
 
package/dist/cli.js CHANGED
@@ -8373,8 +8373,8 @@ var PixelLabProvider = class _PixelLabProvider {
8373
8373
  const sources = [];
8374
8374
  const terrainTiles = [];
8375
8375
  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}`;
8376
+ const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
8377
+ const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
8378
8378
  const file = path12.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
8379
8379
  writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
8380
8380
  sources.push({ url: `file://${file}`, role });
@@ -8493,6 +8493,9 @@ var PixelLabProvider = class _PixelLabProvider {
8493
8493
  async delete(assetId) {
8494
8494
  await this.client.deleteObject(assetId);
8495
8495
  }
8496
+ async deleteCharacter(id) {
8497
+ await this.client.deleteCharacter(id);
8498
+ }
8496
8499
  async *listCharacters() {
8497
8500
  for await (const character of this.client.iterateCharacters(100)) {
8498
8501
  yield remoteCharacter(character);
@@ -11406,6 +11409,9 @@ async function loadClaims(lockPaths, opts = {}) {
11406
11409
  add(entry.objectId);
11407
11410
  add(entry.reviewObjectId);
11408
11411
  add(entry.jobId);
11412
+ for (const id of [entry.objectId, entry.jobId]) {
11413
+ if (id?.includes("#")) add(id.split("#")[0]);
11414
+ }
11409
11415
  }
11410
11416
  }
11411
11417
  return claimed;
@@ -11429,6 +11435,122 @@ async function findOrphans(provider, claimed, opts = {}) {
11429
11435
  })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
11430
11436
  return { orphans, total: all.length };
11431
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
+ }
11432
11554
  var MIN_STYLE_FRAGMENT_LENGTH = 12;
11433
11555
  function matchStyleByPattern(prompt, manifest) {
11434
11556
  const p = prompt.toLowerCase();
@@ -11553,18 +11675,22 @@ async function applyTags(provider, decisions, existing, opts = {}) {
11553
11675
  let tagged = 0;
11554
11676
  let failed = 0;
11555
11677
  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)}`);
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
+ }
11568
11694
  }
11569
11695
  }
11570
11696
  return { tagged, failed };
@@ -11710,7 +11836,7 @@ import path19 from "path";
11710
11836
  // src/pick/salvage-sheet.ts
11711
11837
  var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11712
11838
  function renderSalvageSheet(orphans, ctx) {
11713
- 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) } : {} }));
11714
11840
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
11715
11841
  const styleId = ctx ? escapeHtml(ctx.styleId) : null;
11716
11842
  const importDir = ctx ? escapeHtml(ctx.importDir) : null;
@@ -11796,6 +11922,7 @@ ITEMS.forEach((o, i) => {
11796
11922
  el.innerHTML =
11797
11923
  '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
11798
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>' : '') +
11799
11926
  '<div class="prompt">' + o.prompt + '</div>' +
11800
11927
  '<div class="acts">' +
11801
11928
  '<button data-a="import">import</button>' +
@@ -11973,6 +12100,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11973
12100
  });
11974
12101
  const byId = new Map(orphans.map((o) => [o.id, o]));
11975
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
+ );
11976
12106
  return serveReviewPage({
11977
12107
  html,
11978
12108
  port: opts.port,
@@ -11989,10 +12119,34 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
11989
12119
  const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
11990
12120
  const taken = new Set(Object.keys(ctx.manifest.assets));
11991
12121
  const importedAssetIds = [];
12122
+ const characterAssetIds = [];
11992
12123
  for (const decision of decisions) {
11993
12124
  const orphan = byId.get(decision.id);
11994
12125
  if (!orphan) continue;
11995
- 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") {
11996
12150
  try {
11997
12151
  const buf = await provider.download(orphan.previewUrl);
11998
12152
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
@@ -12056,11 +12210,12 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
12056
12210
  result.discarded++;
12057
12211
  }
12058
12212
  }
12059
- await applyTags(provider, decisions, existingTags, { onProgress: log2 });
12213
+ await applyTags(provider, decisions, existingTags, { onProgress: log2, characters: characterMembers });
12060
12214
  const raw = JSON.parse(await readFile14(ctx.manifestPath, "utf8"));
12061
12215
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
12062
12216
  await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
12063
12217
  await saveLock(ctx.lockPath, ctx.lock);
12218
+ if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
12064
12219
  return result;
12065
12220
  }
12066
12221
  });
@@ -12502,7 +12657,18 @@ async function runSalvage2(args) {
12502
12657
  const claimed = await loadClaims(lockPaths, { provider: provider.id });
12503
12658
  const { orphans, total } = await findOrphans(provider, claimed, { onProgress: diag });
12504
12659
  diag(` ${claimed.size} claimed \xB7 ${orphans.length} unclaimed of ${total}`);
12505
- 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) {
12506
12672
  if (jsonMode) log("[]");
12507
12673
  else log(`
12508
12674
  nothing to triage`);
@@ -12513,9 +12679,10 @@ async function runSalvage2(args) {
12513
12679
  workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
12514
12680
  args.claims
12515
12681
  );
12516
- const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, accountManifest, siblings);
12517
- const multiStyle = Object.keys(accountManifest.styles).length > 1;
12518
- 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) {
12519
12686
  diag(`
12520
12687
  by style (matched against each style's prompt prefix/suffix):`);
12521
12688
  for (const [id, list] of matched) diag(` ${id.padEnd(24)} ${list.length}`);
@@ -12536,16 +12703,33 @@ async function runSalvage2(args) {
12536
12703
  out of the sessions below; force them into one style with --style <id>.`
12537
12704
  );
12538
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
+ }
12539
12720
  if (args.dryRun) {
12721
+ const listed = [...orphans, ...characterOrphans];
12540
12722
  if (args.json) {
12541
- log(JSON.stringify(orphans, null, 2));
12723
+ log(JSON.stringify(listed, null, 2));
12542
12724
  } else {
12543
- const shown = args.all ? orphans : orphans.slice(0, 30);
12725
+ const shown = args.all ? listed : listed.slice(0, 30);
12544
12726
  for (const o of shown) {
12545
- 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
+ );
12546
12730
  }
12547
- if (!args.all && orphans.length > 30) {
12548
- 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)`);
12549
12733
  }
12550
12734
  }
12551
12735
  diag(`
@@ -12566,6 +12750,12 @@ async function runSalvage2(args) {
12566
12750
  await saveLock(args.lock, lock);
12567
12751
  if (n) log(` baselined ${n} imported asset(s) against the manifest`);
12568
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
+ };
12569
12759
  const runOne = async (styleId, list) => {
12570
12760
  const style = accountManifest.styles[styleId];
12571
12761
  const res = await runSalvage(
@@ -12585,10 +12775,19 @@ async function runSalvage2(args) {
12585
12775
  ` imported ${res.imported} \xB7 kept ${res.kept} \xB7 tagged-discard ${res.discarded}` + (res.failed ? ` \xB7 failed ${res.failed}` : "")
12586
12776
  );
12587
12777
  if (res.imported > 0) await rebaseline();
12778
+ if (res.characterAssetIds?.length) await adoptImported(res.characterAssetIds);
12588
12779
  return res;
12589
12780
  };
12590
12781
  if (args.styles.length) {
12591
- 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);
12592
12791
  if (res.discarded) {
12593
12792
  log(`
12594
12793
  Nothing was deleted. To actually remove the discarded objects:`);
@@ -12597,17 +12796,18 @@ async function runSalvage2(args) {
12597
12796
  }
12598
12797
  return;
12599
12798
  }
12600
- if (!matched.size) {
12799
+ const sessions = [...matched, ...characterRoutes.matched];
12800
+ if (!sessions.length) {
12601
12801
  log(`
12602
12802
  nothing matched a known style; nothing to triage`);
12603
12803
  return;
12604
12804
  }
12605
12805
  log(`
12606
- ${matched.size} session(s), one style at a time:`);
12806
+ ${sessions.length} session(s), one style at a time:`);
12607
12807
  let totalDiscarded = 0;
12608
- for (const [styleId, list] of matched) {
12808
+ for (const [styleId, list] of sessions) {
12609
12809
  log(`
12610
- ${styleId} (${list.length})`);
12810
+ ${styleId} (${list.length}${list[0]?.kind === "character" ? " character group(s)" : ""})`);
12611
12811
  const res = await runOne(styleId, list);
12612
12812
  totalDiscarded += res.discarded;
12613
12813
  }
@@ -12625,16 +12825,30 @@ async function runPurge(args) {
12625
12825
  const doomed = [];
12626
12826
  for await (const obj of requireList(provider)()) {
12627
12827
  if (obj.tags.includes("pixelkiln:discard")) {
12628
- 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
+ }
12629
12837
  }
12630
12838
  }
12631
12839
  if (!doomed.length) {
12632
12840
  log(` nothing tagged pixelkiln:discard; run \`pixelkiln salvage\` first`);
12633
12841
  return;
12634
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 ");
12635
12849
  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)}`);
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)}`);
12638
12852
  if (doomed.length > 20) log(` \u2026 and ${doomed.length - 20} more`);
12639
12853
  if (args.dryRun) {
12640
12854
  log(`
@@ -12645,7 +12859,8 @@ async function runPurge(args) {
12645
12859
  This permanently deletes them from your ${provider.id} account.`);
12646
12860
  log(` Any local files already downloaded are untouched, but the objects`);
12647
12861
  log(` and their URLs are gone and cannot be re-downloaded.`);
12648
- 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)) {
12649
12864
  log(` aborted`);
12650
12865
  return;
12651
12866
  }
@@ -12653,7 +12868,8 @@ async function runPurge(args) {
12653
12868
  let failed = 0;
12654
12869
  for (const d of doomed) {
12655
12870
  try {
12656
- await requireDelete(provider)(d.id);
12871
+ if (d.kind === "character") await provider.deleteCharacter(d.id);
12872
+ else await requireDelete(provider)(d.id);
12657
12873
  deleted++;
12658
12874
  } catch (err) {
12659
12875
  failed++;