pixelkiln 0.68.1 → 0.69.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
@@ -55,7 +55,7 @@ provenance and no long-lived npm token.
55
55
  | Hand edits | Touch-ups in your own editor or a pinned in-browser Pixelorama, kept beside the generated art with the generation still the record; frame and tile sets member by member. |
56
56
  | Controlled inputs | Hashed revision lineage (image-to-image and inpaint; on PixelLab also palette reduction, pixel cleanup, animation, skeleton posing, interpolation, and animation edits), fail-closed parent approval, source-versus-candidate review, and content-addressed per-asset ComfyUI bindings. |
57
57
  | Characters | A base in 4 or 8 directions from a prompt or your own sprite (four PixelLab engines), poses, loops, portraits, and outfit transfers as dependent assets generated in waves, free mirrored directions (one `directions` list declares a loop and mirrors the rest), account adoption and salvage, and SpriteFrames export. |
58
- | PixelLab utilities | `unzoom` returns upscaled reference art to its native grid, `font` generates a `.ttf` and glyph atlas, and `estimate-skeleton` starts a keypoints file for skeleton animation. |
58
+ | PixelLab utilities | `unzoom` returns upscaled reference art to its native grid, `font` generates a `.ttf` and glyph atlas, `estimate-skeleton` starts a keypoints file for skeleton animation, and `skeleton-preview` draws its poses over the sprite for free. |
59
59
  | Existing-art onboarding | Manifest scaffolding, exact-hash account adoption, and prompt recovery. |
60
60
  | Recovery | Safe stale-output replacement, validated caches, durable references, resumable paid jobs, and per-asset generation history with free restores. |
61
61
  | Shared-account safety | Cross-project claim files or a registered workspace catalog, sibling-style exclusion, reviewed salvage, keep/discard tags, separate confirmed purge. |
package/dist/cli.js CHANGED
@@ -67,7 +67,7 @@ function exitCodeFor(err) {
67
67
  }
68
68
 
69
69
  // src/cli/main.ts
70
- import { existsSync as existsSync33 } from "fs";
70
+ import { existsSync as existsSync34 } from "fs";
71
71
  import { readFile as readFile31 } from "fs/promises";
72
72
 
73
73
  // src/cli/args.ts
@@ -107,7 +107,8 @@ var VALUE_FLAGS = [
107
107
  "--quantize",
108
108
  "--description",
109
109
  "--weight",
110
- "--glyph-px"
110
+ "--glyph-px",
111
+ "--frames"
111
112
  ];
