pixelkiln 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,7 @@ provenance and no long-lived npm token.
59
59
  | Shared-account safety | Cross-project claim files or a registered workspace catalog, sibling-style exclusion, reviewed salvage, keep/discard tags, separate confirmed purge. |
60
60
  | Quality control | Palette snapping on download, native grid recovery, named approval, regression baselines, and fail-closed packaging. |
61
61
  | Sprite packaging | Deterministic RGBA packing, stable-cell mounting, explicit external input lists, structural output roles. |
62
- | Engine export | Lossless generic tile contract, Tiled Wang sets, and Godot 4 terrain sets. |
62
+ | Engine export | Aseprite sheet JSON and Godot SpriteFrames from `pack`; Tiled Wang sets and Godot terrain sets from `export`. |
63
63
  | Artifact integrity | Portable source/output hashes, canonical fingerprints, manual-edit protection, transactional promotion, crash journal recovery. |
64
64
  | Library/extension | Public TypeScript primitives, provider capability interface, and deterministic `FakeProvider`. |
65
65
 
package/dist/cli.js CHANGED
@@ -336,8 +336,9 @@ function parseArgs(argv) {
336
336
  }
337
337
  const manifest = get("--manifest") ?? "pixelkiln.manifest.json";
338
338
  const rawFormat = get("--format");
339
- if (rawFormat && rawFormat !== "generic" && rawFormat !== "tiled" && rawFormat !== "godot") {
340
- throw new UsageError(`--format must be generic, tiled, or godot, got "${rawFormat}"`);
339
+ const formats = command === "export" ? ["generic", "tiled", "godot"] : ["generic", "aseprite", "godot"];
340
+ if (rawFormat && !formats.includes(rawFormat)) {
341
+ throw new UsageError(`--format must be ${formats.slice(0, -1).join(", ")}, or ${formats.at(-1)}, got "${rawFormat}"`);
341
342
  }
342
343
  const numberOption = (flag, opts) => {
343
344
  const raw = get(flag);
@@ -465,7 +466,8 @@ Options
465
466
  --columns <n> pack/export: sprites or tiles per row (default: near-square)
466
467
  --port <n> Local review/gallery server port (default: choose a free port)
467
468
  --inputs <path> pack/quality snapshot: JSON input list; needs --out
468
- --format <format> export: generic (default), tiled, or godot
469
+ --format <format> export: generic (default), tiled, or godot (TileSet)
470
+ pack/mount: generic (default), aseprite, or godot (SpriteFrames)
469
471
  --output-role <r> pack: include only this output role (repeatable)
470
472
  --primary-only pack: include only unambiguous primary/single outputs
471
473
  --max-distance <n> audit: maximum palette distance
@@ -522,6 +524,7 @@ Examples
522
524
  pixelkiln adopt --tag
523
525
  pixelkiln pack --style heybud-premium
524
526
  pixelkiln pack --inputs sprites.json --out dist/sheet # no manifest needed
527
+ pixelkiln pack --style hero --format aseprite # sheet JSON engines load
525
528
  pixelkiln mount --style ground
526
529
  pixelkiln export --style ground --only terrain --format tiled
527
530
  pixelkiln refine --style environment
@@ -1903,6 +1906,15 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
1903
1906
  sha256: ""
1904
1907
  }];
1905
1908
  }
1909
+ function frameSetFps(entry) {
1910
+ const namespace = entry.providerMetadata?.[entry.provider];
1911
+ const frameSet = namespace?.frameSet;
1912
+ if (frameSet && typeof frameSet === "object" && "fps" in frameSet) {
1913
+ const fps = frameSet.fps;
1914
+ if (typeof fps === "number" && Number.isFinite(fps) && fps > 0) return fps;
1915
+ }
1916
+ return null;
1917
+ }
1906
1918
 
1907
1919
  // src/pipeline/quality-profile.ts
1908
1920
  import { existsSync as existsSync3 } from "fs";
@@ -9174,16 +9186,7 @@ async function samePixels(a, b) {
9174
9186
  return false;
9175
9187
  }
9176
9188
  }
