pixelkiln 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -872,12 +872,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
872
872
  }
873
873
  validate(spec, styleImages) {
874
874
  const options = resolvedOptions(spec);
875
- if (spec.source && !spec.revision) {
876
- if (spec.generator === "frames") {
877
- throw new Error("ComfyUI frame sets require generated provider outputs, not asset.source");
878
- }
879
- return;
880
- }
875
+ if (spec.source && !spec.revision && spec.generator !== "frames") return;
881
876
  if (spec.width < 16 || spec.height < 16 || spec.width > 4096 || spec.height > 4096) {
882
877
  throw new Error("ComfyUI output dimensions must be between 16 and 4096 pixels");
883
878
  }
@@ -3673,13 +3668,25 @@ function resolveOutputPath(recordedPath, manifestDir) {
3673
3668
  return path5.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path5.sep));
3674
3669
  }
3675
3670
  function expectedOutputPath(spec, role, index, total, mediaType) {
3676
- const originalExt = path5.extname(spec.outFile);
3671
+ return memberPath(spec.outFile, role, index, total, mediaType);
3672
+ }
3673
+ function memberPath(file, role, index, total, mediaType) {
3674
+ const originalExt = path5.extname(file);
3677
3675
  const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3678
- const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3676
+ const stem = originalExt ? file.slice(0, -originalExt.length) : file;
3679
3677
  if (total === 1) return `${stem}${ext}`;
3680
3678
  const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3681
3679
  return `${stem}-${safeRole}${ext}`;
3682
3680
  }
3681
+ function isFrameSetEntry(entry) {
3682
+ return entry.generator === "frames" && entry.outputs.length > 1;
3683
+ }
3684
+ function sourceOutputPath(source, entry, index, manifestDir) {
3685
+ const absolute = path5.resolve(manifestDir, source);
3686
+ if (!isFrameSetEntry(entry)) return absolute;
3687
+ const output = entry.outputs[index];
3688
+ return memberPath(absolute, output.role, index, entry.outputs.length, output.mediaType);
3689
+ }
3683
3690
  function currentOutputPath(output, spec, index, total) {
3684
3691
  return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3685
3692
  }
