pixelkiln 0.16.0 → 0.17.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,10 +55,10 @@ PixelKiln keeps the missing record:
55
55
  | Workflow | What PixelKiln provides |
56
56
  |---|---|
57
57
  | Plan and budget | Offline manifest/lock/disk diff, provider-grouped estimates, keyed mixed-provider budget ceilings, JSON/CI gate. |
58
- | Generate and review | Resumable submit/poll/pick/fetch pipeline with a fast local candidate sheet. |
58
+ | Generate and review | Resumable submit/poll/pick/fetch pipeline, exact next-step hints, and a fast local candidate sheet. |
59
59
  | Controlled revisions | Hashed image-to-image/inpaint lineage, fail-closed parent approval, and source-versus-candidate review; ComfyUI is the first adapter. |
60
60
  | Existing-art onboarding | Manifest scaffolding, exact-hash account adoption, and prompt recovery. |
61
- | Recovery | Validated local content cache, durable provider-reference restore, account object-hash cache, and resumable jobs. |
61
+ | Recovery | Safe stale-output replacement, validated caches, durable references, and resumable paid jobs. |
62
62
  | Shared-account safety | Cross-project claim files or a registered workspace catalog, sibling-style exclusion, reviewed salvage, keep/discard tags, separate confirmed purge. |
63
63
  | Quality control | Manifest-native grid recovery, closed palettes, named approval, regression baselines, and fail-closed packaging. |
64
64
  | Sprite packaging | Deterministic RGBA packing, stable-cell mounting, explicit external input lists, structural output roles. |
package/dist/cli.d.ts CHANGED
@@ -4,8 +4,23 @@ import { z } from 'zod';
4
4
  declare const GridConfidenceSchema: z.ZodEnum<["low", "medium", "high"]>;
5
5
  type GridConfidence = z.infer<typeof GridConfidenceSchema>;
6
6
 
7
+ interface ReviewReadyInfo {
8
+ url: string;
9
+ keys: string[];
10
+ }
11
+
7
12
  type TilesetFormat = "generic" | "tiled" | "godot";
8
13
 
