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/index.js CHANGED
@@ -193,6 +193,14 @@ var CharacterAnimationSchema = z.object({
193
193
  template: z.string().min(1).optional(),
194
194
  /** Direction to animate. */
195
195
  direction: CharacterDirectionSchema.default("south"),
196
+ /**
197
+ * Shorthand for one loop in several directions, instead of `direction`.
198
+ * Expanded when the manifest loads into one loop per named direction
199
+ * (`<id>.<direction>`) plus a mirror for each direction whose flip is
200
+ * not named, exactly as if each had been written out. See
201
+ * `src/loop-directions.ts`.
202
+ */
203
+ directions: z.array(CharacterDirectionSchema).min(1).optional(),
196
204
  /** Frames to generate without a template (v3). Even, 4 to 16. */
197
205
  frames: z.number().int().min(4).max(16).optional(),
198
206
  /** Playback rate recorded with the frames; PixelLab does not store one. */
@@ -221,6 +229,13 @@ var CharacterAnimationSchema = z.object({
221
229
  /** v3 text loops only: let PixelLab expand the action into a fuller motion description first. */
222
230
  enhancePrompt: z.boolean().optional()
223
231
  }).strict().superRefine((animation, context) => {
232
+ if (animation.directions !== void 0) {
233
+ context.addIssue({
234
+ code: z.ZodIssueCode.custom,
235
+ message: "directions is expanded when the manifest loads; validate the loaded manifest, not the raw file",
236
+ path: ["directions"]
237
+ });
238
+ }
224
239
  if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
225
240
  context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
226
241
  }
@@ -4719,8 +4734,8 @@ var PixelLabProvider = class _PixelLabProvider {
4719
4734
  const sources = [];
4720
4735
  const terrainTiles = [];
4721
4736
  for (const [index, tile] of tileset.tiles.entries()) {
4722
- const slug = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
4723
- const role = `tile-${String(index).padStart(2, "0")}-${slug}`;
4737
+ const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
4738
+ const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
4724
4739
  const file = path.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
4725
4740
  writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
4726
4741
  sources.push({ url: `file://${file}`, role });
@@ -4839,6 +4854,9 @@ var PixelLabProvider = class _PixelLabProvider {
4839
4854
  async delete(assetId) {
4840
4855
  await this.client.deleteObject(assetId);
4841
4856
  }
4857
+ async deleteCharacter(id) {
4858
+ await this.client.deleteCharacter(id);
4859
+ }
4842
4860
  async *listCharacters() {
4843
4861
  for await (const character of this.client.iterateCharacters(100)) {
4844
4862
  yield remoteCharacter(character);
@@ -6862,6 +6880,8 @@ var FakeProvider = class {
6862
6880
  this.tags.set(objectId, [...tags]);
6863
6881
  const asset = this.assets.get(objectId);
6864
6882
  if (asset) asset.tags = [...tags];
6883
+ const character = this.characters.get(objectId.split("#")[0]);
6884
+ if (character) character.tags = [...tags];
6865
6885
  }
6866
6886
  /**
6867
6887
  * Assigned in the constructor rather than declared as a method, so a test can
@@ -6896,6 +6916,10 @@ var FakeProvider = class {
6896
6916
  this.deleted.push(assetId);
6897
6917
  this.assets.delete(assetId);
6898
6918
  }
6919
+ async deleteCharacter(id) {
6920
+ this.deleted.push(id);
6921
+ this.characters.delete(id);
6922
+ }
6899
6923
  /** Seed a pre-existing remote asset, for adopt / salvage tests. */
6900
6924
  seed(asset) {
6901
6925
  const full = {
@@ -6992,6 +7016,91 @@ import { readFile as readFile3 } from "fs/promises";
6992
7016
  import { existsSync as existsSync4 } from "fs";
6993
7017
  import path5 from "path";
6994
7018
 
7019
+ // src/loop-directions.ts
7020
+ var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
7021
+ var MIRROR_FIELDS = ["category", "tags", "styles"];
7022
+ function loopDirectionId(shorthandId, direction) {
7023
+ return `${shorthandId}.${direction}`;
7024
+ }
7025
+ function expandLoopDirections(raw) {
7026
+ const families = {};
7027
+ const issues = [];
7028
+ if (!isObject2(raw) || !isObject2(raw.assets)) return { raw, families, issues };
7029
+ const assets = raw.assets;
7030
+ if (!Object.values(assets).some((asset) => isObject2(asset) && isObject2(asset.animation) && "directions" in asset.animation)) {
7031
+ return { raw, families, issues };
7032
+ }
7033
+ const expanded = {};
7034
+ const claimed = new Set(Object.keys(assets));
7035
+ for (const [id, asset] of Object.entries(assets)) {
7036
+ if (!isObject2(asset) || !isObject2(asset.animation) || !("directions" in asset.animation)) {
7037
+ expanded[id] = asset;
7038
+ continue;
7039
+ }
7040
+ const where = `assets.${id}.animation.directions`;
7041
+ const { directions: rawDirections, ...animation } = asset.animation;
7042
+ const parsed = CharacterDirectionSchema.array().min(1).safeParse(rawDirections);
7043
+ if (!parsed.success) {
7044
+ issues.push(`${where}: expected a non-empty list of directions (${CharacterDirectionSchema.options.join(", ")})`);
7045
+ continue;
7046
+ }
7047
+ const directions = parsed.data;
7048
+ const duplicate = directions.find((direction, index) => directions.indexOf(direction) !== index);
7049
+ if (duplicate) {
7050
+ issues.push(`${where}: "${duplicate}" is listed twice`);
7051
+ continue;
7052
+ }
7053
+ if ("direction" in animation) {
7054
+ issues.push(`assets.${id}.animation: direction and directions are mutually exclusive; directions names every loop to make`);
7055
+ continue;
7056
+ }
7057
+ const single = SINGLE_OUTPUT_FIELDS.filter((field) => field in asset);
7058
+ if (single.length) {
7059
+ issues.push(
7060
+ `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`
7061
+ );
7062
+ continue;
7063
+ }
7064
+ const loops = directions.map((direction) => ({ id: loopDirectionId(id, direction), direction }));
7065
+ const named = new Set(directions);
7066
+ const mirrors = [];
7067
+ for (const { id: loopId, direction } of loops) {
7068
+ const flipped = MIRRORED_DIRECTION[direction];
7069
+ if (flipped === direction || named.has(flipped)) continue;
7070
+ named.add(flipped);
7071
+ mirrors.push({ id: loopDirectionId(id, flipped), of: loopId });
7072
+ }
7073
+ const ids = [...loops.map((loop) => loop.id), ...mirrors.map((mirror) => mirror.id)];
7074
+ const collision = ids.find((expandedId) => claimed.has(expandedId));
7075
+ if (collision) {
7076
+ issues.push(`${where}: expands to "${collision}", which is already declared`);
7077
+ continue;
7078
+ }
7079
+ for (const expandedId of ids) claimed.add(expandedId);
7080
+ for (const loop of loops) {
7081
+ expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
7082
+ }
7083
+ for (const mirror of mirrors) {
7084
+ const inherited = {};
7085
+ for (const field of MIRROR_FIELDS) if (field in asset) inherited[field] = asset[field];
7086
+ expanded[mirror.id] = { mirror: mirror.of, ...inherited };
7087
+ }
7088
+ families[id] = ids;
7089
+ }
7090
+ return { raw: { ...raw, assets: expanded }, families, issues };
7091
+ }
7092
+ function expandAssetFilter(ids, families) {
7093
+ if (!families) return [...ids];
7094
+ return [...new Set(ids.flatMap((id) => families[id] ?? [id]))];
7095
+ }
7096
+ function loopShorthandOf(assetId, families) {
7097
+ if (!families) return void 0;
7098
+ return Object.keys(families).find((id) => families[id].includes(assetId));
7099
+ }
7100
+ function isObject2(value) {
7101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7102
+ }
7103
+
6995
7104
  // src/outputs.ts
6996
7105
  import { existsSync as existsSync3 } from "fs";
6997
7106
  import path4 from "path";
@@ -7310,7 +7419,12 @@ function frameSetFps(entry) {
7310
7419
  async function loadManifest(manifestPath) {
7311
7420
  const abs = path5.resolve(manifestPath);
7312
7421
  if (!existsSync4(abs)) throw new ProjectError(`No manifest at ${abs}`);
7313
- const input = ManifestInputSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
7422
+ const expansion = expandLoopDirections(JSON.parse(await readFile3(abs, "utf8")));
7423
+ if (expansion.issues.length) {
7424
+ throw new ProjectError(`Manifest at ${abs} is invalid:
7425
+ ${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
7426
+ }
7427
+ const input = ManifestInputSchema.safeParse(expansion.raw);
7314
7428
  if (!input.success) {
7315
7429
  const issues = formatManifestIssues(input.error.issues);
7316
7430
  throw new ProjectError(`Manifest at ${abs} is invalid:
@@ -7395,7 +7509,8 @@ ${issues}`);
7395
7509
  throw new ProjectError(`Manifest at ${abs} is invalid:
7396
7510
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
7397
7511
  }
7398
- return { manifest: parsed.data, root: path5.dirname(abs), path: abs };
7512
+ const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
7513
+ return { manifest: parsed.data, root: path5.dirname(abs), path: abs, ...loopFamilies };
7399
7514
  }
7400
7515
  function parentAssetId(asset) {
7401
7516
  return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
@@ -7479,12 +7594,13 @@ async function resolveSpecs(loaded, filter) {
7479
7594
  );
7480
7595
  }
7481
7596
  }
7482
- for (const unknownAsset of filter?.assets ?? []) {
7597
+ const assetFilter = expandAssetFilter(filter?.assets ?? [], loaded.loopFamilies);
7598
+ for (const unknownAsset of assetFilter) {
7483
7599
  if (!manifest.assets[unknownAsset]) {
7484
7600
  throw new UsageError(`Unknown asset "${unknownAsset}".`);
7485
7601
  }
7486
7602
  }
7487
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
7603
+ const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
7488
7604
  const resolutionAssetIds = new Set(requestedAssetIds);
7489
7605
  for (const assetId of [...requestedAssetIds]) {
7490
7606
  let current = parentAssetId(manifest.assets[assetId]);
@@ -14208,11 +14324,21 @@ function assetStyles(raw, asset) {
14208
14324
  const own = Array.isArray(asset.styles) ? asset.styles : [];
14209
14325
  return own.length ? own : Object.keys(raw.styles ?? {});
14210
14326
  }
14327
+ function declaredAssets(raw) {
14328
+ const expanded = expandLoopDirections(raw).raw;
14329
+ return new Set(Object.keys(expanded.assets ?? {}));
14330
+ }
14331
+ function undeclaredAsset(raw, assetId) {
14332
+ const shorthand = loopShorthandOf(assetId, expandLoopDirections(raw).families);
14333
+ return new ManifestEditError(
14334
+ 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`
14335
+ );
14336
+ }
14211
14337
  function applyEdit(raw, edit) {
14212
14338
  raw.assets ??= {};
14213
14339
  if (edit.action === "set-source" || edit.action === "clear-source") {
14214
14340
  const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
14215
- if (!asset2) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
14341
+ if (!asset2) throw undeclaredAsset(raw, edit.assetId);
14216
14342
  if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
14217
14343
  throw new ManifestEditError(`unknown style "${edit.styleId}"`);
14218
14344
  }
@@ -14264,7 +14390,7 @@ function applyEdit(raw, edit) {
14264
14390
  return;
14265
14391
  }
14266
14392
  if (edit.action === "add-asset") {
14267
- if (Object.hasOwn(raw.assets, edit.assetId)) {
14393
+ if (declaredAssets(raw).has(edit.assetId)) {
14268
14394
  throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
14269
14395
  }
14270
14396
  const { styles, tags, ...rest } = edit.asset;
@@ -14278,20 +14404,20 @@ function applyEdit(raw, edit) {
14278
14404
  }
14279
14405
  asset2.styles = styles;
14280
14406
  }
14281
- if (edit.asset.revision && !Object.hasOwn(raw.assets, edit.asset.revision.from)) {
14407
+ if (edit.asset.revision && !declaredAssets(raw).has(edit.asset.revision.from)) {
14282
14408
  throw new ManifestEditError(`revision parent "${edit.asset.revision.from}" is not declared by the manifest`);
14283
14409
  }
14284
- if (edit.asset.state && !Object.hasOwn(raw.assets, edit.asset.state.of)) {
14410
+ if (edit.asset.state && !declaredAssets(raw).has(edit.asset.state.of)) {
14285
14411
  throw new ManifestEditError(`state parent "${edit.asset.state.of}" is not declared by the manifest`);
14286
14412
  }
14287
- if (edit.asset.animation && !Object.hasOwn(raw.assets, edit.asset.animation.of)) {
14413
+ if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
14288
14414
  throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
14289
14415
  }
14290
14416
  raw.assets[edit.assetId] = asset2;
14291
14417
  return;
14292
14418
  }
14293
14419
  const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
14294
- if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
14420
+ if (!asset) throw undeclaredAsset(raw, edit.assetId);
14295
14421
  const { patch } = edit;
14296
14422
  if (patch.prompt !== void 0) asset.prompt = patch.prompt;
14297
14423
  if (patch.promptForStyle) {
@@ -14905,6 +15031,7 @@ async function describeQuality(media, root, inspection) {
14905
15031
  }
14906
15032
  async function buildGallerySnapshot(opts) {
14907
15033
  const { loaded, specs, lock } = opts;
15034
+ const filter = opts.filter?.assets?.length ? { ...opts.filter, assets: expandAssetFilter(opts.filter.assets, loaded.loopFamilies) } : opts.filter;
14908
15035
  const root = loaded.root;
14909
15036
  const media = /* @__PURE__ */ new Map();
14910
15037
  const plan = await buildPlan(specs, lock);
@@ -15049,7 +15176,7 @@ async function buildGallerySnapshot(opts) {
15049
15176
  for (const [key, entry] of Object.entries(lock.entries)) {
15050
15177
  if (shown.has(key)) continue;
15051
15178
  if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
15052
- if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
15179
+ if (!matchesFilter(filter, entry.styleId, entry.assetId)) continue;
15053
15180
  const outputs = await Promise.all(
15054
15181
  entry.outputs.map(
15055
15182
  (output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
@@ -15215,7 +15342,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
15215
15342
  try {
15216
15343
  const loaded = await loadManifest(manifestPath);
15217
15344
  const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
15218
- const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
15345
+ const assetFilter = expandAssetFilter(filter.assets, loaded.loopFamilies).filter((id) => loaded.manifest.assets[id]);
15219
15346
  const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
15220
15347
  const lock = await loadLock(lockPath);
15221
15348
  let projectItems = [];
@@ -16317,6 +16444,9 @@ async function loadClaims(lockPaths, opts = {}) {
16317
16444
  add(entry.objectId);
16318
16445
  add(entry.reviewObjectId);
16319
16446
  add(entry.jobId);
16447
+ for (const id of [entry.objectId, entry.jobId]) {
16448
+ if (id?.includes("#")) add(id.split("#")[0]);
16449
+ }
16320
16450
  }
16321
16451
  }
16322
16452
  return claimed;
@@ -16340,6 +16470,54 @@ async function findOrphans(provider, claimed, opts = {}) {
16340
16470
  })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
16341
16471
  return { orphans, total: all.length };
16342
16472
  }
16473
+ function isState(character) {
16474
+ return Boolean(character.stateName && character.stateName !== "Idle");
16475
+ }
16476
+ function characterSalvageAssets(characters, ctx) {
16477
+ const ordered = [...characters].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
16478
+ const base = ordered[0];
16479
+ const common = { styles: [ctx.styleId], tags: ["salvaged"] };
16480
+ const assets = {};
16481
+ const skipped = [];
16482
+ const baseId = idFromPrompt(base.name || base.prompt, ctx.taken);
16483
+ const size = base.width === base.height && base.width >= 32 && base.width <= 256 && base.width !== ctx.styleSize ? { size: base.width } : {};
16484
+ assets[baseId] = { prompt: base.prompt || base.name || baseId, remoteId: base.id, ...size, ...common };
16485
+ const assetIdOf = /* @__PURE__ */ new Map([[base.id, baseId]]);
16486
+ for (const state of ordered.slice(1)) {
16487
+ const stateId = uniqueId(`${baseId}_${slug(state.stateName ?? "state")}`, ctx.taken);
16488
+ assetIdOf.set(state.id, stateId);
16489
+ assets[stateId] = { prompt: state.prompt || state.stateName || stateId, state: { of: baseId }, remoteId: state.id, ...common };
16490
+ }
16491
+ for (const character of ordered) {
16492
+ const parentId = assetIdOf.get(character.id);
16493
+ for (const animation of character.animations) {
16494
+ if (!animation.frames.length) continue;
16495
+ const label = animation.name || animation.type;
16496
+ if (!animation.groupId) {
16497
+ skipped.push(`${parentId}: "${label}" ${animation.direction} loop has no animation group id to adopt it by`);
16498
+ continue;
16499
+ }
16500
+ const loopId = uniqueId(`${parentId}.${slug(label)}.${animation.direction}`, ctx.taken);
16501
+ assets[loopId] = {
16502
+ prompt: label,
16503
+ animation: { of: parentId, direction: animation.direction },
16504
+ remoteId: `${character.id}#${animation.groupId}`,
16505
+ ...common
16506
+ };
16507
+ }
16508
+ }
16509
+ return { assets, skipped };
16510
+ }
16511
+ function slug(text) {
16512
+ return text.toLowerCase().replace(/^pixelkiln:.*\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "loop";
16513
+ }
16514
+ function uniqueId(base, taken) {
16515
+ let id = base;
16516
+ let n = 2;
16517
+ while (taken.has(id)) id = `${base}_${n++}`;
16518
+ taken.add(id);
16519
+ return id;
16520
+ }
16343
16521
  var MIN_STYLE_FRAGMENT_LENGTH = 12;
16344
16522
  function matchStyleByPattern(prompt, manifest) {
16345
16523
  const p = prompt.toLowerCase();
@@ -16469,18 +16647,22 @@ async function applyTags(provider, decisions, existing, opts = {}) {
16469
16647
  let tagged = 0;
16470
16648
  let failed = 0;
16471
16649
  for (const { id, action } of decisions) {
16472
- const current = (existing.get(id) ?? []).filter(
16473
- (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
16474
- );
16475
- const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
16476
- const next = [...current.slice(0, 19), decisionTag];
16477
- if (!provider.setTags) return { tagged, failed };
16478
- try {
16479
- await provider.setTags(id, next);
16480
- tagged++;
16481
- } catch (err) {
16482
- failed++;
16483
- log(` tag failed ${id}: ${err instanceof Error ? err.message : String(err)}`);
16650
+ const group = opts.characters?.get(id);
16651
+ const targets = group ?? [{ id, tags: existing.get(id) ?? [] }];
16652
+ for (const target of targets) {
16653
+ const current = target.tags.filter(
16654
+ (t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
16655
+ );
16656
+ const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
16657
+ const next = [...current.slice(0, 19), decisionTag];
16658
+ if (!provider.setTags) return { tagged, failed };
16659
+ try {
16660
+ await provider.setTags(target.id, next, group ? "character" : void 0);
16661
+ tagged++;
16662
+ } catch (err) {
16663
+ failed++;
16664
+ log(` tag failed ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
16665
+ }
16484
16666
  }
16485
16667
  }
16486
16668
  return { tagged, failed };
@@ -16493,7 +16675,7 @@ import path33 from "path";
16493
16675
  // src/pick/salvage-sheet.ts
16494
16676
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
16495
16677
  function renderSalvageSheet(orphans, ctx) {
16496
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt) }));
16678
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt), ...o.note ? { note: escapeHtml3(o.note) } : {} }));
16497
16679
  const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
16498
16680
  const styleId = ctx ? escapeHtml3(ctx.styleId) : null;
16499
16681
  const importDir = ctx ? escapeHtml3(ctx.importDir) : null;
@@ -16579,6 +16761,7 @@ ITEMS.forEach((o, i) => {
16579
16761
  el.innerHTML =
16580
16762
  '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
16581
16763
  '<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
16764
+ (o.note ? '<div class="meta">' + o.note + '</div>' : '') +
16582
16765
  '<div class="prompt">' + o.prompt + '</div>' +
16583
16766
  '<div class="acts">' +
16584
16767
  '<button data-a="import">import</button>' +
@@ -16657,6 +16840,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16657
16840
  });
16658
16841
  const byId = new Map(orphans.map((o) => [o.id, o]));
16659
16842
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
16843
+ const characterMembers = new Map(
16844
+ orphans.flatMap((o) => o.kind === "character" && o.members ? [[o.id, o.members]] : [])
16845
+ );
16660
16846
  return serveReviewPage({
16661
16847
  html,
16662
16848
  port: opts.port,
@@ -16673,10 +16859,34 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16673
16859
  const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
16674
16860
  const taken = new Set(Object.keys(ctx.manifest.assets));
16675
16861
  const importedAssetIds = [];
16862
+ const characterAssetIds = [];
16676
16863
  for (const decision of decisions) {
16677
16864
  const orphan = byId.get(decision.id);
16678
16865
  if (!orphan) continue;
16679
- if (decision.action === "import") {
16866
+ if (decision.action === "import" && orphan.kind === "character") {
16867
+ try {
16868
+ if (!provider.getCharacter) throw new Error(`provider ${provider.id} cannot read characters`);
16869
+ const details = [];
16870
+ for (const member of orphan.members ?? [{ id: orphan.id }]) details.push(await provider.getCharacter(member.id));
16871
+ const style = ctx.manifest.styles[ctx.styleId];
16872
+ const { assets, skipped } = characterSalvageAssets(details, {
16873
+ styleId: ctx.styleId,
16874
+ styleSize: style?.size,
16875
+ taken
16876
+ });
16877
+ for (const [assetId, asset] of Object.entries(assets)) {
16878
+ ctx.manifest.assets[assetId] = asset;
16879
+ importedAssetIds.push(assetId);
16880
+ characterAssetIds.push(assetId);
16881
+ }
16882
+ for (const line of skipped) log(` not imported: ${line}`);
16883
+ result.imported++;
16884
+ log(` imported character ${orphan.id} as ${Object.keys(assets).length} asset(s)`);
16885
+ } catch (err) {
16886
+ result.failed++;
16887
+ log(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
16888
+ }
16889
+ } else if (decision.action === "import") {
16680
16890
  try {
16681
16891
  const buf = await provider.download(orphan.previewUrl);
16682
16892
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
@@ -16740,11 +16950,12 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
16740
16950
  result.discarded++;
16741
16951
  }
16742
16952
  }
16743
- await applyTags(provider, decisions, existingTags, { onProgress: log });
16953
+ await applyTags(provider, decisions, existingTags, { onProgress: log, characters: characterMembers });
16744
16954
  const raw = JSON.parse(await readFile26(ctx.manifestPath, "utf8"));
16745
16955
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
16746
16956
  await writeFile13(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
16747
16957
  await saveLock(ctx.lockPath, ctx.lock);
16958
+ if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
16748
16959
  return result;
16749
16960
  }
16750
16961
  });