9177
- function metadataFps(entry) {
9178
- if (!entry) return null;
9179
- const namespace = entry.providerMetadata?.[entry.provider];
9180
- const frameSet = namespace?.frameSet;
9181
- if (frameSet && typeof frameSet === "object" && "fps" in frameSet) {
9182
- const fps = frameSet.fps;
9183
- if (typeof fps === "number" && Number.isFinite(fps) && fps > 0) return fps;
9184
- }
9185
- return null;
9186
- }
9189
+ var metadataFps = (entry) => entry ? frameSetFps(entry) : null;
9187
9190
  async function fileInfo(absolutePath) {
9188
9191
  try {
9189
9192
  const info = await stat2(absolutePath);
@@ -12716,6 +12719,7 @@ import { readFile as readFile23 } from "fs/promises";
12716
12719
  // src/pipeline/pack.ts
12717
12720
  import { readFileSync as readFileSync5 } from "fs";
12718
12721
  import path35 from "path";
12722
+ var DEFAULT_FRAME_SET_FPS = 12;
12719
12723
  function resolvePackInputs(raw, inputsFilePath) {
12720
12724
  if (!Array.isArray(raw) || !raw.length) {
12721
12725
  throw new Error("--inputs must be a non-empty JSON array of { id, path }");
@@ -12815,6 +12819,7 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
12815
12819
  const inputs = [];
12816
12820
  const noOutput = [];
12817
12821
  const locked = /* @__PURE__ */ new Set();
12822
+ const sets = [];
12818
12823
  for (const [key, entry] of entries) {
12819
12824
  const id = key.slice(prefix.length);
12820
12825
  locked.add(id);
@@ -12842,6 +12847,15 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
12842
12847
  continue;
12843
12848
  }
12844
12849
  for (const output of selected) inputs.push({ id: output.id, path: output.absolutePath });
12850
+ if (entry.outputs.length > 1) {
12851
+ const fps = entry.generator === "frames" ? frameSetFps(entry) ?? DEFAULT_FRAME_SET_FPS : null;
12852
+ sets.push({
12853
+ id,
12854
+ kind: entry.generator === "frames" ? "frames" : "members",
12855
+ ...fps ? { fps } : {},
12856
+ frames: selected.map((output) => output.id)
12857
+ });
12858
+ }
12845
12859
  }
12846
12860
  for (const [id, source] of Object.entries(sources)) {
12847
12861
  if (!locked.has(id)) inputs.push({ id, path: path35.resolve(manifestDir, source) });
@@ -12850,9 +12864,11 @@ function packStyle(lock, styleId, manifestDir, options = {}) {
12850
12864
  throw new Error(`No readable sprites for style "${styleId}" \u2014 ${noOutput.length} skipped.`);
12851
12865
  }
12852
12866
  const packed = packSprites(inputs, options);
12867
+ const packedIds = new Set(packed.atlas.frames.map((frame) => frame.id));
12868
+ const placed = sets.map((set) => ({ ...set, frames: set.frames.filter((frameId) => packedIds.has(frameId)) })).filter((set) => set.frames.length).sort((a, b) => a.id.localeCompare(b.id));
12853
12869
  return {
12854
12870
  ...packed,
12855
- atlas: { ...packed.atlas, style: styleId },
12871
+ atlas: { ...packed.atlas, style: styleId, ...placed.length ? { sets: placed } : {} },
12856
12872
  skipped: [...noOutput, ...packed.skipped]
12857
12873
  };
12858
12874
  }
@@ -13261,6 +13277,98 @@ function isRecord2(value) {
13261
13277
  return typeof value === "object" && value !== null && !Array.isArray(value);
13262
13278
  }
13263
13279
 
13280
+ // src/pipeline/sheet-formats.ts
13281
+ var SHEET_FORMATS = ["generic", "aseprite", "godot"];
13282
+ var ASEPRITE_DEFAULT_DURATION_MS = 100;
13283
+ var GODOT_DEFAULT_SPEED = 5;
13284
+ function renderAsepriteSheet(atlas, opts) {
13285
+ const index = new Map(atlas.frames.map((frame, i) => [frame.id, i]));
13286
+ const durations = /* @__PURE__ */ new Map();
13287
+ const frameTags = [];
13288
+ for (const set of atlas.sets ?? []) {
13289
+ const positions = set.frames.map((id) => index.get(id)).filter((i) => i !== void 0);
13290
+ if (!positions.length) continue;
13291
+ if (set.kind === "frames" && set.fps) {
13292
+ for (const id of set.frames) durations.set(id, Math.max(1, Math.round(1e3 / set.fps)));
13293
+ }
13294
+ frameTags.push({ name: set.id, from: Math.min(...positions), to: Math.max(...positions), direction: "forward" });
13295
+ }
13296
+ const frames = {};
13297
+ for (const frame of atlas.frames) {
13298
+ frames[frame.id] = {
13299
+ frame: { x: frame.x, y: frame.y, w: frame.width, h: frame.height },
13300
+ rotated: false,
13301
+ trimmed: false,
13302
+ spriteSourceSize: { x: 0, y: 0, w: frame.width, h: frame.height },
13303
+ sourceSize: { w: frame.width, h: frame.height },
13304
+ duration: durations.get(frame.id) ?? ASEPRITE_DEFAULT_DURATION_MS
13305
+ };
13306
+ }
13307
+ const document = {
13308
+ frames,
13309
+ meta: {
13310
+ app: "https://pixelkiln.griffen.codes",
13311
+ version: opts.version ?? "pixelkiln",
13312
+ image: opts.imageName,
13313
+ format: "RGBA8888",
13314
+ size: { w: atlas.sheet.width, h: atlas.sheet.height },
13315
+ scale: "1",
13316
+ frameTags,
13317
+ layers: [{ name: atlas.style, opacity: 255, blendMode: "normal" }],
13318
+ slices: []
13319
+ }
13320
+ };
13321
+ return JSON.stringify(document, null, 2) + "\n";
13322
+ }
13323
+ function renderGodotSpriteFrames(atlas, opts) {
13324
+ const textureIds = /* @__PURE__ */ new Map();
13325
+ atlas.frames.forEach((frame, i) => textureIds.set(frame.id, `AtlasTexture_${i + 1}`));
13326
+ const inSet = new Set((atlas.sets ?? []).flatMap((set) => set.frames));
13327
+ const lines = [
13328
+ `[gd_resource type="SpriteFrames" load_steps=${atlas.frames.length + 2} format=3]`,
13329
+ "",
13330
+ `[ext_resource type="Texture2D" path=${JSON.stringify(`./${opts.imageName}`)} id="1_texture"]`
13331
+ ];
13332
+ for (const frame of atlas.frames) {
13333
+ lines.push(
13334
+ "",
13335
+ `[sub_resource type="AtlasTexture" id="${textureIds.get(frame.id)}"]`,
13336
+ `atlas = ExtResource("1_texture")`,
13337
+ `region = Rect2(${frame.x}, ${frame.y}, ${frame.width}, ${frame.height})`
13338
+ );
13339
+ }
13340
+ const animations = [];
13341
+ const animation = (name, ids, speed, loop) => {
13342
+ const frames = ids.filter((id) => textureIds.has(id)).map((id) => `{
13343
+ "duration": 1.0,
13344
+ "texture": SubResource("${textureIds.get(id)}")
13345
+ }`);
13346
+ if (!frames.length) return;
13347
+ animations.push(
13348
+ `{
13349
+ "frames": [${frames.join(", ")}],
13350
+ "loop": ${loop},
13351
+ "name": &${JSON.stringify(name)},
13352
+ "speed": ${speed.toFixed(1)}
13353
+ }`
13354
+ );
13355
+ };
13356
+ for (const set of atlas.sets ?? []) {
13357
+ if (set.kind === "frames") animation(set.id, set.frames, set.fps ?? GODOT_DEFAULT_SPEED, true);
13358
+ else for (const id of set.frames) animation(id, [id], GODOT_DEFAULT_SPEED, false);
13359
+ }
13360
+ for (const frame of atlas.frames) {
13361
+ if (!inSet.has(frame.id)) animation(frame.id, [frame.id], GODOT_DEFAULT_SPEED, false);
13362
+ }
13363
+ lines.push("", "[resource]", `animations = [${animations.join(", ")}]`, "");
13364
+ return lines.join("\n");
13365
+ }
13366
+ function renderSheetDocument(format, atlas, opts) {
13367
+ if (format === "aseprite") return { extension: ".json", document: renderAsepriteSheet(atlas, opts) };
13368
+ if (format === "godot") return { extension: ".tres", document: renderGodotSpriteFrames(atlas, opts) };
13369
+ return { extension: ".json", document: JSON.stringify(atlas, null, 2) + "\n" };
13370
+ }
13371
+
13264
13372
  // src/cli/commands/pack.ts
13265
13373
  async function runPack(args) {
13266
13374
  if (args.inputs) {
@@ -13271,20 +13379,22 @@ async function runPack(args) {
13271
13379
  const raw = JSON.parse(await readFile23(path36.resolve(args.inputs), "utf8"));
13272
13380
  const inputs = resolvePackInputs(raw, args.inputs);
13273
13381
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
13274
- const base = path36.resolve(args.out.replace(/\.png$/, ""));
13382
+ const format = sheetFormat(args);
13383
+ const base = path36.resolve(args.out.replace(/\.(?:png|json|tres)$/i, ""));
13384
+ const { extension, document } = renderSheetDocument(format, atlas, { imageName: path36.basename(`${base}.png`) });
13275
13385
  const outputs = [
13276
13386
  { path: `${base}.png`, data: png },
13277
- { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
13387
+ { path: `${base}${extension}`, data: document }
13278
13388
  ];
13279
13389
  await writeManagedArtifactBundle(`${base}.pixelkiln.json`, outputs, {
13280
13390
  kind: "pack",
13281
13391
  sources: [await provenanceFile("$inputs", args.inputs), ...sources],
13282
- options: { columns: args.columns ?? null, order: "id", style: null }
13392
+ options: { columns: args.columns ?? null, format, order: "id", style: null }
13283
13393
  }, { force: args.force });
13284
13394
  log(
13285
- ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
13395
+ ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB (${format})`
13286
13396
  );
13287
- log(` ${path36.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
13397
+ log(` ${path36.relative(process.cwd(), base)}.png + ${extension} + .pixelkiln.json`);
13288
13398
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
13289
13399
  return;
13290
13400
  }
@@ -13305,10 +13415,12 @@ async function runPack(args) {
13305
13415
  sources: manifestSources(loaded.manifest, styleId)
13306
13416
  });
13307
13417
  const style = loaded.manifest.styles[styleId];
13308
- const base = args.out ? path36.resolve(args.out.replace(/\.png$/, "")) : path36.resolve(manifestDir, style.outDir, `${styleId}-sheet`);
13418
+ const base = args.out ? path36.resolve(args.out.replace(/\.(?:png|json|tres)$/i, "")) : path36.resolve(manifestDir, style.outDir, `${styleId}-sheet`);
13419
+ const format = sheetFormat(args);
13420
+ const { extension, document } = renderSheetDocument(format, atlas, { imageName: path36.basename(`${base}.png`) });
13309
13421
  const outputs = [
13310
13422
  { path: `${base}.png`, data: png },
13311
- { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
13423
+ { path: `${base}${extension}`, data: document }
13312
13424
  ];
13313
13425
  const qualityRecords = qualitySources ? await Promise.all(
13314
13426
  packagingSpecs.filter((spec) => spec.quality).map((spec) => provenanceFile(
@@ -13326,6 +13438,7 @@ async function runPack(args) {
13326
13438
  ],
13327
13439
  options: {
13328
13440
  columns: args.columns ?? null,
13441
+ format,
13329
13442
  order: "id",
13330
13443
  outputRoles: [...args.outputRoles].sort(),
13331
13444
  primaryOnly: args.primaryOnly,
@@ -13333,9 +13446,9 @@ async function runPack(args) {
13333
13446
  }
13334
13447
  }, { force: args.force });
13335
13448
  log(
13336
- ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
13449
+ ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)` + (atlas.sets?.length ? `, ${atlas.sets.length} set(s)` : "") + ` (${format})`
13337
13450
  );
13338
- log(` ${path36.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
13451
+ log(` ${path36.relative(process.cwd(), base)}.png + ${extension} + .pixelkiln.json`);
13339
13452
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
13340
13453
  }
13341
13454
  }
@@ -13381,11 +13494,13 @@ async function runMount(args) {
13381
13494
  outputRoles
13382
13495
  );
13383
13496
  const out = path36.resolve(manifestDir, style.mount.out);
13384
- const metadata = out.replace(/\.png$/, "") + ".json";
13497
+ const format = sheetFormat(args);
13498
+ const { extension, document } = renderSheetDocument(format, atlas, { imageName: path36.basename(out) });
13499
+ const metadata = out.replace(/\.png$/, "") + extension;
13385
13500
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
13386
13501
  const outputs = [
13387
13502
  { path: out, data: png },
13388
- { path: metadata, data: JSON.stringify(atlas, null, 2) + "\n" }
13503
+ { path: metadata, data: document }
13389
13504
  ];
13390
13505
  const qualityRecords = qualitySources ? await Promise.all(
13391
13506
  packagingSpecs.filter((spec) => spec.quality && Object.hasOwn(cells, spec.assetId)).map((spec) => provenanceFile(
@@ -13407,6 +13522,7 @@ async function runMount(args) {
13407
13522
  cellHeight: style.mount.cellHeight,
13408
13523
  cellWidth: style.mount.cellWidth,
13409
13524
  cells: Object.entries(cells).sort(([a], [b]) => a.localeCompare(b)),
13525
+ format,
13410
13526
  style: styleId
13411
13527
  }
13412
13528
  }, { force: args.force });
@@ -13473,6 +13589,13 @@ async function runExport(args) {
13473
13589
  );
13474
13590
  }
13475
13591
  }
13592
+ function sheetFormat(args) {
13593
+ const format = args.format ?? "generic";
13594
+ if (!SHEET_FORMATS.includes(format)) {
13595
+ throw new Error(`--format ${format} is for export; sheets take ${SHEET_FORMATS.join(", ")}`);
13596
+ }
13597
+ return format;
13598
+ }
13476
13599
 
13477
13600
  // src/cli/commands/quality.ts
13478
13601
  import path38 from "path";