14
+ interface CliWritable {
15
+ isTTY?: boolean;
16
+ write(chunk: string): unknown;
17
+ }
18
+ /**
19
+ * Interactive readiness is progress, not command output. Keep it on stdout in
20
+ * a terminal, but use stderr when stdout is piped so consumers such as `tail`
21
+ * cannot hold the only copy of the live URL until the server exits.
22
+ */
23
+ declare function announceReviewReady(info: ReviewReadyInfo, stdout?: CliWritable, stderr?: CliWritable): void;
9
24
  interface Args {
10
25
  command: string;
11
26
  manifest: string;
@@ -76,4 +91,4 @@ declare const COMMANDS: readonly ["init", "plan", "doctor", "gen", "submit", "po
76
91
  */
77
92
  declare function parseArgs(argv: string[]): Args;
78
93
 
79
- export { COMMANDS, parseArgs };
94
+ export { COMMANDS, announceReviewReady, parseArgs };
package/dist/cli.js CHANGED
@@ -1550,7 +1550,9 @@ var QualityProfileSchema = z2.object({
1550
1550
  /** Optional alpha requirement for isolated assets. */
1551
1551
  minTransparency: z2.number().min(0).max(1).optional(),
1552
1552
  /** Pixel Art Fixer revision written into the quality record. */
1553
- fixerRevision: z2.string().min(1).optional()
1553
+ fixerRevision: z2.string().min(1).optional(),
1554
+ /** Python executable containing Pixel Art Fixer, relative to the manifest unless absolute. */
1555
+ fixerPython: z2.string().min(1).optional()
1554
1556
  }).strict().refine(
1555
1557
  (profile) => new Set(profile.palette.map((color) => color.replace(/^#/, "").toLowerCase())).size === profile.palette.length,
1556
1558
  { message: "quality palette colors must be unique", path: ["palette"] }
@@ -1804,6 +1806,18 @@ var LockEntrySchema = z2.object({
1804
1806
  maskSha256: z2.string().regex(/^[0-9a-f]{64}$/).optional(),
1805
1807
  strength: z2.number().min(0).max(1).optional()
1806
1808
  }).strict().nullable().default(null),
1809
+ /**
1810
+ * Output hashes owned by the previous generation while its replacement is
1811
+ * pending. They authorize replacing only unchanged PixelKiln-owned files.
1812
+ */
1813
+ supersededOutputs: z2.array(
1814
+ z2.object({
1815
+ path: z2.string(),
1816
+ sha256: z2.string(),
1817
+ role: z2.string().optional(),
1818
+ mediaType: MediaTypeSchema.optional()
1819
+ })
1820
+ ).optional(),
1807
1821
  /** Set at submit time, before the request is awaited, so a crash is recoverable. */
1808
1822
  jobId: z2.string().nullable().default(null),
1809
1823
  /** For `1dir`: the multi-candidate parent object awaiting selection. */
@@ -3273,7 +3287,8 @@ async function resolveSpecs(loaded, filter) {
3273
3287
  palette: style.quality.palette,
3274
3288
  minGridConfidence: style.quality.minGridConfidence,
3275
3289
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
3276
- ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {}
3290
+ ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
3291
+ ...style.quality.fixerPython ? { fixerPython: path4.resolve(root, style.quality.fixerPython) } : {}
3277
3292
  }
3278
3293
  } : {},
3279
3294
  tags,
@@ -5075,7 +5090,9 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5075
5090
  minGridConfidence: spec.quality.minGridConfidence,
5076
5091
  ...spec.quality.minTransparency == null ? {} : { minTransparency: spec.quality.minTransparency },
5077
5092
  fixerRevision: spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
5078
- fixerPython: options.fixerPython,
5093
+ // A one-off CLI/library override wins; otherwise each style can carry
5094
+ // the stable project-local interpreter that provides its pinned fixer.
5095
+ fixerPython: options.fixerPython ?? spec.quality.fixerPython,
5079
5096
  fixerCommand: options.fixerCommand,
5080
5097
  fixerArgsPrefix: options.fixerArgsPrefix,
5081
5098
  force: options.force
@@ -5199,6 +5216,29 @@ async function anyOutputModified(entry, spec) {
5199
5216
  }
5200
5217
  return false;
5201
5218
  }
5219
+ function resumeCommandForStatus(status) {
5220
+ if (status === "pending" || status === "processing") return "poll";
5221
+ if (status === "review") return "pick";
5222
+ if (status === "selected" || status === "download-failed") return "fetch";
5223
+ return null;
5224
+ }
5225
+ function resumeActions(specs, lock) {
5226
+ const grouped = /* @__PURE__ */ new Map();
5227
+ for (const spec of specs) {
5228
+ const key = lockKey(spec.styleId, spec.assetId);
5229
+ const entry = lock.entries[key];
5230
+ if (!entry || entry.specHash !== spec.specHash) continue;
5231
+ const command = resumeCommandForStatus(entry.status);
5232
+ if (!command) continue;
5233
+ if (command === "poll" && !entry.jobId) continue;
5234
+ if (command === "pick" && !entry.reviewObjectId) continue;
5235
+ grouped.set(command, [...grouped.get(command) ?? [], key]);
5236
+ }
5237
+ return ["poll", "pick", "fetch"].flatMap((command) => {
5238
+ const keys = grouped.get(command);
5239
+ return keys?.length ? [{ command, keys }] : [];
5240
+ });
5241
+ }
5202
5242
  async function buildPlan(specs, lock, opts = {}) {
5203
5243
  const items = [];
5204
5244
  for (const spec of specs) {
@@ -5234,10 +5274,20 @@ async function buildPlan(specs, lock, opts = {}) {
5234
5274
  reason = "prompt, size, or style changed";
5235
5275
  } else if (entry.status === "download-failed") {
5236
5276
  state = "recoverable";
5237
- reason = `${entry.error ?? "download failed"}; run fetch or restore (no generation cost)`;
5277
+ const force = entry.error?.includes("pass --force") ? " --force" : "";
5278
+ reason = `${entry.error ?? "download failed"}; run pixelkiln fetch${force} (no generation cost)`;
5238
5279
  } else if (entry.status === "failed") {
5239
5280
  state = "failed";
5240
5281
  reason = entry.error ?? "previous attempt failed";
5282
+ } else if (entry.status === "selected") {
5283
+ state = "recoverable";
5284
+ reason = "provider output is selected; run pixelkiln fetch (no generation cost)";
5285
+ } else if (entry.status === "pending" || entry.status === "processing") {
5286
+ state = "in-flight";
5287
+ reason = entry.jobId ? "awaiting processing; run pixelkiln poll" : "submission state has no job id; run pixelkiln doctor before retrying";
5288
+ } else if (entry.status === "review") {
5289
+ state = "in-flight";
5290
+ reason = entry.reviewObjectId ? "awaiting review; run pixelkiln pick" : "review state has no review object id; run pixelkiln doctor";
5241
5291
  } else if (entry.status !== "downloaded") {
5242
5292
  state = "in-flight";
5243
5293
  reason = `awaiting ${entry.status}`;
@@ -5374,6 +5424,8 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5374
5424
  const since = Date.now() - lastSubmitAt;
5375
5425
  if (since < spacing) await sleep2(spacing - since);
5376
5426
  await requireRevisionReady(spec, lock);
5427
+ const previousEntry = lock.entries[key];
5428
+ const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
5377
5429
  upsert(lock, key, {
5378
5430
  styleId: spec.styleId,
5379
5431
  assetId: spec.assetId,
@@ -5397,6 +5449,9 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5397
5449
  candidateIndex: null,
5398
5450
  error: null,
5399
5451
  outputs: [],
5452
+ // Keep the old ownership proof while new bytes are pending. Fetch may
5453
+ // replace that file only while its hash still matches this record.
5454
+ supersededOutputs,
5400
5455
  providerMetadata: {},
5401
5456
  sourceUrl: null,
5402
5457
  sourceUrls: [],
@@ -5607,22 +5662,37 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
5607
5662
  );
5608
5663
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
5609
5664
  if (existsSync9(target)) {
5610
- if (!recorded) {
5611
- throw new Error(`refusing to overwrite untracked output ${target}`);
5612
- }
5613
- if (await sha256File(target) !== recorded.sha256) {
5614
- throw new Error(`refusing to overwrite modified output ${target}`);
5665
+ const currentHash = await sha256File(target);
5666
+ if (recorded && currentHash === recorded.sha256) {
5667
+ if (cacheDir) {
5668
+ await cacheMedia(
5669
+ cacheDir,
5670
+ await readFile7(target),
5671
+ recorded.mediaType ?? MediaType.PNG,
5672
+ recorded.sha256
5673
+ );
5674
+ }
5675
+ outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
5676
+ continue;
5615
5677
  }
5616
- if (cacheDir) {
5617
- await cacheMedia(
5618
- cacheDir,
5619
- await readFile7(target),
5620
- recorded.mediaType ?? MediaType.PNG,
5621
- recorded.sha256
5622
- );
5678
+ const superseded = (entry.supersededOutputs ?? []).find(
5679
+ (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
5680
+ );
5681
+ if (!opts.force) {
5682
+ if (!recorded && superseded && currentHash === superseded.sha256) {
5683
+ } else if (recorded || superseded) {
5684
+ throw new Error(
5685
+ `refusing to overwrite modified output ${target}; pass --force to replace it`
5686
+ );
5687
+ } else {
5688
+ throw new Error(
5689
+ `refusing to overwrite untracked output ${target}; pass --force to replace it`
5690
+ );
5691
+ }
5623
5692
  }
5624
- outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
5625
- continue;
5693
+ log2(
5694
+ ` replace ${path14.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
5695
+ );
5626
5696
  }
5627
5697
  const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
5628
5698
  let buf = recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null;
@@ -5670,7 +5740,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
5670
5740
  sourceUrl: persistentSources[0]?.url ?? null,
5671
5741
  sourceUrls: persistentSources,
5672
5742
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
5673
- error: null
5743
+ error: null,
5744
+ supersededOutputs: []
5674
5745
  });
5675
5746
  result.downloaded++;
5676
5747
  } catch (err) {
@@ -5841,6 +5912,14 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
5841
5912
  unsettled ? "warning" : "ok",
5842
5913
  unsettled ? `${unsettled} spec(s) are not current: ` + Object.entries(counts).filter(([, n]) => n > 0).map(([state, n]) => `${n} ${state}`).join(", ") : "every resolved spec is current"
5843
5914
  );
5915
+ const resumable = resumeActions(specs, lock);
5916
+ add(
5917
+ "next-actions",
5918
+ resumable.length ? "warning" : "ok",
5919
+ resumable.length ? "resume paid work without regenerating: " + resumable.map(
5920
+ (action) => `pixelkiln ${action.command} (${action.keys.length} asset${action.keys.length === 1 ? "" : "s"})`
5921
+ ).join(", ") : "no generation stage is waiting for poll, pick, or fetch"
5922
+ );
5844
5923
  const quality = plan.items.flatMap((item) => item.quality ? [item.quality] : []);
5845
5924
  const qualityPending = quality.filter((item) => item.state !== "approved");
5846
5925
  if (quality.length) {
@@ -6578,10 +6657,15 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6578
6657
  onProgress: log2,
6579
6658
  assets: reviewAssets,
6580
6659
  onReady: (url) => {
6581
- log2(`
6660
+ const info = { url, keys: groups.map((group) => group.key) };
6661
+ if (opts.onReady) {
6662
+ opts.onReady(info);
6663
+ } else {
6664
+ log2(`
6582
6665
  ${groups.length} asset(s) awaiting selection: ${url}`);
6583
- log2(` (leave this running; it exits once you apply)
6666
+ log2(` (leave this running; it exits once you apply)
6584
6667
  `);
6668
+ }
6585
6669
  },
6586
6670
  handleApply: async (body) => {
6587
6671
  const { selections } = body;
@@ -8543,6 +8627,17 @@ async function checkQualityBaseline(baselinePath) {
8543
8627
 
8544
8628
  // src/cli.ts
8545
8629
  var log = (msg = "") => console.log(msg);
8630
+ function announceReviewReady(info, stdout = process.stdout, stderr = process.stderr) {
8631
+ const stream = stdout.isTTY ? stdout : stderr;
8632
+ const count = info.keys.length;
8633
+ stream.write(
8634
+ `
8635
+ ${count} asset${count === 1 ? "" : "s"} awaiting selection: ${info.url}
8636
+ (leave this running; it exits once you apply)
8637
+
8638
+ `
8639
+ );
8640
+ }
8546
8641
  async function provenanceFile(id, file) {
8547
8642
  const absolute = path24.resolve(file);
8548
8643
  return {
@@ -8900,7 +8995,7 @@ Options
8900
8995
  --max-colors <n> audit: maximum distinct opaque colors
8901
8996
  --sigma <n> audit: relative outlier cutoff (default: 1.5)
8902
8997
  --palette <hexes> refine: final comma-separated #rrggbb colors (repeatable)
8903
- --fixer-python <path> refine: Python with Pixel Art Fixer installed
8998
+ --fixer-python <path> refine: override Python with Pixel Art Fixer installed
8904
8999
  --fixer-revision <sha> refine: Pixel Art Fixer revision to record
8905
9000
  --min-grid-confidence <level> refine: high (default), medium, or low
8906
9001
  --reviewer <name> refine approve: human reviewer recorded in provenance
@@ -8914,7 +9009,7 @@ Options
8914
9009
  --budget <n|provider=n> Refuse to exceed one provider ceiling; repeat keyed budgets
8915
9010
  for a mixed-provider run
8916
9011
  --provider <id> Choose the account for balance/adopt/salvage/purge in a mixed manifest
8917
- --force Regenerate; also rerun refinement or replace changed derived state
9012
+ --force Regenerate, replace a fetch destination, or rebuild managed output
8918
9013
  --dry-run Never spend; doctor also skips provider connectivity
8919
9014
  --all salvage --dry-run: list every unclaimed object, not just the first 30
8920
9015
  --json Machine-readable output where supported, including quality checks
@@ -8997,6 +9092,17 @@ function printPlan(plan) {
8997
9092
  }
8998
9093
  }
8999
9094
  }
9095
+ function printResumeActions(specs, lock) {
9096
+ const actions = resumeActions(specs, lock);
9097
+ for (const action of actions) {
9098
+ const shown = action.keys.slice(0, 3).join(", ");
9099
+ const more = action.keys.length > 3 ? `, +${action.keys.length - 3} more` : "";
9100
+ log(
9101
+ ` next: pixelkiln ${action.command} \u2014 ${action.keys.length} asset${action.keys.length === 1 ? "" : "s"} (${shown}${more})`
9102
+ );
9103
+ }
9104
+ return actions.length;
9105
+ }
9000
9106
  function manifestProviderIds(manifest) {
9001
9107
  return [...new Set(
9002
9108
  Object.values(manifest.styles).map((style) => style.provider ?? manifest.provider)
@@ -10395,7 +10501,10 @@ async function main() {
10395
10501
  ${total.completed} ready \xB7 ${total.review} awaiting selection \xB7 ${total.failed} failed`
10396
10502
  );
10397
10503
  if (total.failed || total.stillRunning) process.exitCode = 1;
10398
- if (args.command === "poll") return;
10504
+ if (args.command === "poll") {
10505
+ printResumeActions(specs, lock);
10506
+ return;
10507
+ }
10399
10508
  }
10400
10509
  if (args.command === "pick" || args.command === "gen") {
10401
10510
  const total = { selected: 0, skipped: 0 };
@@ -10404,6 +10513,7 @@ async function main() {
10404
10513
  port: args.port,
10405
10514
  open: !args.noOpen,
10406
10515
  onProgress: log,
10516
+ onReady: announceReviewReady,
10407
10517
  keys: providerSpecsForRun.map((spec) => lockKey(spec.styleId, spec.assetId)),
10408
10518
  specs: providerSpecsForRun
10409
10519
  });
@@ -10417,7 +10527,10 @@ async function main() {
10417
10527
  selected ${total.selected}, left in review ${total.skipped}`);
10418
10528
  }
10419
10529
  if (args.command === "gen" && total.skipped) process.exitCode = 1;
10420
- if (args.command === "pick") return;
10530
+ if (args.command === "pick") {
10531
+ printResumeActions(specs, lock);
10532
+ return;
10533
+ }
10421
10534
  }
10422
10535
  if (args.command === "fetch" || args.command === "restore" || args.command === "gen") {
10423
10536
  log(`
@@ -10427,7 +10540,8 @@ async function main() {
10427
10540
  const groupProvider = providerFor(providerId);
10428
10541
  const res = await fetchAssets(groupProvider, providerSpecsForRun, lock, args.lock, {
10429
10542
  onProgress: log,
10430
- repair: args.command === "restore"
10543
+ repair: args.command === "restore",
10544
+ force: args.force
10431
10545
  });
10432
10546
  total.downloaded += res.downloaded;
10433
10547
  total.skipped += res.skipped;
@@ -10446,10 +10560,22 @@ async function main() {
10446
10560
  if (args.tag) log(` tagged ${total.tagged} object(s) upstream`);
10447
10561
  await saveLock(args.lock, lock);
10448
10562
  log(` lockfile written: ${args.lock}`);
10563
+ const resumeActionCount = printResumeActions(specs, lock);
10449
10564
  if (args.command === "gen" && specs.some((spec) => spec.quality) && !total.failed) {
10450
- log(`
10565
+ const quality = (await Promise.all(
10566
+ specs.filter((spec) => spec.quality).map((spec) => inspectQualityProfile(spec, lock))
10567
+ )).filter((item) => item !== null);
10568
+ const blocked = quality.filter((item) => item.state === "blocked");
10569
+ if (blocked.length) {
10570
+ log(
10571
+ `
10572
+ ${blocked.length} quality source${blocked.length === 1 ? " is" : "s are"} not ready; ` + (resumeActionCount ? "finish the resume steps above before refining." : "run `pixelkiln plan` for the blocking reason before refining.")
10573
+ );
10574
+ } else {
10575
+ log(`
10451
10576
  Raw provider output is ready. Run \`pixelkiln refine\` to build the configured quality output.`);
10452
- log(` Packaging stays blocked until each refined PNG has a current human approval.`);
10577
+ log(` Packaging stays blocked until each refined PNG has a current human approval.`);
10578
+ }
10453
10579
  }
10454
10580
  return;
10455
10581
  }
@@ -10463,6 +10589,7 @@ main().catch((err) => {
10463
10589
  });
10464
10590
  export {
10465
10591
  COMMANDS,
10592
+ announceReviewReady,
10466
10593
  parseArgs
10467
10594
  };
10468
10595
  //# sourceMappingURL=cli.js.map