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/index.js CHANGED
@@ -544,11 +544,11 @@ var PixelLabClient = class {
544
544
  * common than the failure mode of retrying (a duplicate object), and a
545
545
  * duplicate is visible and free to delete whereas a silent gap is neither.
546
546
  */
547
- async request(path27, init, attempt = 0) {
547
+ async request(path29, init, attempt = 0) {
548
548
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
549
549
  let res;
550
550
  try {
551
- res = await fetch(`${BASE}${path27}`, {
551
+ res = await fetch(`${BASE}${path29}`, {
552
552
  ...init,
553
553
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
554
554
  headers: {
@@ -560,24 +560,24 @@ var PixelLabClient = class {
560
560
  } catch (err) {
561
561
  if (attempt < MAX_RETRIES) {
562
562
  await sleep(backoffMs(attempt));
563
- return this.request(path27, init, attempt + 1);
563
+ return this.request(path29, init, attempt + 1);
564
564
  }
565
565
  throw err;
566
566
  }
567
567
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
568
568
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
569
569
  await sleep(waitMs);
570
- return this.request(path27, init, attempt + 1);
570
+ return this.request(path29, init, attempt + 1);
571
571
  }
572
572
  const text = await res.text();
573
573
  if (!res.ok) {
574
- throw new PixelLabError(`${init?.method ?? "GET"} ${path27} \u2192 ${res.status}`, res.status, text);
574
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path29} \u2192 ${res.status}`, res.status, text);
575
575
  }
576
576
  if (!text) return {};
577
577
  try {
578
578
  return JSON.parse(text);
579
579
  } catch {
580
- throw new Error(`${init?.method ?? "GET"} ${path27} returned invalid JSON`);
580
+ throw new Error(`${init?.method ?? "GET"} ${path29} returned invalid JSON`);
581
581
  }
582
582
  }
583
583
  async balance() {
@@ -1628,9 +1628,9 @@ var RetroDiffusionClient = class {
1628
1628
  }
1629
1629
  return balance;
1630
1630
  }
1631
- async call(path27, init = {}) {
1631
+ async call(path29, init = {}) {
1632
1632
  if (!this.token) throw new Error("RD_API_KEY is not set");
1633
- const response = await this.request(`${this.baseUrl}${path27}`, {
1633
+ const response = await this.request(`${this.baseUrl}${path29}`, {
1634
1634
  ...init,
1635
1635
  headers: {
1636
1636
  "Content-Type": "application/json",
@@ -2433,10 +2433,10 @@ import { createReadStream } from "fs";
2433
2433
  function sha256(data) {
2434
2434
  return createHash("sha256").update(data).digest("hex");
2435
2435
  }
2436
- async function sha256File(path27) {
2436
+ async function sha256File(path29) {
2437
2437
  return new Promise((resolve, reject) => {
2438
2438
  const hash = createHash("sha256");
2439
- const stream = createReadStream(path27);
2439
+ const stream = createReadStream(path29);
2440
2440
  stream.on("data", (chunk2) => hash.update(chunk2));
2441
2441
  stream.on("error", reject);
2442
2442
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -2729,12 +2729,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
2729
2729
  }
2730
2730
  validate(spec, styleImages) {
2731
2731
  const options = resolvedOptions(spec);
2732
- if (spec.source && !spec.revision) {
2733
- if (spec.generator === "frames") {
2734
- throw new Error("ComfyUI frame sets require generated provider outputs, not asset.source");
2735
- }
2736
- return;
2737
- }
2732
+ if (spec.source && !spec.revision && spec.generator !== "frames") return;
2738
2733
  if (spec.width < 16 || spec.height < 16 || spec.width > 4096 || spec.height > 4096) {
2739
2734
  throw new Error("ComfyUI output dimensions must be between 16 and 4096 pixels");
2740
2735
  }
@@ -3568,10 +3563,11 @@ registerProvider({
3568
3563
 
3569
3564
  // src/manifest.ts
3570
3565
  import { readFile as readFile3 } from "fs/promises";
3571
- import { existsSync as existsSync3 } from "fs";
3566
+ import { existsSync as existsSync4 } from "fs";
3572
3567
  import path5 from "path";
3573
3568
 
3574
3569
  // src/outputs.ts
3570
+ import { existsSync as existsSync3 } from "fs";
3575
3571
  import path4 from "path";
3576
3572
 
3577
3573
  // src/lock.ts
@@ -3761,13 +3757,31 @@ function resolveOutputPath(recordedPath, manifestDir) {
3761
3757
  return path4.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path4.sep));
3762
3758
  }
3763
3759
  function expectedOutputPath(spec, role, index, total, mediaType) {
3764
- const originalExt = path4.extname(spec.outFile);
3760
+ return memberPath(spec.outFile, role, index, total, mediaType);
3761
+ }
3762
+ function memberPath(file, role, index, total, mediaType) {
3763
+ const originalExt = path4.extname(file);
3765
3764
  const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3766
- const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3765
+ const stem = originalExt ? file.slice(0, -originalExt.length) : file;
3767
3766
  if (total === 1) return `${stem}${ext}`;
3768
3767
  const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3769
3768
  return `${stem}-${safeRole}${ext}`;
3770
3769
  }
3770
+ function isMemberSetEntry(entry) {
3771
+ return entry.outputs.length > 1 && entry.outputs.every((output) => !output.mediaType || output.mediaType === "image/png");
3772
+ }
3773
+ function isFrameSetEntry(entry) {
3774
+ return entry.generator === "frames" && isMemberSetEntry(entry);
3775
+ }
3776
+ function sourceIsStem(source, entry, manifestDir) {
3777
+ return isMemberSetEntry(entry) && !existsSync3(path4.resolve(manifestDir, source));
3778
+ }
3779
+ function sourceOutputPath(source, entry, index, manifestDir) {
3780
+ const absolute = path4.resolve(manifestDir, source);
3781
+ if (!sourceIsStem(source, entry, manifestDir)) return absolute;
3782
+ const output = entry.outputs[index];
3783
+ return memberPath(absolute, output.role, index, entry.outputs.length, output.mediaType);
3784
+ }
3771
3785
  function currentOutputPath(output, spec, index, total) {
3772
3786
  return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3773
3787
  }
@@ -3858,7 +3872,7 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
3858
3872
  // src/manifest.ts
3859
3873
  async function loadManifest(manifestPath) {
3860
3874
  const abs = path5.resolve(manifestPath);
3861
- if (!existsSync3(abs)) throw new Error(`No manifest at ${abs}`);
3875
+ if (!existsSync4(abs)) throw new Error(`No manifest at ${abs}`);
3862
3876
  const input = ManifestInputSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
3863
3877
  if (!input.success) {
3864
3878
  const issues = formatManifestIssues(input.error.issues);
@@ -4030,7 +4044,7 @@ async function resolveSpecs(loaded, filter) {
4030
4044
  const abs = path5.resolve(root, rel);
4031
4045
  let hit = styleImageCache.get(abs);
4032
4046
  if (!hit) {
4033
- if (!existsSync3(abs)) throw new Error(`Style image not found: ${abs}`);
4047
+ if (!existsSync4(abs)) throw new Error(`Style image not found: ${abs}`);
4034
4048
  const buf = await readFile3(abs);
4035
4049
  const metadata = imageMetadata(buf);
4036
4050
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
@@ -4271,7 +4285,7 @@ function pngPath(file) {
4271
4285
  return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
4272
4286
  }
4273
4287
  async function optionalRevisionImage(file, label, requiredFormat) {
4274
- if (!existsSync3(file)) return null;
4288
+ if (!existsSync4(file)) return null;
4275
4289
  const bytes = await readFile3(file);
4276
4290
  const metadata = imageMetadata(bytes);
4277
4291
  if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
@@ -4823,7 +4837,7 @@ async function writeArtifactBundle(files, options = {}) {
4823
4837
 
4824
4838
  // src/recipes.ts
4825
4839
  import path7 from "path";
4826
- import { existsSync as existsSync4 } from "fs";
4840
+ import { existsSync as existsSync5 } from "fs";
4827
4841
  import { readdir, readFile as readFile5 } from "fs/promises";
4828
4842
  import { fileURLToPath } from "url";
4829
4843
  import { z as z3 } from "zod";
@@ -5026,7 +5040,7 @@ async function readRecipeFile(recipePath, bundled) {
5026
5040
  return { recipe: parsed.data, path: absolute, dir: path7.dirname(absolute), bundled };
5027
5041
  }
5028
5042
  async function walkRecipeFiles(root) {
5029
- if (!existsSync4(root)) return [];
5043
+ if (!existsSync5(root)) return [];
5030
5044
  const out = [];
5031
5045
  for (const entry of await readdir(root, { withFileTypes: true })) {
5032
5046
  const resolved = path7.join(root, entry.name);
@@ -5064,7 +5078,7 @@ function parseSelector(selector) {
5064
5078
  }
5065
5079
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
5066
5080
  const local = path7.resolve(target);
5067
- if (existsSync4(local)) {
5081
+ if (existsSync5(local)) {
5068
5082
  const recipePath = path7.basename(local) === RECIPE_FILE ? local : path7.join(local, RECIPE_FILE);
5069
5083
  return readRecipeFile(recipePath, false);
5070
5084
  }
@@ -5080,7 +5094,7 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
5080
5094
  }
5081
5095
  async function verifyFile(root, file) {
5082
5096
  const absolute = path7.resolve(root, ...file.path.split("/"));
5083
- if (!existsSync4(absolute)) {
5097
+ if (!existsSync5(absolute)) {
5084
5098
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
5085
5099
  }
5086
5100
  const actualSha256 = await sha256File(absolute);
@@ -5158,7 +5172,7 @@ async function installRecipe(target, options = {}) {
5158
5172
  })));
5159
5173
  if (!options.force) {
5160
5174
  for (const file of files) {
5161
- if (!existsSync4(file.path)) continue;
5175
+ if (!existsSync5(file.path)) continue;
5162
5176
  const current = await readFile5(file.path);
5163
5177
  if (!current.equals(file.data)) {
5164
5178
  throw new Error(
@@ -5182,11 +5196,11 @@ async function installRecipe(target, options = {}) {
5182
5196
  }
5183
5197
 
5184
5198
  // src/pipeline/plan.ts
5185
- import { existsSync as existsSync8 } from "fs";
5199
+ import { existsSync as existsSync9 } from "fs";
5186
5200
  import path12 from "path";
5187
5201
 
5188
5202
  // src/pipeline/quality-profile.ts
5189
- import { existsSync as existsSync6 } from "fs";
5203
+ import { existsSync as existsSync7 } from "fs";
5190
5204
  import path10 from "path";
5191
5205
 
5192
5206
  // src/pipeline/refine.ts
@@ -5198,7 +5212,7 @@ import { promisify } from "util";
5198
5212
 
5199
5213
  // src/pipeline/audit.ts
5200
5214
  import { readFile as readFile6 } from "fs/promises";
5201
- import { existsSync as existsSync5 } from "fs";
5215
+ import { existsSync as existsSync6 } from "fs";
5202
5216
  import path8 from "path";
5203
5217
  function colorDistance(a, b) {
5204
5218
  const rmean = (a.r + b.r) / 2;
@@ -5244,7 +5258,7 @@ async function auditStyle(loaded, specs, styleId, lock) {
5244
5258
  const unreadable = [];
5245
5259
  for (const spec of mine) {
5246
5260
  for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
5247
- if (!existsSync5(output.absolutePath)) {
5261
+ if (!existsSync6(output.absolutePath)) {
5248
5262
  missing.push(output.id);
5249
5263
  continue;
5250
5264
  }
@@ -5274,7 +5288,7 @@ async function auditStyle(loaded, specs, styleId, lock) {
5274
5288
  const refPalettes = [];
5275
5289
  for (const img of style.styleImages) {
5276
5290
  const abs = path8.resolve(loaded.root, img.path);
5277
- if (!existsSync5(abs)) continue;
5291
+ if (!existsSync6(abs)) continue;
5278
5292
  try {
5279
5293
  refPalettes.push(extractPalette(decodePng(await readFile6(abs)), 12));
5280
5294
  } catch {
@@ -5903,7 +5917,7 @@ async function qualitySource(spec, lock) {
5903
5917
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
5904
5918
  }
5905
5919
  const source = path10.resolve(spec.root, spec.source);
5906
- return existsSync6(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5920
+ return existsSync7(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5907
5921
  }
5908
5922
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5909
5923
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -5932,7 +5946,7 @@ async function qualitySource(spec, lock) {
5932
5946
  }
5933
5947
  if (role) roles.add(role);
5934
5948
  const source = currentEntryOutputPath(entry, spec, index);
5935
- if (!existsSync6(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5949
+ if (!existsSync7(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5936
5950
  if (await sha256File(source) !== output.sha256) {
5937
5951
  return { items: null, reason: "raw provider output was modified after download" };
5938
5952
  }
@@ -5951,7 +5965,7 @@ async function inspectQualityProfile(spec, lock) {
5951
5965
  }
5952
5966
  const sourcePaths = source.items.map((item) => item.path);
5953
5967
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
5954
- if (!existsSync6(record)) {
5968
+ if (!existsSync7(record)) {
5955
5969
  return {
5956
5970
  key,
5957
5971
  state: "needs-refinement",
@@ -6132,7 +6146,7 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
6132
6146
  }
6133
6147
 
6134
6148
  // src/pipeline/revision.ts
6135
- import { existsSync as existsSync7 } from "fs";
6149
+ import { existsSync as existsSync8 } from "fs";
6136
6150
  import path11 from "path";
6137
6151
  async function inspectRevisionReadiness(spec, lock) {
6138
6152
  if (!spec.revision) return null;
@@ -6148,14 +6162,14 @@ async function inspectRevision(spec, lock, seen) {
6148
6162
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
6149
6163
  };
6150
6164
  }
6151
- if (!revision.sourceSha256 || !existsSync7(revision.sourceFile)) {
6165
+ if (!revision.sourceSha256 || !existsSync8(revision.sourceFile)) {
6152
6166
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
6153
6167
  }
6154
6168
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
6155
6169
  return { ready: false, reason: "revision source changed after the manifest was resolved" };
6156
6170
  }
6157
6171
  if (revision.maskFile) {
6158
- if (!revision.maskSha256 || !existsSync7(revision.maskFile)) {
6172
+ if (!revision.maskSha256 || !existsSync8(revision.maskFile)) {
6159
6173
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
6160
6174
  }
6161
6175
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -6186,7 +6200,7 @@ async function inspectParent(spec, lock, seen) {
6186
6200
  }
6187
6201
  if (spec.source) {
6188
6202
  const source = path11.resolve(spec.root, spec.source);
6189
- return existsSync7(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
6203
+ return existsSync8(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
6190
6204
  }
6191
6205
  const entry = lock.entries[key];
6192
6206
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -6200,7 +6214,7 @@ async function inspectParent(spec, lock, seen) {
6200
6214
  for (let index = 0; index < entry.outputs.length; index++) {
6201
6215
  const output = entry.outputs[index];
6202
6216
  const file = currentEntryOutputPath(entry, spec, index);
6203
- if (!existsSync7(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6217
+ if (!existsSync8(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6204
6218
  if (await sha256File(file) !== output.sha256) {
6205
6219
  return { ready: false, reason: `parent output was modified: ${file}` };
6206
6220
  }
@@ -6254,8 +6268,8 @@ async function buildPlan(specs, lock, opts = {}) {
6254
6268
  } else if (opts.force) {
6255
6269
  state = "missing";
6256
6270
  reason = "--force";
6257
- } else if (!entry && spec.source) {
6258
- if (existsSync8(path12.resolve(spec.root, spec.source))) {
6271
+ } else if (!entry && spec.source && spec.generator !== "frames") {
6272
+ if (existsSync9(path12.resolve(spec.root, spec.source))) {
6259
6273
  state = "ok";
6260
6274
  reason = `placed from ${spec.source}; not generated`;
6261
6275
  } else {
@@ -6263,7 +6277,7 @@ async function buildPlan(specs, lock, opts = {}) {
6263
6277
  reason = `declared source is missing: ${spec.source}`;
6264
6278
  }
6265
6279
  } else if (!entry) {
6266
- if (existsSync8(spec.outFile)) {
6280
+ if (existsSync9(spec.outFile)) {
6267
6281
  state = "untracked";
6268
6282
  reason = "file on disk, no provenance recorded";
6269
6283
  } else {
@@ -6295,9 +6309,9 @@ async function buildPlan(specs, lock, opts = {}) {
6295
6309
  } else if (entry.status !== "downloaded") {
6296
6310
  state = "in-flight";
6297
6311
  reason = `awaiting ${entry.status}`;
6298
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync8(currentEntryOutputPath(entry, spec, index)))) {
6312
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index)))) {
6299
6313
  state = "orphaned";
6300
- 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(", ")}`;
6314
+ 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(", ")}`;
6301
6315
  } else if (await anyOutputModified(entry, spec)) {
6302
6316
  state = "orphaned";
6303
6317
  reason = "output modified since download";
@@ -6363,7 +6377,7 @@ function summarize(plan) {
6363
6377
 
6364
6378
  // src/pipeline/quality-regression.ts
6365
6379
  import path13 from "path";
6366
- import { existsSync as existsSync9 } from "fs";
6380
+ import { existsSync as existsSync10 } from "fs";
6367
6381
  import { readFile as readFile8 } from "fs/promises";
6368
6382
  import { z as z4 } from "zod";
6369
6383
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
@@ -6606,7 +6620,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
6606
6620
  cases
6607
6621
  });
6608
6622
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
6609
- if (existsSync9(absolute) && !options.force) {
6623
+ if (existsSync10(absolute) && !options.force) {
6610
6624
  const current = await readFile8(absolute);
6611
6625
  if (!current.equals(data)) {
6612
6626
  throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
@@ -6743,13 +6757,13 @@ async function checkQualityBaseline(baselinePath) {
6743
6757
  }
6744
6758
 
6745
6759
  // src/pipeline/cache-health.ts
6746
- import { existsSync as existsSync11 } from "fs";
6760
+ import { existsSync as existsSync12 } from "fs";
6747
6761
  import { readFile as readFile10, readdir as readdir2, rm as rm5 } from "fs/promises";
6748
6762
  import path14 from "path";
6749
6763
 
6750
6764
  // src/cache.ts
6751
6765
  import { mkdir as mkdir3, readFile as readFile9, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6752
- import { existsSync as existsSync10 } from "fs";
6766
+ import { existsSync as existsSync11 } from "fs";
6753
6767
  import pathModule from "path";
6754
6768
  import { z as z5 } from "zod";
6755
6769
  var HashCacheSchema = z5.object({
@@ -6764,10 +6778,10 @@ function isSha256Hash(value) {
6764
6778
  function parseCache(value) {
6765
6779
  return HashCacheSchema.parse(value);
6766
6780
  }
6767
- async function loadCache(path27) {
6768
- if (!existsSync10(path27)) return { version: 1, hashes: {} };
6781
+ async function loadCache(path29) {
6782
+ if (!existsSync11(path29)) return { version: 1, hashes: {} };
6769
6783
  try {
6770
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path27, "utf8")));
6784
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path29, "utf8")));
6771
6785
  if (!parsed.success) return { version: 1, hashes: {} };
6772
6786
  return {
6773
6787
  version: 1,
@@ -6779,18 +6793,18 @@ async function loadCache(path27) {
6779
6793
  return { version: 1, hashes: {} };
6780
6794
  }
6781
6795
  }
6782
- async function saveCache(path27, cache) {
6796
+ async function saveCache(path29, cache) {
6783
6797
  const sorted = {};
6784
6798
  for (const key of Object.keys(cache.hashes).sort()) {
6785
6799
  const hash = cache.hashes[key];
6786
6800
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6787
6801
  sorted[key] = hash;
6788
6802
  }
6789
- await mkdir3(pathModule.dirname(pathModule.resolve(path27)), { recursive: true });
6790
- const tmp = `${path27}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6803
+ await mkdir3(pathModule.dirname(pathModule.resolve(path29)), { recursive: true });
6804
+ const tmp = `${path29}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6791
6805
  try {
6792
6806
  await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6793
- await rename3(tmp, path27);
6807
+ await rename3(tmp, path29);
6794
6808
  } finally {
6795
6809
  await rm4(tmp, { force: true });
6796
6810
  }
@@ -6812,7 +6826,7 @@ function cachePathFor(lockPath) {
6812
6826
 
6813
6827
  // src/pipeline/cache-health.ts
6814
6828
  async function inspectCaches(lock, lockPath, options = {}) {
6815
- if (options.prune && !existsSync11(lockPath)) {
6829
+ if (options.prune && !existsSync12(lockPath)) {
6816
6830
  throw new Error(`Refusing to prune without an existing lockfile at ${path14.resolve(lockPath)}`);
6817
6831
  }
6818
6832
  const contentDir = path14.resolve(path14.dirname(lockPath), ".pixelkiln", "cache");
@@ -6857,7 +6871,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
6857
6871
  async function inspectContentCache(contentDir, referenced) {
6858
6872
  const report = {
6859
6873
  path: contentDir,
6860
- exists: existsSync11(contentDir),
6874
+ exists: existsSync12(contentDir),
6861
6875
  files: 0,
6862
6876
  bytes: 0,
6863
6877
  valid: 0,
@@ -6921,7 +6935,7 @@ async function inspectContentCache(contentDir, referenced) {
6921
6935
  async function inspectRemoteHashCache(remotePath) {
6922
6936
  const report = {
6923
6937
  path: remotePath,
6924
- exists: existsSync11(remotePath),
6938
+ exists: existsSync12(remotePath),
6925
6939
  entries: 0,
6926
6940
  valid: 0,
6927
6941
  invalidIds: [],
@@ -7049,12 +7063,15 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
7049
7063
  for (const [key, entry] of entries) {
7050
7064
  const id = key.slice(prefix.length);
7051
7065
  locked.add(id);
7052
- const outputs = resolveEntryOutputs(entry, id, manifestDir);
7066
+ let outputs = resolveEntryOutputs(entry, id, manifestDir);
7053
7067
  const source = sources[id];
7054
7068
  if (source && outputs.length <= 1) {
7055
7069
  inputs.push({ id, path: path15.resolve(manifestDir, source) });
7056
7070
  continue;
7057
7071
  }
7072
+ if (source && sourceIsStem(source, entry, manifestDir)) {
7073
+ outputs = outputs.map((output) => ({ ...output, absolutePath: sourceOutputPath(source, entry, output.index, manifestDir) }));
7074
+ }
7058
7075
  let selected = outputs;
7059
7076
  if (options.outputRoles?.length) {
7060
7077
  selected = outputs.filter(
@@ -7176,11 +7193,21 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
7176
7193
  for (const id of ids) {
7177
7194
  const cell = cells[id];
7178
7195
  const source = sources[id];
7179
- if (source) {
7196
+ const entry = lock.entries[`${styleId}/${id}`];
7197
+ if (source && !(entry && sourceIsStem(source, entry, manifestDir))) {
7180
7198
  placements.push({ id, path: path15.resolve(manifestDir, source), cell });
7181
7199
  continue;
7182
7200
  }
7183
- const entry = lock.entries[`${styleId}/${id}`];
7201
+ if (source && entry) {
7202
+ const selection2 = selectEntryOutput(entry, outputRoles[id]);
7203
+ if (!selection2.ok) {
7204
+ skipped.push({ id, reason: selection2.reason });
7205
+ continue;
7206
+ }
7207
+ const index = entry.outputs.findIndex((output) => output.path === selection2.output.path);
7208
+ placements.push({ id, path: sourceOutputPath(source, entry, Math.max(index, 0), manifestDir), cell });
7209
+ continue;
7210
+ }
7184
7211
  if (!entry) {
7185
7212
  skipped.push({
7186
7213
  id,
@@ -7247,8 +7274,11 @@ function normalizeTileRules(raw) {
7247
7274
  };
7248
7275
  }
7249
7276
  function exportTileset(entry, spec, options) {
7250
- const outputs = resolveSpecEntryOutputs(entry, spec);
7277
+ let outputs = resolveSpecEntryOutputs(entry, spec);
7251
7278
  if (!outputs.length) throw new Error(`${spec.styleId}/${spec.assetId} has no downloaded outputs`);
7279
+ if (spec.source && sourceIsStem(spec.source, entry, options.manifestDir)) {
7280
+ outputs = outputs.map((output) => ({ ...output, absolutePath: sourceOutputPath(spec.source, entry, output.index, options.manifestDir) }));
7281
+ }
7252
7282
  const packed = packSprites(
7253
7283
  outputs.map((output) => ({
7254
7284
  id: output.role ?? fallbackOutputRole(output.index),
@@ -7740,7 +7770,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
7740
7770
  }
7741
7771
 
7742
7772
  // src/pipeline/fetch.ts
7743
- import { existsSync as existsSync12 } from "fs";
7773
+ import { existsSync as existsSync13 } from "fs";
7744
7774
  import { mkdir as mkdir4, readFile as readFile11, rename as rename4, rm as rm6, writeFile as writeFile4 } from "fs/promises";
7745
7775
  import path16 from "path";
7746
7776
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
@@ -7757,7 +7787,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7757
7787
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7758
7788
  if (!opts.repair) return false;
7759
7789
  const spec = specByKey.get(key);
7760
- return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync12(resolveOutputPath(output.path, spec.root)));
7790
+ return e.outputs.length === 0 || !spec || e.outputs.some((output) => !existsSync13(resolveOutputPath(output.path, spec.root)));
7761
7791
  });
7762
7792
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
7763
7793
  let cursor = 0;
@@ -7805,7 +7835,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7805
7835
  continue;
7806
7836
  }
7807
7837
  }
7808
- if (existsSync12(target)) {
7838
+ if (existsSync13(target)) {
7809
7839
  const currentHash = await sha256File(target);
7810
7840
  if (recorded && currentHash === recorded.sha256) {
7811
7841
  if (!fresh) {
@@ -7916,7 +7946,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7916
7946
  }
7917
7947
  async function readCachedMedia(cacheDir, hash, mediaType) {
7918
7948
  const file = path16.join(cacheDir, cacheFileName(hash, mediaType));
7919
- if (!existsSync12(file)) return null;
7949
+ if (!existsSync13(file)) return null;
7920
7950
  try {
7921
7951
  const buf = await readFile11(file);
7922
7952
  if (sha256(buf) !== hash) return null;
@@ -7937,7 +7967,7 @@ async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
7937
7967
  try {
7938
7968
  await rename4(tmp, file);
7939
7969
  } catch (err) {
7940
- if (!existsSync12(file)) throw err;
7970
+ if (!existsSync13(file)) throw err;
7941
7971
  } finally {
7942
7972
  await rm6(tmp, { force: true });
7943
7973
  }
@@ -7970,7 +8000,7 @@ async function pushTags(provider, specs, lock, opts = {}) {
7970
8000
  }
7971
8001
 
7972
8002
  // src/pipeline/doctor.ts
7973
- import { constants, existsSync as existsSync13 } from "fs";
8003
+ import { constants, existsSync as existsSync14 } from "fs";
7974
8004
  import { access, stat as stat2 } from "fs/promises";
7975
8005
  import path17 from "path";
7976
8006
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
@@ -8042,7 +8072,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
8042
8072
  cacheDir,
8043
8073
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
8044
8074
  );
8045
- if (existsSync13(cached) && await sha256File(cached) === output.sha256) {
8075
+ if (existsSync14(cached) && await sha256File(cached) === output.sha256) {
8046
8076
  hasCache = true;
8047
8077
  break;
8048
8078
  }
@@ -8145,7 +8175,7 @@ async function nearestExistingDirectory(start) {
8145
8175
  }
8146
8176
 
8147
8177
  // src/pipeline/adopt.ts
8148
- import { existsSync as existsSync14 } from "fs";
8178
+ import { existsSync as existsSync15 } from "fs";
8149
8179
  import { readFile as readFile12, writeFile as writeFile5 } from "fs/promises";
8150
8180
  import path18 from "path";
8151
8181
  async function adopt(provider, specs, lock, lockPath, opts = {}) {
@@ -8158,7 +8188,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
8158
8188
  const localByHash = /* @__PURE__ */ new Map();
8159
8189
  const unmatchedLocal = [];
8160
8190
  for (const spec of specs) {
8161
- if (!existsSync14(spec.outFile)) {
8191
+ if (!existsSync15(spec.outFile)) {
8162
8192
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
8163
8193
  continue;
8164
8194
  }
@@ -8280,12 +8310,12 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
8280
8310
  }
8281
8311
 
8282
8312
  // src/pick/server.ts
8283
- import { existsSync as existsSync16 } from "fs";
8313
+ import { existsSync as existsSync17 } from "fs";
8284
8314
  import { readFile as readFile15 } from "fs/promises";
8285
8315
 
8286
8316
  // src/pipeline/init.ts
8287
8317
  import { readdir as readdir3, readFile as readFile13, writeFile as writeFile6 } from "fs/promises";
8288
- import { existsSync as existsSync15 } from "fs";
8318
+ import { existsSync as existsSync16 } from "fs";
8289
8319
  import path19 from "path";
8290
8320
  function pngSize(buf) {
8291
8321
  if (buf.length < 24) return null;
@@ -8858,7 +8888,7 @@ async function currentArt(entry, spec) {
8858
8888
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
8859
8889
  if (!previous) return null;
8860
8890
  const file = resolveOutputPath(previous.path, spec.root);
8861
- if (!existsSync16(file)) return null;
8891
+ if (!existsSync17(file)) return null;
8862
8892
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
8863
8893
  if (!contentType) return null;
8864
8894
  let width = entry.width;
@@ -9029,13 +9059,13 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
9029
9059
 
9030
9060
  // src/gallery/snapshot.ts
9031
9061
  import { createHash as createHash4 } from "crypto";
9032
- import { existsSync as existsSync19 } from "fs";
9062
+ import { existsSync as existsSync20 } from "fs";
9033
9063
  import { readFile as readFile19, stat as stat3 } from "fs/promises";
9034
9064
  import path24 from "path";
9035
9065
 
9036
9066
  // src/workspace.ts
9037
9067
  import { mkdir as mkdir5, readFile as readFile16, rename as rename5, rm as rm7, writeFile as writeFile7 } from "fs/promises";
9038
- import { existsSync as existsSync17 } from "fs";
9068
+ import { existsSync as existsSync18 } from "fs";
9039
9069
  import path20 from "path";
9040
9070
  import { z as z6 } from "zod";
9041
9071
  var WorkspaceProjectSchema = z6.object({
@@ -9061,7 +9091,7 @@ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message
9061
9091
  );
9062
9092
  }
9063
9093
  async function loadWorkspace(workspacePath) {
9064
- if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
9094
+ if (!existsSync18(workspacePath)) return { version: 1, projects: [] };
9065
9095
  let raw;
9066
9096
  try {
9067
9097
  raw = JSON.parse(await readFile16(workspacePath, "utf8"));
@@ -9113,14 +9143,14 @@ function validateWorkspace(ws, dir) {
9113
9143
  message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
9114
9144
  });
9115
9145
  }
9116
- if (!existsSync17(manifestPath)) {
9146
+ if (!existsSync18(manifestPath)) {
9117
9147
  diagnostics.push({
9118
9148
  id: "missing-manifest",
9119
9149
  level: "error",
9120
9150
  message: `project "${project.id}" manifest not found: ${manifestPath}`
9121
9151
  });
9122
9152
  }
9123
- if (!existsSync17(lockPath)) {
9153
+ if (!existsSync18(lockPath)) {
9124
9154
  diagnostics.push({
9125
9155
  id: "missing-lock",
9126
9156
  level: "error",
@@ -9421,7 +9451,7 @@ async function applyManifestEdit(manifestPath, edit) {
9421
9451
 
9422
9452
  // src/pipeline/hand-edit.ts
9423
9453
  import { spawn as spawn2 } from "child_process";
9424
- import { existsSync as existsSync18 } from "fs";
9454
+ import { existsSync as existsSync19 } from "fs";
9425
9455
  import { copyFile, mkdir as mkdir6, readFile as readFile18, rename as rename7, rm as rm9, writeFile as writeFile9 } from "fs/promises";
9426
9456
  import path22 from "path";
9427
9457
  import { z as z8 } from "zod";
@@ -9434,36 +9464,63 @@ function handEditPath(loaded, spec) {
9434
9464
  const file = inside ? relative : path22.basename(spec.outFile);
9435
9465
  return path22.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
9436
9466
  }
9437
- function handEditBase(spec, lock) {
9467
+ function handEditBases(spec, lock) {
9438
9468
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9439
9469
  if (entry) {
9440
- if (entry.outputs.length !== 1) return null;
9441
- const primary = primaryOutput(entry);
9442
- if (!primary || primary.mediaType && primary.mediaType !== "image/png") return null;
9443
- const file = currentEntryOutputPath(entry, spec, 0);
9444
- return existsSync18(file) ? file : null;
9470
+ if (entry.outputs.length !== 1 && !isMemberSetEntry(entry)) return null;
9471
+ const members = entry.outputs.map((output, index) => ({
9472
+ role: entry.outputs.length === 1 ? null : output.role ?? null,
9473
+ path: currentEntryOutputPath(entry, spec, index),
9474
+ mediaType: output.mediaType
9475
+ }));
9476
+ if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
9477
+ if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
9478
+ if (!members.every((member) => existsSync19(member.path))) return null;
9479
+ return members.map(({ role, path: file }) => ({ role, path: file }));
9480
+ }
9481
+ return existsSync19(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
9482
+ }
9483
+ function handEditBase(spec, lock) {
9484
+ const bases = handEditBases(spec, lock);
9485
+ return bases && bases.length === 1 ? bases[0].path : null;
9486
+ }
9487
+ function handEditMembers(loaded, spec, lock, editPath) {
9488
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9489
+ const bases = handEditBases(spec, lock);
9490
+ if (entry && isMemberSetEntry(entry)) {
9491
+ return entry.outputs.map((output, index) => ({
9492
+ role: output.role ?? null,
9493
+ path: memberPath(editPath, output.role, index, entry.outputs.length, output.mediaType),
9494
+ base: bases?.[index]?.path ?? null
9495
+ }));
9445
9496
  }
9446
- return existsSync18(spec.outFile) ? spec.outFile : null;
9497
+ return [{ role: null, path: editPath, base: bases?.[0]?.path ?? null }];
9447
9498
  }
9448
9499
  async function startHandEdit(loaded, lock, spec, opts = {}) {
9449
9500
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9450
- if (entry && entry.outputs.length > 1) {
9501
+ if (entry && entry.outputs.length > 1 && !isMemberSetEntry(entry)) {
9451
9502
  throw new ManifestEditError(
9452
- `${spec.styleId}/${spec.assetId} has ${entry.outputs.length} outputs; hand edits currently cover single-image assets`
9503
+ `${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`
9504
+ );
9505
+ }
9506
+ if (entry && spec.source && isMemberSetEntry(entry) && !sourceIsStem(spec.source, entry, loaded.root)) {
9507
+ throw new ManifestEditError(
9508
+ `${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`
9453
9509
  );
9454
9510
  }
9455
9511
  const existing = spec.source ? path22.resolve(loaded.root, spec.source) : null;
9456
9512
  const editPath = existing ?? handEditPath(loaded, spec);
9513
+ const members = handEditMembers(loaded, spec, lock, editPath);
9457
9514
  let created = false;
9458
- if (!existsSync18(editPath)) {
9459
- const base = handEditBase(spec, lock);
9460
- if (!base) {
9515
+ for (const member of members) {
9516
+ if (existsSync19(member.path)) continue;
9517
+ if (!member.base) {
9461
9518
  throw new ManifestEditError(
9462
9519
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
9463
9520
  );
9464
9521
  }
9465
- await mkdir6(path22.dirname(editPath), { recursive: true });
9466
- await copyFile(base, editPath);
9522
+ await mkdir6(path22.dirname(member.path), { recursive: true });
9523
+ await copyFile(member.base, member.path);
9467
9524
  created = true;
9468
9525
  }
9469
9526
  const source = portableOutputPath(editPath, loaded.root);
@@ -9480,7 +9537,7 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
9480
9537
  declared = result.changed;
9481
9538
  manifestSha256 = result.sha256;
9482
9539
  }
9483
- return { editPath, source, created, declared, manifestSha256 };
9540
+ return { editPath, members, source, created, declared, manifestSha256 };
9484
9541
  }
9485
9542
  async function detachHandEdit(loaded, spec, opts = {}) {
9486
9543
  if (!spec.source) {
@@ -9494,19 +9551,44 @@ async function detachHandEdit(loaded, spec, opts = {}) {
9494
9551
  });
9495
9552
  return { manifestSha256: result.sha256, changed: result.changed };
9496
9553
  }
9497
- var HandEditCompanionSchema = z8.object({
9498
- version: z8.literal(1),
9499
- /** sha256 of the generated art the edit started from; null when it started from untracked art. */
9554
+ var CompanionOutputSchema = z8.object({
9555
+ /** Output role for a set member; null for a single image. */
9556
+ role: z8.string().nullable(),
9557
+ /** sha256 of the generated file this member started from; null when it started from untracked art. */
9500
9558
  basedOn: z8.string().regex(/^[0-9a-f]{64}$/).nullable(),
9501
- /** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
9502
- sha256: z8.string().regex(/^[0-9a-f]{64}$/),
9559
+ /** sha256 of the member as saved, so a later change by another tool is visible. */
9560
+ sha256: z8.string().regex(/^[0-9a-f]{64}$/)
9561
+ }).strict();
9562
+ var CompanionV2Schema = z8.object({
9563
+ version: z8.literal(2),
9503
9564
  /** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
9504
9565
  editor: z8.string().min(1),
9505
9566
  protocol: z8.number().int().positive(),
9506
9567
  savedAt: z8.string().datetime(),
9507
9568
  /** Basename of the layered project file beside the edit, when one was kept. */
9569
+ project: z8.string().min(1).nullable(),
9570
+ /** One entry per file, in output order. */
9571
+ outputs: z8.array(CompanionOutputSchema).min(1)
9572
+ }).strict();
9573
+ var CompanionV1Schema = z8.object({
9574
+ version: z8.literal(1),
9575
+ basedOn: z8.string().regex(/^[0-9a-f]{64}$/).nullable(),
9576
+ sha256: z8.string().regex(/^[0-9a-f]{64}$/),
9577
+ editor: z8.string().min(1),
9578
+ protocol: z8.number().int().positive(),
9579
+ savedAt: z8.string().datetime(),
9508
9580
  project: z8.string().min(1).nullable()
9509
9581
  }).strict();
9582
+ var HandEditCompanionSchema = z8.union([CompanionV2Schema, CompanionV1Schema]).transform(
9583
+ (value) => value.version === 2 ? value : {
9584
+ version: 2,
9585
+ editor: value.editor,
9586
+ protocol: value.protocol,
9587
+ savedAt: value.savedAt,
9588
+ project: value.project,
9589
+ outputs: [{ role: null, basedOn: value.basedOn, sha256: value.sha256 }]
9590
+ }
9591
+ );
9510
9592
  function handEditCompanionPath(editPath) {
9511
9593
  return editPath.replace(/\.png$/i, "") + ".edit.json";
9512
9594
  }
@@ -9528,45 +9610,79 @@ async function readHandEditCompanion(editPath) {
9528
9610
  }
9529
9611
  var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
9530
9612
  async function saveHandEdit(loaded, lock, spec, input) {
9531
- if (input.png.length > MAX_HAND_EDIT_BYTES) {
9532
- throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9533
- }
9613
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9614
+ const memberSet = entry ? isMemberSetEntry(entry) : false;
9615
+ const incoming = input.frames ?? (input.png ? [{ role: null, png: input.png }] : []);
9616
+ if (!incoming.length) throw new ManifestEditError("the save carried no image");
9534
9617
  if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
9535
9618
  throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9536
9619
  }
9537
- let decoded;
9538
- try {
9539
- validateMedia(input.png, MediaType.PNG);
9540
- decoded = decodePng(input.png);
9541
- } catch (error) {
9542
- throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
9620
+ if (memberSet) {
9621
+ const expected = entry.outputs.map((output) => output.role ?? null);
9622
+ const got = incoming.map((frame) => frame.role);
9623
+ if (got.length !== expected.length || expected.some((role, index) => got[index] !== role)) {
9624
+ throw new ManifestEditError(
9625
+ `${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`
9626
+ );
9627
+ }
9628
+ } else if (incoming.length !== 1) {
9629
+ throw new ManifestEditError(`${spec.styleId}/${spec.assetId} is one image; the editor returned ${incoming.length} frames \u2014 keep a single frame`);
9543
9630
  }
9544
- const base = handEditBase(spec, lock);
9545
- const expected = base ? decodePng(await readFile18(base)) : { width: spec.width, height: spec.height };
9546
- if (decoded.width !== expected.width || decoded.height !== expected.height) {
9547
- throw new ManifestEditError(
9548
- `the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
9549
- );
9631
+ const decoded = incoming.map((frame, index) => {
9632
+ if (frame.png.length > MAX_HAND_EDIT_BYTES) {
9633
+ throw new ManifestEditError(`the edit is ${frame.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
9634
+ }
9635
+ try {
9636
+ validateMedia(frame.png, MediaType.PNG);
9637
+ return decodePng(frame.png);
9638
+ } catch (error) {
9639
+ const which = memberSet ? `member ${frame.role ?? index}` : "the edit";
9640
+ throw new ManifestEditError(`${which} is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
9641
+ }
9642
+ });
9643
+ const bases = handEditBases(spec, lock);
9644
+ const expectedSize = bases?.[0] ? decodePng(await readFile18(bases[0].path)) : { width: spec.width, height: spec.height };
9645
+ for (const [index, image] of decoded.entries()) {
9646
+ if (image.width !== expectedSize.width || image.height !== expectedSize.height) {
9647
+ const which = memberSet ? `member ${incoming[index].role ?? index}` : "the edit";
9648
+ throw new ManifestEditError(
9649
+ `${which} is ${image.width}\xD7${image.height}; ${spec.styleId}/${spec.assetId} is ${expectedSize.width}\xD7${expectedSize.height}`
9650
+ );
9651
+ }
9550
9652
  }
9551
9653
  const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
9552
- const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
9553
- const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
9554
- const digest2 = sha256(input.png);
9555
9654
  const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
9556
9655
  const companion = {
9557
- version: 1,
9558
- basedOn,
9559
- sha256: digest2,
9656
+ version: 2,
9560
9657
  editor: input.editor,
9561
9658
  protocol: input.protocol,
9562
9659
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
9563
- project: projectPath ? path22.basename(projectPath) : null
9660
+ project: projectPath ? path22.basename(projectPath) : null,
9661
+ outputs: incoming.map((frame, index) => ({
9662
+ role: frame.role,
9663
+ basedOn: basedOnHash(entry, index),
9664
+ sha256: sha256(frame.png)
9665
+ }))
9564
9666
  };
9565
- await replaceFile(started.editPath, input.png);
9667
+ for (const [index, member] of started.members.entries()) {
9668
+ await replaceFile(member.path, incoming[index].png);
9669
+ }
9566
9670
  if (projectPath && input.project) await replaceFile(projectPath, input.project);
9567
9671
  const companionPath = handEditCompanionPath(started.editPath);
9568
9672
  await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
9569
- return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
9673
+ return {
9674
+ ...started,
9675
+ sha256: companion.outputs[0].sha256,
9676
+ basedOn: companion.outputs[0].basedOn,
9677
+ companion,
9678
+ companionPath,
9679
+ projectPath
9680
+ };
9681
+ }
9682
+ function basedOnHash(entry, index) {
9683
+ if (!entry) return null;
9684
+ if (entry.outputs.length === 1) return primaryOutput(entry)?.sha256 ?? null;
9685
+ return entry.outputs[index]?.sha256 ?? null;
9570
9686
  }
9571
9687
  async function replaceFile(file, bytes) {
9572
9688
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
@@ -9613,7 +9729,10 @@ var HandEditRequestSchema = z9.discriminatedUnion("action", [
9613
9729
  assetId: z9.string().min(1),
9614
9730
  styleId: z9.string().min(1),
9615
9731
  expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/),
9616
- png: Base64.min(1),
9732
+ /** The flattened image; for a frame set, `frames` instead. */
9733
+ png: Base64.min(1).optional(),
9734
+ /** Every frame of a set, tagged with the role it was opened under (null for one added in the editor). */
9735
+ frames: z9.array(z9.object({ role: z9.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
9617
9736
  /** The editor's layered project file, kept beside the edit. */
9618
9737
  pxo: Base64.optional(),
9619
9738
  /** Editor identity from the bridge's ready message. */
@@ -9636,18 +9755,23 @@ function createGalleryEditHandler(opts) {
9636
9755
  const started = await startHandEdit(ctx.loaded, ctx.lock, spec, { expectedSha256: request.expectedSha256 });
9637
9756
  log(` hand edit ${started.created ? "created" : "found"}: ${started.source}${started.declared ? " (declared in the manifest)" : ""}`);
9638
9757
  if (request.open) {
9639
- const command = (opts.openEditor ?? openInEditor)(started.editPath);
9758
+ const command = (opts.openEditor ?? openInEditor)(started.members[0].path);
9640
9759
  log(` opened with: ${command}`);
9641
9760
  }
9642
9761
  } else if (request.action === "save-edit") {
9762
+ if (request.png !== void 0 === (request.frames !== void 0)) {
9763
+ throw new ManifestEditError("invalid edit: send png for one image or frames for a set, not both");
9764
+ }
9643
9765
  const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
9644
- png: Buffer.from(request.png, "base64"),
9766
+ ...request.png !== void 0 ? { png: Buffer.from(request.png, "base64") } : {},
9767
+ ...request.frames ? { frames: request.frames.map((frame) => ({ role: frame.role, png: Buffer.from(frame.png, "base64") })) } : {},
9645
9768
  ...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
9646
9769
  editor: request.editor,
9647
9770
  protocol: request.protocol,
9648
9771
  expectedSha256: request.expectedSha256
9649
9772
  });
9650
- log(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
9773
+ const what = saved.members.length > 1 ? `${saved.source} (${saved.members.length} frames)` : saved.source;
9774
+ log(` hand edit saved from ${request.editor}: ${what}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
9651
9775
  } else {
9652
9776
  const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
9653
9777
  if (result2.changed) log(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
@@ -9695,6 +9819,18 @@ function matchesFilter(filter, styleId, assetId) {
9695
9819
  if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
9696
9820
  return true;
9697
9821
  }
9822
+ var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
9823
+ async function samePixels(a, b) {
9824
+ try {
9825
+ const [left, right] = await Promise.all([readFile19(a), readFile19(b)]);
9826
+ if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
9827
+ const x = decodePng(left);
9828
+ const y = decodePng(right);
9829
+ return x.width === y.width && x.height === y.height && x.pixels.equals(y.pixels);
9830
+ } catch {
9831
+ return false;
9832
+ }
9833
+ }
9698
9834
  function metadataFps(entry) {
9699
9835
  if (!entry) return null;
9700
9836
  const namespace = entry.providerMetadata?.[entry.provider];
@@ -9742,7 +9878,7 @@ async function describeQuality(media, root, inspection) {
9742
9878
  outputPaths.map((file) => describeOutput(media, root, path24.resolve(root, file)))
9743
9879
  );
9744
9880
  const recordPath = path24.resolve(root, inspection.record);
9745
- const recordExists = existsSync19(recordPath);
9881
+ const recordExists = existsSync20(recordPath);
9746
9882
  let options = null;
9747
9883
  let check = null;
9748
9884
  if (recordExists) {
@@ -9812,6 +9948,8 @@ async function buildGallerySnapshot(opts) {
9812
9948
  const asset = loaded.manifest.assets[spec.assetId] ?? null;
9813
9949
  let outputs;
9814
9950
  let edit = null;
9951
+ let edits = [];
9952
+ let editChanged = [];
9815
9953
  let editStatus = null;
9816
9954
  let editMeta = null;
9817
9955
  if (entry && entry.outputs.length) {
@@ -9822,13 +9960,19 @@ async function buildGallerySnapshot(opts) {
9822
9960
  );
9823
9961
  if (spec.source) {
9824
9962
  const editPath = path24.resolve(root, spec.source);
9825
- const editSha = existsSync19(editPath) ? await sha256File(editPath) : null;
9826
- edit = await describeOutput(media, root, editPath, { sha256: editSha });
9827
- const generated = outputs[0];
9963
+ const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
9964
+ const editShas = await Promise.all(
9965
+ Array.from({ length: memberCount }, async (_, index) => {
9966
+ const file = sourceOutputPath(spec.source, entry, index, root);
9967
+ return { file, sha256: existsSync20(file) ? await sha256File(file) : null };
9968
+ })
9969
+ );
9970
+ edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
9971
+ edit = edits[0] ?? null;
9828
9972
  const companion = await readHandEditCompanion(editPath);
9829
9973
  if (companion) {
9830
9974
  const projectPath = handEditProjectPath(editPath);
9831
- const project = companion.project && existsSync19(projectPath) ? await fileInfo(projectPath) : null;
9975
+ const project = companion.project && existsSync20(projectPath) ? await fileInfo(projectPath) : null;
9832
9976
  let projectUrl = null;
9833
9977
  if (project) {
9834
9978
  const id = galleryMediaId(projectPath);
@@ -9838,18 +9982,34 @@ async function buildGallerySnapshot(opts) {
9838
9982
  editMeta = {
9839
9983
  editor: companion.editor,
9840
9984
  savedAt: companion.savedAt,
9841
- basedOn: companion.basedOn,
9842
- changedSince: editSha !== null && editSha !== companion.sha256,
9985
+ basedOn: companion.outputs[0]?.basedOn ?? null,
9986
+ changedSince: editShas.some(({ sha256: editSha }, index) => {
9987
+ const recorded = companion.outputs[index]?.sha256;
9988
+ return editSha !== null && recorded !== void 0 && editSha !== recorded;
9989
+ }),
9843
9990
  project: project ? portableOutputPath(projectPath, root) : null,
9844
9991
  projectUrl
9845
9992
  };
9846
9993
  }
9847
- 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);
9848
- editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
9994
+ const regenerated = edits.some((member, index) => {
9995
+ const generated = outputs[index];
9996
+ if (companion) {
9997
+ const recorded = companion.outputs[index];
9998
+ return recorded !== void 0 && generated.sha256 !== null && recorded.basedOn !== generated.sha256;
9999
+ }
10000
+ return Boolean(generated.modifiedAt && member.modifiedAt && generated.modifiedAt > member.modifiedAt && generated.exists);
10001
+ });
10002
+ editChanged = await Promise.all(edits.map(async (member, index) => {
10003
+ const generated = outputs[index];
10004
+ if (!member.exists || !generated.exists) return true;
10005
+ if (member.sha256 === generated.sha256) return false;
10006
+ return !await samePixels(member.absolutePath, generated.absolutePath);
10007
+ }));
10008
+ editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
9849
10009
  }
9850
10010
  } else if (spec.source) {
9851
10011
  outputs = [await describeOutput(media, root, path24.resolve(root, spec.source))];
9852
- } else if (!entry && existsSync19(spec.outFile)) {
10012
+ } else if (!entry && existsSync20(spec.outFile)) {
9853
10013
  outputs = [await describeOutput(media, root, spec.outFile)];
9854
10014
  } else {
9855
10015
  outputs = [];
@@ -9894,6 +10054,8 @@ async function buildGallerySnapshot(opts) {
9894
10054
  asset,
9895
10055
  source: spec.source ?? null,
9896
10056
  edit,
10057
+ edits,
10058
+ editChanged,
9897
10059
  editStatus,
9898
10060
  editMeta,
9899
10061
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
@@ -9951,6 +10113,8 @@ async function buildGallerySnapshot(opts) {
9951
10113
  asset: null,
9952
10114
  source: null,
9953
10115
  edit: null,
10116
+ edits: [],
10117
+ editChanged: [],
9954
10118
  editStatus: null,
9955
10119
  editMeta: null,
9956
10120
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
@@ -10879,10 +11043,15 @@ window.addEventListener('message', (e) => {
10879
11043
  // declares the file the same way pixelkiln edit does.
10880
11044
 
10881
11045
  let SHEET = null;
10882
- const editorSource = (item) => item.edit && item.edit.exists ? item.edit
10883
- : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
10884
- const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
10885
- !(item.outputs.length > 1);
11046
+ // What the editor gets: the edit files when they are all there, else the
11047
+ // generated outputs (one PNG, or every frame of a set).
11048
+ const editorSources = (item) => {
11049
+ if (item.edits.length && item.edits.every((e) => e.exists)) return { files: item.edits, fromEdit: true };
11050
+ if (editableOutputs(item)) return { files: item.outputs, fromEdit: false };
11051
+ return null;
11052
+ };
11053
+ const editorSource = (item) => { const s = editorSources(item); return s ? s.files[0] : null; };
11054
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSources(item);
10886
11055
  const toBase64 = (buf) => new Promise((resolve, reject) => {
10887
11056
  const r = new FileReader();
10888
11057
  r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
@@ -10911,7 +11080,7 @@ function openEditorSheet(item) {
10911
11080
  const bar = el('div', 'rbar');
10912
11081
  const close = el('button', null, 'Close'); close.type = 'button';
10913
11082
  close.onclick = () => closeEditorSheet();
10914
- const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
11083
+ 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) : ''));
10915
11084
  const status = el('span', 'st');
10916
11085
  const acts = el('div', 'acts');
10917
11086
  const msg = el('span', 'msg');
@@ -10932,23 +11101,34 @@ function openEditorSheet(item) {
10932
11101
  stage.append(frame, loading);
10933
11102
  panel.append(bar, stage);
10934
11103
  host.append(scrim, panel);
10935
- SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false };
11104
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false, roles: null };
10936
11105
  sheetStatus('cool', 'loading');
10937
11106
  sheetButtons();
10938
11107
  close.focus({ preventScroll: true });
10939
11108
  }
10940
- // The edit file is what the editor gets; when a browser save kept the layered
10941
- // project beside it, that goes along too and the editor restores the layers,
10942
- // falling back to the flattened PNG if the file cannot be read.
11109
+ // The edit files are what the editor gets; when a browser save kept the
11110
+ // layered project beside them, that goes along too and the editor restores
11111
+ // the layers, falling back to the flattened PNGs if the file cannot be read.
11112
+ // A frame set is sent as frames, one per member, and comes back the same way.
10943
11113
  async function sendOpen() {
10944
- const { item, source } = SHEET;
11114
+ const { item } = SHEET;
11115
+ const sources = editorSources(item);
10945
11116
  const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
10946
- const projectUrl = source === item.edit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
10947
- let png, pxo = null;
11117
+ const projectUrl = sources.fromEdit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
11118
+ const set = sources.files.length > 1;
11119
+ if (set && SHEET.ready.version < 3) {
11120
+ SHEET.msg.textContent = 'This editor build cannot open sets; reinstall it with pixelkiln tools install editor.';
11121
+ sheetStatus('bad', 'unsupported');
11122
+ return;
11123
+ }
11124
+ const pngs = [];
11125
+ let pxo = null;
10948
11126
  try {
10949
- const res = await fetch(source.url, { cache: 'no-store' });
10950
- if (!res.ok) throw new Error(await res.text());
10951
- png = await res.arrayBuffer();
11127
+ for (const file of sources.files) {
11128
+ const res = await fetch(file.url, { cache: 'no-store' });
11129
+ if (!res.ok) throw new Error(await res.text());
11130
+ pngs.push(await res.arrayBuffer());
11131
+ }
10952
11132
  if (projectUrl) {
10953
11133
  const p = await fetch(projectUrl, { cache: 'no-store' });
10954
11134
  if (p.ok) pxo = await p.arrayBuffer();
@@ -10960,12 +11140,22 @@ async function sendOpen() {
10960
11140
  if (!SHEET) return;
10961
11141
  const request = 'open-' + (++SHEET.requests);
10962
11142
  SHEET.sentProject = !!pxo;
11143
+ SHEET.roles = set ? item.outputs.map((o) => o.role || null) : null;
10963
11144
  const message = {
10964
11145
  type: 'pixelkiln:open', request,
10965
11146
  asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
10966
- png, palette: style ? style.palette : [],
11147
+ png: pngs[0], palette: style ? style.palette : [],
10967
11148
  };
10968
- const transfer = [png];
11149
+ const transfer = [pngs[0]];
11150
+ if (set) {
11151
+ message.frames = pngs.map((png, i) => ({ role: SHEET.roles[i], png }));
11152
+ // A tile set is not an animation; a slow rate keeps its timeline still-ish.
11153
+ message.fps = isFrameSet(item) ? (item.fps || 12) : 1;
11154
+ for (const png of pngs.slice(1)) transfer.push(png);
11155
+ // The first frame is both png (older editors) and frames[0]; copy it so both transfer.
11156
+ message.png = pngs[0].slice(0);
11157
+ transfer.push(message.png);
11158
+ }
10969
11159
  if (pxo) { message.pxo = pxo; transfer.push(pxo); }
10970
11160
  SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
10971
11161
  sheetStatus('cool', 'opening');
@@ -10993,8 +11183,9 @@ async function onEditorMessage(m) {
10993
11183
  case 'pixelkiln:opened':
10994
11184
  SHEET.opened = true;
10995
11185
  SHEET.loading.remove();
10996
- if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file with its ' + (m.layers === 1 ? 'layer' : m.layers + ' layers') + ' restored');
10997
- else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file; its layer file could not be opened');
11186
+ 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');
11187
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file' + (m.frames > 1 ? 's' : '') + '; the layer file could not be opened');
11188
+ 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)));
10998
11189
  else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
10999
11190
  sheetButtons();
11000
11191
  break;
@@ -11007,14 +11198,21 @@ async function onEditorMessage(m) {
11007
11198
  const pending = SHEET.pending;
11008
11199
  if (!pending || pending.request !== m.request) return;
11009
11200
  try {
11010
- const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
11201
+ const body = { editor: SHEET.ready.editor, protocol: SHEET.ready.version };
11202
+ if (SHEET.roles) {
11203
+ if (!Array.isArray(m.frames)) throw new Error('the editor returned no frames');
11204
+ body.frames = [];
11205
+ for (const f of m.frames) body.frames.push({ role: f.role === undefined ? null : f.role, png: await toBase64(f.png) });
11206
+ } else {
11207
+ body.png = await toBase64(m.png);
11208
+ }
11011
11209
  if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
11012
11210
  await postHandEdit(SHEET.item, 'save-edit', body);
11013
11211
  const saved = snap.items.find((i) => i.id === SHEET.item.id);
11014
11212
  if (saved) SHEET.item = saved;
11015
11213
  SHEET.dirty = false;
11016
11214
  SHEET.pending = null;
11017
- 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.' };
11215
+ 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.' };
11018
11216
  render();
11019
11217
  if (pending.close) { closeEditorSheet(true); return; }
11020
11218
  sheetStatus('ok', 'saved');
@@ -11162,7 +11360,7 @@ function clearFilters() {
11162
11360
  function thumb(item) {
11163
11361
  const cell = el('div', 'cell');
11164
11362
  // A hand edit is what ships, so it is what the card shows.
11165
- const shown = item.edit && item.edit.url && item.editStatus === 'edited' ? [item.edit] : item.outputs.filter((o) => o.url);
11363
+ const shown = item.edit && item.editStatus === 'edited' && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
11166
11364
  if (!shown.length) {
11167
11365
  cell.classList.add('none');
11168
11366
  cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
@@ -11576,7 +11774,7 @@ function openCompare() {
11576
11774
  // ---- hand edits -------------------------------------------------------------
11577
11775
 
11578
11776
  const EDIT_STATUS_TEXT = {
11579
- same: 'A copy of the generated art, not changed yet. Open it in your editor and save.',
11777
+ same: 'The generated art\u2019s pixels, not changed yet. Open it in your editor and save.',
11580
11778
  edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
11581
11779
  'regenerated-since': 'The generated art changed after this edit was saved \u2014 the edit is based on an older generation.',
11582
11780
  missing: 'The declared file is not on disk.',
@@ -11587,12 +11785,18 @@ async function postHandEdit(item, action, extra) {
11587
11785
  if (item.project) body.project = item.project;
11588
11786
  snap = await postEdit(body);
11589
11787
  }
11788
+ // A single PNG or a set of them \u2014 frames, tiles \u2014 is hand-editable, one file
11789
+ // per member; a set with a GIF in it is not.
11790
+ const editableOutputs = (item) => item.outputs.length && item.outputs.every((o) => o.exists && o.mediaType === 'image/png');
11791
+ // What the members of a set are called: frames for an animation, tiles for a tile set.
11792
+ const memberNoun = (item, n) => isFrameSet(item) ? (n === 1 ? 'frame' : 'frames')
11793
+ : item.outputs.every((o) => o.role && /^tile/i.test(o.role)) ? (n === 1 ? 'tile' : 'tiles') : (n === 1 ? 'member' : 'members');
11590
11794
  function handEditSection(item) {
11591
- const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 &&
11592
- item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png';
11795
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editableOutputs(item);
11593
11796
  if (!item.edit && !canStart) return null;
11797
+ const set = item.edits.length > 1;
11594
11798
  const s = el('section', 'meta');
11595
- s.append(el('h3', null, 'Hand edit'));
11799
+ s.append(el('h3', null, set ? 'Hand edit (' + item.edits.length + ' ' + memberNoun(item, item.edits.length) + ')' : 'Hand edit'));
11596
11800
  if (item.edit) {
11597
11801
  const pair = el('div', 'pair');
11598
11802
  const shared = displayScale(item.width, item.height, 200, 160);
@@ -11606,24 +11810,36 @@ function handEditSection(item) {
11606
11810
  f.append(el('figcaption', null, label));
11607
11811
  return f;
11608
11812
  };
11609
- pair.append(fig(item.outputs[0], 'generated'), fig(item.edit, 'edit'));
11813
+ // For a set, the member the preview is on; the strip there steps through them.
11814
+ const at = set ? Math.min(ui.member, item.edits.length - 1) : 0;
11815
+ const which = set ? ' \xB7 ' + (item.outputs[at].role || memberNoun(item, 1) + ' ' + (at + 1)) : '';
11816
+ pair.append(fig(item.outputs[at], 'generated' + which), fig(item.edits[at], 'edit' + which));
11610
11817
  s.append(pair);
11611
11818
  const dl = el('dl');
11612
11819
  const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
11613
11820
  const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
11614
11821
  row(dl, 'status', st);
11615
11822
  row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
11616
- row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11617
- if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11618
- row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
11823
+ if (set) {
11824
+ 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') });
11825
+ const differing = item.editChanged.filter(Boolean).length;
11826
+ row(dl, 'changed', differing + ' of ' + item.edits.length + ' ' + memberNoun(item, item.edits.length) + ' differ from the generated art' +
11827
+ (differing ? ': ' + item.edits.filter((e, i) => item.editChanged[i]).map((e) => item.outputs[item.edits.indexOf(e)].role || e.path.split('/').pop()).join(', ') : ''));
11828
+ } else {
11829
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
11830
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
11831
+ }
11832
+ row(dl, 'saved', fmtWhen(item.edits.reduce((latest, e) => e.modifiedAt && (!latest || e.modifiedAt > latest) ? e.modifiedAt : latest, null)));
11619
11833
  if (item.editMeta) {
11620
- row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
11621
- if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
11834
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 a file changed since' : ''));
11835
+ if (item.editMeta.basedOn && !set) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
11622
11836
  if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
11623
11837
  }
11624
11838
  s.append(dl);
11625
11839
  } else {
11626
- 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.'));
11840
+ s.append(el('div', 'state-dim', item.outputs.length > 1
11841
+ ? '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.'
11842
+ : '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.'));
11627
11843
  }
11628
11844
  if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
11629
11845
  const acts = el('div', 'hand-actions');
@@ -11651,10 +11867,11 @@ function handEditSection(item) {
11651
11867
  }
11652
11868
  if (item.edit) {
11653
11869
  go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
11654
- go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
11870
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file' + (set ? 's are' : ' is') + ' still at ' + item.source + '; the generated art is placed again.');
11655
11871
  } else if (canStart) {
11656
- 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.');
11657
- go('Create edit file only', 'start-edit', { open: false }, 'Created the edit file and declared it in the manifest.');
11872
+ const many = item.outputs.length > 1;
11873
+ 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.');
11874
+ 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.');
11658
11875
  }
11659
11876
  acts.append(msg);
11660
11877
  s.append(acts);
@@ -12407,11 +12624,305 @@ if (ui.compare.length >= 2 && !ui.open) openCompare();
12407
12624
  }
12408
12625
  var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
12409
12626
 
12627
+ // tools/pixelorama-bridge/pin.json
12628
+ var pin_default = {
12629
+ pixelorama: "v1.2.2",
12630
+ godot: "4.7.2",
12631
+ extensionsApi: 9,
12632
+ protocol: 3,
12633
+ release: "editor-pixelorama-v1.2.2-pk.14",
12634
+ files: {
12635
+ "index.apple-touch-icon.png": {
12636
+ sha256: "ee5434873e0fed91e9a888c3ea5a8b79beac25999308be7a6eb41bf4ce640d27",
12637
+ bytes: 1036
12638
+ },
12639
+ "index.audio.position.worklet.js": {
12640
+ sha256: "be33985bc7160d6bf9646f259cd86b259cd67b02ccb297ee5c44f8ac84327bc8",
12641
+ bytes: 2973
12642
+ },
12643
+ "index.audio.worklet.js": {
12644
+ sha256: "5b476a9c9ce642c0ee4256436d1bc31d9c38f868aca0f9a8e2a57c18d2dec2a3",
12645
+ bytes: 7298
12646
+ },
12647
+ "index.html": {
12648
+ sha256: "2974146c89f4df9f796e840947c334aaf3e4fa3dd18bcd7e08db2eabfd0f086e",
12649
+ bytes: 5442
12650
+ },
12651
+ "index.icon.png": {
12652
+ sha256: "42a327d23178ddc26cc97e3560ebca6bc7c575d2a1b12a4837f2462049ca5745",
12653
+ bytes: 1070
12654
+ },
12655
+ "index.js": {
12656
+ sha256: "33c94cb3175f3333b82e2a3be5e8e86f77986f0aa2042b1631f6367a4e5bb6ba",
12657
+ bytes: 279815
12658
+ },
12659
+ "index.pck": {
12660
+ sha256: "736bd383c82fdadf31cc894caf08490b99c5df572aa20db683972c35c7a0465b",
12661
+ bytes: 6429628
12662
+ },
12663
+ "index.png": {
12664
+ sha256: "6e3ca3eb4a99e4c07bdd89ebc873b440ec0ffd66ce1955160da9e7ed80d538d7",
12665
+ bytes: 13558
12666
+ },
12667
+ "index.wasm": {
12668
+ sha256: "fc74679e3b97f76878947fcd4fbe1268cbfa6188182a2e33bbc3f5dc9bfa57d0",
12669
+ bytes: 39514754
12670
+ }
12671
+ }
12672
+ };
12673
+
12674
+ // src/editor/pin.ts
12675
+ var EDITOR_PIN = pin_default;
12676
+ var EDITOR_RELEASE_BASE = "https://github.com/gfargo/pixelkiln/releases/download";
12677
+
12678
+ // src/editor/install.ts
12679
+ import { createHash as createHash5 } from "crypto";
12680
+ import { existsSync as existsSync21 } from "fs";
12681
+ import { mkdir as mkdir7, readFile as readFile20, rename as rename8, rm as rm10, stat as stat4, writeFile as writeFile10 } from "fs/promises";
12682
+ import os2 from "os";
12683
+ import path25 from "path";
12684
+ var EditorInstallError = class extends Error {
12685
+ constructor(message6, file) {
12686
+ super(message6);
12687
+ this.file = file;
12688
+ this.name = "EditorInstallError";
12689
+ }
12690
+ file;
12691
+ };
12692
+ function editorToolsRoot() {
12693
+ const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
12694
+ if (override) return path25.resolve(override);
12695
+ if (process.platform === "darwin") return path25.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
12696
+ if (process.platform === "win32") {
12697
+ return path25.join(process.env.LOCALAPPDATA ?? path25.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
12698
+ }
12699
+ return path25.join(process.env.XDG_CACHE_HOME ?? path25.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
12700
+ }
12701
+ function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
12702
+ return path25.join(root, "pixelorama", pin.release ?? "unpublished");
12703
+ }
12704
+ function editorBaseUrl(pin = EDITOR_PIN) {
12705
+ const override = process.env.PIXELKILN_EDITOR_URL?.trim();
12706
+ if (override) return override.replace(/\/+$/, "");
12707
+ if (!pin.release) return null;
12708
+ return `${EDITOR_RELEASE_BASE}/${encodeURIComponent(pin.release)}`;
12709
+ }
12710
+ async function sha256Of(file) {
12711
+ try {
12712
+ return createHash5("sha256").update(await readFile20(file)).digest("hex");
12713
+ } catch {
12714
+ return null;
12715
+ }
12716
+ }
12717
+ async function editorStatus(opts = {}) {
12718
+ const pin = opts.pin ?? EDITOR_PIN;
12719
+ const dir = opts.dir ?? editorDir(pin);
12720
+ const missing = [];
12721
+ let installedBytes = 0;
12722
+ let totalBytes = 0;
12723
+ for (const [name, expected] of Object.entries(pin.files)) {
12724
+ totalBytes += expected.bytes;
12725
+ const file = path25.join(dir, name);
12726
+ const info = existsSync21(file) ? await stat4(file).catch(() => null) : null;
12727
+ if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
12728
+ installedBytes += expected.bytes;
12729
+ } else {
12730
+ missing.push(name);
12731
+ }
12732
+ }
12733
+ const pinned = Object.keys(pin.files).length > 0 && pin.release !== null;
12734
+ return {
12735
+ release: pin.release,
12736
+ dir,
12737
+ installed: pinned && missing.length === 0,
12738
+ missing: pinned ? missing : Object.keys(pin.files),
12739
+ totalBytes,
12740
+ installedBytes
12741
+ };
12742
+ }
12743
+ async function installEditor(opts = {}) {
12744
+ const pin = opts.pin ?? EDITOR_PIN;
12745
+ const dir = opts.dir ?? editorDir(pin);
12746
+ const baseUrl = opts.baseUrl ?? editorBaseUrl(pin);
12747
+ const doFetch = opts.fetch ?? fetch;
12748
+ const before = await editorStatus({ pin, dir });
12749
+ if (!pin.release || !Object.keys(pin.files).length) {
12750
+ throw new EditorInstallError(
12751
+ "this PixelKiln version pins no published editor build; upgrade, or set PIXELKILN_EDITOR_URL to a build you trust"
12752
+ );
12753
+ }
12754
+ if (!baseUrl) throw new EditorInstallError("no download location for the editor build");
12755
+ await mkdir7(dir, { recursive: true });
12756
+ const downloaded = [];
12757
+ const skipped = Object.keys(pin.files).filter((name) => !before.missing.includes(name));
12758
+ let fetchedBytes = before.installedBytes;
12759
+ const names = before.missing;
12760
+ for (const [index, name] of names.entries()) {
12761
+ const expected = pin.files[name];
12762
+ const report = (phase, received) => opts.onProgress?.({
12763
+ file: name,
12764
+ index,
12765
+ count: names.length,
12766
+ bytes: expected.bytes,
12767
+ totalBytes: before.totalBytes,
12768
+ fetchedBytes: fetchedBytes + received,
12769
+ phase
12770
+ });
12771
+ report("start", 0);
12772
+ const url = `${baseUrl}/${encodeURIComponent(name)}`;
12773
+ const response = await doFetch(url, { signal: opts.signal, redirect: "follow" });
12774
+ if (!response.ok) throw new EditorInstallError(`${name}: ${response.status} ${response.statusText} from ${url}`, name);
12775
+ const bytes = await readBody(response, expected.bytes, (received) => report("progress", received));
12776
+ if (bytes.length !== expected.bytes) {
12777
+ throw new EditorInstallError(`${name}: expected ${expected.bytes} bytes, got ${bytes.length}`, name);
12778
+ }
12779
+ const digest2 = createHash5("sha256").update(bytes).digest("hex");
12780
+ if (digest2 !== expected.sha256) {
12781
+ throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
12782
+ }
12783
+ const final = path25.join(dir, name);
12784
+ const tmp = `${final}.${process.pid}.part`;
12785
+ try {
12786
+ await writeFile10(tmp, bytes);
12787
+ await rename8(tmp, final);
12788
+ } finally {
12789
+ await rm10(tmp, { force: true });
12790
+ }
12791
+ fetchedBytes += bytes.length;
12792
+ downloaded.push(name);
12793
+ report("done", 0);
12794
+ }
12795
+ const after = await editorStatus({ pin, dir });
12796
+ if (!after.installed) {
12797
+ throw new EditorInstallError(`editor install is incomplete: ${after.missing.join(", ")}`);
12798
+ }
12799
+ return { ...after, downloaded, skipped };
12800
+ }
12801
+ async function readBody(response, expectedBytes, onBytes) {
12802
+ if (!response.body) return Buffer.from(await response.arrayBuffer());
12803
+ const chunks = [];
12804
+ let received = 0;
12805
+ const reader = response.body.getReader();
12806
+ for (; ; ) {
12807
+ const { done, value } = await reader.read();
12808
+ if (done) break;
12809
+ chunks.push(value);
12810
+ received += value.length;
12811
+ if (received > expectedBytes) {
12812
+ await reader.cancel().catch(() => {
12813
+ });
12814
+ break;
12815
+ }
12816
+ onBytes(received);
12817
+ }
12818
+ return Buffer.concat(chunks);
12819
+ }
12820
+ var EDITOR_CONTENT_TYPES = {
12821
+ ".html": "text/html; charset=utf-8",
12822
+ ".js": "text/javascript; charset=utf-8",
12823
+ ".wasm": "application/wasm",
12824
+ ".pck": "application/octet-stream",
12825
+ ".png": "image/png",
12826
+ ".json": "application/json; charset=utf-8"
12827
+ };
12828
+ function editorContentType(name) {
12829
+ return EDITOR_CONTENT_TYPES[path25.extname(name).toLowerCase()] ?? null;
12830
+ }
12831
+
12832
+ // src/gallery/editor.ts
12833
+ import { readFile as readFile21 } from "fs/promises";
12834
+ import path26 from "path";
12835
+ function editorRoute(release, name = "index.html") {
12836
+ return `/editor/${encodeURIComponent(release)}/${encodeURIComponent(name)}`;
12837
+ }
12838
+ function createGalleryEditorHandlers(opts = {}) {
12839
+ const pin = opts.pin ?? EDITOR_PIN;
12840
+ const dir = opts.dir ?? editorDir(pin);
12841
+ const log = opts.onProgress ?? (() => {
12842
+ });
12843
+ let verified = null;
12844
+ let checking = null;
12845
+ let installing = null;
12846
+ let running = null;
12847
+ let error = null;
12848
+ const check = () => {
12849
+ if (!checking) {
12850
+ checking = editorStatus({ pin, dir }).then((status) => {
12851
+ verified = status;
12852
+ return status;
12853
+ }).finally(() => {
12854
+ checking = null;
12855
+ });
12856
+ }
12857
+ return checking;
12858
+ };
12859
+ const describe = (status) => ({
12860
+ ...status,
12861
+ pixelorama: pin.pixelorama,
12862
+ protocol: pin.protocol,
12863
+ url: status.installed && pin.release ? editorRoute(pin.release) : null,
12864
+ installing,
12865
+ error
12866
+ });
12867
+ return {
12868
+ release: pin.release,
12869
+ status: async () => describe(verified ?? await check()),
12870
+ install: async () => {
12871
+ if (!running) {
12872
+ error = null;
12873
+ installing = { file: "", index: 0, count: 0, bytes: 0, totalBytes: 0, fetchedBytes: 0, phase: "start" };
12874
+ log(` installing the editor (Pixelorama ${pin.pixelorama}) into ${dir}`);
12875
+ running = installEditor({
12876
+ pin,
12877
+ dir,
12878
+ baseUrl: opts.baseUrl ?? editorBaseUrl(pin) ?? void 0,
12879
+ ...opts.fetch ? { fetch: opts.fetch } : {},
12880
+ onProgress: (progress) => {
12881
+ installing = progress;
12882
+ }
12883
+ }).then((result) => {
12884
+ verified = result;
12885
+ const mb = (result.totalBytes / 1e6).toFixed(1);
12886
+ log(` editor ready: ${result.downloaded.length} file(s) fetched, ${result.skipped.length} already present (${mb} MB)`);
12887
+ }).catch((err) => {
12888
+ error = err instanceof Error ? err.message : String(err);
12889
+ verified = null;
12890
+ log(` editor install failed: ${error}`);
12891
+ }).finally(() => {
12892
+ installing = null;
12893
+ running = null;
12894
+ });
12895
+ }
12896
+ return describe(verified ?? await check());
12897
+ },
12898
+ file: async (release, name) => {
12899
+ if (!pin.release || release !== pin.release) return null;
12900
+ const expected = pin.files[name];
12901
+ const contentType = editorContentType(name);
12902
+ if (!expected || !contentType) return null;
12903
+ const status = verified ?? await check();
12904
+ if (!status.installed) return null;
12905
+ let bytes;
12906
+ try {
12907
+ bytes = await readFile21(path26.join(dir, name));
12908
+ } catch {
12909
+ verified = null;
12910
+ return null;
12911
+ }
12912
+ if (bytes.length !== expected.bytes) {
12913
+ verified = null;
12914
+ return null;
12915
+ }
12916
+ return { bytes, contentType };
12917
+ }
12918
+ };
12919
+ }
12920
+
12410
12921
  // src/gallery/server.ts
12411
12922
  import { spawn as spawn3 } from "child_process";
12412
12923
  import { randomBytes } from "crypto";
12413
12924
  import { createServer as createServer2 } from "http";
12414
- import { readFile as readFile20 } from "fs/promises";
12925
+ import { readFile as readFile22 } from "fs/promises";
12415
12926
  var MAX_EDIT_BYTES = 48 * 1024 * 1024;
12416
12927
  var EDITOR_CSP = [
12417
12928
  "default-src 'self'",
@@ -12570,7 +13081,7 @@ async function serveGallery(opts) {
12570
13081
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
12571
13082
  if (!asset) return fail(404, "no such review asset");
12572
13083
  try {
12573
- const bytes = await readFile20(asset.path);
13084
+ const bytes = await readFile22(asset.path);
12574
13085
  res.writeHead(200, {
12575
13086
  "Content-Type": asset.contentType,
12576
13087
  "Cache-Control": "no-store",
@@ -12646,7 +13157,7 @@ async function serveGallery(opts) {
12646
13157
  if (!asset) return fail(404, "no such gallery media");
12647
13158
  let bytes;
12648
13159
  try {
12649
- bytes = await readFile20(asset.path);
13160
+ bytes = await readFile22(asset.path);
12650
13161
  } catch {
12651
13162
  return fail(404, "gallery media is no longer on disk; refresh the page");
12652
13163
  }
@@ -12956,19 +13467,19 @@ function createGenerateHandlers(opts) {
12956
13467
  }
12957
13468
 
12958
13469
  // src/pipeline/salvage.ts
12959
- import { readFile as readFile21 } from "fs/promises";
12960
- import { existsSync as existsSync20 } from "fs";
12961
- import path25 from "path";
13470
+ import { readFile as readFile23 } from "fs/promises";
13471
+ import { existsSync as existsSync22 } from "fs";
13472
+ import path27 from "path";
12962
13473
  function providerClaimId(provider, objectId) {
12963
13474
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
12964
13475
  }
12965
13476
  async function loadClaims(lockPaths, opts = {}) {
12966
13477
  const claimed = /* @__PURE__ */ new Set();
12967
13478
  for (const p of lockPaths) {
12968
- if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
13479
+ if (!existsSync22(p)) throw new Error(`Claim lockfile not found: ${p}`);
12969
13480
  let parsed;
12970
13481
  try {
12971
- parsed = parseLock(JSON.parse(await readFile21(p, "utf8")));
13482
+ parsed = parseLock(JSON.parse(await readFile23(p, "utf8")));
12972
13483
  } catch {
12973
13484
  throw new Error(`Claim lockfile is malformed: ${p}`);
12974
13485
  }
@@ -13021,20 +13532,20 @@ function matchOrphanStyle(prompt, manifest) {
13021
13532
  return matchStyleByPattern(prompt, manifest);
13022
13533
  }
13023
13534
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
13024
- const own = path25.resolve(ownManifestPath);
13535
+ const own = path27.resolve(ownManifestPath);
13025
13536
  const siblingManifestPaths = [
13026
13537
  .../* @__PURE__ */ new Set([
13027
13538
  ...workspaceManifestPaths,
13028
- ...claimPaths.map((c) => path25.join(path25.dirname(path25.resolve(c)), "pixelkiln.manifest.json"))
13539
+ ...claimPaths.map((c) => path27.join(path27.dirname(path27.resolve(c)), "pixelkiln.manifest.json"))
13029
13540
  ])
13030
13541
  ];
13031
13542
  const siblings = [];
13032
13543
  for (const siblingManifestPath of siblingManifestPaths) {
13033
- if (path25.resolve(siblingManifestPath) === own) continue;
13034
- if (!existsSync20(siblingManifestPath)) continue;
13544
+ if (path27.resolve(siblingManifestPath) === own) continue;
13545
+ if (!existsSync22(siblingManifestPath)) continue;
13035
13546
  try {
13036
13547
  const { manifest } = await loadManifest(siblingManifestPath);
13037
- siblings.push({ label: path25.basename(path25.dirname(siblingManifestPath)), manifest });
13548
+ siblings.push({ label: path27.basename(path27.dirname(siblingManifestPath)), manifest });
13038
13549
  } catch {
13039
13550
  }
13040
13551
  }
@@ -13150,8 +13661,8 @@ async function applyTags(provider, decisions, existing, opts = {}) {
13150
13661
  }
13151
13662
 
13152
13663
  // src/pick/salvage-server.ts
13153
- import { mkdir as mkdir7, writeFile as writeFile10, readFile as readFile22 } from "fs/promises";
13154
- import path26 from "path";
13664
+ import { mkdir as mkdir8, writeFile as writeFile11, readFile as readFile24 } from "fs/promises";
13665
+ import path28 from "path";
13155
13666
 
13156
13667
  // src/pick/salvage-sheet.ts
13157
13668
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -13316,7 +13827,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13316
13827
  });
13317
13828
  const html = renderSalvageSheet(orphans, {
13318
13829
  styleId: ctx.styleId,
13319
- importDir: path26.relative(process.cwd(), ctx.importDir) || "."
13830
+ importDir: path28.relative(process.cwd(), ctx.importDir) || "."
13320
13831
  });
13321
13832
  const byId = new Map(orphans.map((o) => [o.id, o]));
13322
13833
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -13345,10 +13856,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13345
13856
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
13346
13857
  decodePng(buf);
13347
13858
  const assetId = idFromPrompt(orphan.prompt, taken);
13348
- const rel = path26.join("_salvaged", `${assetId}.png`);
13349
- const outFile = path26.resolve(ctx.importDir, rel);
13350
- await mkdir7(path26.dirname(outFile), { recursive: true });
13351
- await writeFile10(outFile, buf);
13859
+ const rel = path28.join("_salvaged", `${assetId}.png`);
13860
+ const outFile = path28.resolve(ctx.importDir, rel);
13861
+ await mkdir8(path28.dirname(outFile), { recursive: true });
13862
+ await writeFile11(outFile, buf);
13352
13863
  ctx.manifest.assets[assetId] = {
13353
13864
  prompt: orphan.prompt,
13354
13865
  promptByStyle: {},
@@ -13380,7 +13891,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13380
13891
  sourceUrl: durableSource,
13381
13892
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
13382
13893
  outputs: [{
13383
- path: portableOutputPath(outFile, path26.dirname(ctx.manifestPath)),
13894
+ path: portableOutputPath(outFile, path28.dirname(ctx.manifestPath)),
13384
13895
  sha256: sha256(buf)
13385
13896
  }],
13386
13897
  submittedAt: orphan.createdAt,
@@ -13404,9 +13915,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13404
13915
  }
13405
13916
  }
13406
13917
  await applyTags(provider, decisions, existingTags, { onProgress: log });
13407
- const raw = JSON.parse(await readFile22(ctx.manifestPath, "utf8"));
13918
+ const raw = JSON.parse(await readFile24(ctx.manifestPath, "utf8"));
13408
13919
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
13409
- await writeFile10(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
13920
+ await writeFile11(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
13410
13921
  await saveLock(ctx.lockPath, ctx.lock);
13411
13922
  return result;
13412
13923
  }
@@ -13520,17 +14031,23 @@ export {
13520
14031
  ComfyUIProvider,
13521
14032
  DEFAULT_QUALITY_TOLERANCES,
13522
14033
  DEFAULT_RATE_LIMIT,
14034
+ EDITOR_PIN,
14035
+ EDITOR_RELEASE_BASE,
14036
+ EditorInstallError,
13523
14037
  FAKE_PNG,
13524
14038
  FakeProvider,
13525
14039
  GenerateRequestError,
13526
14040
  GenerateRequestSchema,
13527
14041
  GeneratorSchema,
13528
14042
  GridConfidenceSchema,
14043
+ HAND_EDIT_DIR,
14044
+ HandEditCompanionSchema,
13529
14045
  ImageQualityMetricsSchema,
13530
14046
  InheritedStyleSchema,
13531
14047
  LockEntrySchema,
13532
14048
  LockSchema,
13533
14049
  MAX_DOWNLOAD_BYTES,
14050
+ MAX_HAND_EDIT_BYTES,
13534
14051
  MAX_RETRIES,
13535
14052
  ManifestDriftError,
13536
14053
  ManifestEditError,
@@ -13577,12 +14094,19 @@ export {
13577
14094
  countNumberedDescriptions,
13578
14095
  createArtifactBundleManifest,
13579
14096
  createGalleryEditHandler,
14097
+ createGalleryEditorHandlers,
13580
14098
  createGenerateHandlers,
13581
14099
  createProvider,
13582
14100
  currentEntryOutputPath,
13583
14101
  currentOutputPath,
14102
+ detachHandEdit,
13584
14103
  detectMediaType,
13585
14104
  doctor,
14105
+ editorBaseUrl,
14106
+ editorDir,
14107
+ editorRoute,
14108
+ editorStatus,
14109
+ editorToolsRoot,
13586
14110
  evaluateAudit,
13587
14111
  expectedOutputPath,
13588
14112
  exportTileset,
@@ -13594,13 +14118,22 @@ export {
13594
14118
  galleryMediaRoute,
13595
14119
  generationCost,
13596
14120
  groupOrphansByStyle,
14121
+ handEditBase,
14122
+ handEditBases,
14123
+ handEditCompanionPath,
14124
+ handEditMembers,
14125
+ handEditPath,
14126
+ handEditProjectPath,
13597
14127
  hex,
13598
14128
  idFromPrompt,
13599
14129
  imageMetadata,
13600
14130
  inspectCaches,
13601
14131
  inspectQualityProfile,
13602
14132
  inspectRevisionReadiness,
14133
+ installEditor,
13603
14134
  installRecipe,
14135
+ isFrameSetEntry,
14136
+ isMemberSetEntry,
13604
14137
  isSensitiveSourceUrl,
13605
14138
  listBundledRecipes,
13606
14139
  loadClaims,
@@ -13615,12 +14148,14 @@ export {
13615
14148
  measureImageQuality,
13616
14149
  mediaExtension,
13617
14150
  mediaTypeFromExtension,
14151
+ memberPath,
13618
14152
  mergePalettes,
13619
14153
  mountSprites,
13620
14154
  mountStyle,
13621
14155
  normalizeLockOutputPaths,
13622
14156
  normalizeRefinementPalette,
13623
14157
  normalizeTileRules,
14158
+ openInEditor,
13624
14159
  outliers,
13625
14160
  outputId,
13626
14161
  packSprites,
@@ -13640,6 +14175,7 @@ export {
13640
14175
  qualityRecordPath,
13641
14176
  quantizeToPalette,
13642
14177
  readArtifactBundleManifest,
14178
+ readHandEditCompanion,
13643
14179
  readQualityBaseline,
13644
14180
  recipeDigest,
13645
14181
  refineAsset,
@@ -13672,6 +14208,7 @@ export {
13672
14208
  retryAfterMs,
13673
14209
  runPicker,
13674
14210
  runSalvage,
14211
+ saveHandEdit,
13675
14212
  saveLock,
13676
14213
  saveWorkspace,
13677
14214
  scanAssets,
@@ -13683,8 +14220,11 @@ export {
13683
14220
  shouldRetry,
13684
14221
  slugify,
13685
14222
  snapshotQualityBaseline,
14223
+ sourceIsStem,
14224
+ sourceOutputPath,
13686
14225
  specHash,
13687
14226
  spendByUnit,
14227
+ startHandEdit,
13688
14228
  styleImagesBase64,
13689
14229
  submit,
13690
14230
  summarize,