pixelkiln 0.28.0 → 0.30.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import path30 from "path";
5
- import { existsSync as existsSync23 } from "fs";
5
+ import { existsSync as existsSync24 } from "fs";
6
6
  import { readFile as readFile25 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
@@ -872,12 +872,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
872
872
  }
873
873
  validate(spec, styleImages) {
874
874
  const options = resolvedOptions(spec);
875
- if (spec.source && !spec.revision) {
876
- if (spec.generator === "frames") {
877
- throw new Error("ComfyUI frame sets require generated provider outputs, not asset.source");
878
- }
879
- return;
880
- }
875
+ if (spec.source && !spec.revision && spec.generator !== "frames") return;
881
876
  if (spec.width < 16 || spec.height < 16 || spec.width > 4096 || spec.height > 4096) {
882
877
  throw new Error("ComfyUI output dimensions must be between 16 and 4096 pixels");
883
878
  }
@@ -3483,10 +3478,11 @@ registerProvider({
3483
3478
 
3484
3479
  // src/manifest.ts
3485
3480
  import { readFile as readFile3 } from "fs/promises";
3486
- import { existsSync as existsSync4 } from "fs";
3481
+ import { existsSync as existsSync5 } from "fs";
3487
3482
  import path6 from "path";
3488
3483
 
3489
3484
  // src/outputs.ts
3485
+ import { existsSync as existsSync4 } from "fs";
3490
3486
  import path5 from "path";
3491
3487
 
3492
3488
  // src/lock.ts
@@ -3673,13 +3669,28 @@ function resolveOutputPath(recordedPath, manifestDir) {
3673
3669
  return path5.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path5.sep));
3674
3670
  }
3675
3671
  function expectedOutputPath(spec, role, index, total, mediaType) {
3676
- const originalExt = path5.extname(spec.outFile);
3672
+ return memberPath(spec.outFile, role, index, total, mediaType);
3673
+ }
3674
+ function memberPath(file, role, index, total, mediaType) {
3675
+ const originalExt = path5.extname(file);
3677
3676
  const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3678
- const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3677
+ const stem = originalExt ? file.slice(0, -originalExt.length) : file;
3679
3678
  if (total === 1) return `${stem}${ext}`;
3680
3679
  const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3681
3680
  return `${stem}-${safeRole}${ext}`;
3682
3681
  }
3682
+ function isMemberSetEntry(entry) {
3683
+ return entry.outputs.length > 1 && entry.outputs.every((output) => !output.mediaType || output.mediaType === "image/png");
3684
+ }
3685
+ function sourceIsStem(source, entry, manifestDir) {
3686
+ return isMemberSetEntry(entry) && !existsSync4(path5.resolve(manifestDir, source));
3687
+ }
3688
+ function sourceOutputPath(source, entry, index, manifestDir) {
3689
+ const absolute = path5.resolve(manifestDir, source);
3690
+ if (!sourceIsStem(source, entry, manifestDir)) return absolute;
3691
+ const output = entry.outputs[index];
3692
+ return memberPath(absolute, output.role, index, entry.outputs.length, output.mediaType);
3693
+ }
3683
3694
  function currentOutputPath(output, spec, index, total) {
3684
3695
  return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3685
3696
  }
@@ -3764,7 +3775,7 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
3764
3775
  // src/manifest.ts
3765
3776
  async function loadManifest(manifestPath) {
3766
3777
  const abs = path6.resolve(manifestPath);
3767
- if (!existsSync4(abs)) throw new Error(`No manifest at ${abs}`);
3778
+ if (!existsSync5(abs)) throw new Error(`No manifest at ${abs}`);
3768
3779
  const input = ManifestInputSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
3769
3780
  if (!input.success) {
3770
3781
  const issues = formatManifestIssues(input.error.issues);
@@ -3936,7 +3947,7 @@ async function resolveSpecs(loaded, filter) {
3936
3947
  const abs = path6.resolve(root, rel);
3937
3948
  let hit = styleImageCache.get(abs);
3938
3949
  if (!hit) {
3939
- if (!existsSync4(abs)) throw new Error(`Style image not found: ${abs}`);
3950
+ if (!existsSync5(abs)) throw new Error(`Style image not found: ${abs}`);
3940
3951
  const buf = await readFile3(abs);
3941
3952
  const metadata = imageMetadata(buf);
3942
3953
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
@@ -4177,7 +4188,7 @@ function pngPath(file) {
4177
4188
  return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
4178
4189
  }
4179
4190
  async function optionalRevisionImage(file, label, requiredFormat) {
4180
- if (!existsSync4(file)) return null;
4191
+ if (!existsSync5(file)) return null;
4181
4192
  const bytes = await readFile3(file);
4182
4193
  const metadata = imageMetadata(bytes);
4183
4194
  if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
@@ -4336,12 +4347,15 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
4336
4347
  for (const [key, entry] of entries) {
4337
4348
  const id = key.slice(prefix.length);
4338
4349
  locked.add(id);
4339
- const outputs = resolveEntryOutputs(entry, id, manifestDir);
4350
+ let outputs = resolveEntryOutputs(entry, id, manifestDir);
4340
4351
  const source = sources[id];
4341
4352
  if (source && outputs.length <= 1) {
4342
4353
  inputs.push({ id, path: path7.resolve(manifestDir, source) });
4343
4354
  continue;
4344
4355
  }
4356
+ if (source && sourceIsStem(source, entry, manifestDir)) {
4357
+ outputs = outputs.map((output) => ({ ...output, absolutePath: sourceOutputPath(source, entry, output.index, manifestDir) }));
4358
+ }
4345
4359
  let selected = outputs;
4346
4360
  if (options.outputRoles?.length) {
4347
4361
  selected = outputs.filter(
@@ -4463,11 +4477,21 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
4463
4477
  for (const id of ids) {
4464
4478
  const cell = cells[id];
4465
4479
  const source = sources[id];
4466
- if (source) {
4480
+ const entry = lock.entries[`${styleId}/${id}`];
4481
+ if (source && !(entry && sourceIsStem(source, entry, manifestDir))) {
4467
4482
  placements.push({ id, path: path7.resolve(manifestDir, source), cell });
4468
4483
  continue;
4469
4484
  }
4470
- const entry = lock.entries[`${styleId}/${id}`];
4485
+ if (source && entry) {
4486
+ const selection2 = selectEntryOutput(entry, outputRoles[id]);
4487
+ if (!selection2.ok) {
4488
+ skipped.push({ id, reason: selection2.reason });
4489
+ continue;
4490
+ }
4491
+ const index = entry.outputs.findIndex((output) => output.path === selection2.output.path);
4492
+ placements.push({ id, path: sourceOutputPath(source, entry, Math.max(index, 0), manifestDir), cell });
4493
+ continue;
4494
+ }
4471
4495
  if (!entry) {
4472
4496
  skipped.push({
4473
4497
  id,
@@ -4508,11 +4532,11 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
4508
4532
  }
4509
4533
 
4510
4534
  // src/pipeline/plan.ts
4511
- import { existsSync as existsSync8 } from "fs";
4535
+ import { existsSync as existsSync9 } from "fs";
4512
4536
  import path13 from "path";
4513
4537
 
4514
4538
  // src/pipeline/quality-profile.ts
4515
- import { existsSync as existsSync6 } from "fs";
4539
+ import { existsSync as existsSync7 } from "fs";
4516
4540
  import path11 from "path";
4517
4541
 
4518
4542
  // src/pipeline/refine.ts
@@ -4980,7 +5004,7 @@ async function writeArtifactBundle(files, options = {}) {
4980
5004
 
4981
5005
  // src/pipeline/audit.ts
4982
5006
  import { readFile as readFile5 } from "fs/promises";
4983
- import { existsSync as existsSync5 } from "fs";
5007
+ import { existsSync as existsSync6 } from "fs";
4984
5008
  import path9 from "path";
4985
5009
  function colorDistance(a, b) {
4986
5010
  const rmean = (a.r + b.r) / 2;
@@ -5026,7 +5050,7 @@ async function auditStyle(loaded, specs, styleId, lock) {
5026
5050
  const unreadable = [];
5027
5051
  for (const spec of mine) {
5028
5052
  for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
5029
- if (!existsSync5(output.absolutePath)) {
5053
+ if (!existsSync6(output.absolutePath)) {
5030
5054
  missing.push(output.id);
5031
5055
  continue;
5032
5056
  }
@@ -5056,7 +5080,7 @@ async function auditStyle(loaded, specs, styleId, lock) {
5056
5080
  const refPalettes = [];
5057
5081
  for (const img of style.styleImages) {
5058
5082
  const abs = path9.resolve(loaded.root, img.path);
5059
- if (!existsSync5(abs)) continue;
5083
+ if (!existsSync6(abs)) continue;
5060
5084
  try {
5061
5085
  refPalettes.push(extractPalette(decodePng(await readFile5(abs)), 12));
5062
5086
  } catch {
@@ -5685,7 +5709,7 @@ async function qualitySource(spec, lock) {
5685
5709
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
5686
5710
  }
5687
5711
  const source = path11.resolve(spec.root, spec.source);
5688
- return existsSync6(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5712
+ return existsSync7(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5689
5713
  }
5690
5714
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5691
5715
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -5714,7 +5738,7 @@ async function qualitySource(spec, lock) {
5714
5738
  }
5715
5739
  if (role) roles.add(role);
5716
5740
  const source = currentEntryOutputPath(entry, spec, index);
5717
- if (!existsSync6(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5741
+ if (!existsSync7(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5718
5742
  if (await sha256File(source) !== output.sha256) {
5719
5743
  return { items: null, reason: "raw provider output was modified after download" };
5720
5744
  }
@@ -5733,7 +5757,7 @@ async function inspectQualityProfile(spec, lock) {
5733
5757
  }
5734
5758
  const sourcePaths = source.items.map((item) => item.path);
5735
5759
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
5736
- if (!existsSync6(record)) {
5760
+ if (!existsSync7(record)) {
5737
5761
  return {
5738
5762
  key,
5739
5763
  state: "needs-refinement",
@@ -5914,7 +5938,7 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
5914
5938
  }
5915
5939
 
5916
5940
  // src/pipeline/revision.ts
5917
- import { existsSync as existsSync7 } from "fs";
5941
+ import { existsSync as existsSync8 } from "fs";
5918
5942
  import path12 from "path";
5919
5943
  async function inspectRevisionReadiness(spec, lock) {
5920
5944
  if (!spec.revision) return null;
@@ -5930,14 +5954,14 @@ async function inspectRevision(spec, lock, seen) {
5930
5954
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
5931
5955
  };
5932
5956
  }
5933
- if (!revision.sourceSha256 || !existsSync7(revision.sourceFile)) {
5957
+ if (!revision.sourceSha256 || !existsSync8(revision.sourceFile)) {
5934
5958
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
5935
5959
  }
5936
5960
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
5937
5961
  return { ready: false, reason: "revision source changed after the manifest was resolved" };
5938
5962
  }
5939
5963
  if (revision.maskFile) {
5940
- if (!revision.maskSha256 || !existsSync7(revision.maskFile)) {
5964
+ if (!revision.maskSha256 || !existsSync8(revision.maskFile)) {
5941
5965
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
5942
5966
  }
5943
5967
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -5968,7 +5992,7 @@ async function inspectParent(spec, lock, seen) {
5968
5992
  }
5969
5993
  if (spec.source) {
5970
5994
  const source = path12.resolve(spec.root, spec.source);
5971
- return existsSync7(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
5995
+ return existsSync8(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
5972
5996
  }
5973
5997
  const entry = lock.entries[key];
5974
5998
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -5982,7 +6006,7 @@ async function inspectParent(spec, lock, seen) {
5982
6006
  for (let index = 0; index < entry.outputs.length; index++) {
5983
6007
  const output = entry.outputs[index];
5984
6008
  const file = currentEntryOutputPath(entry, spec, index);
5985
- if (!existsSync7(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6009
+ if (!existsSync8(file)) return { ready: false, reason: `parent output is missing: ${file}` };
5986
6010
  if (await sha256File(file) !== output.sha256) {
5987
6011
  return { ready: false, reason: `parent output was modified: ${file}` };
5988
6012
  }
@@ -6036,8 +6060,8 @@ async function buildPlan(specs, lock, opts = {}) {
6036
6060
  } else if (opts.force) {
6037
6061
  state = "missing";
6038
6062
  reason = "--force";
6039
- } else if (!entry && spec.source) {
6040
- if (existsSync8(path13.resolve(spec.root, spec.source))) {
6063
+ } else if (!entry && spec.source && spec.generator !== "frames") {
6064
+ if (existsSync9(path13.resolve(spec.root, spec.source))) {
6041
6065
  state = "ok";
6042
6066
  reason = `placed from ${spec.source}; not generated`;
6043
6067
  } else {
@@ -6045,7 +6069,7 @@ async function buildPlan(specs, lock, opts = {}) {
6045
6069
  reason = `declared source is missing: ${spec.source}`;
6046
6070
  }
6047
6071
  } else if (!entry) {
6048
- if (existsSync8(spec.outFile)) {
6072
+ if (existsSync9(spec.outFile)) {
6049
6073
  state = "untracked";
6050
6074
  reason = "file on disk, no provenance recorded";
6051
6075
  } else {
@@ -6077,9 +6101,9 @@ async function buildPlan(specs, lock, opts = {}) {
6077
6101
  } else if (entry.status !== "downloaded") {
6078
6102
  state = "in-flight";
6079
6103
  reason = `awaiting ${entry.status}`;
6080
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync8(currentEntryOutputPath(entry, spec, index)))) {
6104
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index)))) {
6081
6105
  state = "orphaned";
6082
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync8(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6106
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6083
6107
  } else if (await anyOutputModified(entry, spec)) {
6084
6108
  state = "orphaned";
6085
6109
  reason = "output modified since download";
@@ -6407,7 +6431,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
6407
6431
  }
6408
6432
 
6409
6433
  // src/pipeline/fetch.ts
6410
- import { existsSync as existsSync9 } from "fs";
6434
+ import { existsSync as existsSync10 } from "fs";
6411
6435
  import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6412
6436
  import path14 from "path";
6413
6437
 
@@ -6463,7 +6487,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6463
6487
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
6464
6488
  if (!opts.repair) return false;
6465
6489
  const spec = specByKey.get(key);
6466
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync9(resolveOutputPath(output.path, spec.root)));
6490
+ return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync10(resolveOutputPath(output.path, spec.root)));
6467
6491
  });
6468
6492
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
6469
6493
  let cursor = 0;
@@ -6511,7 +6535,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6511
6535
  continue;
6512
6536
  }
6513
6537
  }
6514
- if (existsSync9(target)) {
6538
+ if (existsSync10(target)) {
6515
6539
  const currentHash = await sha256File(target);
6516
6540
  if (recorded && currentHash === recorded.sha256) {
6517
6541
  if (!fresh) {
@@ -6622,7 +6646,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6622
6646
  }
6623
6647
  async function readCachedMedia(cacheDir, hash, mediaType) {
6624
6648
  const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6625
- if (!existsSync9(file)) return null;
6649
+ if (!existsSync10(file)) return null;
6626
6650
  try {
6627
6651
  const buf = await readFile7(file);
6628
6652
  if (sha256(buf) !== hash) return null;
@@ -6643,7 +6667,7 @@ async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
6643
6667
  try {
6644
6668
  await rename3(tmp, file);
6645
6669
  } catch (err) {
6646
- if (!existsSync9(file)) throw err;
6670
+ if (!existsSync10(file)) throw err;
6647
6671
  } finally {
6648
6672
  await rm4(tmp, { force: true });
6649
6673
  }
@@ -6676,7 +6700,7 @@ async function pushTags(provider, specs, lock, opts = {}) {
6676
6700
  }
6677
6701
 
6678
6702
  // src/pipeline/doctor.ts
6679
- import { constants, existsSync as existsSync10 } from "fs";
6703
+ import { constants, existsSync as existsSync11 } from "fs";
6680
6704
  import { access, stat as stat2 } from "fs/promises";
6681
6705
  import path15 from "path";
6682
6706
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
@@ -6748,7 +6772,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6748
6772
  cacheDir,
6749
6773
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
6750
6774
  );
6751
- if (existsSync10(cached) && await sha256File(cached) === output.sha256) {
6775
+ if (existsSync11(cached) && await sha256File(cached) === output.sha256) {
6752
6776
  hasCache = true;
6753
6777
  break;
6754
6778
  }
@@ -6851,13 +6875,13 @@ async function nearestExistingDirectory(start) {
6851
6875
  }
6852
6876
 
6853
6877
  // src/pipeline/adopt.ts
6854
- import { existsSync as existsSync12 } from "fs";
6878
+ import { existsSync as existsSync13 } from "fs";
6855
6879
  import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
6856
6880
  import path16 from "path";
6857
6881
 
6858
6882
  // src/cache.ts
6859
6883
  import { mkdir as mkdir4, readFile as readFile8, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
6860
- import { existsSync as existsSync11 } from "fs";
6884
+ import { existsSync as existsSync12 } from "fs";
6861
6885
  import pathModule from "path";
6862
6886
  import { z as z3 } from "zod";
6863
6887
  var HashCacheSchema = z3.object({
@@ -6873,7 +6897,7 @@ function parseCache(value) {
6873
6897
  return HashCacheSchema.parse(value);
6874
6898
  }
6875
6899
  async function loadCache(path31) {
6876
- if (!existsSync11(path31)) return { version: 1, hashes: {} };
6900
+ if (!existsSync12(path31)) return { version: 1, hashes: {} };
6877
6901
  try {
6878
6902
  const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path31, "utf8")));
6879
6903
  if (!parsed.success) return { version: 1, hashes: {} };
@@ -6929,7 +6953,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
6929
6953
  const localByHash = /* @__PURE__ */ new Map();
6930
6954
  const unmatchedLocal = [];
6931
6955
  for (const spec of specs) {
6932
- if (!existsSync12(spec.outFile)) {
6956
+ if (!existsSync13(spec.outFile)) {
6933
6957
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
6934
6958
  continue;
6935
6959
  }
@@ -7086,12 +7110,12 @@ function formatUnmatchedRemote(objects, limit = 15) {
7086
7110
  }
7087
7111
 
7088
7112
  // src/pick/server.ts
7089
- import { existsSync as existsSync14 } from "fs";
7113
+ import { existsSync as existsSync15 } from "fs";
7090
7114
  import { readFile as readFile12 } from "fs/promises";
7091
7115
 
7092
7116
  // src/pipeline/init.ts
7093
7117
  import { readdir, readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
7094
- import { existsSync as existsSync13 } from "fs";
7118
+ import { existsSync as existsSync14 } from "fs";
7095
7119
  import path17 from "path";
7096
7120
  function pngSize(buf) {
7097
7121
  if (buf.length < 24) return null;
@@ -7184,7 +7208,7 @@ function mode(values) {
7184
7208
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
7185
7209
  }
7186
7210
  async function writeManifestFile(target, manifest) {
7187
- if (existsSync13(target)) {
7211
+ if (existsSync14(target)) {
7188
7212
  throw new Error(`${target} already exists \u2014 refusing to overwrite an existing manifest.`);
7189
7213
  }
7190
7214
  await writeFile6(target, JSON.stringify(manifest, null, 2) + "\n");
@@ -7670,7 +7694,7 @@ async function currentArt(entry, spec) {
7670
7694
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
7671
7695
  if (!previous) return null;
7672
7696
  const file = resolveOutputPath(previous.path, spec.root);
7673
- if (!existsSync14(file)) return null;
7697
+ if (!existsSync15(file)) return null;
7674
7698
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
7675
7699
  if (!contentType) return null;
7676
7700
  let width = entry.width;
@@ -7841,13 +7865,13 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
7841
7865
 
7842
7866
  // src/gallery/snapshot.ts
7843
7867
  import { createHash as createHash3 } from "crypto";
7844
- import { existsSync as existsSync17 } from "fs";
7868
+ import { existsSync as existsSync18 } from "fs";
7845
7869
  import { readFile as readFile16, stat as stat3 } from "fs/promises";
7846
7870
  import path22 from "path";
7847
7871
 
7848
7872
  // src/workspace.ts
7849
7873
  import { mkdir as mkdir5, readFile as readFile13, rename as rename5, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7850
- import { existsSync as existsSync15 } from "fs";
7874
+ import { existsSync as existsSync16 } from "fs";
7851
7875
  import path18 from "path";
7852
7876
  import { z as z4 } from "zod";
7853
7877
  var WorkspaceProjectSchema = z4.object({
@@ -7873,7 +7897,7 @@ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message
7873
7897
  );
7874
7898
  }
7875
7899
  async function loadWorkspace(workspacePath) {
7876
- if (!existsSync15(workspacePath)) return { version: 1, projects: [] };
7900
+ if (!existsSync16(workspacePath)) return { version: 1, projects: [] };
7877
7901
  let raw;
7878
7902
  try {
7879
7903
  raw = JSON.parse(await readFile13(workspacePath, "utf8"));
@@ -7925,14 +7949,14 @@ function validateWorkspace(ws, dir) {
7925
7949
  message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
7926
7950
  });
7927
7951
  }
7928
- if (!existsSync15(manifestPath)) {
7952
+ if (!existsSync16(manifestPath)) {
7929
7953
  diagnostics.push({
7930
7954
  id: "missing-manifest",
7931
7955
  level: "error",
7932
7956
  message: `project "${project.id}" manifest not found: ${manifestPath}`
7933
7957
  });
7934
7958
  }
7935
- if (!existsSync15(lockPath)) {
7959
+ if (!existsSync16(lockPath)) {
7936
7960
  diagnostics.push({
7937
7961
  id: "missing-lock",
7938
7962
  level: "error",
@@ -8233,7 +8257,7 @@ async function applyManifestEdit(manifestPath, edit) {
8233
8257
 
8234
8258
  // src/pipeline/hand-edit.ts
8235
8259
  import { spawn as spawn2 } from "child_process";
8236
- import { existsSync as existsSync16 } from "fs";
8260
+ import { existsSync as existsSync17 } from "fs";
8237
8261
  import { copyFile, mkdir as mkdir6, readFile as readFile15, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
8238
8262
  import path20 from "path";
8239
8263
  import { z as z6 } from "zod";
@@ -8246,36 +8270,59 @@ function handEditPath(loaded, spec) {
8246
8270
  const file = inside ? relative : path20.basename(spec.outFile);
8247
8271
  return path20.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8248
8272
  }
8249
- function handEditBase(spec, lock) {
8273
+ function handEditBases(spec, lock) {
8250
8274
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8251
8275
  if (entry) {
8252
- if (entry.outputs.length !== 1) return null;
8253
- const primary = primaryOutput(entry);
8254
- if (!primary || primary.mediaType && primary.mediaType !== "image/png") return null;
8255
- const file = currentEntryOutputPath(entry, spec, 0);
8256
- return existsSync16(file) ? file : null;
8276
+ if (entry.outputs.length !== 1 && !isMemberSetEntry(entry)) return null;
8277
+ const members = entry.outputs.map((output, index) => ({
8278
+ role: entry.outputs.length === 1 ? null : output.role ?? null,
8279
+ path: currentEntryOutputPath(entry, spec, index),
8280
+ mediaType: output.mediaType
8281
+ }));
8282
+ if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
8283
+ if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
8284
+ if (!members.every((member) => existsSync17(member.path))) return null;
8285
+ return members.map(({ role, path: file }) => ({ role, path: file }));
8286
+ }
8287
+ return existsSync17(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8288
+ }
8289
+ function handEditMembers(loaded, spec, lock, editPath) {
8290
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8291
+ const bases = handEditBases(spec, lock);
8292
+ if (entry && isMemberSetEntry(entry)) {
8293
+ return entry.outputs.map((output, index) => ({
8294
+ role: output.role ?? null,
8295
+ path: memberPath(editPath, output.role, index, entry.outputs.length, output.mediaType),
8296
+ base: bases?.[index]?.path ?? null
8297
+ }));
8257
8298
  }
8258
- return existsSync16(spec.outFile) ? spec.outFile : null;
8299
+ return [{ role: null, path: editPath, base: bases?.[0]?.path ?? null }];
8259
8300
  }
8260
8301
  async function startHandEdit(loaded, lock, spec, opts = {}) {
8261
8302
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8262
- if (entry && entry.outputs.length > 1) {
8303
+ if (entry && entry.outputs.length > 1 && !isMemberSetEntry(entry)) {
8304
+ throw new ManifestEditError(
8305
+ `${spec.styleId}/${spec.assetId} is a set of ${entry.outputs.length} outputs that are not all PNG; hand edits cover PNG images and sets of them`
8306
+ );
8307
+ }
8308
+ if (entry && spec.source && isMemberSetEntry(entry) && !sourceIsStem(spec.source, entry, loaded.root)) {
8263
8309
  throw new ManifestEditError(
8264
- `${spec.styleId}/${spec.assetId} has ${entry.outputs.length} outputs; hand edits currently cover single-image assets`
8310
+ `${spec.styleId}/${spec.assetId} declares ${spec.source}, one committed file placed for the whole set; clear that source (pixelkiln edit detach) to hand-edit the members one by one`
8265
8311
  );
8266
8312
  }
8267
8313
  const existing = spec.source ? path20.resolve(loaded.root, spec.source) : null;
8268
8314
  const editPath = existing ?? handEditPath(loaded, spec);
8315
+ const members = handEditMembers(loaded, spec, lock, editPath);
8269
8316
  let created = false;
8270
- if (!existsSync16(editPath)) {
8271
- const base = handEditBase(spec, lock);
8272
- if (!base) {
8317
+ for (const member of members) {
8318
+ if (existsSync17(member.path)) continue;
8319
+ if (!member.base) {
8273
8320
  throw new ManifestEditError(
8274
8321
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
8275
8322
  );
8276
8323
  }
8277
- await mkdir6(path20.dirname(editPath), { recursive: true });
8278
- await copyFile(base, editPath);
8324
+ await mkdir6(path20.dirname(member.path), { recursive: true });
8325
+ await copyFile(member.base, member.path);
8279
8326
  created = true;
8280
8327
  }
8281
8328
  const source = portableOutputPath(editPath, loaded.root);
@@ -8292,7 +8339,7 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
8292
8339
  declared = result.changed;
8293
8340
  manifestSha256 = result.sha256;
8294
8341
  }
8295
- return { editPath, source, created, declared, manifestSha256 };
8342
+ return { editPath, members, source, created, declared, manifestSha256 };
8296
8343
  }
8297
8344
  async function detachHandEdit(loaded, spec, opts = {}) {
8298
8345
  if (!spec.source) {
@@ -8306,19 +8353,44 @@ async function detachHandEdit(loaded, spec, opts = {}) {
8306
8353
  });
8307
8354
  return { manifestSha256: result.sha256, changed: result.changed };
8308
8355
  }
8309
- var HandEditCompanionSchema = z6.object({
8310
- version: z6.literal(1),
8311
- /** sha256 of the generated art the edit started from; null when it started from untracked art. */
8356
+ var CompanionOutputSchema = z6.object({
8357
+ /** Output role for a set member; null for a single image. */
8358
+ role: z6.string().nullable(),
8359
+ /** sha256 of the generated file this member started from; null when it started from untracked art. */
8312
8360
  basedOn: z6.string().regex(/^[0-9a-f]{64}$/).nullable(),
8313
- /** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
8314
- sha256: z6.string().regex(/^[0-9a-f]{64}$/),
8361
+ /** sha256 of the member as saved, so a later change by another tool is visible. */
8362
+ sha256: z6.string().regex(/^[0-9a-f]{64}$/)
8363
+ }).strict();
8364
+ var CompanionV2Schema = z6.object({
8365
+ version: z6.literal(2),
8315
8366
  /** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
8316
8367
  editor: z6.string().min(1),
8317
8368
  protocol: z6.number().int().positive(),
8318
8369
  savedAt: z6.string().datetime(),
8319
8370
  /** Basename of the layered project file beside the edit, when one was kept. */
8371
+ project: z6.string().min(1).nullable(),
8372
+ /** One entry per file, in output order. */
8373
+ outputs: z6.array(CompanionOutputSchema).min(1)
8374
+ }).strict();
8375
+ var CompanionV1Schema = z6.object({
8376
+ version: z6.literal(1),
8377
+ basedOn: z6.string().regex(/^[0-9a-f]{64}$/).nullable(),
8378
+ sha256: z6.string().regex(/^[0-9a-f]{64}$/),
8379
+ editor: z6.string().min(1),
8380
+ protocol: z6.number().int().positive(),
8381
+ savedAt: z6.string().datetime(),
8320
8382
  project: z6.string().min(1).nullable()
8321
8383
  }).strict();
8384
+ var HandEditCompanionSchema = z6.union([CompanionV2Schema, CompanionV1Schema]).transform(
8385
+ (value) => value.version === 2 ? value : {
8386
+ version: 2,
8387
+ editor: value.editor,
8388
+ protocol: value.protocol,
8389
+ savedAt: value.savedAt,
8390
+ project: value.project,
8391
+ outputs: [{ role: null, basedOn: value.basedOn, sha256: value.sha256 }]
8392
+ }
8393
+ );
8322
8394
  function handEditCompanionPath(editPath) {
8323
8395
  return editPath.replace(/\.png$/i, "") + ".edit.json";
8324
8396
  }
@@ -8340,45 +8412,79 @@ async function readHandEditCompanion(editPath) {
8340
8412
  }
8341
8413
  var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
8342
8414
  async function saveHandEdit(loaded, lock, spec, input) {
8343
- if (input.png.length > MAX_HAND_EDIT_BYTES) {
8344
- throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8345
- }
8415
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8416
+ const memberSet = entry ? isMemberSetEntry(entry) : false;
8417
+ const incoming = input.frames ?? (input.png ? [{ role: null, png: input.png }] : []);
8418
+ if (!incoming.length) throw new ManifestEditError("the save carried no image");
8346
8419
  if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
8347
8420
  throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8348
8421
  }
8349
- let decoded;
8350
- try {
8351
- validateMedia(input.png, MediaType.PNG);
8352
- decoded = decodePng(input.png);
8353
- } catch (error) {
8354
- throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
8422
+ if (memberSet) {
8423
+ const expected = entry.outputs.map((output) => output.role ?? null);
8424
+ const got = incoming.map((frame) => frame.role);
8425
+ if (got.length !== expected.length || expected.some((role, index) => got[index] !== role)) {
8426
+ throw new ManifestEditError(
8427
+ `${spec.styleId}/${spec.assetId} is a set of ${expected.length} members (${expected.join(", ")}); the editor returned ${got.length} (${got.map((role) => role ?? "new").join(", ")}) \u2014 keep the frames it opened with`
8428
+ );
8429
+ }
8430
+ } else if (incoming.length !== 1) {
8431
+ throw new ManifestEditError(`${spec.styleId}/${spec.assetId} is one image; the editor returned ${incoming.length} frames \u2014 keep a single frame`);
8355
8432
  }
8356
- const base = handEditBase(spec, lock);
8357
- const expected = base ? decodePng(await readFile15(base)) : { width: spec.width, height: spec.height };
8358
- if (decoded.width !== expected.width || decoded.height !== expected.height) {
8359
- throw new ManifestEditError(
8360
- `the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
8361
- );
8433
+ const decoded = incoming.map((frame, index) => {
8434
+ if (frame.png.length > MAX_HAND_EDIT_BYTES) {
8435
+ throw new ManifestEditError(`the edit is ${frame.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8436
+ }
8437
+ try {
8438
+ validateMedia(frame.png, MediaType.PNG);
8439
+ return decodePng(frame.png);
8440
+ } catch (error) {
8441
+ const which = memberSet ? `member ${frame.role ?? index}` : "the edit";
8442
+ throw new ManifestEditError(`${which} is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
8443
+ }
8444
+ });
8445
+ const bases = handEditBases(spec, lock);
8446
+ const expectedSize = bases?.[0] ? decodePng(await readFile15(bases[0].path)) : { width: spec.width, height: spec.height };
8447
+ for (const [index, image] of decoded.entries()) {
8448
+ if (image.width !== expectedSize.width || image.height !== expectedSize.height) {
8449
+ const which = memberSet ? `member ${incoming[index].role ?? index}` : "the edit";
8450
+ throw new ManifestEditError(
8451
+ `${which} is ${image.width}\xD7${image.height}; ${spec.styleId}/${spec.assetId} is ${expectedSize.width}\xD7${expectedSize.height}`
8452
+ );
8453
+ }
8362
8454
  }
8363
8455
  const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
8364
- const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8365
- const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
8366
- const digest2 = sha256(input.png);
8367
8456
  const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
8368
8457
  const companion = {
8369
- version: 1,
8370
- basedOn,
8371
- sha256: digest2,
8458
+ version: 2,
8372
8459
  editor: input.editor,
8373
8460
  protocol: input.protocol,
8374
8461
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
8375
- project: projectPath ? path20.basename(projectPath) : null
8462
+ project: projectPath ? path20.basename(projectPath) : null,
8463
+ outputs: incoming.map((frame, index) => ({
8464
+ role: frame.role,
8465
+ basedOn: basedOnHash(entry, index),
8466
+ sha256: sha256(frame.png)
8467
+ }))
8376
8468
  };
8377
- await replaceFile(started.editPath, input.png);
8469
+ for (const [index, member] of started.members.entries()) {
8470
+ await replaceFile(member.path, incoming[index].png);
8471
+ }
8378
8472
  if (projectPath && input.project) await replaceFile(projectPath, input.project);
8379
8473
  const companionPath = handEditCompanionPath(started.editPath);
8380
8474
  await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
8381
- return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
8475
+ return {
8476
+ ...started,
8477
+ sha256: companion.outputs[0].sha256,
8478
+ basedOn: companion.outputs[0].basedOn,
8479
+ companion,
8480
+ companionPath,
8481
+ projectPath
8482
+ };
8483
+ }
8484
+ function basedOnHash(entry, index) {
8485
+ if (!entry) return null;
8486
+ if (entry.outputs.length === 1) return primaryOutput(entry)?.sha256 ?? null;
8487
+ return entry.outputs[index]?.sha256 ?? null;
8382
8488
  }
8383
8489
  async function replaceFile(file, bytes) {
8384
8490
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
@@ -8425,7 +8531,10 @@ var HandEditRequestSchema = z7.discriminatedUnion("action", [
8425
8531
  assetId: z7.string().min(1),
8426
8532
  styleId: z7.string().min(1),
8427
8533
  expectedSha256: z7.string().regex(/^[0-9a-f]{64}$/),
8428
- png: Base64.min(1),
8534
+ /** The flattened image; for a frame set, `frames` instead. */
8535
+ png: Base64.min(1).optional(),
8536
+ /** Every frame of a set, tagged with the role it was opened under (null for one added in the editor). */
8537
+ frames: z7.array(z7.object({ role: z7.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
8429
8538
  /** The editor's layered project file, kept beside the edit. */
8430
8539
  pxo: Base64.optional(),
8431
8540
  /** Editor identity from the bridge's ready message. */
@@ -8448,18 +8557,23 @@ function createGalleryEditHandler(opts) {
8448
8557
  const started = await startHandEdit(ctx.loaded, ctx.lock, spec, { expectedSha256: request.expectedSha256 });
8449
8558
  log2(` hand edit ${started.created ? "created" : "found"}: ${started.source}${started.declared ? " (declared in the manifest)" : ""}`);
8450
8559
  if (request.open) {
8451
- const command = (opts.openEditor ?? openInEditor)(started.editPath);
8560
+ const command = (opts.openEditor ?? openInEditor)(started.members[0].path);
8452
8561
  log2(` opened with: ${command}`);
8453
8562
  }
8454
8563
  } else if (request.action === "save-edit") {
8564
+ if (request.png !== void 0 === (request.frames !== void 0)) {
8565
+ throw new ManifestEditError("invalid edit: send png for one image or frames for a set, not both");
8566
+ }
8455
8567
  const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
8456
- png: Buffer.from(request.png, "base64"),
8568
+ ...request.png !== void 0 ? { png: Buffer.from(request.png, "base64") } : {},
8569
+ ...request.frames ? { frames: request.frames.map((frame) => ({ role: frame.role, png: Buffer.from(frame.png, "base64") })) } : {},
8457
8570
  ...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
8458
8571
  editor: request.editor,
8459
8572
  protocol: request.protocol,
8460
8573
  expectedSha256: request.expectedSha256
8461
8574
  });
8462
- log2(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
8575
+ const what = saved.members.length > 1 ? `${saved.source} (${saved.members.length} frames)` : saved.source;
8576
+ log2(` hand edit saved from ${request.editor}: ${what}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
8463
8577
  } else {
8464
8578
  const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
8465
8579
  if (result2.changed) log2(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
@@ -8507,6 +8621,18 @@ function matchesFilter(filter, styleId, assetId) {
8507
8621
  if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
8508
8622
  return true;
8509
8623
  }
8624
+ var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
8625
+ async function samePixels(a, b) {
8626
+ try {
8627
+ const [left, right] = await Promise.all([readFile16(a), readFile16(b)]);
8628
+ if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
8629
+ const x = decodePng(left);
8630
+ const y = decodePng(right);
8631
+ return x.width === y.width && x.height === y.height && x.pixels.equals(y.pixels);
8632
+ } catch {
8633
+ return false;
8634
+ }
8635
+ }
8510
8636
  function metadataFps(entry) {
8511
8637
  if (!entry) return null;
8512
8638
  const namespace = entry.providerMetadata?.[entry.provider];
@@ -8554,7 +8680,7 @@ async function describeQuality(media, root, inspection) {
8554
8680
  outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
8555
8681
  );
8556
8682
  const recordPath = path22.resolve(root, inspection.record);
8557
- const recordExists = existsSync17(recordPath);
8683
+ const recordExists = existsSync18(recordPath);
8558
8684
  let options = null;
8559
8685
  let check = null;
8560
8686
  if (recordExists) {
@@ -8624,6 +8750,8 @@ async function buildGallerySnapshot(opts) {
8624
8750
  const asset = loaded.manifest.assets[spec.assetId] ?? null;
8625
8751
  let outputs;
8626
8752
  let edit = null;
8753
+ let edits = [];
8754
+ let editChanged = [];
8627
8755
  let editStatus = null;
8628
8756
  let editMeta = null;
8629
8757
  if (entry && entry.outputs.length) {
@@ -8634,13 +8762,19 @@ async function buildGallerySnapshot(opts) {
8634
8762
  );
8635
8763
  if (spec.source) {
8636
8764
  const editPath = path22.resolve(root, spec.source);
8637
- const editSha = existsSync17(editPath) ? await sha256File(editPath) : null;
8638
- edit = await describeOutput(media, root, editPath, { sha256: editSha });
8639
- const generated = outputs[0];
8765
+ const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
8766
+ const editShas = await Promise.all(
8767
+ Array.from({ length: memberCount }, async (_, index) => {
8768
+ const file = sourceOutputPath(spec.source, entry, index, root);
8769
+ return { file, sha256: existsSync18(file) ? await sha256File(file) : null };
8770
+ })
8771
+ );
8772
+ edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
8773
+ edit = edits[0] ?? null;
8640
8774
  const companion = await readHandEditCompanion(editPath);
8641
8775
  if (companion) {
8642
8776
  const projectPath = handEditProjectPath(editPath);
8643
- const project = companion.project && existsSync17(projectPath) ? await fileInfo(projectPath) : null;
8777
+ const project = companion.project && existsSync18(projectPath) ? await fileInfo(projectPath) : null;
8644
8778
  let projectUrl = null;
8645
8779
  if (project) {
8646
8780
  const id = galleryMediaId(projectPath);
@@ -8650,18 +8784,34 @@ async function buildGallerySnapshot(opts) {
8650
8784
  editMeta = {
8651
8785
  editor: companion.editor,
8652
8786
  savedAt: companion.savedAt,
8653
- basedOn: companion.basedOn,
8654
- changedSince: editSha !== null && editSha !== companion.sha256,
8787
+ basedOn: companion.outputs[0]?.basedOn ?? null,
8788
+ changedSince: editShas.some(({ sha256: editSha }, index) => {
8789
+ const recorded = companion.outputs[index]?.sha256;
8790
+ return editSha !== null && recorded !== void 0 && editSha !== recorded;
8791
+ }),
8655
8792
  project: project ? portableOutputPath(projectPath, root) : null,
8656
8793
  projectUrl
8657
8794
  };
8658
8795
  }
8659
- const regenerated = companion ? generated?.sha256 !== void 0 && generated.sha256 !== null && companion.basedOn !== generated.sha256 : Boolean(generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists);
8660
- editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
8796
+ const regenerated = edits.some((member, index) => {
8797
+ const generated = outputs[index];
8798
+ if (companion) {
8799
+ const recorded = companion.outputs[index];
8800
+ return recorded !== void 0 && generated.sha256 !== null && recorded.basedOn !== generated.sha256;
8801
+ }
8802
+ return Boolean(generated.modifiedAt && member.modifiedAt && generated.modifiedAt > member.modifiedAt && generated.exists);
8803
+ });
8804
+ editChanged = await Promise.all(edits.map(async (member, index) => {
8805
+ const generated = outputs[index];
8806
+ if (!member.exists || !generated.exists) return true;
8807
+ if (member.sha256 === generated.sha256) return false;
8808
+ return !await samePixels(member.absolutePath, generated.absolutePath);
8809
+ }));
8810
+ editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
8661
8811
  }
8662
8812
  } else if (spec.source) {
8663
8813
  outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
8664
- } else if (!entry && existsSync17(spec.outFile)) {
8814
+ } else if (!entry && existsSync18(spec.outFile)) {
8665
8815
  outputs = [await describeOutput(media, root, spec.outFile)];
8666
8816
  } else {
8667
8817
  outputs = [];
@@ -8706,6 +8856,8 @@ async function buildGallerySnapshot(opts) {
8706
8856
  asset,
8707
8857
  source: spec.source ?? null,
8708
8858
  edit,
8859
+ edits,
8860
+ editChanged,
8709
8861
  editStatus,
8710
8862
  editMeta,
8711
8863
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
@@ -8763,6 +8915,8 @@ async function buildGallerySnapshot(opts) {
8763
8915
  asset: null,
8764
8916
  source: null,
8765
8917
  edit: null,
8918
+ edits: [],
8919
+ editChanged: [],
8766
8920
  editStatus: null,
8767
8921
  editMeta: null,
8768
8922
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
@@ -9697,10 +9851,15 @@ window.addEventListener('message', (e) => {
9697
9851
  // declares the file the same way pixelkiln edit does.
9698
9852
 
9699
9853
  let SHEET = null;
9700
- const editorSource = (item) => item.edit && item.edit.exists ? item.edit
9701
- : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
9702
- const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
9703
- !(item.outputs.length > 1);
9854
+ // What the editor gets: the edit files when they are all there, else the
9855
+ // generated outputs (one PNG, or every frame of a set).
9856
+ const editorSources = (item) => {
9857
+ if (item.edits.length && item.edits.every((e) => e.exists)) return { files: item.edits, fromEdit: true };
9858
+ if (editableOutputs(item)) return { files: item.outputs, fromEdit: false };
9859
+ return null;
9860
+ };
9861
+ const editorSource = (item) => { const s = editorSources(item); return s ? s.files[0] : null; };
9862
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSources(item);
9704
9863
  const toBase64 = (buf) => new Promise((resolve, reject) => {
9705
9864
  const r = new FileReader();
9706
9865
  r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
@@ -9729,7 +9888,7 @@ function openEditorSheet(item) {
9729
9888
  const bar = el('div', 'rbar');
9730
9889
  const close = el('button', null, 'Close'); close.type = 'button';
9731
9890
  close.onclick = () => closeEditorSheet();
9732
- const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
9891
+ const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height + (item.outputs.length > 1 ? ' \xB7 ' + item.outputs.length + ' ' + memberNoun(item, item.outputs.length) : ''));
9733
9892
  const status = el('span', 'st');
9734
9893
  const acts = el('div', 'acts');
9735
9894
  const msg = el('span', 'msg');
@@ -9750,23 +9909,34 @@ function openEditorSheet(item) {
9750
9909
  stage.append(frame, loading);
9751
9910
  panel.append(bar, stage);
9752
9911
  host.append(scrim, panel);
9753
- SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false };
9912
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false, roles: null };
9754
9913
  sheetStatus('cool', 'loading');
9755
9914
  sheetButtons();
9756
9915
  close.focus({ preventScroll: true });
9757
9916
  }
9758
- // The edit file is what the editor gets; when a browser save kept the layered
9759
- // project beside it, that goes along too and the editor restores the layers,
9760
- // falling back to the flattened PNG if the file cannot be read.
9917
+ // The edit files are what the editor gets; when a browser save kept the
9918
+ // layered project beside them, that goes along too and the editor restores
9919
+ // the layers, falling back to the flattened PNGs if the file cannot be read.
9920
+ // A frame set is sent as frames, one per member, and comes back the same way.
9761
9921
  async function sendOpen() {
9762
- const { item, source } = SHEET;
9922
+ const { item } = SHEET;
9923
+ const sources = editorSources(item);
9763
9924
  const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
9764
- const projectUrl = source === item.edit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
9765
- let png, pxo = null;
9925
+ const projectUrl = sources.fromEdit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
9926
+ const set = sources.files.length > 1;
9927
+ if (set && SHEET.ready.version < 3) {
9928
+ SHEET.msg.textContent = 'This editor build cannot open sets; reinstall it with pixelkiln tools install editor.';
9929
+ sheetStatus('bad', 'unsupported');
9930
+ return;
9931
+ }
9932
+ const pngs = [];
9933
+ let pxo = null;
9766
9934
  try {
9767
- const res = await fetch(source.url, { cache: 'no-store' });
9768
- if (!res.ok) throw new Error(await res.text());
9769
- png = await res.arrayBuffer();
9935
+ for (const file of sources.files) {
9936
+ const res = await fetch(file.url, { cache: 'no-store' });
9937
+ if (!res.ok) throw new Error(await res.text());
9938
+ pngs.push(await res.arrayBuffer());
9939
+ }
9770
9940
  if (projectUrl) {
9771
9941
  const p = await fetch(projectUrl, { cache: 'no-store' });
9772
9942
  if (p.ok) pxo = await p.arrayBuffer();
@@ -9778,12 +9948,22 @@ async function sendOpen() {
9778
9948
  if (!SHEET) return;
9779
9949
  const request = 'open-' + (++SHEET.requests);
9780
9950
  SHEET.sentProject = !!pxo;
9951
+ SHEET.roles = set ? item.outputs.map((o) => o.role || null) : null;
9781
9952
  const message = {
9782
9953
  type: 'pixelkiln:open', request,
9783
9954
  asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
9784
- png, palette: style ? style.palette : [],
9955
+ png: pngs[0], palette: style ? style.palette : [],
9785
9956
  };
9786
- const transfer = [png];
9957
+ const transfer = [pngs[0]];
9958
+ if (set) {
9959
+ message.frames = pngs.map((png, i) => ({ role: SHEET.roles[i], png }));
9960
+ // A tile set is not an animation; a slow rate keeps its timeline still-ish.
9961
+ message.fps = isFrameSet(item) ? (item.fps || 12) : 1;
9962
+ for (const png of pngs.slice(1)) transfer.push(png);
9963
+ // The first frame is both png (older editors) and frames[0]; copy it so both transfer.
9964
+ message.png = pngs[0].slice(0);
9965
+ transfer.push(message.png);
9966
+ }
9787
9967
  if (pxo) { message.pxo = pxo; transfer.push(pxo); }
9788
9968
  SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
9789
9969
  sheetStatus('cool', 'opening');
@@ -9811,8 +9991,9 @@ async function onEditorMessage(m) {
9811
9991
  case 'pixelkiln:opened':
9812
9992
  SHEET.opened = true;
9813
9993
  SHEET.loading.remove();
9814
- if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file with its ' + (m.layers === 1 ? 'layer' : m.layers + ' layers') + ' restored');
9815
- else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file; its layer file could not be opened');
9994
+ if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file' + (m.frames > 1 ? 's' : '') + ' with ' + (m.layers === 1 ? 'the layer' : m.layers + ' layers') + (m.frames > 1 ? ' and ' + m.frames + ' ' + memberNoun(SHEET.item, m.frames) : '') + ' restored');
9995
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file' + (m.frames > 1 ? 's' : '') + '; the layer file could not be opened');
9996
+ else if (m.source === 'frames') sheetStatus('ok', (SHEET.item.edits.length ? 'editing the edit files' : 'editing a copy of the generated ' + memberNoun(SHEET.item, m.frames)) + ' as ' + m.frames + ' frames' + (isFrameSet(SHEET.item) ? '' : ', one per ' + memberNoun(SHEET.item, 1)));
9816
9997
  else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
9817
9998
  sheetButtons();
9818
9999
  break;
@@ -9825,14 +10006,21 @@ async function onEditorMessage(m) {
9825
10006
  const pending = SHEET.pending;
9826
10007
  if (!pending || pending.request !== m.request) return;
9827
10008
  try {
9828
- const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
10009
+ const body = { editor: SHEET.ready.editor, protocol: SHEET.ready.version };
10010
+ if (SHEET.roles) {
10011
+ if (!Array.isArray(m.frames)) throw new Error('the editor returned no frames');
10012
+ body.frames = [];
10013
+ for (const f of m.frames) body.frames.push({ role: f.role === undefined ? null : f.role, png: await toBase64(f.png) });
10014
+ } else {
10015
+ body.png = await toBase64(m.png);
10016
+ }
9829
10017
  if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
9830
10018
  await postHandEdit(SHEET.item, 'save-edit', body);
9831
10019
  const saved = snap.items.find((i) => i.id === SHEET.item.id);
9832
10020
  if (saved) SHEET.item = saved;
9833
10021
  SHEET.dirty = false;
9834
10022
  SHEET.pending = null;
9835
- ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edit ? saved.edit.path : 'the edit') + '. mount and pack place it in place of the generated art.' };
10023
+ ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edits.length > 1 ? saved.edits.length + ' ' + memberNoun(saved, saved.edits.length) + ' to ' + saved.source : saved && saved.edit ? saved.edit.path : 'the edit') + '. mount and pack place ' + (saved && saved.edits.length > 1 ? 'them' : 'it') + ' in place of the generated art.' };
9836
10024
  render();
9837
10025
  if (pending.close) { closeEditorSheet(true); return; }
9838
10026
  sheetStatus('ok', 'saved');
@@ -9980,7 +10168,7 @@ function clearFilters() {
9980
10168
  function thumb(item) {
9981
10169
  const cell = el('div', 'cell');
9982
10170
  // A hand edit is what ships, so it is what the card shows.
9983
- const shown = item.edit && item.edit.url && item.editStatus === 'edited' ? [item.edit] : item.outputs.filter((o) => o.url);
10171
+ const shown = item.edit && item.editStatus === 'edited' && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
9984
10172
  if (!shown.length) {
9985
10173
  cell.classList.add('none');
9986
10174
  cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
@@ -10394,7 +10582,7 @@ function openCompare() {
10394
10582
  // ---- hand edits -------------------------------------------------------------
10395
10583
 
10396
10584
  const EDIT_STATUS_TEXT = {
10397
- same: 'A copy of the generated art, not changed yet. Open it in your editor and save.',
10585
+ same: 'The generated art\u2019s pixels, not changed yet. Open it in your editor and save.',
10398
10586
  edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
10399
10587
  'regenerated-since': 'The generated art changed after this edit was saved \u2014 the edit is based on an older generation.',
10400
10588
  missing: 'The declared file is not on disk.',
@@ -10405,12 +10593,18 @@ async function postHandEdit(item, action, extra) {
10405
10593
  if (item.project) body.project = item.project;
10406
10594
  snap = await postEdit(body);
10407
10595
  }
10596
+ // A single PNG or a set of them \u2014 frames, tiles \u2014 is hand-editable, one file
10597
+ // per member; a set with a GIF in it is not.
10598
+ const editableOutputs = (item) => item.outputs.length && item.outputs.every((o) => o.exists && o.mediaType === 'image/png');
10599
+ // What the members of a set are called: frames for an animation, tiles for a tile set.
10600
+ const memberNoun = (item, n) => isFrameSet(item) ? (n === 1 ? 'frame' : 'frames')
10601
+ : item.outputs.every((o) => o.role && /^tile/i.test(o.role)) ? (n === 1 ? 'tile' : 'tiles') : (n === 1 ? 'member' : 'members');
10408
10602
  function handEditSection(item) {
10409
- const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 &&
10410
- item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png';
10603
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editableOutputs(item);
10411
10604
  if (!item.edit && !canStart) return null;
10605
+ const set = item.edits.length > 1;
10412
10606
  const s = el('section', 'meta');
10413
- s.append(el('h3', null, 'Hand edit'));
10607
+ s.append(el('h3', null, set ? 'Hand edit (' + item.edits.length + ' ' + memberNoun(item, item.edits.length) + ')' : 'Hand edit'));
10414
10608
  if (item.edit) {
10415
10609
  const pair = el('div', 'pair');
10416
10610
  const shared = displayScale(item.width, item.height, 200, 160);
@@ -10424,24 +10618,36 @@ function handEditSection(item) {
10424
10618
  f.append(el('figcaption', null, label));
10425
10619
  return f;
10426
10620
  };
10427
- pair.append(fig(item.outputs[0], 'generated'), fig(item.edit, 'edit'));
10621
+ // For a set, the member the preview is on; the strip there steps through them.
10622
+ const at = set ? Math.min(ui.member, item.edits.length - 1) : 0;
10623
+ const which = set ? ' \xB7 ' + (item.outputs[at].role || memberNoun(item, 1) + ' ' + (at + 1)) : '';
10624
+ pair.append(fig(item.outputs[at], 'generated' + which), fig(item.edits[at], 'edit' + which));
10428
10625
  s.append(pair);
10429
10626
  const dl = el('dl');
10430
10627
  const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
10431
10628
  const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
10432
10629
  row(dl, 'status', st);
10433
10630
  row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
10434
- row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
10435
- if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
10436
- row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
10631
+ if (set) {
10632
+ row(dl, 'files', item.source + ' \u2014 one per ' + memberNoun(item, 1) + ': ' + item.edits.map((e) => e.path.split('/').pop()).join(', '), { mono: true, copy: item.edits.map((e) => e.absolutePath).join('\\n') });
10633
+ const differing = item.editChanged.filter(Boolean).length;
10634
+ row(dl, 'changed', differing + ' of ' + item.edits.length + ' ' + memberNoun(item, item.edits.length) + ' differ from the generated art' +
10635
+ (differing ? ': ' + item.edits.filter((e, i) => item.editChanged[i]).map((e) => item.outputs[item.edits.indexOf(e)].role || e.path.split('/').pop()).join(', ') : ''));
10636
+ } else {
10637
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
10638
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
10639
+ }
10640
+ row(dl, 'saved', fmtWhen(item.edits.reduce((latest, e) => e.modifiedAt && (!latest || e.modifiedAt > latest) ? e.modifiedAt : latest, null)));
10437
10641
  if (item.editMeta) {
10438
- row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
10439
- if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
10642
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 a file changed since' : ''));
10643
+ if (item.editMeta.basedOn && !set) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
10440
10644
  if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
10441
10645
  }
10442
10646
  s.append(dl);
10443
10647
  } else {
10444
- s.append(el('div', 'state-dim', 'Touch it up in your own editor. The generated file stays untouched; the edit is a sibling file the manifest points at, and mount and pack place that instead.'));
10648
+ s.append(el('div', 'state-dim', item.outputs.length > 1
10649
+ ? 'Touch the ' + memberNoun(item, 2) + ' up in your own editor or in the browser, where they open as ' + (isFrameSet(item) ? 'one animation' : 'the frames of one project') + '. The generated files stay untouched; the edit is one sibling file per ' + memberNoun(item, 1) + ' that the manifest points at, and mount, pack, and export place those instead.'
10650
+ : 'Touch it up in your own editor. The generated file stays untouched; the edit is a sibling file the manifest points at, and mount and pack place that instead.'));
10445
10651
  }
10446
10652
  if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
10447
10653
  const acts = el('div', 'hand-actions');
@@ -10469,10 +10675,11 @@ function handEditSection(item) {
10469
10675
  }
10470
10676
  if (item.edit) {
10471
10677
  go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
10472
- go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
10678
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file' + (set ? 's are' : ' is') + ' still at ' + item.source + '; the generated art is placed again.');
10473
10679
  } else if (canStart) {
10474
- go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file and opened it in your editor. Save there, then Refresh to see it here.');
10475
- go('Create edit file only', 'start-edit', { open: false }, 'Created the edit file and declared it in the manifest.');
10680
+ const many = item.outputs.length > 1;
10681
+ go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file' + (many ? 's and opened the first ' + memberNoun(item, 1) : ' and opened it') + ' in your editor. Save there, then Refresh to see it here.');
10682
+ go('Create edit file' + (many ? 's' : '') + ' only', 'start-edit', { open: false }, 'Created the edit file' + (many ? 's' : '') + ' and declared ' + (many ? 'them' : 'it') + ' in the manifest.');
10476
10683
  }
10477
10684
  acts.append(msg);
10478
10685
  s.append(acts);
@@ -11775,7 +11982,7 @@ import path24 from "path";
11775
11982
 
11776
11983
  // src/editor/install.ts
11777
11984
  import { createHash as createHash4 } from "crypto";
11778
- import { existsSync as existsSync18 } from "fs";
11985
+ import { existsSync as existsSync19 } from "fs";
11779
11986
  import { mkdir as mkdir7, readFile as readFile18, rename as rename8, rm as rm9, stat as stat4, writeFile as writeFile10 } from "fs/promises";
11780
11987
  import os2 from "os";
11781
11988
  import path23 from "path";
@@ -11785,8 +11992,8 @@ var pin_default = {
11785
11992
  pixelorama: "v1.2.2",
11786
11993
  godot: "4.7.2",
11787
11994
  extensionsApi: 9,
11788
- protocol: 2,
11789
- release: "editor-pixelorama-v1.2.2-pk.10",
11995
+ protocol: 3,
11996
+ release: "editor-pixelorama-v1.2.2-pk.14",
11790
11997
  files: {
11791
11998
  "index.apple-touch-icon.png": {
11792
11999
  sha256: "ee5434873e0fed91e9a888c3ea5a8b79beac25999308be7a6eb41bf4ce640d27",
@@ -11801,7 +12008,7 @@ var pin_default = {
11801
12008
  bytes: 7298
11802
12009
  },
11803
12010
  "index.html": {
11804
- sha256: "ad777659ca39a22872437d44595b749c1637f83994d5f82d50dca861f2186b9c",
12011
+ sha256: "2974146c89f4df9f796e840947c334aaf3e4fa3dd18bcd7e08db2eabfd0f086e",
11805
12012
  bytes: 5442
11806
12013
  },
11807
12014
  "index.icon.png": {
@@ -11813,8 +12020,8 @@ var pin_default = {
11813
12020
  bytes: 279815
11814
12021
  },
11815
12022
  "index.pck": {
11816
- sha256: "546a3f9851df0854c9f6052f29f1e559de79deb53b936d44a72c3475234c26ff",
11817
- bytes: 6427868
12023
+ sha256: "736bd383c82fdadf31cc894caf08490b99c5df572aa20db683972c35c7a0465b",
12024
+ bytes: 6429628
11818
12025
  },
11819
12026
  "index.png": {
11820
12027
  sha256: "6e3ca3eb4a99e4c07bdd89ebc873b440ec0ffd66ce1955160da9e7ed80d538d7",
@@ -11874,7 +12081,7 @@ async function editorStatus(opts = {}) {
11874
12081
  for (const [name, expected] of Object.entries(pin.files)) {
11875
12082
  totalBytes += expected.bytes;
11876
12083
  const file = path23.join(dir, name);
11877
- const info = existsSync18(file) ? await stat4(file).catch(() => null) : null;
12084
+ const info = existsSync19(file) ? await stat4(file).catch(() => null) : null;
11878
12085
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
11879
12086
  installedBytes += expected.bytes;
11880
12087
  } else {
@@ -12069,7 +12276,7 @@ function createGalleryEditorHandlers(opts = {}) {
12069
12276
 
12070
12277
  // src/pipeline/salvage.ts
12071
12278
  import { readFile as readFile20 } from "fs/promises";
12072
- import { existsSync as existsSync19 } from "fs";
12279
+ import { existsSync as existsSync20 } from "fs";
12073
12280
  import path25 from "path";
12074
12281
  function providerClaimId(provider, objectId) {
12075
12282
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
@@ -12077,7 +12284,7 @@ function providerClaimId(provider, objectId) {
12077
12284
  async function loadClaims(lockPaths, opts = {}) {
12078
12285
  const claimed = /* @__PURE__ */ new Set();
12079
12286
  for (const p of lockPaths) {
12080
- if (!existsSync19(p)) throw new Error(`Claim lockfile not found: ${p}`);
12287
+ if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
12081
12288
  let parsed;
12082
12289
  try {
12083
12290
  parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
@@ -12138,7 +12345,7 @@ async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, cla
12138
12345
  const siblings = [];
12139
12346
  for (const siblingManifestPath of siblingManifestPaths) {
12140
12347
  if (path25.resolve(siblingManifestPath) === own) continue;
12141
- if (!existsSync19(siblingManifestPath)) continue;
12348
+ if (!existsSync20(siblingManifestPath)) continue;
12142
12349
  try {
12143
12350
  const { manifest } = await loadManifest(siblingManifestPath);
12144
12351
  siblings.push({ label: path25.basename(path25.dirname(siblingManifestPath)), manifest });
@@ -12360,11 +12567,11 @@ async function workspaceStatus(ws, dir) {
12360
12567
  }
12361
12568
 
12362
12569
  // src/pipeline/cache-health.ts
12363
- import { existsSync as existsSync20 } from "fs";
12570
+ import { existsSync as existsSync21 } from "fs";
12364
12571
  import { readFile as readFile21, readdir as readdir2, rm as rm10 } from "fs/promises";
12365
12572
  import path26 from "path";
12366
12573
  async function inspectCaches(lock, lockPath, options = {}) {
12367
- if (options.prune && !existsSync20(lockPath)) {
12574
+ if (options.prune && !existsSync21(lockPath)) {
12368
12575
  throw new Error(`Refusing to prune without an existing lockfile at ${path26.resolve(lockPath)}`);
12369
12576
  }
12370
12577
  const contentDir = path26.resolve(path26.dirname(lockPath), ".pixelkiln", "cache");
@@ -12409,7 +12616,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
12409
12616
  async function inspectContentCache(contentDir, referenced) {
12410
12617
  const report = {
12411
12618
  path: contentDir,
12412
- exists: existsSync20(contentDir),
12619
+ exists: existsSync21(contentDir),
12413
12620
  files: 0,
12414
12621
  bytes: 0,
12415
12622
  valid: 0,
@@ -12473,7 +12680,7 @@ async function inspectContentCache(contentDir, referenced) {
12473
12680
  async function inspectRemoteHashCache(remotePath) {
12474
12681
  const report = {
12475
12682
  path: remotePath,
12476
- exists: existsSync20(remotePath),
12683
+ exists: existsSync21(remotePath),
12477
12684
  entries: 0,
12478
12685
  valid: 0,
12479
12686
  invalidIds: [],
@@ -12523,8 +12730,11 @@ function normalizeTileRules(raw) {
12523
12730
  };
12524
12731
  }
12525
12732
  function exportTileset(entry, spec, options) {
12526
- const outputs = resolveSpecEntryOutputs(entry, spec);
12733
+ let outputs = resolveSpecEntryOutputs(entry, spec);
12527
12734
  if (!outputs.length) throw new Error(`${spec.styleId}/${spec.assetId} has no downloaded outputs`);
12735
+ if (spec.source && sourceIsStem(spec.source, entry, options.manifestDir)) {
12736
+ outputs = outputs.map((output) => ({ ...output, absolutePath: sourceOutputPath(spec.source, entry, output.index, options.manifestDir) }));
12737
+ }
12528
12738
  const packed = packSprites(
12529
12739
  outputs.map((output) => ({
12530
12740
  id: output.role ?? fallbackOutputRole(output.index),
@@ -13018,7 +13228,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13018
13228
 
13019
13229
  // src/recipes.ts
13020
13230
  import path28 from "path";
13021
- import { existsSync as existsSync21 } from "fs";
13231
+ import { existsSync as existsSync22 } from "fs";
13022
13232
  import { readdir as readdir3, readFile as readFile23 } from "fs/promises";
13023
13233
  import { fileURLToPath } from "url";
13024
13234
  import { z as z9 } from "zod";
@@ -13221,7 +13431,7 @@ async function readRecipeFile(recipePath, bundled) {
13221
13431
  return { recipe: parsed.data, path: absolute, dir: path28.dirname(absolute), bundled };
13222
13432
  }
13223
13433
  async function walkRecipeFiles(root) {
13224
- if (!existsSync21(root)) return [];
13434
+ if (!existsSync22(root)) return [];
13225
13435
  const out = [];
13226
13436
  for (const entry of await readdir3(root, { withFileTypes: true })) {
13227
13437
  const resolved = path28.join(root, entry.name);
@@ -13259,7 +13469,7 @@ function parseSelector(selector) {
13259
13469
  }
13260
13470
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
13261
13471
  const local = path28.resolve(target);
13262
- if (existsSync21(local)) {
13472
+ if (existsSync22(local)) {
13263
13473
  const recipePath = path28.basename(local) === RECIPE_FILE ? local : path28.join(local, RECIPE_FILE);
13264
13474
  return readRecipeFile(recipePath, false);
13265
13475
  }
@@ -13275,7 +13485,7 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
13275
13485
  }
13276
13486
  async function verifyFile(root, file) {
13277
13487
  const absolute = path28.resolve(root, ...file.path.split("/"));
13278
- if (!existsSync21(absolute)) {
13488
+ if (!existsSync22(absolute)) {
13279
13489
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
13280
13490
  }
13281
13491
  const actualSha256 = await sha256File(absolute);
@@ -13353,7 +13563,7 @@ async function installRecipe(target, options = {}) {
13353
13563
  })));
13354
13564
  if (!options.force) {
13355
13565
  for (const file of files) {
13356
- if (!existsSync21(file.path)) continue;
13566
+ if (!existsSync22(file.path)) continue;
13357
13567
  const current = await readFile23(file.path);
13358
13568
  if (!current.equals(file.data)) {
13359
13569
  throw new Error(
@@ -13378,7 +13588,7 @@ async function installRecipe(target, options = {}) {
13378
13588
 
13379
13589
  // src/pipeline/quality-regression.ts
13380
13590
  import path29 from "path";
13381
- import { existsSync as existsSync22 } from "fs";
13591
+ import { existsSync as existsSync23 } from "fs";
13382
13592
  import { readFile as readFile24 } from "fs/promises";
13383
13593
  import { z as z10 } from "zod";
13384
13594
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
@@ -13621,7 +13831,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
13621
13831
  cases
13622
13832
  });
13623
13833
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
13624
- if (existsSync22(absolute) && !options.force) {
13834
+ if (existsSync23(absolute) && !options.force) {
13625
13835
  const current = await readFile24(absolute);
13626
13836
  if (!current.equals(data)) {
13627
13837
  throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
@@ -13862,7 +14072,7 @@ async function provenanceFile(id, file) {
13862
14072
  return {
13863
14073
  id,
13864
14074
  path: absolute,
13865
- sha256: existsSync23(absolute) ? await sha256File(absolute) : null,
14075
+ sha256: existsSync24(absolute) ? await sha256File(absolute) : null,
13866
14076
  included: true
13867
14077
  };
13868
14078
  }
@@ -14502,7 +14712,7 @@ async function confirm(question, auto) {
14502
14712
  return answer === "y" || answer === "yes";
14503
14713
  }
14504
14714
  async function requireCompleteWorkspaceClaims(workspacePath) {
14505
- if (!existsSync23(workspacePath)) {
14715
+ if (!existsSync24(workspacePath)) {
14506
14716
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14507
14717
  }
14508
14718
  const dir = path30.dirname(path30.resolve(workspacePath));
@@ -14761,7 +14971,7 @@ async function main() {
14761
14971
  if (args.command === "init") {
14762
14972
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
14763
14973
  const root = path30.resolve(args.from);
14764
- if (!existsSync23(root)) throw new Error(`No directory at ${root}`);
14974
+ if (!existsSync24(root)) throw new Error(`No directory at ${root}`);
14765
14975
  const generator = args.generator ?? "map";
14766
14976
  if (generator !== "1dir" && generator !== "map") {
14767
14977
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
@@ -14881,7 +15091,7 @@ async function main() {
14881
15091
  log(` registered "${id}" in ${path30.relative(process.cwd(), workspacePath)}`);
14882
15092
  log(` manifest: ${project.manifest}`);
14883
15093
  log(` lock: ${project.lock}`);
14884
- if (!existsSync23(lockPath)) {
15094
+ if (!existsSync24(lockPath)) {
14885
15095
  log(
14886
15096
  ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
14887
15097
  );
@@ -14905,7 +15115,7 @@ async function main() {
14905
15115
  return;
14906
15116
  }
14907
15117
  if (args.subcommand === "list") {
14908
- if (!existsSync23(workspacePath)) {
15118
+ if (!existsSync24(workspacePath)) {
14909
15119
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14910
15120
  }
14911
15121
  const ws = await loadWorkspace(workspacePath);
@@ -14925,7 +15135,7 @@ async function main() {
14925
15135
  return;
14926
15136
  }
14927
15137
  if (args.subcommand === "status") {
14928
- if (!existsSync23(workspacePath)) {
15138
+ if (!existsSync24(workspacePath)) {
14929
15139
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14930
15140
  }
14931
15141
  const ws = await loadWorkspace(workspacePath);
@@ -14981,7 +15191,7 @@ async function main() {
14981
15191
  }
14982
15192
  if (args.command === "gallery" && args.workspace) {
14983
15193
  const workspacePath = path30.resolve(args.workspace);
14984
- if (!existsSync23(workspacePath)) {
15194
+ if (!existsSync24(workspacePath)) {
14985
15195
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14986
15196
  }
14987
15197
  const filter = { styles: args.styles, assets: args.assets };
@@ -15016,7 +15226,7 @@ async function main() {
15016
15226
  });
15017
15227
  return;
15018
15228
  }
15019
- if (!existsSync23(path30.resolve(args.manifest))) {
15229
+ if (!existsSync24(path30.resolve(args.manifest))) {
15020
15230
  throw new Error(
15021
15231
  `No manifest at ${path30.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
15022
15232
  );
@@ -15121,12 +15331,15 @@ async function main() {
15121
15331
  return;
15122
15332
  }
15123
15333
  const started = await startHandEdit(loaded, lock, spec);
15124
- log(` ${started.created ? "created" : "found"} ${started.source}${started.declared ? " and declared it as the asset's source" : ""}`);
15334
+ const set = started.members.length > 1;
15335
+ const kind = spec.generator === "frames" ? "frames" : "members";
15336
+ log(` ${started.created ? "created" : "found"} ${started.source}${set ? ` (${started.members.length} ${kind})` : ""}${started.declared ? " and declared it as the asset's source" : ""}`);
15337
+ if (set) for (const member of started.members) log(` ${member.role ?? ""} ${path30.relative(process.cwd(), member.path)}`);
15125
15338
  if (args.noOpen) {
15126
- log(` edit it with your own tool, then run pixelkiln plan; mount and pack place it in place of the generated art`);
15339
+ log(` edit ${set ? "them" : "it"} with your own tool, then run pixelkiln plan; mount and pack place ${set ? "them" : "it"} in place of the generated art`);
15127
15340
  } else {
15128
- const command = openInEditor(started.editPath);
15129
- log(` opened with: ${command}${process.env.PIXELKILN_EDITOR ? "" : " (set PIXELKILN_EDITOR to choose the program)"}`);
15341
+ const command = openInEditor(started.members[0].path);
15342
+ log(` opened ${set ? `the first ${kind === "frames" ? "frame" : "member"} ` : ""}with: ${command}${process.env.PIXELKILN_EDITOR ? "" : " (set PIXELKILN_EDITOR to choose the program)"}`);
15130
15343
  }
15131
15344
  return;
15132
15345
  }
@@ -15262,7 +15475,7 @@ async function main() {
15262
15475
  let intact = true;
15263
15476
  for (const output of entry.outputs) {
15264
15477
  const file = resolveOutputPath(output.path, item.spec.root);
15265
- if (!existsSync23(file) || await sha256File(file) !== output.sha256) {
15478
+ if (!existsSync24(file) || await sha256File(file) !== output.sha256) {
15266
15479
  intact = false;
15267
15480
  break;
15268
15481
  }
@@ -15644,7 +15857,7 @@ async function main() {
15644
15857
  const lockPaths = [
15645
15858
  .../* @__PURE__ */ new Set([
15646
15859
  ...workspaceLockPaths,
15647
- ...existsSync23(ownLock) ? [ownLock] : [],
15860
+ ...existsSync24(ownLock) ? [ownLock] : [],
15648
15861
  ...args.claims.map((c) => path30.resolve(c))
15649
15862
  ])
15650
15863
  ];