112
113
  var BOOL_FLAGS = [
113
114
  "--force",
@@ -158,6 +159,7 @@ var COMMANDS = [
158
159
  "recipe",
159
160
  "workspace",
160
161
  "estimate-skeleton",
162
+ "skeleton-preview",
161
163
  "unzoom",
162
164
  "font",
163
165
  "help",
@@ -256,6 +258,12 @@ function parseArgs(argv) {
256
258
  target = rest[0]?.startsWith("-") ? void 0 : rest[0];
257
259
  if (target === void 0) throw new UsageError("estimate-skeleton needs an image path");
258
260
  rest = rest.slice(1);
261
+ } else if (command === "skeleton-preview") {
262
+ target = rest[0]?.startsWith("-") ? void 0 : rest[0];
263
+ if (target === void 0) {
264
+ throw new UsageError("skeleton-preview needs a keypoints file or an animate-skeleton asset id");
265
+ }
266
+ rest = rest.slice(1);
259
267
  } else if (command === "refine") {
260
268
  subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "run" : rest[0];
261
269
  if (!REFINE_SUBCOMMANDS.includes(subcommand)) {
@@ -421,7 +429,8 @@ function parseArgs(argv) {
421
429
  quantize: numberOption("--quantize", { min: -1, max: 256, integer: true }),
422
430
  description: get("--description"),
423
431
  weight: get("--weight"),
424
- glyphPx: numberOption("--glyph-px", { min: 8, max: 64, integer: true })
432
+ glyphPx: numberOption("--glyph-px", { min: 8, max: 64, integer: true }),
433
+ frames: numberOption("--frames", { min: 3, max: 15, integer: true })
425
434
  };
426
435
  }
427
436
 
@@ -461,11 +470,16 @@ Commands
461
470
  first. Local art is untouched.
462
471
  tag Push manifest tags to the objects upstream (free).
463
472
  balance Show the provider's remaining balance.
464
- estimate-skeleton <image> [--out <file>]
465
- PixelLab only: derive an 18-joint skeleton from an image, direct
466
- and un-budgeted (like balance, not a generation). Prints the
467
- keypoints JSON, or writes it with --out \u2014 the way to bootstrap
468
- an animate-skeleton revision's keypointsFile.
473
+ estimate-skeleton <image> [--out <file>] [--frames <n>] [--force]
474
+ PixelLab only: derive an 18-joint skeleton from a square 16-256px
475
+ image, direct and un-budgeted (like balance, not a generation).
476
+ Prints, or writes with --out, a ready keypoints file for an
477
+ animate-skeleton revision: the estimated pose plus --frames copies
478
+ (default 4) to edit. --out refuses to replace a file without --force.
479
+ skeleton-preview <keypoints.json | asset> [--from <image>] [--out <file>]
480
+ Draw every pose of a keypoints file over its source image as one
481
+ PNG, locally and free. Given an animate-skeleton asset id, reads
482
+ its keypointsFile and source from the manifest.
469
483
  status Summarise the lockfile.
470
484
  edit Hand-edit an asset in your own editor: copies the generated PNG to
471
485
  <outDir>/edits/, declares it as the asset's source, and opens it.
@@ -5756,14 +5770,61 @@ import { z as z3 } from "zod";
5756
5770
 
5757
5771
  // src/skeleton.ts
5758
5772
  import { z as z2 } from "zod";
5773
+ var SKELETON_LABELS = [
5774
+ "NOSE",
5775
+ "NECK",
5776
+ "RIGHT SHOULDER",
5777
+ "RIGHT ELBOW",
5778
+ "RIGHT ARM",
5779
+ "LEFT SHOULDER",
5780
+ "LEFT ELBOW",
5781
+ "LEFT ARM",
5782
+ "RIGHT HIP",
5783
+ "RIGHT KNEE",
5784
+ "RIGHT LEG",
5785
+ "LEFT HIP",
5786
+ "LEFT KNEE",
5787
+ "LEFT LEG",
5788
+ "RIGHT EYE",
5789
+ "LEFT EYE",
5790
+ "RIGHT EAR",
5791
+ "LEFT EAR"
5792
+ ];
5793
+ var SKELETON_BONES = [
5794
+ ["NOSE", "NECK"],
5795
+ ["NECK", "RIGHT SHOULDER"],
5796
+ ["RIGHT SHOULDER", "RIGHT ELBOW"],
5797
+ ["RIGHT ELBOW", "RIGHT ARM"],
5798
+ ["NECK", "LEFT SHOULDER"],
5799
+ ["LEFT SHOULDER", "LEFT ELBOW"],
5800
+ ["LEFT ELBOW", "LEFT ARM"],
5801
+ ["NECK", "RIGHT HIP"],
5802
+ ["RIGHT HIP", "RIGHT KNEE"],
5803
+ ["RIGHT KNEE", "RIGHT LEG"],
5804
+ ["NECK", "LEFT HIP"],
5805
+ ["LEFT HIP", "LEFT KNEE"],
5806
+ ["LEFT KNEE", "LEFT LEG"],
5807
+ ["NOSE", "RIGHT EYE"],
5808
+ ["RIGHT EYE", "RIGHT EAR"],
5809
+ ["NOSE", "LEFT EYE"],
5810
+ ["LEFT EYE", "LEFT EAR"]
5811
+ ];
5759
5812
  var SkeletonKeypointSchema = z2.object({
5760
- label: z2.string().min(1),
5813
+ label: z2.enum(SKELETON_LABELS),
5761
5814
  x: z2.number().min(0).max(1),
5762
5815
  y: z2.number().min(0).max(1),
5763
5816
  z_index: z2.number(),
5764
5817
  depth: z2.number().optional()
5765
5818
  }).strict();
5766
- var SkeletonFrameSchema = z2.array(SkeletonKeypointSchema).length(18);
5819
+ var SkeletonFrameSchema = z2.array(SkeletonKeypointSchema).length(18).superRefine((frame, context) => {
5820
+ const seen = /* @__PURE__ */ new Set();
5821
+ for (const [index, joint] of frame.entries()) {
5822
+ if (seen.has(joint.label)) {
5823
+ context.addIssue({ code: z2.ZodIssueCode.custom, message: `"${joint.label}" appears twice`, path: [index, "label"] });
5824
+ }
5825
+ seen.add(joint.label);
5826
+ }
5827
+ });
5767
5828
  var SkeletonSetSchema = z2.object({
5768
5829
  firstFrameKeypoints: SkeletonFrameSchema,
5769
5830
  frames: z2.array(SkeletonFrameSchema).min(3).max(15)
@@ -5776,6 +5837,11 @@ function parseSkeletonSet(json, file) {
5776
5837
  }
5777
5838
  return result.data;
5778
5839
  }
5840
+ var DEFAULT_SCAFFOLD_FRAMES = 4;
5841
+ function scaffoldSkeletonSet(keypoints, frames = DEFAULT_SCAFFOLD_FRAMES) {
5842
+ const copy = () => keypoints.map((joint) => ({ ...joint }));
5843
+ return { firstFrameKeypoints: copy(), frames: Array.from({ length: frames }, copy) };
5844
+ }
5779
5845
 
5780
5846
  // src/client.ts
5781
5847
  var BASE = process.env.PIXELLAB_API_BASE ?? "https://api.pixellab.ai/v2";
@@ -7128,7 +7194,7 @@ var PixelLabClient = class {
7128
7194
  * account (see `EstimateSkeletonResponseSchema`'s own caveat).
7129
7195
  */
7130
7196
  async estimateSkeleton(args) {
7131
- const body = { image: args.image };
7197
+ const body = { image: { type: "base64", base64: args.image.base64, format: args.image.format } };
7132
7198
  const res = validateResponse(
7133
7199
  EstimateSkeletonResponseSchema,
7134
7200
  await this.request("/estimate-skeleton", { method: "POST", body: JSON.stringify(body) }),
@@ -20537,27 +20603,192 @@ async function runRefine(args) {
20537
20603
 
20538
20604
  // src/cli/commands/skeleton.ts
20539
20605
  import path46 from "path";
20606
+ import { existsSync as existsSync32 } from "fs";
20540
20607
  import { readFile as readFile30, writeFile as writeFile15 } from "fs/promises";
20608
+
20609
+ // src/skeleton-preview.ts
20610
+ var RIGHT = [255, 140, 40, 255];
20611
+ var LEFT = [60, 150, 255, 255];
20612
+ var CENTER = [240, 240, 240, 255];
20613
+ var GROUND = [28, 28, 34, 255];
20614
+ var OUTLINE = [0, 0, 0, 255];
20615
+ var GUTTER = 4;
20616
+ var MIN_CELL = 192;
20617
+ function renderSkeletonSheet(set, options = {}) {
20618
+ const poses = [set.firstFrameKeypoints, ...set.frames];
20619
+ const source = options.source;
20620
+ const scale = source ? Math.max(1, Math.ceil(MIN_CELL / Math.max(source.width, source.height))) : 1;
20621
+ const cellW = source ? source.width * scale : options.cellSize ?? MIN_CELL;
20622
+ const cellH = source ? source.height * scale : options.cellSize ?? MIN_CELL;
20623
+ const columns = Math.min(options.columns ?? 8, poses.length);
20624
+ const rows = Math.ceil(poses.length / columns);
20625
+ const width = columns * cellW + (columns + 1) * GUTTER;
20626
+ const height = rows * cellH + (rows + 1) * GUTTER;
20627
+ const canvas = new Canvas(width, height);
20628
+ canvas.fill(0, 0, width, height, [12, 12, 16, 255]);
20629
+ poses.forEach((pose, index) => {
20630
+ const left = GUTTER + index % columns * (cellW + GUTTER);
20631
+ const top = GUTTER + Math.floor(index / columns) * (cellH + GUTTER);
20632
+ canvas.fill(left, top, cellW, cellH, GROUND);
20633
+ if (source) canvas.drawImage(source, left, top, scale, index === 0 ? 1 : 0.45);
20634
+ drawPose(canvas, pose, left, top, cellW, cellH, Math.max(1, Math.round(Math.min(cellW, cellH) / 128)));
20635
+ });
20636
+ return encodeRgbaPng(width, height, canvas.data);
20637
+ }
20638
+ function drawPose(canvas, pose, left, top, w, h, stroke) {
20639
+ const at = new Map(pose.map((joint) => [joint.label, joint]));
20640
+ const point = (joint) => [left + joint.x * (w - 1), top + joint.y * (h - 1)];
20641
+ for (const outline of [true, false]) {
20642
+ for (const [from, to] of SKELETON_BONES) {
20643
+ const a = at.get(from);
20644
+ const b = at.get(to);
20645
+ if (!a || !b) continue;
20646
+ const [x0, y0] = point(a);
20647
+ const [x1, y1] = point(b);
20648
+ canvas.line(x0, y0, x1, y1, outline ? OUTLINE : colorOf(to), outline ? stroke + 2 : stroke);
20649
+ }
20650
+ for (const joint of pose) {
20651
+ const [x, y] = point(joint);
20652
+ canvas.dot(x, y, outline ? stroke + 3 : stroke + 1, outline ? OUTLINE : colorOf(joint.label));
20653
+ }
20654
+ }
20655
+ }
20656
+ function colorOf(label) {
20657
+ if (label.startsWith("RIGHT")) return RIGHT;
20658
+ if (label.startsWith("LEFT")) return LEFT;
20659
+ return CENTER;
20660
+ }
20661
+ var Canvas = class {
20662
+ constructor(width, height) {
20663
+ this.width = width;
20664
+ this.height = height;
20665
+ this.data = Buffer.alloc(width * height * 4);
20666
+ }
20667
+ width;
20668
+ height;
20669
+ data;
20670
+ set(x, y, [r, g, b, a]) {
20671
+ const px = Math.round(x);
20672
+ const py = Math.round(y);
20673
+ if (px < 0 || py < 0 || px >= this.width || py >= this.height) return;
20674
+ const i = (py * this.width + px) * 4;
20675
+ this.data[i] = r;
20676
+ this.data[i + 1] = g;
20677
+ this.data[i + 2] = b;
20678
+ this.data[i + 3] = a;
20679
+ }
20680
+ fill(x, y, w, h, color) {
20681
+ for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) this.set(x + dx, y + dy, color);
20682
+ }
20683
+ /** Nearest-neighbour upscale, composited over what is there, at `strength`. */
20684
+ drawImage(image, left, top, scale, strength) {
20685
+ for (let y = 0; y < image.height; y++) {
20686
+ for (let x = 0; x < image.width; x++) {
20687
+ const i = (y * image.width + x) * 4;
20688
+ const alpha = image.pixels[i + 3] / 255 * strength;
20689
+ if (alpha === 0) continue;
20690
+ const src = [image.pixels[i], image.pixels[i + 1], image.pixels[i + 2]];
20691
+ for (let dy = 0; dy < scale; dy++) {
20692
+ for (let dx = 0; dx < scale; dx++) {
20693
+ const o = ((top + y * scale + dy) * this.width + left + x * scale + dx) * 4;
20694
+ for (let c = 0; c < 3; c++) this.data[o + c] = Math.round(src[c] * alpha + this.data[o + c] * (1 - alpha));
20695
+ }
20696
+ }
20697
+ }
20698
+ }
20699
+ }
20700
+ line(x0, y0, x1, y1, color, stroke) {
20701
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
20702
+ for (let step = 0; step <= steps; step++) {
20703
+ const t = step / steps;
20704
+ this.dot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, stroke, color);
20705
+ }
20706
+ }
20707
+ /** A filled square `size` wide centred on (x, y): crisp at pixel-art scale. */
20708
+ dot(x, y, size, color) {
20709
+ const half = Math.floor(size / 2);
20710
+ for (let dy = -half; dy < size - half; dy++) for (let dx = -half; dx < size - half; dx++) this.set(x + dx, y + dy, color);
20711
+ }
20712
+ };
20713
+
20714
+ // src/cli/commands/skeleton.ts
20715
+ var ESTIMATE_SKELETON_SIZES = [16, 32, 64, 128, 256];
20541
20716
  async function runEstimateSkeleton(args) {
20542
20717
  const image = args.target;
20543
20718
  if (!image) throw new UsageError("estimate-skeleton needs an image path: pixelkiln estimate-skeleton <image> [--out keypoints.json]");
20719
+ if (args.out && existsSync32(path46.resolve(args.out)) && !args.force) {
20720
+ throw new UsageError(`${args.out} already exists; pass --force to replace it`);
20721
+ }
20544
20722
  loadEnvFiles(path46.dirname(path46.resolve(args.manifest)));
20545
20723
  loadEnvFiles(process.cwd());
20546
20724
  const bytes = await readFile30(path46.resolve(image));
20547
20725
  const metadata = imageMetadata(bytes);
20548
20726
  if (!metadata) throw new UsageError(`${image} is not a readable PNG or JPEG`);
20727
+ if (metadata.width !== metadata.height || !ESTIMATE_SKELETON_SIZES.includes(metadata.width)) {
20728
+ throw new UsageError(
20729
+ `${image} is ${metadata.width}x${metadata.height}; estimate-skeleton takes a square image of ${ESTIMATE_SKELETON_SIZES.join(", ")} pixels`
20730
+ );
20731
+ }
20549
20732
  const client2 = clientFromEnv();
20550
20733
  const res = await client2.estimateSkeleton({
20551
20734
  image: { base64: bytes.toString("base64"), format: metadata.format }
20552
20735
  });
20553
- const json = JSON.stringify(res.keypoints, null, 2);
20736
+ const frames = args.frames ?? DEFAULT_SCAFFOLD_FRAMES;
20737
+ const json = JSON.stringify(scaffoldSkeletonSet(res.keypoints, frames), null, 2);
20554
20738
  if (args.out) {
20555
20739
  await writeFile15(path46.resolve(args.out), json + "\n");
20556
- log(` wrote ${res.keypoints.length} keypoints to ${args.out}`);
20740
+ log(` wrote ${args.out}: the estimated pose and ${frames} frames to edit`);
20741
+ log(` preview it with: pixelkiln skeleton-preview ${args.out} --from ${image}`);
20557
20742
  } else {
20558
20743
  log(json);
20559
20744
  }
20560
20745
  }
20746
+ async function runSkeletonPreview(args) {
20747
+ const target = args.target;
20748
+ let keypointsFile;
20749
+ let sourceFile;
20750
+ let label;
20751
+ if (target.endsWith(".json")) {
20752
+ keypointsFile = path46.resolve(target);
20753
+ sourceFile = args.from ? path46.resolve(args.from) : void 0;
20754
+ label = target;
20755
+ } else {
20756
+ const loaded = await loadManifest(args.manifest);
20757
+ const specs = (await resolveSpecs(loaded, { styles: args.styles, assets: [target] })).filter((spec2) => spec2.assetId === target);
20758
+ const spec = specs.find((candidate) => candidate.revision?.mode === "animate-skeleton");
20759
+ if (!spec) {
20760
+ throw new UsageError(
20761
+ specs.length ? `${target} is not an animate-skeleton revision; pass a keypoints file instead` : `No asset "${target}" in ${args.manifest}`
20762
+ );
20763
+ }
20764
+ keypointsFile = spec.revision.keypointsFile;
20765
+ sourceFile = args.from ? path46.resolve(args.from) : spec.revision.sourceFile;
20766
+ label = `${spec.styleId}/${spec.assetId}`;
20767
+ }
20768
+ if (!existsSync32(keypointsFile)) throw new UsageError(`Keypoints file does not exist yet: ${keypointsFile}`);
20769
+ let json;
20770
+ try {
20771
+ json = JSON.parse(await readFile30(keypointsFile, "utf8"));
20772
+ } catch (error) {
20773
+ throw new UsageError(`${keypointsFile} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
20774
+ }
20775
+ const set = parseSkeletonSet(json, keypointsFile);
20776
+ let source;
20777
+ if (sourceFile) {
20778
+ if (!existsSync32(sourceFile)) {
20779
+ log(` source image does not exist yet, drawing on a plain ground: ${sourceFile}`);
20780
+ } else {
20781
+ const bytes = await readFile30(sourceFile);
20782
+ if (imageMetadata(bytes)?.format !== "png") throw new UsageError(`${sourceFile} must be a PNG to preview over`);
20783
+ source = decodePng(bytes);
20784
+ }
20785
+ }
20786
+ const out = path46.resolve(args.out ?? keypointsFile.replace(/\.json$/, "") + ".preview.png");
20787
+ await writeFile15(out, renderSkeletonSheet(set, { source }));
20788
+ log(` ${label}: ${set.frames.length} frames after the starting pose`);
20789
+ const shown = path46.relative(process.cwd(), out);
20790
+ log(` wrote ${shown.startsWith("..") ? out : shown}`);
20791
+ }
20561
20792
 
20562
20793
  // src/cli/commands/tools.ts
20563
20794
  async function runTools(args) {
@@ -20602,7 +20833,7 @@ async function runTools(args) {
20602
20833
 
20603
20834
  // src/cli/commands/workspace.ts
20604
20835
  import path47 from "path";
20605
- import { existsSync as existsSync32 } from "fs";
20836
+ import { existsSync as existsSync33 } from "fs";
20606
20837
  async function runWorkspace(args) {
20607
20838
  const workspacePath = path47.resolve(args.workspace ?? "pixelkiln.workspace.json");
20608
20839
  const dir = path47.dirname(workspacePath);
@@ -20632,7 +20863,7 @@ async function runWorkspace(args) {
20632
20863
  log(` registered "${id}" in ${path47.relative(process.cwd(), workspacePath)}`);
20633
20864
  log(` manifest: ${project.manifest}`);
20634
20865
  log(` lock: ${project.lock}`);
20635
- if (!existsSync32(lockPath)) {
20866
+ if (!existsSync33(lockPath)) {
20636
20867
  log(
20637
20868
  ` warning: no lockfile there yet; this project contributes no claims until one is generated`
20638
20869
  );
@@ -20656,7 +20887,7 @@ async function runWorkspace(args) {
20656
20887
  return;
20657
20888
  }
20658
20889
  if (args.subcommand === "list") {
20659
- if (!existsSync32(workspacePath)) {
20890
+ if (!existsSync33(workspacePath)) {
20660
20891
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
20661
20892
  }
20662
20893
  const ws = await loadWorkspace(workspacePath);
@@ -20676,7 +20907,7 @@ async function runWorkspace(args) {
20676
20907
  return;
20677
20908
  }
20678
20909
  if (args.subcommand === "status") {
20679
- if (!existsSync32(workspacePath)) {
20910
+ if (!existsSync33(workspacePath)) {
20680
20911
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
20681
20912
  }
20682
20913
  const ws = await loadWorkspace(workspacePath);
@@ -20738,7 +20969,7 @@ async function runHelp() {
20738
20969
  async function runVersion() {
20739
20970
  for (const up of ["../package.json", "../../package.json"]) {
20740
20971
  const url = new URL(up, import.meta.url);
20741
- if (!existsSync33(url)) continue;
20972
+ if (!existsSync34(url)) continue;
20742
20973
  const pkg = JSON.parse(await readFile31(url, "utf8"));
20743
20974
  if (pkg.name === "pixelkiln") {
20744
20975
  log(`${pkg.name} ${pkg.version}`);
@@ -20784,6 +21015,7 @@ var RUNNERS = {
20784
21015
  recipe: runRecipe,
20785
21016
  workspace: runWorkspace,
20786
21017
  "estimate-skeleton": runEstimateSkeleton,
21018
+ "skeleton-preview": runSkeletonPreview,
20787
21019
  unzoom: runUnzoom,
20788
21020
  font: runFont
20789
21021
  };