pixelkiln 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -170,6 +170,18 @@ var StyleObjectSchema = z.object({
170
170
  * chocolate bar.
171
171
  */
172
172
  palette: z.array(HexColorSchema).default([]),
173
+ /**
174
+ * Snap every visible pixel of the downloaded art to the nearest `palette`
175
+ * colour, without dithering. The palette is sent to providers that take
176
+ * one, and most honour it most of the time; this makes it a guarantee
177
+ * for every generator, including the ones with no palette parameter.
178
+ * Applied by `fetch` as the file is written and recorded on the lock
179
+ * entry, so a restore reproduces the same bytes. Turning it on or off
180
+ * does not change the spec hash: `plan` reports the entry as recoverable
181
+ * and the next `fetch` or `restore` re-applies it from the art already
182
+ * on disk or in the cache, spending nothing.
183
+ */
184
+ enforcePalette: z.boolean().default(false),
173
185
  /** Fail-closed native-grid, palette, and human-approval policy for derived art. */
174
186
  quality: QualityProfileSchema.optional(),
175
187
  /**
@@ -211,6 +223,13 @@ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImag
211
223
  path: ["quality"]
212
224
  });
213
225
  }
226
+ if (style.enforcePalette && style.palette.length < 2) {
227
+ ctx.addIssue({
228
+ code: z.ZodIssueCode.custom,
229
+ message: "enforcePalette needs a palette of at least two colours to snap to",
230
+ path: ["enforcePalette"]
231
+ });
232
+ }
214
233
  });
215
234
  var InheritedStyleSchema = StyleObjectSchema.partial().extend({
216
235
  extends: z.string().min(1),
@@ -333,7 +352,21 @@ var LockOutputSchema = z.object({
333
352
  path: z.string(),
334
353
  sha256: z.string(),
335
354
  role: z.string().optional(),
336
- mediaType: MediaTypeSchema.optional()
355
+ mediaType: MediaTypeSchema.optional(),
356
+ /**
357
+ * Hash of the provider's bytes before post-processing changed them, when
358
+ * it did. The content cache keeps both, so the post-processing can be
359
+ * undone or redone without contacting the provider.
360
+ */
361
+ raw: z.string().optional()
362
+ });
363
+ var PostprocessSchema = z.object({
364
+ palette: z.object({
365
+ /** Canonical lowercase `#rrggbb` values, in manifest order. */
366
+ colors: z.array(z.string()).min(2).max(256),
367
+ dither: z.literal("none"),
368
+ distance: z.literal("redmean")
369
+ }).optional()
337
370
  });