@@ -4336,12 +4343,15 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
4336
4343
  for (const [key, entry] of entries) {
4337
4344
  const id = key.slice(prefix.length);
4338
4345
  locked.add(id);
4339
- const outputs = resolveEntryOutputs(entry, id, manifestDir);
4346
+ let outputs = resolveEntryOutputs(entry, id, manifestDir);
4340
4347
  const source = sources[id];
4341
4348
  if (source && outputs.length <= 1) {
4342
4349
  inputs.push({ id, path: path7.resolve(manifestDir, source) });
4343
4350
  continue;
4344
4351
  }
4352
+ if (source && isFrameSetEntry(entry)) {
4353
+ outputs = outputs.map((output) => ({ ...output, absolutePath: sourceOutputPath(source, entry, output.index, manifestDir) }));
4354
+ }
4345
4355
  let selected = outputs;
4346
4356
  if (options.outputRoles?.length) {
4347
4357
  selected = outputs.filter(
@@ -4463,11 +4473,21 @@ function mountStyle(lock, styleId, manifestDir, mount, cells, sources = {}, outp
4463
4473
  for (const id of ids) {
4464
4474
  const cell = cells[id];
4465
4475
  const source = sources[id];
4466
- if (source) {
4476
+ const entry = lock.entries[`${styleId}/${id}`];
4477
+ if (source && !(entry && isFrameSetEntry(entry))) {
4467
4478
  placements.push({ id, path: path7.resolve(manifestDir, source), cell });
4468
4479
  continue;
4469
4480
  }
4470
- const entry = lock.entries[`${styleId}/${id}`];
4481
+ if (source && entry) {
4482
+ const selection2 = selectEntryOutput(entry, outputRoles[id]);
4483
+ if (!selection2.ok) {
4484
+ skipped.push({ id, reason: selection2.reason });
4485
+ continue;
4486
+ }
4487
+ const index = entry.outputs.findIndex((output) => output.path === selection2.output.path);
4488
+ placements.push({ id, path: sourceOutputPath(source, entry, Math.max(index, 0), manifestDir), cell });
4489
+ continue;
4490
+ }
4471
4491
  if (!entry) {
4472
4492
  skipped.push({
4473
4493
  id,
@@ -6036,7 +6056,7 @@ async function buildPlan(specs, lock, opts = {}) {
6036
6056
  } else if (opts.force) {
6037
6057
  state = "missing";
6038
6058
  reason = "--force";
6039
- } else if (!entry && spec.source) {
6059
+ } else if (!entry && spec.source && spec.generator !== "frames") {
6040
6060
  if (existsSync8(path13.resolve(spec.root, spec.source))) {
6041
6061
  state = "ok";
6042
6062
  reason = `placed from ${spec.source}; not generated`;
@@ -8246,36 +8266,54 @@ function handEditPath(loaded, spec) {
8246
8266
  const file = inside ? relative : path20.basename(spec.outFile);
8247
8267
  return path20.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8248
8268
  }
8249
- function handEditBase(spec, lock) {
8269
+ function handEditBases(spec, lock) {
8250
8270
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8251
8271
  if (entry) {
8252
- if (entry.outputs.length !== 1) return null;
8253
- const primary = primaryOutput(entry);
8254
- if (!primary || primary.mediaType && primary.mediaType !== "image/png") return null;
8255
- const file = currentEntryOutputPath(entry, spec, 0);
8256
- return existsSync16(file) ? file : null;
8272
+ if (entry.outputs.length !== 1 && !isFrameSetEntry(entry)) return null;
8273
+ const members = entry.outputs.map((output, index) => ({
8274
+ role: entry.outputs.length === 1 ? null : output.role ?? null,
8275
+ path: currentEntryOutputPath(entry, spec, index),
8276
+ mediaType: output.mediaType
8277
+ }));
8278
+ if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
8279
+ if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
8280
+ if (!members.every((member) => existsSync16(member.path))) return null;
8281
+ return members.map(({ role, path: file }) => ({ role, path: file }));
8282
+ }
8283
+ return existsSync16(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8284
+ }
8285
+ function handEditMembers(loaded, spec, lock, editPath) {
8286
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8287
+ const bases = handEditBases(spec, lock);
8288
+ if (entry && isFrameSetEntry(entry)) {
8289
+ return entry.outputs.map((output, index) => ({
8290
+ role: output.role ?? null,
8291
+ path: memberPath(editPath, output.role, index, entry.outputs.length, output.mediaType),
8292
+ base: bases?.[index]?.path ?? null
8293
+ }));
8257
8294
  }
8258
- return existsSync16(spec.outFile) ? spec.outFile : null;
8295
+ return [{ role: null, path: editPath, base: bases?.[0]?.path ?? null }];
8259
8296
  }
8260
8297
  async function startHandEdit(loaded, lock, spec, opts = {}) {
8261
8298
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8262
- if (entry && entry.outputs.length > 1) {
8299
+ if (entry && entry.outputs.length > 1 && !isFrameSetEntry(entry)) {
8263
8300
  throw new ManifestEditError(
8264
- `${spec.styleId}/${spec.assetId} has ${entry.outputs.length} outputs; hand edits currently cover single-image assets`
8301
+ `${spec.styleId}/${spec.assetId} is a structural set of ${entry.outputs.length} outputs; hand edits cover single images and frame sets`
8265
8302
  );
8266
8303
  }
8267
8304
  const existing = spec.source ? path20.resolve(loaded.root, spec.source) : null;
8268
8305
  const editPath = existing ?? handEditPath(loaded, spec);
8306
+ const members = handEditMembers(loaded, spec, lock, editPath);
8269
8307
  let created = false;
8270
- if (!existsSync16(editPath)) {
8271
- const base = handEditBase(spec, lock);
8272
- if (!base) {
8308
+ for (const member of members) {
8309
+ if (existsSync16(member.path)) continue;
8310
+ if (!member.base) {
8273
8311
  throw new ManifestEditError(
8274
8312
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
8275
8313
  );
8276
8314
  }
8277
- await mkdir6(path20.dirname(editPath), { recursive: true });
8278
- await copyFile(base, editPath);
8315
+ await mkdir6(path20.dirname(member.path), { recursive: true });
8316
+ await copyFile(member.base, member.path);
8279
8317
  created = true;
8280
8318
  }
8281
8319
  const source = portableOutputPath(editPath, loaded.root);
@@ -8292,7 +8330,7 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
8292
8330
  declared = result.changed;
8293
8331
  manifestSha256 = result.sha256;
8294
8332
  }
8295
- return { editPath, source, created, declared, manifestSha256 };
8333
+ return { editPath, members, source, created, declared, manifestSha256 };
8296
8334
  }
8297
8335
  async function detachHandEdit(loaded, spec, opts = {}) {
8298
8336
  if (!spec.source) {
@@ -8306,19 +8344,44 @@ async function detachHandEdit(loaded, spec, opts = {}) {
8306
8344
  });
8307
8345
  return { manifestSha256: result.sha256, changed: result.changed };
8308
8346
  }
8309
- var HandEditCompanionSchema = z6.object({
8310
- version: z6.literal(1),
8311
- /** sha256 of the generated art the edit started from; null when it started from untracked art. */
8347
+ var CompanionOutputSchema = z6.object({
8348
+ /** Output role for a set member; null for a single image. */
8349
+ role: z6.string().nullable(),
8350
+ /** sha256 of the generated file this member started from; null when it started from untracked art. */
8312
8351
  basedOn: z6.string().regex(/^[0-9a-f]{64}$/).nullable(),
8313
- /** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
8314
- sha256: z6.string().regex(/^[0-9a-f]{64}$/),
8352
+ /** sha256 of the member as saved, so a later change by another tool is visible. */
8353
+ sha256: z6.string().regex(/^[0-9a-f]{64}$/)
8354
+ }).strict();
8355
+ var CompanionV2Schema = z6.object({
8356
+ version: z6.literal(2),
8315
8357
  /** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
8316
8358
  editor: z6.string().min(1),
8317
8359
  protocol: z6.number().int().positive(),
8318
8360
  savedAt: z6.string().datetime(),
8319
8361
  /** Basename of the layered project file beside the edit, when one was kept. */
8362
+ project: z6.string().min(1).nullable(),
8363
+ /** One entry per file, in output order. */
8364
+ outputs: z6.array(CompanionOutputSchema).min(1)
8365
+ }).strict();
8366
+ var CompanionV1Schema = z6.object({
8367
+ version: z6.literal(1),
8368
+ basedOn: z6.string().regex(/^[0-9a-f]{64}$/).nullable(),
8369
+ sha256: z6.string().regex(/^[0-9a-f]{64}$/),
8370
+ editor: z6.string().min(1),
8371
+ protocol: z6.number().int().positive(),
8372
+ savedAt: z6.string().datetime(),
8320
8373
  project: z6.string().min(1).nullable()
8321
8374
  }).strict();
8375
+ var HandEditCompanionSchema = z6.union([CompanionV2Schema, CompanionV1Schema]).transform(
8376
+ (value) => value.version === 2 ? value : {
8377
+ version: 2,
8378
+ editor: value.editor,
8379
+ protocol: value.protocol,
8380
+ savedAt: value.savedAt,
8381
+ project: value.project,
8382
+ outputs: [{ role: null, basedOn: value.basedOn, sha256: value.sha256 }]
8383
+ }
8384
+ );
8322
8385
  function handEditCompanionPath(editPath) {
8323
8386
  return editPath.replace(/\.png$/i, "") + ".edit.json";
8324
8387
  }
@@ -8340,45 +8403,79 @@ async function readHandEditCompanion(editPath) {
8340
8403
  }
8341
8404
  var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
8342
8405
  async function saveHandEdit(loaded, lock, spec, input) {
8343
- if (input.png.length > MAX_HAND_EDIT_BYTES) {
8344
- throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8345
- }
8406
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8407
+ const frameSet = entry ? isFrameSetEntry(entry) : false;
8408
+ const incoming = input.frames ?? (input.png ? [{ role: null, png: input.png }] : []);
8409
+ if (!incoming.length) throw new ManifestEditError("the save carried no image");
8346
8410
  if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
8347
8411
  throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8348
8412
  }
8349
- let decoded;
8350
- try {
8351
- validateMedia(input.png, MediaType.PNG);
8352
- decoded = decodePng(input.png);
8353
- } catch (error) {
8354
- throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
8413
+ if (frameSet) {
8414
+ const expected = entry.outputs.map((output) => output.role ?? null);
8415
+ const got = incoming.map((frame) => frame.role);
8416
+ if (got.length !== expected.length || expected.some((role, index) => got[index] !== role)) {
8417
+ throw new ManifestEditError(
8418
+ `${spec.styleId}/${spec.assetId} is a set of ${expected.length} frames (${expected.join(", ")}); the editor returned ${got.length} (${got.map((role) => role ?? "new").join(", ")}) \u2014 keep the frames it opened with`
8419
+ );
8420
+ }
8421
+ } else if (incoming.length !== 1) {
8422
+ throw new ManifestEditError(`${spec.styleId}/${spec.assetId} is one image; the editor returned ${incoming.length} frames \u2014 keep a single frame`);
8355
8423
  }
8356
- const base = handEditBase(spec, lock);
8357
- const expected = base ? decodePng(await readFile15(base)) : { width: spec.width, height: spec.height };
8358
- if (decoded.width !== expected.width || decoded.height !== expected.height) {
8359
- throw new ManifestEditError(
8360
- `the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
8361
- );
8424
+ const decoded = incoming.map((frame, index) => {
8425
+ if (frame.png.length > MAX_HAND_EDIT_BYTES) {
8426
+ throw new ManifestEditError(`the edit is ${frame.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
8427
+ }
8428
+ try {
8429
+ validateMedia(frame.png, MediaType.PNG);
8430
+ return decodePng(frame.png);
8431
+ } catch (error) {
8432
+ const which = frameSet ? `frame ${frame.role ?? index}` : "the edit";
8433
+ throw new ManifestEditError(`${which} is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
8434
+ }
8435
+ });
8436
+ const bases = handEditBases(spec, lock);
8437
+ const expectedSize = bases?.[0] ? decodePng(await readFile15(bases[0].path)) : { width: spec.width, height: spec.height };
8438
+ for (const [index, image] of decoded.entries()) {
8439
+ if (image.width !== expectedSize.width || image.height !== expectedSize.height) {
8440
+ const which = frameSet ? `frame ${incoming[index].role ?? index}` : "the edit";
8441
+ throw new ManifestEditError(
8442
+ `${which} is ${image.width}\xD7${image.height}; ${spec.styleId}/${spec.assetId} is ${expectedSize.width}\xD7${expectedSize.height}`
8443
+ );
8444
+ }
8362
8445
  }
8363
8446
  const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
8364
- const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
8365
- const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
8366
- const digest2 = sha256(input.png);
8367
8447
  const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
8368
8448
  const companion = {
8369
- version: 1,
8370
- basedOn,
8371
- sha256: digest2,
8449
+ version: 2,
8372
8450
  editor: input.editor,
8373
8451
  protocol: input.protocol,
8374
8452
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
8375
- project: projectPath ? path20.basename(projectPath) : null
8453
+ project: projectPath ? path20.basename(projectPath) : null,
8454
+ outputs: incoming.map((frame, index) => ({
8455
+ role: frame.role,
8456
+ basedOn: basedOnHash(entry, index),
8457
+ sha256: sha256(frame.png)
8458
+ }))
8376
8459
  };
8377
- await replaceFile(started.editPath, input.png);
8460
+ for (const [index, member] of started.members.entries()) {
8461
+ await replaceFile(member.path, incoming[index].png);
8462
+ }
8378
8463
  if (projectPath && input.project) await replaceFile(projectPath, input.project);
8379
8464
  const companionPath = handEditCompanionPath(started.editPath);
8380
8465
  await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
8381
- return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
8466
+ return {
8467
+ ...started,
8468
+ sha256: companion.outputs[0].sha256,
8469
+ basedOn: companion.outputs[0].basedOn,
8470
+ companion,
8471
+ companionPath,
8472
+ projectPath
8473
+ };
8474
+ }
8475
+ function basedOnHash(entry, index) {
8476
+ if (!entry) return null;
8477
+ if (entry.outputs.length === 1) return primaryOutput(entry)?.sha256 ?? null;
8478
+ return entry.outputs[index]?.sha256 ?? null;
8382
8479
  }
8383
8480
  async function replaceFile(file, bytes) {
8384
8481
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
@@ -8425,7 +8522,10 @@ var HandEditRequestSchema = z7.discriminatedUnion("action", [
8425
8522
  assetId: z7.string().min(1),
8426
8523
  styleId: z7.string().min(1),
8427
8524
  expectedSha256: z7.string().regex(/^[0-9a-f]{64}$/),
8428
- png: Base64.min(1),
8525
+ /** The flattened image; for a frame set, `frames` instead. */
8526
+ png: Base64.min(1).optional(),
8527
+ /** Every frame of a set, tagged with the role it was opened under (null for one added in the editor). */
8528
+ frames: z7.array(z7.object({ role: z7.string().min(1).nullable(), png: Base64.min(1) }).strict()).min(1).max(256).optional(),
8429
8529
  /** The editor's layered project file, kept beside the edit. */
8430
8530
  pxo: Base64.optional(),
8431
8531
  /** Editor identity from the bridge's ready message. */
@@ -8448,18 +8548,23 @@ function createGalleryEditHandler(opts) {
8448
8548
  const started = await startHandEdit(ctx.loaded, ctx.lock, spec, { expectedSha256: request.expectedSha256 });
8449
8549
  log2(` hand edit ${started.created ? "created" : "found"}: ${started.source}${started.declared ? " (declared in the manifest)" : ""}`);
8450
8550
  if (request.open) {
8451
- const command = (opts.openEditor ?? openInEditor)(started.editPath);
8551
+ const command = (opts.openEditor ?? openInEditor)(started.members[0].path);
8452
8552
  log2(` opened with: ${command}`);
8453
8553
  }
8454
8554
  } else if (request.action === "save-edit") {
8555
+ if (request.png !== void 0 === (request.frames !== void 0)) {
8556
+ throw new ManifestEditError("invalid edit: send png for one image or frames for a set, not both");
8557
+ }
8455
8558
  const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
8456
- png: Buffer.from(request.png, "base64"),
8559
+ ...request.png !== void 0 ? { png: Buffer.from(request.png, "base64") } : {},
8560
+ ...request.frames ? { frames: request.frames.map((frame) => ({ role: frame.role, png: Buffer.from(frame.png, "base64") })) } : {},
8457
8561
  ...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
8458
8562
  editor: request.editor,
8459
8563
  protocol: request.protocol,
8460
8564
  expectedSha256: request.expectedSha256
8461
8565
  });
8462
- log2(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
8566
+ const what = saved.members.length > 1 ? `${saved.source} (${saved.members.length} frames)` : saved.source;
8567
+ log2(` hand edit saved from ${request.editor}: ${what}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
8463
8568
  } else {
8464
8569
  const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
8465
8570
  if (result2.changed) log2(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
@@ -8490,6 +8595,7 @@ function createGalleryEditHandler(opts) {
8490
8595
  }
8491
8596
 
8492
8597
  // src/gallery/snapshot.ts
8598
+ var PROJECT_FILE_TYPE = "application/octet-stream";
8493
8599
  function galleryMediaId(absolutePath) {
8494
8600
  return createHash3("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
8495
8601
  }
@@ -8506,6 +8612,18 @@ function matchesFilter(filter, styleId, assetId) {
8506
8612
  if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
8507
8613
  return true;
8508
8614
  }
8615
+ var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
8616
+ async function samePixels(a, b) {
8617
+ try {
8618
+ const [left, right] = await Promise.all([readFile16(a), readFile16(b)]);
8619
+ if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
8620
+ const x = decodePng(left);
8621
+ const y = decodePng(right);
8622
+ return x.width === y.width && x.height === y.height && x.pixels.equals(y.pixels);
8623
+ } catch {
8624
+ return false;
8625
+ }
8626
+ }
8509
8627
  function metadataFps(entry) {
8510
8628
  if (!entry) return null;
8511
8629
  const namespace = entry.providerMetadata?.[entry.provider];
@@ -8623,6 +8741,8 @@ async function buildGallerySnapshot(opts) {
8623
8741
  const asset = loaded.manifest.assets[spec.assetId] ?? null;
8624
8742
  let outputs;
8625
8743
  let edit = null;
8744
+ let edits = [];
8745
+ let editChanged = [];
8626
8746
  let editStatus = null;
8627
8747
  let editMeta = null;
8628
8748
  if (entry && entry.outputs.length) {
@@ -8633,22 +8753,52 @@ async function buildGallerySnapshot(opts) {
8633
8753
  );
8634
8754
  if (spec.source) {
8635
8755
  const editPath = path22.resolve(root, spec.source);
8636
- const editSha = existsSync17(editPath) ? await sha256File(editPath) : null;
8637
- edit = await describeOutput(media, root, editPath, { sha256: editSha });
8638
- const generated = outputs[0];
8756
+ const memberCount = isFrameSetEntry(entry) ? entry.outputs.length : 1;
8757
+ const editShas = await Promise.all(
8758
+ Array.from({ length: memberCount }, async (_, index) => {
8759
+ const file = sourceOutputPath(spec.source, entry, index, root);
8760
+ return { file, sha256: existsSync17(file) ? await sha256File(file) : null };
8761
+ })
8762
+ );
8763
+ edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
8764
+ edit = edits[0] ?? null;
8639
8765
  const companion = await readHandEditCompanion(editPath);
8640
8766
  if (companion) {
8641
8767
  const projectPath = handEditProjectPath(editPath);
8768
+ const project = companion.project && existsSync17(projectPath) ? await fileInfo(projectPath) : null;
8769
+ let projectUrl = null;
8770
+ if (project) {
8771
+ const id = galleryMediaId(projectPath);
8772
+ media.set(id, { path: projectPath, contentType: PROJECT_FILE_TYPE });
8773
+ projectUrl = `${galleryMediaRoute(id)}?v=${project.bytes}-${Date.parse(project.modifiedAt).toString(36)}`;
8774
+ }
8642
8775
  editMeta = {
8643
8776
  editor: companion.editor,
8644
8777
  savedAt: companion.savedAt,
8645
- basedOn: companion.basedOn,
8646
- changedSince: editSha !== null && editSha !== companion.sha256,
8647
- project: companion.project && existsSync17(projectPath) ? portableOutputPath(projectPath, root) : null
8778
+ basedOn: companion.outputs[0]?.basedOn ?? null,
8779
+ changedSince: editShas.some(({ sha256: editSha }, index) => {
8780
+ const recorded = companion.outputs[index]?.sha256;
8781
+ return editSha !== null && recorded !== void 0 && editSha !== recorded;
8782
+ }),
8783
+ project: project ? portableOutputPath(projectPath, root) : null,
8784
+ projectUrl
8648
8785
  };
8649
8786
  }
8650
- 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);
8651
- editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
8787
+ const regenerated = edits.some((member, index) => {
8788
+ const generated = outputs[index];
8789
+ if (companion) {
8790
+ const recorded = companion.outputs[index];
8791
+ return recorded !== void 0 && generated.sha256 !== null && recorded.basedOn !== generated.sha256;
8792
+ }
8793
+ return Boolean(generated.modifiedAt && member.modifiedAt && generated.modifiedAt > member.modifiedAt && generated.exists);
8794
+ });
8795
+ editChanged = await Promise.all(edits.map(async (member, index) => {
8796
+ const generated = outputs[index];
8797
+ if (!member.exists || !generated.exists) return true;
8798
+ if (member.sha256 === generated.sha256) return false;
8799
+ return !await samePixels(member.absolutePath, generated.absolutePath);
8800
+ }));
8801
+ editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
8652
8802
  }
8653
8803
  } else if (spec.source) {
8654
8804
  outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
@@ -8697,6 +8847,8 @@ async function buildGallerySnapshot(opts) {
8697
8847
  asset,
8698
8848
  source: spec.source ?? null,
8699
8849
  edit,
8850
+ edits,
8851
+ editChanged,
8700
8852
  editStatus,
8701
8853
  editMeta,
8702
8854
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
@@ -8754,6 +8906,8 @@ async function buildGallerySnapshot(opts) {
8754
8906
  asset: null,
8755
8907
  source: null,
8756
8908
  edit: null,
8909
+ edits: [],
8910
+ editChanged: [],
8757
8911
  editStatus: null,
8758
8912
  editMeta: null,
8759
8913
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
@@ -9688,10 +9842,15 @@ window.addEventListener('message', (e) => {
9688
9842
  // declares the file the same way pixelkiln edit does.
9689
9843
 
9690
9844
  let SHEET = null;
9691
- const editorSource = (item) => item.edit && item.edit.exists ? item.edit
9692
- : item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
9693
- const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
9694
- !(item.outputs.length > 1);
9845
+ // What the editor gets: the edit files when they are all there, else the
9846
+ // generated outputs (one PNG, or every frame of a set).
9847
+ const editorSources = (item) => {
9848
+ if (item.edits.length && item.edits.every((e) => e.exists)) return { files: item.edits, fromEdit: true };
9849
+ if (editableOutputs(item)) return { files: item.outputs, fromEdit: false };
9850
+ return null;
9851
+ };
9852
+ const editorSource = (item) => { const s = editorSources(item); return s ? s.files[0] : null; };
9853
+ const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSources(item);
9695
9854
  const toBase64 = (buf) => new Promise((resolve, reject) => {
9696
9855
  const r = new FileReader();
9697
9856
  r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
@@ -9720,7 +9879,7 @@ function openEditorSheet(item) {
9720
9879
  const bar = el('div', 'rbar');
9721
9880
  const close = el('button', null, 'Close'); close.type = 'button';
9722
9881
  close.onclick = () => closeEditorSheet();
9723
- const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
9882
+ 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 + ' frames' : ''));
9724
9883
  const status = el('span', 'st');
9725
9884
  const acts = el('div', 'acts');
9726
9885
  const msg = el('span', 'msg');
@@ -9741,30 +9900,62 @@ function openEditorSheet(item) {
9741
9900
  stage.append(frame, loading);
9742
9901
  panel.append(bar, stage);
9743
9902
  host.append(scrim, panel);
9744
- SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src };
9903
+ SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false, roles: null };
9745
9904
  sheetStatus('cool', 'loading');
9746
9905
  sheetButtons();
9747
9906
  close.focus({ preventScroll: true });
9748
9907
  }
9908
+ // The edit files are what the editor gets; when a browser save kept the
9909
+ // layered project beside them, that goes along too and the editor restores
9910
+ // the layers, falling back to the flattened PNGs if the file cannot be read.
9911
+ // A frame set is sent as frames, one per member, and comes back the same way.
9749
9912
  async function sendOpen() {
9750
- const { item, source } = SHEET;
9913
+ const { item } = SHEET;
9914
+ const sources = editorSources(item);
9751
9915
  const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
9752
- let png;
9916
+ const projectUrl = sources.fromEdit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
9917
+ const set = sources.files.length > 1;
9918
+ if (set && SHEET.ready.version < 3) {
9919
+ SHEET.msg.textContent = 'This editor build cannot open frame sets; reinstall it with pixelkiln tools install editor.';
9920
+ sheetStatus('bad', 'unsupported');
9921
+ return;
9922
+ }
9923
+ const pngs = [];
9924
+ let pxo = null;
9753
9925
  try {
9754
- const res = await fetch(source.url, { cache: 'no-store' });
9755
- if (!res.ok) throw new Error(await res.text());
9756
- png = await res.arrayBuffer();
9926
+ for (const file of sources.files) {
9927
+ const res = await fetch(file.url, { cache: 'no-store' });
9928
+ if (!res.ok) throw new Error(await res.text());
9929
+ pngs.push(await res.arrayBuffer());
9930
+ }
9931
+ if (projectUrl) {
9932
+ const p = await fetch(projectUrl, { cache: 'no-store' });
9933
+ if (p.ok) pxo = await p.arrayBuffer();
9934
+ }
9757
9935
  } catch (err) {
9758
9936
  if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
9759
9937
  return;
9760
9938
  }
9761
9939
  if (!SHEET) return;
9762
9940
  const request = 'open-' + (++SHEET.requests);
9763
- SHEET.frame.contentWindow.postMessage({
9941
+ SHEET.sentProject = !!pxo;
9942
+ SHEET.roles = set ? item.outputs.map((o) => o.role || null) : null;
9943
+ const message = {
9764
9944
  type: 'pixelkiln:open', request,
9765
9945
  asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
9766
- png, palette: style ? style.palette : [],
9767
- }, location.origin, [png]);
9946
+ png: pngs[0], palette: style ? style.palette : [],
9947
+ };
9948
+ const transfer = [pngs[0]];
9949
+ if (set) {
9950
+ message.frames = pngs.map((png, i) => ({ role: SHEET.roles[i], png }));
9951
+ message.fps = item.fps || 12;
9952
+ for (const png of pngs.slice(1)) transfer.push(png);
9953
+ // The first frame is both png (older editors) and frames[0]; copy it so both transfer.
9954
+ message.png = pngs[0].slice(0);
9955
+ transfer.push(message.png);
9956
+ }
9957
+ if (pxo) { message.pxo = pxo; transfer.push(pxo); }
9958
+ SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
9768
9959
  sheetStatus('cool', 'opening');
9769
9960
  }
9770
9961
  function requestEditorSave(close) {
@@ -9790,7 +9981,10 @@ async function onEditorMessage(m) {
9790
9981
  case 'pixelkiln:opened':
9791
9982
  SHEET.opened = true;
9792
9983
  SHEET.loading.remove();
9793
- sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
9984
+ 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 + ' frames' : '') + ' restored');
9985
+ else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file' + (m.frames > 1 ? 's' : '') + '; the layer file could not be opened');
9986
+ else if (m.source === 'frames') sheetStatus('ok', (SHEET.item.edits.length ? 'editing the edit files' : 'editing a copy of the generated frames') + ' as ' + m.frames + ' frames');
9987
+ else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
9794
9988
  sheetButtons();
9795
9989
  break;
9796
9990
  case 'pixelkiln:dirty':
@@ -9802,14 +9996,21 @@ async function onEditorMessage(m) {
9802
9996
  const pending = SHEET.pending;
9803
9997
  if (!pending || pending.request !== m.request) return;
9804
9998
  try {
9805
- const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
9999
+ const body = { editor: SHEET.ready.editor, protocol: SHEET.ready.version };
10000
+ if (SHEET.roles) {
10001
+ if (!Array.isArray(m.frames)) throw new Error('the editor returned no frames');
10002
+ body.frames = [];
10003
+ for (const f of m.frames) body.frames.push({ role: f.role === undefined ? null : f.role, png: await toBase64(f.png) });
10004
+ } else {
10005
+ body.png = await toBase64(m.png);
10006
+ }
9806
10007
  if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
9807
10008
  await postHandEdit(SHEET.item, 'save-edit', body);
9808
10009
  const saved = snap.items.find((i) => i.id === SHEET.item.id);
9809
10010
  if (saved) SHEET.item = saved;
9810
10011
  SHEET.dirty = false;
9811
10012
  SHEET.pending = null;
9812
- 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.' };
10013
+ ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edits.length > 1 ? saved.edits.length + ' frames 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.' };
9813
10014
  render();
9814
10015
  if (pending.close) { closeEditorSheet(true); return; }
9815
10016
  sheetStatus('ok', 'saved');
@@ -9957,7 +10158,7 @@ function clearFilters() {
9957
10158
  function thumb(item) {
9958
10159
  const cell = el('div', 'cell');
9959
10160
  // A hand edit is what ships, so it is what the card shows.
9960
- const shown = item.edit && item.edit.url && item.editStatus === 'edited' ? [item.edit] : item.outputs.filter((o) => o.url);
10161
+ const shown = item.edit && item.editStatus === 'edited' && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
9961
10162
  if (!shown.length) {
9962
10163
  cell.classList.add('none');
9963
10164
  cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
@@ -10371,7 +10572,7 @@ function openCompare() {
10371
10572
  // ---- hand edits -------------------------------------------------------------
10372
10573
 
10373
10574
  const EDIT_STATUS_TEXT = {
10374
- same: 'A copy of the generated art, not changed yet. Open it in your editor and save.',
10575
+ same: 'The generated art\u2019s pixels, not changed yet. Open it in your editor and save.',
10375
10576
  edited: 'Differs from the generated art. mount and pack place this file; the generated file stays as the record.',
10376
10577
  'regenerated-since': 'The generated art changed after this edit was saved \u2014 the edit is based on an older generation.',
10377
10578
  missing: 'The declared file is not on disk.',
@@ -10382,12 +10583,16 @@ async function postHandEdit(item, action, extra) {
10382
10583
  if (item.project) body.project = item.project;
10383
10584
  snap = await postEdit(body);
10384
10585
  }
10586
+ // A single PNG, or an ordered frame set: both are hand-editable, one file per
10587
+ // member. Structural sets (tiles, characters) are not.
10588
+ const editableOutputs = (item) => item.outputs.length && (item.outputs.length === 1 || isFrameSet(item)) &&
10589
+ item.outputs.every((o) => o.exists && o.mediaType === 'image/png');
10385
10590
  function handEditSection(item) {
10386
- const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 &&
10387
- item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png';
10591
+ const canStart = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editableOutputs(item);
10388
10592
  if (!item.edit && !canStart) return null;
10593
+ const set = item.edits.length > 1;
10389
10594
  const s = el('section', 'meta');
10390
- s.append(el('h3', null, 'Hand edit'));
10595
+ s.append(el('h3', null, set ? 'Hand edit (' + item.edits.length + ' frames)' : 'Hand edit'));
10391
10596
  if (item.edit) {
10392
10597
  const pair = el('div', 'pair');
10393
10598
  const shared = displayScale(item.width, item.height, 200, 160);
@@ -10401,24 +10606,36 @@ function handEditSection(item) {
10401
10606
  f.append(el('figcaption', null, label));
10402
10607
  return f;
10403
10608
  };
10404
- pair.append(fig(item.outputs[0], 'generated'), fig(item.edit, 'edit'));
10609
+ // For a set, the member the preview is on; the strip there steps through them.
10610
+ const at = set ? Math.min(ui.member, item.edits.length - 1) : 0;
10611
+ const which = set ? ' \xB7 ' + (item.outputs[at].role || 'frame ' + (at + 1)) : '';
10612
+ pair.append(fig(item.outputs[at], 'generated' + which), fig(item.edits[at], 'edit' + which));
10405
10613
  s.append(pair);
10406
10614
  const dl = el('dl');
10407
10615
  const tone = item.editStatus === 'edited' ? 'ok' : item.editStatus === 'same' ? 'dim' : 'warn';
10408
10616
  const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone), document.createTextNode(' ' + item.editStatus));
10409
10617
  row(dl, 'status', st);
10410
10618
  row(dl, 'why', EDIT_STATUS_TEXT[item.editStatus] || '');
10411
- row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
10412
- if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
10413
- row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
10619
+ if (set) {
10620
+ row(dl, 'files', item.source + ' \u2014 one per frame: ' + item.edits.map((e) => e.path.split('/').pop()).join(', '), { mono: true, copy: item.edits.map((e) => e.absolutePath).join('\\n') });
10621
+ const differing = item.editChanged.filter(Boolean).length;
10622
+ row(dl, 'changed', differing + ' of ' + item.edits.length + ' frames differ from the generated art' +
10623
+ (differing ? ': ' + item.edits.filter((e, i) => item.editChanged[i]).map((e) => item.outputs[item.edits.indexOf(e)].role || e.path.split('/').pop()).join(', ') : ''));
10624
+ } else {
10625
+ row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
10626
+ if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
10627
+ }
10628
+ row(dl, 'saved', fmtWhen(item.edits.reduce((latest, e) => e.modifiedAt && (!latest || e.modifiedAt > latest) ? e.modifiedAt : latest, null)));
10414
10629
  if (item.editMeta) {
10415
- row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
10416
- if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
10630
+ row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 a file changed since' : ''));
10631
+ if (item.editMeta.basedOn && !set) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
10417
10632
  if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
10418
10633
  }
10419
10634
  s.append(dl);
10420
10635
  } else {
10421
- 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.'));
10636
+ s.append(el('div', 'state-dim', isFrameSet(item)
10637
+ ? 'Touch the frames up in your own editor or in the browser, where they open as one animation. The generated files stay untouched; the edit is one sibling file per frame that the manifest points at, and mount and pack place those instead.'
10638
+ : '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.'));
10422
10639
  }
10423
10640
  if (EDITABLE && item.declared && projectOf(item)?.manifestSha256) {
10424
10641
  const acts = el('div', 'hand-actions');
@@ -10446,10 +10663,10 @@ function handEditSection(item) {
10446
10663
  }
10447
10664
  if (item.edit) {
10448
10665
  go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
10449
- go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
10666
+ go('Detach edit', 'detach-edit', {}, 'Detached. The file' + (set ? 's are' : ' is') + ' still at ' + item.source + '; the generated art is placed again.');
10450
10667
  } else if (canStart) {
10451
- 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.');
10452
- go('Create edit file only', 'start-edit', { open: false }, 'Created the edit file and declared it in the manifest.');
10668
+ go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file' + (isFrameSet(item) ? 's and opened the first frame' : ' and opened it') + ' in your editor. Save there, then Refresh to see it here.');
10669
+ go('Create edit file' + (isFrameSet(item) ? 's' : '') + ' only', 'start-edit', { open: false }, 'Created the edit file' + (isFrameSet(item) ? 's' : '') + ' and declared ' + (isFrameSet(item) ? 'them' : 'it') + ' in the manifest.');
10453
10670
  }
10454
10671
  acts.append(msg);
10455
10672
  s.append(acts);
@@ -11762,8 +11979,8 @@ var pin_default = {
11762
11979
  pixelorama: "v1.2.2",
11763
11980
  godot: "4.7.2",
11764
11981
  extensionsApi: 9,
11765
- protocol: 1,
11766
- release: "editor-pixelorama-v1.2.2-pk.6",
11982
+ protocol: 3,
11983
+ release: "editor-pixelorama-v1.2.2-pk.14",
11767
11984
  files: {
11768
11985
  "index.apple-touch-icon.png": {
11769
11986
  sha256: "ee5434873e0fed91e9a888c3ea5a8b79beac25999308be7a6eb41bf4ce640d27",
@@ -11778,7 +11995,7 @@ var pin_default = {
11778
11995
  bytes: 7298
11779
11996
  },
11780
11997
  "index.html": {
11781
- sha256: "43299b159b780809044fc0a484906bb93a280116c22cc87388ee2ab8263ac1f3",
11998
+ sha256: "2974146c89f4df9f796e840947c334aaf3e4fa3dd18bcd7e08db2eabfd0f086e",
11782
11999
  bytes: 5442
11783
12000
  },
11784
12001
  "index.icon.png": {
@@ -11790,8 +12007,8 @@ var pin_default = {
11790
12007
  bytes: 279815
11791
12008
  },
11792
12009
  "index.pck": {
11793
- sha256: "ee10aa3d6e09a1ff2a334d0de7df1ae57f32ce00698948892a46443c2c67ae7a",
11794
- bytes: 6426700
12010
+ sha256: "736bd383c82fdadf31cc894caf08490b99c5df572aa20db683972c35c7a0465b",
12011
+ bytes: 6429628
11795
12012
  },
11796
12013
  "index.png": {
11797
12014
  sha256: "6e3ca3eb4a99e4c07bdd89ebc873b440ec0ffd66ce1955160da9e7ed80d538d7",
@@ -15098,12 +15315,14 @@ async function main() {
15098
15315
  return;
15099
15316
  }
15100
15317
  const started = await startHandEdit(loaded, lock, spec);
15101
- log(` ${started.created ? "created" : "found"} ${started.source}${started.declared ? " and declared it as the asset's source" : ""}`);
15318
+ const set = started.members.length > 1;
15319
+ log(` ${started.created ? "created" : "found"} ${started.source}${set ? ` (${started.members.length} frames)` : ""}${started.declared ? " and declared it as the asset's source" : ""}`);
15320
+ if (set) for (const member of started.members) log(` ${member.role ?? ""} ${path30.relative(process.cwd(), member.path)}`);
15102
15321
  if (args.noOpen) {
15103
- log(` edit it with your own tool, then run pixelkiln plan; mount and pack place it in place of the generated art`);
15322
+ log(` edit ${set ? "them" : "it"} with your own tool, then run pixelkiln plan; mount and pack place ${set ? "them" : "it"} in place of the generated art`);
15104
15323
  } else {
15105
- const command = openInEditor(started.editPath);
15106
- log(` opened with: ${command}${process.env.PIXELKILN_EDITOR ? "" : " (set PIXELKILN_EDITOR to choose the program)"}`);
15324
+ const command = openInEditor(started.members[0].path);
15325
+ log(` opened ${set ? "the first frame " : ""}with: ${command}${process.env.PIXELKILN_EDITOR ? "" : " (set PIXELKILN_EDITOR to choose the program)"}`);
15107
15326
  }
15108
15327
  return;
15109
15328
  }