338
371
  var LockSourceSchema = z.object({
339
372
  url: z.string(),
@@ -359,6 +392,7 @@ var LockHistoryEntrySchema = z.object({
359
392
  cost: z.number().finite().nonnegative().default(0),
360
393
  costUnit: z.string().min(1).default("generations"),
361
394
  provider: z.string().default("pixellab"),
395
+ postprocess: PostprocessSchema.optional(),
362
396
  /** When the replacement was submitted. */
363
397
  retiredAt: z.string()
364
398
  });
@@ -430,14 +464,7 @@ var LockEntrySchema = z.object({
430
464
  * `role` labels non-primary artifacts (e.g. "portrait", "spriteframes") so a
431
465
  * consumer can find the one it wants without pattern-matching on paths.
432
466
  */
433
- outputs: z.array(
434
- z.object({
435
- path: z.string(),
436
- sha256: z.string(),
437
- role: z.string().optional(),
438
- mediaType: MediaTypeSchema.optional()
439
- })
440
- ).default([]),
467
+ outputs: z.array(LockOutputSchema).default([]),
441
468
  /**
442
469
  * Provider-owned data retained for downstream consumers, namespaced by
443
470
  * provider id. PixelLab connectable sets keep their exact `tileRules` here
@@ -453,7 +480,9 @@ var LockEntrySchema = z.object({
453
480
  /** Which provider produced this. Absent on entries written before providers. */
454
481
  provider: z.string().default("pixellab"),
455
482
  /** Generations this entry replaced, newest first; see LockHistoryEntrySchema. */
456
- history: z.array(LockHistoryEntrySchema).optional()
483
+ history: z.array(LockHistoryEntrySchema).optional(),
484
+ /** Post-processing applied to the recorded outputs; absent when the bytes are the provider's. */
485
+ postprocess: PostprocessSchema.optional()
457
486
  });
458
487
  var LockSchema = z.object({
459
488
  version: z.literal(2),
@@ -474,6 +503,72 @@ function lockKey(styleId, assetId) {
474
503
  return `${styleId}/${assetId}`;
475
504
  }
476
505
 
506
+ // src/errors.ts
507
+ var EXIT_CODES = {
508
+ usage: 2,
509
+ project: 3,
510
+ provider: 4,
511
+ "refused-overwrite": 5,
512
+ budget: 6,
513
+ capability: 7
514
+ };
515
+ var PixelKilnError = class extends Error {
516
+ code;
517
+ hint;
518
+ constructor(message6, code, opts = {}) {
519
+ super(message6, opts.cause === void 0 ? void 0 : { cause: opts.cause });
520
+ this.name = "PixelKilnError";
521
+ this.code = code;
522
+ if (opts.hint) this.hint = opts.hint;
523
+ }
524
+ };
525
+ var UsageError = class extends PixelKilnError {
526
+ constructor(message6, opts) {
527
+ super(message6, "usage", opts);
528
+ this.name = "UsageError";
529
+ }
530
+ };
531
+ var ProjectError = class extends PixelKilnError {
532
+ constructor(message6, opts) {
533
+ super(message6, "project", opts);
534
+ this.name = "ProjectError";
535
+ }
536
+ };
537
+ var ProviderError = class extends PixelKilnError {
538
+ /** Registry id of the provider that failed. */
539
+ provider;
540
+ /** HTTP status when the failure was a response, otherwise undefined. */
541
+ status;
542
+ constructor(provider, message6, opts = {}) {
543
+ super(message6, "provider", opts);
544
+ this.name = "ProviderError";
545
+ this.provider = provider;
546
+ if (opts.status !== void 0) this.status = opts.status;
547
+ }
548
+ };
549
+ var OverwriteRefusedError = class extends PixelKilnError {
550
+ /** The file(s) left untouched. */
551
+ files;
552
+ constructor(message6, files, opts) {
553
+ super(message6, "refused-overwrite", opts);
554
+ this.name = "OverwriteRefusedError";
555
+ this.files = files;
556
+ }
557
+ };
558
+ var BudgetError = class extends PixelKilnError {
559
+ constructor(message6, opts) {
560
+ super(message6, "budget", opts);
561
+ this.name = "BudgetError";
562
+ }
563
+ };
564
+ function errorCode(err) {
565
+ return err instanceof PixelKilnError ? err.code : void 0;
566
+ }
567
+ function exitCodeFor(err) {
568
+ const code = errorCode(err);
569
+ return code ? EXIT_CODES[code] : 1;
570
+ }
571
+
477
572
  // src/client.ts
478
573
  import { z as z2 } from "zod";
479
574
 
@@ -590,14 +685,12 @@ function validateResponse(schema, raw, operation) {
590
685
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
591
686
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
592
687
  }
593
- var PixelLabError = class extends Error {
688
+ var PixelLabError = class extends ProviderError {
594
689
  constructor(message6, status, body) {
595
- super(message6);
596
- this.status = status;
690
+ super("pixellab", message6, { status });
597
691
  this.body = body;
598
692
  this.name = "PixelLabError";
599
693
  }
600
- status;
601
694
  body;
602
695
  };
603
696
  var PixelLabClient = class {
@@ -877,10 +970,11 @@ function measureBalanceChange(before, after) {
877
970
  };
878
971
  }
879
972
  var DEFAULT_RATE_LIMIT = { spacingMs: 2500, maxInFlight: 8 };
880
- var UnsupportedCapabilityError = class extends Error {
973
+ var UnsupportedCapabilityError = class extends PixelKilnError {
881
974
  constructor(providerId, capability) {
882
975
  super(
883
- `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`
976
+ `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`,
977
+ "capability"
884
978
  );
885
979
  this.name = "UnsupportedCapabilityError";
886
980
  }
@@ -1705,7 +1799,7 @@ var RetroDiffusionClient = class {
1705
1799
  /** Result URLs are signed; no token header, and the same retry policy as the API. */
1706
1800
  async download(url) {
1707
1801
  const response = await this.request(url);
1708
- if (!response.ok) throw new Error(`Retro Diffusion download failed (${response.status})`);
1802
+ if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
1709
1803
  return Buffer.from(await response.arrayBuffer());
1710
1804
  }
1711
1805
  async call(path32, init = {}) {
@@ -1728,8 +1822,10 @@ var RetroDiffusionClient = class {
1728
1822
  if (!response.ok) {
1729
1823
  const retry = response.headers.get("retry-after");
1730
1824
  const detail = retroError(value);
1731
- throw new Error(
1732
- `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
1825
+ throw new ProviderError(
1826
+ "retrodiffusion",
1827
+ `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : ""),
1828
+ { status: response.status }
1733
1829
  );
1734
1830
  }
1735
1831
  return value;
@@ -2121,7 +2217,7 @@ var ScenarioClient = class {
2121
2217
  throw new Error("Scenario downloads must use HTTP or HTTPS");
2122
2218
  }
2123
2219
  const response = await this.request(parsed);
2124
- if (!response.ok) throw new Error(`Scenario download failed (${response.status})`);
2220
+ if (!response.ok) throw new ProviderError("scenario", `Scenario download failed (${response.status})`, { status: response.status });
2125
2221
  const declared = Number(response.headers.get("content-length"));
2126
2222
  if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) {
2127
2223
  throw new Error(`Scenario download exceeds the ${MAX_DOWNLOAD_BYTES}-byte safety limit`);
@@ -2168,8 +2264,10 @@ var ScenarioClient = class {
2168
2264
  if (!response.ok) {
2169
2265
  const retry = response.headers.get("retry-after");
2170
2266
  const detail = redactScenarioError(value, [this.apiKey, this.apiSecret, auth]);
2171
- throw new Error(
2172
- `Scenario request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
2267
+ throw new ProviderError(
2268
+ "scenario",
2269
+ `Scenario request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : ""),
2270
+ { status: response.status }
2173
2271
  );
2174
2272
  }
2175
2273
  return value;
@@ -2619,8 +2717,10 @@ var ComfyUIClient = class {
2619
2717
  value = null;
2620
2718
  }
2621
2719
  if (!response.ok) {
2622
- throw new Error(
2623
- `ComfyUI image upload failed (${response.status})` + (apiError(value) ? `: ${apiError(value)}` : "")
2720
+ throw new ProviderError(
2721
+ "comfyui",
2722
+ `ComfyUI image upload failed (${response.status})` + (apiError(value) ? `: ${apiError(value)}` : ""),
2723
+ { status: response.status }
2624
2724
  );
2625
2725
  }
2626
2726
  if (!isObject(value) || typeof value.name !== "string" || !value.name) {
@@ -2643,7 +2743,7 @@ var ComfyUIClient = class {
2643
2743
  let target = source;
2644
2744
  if (source.startsWith("comfyui://")) target = this.viewUrl(parseSource(source));
2645
2745
  const response = await this.request(target);
2646
- if (!response.ok) throw new Error(`ComfyUI download failed (${response.status})`);
2746
+ if (!response.ok) throw new ProviderError("comfyui", `ComfyUI download failed (${response.status})`, { status: response.status });
2647
2747
  return Buffer.from(await response.arrayBuffer());
2648
2748
  }
2649
2749
  endpoint(route) {
@@ -2668,8 +2768,10 @@ var ComfyUIClient = class {
2668
2768
  }
2669
2769
  if (!response.ok) {
2670
2770
  const detail = apiError(value);
2671
- throw new Error(
2672
- `ComfyUI request failed (${response.status}) at ${url.pathname}` + (detail ? `: ${detail}` : "")
2771
+ throw new ProviderError(
2772
+ "comfyui",
2773
+ `ComfyUI request failed (${response.status}) at ${url.pathname}` + (detail ? `: ${detail}` : ""),
2774
+ { status: response.status }
2673
2775
  );
2674
2776
  }
2675
2777
  return value;
@@ -3659,9 +3761,10 @@ async function loadLock(lockPath) {
3659
3761
  try {
3660
3762
  return parseLock(JSON.parse(await readFile2(lockPath, "utf8")));
3661
3763
  } catch (err) {
3662
- throw new Error(
3764
+ throw new ProjectError(
3663
3765
  `Lockfile at ${lockPath} is malformed:
3664
- ${err instanceof Error ? err.message : String(err)}`
3766
+ ${err instanceof Error ? err.message : String(err)}`,
3767
+ { cause: err }
3665
3768
  );
3666
3769
  }
3667
3770
  }
@@ -3695,8 +3798,9 @@ async function writeLockWhileHeld(file, lock) {
3695
3798
  try {
3696
3799
  disk = parseLock(JSON.parse(await readFile2(file, "utf8")));
3697
3800
  } catch (err) {
3698
- throw new Error(
3699
- `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
3801
+ throw new ProjectError(
3802
+ `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`,
3803
+ { cause: err }
3700
3804
  );
3701
3805
  }
3702
3806
  }
@@ -3952,18 +4056,18 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
3952
4056
  // src/manifest.ts
3953
4057
  async function loadManifest(manifestPath) {
3954
4058
  const abs = path5.resolve(manifestPath);
3955
- if (!existsSync4(abs)) throw new Error(`No manifest at ${abs}`);
4059
+ if (!existsSync4(abs)) throw new ProjectError(`No manifest at ${abs}`);
3956
4060
  const input = ManifestInputSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
3957
4061
  if (!input.success) {
3958
4062
  const issues = formatManifestIssues(input.error.issues);
3959
- throw new Error(`Manifest at ${abs} is invalid:
4063
+ throw new ProjectError(`Manifest at ${abs} is invalid:
3960
4064
  ${issues}`);
3961
4065
  }
3962
4066
  let styles;
3963
4067
  try {
3964
4068
  styles = resolveStyleInheritance(input.data.styles);
3965
4069
  } catch (error) {
3966
- throw new Error(
4070
+ throw new ProjectError(
3967
4071
  `Manifest at ${abs} is invalid:
3968
4072
  ${error instanceof Error ? error.message : String(error)}`
3969
4073
  );
@@ -3971,7 +4075,7 @@ ${issues}`);
3971
4075
  const parsed = ManifestSchema.safeParse({ ...input.data, styles });
3972
4076
  if (!parsed.success) {
3973
4077
  const issues = formatManifestIssues(parsed.error.issues);
3974
- throw new Error(`Manifest at ${abs} is invalid:
4078
+ throw new ProjectError(`Manifest at ${abs} is invalid:
3975
4079
  ${issues}`);
3976
4080
  }
3977
4081
  const styleIds = new Set(Object.keys(parsed.data.styles));
@@ -4021,7 +4125,7 @@ ${issues}`);
4021
4125
  };
4022
4126
  for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
4023
4127
  if (unknownReferences.length) {
4024
- throw new Error(`Manifest at ${abs} is invalid:
4128
+ throw new ProjectError(`Manifest at ${abs} is invalid:
4025
4129
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
4026
4130
  }
4027
4131
  return { manifest: parsed.data, root: path5.dirname(abs), path: abs };
@@ -4100,14 +4204,14 @@ async function resolveSpecs(loaded, filter) {
4100
4204
  );
4101
4205
  for (const unknownStyle of filter?.styles ?? []) {
4102
4206
  if (!manifest.styles[unknownStyle]) {
4103
- throw new Error(
4207
+ throw new UsageError(
4104
4208
  `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
4105
4209
  );
4106
4210
  }
4107
4211
  }
4108
4212
  for (const unknownAsset of filter?.assets ?? []) {
4109
4213
  if (!manifest.assets[unknownAsset]) {
4110
- throw new Error(`Unknown asset "${unknownAsset}".`);
4214
+ throw new UsageError(`Unknown asset "${unknownAsset}".`);
4111
4215
  }
4112
4216
  }
4113
4217
  const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
@@ -4219,6 +4323,7 @@ async function resolveSpecs(loaded, filter) {
4219
4323
  detail: style.detail,
4220
4324
  seed: style.seed,
4221
4325
  palette: style.palette,
4326
+ enforcePalette: style.enforcePalette,
4222
4327
  noBackground: style.noBackground,
4223
4328
  tileSize: generator === "tiles" ? tileSize : void 0,
4224
4329
  tileType: generator === "tiles" ? style.tileType : void 0,
@@ -4423,7 +4528,7 @@ function imageMetadata(buf) {
4423
4528
  }
4424
4529
 
4425
4530
  // src/project.ts
4426
- import path13 from "path";
4531
+ import path12 from "path";
4427
4532
 
4428
4533
  // src/env.ts
4429
4534
  import { readFileSync as readFileSync2, existsSync as existsSync5 } from "fs";
@@ -4470,17 +4575,17 @@ function parseEnv(contents) {
4470
4575
  }
4471
4576
 
4472
4577
  // src/pipeline/plan.ts
4473
- import { existsSync as existsSync9 } from "fs";
4474
- import path12 from "path";
4578
+ import { existsSync as existsSync8 } from "fs";
4579
+ import path11 from "path";
4475
4580
 
4476
4581
  // src/pipeline/quality-profile.ts
4477
- import { existsSync as existsSync7 } from "fs";
4478
- import path10 from "path";
4582
+ import { existsSync as existsSync6 } from "fs";
4583
+ import path9 from "path";
4479
4584
 
4480
4585
  // src/pipeline/refine.ts
4481
- import path9 from "path";
4586
+ import path8 from "path";
4482
4587
  import { execFile } from "child_process";
4483
- import { mkdtemp, readFile as readFile6, rm as rm3 } from "fs/promises";
4588
+ import { mkdtemp, readFile as readFile5, rm as rm3 } from "fs/promises";
4484
4589
  import { tmpdir } from "os";
4485
4590
  import { promisify } from "util";
4486
4591
 
@@ -4718,10 +4823,11 @@ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, opt
4718
4823
  if (!expected || digest(current) !== expected) conflicts.push(destination);
4719
4824
  }
4720
4825
  if (conflicts.length) {
4721
- throw new Error(
4826
+ throw new OverwriteRefusedError(
4722
4827
  `Refusing to overwrite modified or unowned artifact file(s):
4723
4828
  ` + conflicts.map((file) => ` ${file}`).join("\n") + `
4724
- Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
4829
+ Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`,
4830
+ conflicts
4725
4831
  );
4726
4832
  }
4727
4833
  }
@@ -4940,10 +5046,19 @@ async function writeArtifactBundle(files, options = {}) {
4940
5046
  };
4941
5047
  }
4942
5048
 
4943
- // src/pipeline/audit.ts
4944
- import { readFile as readFile5 } from "fs/promises";
4945
- import { existsSync as existsSync6 } from "fs";
4946
- import path8 from "path";
5049
+ // src/palette.ts
5050
+ function normalizePalette(values) {
5051
+ const colors = /* @__PURE__ */ new Map();
5052
+ for (const value of values) {
5053
+ const rgb = parseHex(value);
5054
+ const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5055
+ if (!colors.has(hex2)) colors.set(hex2, { hex: hex2, ...rgb });
5056
+ }
5057
+ if (colors.size < 2 || colors.size > 256) {
5058
+ throw new Error(`A palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5059
+ }
5060
+ return [...colors.values()];
5061
+ }
4947
5062
  function colorDistance(a, b) {
4948
5063
  const rmean = (a.r + b.r) / 2;
4949
5064
  const dr = a.r - b.r;
@@ -4953,146 +5068,45 @@ function colorDistance(a, b) {
4953
5068
  (512 + rmean) * dr * dr / 256 + 4 * dg * dg + (767 - rmean) * db * db / 256
4954
5069
  );
4955
5070
  }
4956
- function paletteDistance(asset, reference) {
4957
- if (!asset.length || !reference.length) return 0;
4958
- let total = 0;
4959
- let weight = 0;
4960
- for (const color of asset) {
4961
- let nearest = Infinity;
4962
- for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
4963
- total += nearest * color.weight;
4964
- weight += color.weight;
4965
- }
4966
- return weight === 0 ? 0 : total / weight;
4967
- }
4968
- function mergePalettes(palettes, topN = 24) {
4969
- const totals = /* @__PURE__ */ new Map();
4970
- for (const palette of palettes) {
4971
- for (const color of palette) {
4972
- const key = `${color.r},${color.g},${color.b}`;
4973
- const existing = totals.get(key);
4974
- if (existing) existing.weight += color.weight;
4975
- else totals.set(key, { ...color });
5071
+ function quantize(png, palette) {
5072
+ const colors = normalizePalette(palette);
5073
+ const pixels = Buffer.from(png.pixels);
5074
+ const nearestOf = /* @__PURE__ */ new Map();
5075
+ let changed = 0;
5076
+ for (let i = 0; i < pixels.length; i += 4) {
5077
+ const alpha = pixels[i + 3];
5078
+ if (alpha === 0) {
5079
+ if (pixels[i] || pixels[i + 1] || pixels[i + 2]) changed++;
5080
+ pixels[i] = 0;
5081
+ pixels[i + 1] = 0;
5082
+ pixels[i + 2] = 0;
5083
+ continue;
4976
5084
  }
4977
- }
4978
- const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
4979
- const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
4980
- return merged.map((c) => ({ ...c, weight: c.weight / sum }));
4981
- }
4982
- async function auditStyle(loaded, specs, styleId, lock) {
4983
- const style = loaded.manifest.styles[styleId];
4984
- if (!style) throw new Error(`Unknown style "${styleId}"`);
4985
- const mine = specs.filter((s) => s.styleId === styleId);
4986
- const assets = [];
4987
- const missing = [];
4988
- const unreadable = [];
4989
- for (const spec of mine) {
4990
- for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
4991
- if (!existsSync6(output.absolutePath)) {
4992
- missing.push(output.id);
4993
- continue;
4994
- }
4995
- try {
4996
- const png = decodePng(await readFile5(output.absolutePath));
4997
- const palette = extractPalette(png, 12);
4998
- assets.push({
4999
- assetId: spec.assetId,
5000
- ...output.role ? { outputRole: output.role } : {},
5001
- id: output.id,
5002
- file: output.absolutePath,
5003
- width: png.width,
5004
- height: png.height,
5005
- paletteDistance: 0,
5006
- // filled once the reference is known
5007
- transparency: transparencyRatio(png),
5008
- colorCount: countColors(png),
5009
- palette
5010
- });
5011
- } catch (err) {
5012
- unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
5085
+ const key = pixels[i] << 16 | pixels[i + 1] << 8 | pixels[i + 2];
5086
+ let nearest = nearestOf.get(key);
5087
+ if (!nearest) {
5088
+ const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5089
+ nearest = colors[0];
5090
+ let distance = colorDistance(pixel, nearest);
5091
+ for (let j = 1; j < colors.length; j++) {
5092
+ const candidate = colors[j];
5093
+ const candidateDistance = colorDistance(pixel, candidate);
5094
+ if (candidateDistance < distance) {
5095
+ nearest = candidate;
5096
+ distance = candidateDistance;
5097
+ }
5013
5098
  }
5099
+ nearestOf.set(key, nearest);
5014
5100
  }
5101
+ if (pixels[i] !== nearest.r || pixels[i + 1] !== nearest.g || pixels[i + 2] !== nearest.b) changed++;
5102
+ pixels[i] = nearest.r;
5103
+ pixels[i + 1] = nearest.g;
5104
+ pixels[i + 2] = nearest.b;
5015
5105
  }
5016
- let reference = [];
5017
- let referenceFromStyleImages = false;
5018
- const refPalettes = [];
5019
- for (const img of style.styleImages) {
5020
- const abs = path8.resolve(loaded.root, img.path);
5021
- if (!existsSync6(abs)) continue;
5022
- try {
5023
- refPalettes.push(extractPalette(decodePng(await readFile5(abs)), 12));
5024
- } catch {
5025
- }
5026
- }
5027
- if (refPalettes.length) {
5028
- reference = mergePalettes(refPalettes);
5029
- referenceFromStyleImages = true;
5030
- for (const asset of assets) {
5031
- asset.paletteDistance = paletteDistance(asset.palette, reference);
5032
- }
5033
- } else {
5034
- reference = mergePalettes(assets.map((a) => a.palette));
5035
- for (const asset of assets) {
5036
- const others = assets.filter((a) => a !== asset).map((a) => a.palette);
5037
- asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
5038
- }
5039
- }
5040
- assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
5041
- return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
5042
- }
5043
- function countColors(png) {
5044
- const seen = /* @__PURE__ */ new Set();
5045
- for (let i = 0; i < png.pixels.length; i += 4) {
5046
- if (png.pixels[i + 3] < 128) continue;
5047
- seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
5048
- }
5049
- return seen.size;
5106
+ return { png: { width: png.width, height: png.height, pixels }, changed };
5050
5107
  }
5051
- function outliers(audit, sigma = 1.5) {
5052
- const n = audit.assets.length;
5053
- if (n < 3) return [];
5054
- const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
5055
- const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
5056
- const sd = Math.sqrt(variance);
5057
- if (sd === 0) return [];
5058
- return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
5059
- }
5060
- function evaluateAudit(audit, thresholds = {}) {
5061
- const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
5062
- const relative = outliers(audit, resolved.sigma);
5063
- const relativeIds = new Set(relative.map((asset) => asset.id));
5064
- const violations = [];
5065
- for (const asset of audit.assets) {
5066
- const reasons = [];
5067
- if (relativeIds.has(asset.id)) {
5068
- reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
5069
- }
5070
- if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
5071
- reasons.push(
5072
- `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
5073
- );
5074
- }
5075
- if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
5076
- reasons.push(
5077
- `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
5078
- );
5079
- }
5080
- if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
5081
- reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
5082
- }
5083
- if (reasons.length) violations.push({ id: asset.id, reasons });
5084
- }
5085
- return {
5086
- safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
5087
- thresholds: resolved,
5088
- outliers: [...relativeIds],
5089
- violations,
5090
- missing: [...audit.missing],
5091
- unreadable: [...audit.unreadable]
5092
- };
5093
- }
5094
- function hex(c) {
5095
- return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
5108
+ function quantizeToPalette(png, palette) {
5109
+ return quantize(png, palette).png;
5096
5110
  }
5097
5111
 
5098
5112
  // src/pipeline/refine.ts
@@ -5179,48 +5193,12 @@ function parseDetection(raw) {
5179
5193
  confidence: confidenceFor(consensus)
5180
5194
  };
5181
5195
  }
5182
- function normalizePalette(values) {
5183
- const colors = /* @__PURE__ */ new Map();
5184
- for (const value of values) {
5185
- const rgb = parseHex(value);
5186
- const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5187
- colors.set(hex2, { hex: hex2, ...rgb });
5188
- }
5189
- if (colors.size < 2 || colors.size > 256) {
5190
- throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5191
- }
5192
- return [...colors.values()];
5193
- }
5194
5196
  function normalizeRefinementPalette(values) {
5195
- return normalizePalette(values).map((color) => color.hex);
5196
- }
5197
- function quantizeToPalette(png, palette) {
5198
- const colors = normalizePalette(palette);
5199
- const pixels = Buffer.from(png.pixels);
5200
- for (let i = 0; i < pixels.length; i += 4) {
5201
- const alpha = pixels[i + 3];
5202
- if (alpha === 0) {
5203
- pixels[i] = 0;
5204
- pixels[i + 1] = 0;
5205
- pixels[i + 2] = 0;
5206
- continue;
5207
- }
5208
- const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5209
- let nearest = colors[0];
5210
- let distance = colorDistance(pixel, nearest);
5211
- for (let j = 1; j < colors.length; j++) {
5212
- const candidate = colors[j];
5213
- const candidateDistance = colorDistance(pixel, candidate);
5214
- if (candidateDistance < distance) {
5215
- nearest = candidate;
5216
- distance = candidateDistance;
5217
- }
5218
- }
5219
- pixels[i] = nearest.r;
5220
- pixels[i + 1] = nearest.g;
5221
- pixels[i + 2] = nearest.b;
5197
+ try {
5198
+ return normalizePalette(values).map((color) => color.hex);
5199
+ } catch (err) {
5200
+ throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${new Set(values.map((v) => v.toLowerCase())).size}.`, { cause: err });
5222
5201
  }
5223
- return { width: png.width, height: png.height, pixels };
5224
5202
  }
5225
5203
  function colorCount(png) {
5226
5204
  const colors = /* @__PURE__ */ new Set();
@@ -5330,16 +5308,16 @@ function sameFrameGrid(first, next) {
5330
5308
  return next.detection.stepX === first.detection.stepX && next.detection.stepY === first.detection.stepY && next.detection.columns === first.detection.columns && next.detection.rows === first.detection.rows && phaseMatches(next.detection.phaseX, first.detection.phaseX, first.detection.stepX) && phaseMatches(next.detection.phaseY, first.detection.phaseY, first.detection.stepY) && next.audit.width === first.audit.width && next.audit.height === first.audit.height && next.sourceWidth === first.sourceWidth && next.sourceHeight === first.sourceHeight;
5331
5309
  }
5332
5310
  async function refineAsset(options) {
5333
- const source = path9.resolve(options.source);
5334
- const output = path9.resolve(options.output);
5311
+ const source = path8.resolve(options.source);
5312
+ const output = path8.resolve(options.output);
5335
5313
  if (source === output) throw new Error("Refinement source and output must be different files.");
5336
- const record = path9.resolve(qualityRecordPath(output));
5314
+ const record = path8.resolve(qualityRecordPath(output));
5337
5315
  const palette = normalizePalette(options.palette).map((color) => color.hex);
5338
5316
  const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
5339
5317
  if (options.minTransparency !== void 0 && (!Number.isFinite(options.minTransparency) || options.minTransparency < 0 || options.minTransparency > 1)) throw new Error("minTransparency must be between 0 and 1.");
5340
5318
  let sourcePng;
5341
5319
  try {
5342
- sourcePng = decodePng(await readFile6(source));
5320
+ sourcePng = decodePng(await readFile5(source));
5343
5321
  } catch (error) {
5344
5322
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
5345
5323
  }
@@ -5356,8 +5334,8 @@ async function refineAsset(options) {
5356
5334
  );
5357
5335
  }
5358
5336
  }
5359
- const temp = await mkdtemp(path9.join(tmpdir(), "pixelkiln-refine-"));
5360
- const recovered = path9.join(temp, "native.png");
5337
+ const temp = await mkdtemp(path8.join(tmpdir(), "pixelkiln-refine-"));
5338
+ const recovered = path8.join(temp, "native.png");
5361
5339
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
5362
5340
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
5363
5341
  try {
@@ -5378,7 +5356,7 @@ async function refineAsset(options) {
5378
5356
  );
5379
5357
  }
5380
5358
  const detection = parseDetection(firstJsonObject(stdout));
5381
- const native = decodePng(await readFile6(recovered));
5359
+ const native = decodePng(await readFile5(recovered));
5382
5360
  const quantized = quantizeToPalette(native, palette);
5383
5361
  const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
5384
5362
  const audit = auditRefinement(
@@ -5442,18 +5420,18 @@ async function refineFrameSet(options) {
5442
5420
  if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
5443
5421
  throw new Error("Frame-set roles must be non-empty and unique.");
5444
5422
  }
5445
- const output = path9.resolve(options.output);
5446
- const record = path9.resolve(qualityRecordPath(output));
5423
+ const output = path8.resolve(options.output);
5424
+ const record = path8.resolve(qualityRecordPath(output));
5447
5425
  const palette = normalizeRefinementPalette(options.palette);
5448
5426
  const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
5449
5427
  if (new Set(finalOutputs).size !== finalOutputs.length) {
5450
5428
  throw new Error("Frame-set roles resolve to duplicate output filenames.");
5451
5429
  }
5452
- const sourceSet = new Set(options.sources.map((source) => path9.resolve(source.path)));
5430
+ const sourceSet = new Set(options.sources.map((source) => path8.resolve(source.path)));
5453
5431
  if (finalOutputs.some((file) => sourceSet.has(file))) {
5454
5432
  throw new Error("Frame-set refinement sources and outputs must be different files.");
5455
5433
  }
5456
- const temp = await mkdtemp(path9.join(tmpdir(), "pixelkiln-refine-frames-"));
5434
+ const temp = await mkdtemp(path8.join(tmpdir(), "pixelkiln-refine-frames-"));
5457
5435
  try {
5458
5436
  const frames = [];
5459
5437
  const outputs = [];
@@ -5461,8 +5439,8 @@ async function refineFrameSet(options) {
5461
5439
  const sourcePaths = [];
5462
5440
  let firstOptions = null;
5463
5441
  for (const [index, sourceEntry] of options.sources.entries()) {
5464
- const source = path9.resolve(sourceEntry.path);
5465
- const temporaryOutput = path9.join(temp, `${String(index).padStart(2, "0")}.png`);
5442
+ const source = path8.resolve(sourceEntry.path);
5443
+ const temporaryOutput = path8.join(temp, `${String(index).padStart(2, "0")}.png`);
5466
5444
  const refined = await refineAsset({
5467
5445
  source,
5468
5446
  output: temporaryOutput,
@@ -5494,7 +5472,7 @@ async function refineFrameSet(options) {
5494
5472
  });
5495
5473
  outputs.push({
5496
5474
  path: finalOutputs[index],
5497
- data: await readFile6(temporaryOutput)
5475
+ data: await readFile5(temporaryOutput)
5498
5476
  });
5499
5477
  }
5500
5478
  const first = frames[0];
@@ -5537,7 +5515,7 @@ async function refineFrameSet(options) {
5537
5515
  );
5538
5516
  return {
5539
5517
  sources: sourcePaths,
5540
- outputs: outputs.map((item) => path9.resolve(item.path)),
5518
+ outputs: outputs.map((item) => path8.resolve(item.path)),
5541
5519
  record,
5542
5520
  frames,
5543
5521
  palette,
@@ -5548,12 +5526,12 @@ async function refineFrameSet(options) {
5548
5526
  }
5549
5527
  }
5550
5528
  async function checkQualityRecord(recordPath) {
5551
- const record = path9.resolve(recordPath);
5529
+ const record = path8.resolve(recordPath);
5552
5530
  const manifest = await readArtifactBundleManifest(record);
5553
5531
  const options = parseRecordOptions(manifest, record);
5554
5532
  const verification = await verifyArtifactBundle(record);
5555
- const sources = manifest.sources.map((source2) => path9.resolve(path9.dirname(record), source2.path));
5556
- const outputs = manifest.outputs.map((output2) => path9.resolve(path9.dirname(record), output2.path));
5533
+ const sources = manifest.sources.map((source2) => path8.resolve(path8.dirname(record), source2.path));
5534
+ const outputs = manifest.outputs.map((output2) => path8.resolve(path8.dirname(record), output2.path));
5557
5535
  const source = sources[0] ?? "";
5558
5536
  const output = outputs[0] ?? "";
5559
5537
  const expectedCount = options.frameSet?.count ?? 1;
@@ -5586,7 +5564,7 @@ async function checkQualityRecord(recordPath) {
5586
5564
  async function approveQualityRecord(recordPath, approval) {
5587
5565
  const reviewer = approval.reviewer.trim();
5588
5566
  if (!reviewer) throw new Error("A human reviewer name is required.");
5589
- const record = path9.resolve(recordPath);
5567
+ const record = path8.resolve(recordPath);
5590
5568
  const manifest = await readArtifactBundleManifest(record);
5591
5569
  const options = parseRecordOptions(manifest, record);
5592
5570
  const verification = await verifyArtifactBundle(record);
@@ -5607,14 +5585,14 @@ async function approveQualityRecord(recordPath, approval) {
5607
5585
  `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
5608
5586
  );
5609
5587
  }
5610
- const root = path9.dirname(record);
5588
+ const root = path8.dirname(record);
5611
5589
  const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
5612
- const outputPath = path9.resolve(root, entry.path);
5613
- return { path: outputPath, data: await readFile6(outputPath) };
5590
+ const outputPath = path8.resolve(root, entry.path);
5591
+ return { path: outputPath, data: await readFile5(outputPath) };
5614
5592
  }));
5615
5593
  const sources = manifest.sources.map((source) => ({
5616
5594
  ...source,
5617
- path: path9.resolve(root, source.path)
5595
+ path: path8.resolve(root, source.path)
5618
5596
  }));
5619
5597
  const approved = {
5620
5598
  ...options,
@@ -5646,8 +5624,8 @@ async function qualitySource(spec, lock) {
5646
5624
  if (spec.generator === "frames") {
5647
5625
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
5648
5626
  }
5649
- const source = path10.resolve(spec.root, spec.source);
5650
- return existsSync7(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5627
+ const source = path9.resolve(spec.root, spec.source);
5628
+ return existsSync6(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5651
5629
  }
5652
5630
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5653
5631
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -5676,7 +5654,7 @@ async function qualitySource(spec, lock) {
5676
5654
  }
5677
5655
  if (role) roles.add(role);
5678
5656
  const source = currentEntryOutputPath(entry, spec, index);
5679
- if (!existsSync7(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5657
+ if (!existsSync6(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5680
5658
  if (await sha256File(source) !== output.sha256) {
5681
5659
  return { items: null, reason: "raw provider output was modified after download" };
5682
5660
  }
@@ -5695,7 +5673,7 @@ async function inspectQualityProfile(spec, lock) {
5695
5673
  }
5696
5674
  const sourcePaths = source.items.map((item) => item.path);
5697
5675
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
5698
- if (!existsSync7(record)) {
5676
+ if (!existsSync6(record)) {
5699
5677
  return {
5700
5678
  key,
5701
5679
  state: "needs-refinement",
@@ -5713,7 +5691,7 @@ async function inspectQualityProfile(spec, lock) {
5713
5691
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
5714
5692
  const expectedTransparency = spec.quality.minTransparency ?? null;
5715
5693
  const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
5716
- const contractChanged = JSON.stringify(check.sources.map((item) => path10.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path10.resolve(item))) || JSON.stringify(check.outputs.map((item) => path10.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path10.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
5694
+ const contractChanged = JSON.stringify(check.sources.map((item) => path9.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path9.resolve(item))) || JSON.stringify(check.outputs.map((item) => path9.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path9.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
5717
5695
  if (contractChanged) {
5718
5696
  return {
5719
5697
  key,
@@ -5875,9 +5853,38 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
5875
5853
  return sources;
5876
5854
  }
5877
5855
 
5856
+ // src/pipeline/postprocess.ts
5857
+ function postprocessFor(spec) {
5858
+ if (!spec.enforcePalette || spec.palette.length < 2) return void 0;
5859
+ return {
5860
+ palette: {
5861
+ colors: normalizePalette(spec.palette).map((color) => color.hex),
5862
+ dither: "none",
5863
+ distance: "redmean"
5864
+ }
5865
+ };
5866
+ }
5867
+ function samePostprocess(a, b) {
5868
+ const pa = a?.palette;
5869
+ const pb = b?.palette;
5870
+ if (!pa && !pb) return true;
5871
+ if (!pa || !pb) return false;
5872
+ return pa.dither === pb.dither && pa.distance === pb.distance && pa.colors.length === pb.colors.length && pa.colors.every((color, i) => color === pb.colors[i]);
5873
+ }
5874
+ function postprocessCurrent(entry, spec) {
5875
+ return samePostprocess(entry.postprocess, postprocessFor(spec));
5876
+ }
5877
+ function applyPostprocess(bytes, mediaType, record) {
5878
+ if (!record?.palette || mediaType !== MediaType.PNG) return { bytes, changed: false };
5879
+ const png = decodePng(bytes);
5880
+ const { png: snapped, changed } = quantize(png, record.palette.colors);
5881
+ if (!changed) return { bytes, changed: false };
5882
+ return { bytes: encodeRgbaPng(snapped.width, snapped.height, snapped.pixels), changed: true };
5883
+ }
5884
+
5878
5885
  // src/pipeline/revision.ts
5879
- import { existsSync as existsSync8 } from "fs";
5880
- import path11 from "path";
5886
+ import { existsSync as existsSync7 } from "fs";
5887
+ import path10 from "path";
5881
5888
  async function inspectRevisionReadiness(spec, lock) {
5882
5889
  if (!spec.revision) return null;
5883
5890
  return inspectRevision(spec, lock, /* @__PURE__ */ new Set());
@@ -5892,14 +5899,14 @@ async function inspectRevision(spec, lock, seen) {
5892
5899
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
5893
5900
  };
5894
5901
  }
5895
- if (!revision.sourceSha256 || !existsSync8(revision.sourceFile)) {
5902
+ if (!revision.sourceSha256 || !existsSync7(revision.sourceFile)) {
5896
5903
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
5897
5904
  }
5898
5905
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
5899
5906
  return { ready: false, reason: "revision source changed after the manifest was resolved" };
5900
5907
  }
5901
5908
  if (revision.maskFile) {
5902
- if (!revision.maskSha256 || !existsSync8(revision.maskFile)) {
5909
+ if (!revision.maskSha256 || !existsSync7(revision.maskFile)) {
5903
5910
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
5904
5911
  }
5905
5912
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -5929,8 +5936,8 @@ async function inspectParent(spec, lock, seen) {
5929
5936
  };
5930
5937
  }
5931
5938
  if (spec.source) {
5932
- const source = path11.resolve(spec.root, spec.source);
5933
- return existsSync8(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
5939
+ const source = path10.resolve(spec.root, spec.source);
5940
+ return existsSync7(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
5934
5941
  }
5935
5942
  const entry = lock.entries[key];
5936
5943
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -5944,7 +5951,7 @@ async function inspectParent(spec, lock, seen) {
5944
5951
  for (let index = 0; index < entry.outputs.length; index++) {
5945
5952
  const output = entry.outputs[index];
5946
5953
  const file = currentEntryOutputPath(entry, spec, index);
5947
- if (!existsSync8(file)) return { ready: false, reason: `parent output is missing: ${file}` };
5954
+ if (!existsSync7(file)) return { ready: false, reason: `parent output is missing: ${file}` };
5948
5955
  if (await sha256File(file) !== output.sha256) {
5949
5956
  return { ready: false, reason: `parent output was modified: ${file}` };
5950
5957
  }
@@ -5973,7 +5980,7 @@ function resumeActions(specs, lock) {
5973
5980
  const entry = lock.entries[key];
5974
5981
  if (!entry || entry.specHash !== spec.specHash) continue;
5975
5982
  if (entry.submissionComplete === false) continue;
5976
- const command = resumeCommandForStatus(entry.status);
5983
+ const command = resumeCommandForStatus(entry.status) ?? (entry.status === "downloaded" && entry.outputs.length && !postprocessCurrent(entry, spec) ? "fetch" : null);
5977
5984
  if (!command) continue;
5978
5985
  if (command === "poll" && !entry.jobId) continue;
5979
5986
  if (command === "pick" && !entry.reviewObjectId) continue;
@@ -5999,7 +6006,7 @@ async function buildPlan(specs, lock, opts = {}) {
5999
6006
  state = "missing";
6000
6007
  reason = "--force";
6001
6008
  } else if (!entry && spec.source && spec.generator !== "frames") {
6002
- if (existsSync9(path12.resolve(spec.root, spec.source))) {
6009
+ if (existsSync8(path11.resolve(spec.root, spec.source))) {
6003
6010
  state = "ok";
6004
6011
  reason = `placed from ${spec.source}; not generated`;
6005
6012
  } else {
@@ -6007,7 +6014,7 @@ async function buildPlan(specs, lock, opts = {}) {
6007
6014
  reason = `declared source is missing: ${spec.source}`;
6008
6015
  }
6009
6016
  } else if (!entry) {
6010
- if (existsSync9(spec.outFile)) {
6017
+ if (existsSync8(spec.outFile)) {
6011
6018
  state = "untracked";
6012
6019
  reason = "file on disk, no provenance recorded";
6013
6020
  } else {
@@ -6039,12 +6046,15 @@ async function buildPlan(specs, lock, opts = {}) {
6039
6046
  } else if (entry.status !== "downloaded") {
6040
6047
  state = "in-flight";
6041
6048
  reason = `awaiting ${entry.status}`;
6042
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index)))) {
6049
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync8(currentEntryOutputPath(entry, spec, index)))) {
6043
6050
  state = "orphaned";
6044
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync9(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6051
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync8(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6045
6052
  } else if (await anyOutputModified(entry, spec)) {
6046
6053
  state = "orphaned";
6047
6054
  reason = "output modified since download";
6055
+ } else if (!postprocessCurrent(entry, spec)) {
6056
+ state = "recoverable";
6057
+ reason = spec.enforcePalette ? "palette enforcement turned on; run pixelkiln fetch to snap the file (no generation cost)" : "palette enforcement turned off; run pixelkiln fetch to restore the provider's bytes (no generation cost)";
6048
6058
  } else {
6049
6059
  state = "ok";
6050
6060
  reason = "up to date";
@@ -6107,13 +6117,13 @@ function summarize(plan) {
6107
6117
 
6108
6118
  // src/project.ts
6109
6119
  async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {}) {
6110
- const absolute = path13.resolve(manifestPath);
6111
- const root = path13.dirname(absolute);
6120
+ const absolute = path12.resolve(manifestPath);
6121
+ const root = path12.dirname(absolute);
6112
6122
  if (opts.env !== false) {
6113
6123
  loadEnvFiles(root);
6114
- if (path13.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
6124
+ if (path12.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
6115
6125
  }
6116
- const lockPath = path13.resolve(opts.lockPath ?? path13.join(root, "pixelkiln.lock.json"));
6126
+ const lockPath = path12.resolve(opts.lockPath ?? path12.join(root, "pixelkiln.lock.json"));
6117
6127
  const loaded = await loadManifest(absolute);
6118
6128
  const specs = await resolveSpecs(loaded, { styles: opts.styles ?? [], assets: opts.assets ?? [] });
6119
6129
  const lock = await loadLock(lockPath);
@@ -6180,9 +6190,9 @@ function shouldPersistSourceUrl(value) {
6180
6190
  }
6181
6191
 
6182
6192
  // src/recipes.ts
6183
- import path14 from "path";
6184
- import { existsSync as existsSync10 } from "fs";
6185
- import { readdir, readFile as readFile7 } from "fs/promises";
6193
+ import path13 from "path";
6194
+ import { existsSync as existsSync9 } from "fs";
6195
+ import { readdir, readFile as readFile6 } from "fs/promises";
6186
6196
  import { fileURLToPath } from "url";
6187
6197
  import { z as z3 } from "zod";
6188
6198
  var SHA256_RE = /^[0-9a-f]{64}$/;
@@ -6191,7 +6201,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
6191
6201
  var RECIPE_FILE = "pixelkiln.recipe.json";
6192
6202
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
6193
6203
  var PortablePathSchema = z3.string().min(1).refine((value) => {
6194
- if (value.includes("\\") || path14.posix.isAbsolute(value)) return false;
6204
+ if (value.includes("\\") || path13.posix.isAbsolute(value)) return false;
6195
6205
  const parts = value.split("/");
6196
6206
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
6197
6207
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -6367,10 +6377,10 @@ function message3(error) {
6367
6377
  return error instanceof Error ? error.message : String(error);
6368
6378
  }
6369
6379
  async function readRecipeFile(recipePath, bundled) {
6370
- const absolute = path14.resolve(recipePath);
6380
+ const absolute = path13.resolve(recipePath);
6371
6381
  let raw;
6372
6382
  try {
6373
- raw = JSON.parse(await readFile7(absolute, "utf8"));
6383
+ raw = JSON.parse(await readFile6(absolute, "utf8"));
6374
6384
  } catch (error) {
6375
6385
  throw new Error(`Could not read recipe ${absolute}: ${message3(error)}`, { cause: error });
6376
6386
  }
@@ -6381,13 +6391,13 @@ async function readRecipeFile(recipePath, bundled) {
6381
6391
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
6382
6392
  );
6383
6393
  }
6384
- return { recipe: parsed.data, path: absolute, dir: path14.dirname(absolute), bundled };
6394
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: path13.dirname(absolute), bundled };
6385
6395
  }
6386
6396
  async function walkRecipeFiles(root) {
6387
- if (!existsSync10(root)) return [];
6397
+ if (!existsSync9(root)) return [];
6388
6398
  const out = [];
6389
6399
  for (const entry of await readdir(root, { withFileTypes: true })) {
6390
- const resolved = path14.join(root, entry.name);
6400
+ const resolved = path13.join(root, entry.name);
6391
6401
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
6392
6402
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
6393
6403
  }
@@ -6398,7 +6408,7 @@ function bundledRecipeRoot() {
6398
6408
  }
6399
6409
  async function listBundledRecipes(root = bundledRecipeRoot()) {
6400
6410
  const recipes = await Promise.all(
6401
- (await walkRecipeFiles(path14.resolve(root))).map((file) => readRecipeFile(file, true))
6411
+ (await walkRecipeFiles(path13.resolve(root))).map((file) => readRecipeFile(file, true))
6402
6412
  );
6403
6413
  return recipes.sort(
6404
6414
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -6421,9 +6431,9 @@ function parseSelector(selector) {
6421
6431
  return { id: match[1], version: match[2] };
6422
6432
  }
6423
6433
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6424
- const local = path14.resolve(target);
6425
- if (existsSync10(local)) {
6426
- const recipePath = path14.basename(local) === RECIPE_FILE ? local : path14.join(local, RECIPE_FILE);
6434
+ const local = path13.resolve(target);
6435
+ if (existsSync9(local)) {
6436
+ const recipePath = path13.basename(local) === RECIPE_FILE ? local : path13.join(local, RECIPE_FILE);
6427
6437
  return readRecipeFile(recipePath, false);
6428
6438
  }
6429
6439
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -6437,8 +6447,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6437
6447
  return matches[0];
6438
6448
  }
6439
6449
  async function verifyFile(root, file) {
6440
- const absolute = path14.resolve(root, ...file.path.split("/"));
6441
- if (!existsSync10(absolute)) {
6450
+ const absolute = path13.resolve(root, ...file.path.split("/"));
6451
+ if (!existsSync9(absolute)) {
6442
6452
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
6443
6453
  }
6444
6454
  const actualSha256 = await sha256File(absolute);
@@ -6451,9 +6461,9 @@ async function verifyFile(root, file) {
6451
6461
  }
6452
6462
  async function verifyRecipe(target, options = {}) {
6453
6463
  const loaded = await resolveRecipe(target, options.bundledRoot);
6454
- const actualIntegrity = recipeDigest(loaded.recipe);
6464
+ const actualIntegrity = recipeDigest(loaded.authored);
6455
6465
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
6456
- const modelRoot = options.modelRoot ? path14.resolve(options.modelRoot) : null;
6466
+ const modelRoot = options.modelRoot ? path13.resolve(options.modelRoot) : null;
6457
6467
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
6458
6468
  ...await verifyFile(modelRoot, model),
6459
6469
  source: model.source,
@@ -6487,7 +6497,7 @@ async function verifyRecipe(target, options = {}) {
6487
6497
  };
6488
6498
  }
6489
6499
  function renderStyle(style, destination, cwd) {
6490
- const portableDir = path14.relative(cwd, destination).split(path14.sep).join("/") || ".";
6500
+ const portableDir = path13.relative(cwd, destination).split(path13.sep).join("/") || ".";
6491
6501
  const replace = (value) => {
6492
6502
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
6493
6503
  if (Array.isArray(value)) return value.map(replace);
@@ -6499,28 +6509,29 @@ function renderStyle(style, destination, cwd) {
6499
6509
  return replace(style);
6500
6510
  }
6501
6511
  async function installRecipe(target, options = {}) {
6502
- const cwd = path14.resolve(options.cwd ?? process.cwd());
6512
+ const cwd = path13.resolve(options.cwd ?? process.cwd());
6503
6513
  const loaded = await resolveRecipe(target, options.bundledRoot);
6504
6514
  const verification = await verifyRecipe(loaded.path);
6505
6515
  if (!verification.ok) {
6506
6516
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
6507
6517
  }
6508
- const destination = options.out ? path14.resolve(cwd, options.out) : path14.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6518
+ const destination = options.out ? path13.resolve(cwd, options.out) : path13.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6509
6519
  const sources = [
6510
6520
  { path: RECIPE_FILE, source: loaded.path },
6511
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path14.join(loaded.dir, ...file.path.split("/")) }))
6521
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path13.join(loaded.dir, ...file.path.split("/")) }))
6512
6522
  ];
6513
6523
  const files = await Promise.all(sources.map(async (file) => ({
6514
- path: path14.join(destination, ...file.path.split("/")),
6515
- data: await readFile7(file.source)
6524
+ path: path13.join(destination, ...file.path.split("/")),
6525
+ data: await readFile6(file.source)
6516
6526
  })));
6517
6527
  if (!options.force) {
6518
6528
  for (const file of files) {
6519
- if (!existsSync10(file.path)) continue;
6520
- const current = await readFile7(file.path);
6529
+ if (!existsSync9(file.path)) continue;
6530
+ const current = await readFile6(file.path);
6521
6531
  if (!current.equals(file.data)) {
6522
- throw new Error(
6523
- `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`
6532
+ throw new OverwriteRefusedError(
6533
+ `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
6534
+ [file.path]
6524
6535
  );
6525
6536
  }
6526
6537
  }
@@ -6539,6 +6550,152 @@ async function installRecipe(target, options = {}) {
6539
6550
  };
6540
6551
  }
6541
6552
 
6553
+ // src/pipeline/audit.ts
6554
+ import { readFile as readFile7 } from "fs/promises";
6555
+ import { existsSync as existsSync10 } from "fs";
6556
+ import path14 from "path";
6557
+ function paletteDistance(asset, reference) {
6558
+ if (!asset.length || !reference.length) return 0;
6559
+ let total = 0;
6560
+ let weight = 0;
6561
+ for (const color of asset) {
6562
+ let nearest = Infinity;
6563
+ for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
6564
+ total += nearest * color.weight;
6565
+ weight += color.weight;
6566
+ }
6567
+ return weight === 0 ? 0 : total / weight;
6568
+ }
6569
+ function mergePalettes(palettes, topN = 24) {
6570
+ const totals = /* @__PURE__ */ new Map();
6571
+ for (const palette of palettes) {
6572
+ for (const color of palette) {
6573
+ const key = `${color.r},${color.g},${color.b}`;
6574
+ const existing = totals.get(key);
6575
+ if (existing) existing.weight += color.weight;
6576
+ else totals.set(key, { ...color });
6577
+ }
6578
+ }
6579
+ const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
6580
+ const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
6581
+ return merged.map((c) => ({ ...c, weight: c.weight / sum }));
6582
+ }
6583
+ async function auditStyle(loaded, specs, styleId, lock) {
6584
+ const style = loaded.manifest.styles[styleId];
6585
+ if (!style) throw new Error(`Unknown style "${styleId}"`);
6586
+ const mine = specs.filter((s) => s.styleId === styleId);
6587
+ const assets = [];
6588
+ const missing = [];
6589
+ const unreadable = [];
6590
+ for (const spec of mine) {
6591
+ for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
6592
+ if (!existsSync10(output.absolutePath)) {
6593
+ missing.push(output.id);
6594
+ continue;
6595
+ }
6596
+ try {
6597
+ const png = decodePng(await readFile7(output.absolutePath));
6598
+ const palette = extractPalette(png, 12);
6599
+ assets.push({
6600
+ assetId: spec.assetId,
6601
+ ...output.role ? { outputRole: output.role } : {},
6602
+ id: output.id,
6603
+ file: output.absolutePath,
6604
+ width: png.width,
6605
+ height: png.height,
6606
+ paletteDistance: 0,
6607
+ // filled once the reference is known
6608
+ transparency: transparencyRatio(png),
6609
+ colorCount: countColors(png),
6610
+ palette
6611
+ });
6612
+ } catch (err) {
6613
+ unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
6614
+ }
6615
+ }
6616
+ }
6617
+ let reference = [];
6618
+ let referenceFromStyleImages = false;
6619
+ const refPalettes = [];
6620
+ for (const img of style.styleImages) {
6621
+ const abs = path14.resolve(loaded.root, img.path);
6622
+ if (!existsSync10(abs)) continue;
6623
+ try {
6624
+ refPalettes.push(extractPalette(decodePng(await readFile7(abs)), 12));
6625
+ } catch {
6626
+ }
6627
+ }
6628
+ if (refPalettes.length) {
6629
+ reference = mergePalettes(refPalettes);
6630
+ referenceFromStyleImages = true;
6631
+ for (const asset of assets) {
6632
+ asset.paletteDistance = paletteDistance(asset.palette, reference);
6633
+ }
6634
+ } else {
6635
+ reference = mergePalettes(assets.map((a) => a.palette));
6636
+ for (const asset of assets) {
6637
+ const others = assets.filter((a) => a !== asset).map((a) => a.palette);
6638
+ asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
6639
+ }
6640
+ }
6641
+ assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
6642
+ return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
6643
+ }
6644
+ function countColors(png) {
6645
+ const seen = /* @__PURE__ */ new Set();
6646
+ for (let i = 0; i < png.pixels.length; i += 4) {
6647
+ if (png.pixels[i + 3] < 128) continue;
6648
+ seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
6649
+ }
6650
+ return seen.size;
6651
+ }
6652
+ function outliers(audit, sigma = 1.5) {
6653
+ const n = audit.assets.length;
6654
+ if (n < 3) return [];
6655
+ const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
6656
+ const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
6657
+ const sd = Math.sqrt(variance);
6658
+ if (sd === 0) return [];
6659
+ return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
6660
+ }
6661
+ function evaluateAudit(audit, thresholds = {}) {
6662
+ const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
6663
+ const relative = outliers(audit, resolved.sigma);
6664
+ const relativeIds = new Set(relative.map((asset) => asset.id));
6665
+ const violations = [];
6666
+ for (const asset of audit.assets) {
6667
+ const reasons = [];
6668
+ if (relativeIds.has(asset.id)) {
6669
+ reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
6670
+ }
6671
+ if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
6672
+ reasons.push(
6673
+ `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
6674
+ );
6675
+ }
6676
+ if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
6677
+ reasons.push(
6678
+ `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
6679
+ );
6680
+ }
6681
+ if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
6682
+ reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
6683
+ }
6684
+ if (reasons.length) violations.push({ id: asset.id, reasons });
6685
+ }
6686
+ return {
6687
+ safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
6688
+ thresholds: resolved,
6689
+ outliers: [...relativeIds],
6690
+ violations,
6691
+ missing: [...audit.missing],
6692
+ unreadable: [...audit.unreadable]
6693
+ };
6694
+ }
6695
+ function hex(c) {
6696
+ return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
6697
+ }
6698
+
6542
6699
  // src/pipeline/quality-regression.ts
6543
6700
  import path15 from "path";
6544
6701
  import { existsSync as existsSync11 } from "fs";
@@ -6787,7 +6944,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
6787
6944
  if (existsSync11(absolute) && !options.force) {
6788
6945
  const current = await readFile8(absolute);
6789
6946
  if (!current.equals(data)) {
6790
- throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
6947
+ throw new OverwriteRefusedError(
6948
+ `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
6949
+ [absolute]
6950
+ );
6791
6951
  }
6792
6952
  }
6793
6953
  const result = await writeArtifactBundle([{ path: absolute, data }]);
@@ -7008,8 +7168,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7008
7168
  if (e.status === "selected" || e.status === "download-failed") return true;
7009
7169
  if (e.status !== "downloaded") return false;
7010
7170
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7011
- if (!opts.repair) return false;
7012
7171
  const spec = specByKey.get(key);
7172
+ if (spec && e.specHash === spec.specHash && e.outputs.length && !postprocessCurrent(e, spec)) {
7173
+ return Boolean(opts.force) || e.outputs.every((output) => {
7174
+ const file = resolveOutputPath(output.path, spec.root);
7175
+ return existsSync13(file) && sha256(readFileSync3(file)) === output.sha256;
7176
+ });
7177
+ }
7178
+ if (!opts.repair) return false;
7013
7179
  if (e.outputs.length === 0 || !spec) return true;
7014
7180
  return e.outputs.some((output) => {
7015
7181
  const file = resolveOutputPath(output.path, spec.root);
@@ -7048,6 +7214,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7048
7214
  }
7049
7215
  const outputs = [];
7050
7216
  let wrote = false;
7217
+ const wanted = postprocessFor(spec);
7218
+ const reapply = entry.status === "downloaded" && !postprocessCurrent(entry, spec);
7051
7219
  try {
7052
7220
  for (let index = 0; index < sources.length; index++) {
7053
7221
  const source = sources[index];
@@ -7055,20 +7223,24 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7055
7223
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
7056
7224
  );
7057
7225
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
7226
+ const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7227
+ const recordedRaw = recorded?.raw ?? recorded?.sha256;
7058
7228
  let fresh = null;
7059
7229
  if (opts.refresh) {
7060
7230
  if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
7061
7231
  fresh = await provider.download(source.url);
7062
- if (recorded && sha256(fresh) === recorded.sha256) {
7232
+ if (recorded && sha256(fresh) === recordedRaw && !reapply) {
7063
7233
  log(` current ${path16.relative(process.cwd(), target)}`);
7064
7234
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7065
7235
  continue;
7066
7236
  }
7067
7237
  }
7238
+ let raw = null;
7239
+ let final = null;
7068
7240
  if (existsSync13(target)) {
7069
7241
  const currentHash = await sha256File(target);
7070
7242
  if (recorded && currentHash === recorded.sha256) {
7071
- if (!fresh) {
7243
+ if (!fresh && !reapply) {
7072
7244
  if (cacheDir) {
7073
7245
  await cacheMedia(
7074
7246
  cacheDir,
@@ -7080,7 +7252,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7080
7252
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7081
7253
  continue;
7082
7254
  }
7083
- log(` changed ${path16.relative(process.cwd(), target)} (upstream)`);
7255
+ if (reapply && !fresh) {
7256
+ if (!recorded.raw) raw = await readFile10(target);
7257
+ log(` reapply ${path16.relative(process.cwd(), target)}`);
7258
+ } else {
7259
+ log(` changed ${path16.relative(process.cwd(), target)} (upstream)`);
7260
+ }
7084
7261
  } else {
7085
7262
  const superseded = (entry.supersededOutputs ?? []).find(
7086
7263
  (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
@@ -7102,26 +7279,41 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7102
7279
  );
7103
7280
  }
7104
7281
  }
7105
- const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7106
- let buf = fresh ?? (recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null);
7107
- if (buf) {
7108
- log(` cached ${path16.relative(process.cwd(), target)}`);
7109
- } else {
7282
+ if (fresh) {
7283
+ raw = fresh;
7284
+ } else if (!raw && recorded && cacheDir) {
7285
+ if (!reapply) final = await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType);
7286
+ if (!final && recordedRaw) raw = await readCachedMedia(cacheDir, recordedRaw, expectedMediaType);
7287
+ if (final || raw) log(` cached ${path16.relative(process.cwd(), target)}`);
7288
+ }
7289
+ if (!final && !raw) {
7110
7290
  if (!source.url) {
7111
7291
  throw new Error(`no source URL or cached bytes remain for ${source.role ?? "asset"}`);
7112
7292
  }
7113
- buf = await provider.download(source.url);
7293
+ raw = await provider.download(source.url);
7114
7294
  }
7115
7295
  let mediaType;
7296
+ const bytes = final ?? raw;
7116
7297
  try {
7117
- mediaType = validateMedia(buf, expectedMediaType);
7298
+ mediaType = validateMedia(bytes, expectedMediaType);
7118
7299
  } catch (err) {
7119
7300
  const label = expectedMediaType === MediaType.GIF ? "GIF" : "PNG";
7120
- const mismatch = detectMediaType(buf) !== expectedMediaType;
7301
+ const mismatch = detectMediaType(bytes) !== expectedMediaType;
7121
7302
  throw new Error(
7122
- `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${buf.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
7303
+ `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${bytes.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
7123
7304
  );
7124
7305
  }
7306
+ let rawHash = final ? recorded?.raw : void 0;
7307
+ let buf = bytes;
7308
+ if (!final) {
7309
+ if (cacheDir) await cacheMedia(cacheDir, raw, mediaType);
7310
+ const processed = applyPostprocess(raw, mediaType, wanted);
7311
+ buf = processed.bytes;
7312
+ if (processed.changed) {
7313
+ rawHash = sha256(raw);
7314
+ log(` snapped ${path16.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
7315
+ }
7316
+ }
7125
7317
  if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
7126
7318
  await mkdir4(path16.dirname(target), { recursive: true });
7127
7319
  const tmp = `${target}.pixelkiln.tmp`;
@@ -7130,7 +7322,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7130
7322
  path: portableOutputPath(target, spec.root),
7131
7323
  sha256: sha256(buf),
7132
7324
  ...source.role ? { role: source.role } : {},
7133
- mediaType
7325
+ mediaType,
7326
+ ...rawHash ? { raw: rawHash } : {}
7134
7327
  });
7135
7328
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
7136
7329
  await saveLock(lockPath, lock);
@@ -7154,7 +7347,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7154
7347
  sourceUrls: persistentSources,
7155
7348
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
7156
7349
  error: null,
7157
- supersededOutputs: []
7350
+ supersededOutputs: [],
7351
+ postprocess: wanted
7158
7352
  });
7159
7353
  result.downloaded++;
7160
7354
  } catch (err) {
@@ -7261,6 +7455,7 @@ function retireGeneration(entry, retiredAt) {
7261
7455
  cost: entry.cost,
7262
7456
  costUnit: entry.costUnit,
7263
7457
  provider: entry.provider,
7458
+ postprocess: entry.postprocess,
7264
7459
  retiredAt
7265
7460
  };
7266
7461
  }
@@ -7273,9 +7468,13 @@ function historyAfterReplacing(previous, limit, retiredAt) {
7273
7468
  }
7274
7469
  function referencedHashes(lock) {
7275
7470
  const hashes = /* @__PURE__ */ new Set();
7471
+ const add = (output) => {
7472
+ hashes.add(output.sha256);
7473
+ if (output.raw) hashes.add(output.raw);
7474
+ };
7276
7475
  for (const entry of Object.values(lock.entries)) {
7277
- for (const output of entry.outputs) hashes.add(output.sha256);
7278
- for (const generation of entry.history ?? []) for (const output of generation.outputs) hashes.add(output.sha256);
7476
+ for (const output of entry.outputs) add(output);
7477
+ for (const generation of entry.history ?? []) for (const output of generation.outputs) add(output);
7279
7478
  }
7280
7479
  return hashes;
7281
7480
  }
@@ -7312,8 +7511,9 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
7312
7511
  const file = currentEntryOutputPath(entry, spec, i);
7313
7512
  if (!existsSync14(file)) continue;
7314
7513
  if (await sha256File(file) !== entry.outputs[i].sha256 && !opts.force) {
7315
- throw new Error(
7316
- `${path17.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`
7514
+ throw new OverwriteRefusedError(
7515
+ `${path17.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
7516
+ [file]
7317
7517
  );
7318
7518
  }
7319
7519
  }
@@ -7345,6 +7545,7 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
7345
7545
  cost: generation.cost,
7346
7546
  costUnit: generation.costUnit,
7347
7547
  provider: generation.provider,
7548
+ postprocess: generation.postprocess,
7348
7549
  history
7349
7550
  });
7350
7551
  await saveLock(lockPath, lock);
@@ -8063,7 +8264,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
8063
8264
  if (opts.budget != null) {
8064
8265
  const cost = [...estimates.values()].reduce((sum, estimate) => sum + estimate.amount, 0);
8065
8266
  if (cost > opts.budget) {
8066
- throw new Error(
8267
+ throw new BudgetError(
8067
8268
  `This run would spend ${formatCost(unit, cost)} but the budget is ${formatCost(unit, opts.budget)}. Narrow it with --only/--style, or raise --budget.`
8068
8269
  );
8069
8270
  }
@@ -8701,6 +8902,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
8701
8902
  promptSuffix: "",
8702
8903
  styleImages: [],
8703
8904
  palette: [],
8905
+ enforcePalette: false,
8704
8906
  outDir,
8705
8907
  tags: [name],
8706
8908
  providerOptions: {}
@@ -9390,7 +9592,7 @@ var WorkspaceSchema = z6.object({
9390
9592
  function parseWorkspace(raw) {
9391
9593
  const parsed = WorkspaceSchema.safeParse(raw);
9392
9594
  if (parsed.success) return parsed.data;
9393
- throw new Error(
9595
+ throw new ProjectError(
9394
9596
  `Workspace catalog is not valid v1:
9395
9597
  ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
9396
9598
  );
@@ -9401,7 +9603,7 @@ async function loadWorkspace(workspacePath) {
9401
9603
  try {
9402
9604
  raw = JSON.parse(await readFile16(workspacePath, "utf8"));
9403
9605
  } catch (err) {
9404
- throw new Error(
9606
+ throw new ProjectError(
9405
9607
  `Workspace catalog at ${workspacePath} is malformed:
9406
9608
  ${err instanceof Error ? err.message : String(err)}`
9407
9609
  );
@@ -9581,6 +9783,8 @@ var ManifestEditSchema = z7.discriminatedUnion("action", [
9581
9783
  promptSuffix: z7.string().optional(),
9582
9784
  /** `#rrggbb` values; null or empty clears the style's own palette. */
9583
9785
  palette: z7.array(z7.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
9786
+ /** Snap downloaded art to the palette; false clears the style's own value. */
9787
+ enforcePalette: z7.boolean().optional(),
9584
9788
  /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
9585
9789
  view: z7.string().max(64).nullable().optional(),
9586
9790
  /** pixflux only; null clears the style's own value. */
@@ -9672,6 +9876,7 @@ function applyEdit(raw, edit) {
9672
9876
  const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
9673
9877
  setOrDelete(style, "palette", colors.length ? colors : null);
9674
9878
  }
9879
+ if (patch2.enforcePalette !== void 0) setOrDelete(style, "enforcePalette", patch2.enforcePalette || null);
9675
9880
  if (patch2.view !== void 0) setOrDelete(style, "view", patch2.view?.trim() || null);
9676
9881
  if (patch2.noBackground !== void 0) setOrDelete(style, "noBackground", patch2.noBackground);
9677
9882
  return;
@@ -10383,6 +10588,7 @@ async function buildGallerySnapshot(opts) {
10383
10588
  outputs,
10384
10589
  quality,
10385
10590
  providerMetadata: entry?.providerMetadata ?? {},
10591
+ postprocess: entry?.postprocess ?? null,
10386
10592
  asset,
10387
10593
  source: spec.source ?? null,
10388
10594
  edit,
@@ -10443,6 +10649,7 @@ async function buildGallerySnapshot(opts) {
10443
10649
  outputs,
10444
10650
  quality: null,
10445
10651
  providerMetadata: entry.providerMetadata ?? {},
10652
+ postprocess: entry.postprocess ?? null,
10446
10653
  asset: null,
10447
10654
  source: null,
10448
10655
  edit: null,
@@ -10491,6 +10698,7 @@ async function buildGallerySnapshot(opts) {
10491
10698
  promptPrefix: style?.promptPrefix ?? "",
10492
10699
  promptSuffix: style?.promptSuffix ?? "",
10493
10700
  palette: style?.palette ?? [],
10701
+ enforcePalette: style?.enforcePalette ?? false,
10494
10702
  view: style?.view ?? null,
10495
10703
  noBackground: style?.noBackground ?? true,
10496
10704
  quality: Boolean(style?.quality),
@@ -12193,6 +12401,7 @@ async function workspaceStatus(ws, dir) {
12193
12401
  }
12194
12402
  export {
12195
12403
  AssetSchema,
12404
+ BudgetError,
12196
12405
  ComfyUIProvider,
12197
12406
  DEFAULT_HISTORY_LIMIT,
12198
12407
  DEFAULT_QUALITY_TOLERANCES,
@@ -12200,6 +12409,7 @@ export {
12200
12409
  DEFAULT_TIMEOUT_MS,
12201
12410
  EDITOR_PIN,
12202
12411
  EDITOR_RELEASE_BASE,
12412
+ EXIT_CODES,
12203
12413
  EditorInstallError,
12204
12414
  FAKE_PNG,
12205
12415
  FakeProvider,
@@ -12224,11 +12434,16 @@ export {
12224
12434
  ManifestInputSchema,
12225
12435
  ManifestSchema,
12226
12436
  MediaType,
12437
+ OverwriteRefusedError,
12227
12438
  PIXEL_ART_FIXER_REVISION,
12228
12439
  PIXEL_ART_FIXER_URL,
12440
+ PixelKilnError,
12229
12441
  PixelLabClient,
12230
12442
  PixelLabError,
12231
12443
  PixelLabProvider,
12444
+ PostprocessSchema,
12445
+ ProjectError,
12446
+ ProviderError,
12232
12447
  QualityBaselineSchema,
12233
12448
  QualityProfileSchema,
12234
12449
  QualityToleranceSchema,
@@ -12240,6 +12455,7 @@ export {
12240
12455
  StyleInputSchema,
12241
12456
  StyleSchema,
12242
12457
  UnsupportedCapabilityError,
12458
+ UsageError,
12243
12459
  WorkspaceProjectSchema,
12244
12460
  WorkspaceSchema,
12245
12461
  adopt,
@@ -12277,7 +12493,9 @@ export {
12277
12493
  editorRoute,
12278
12494
  editorStatus,
12279
12495
  editorToolsRoot,
12496
+ errorCode,
12280
12497
  evaluateAudit,
12498
+ exitCodeFor,
12281
12499
  expectedOutputPath,
12282
12500
  exportTileset,
12283
12501
  fallbackOutputRole,