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.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AssetSchema: () => AssetSchema,
34
+ BudgetError: () => BudgetError,
34
35
  ComfyUIProvider: () => ComfyUIProvider,
35
36
  DEFAULT_HISTORY_LIMIT: () => DEFAULT_HISTORY_LIMIT,
36
37
  DEFAULT_QUALITY_TOLERANCES: () => DEFAULT_QUALITY_TOLERANCES,
@@ -38,6 +39,7 @@ __export(index_exports, {
38
39
  DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
39
40
  EDITOR_PIN: () => EDITOR_PIN,
40
41
  EDITOR_RELEASE_BASE: () => EDITOR_RELEASE_BASE,
42
+ EXIT_CODES: () => EXIT_CODES,
41
43
  EditorInstallError: () => EditorInstallError,
42
44
  FAKE_PNG: () => FAKE_PNG,
43
45
  FakeProvider: () => FakeProvider,
@@ -62,11 +64,16 @@ __export(index_exports, {
62
64
  ManifestInputSchema: () => ManifestInputSchema,
63
65
  ManifestSchema: () => ManifestSchema,
64
66
  MediaType: () => MediaType,
67
+ OverwriteRefusedError: () => OverwriteRefusedError,
65
68
  PIXEL_ART_FIXER_REVISION: () => PIXEL_ART_FIXER_REVISION,
66
69
  PIXEL_ART_FIXER_URL: () => PIXEL_ART_FIXER_URL,
70
+ PixelKilnError: () => PixelKilnError,
67
71
  PixelLabClient: () => PixelLabClient,
68
72
  PixelLabError: () => PixelLabError,
69
73
  PixelLabProvider: () => PixelLabProvider,
74
+ PostprocessSchema: () => PostprocessSchema,
75
+ ProjectError: () => ProjectError,
76
+ ProviderError: () => ProviderError,
70
77
  QualityBaselineSchema: () => QualityBaselineSchema,
71
78
  QualityProfileSchema: () => QualityProfileSchema,
72
79
  QualityToleranceSchema: () => QualityToleranceSchema,
@@ -78,6 +85,7 @@ __export(index_exports, {
78
85
  StyleInputSchema: () => StyleInputSchema,
79
86
  StyleSchema: () => StyleSchema,
80
87
  UnsupportedCapabilityError: () => UnsupportedCapabilityError,
88
+ UsageError: () => UsageError,
81
89
  WorkspaceProjectSchema: () => WorkspaceProjectSchema,
82
90
  WorkspaceSchema: () => WorkspaceSchema,
83
91
  adopt: () => adopt,
@@ -115,7 +123,9 @@ __export(index_exports, {
115
123
  editorRoute: () => editorRoute,
116
124
  editorStatus: () => editorStatus,
117
125
  editorToolsRoot: () => editorToolsRoot,
126
+ errorCode: () => errorCode,
118
127
  evaluateAudit: () => evaluateAudit,
128
+ exitCodeFor: () => exitCodeFor,
119
129
  expectedOutputPath: () => expectedOutputPath,
120
130
  exportTileset: () => exportTileset,
121
131
  fallbackOutputRole: () => fallbackOutputRole,
@@ -441,6 +451,18 @@ var StyleObjectSchema = import_zod.z.object({
441
451
  * chocolate bar.
442
452
  */
443
453
  palette: import_zod.z.array(HexColorSchema).default([]),
454
+ /**
455
+ * Snap every visible pixel of the downloaded art to the nearest `palette`
456
+ * colour, without dithering. The palette is sent to providers that take
457
+ * one, and most honour it most of the time; this makes it a guarantee
458
+ * for every generator, including the ones with no palette parameter.
459
+ * Applied by `fetch` as the file is written and recorded on the lock
460
+ * entry, so a restore reproduces the same bytes. Turning it on or off
461
+ * does not change the spec hash: `plan` reports the entry as recoverable
462
+ * and the next `fetch` or `restore` re-applies it from the art already
463
+ * on disk or in the cache, spending nothing.
464
+ */
465
+ enforcePalette: import_zod.z.boolean().default(false),
444
466
  /** Fail-closed native-grid, palette, and human-approval policy for derived art. */
445
467
  quality: QualityProfileSchema.optional(),
446
468
  /**
@@ -482,6 +504,13 @@ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImag
482
504
  path: ["quality"]
483
505
  });
484
506
  }
507
+ if (style.enforcePalette && style.palette.length < 2) {
508
+ ctx.addIssue({
509
+ code: import_zod.z.ZodIssueCode.custom,
510
+ message: "enforcePalette needs a palette of at least two colours to snap to",
511
+ path: ["enforcePalette"]
512
+ });
513
+ }
485
514
  });
486
515
  var InheritedStyleSchema = StyleObjectSchema.partial().extend({
487
516
  extends: import_zod.z.string().min(1),
@@ -604,7 +633,21 @@ var LockOutputSchema = import_zod.z.object({
604
633
  path: import_zod.z.string(),
605
634
  sha256: import_zod.z.string(),
606
635
  role: import_zod.z.string().optional(),
607
- mediaType: MediaTypeSchema.optional()
636
+ mediaType: MediaTypeSchema.optional(),
637
+ /**
638
+ * Hash of the provider's bytes before post-processing changed them, when
639
+ * it did. The content cache keeps both, so the post-processing can be
640
+ * undone or redone without contacting the provider.
641
+ */
642
+ raw: import_zod.z.string().optional()
643
+ });
644
+ var PostprocessSchema = import_zod.z.object({
645
+ palette: import_zod.z.object({
646
+ /** Canonical lowercase `#rrggbb` values, in manifest order. */
647
+ colors: import_zod.z.array(import_zod.z.string()).min(2).max(256),
648
+ dither: import_zod.z.literal("none"),
649
+ distance: import_zod.z.literal("redmean")
650
+ }).optional()
608
651
  });
609
652
  var LockSourceSchema = import_zod.z.object({
610
653
  url: import_zod.z.string(),
@@ -630,6 +673,7 @@ var LockHistoryEntrySchema = import_zod.z.object({
630
673
  cost: import_zod.z.number().finite().nonnegative().default(0),
631
674
  costUnit: import_zod.z.string().min(1).default("generations"),
632
675
  provider: import_zod.z.string().default("pixellab"),
676
+ postprocess: PostprocessSchema.optional(),
633
677
  /** When the replacement was submitted. */
634
678
  retiredAt: import_zod.z.string()
635
679
  });
@@ -701,14 +745,7 @@ var LockEntrySchema = import_zod.z.object({
701
745
  * `role` labels non-primary artifacts (e.g. "portrait", "spriteframes") so a
702
746
  * consumer can find the one it wants without pattern-matching on paths.
703
747
  */
704
- outputs: import_zod.z.array(
705
- import_zod.z.object({
706
- path: import_zod.z.string(),
707
- sha256: import_zod.z.string(),
708
- role: import_zod.z.string().optional(),
709
- mediaType: MediaTypeSchema.optional()
710
- })
711
- ).default([]),
748
+ outputs: import_zod.z.array(LockOutputSchema).default([]),
712
749
  /**
713
750
  * Provider-owned data retained for downstream consumers, namespaced by
714
751
  * provider id. PixelLab connectable sets keep their exact `tileRules` here
@@ -724,7 +761,9 @@ var LockEntrySchema = import_zod.z.object({
724
761
  /** Which provider produced this. Absent on entries written before providers. */
725
762
  provider: import_zod.z.string().default("pixellab"),
726
763
  /** Generations this entry replaced, newest first; see LockHistoryEntrySchema. */
727
- history: import_zod.z.array(LockHistoryEntrySchema).optional()
764
+ history: import_zod.z.array(LockHistoryEntrySchema).optional(),
765
+ /** Post-processing applied to the recorded outputs; absent when the bytes are the provider's. */
766
+ postprocess: PostprocessSchema.optional()
728
767
  });
729
768
  var LockSchema = import_zod.z.object({
730
769
  version: import_zod.z.literal(2),
@@ -745,6 +784,72 @@ function lockKey(styleId, assetId) {
745
784
  return `${styleId}/${assetId}`;
746
785
  }
747
786
 
787
+ // src/errors.ts
788
+ var EXIT_CODES = {
789
+ usage: 2,
790
+ project: 3,
791
+ provider: 4,
792
+ "refused-overwrite": 5,
793
+ budget: 6,
794
+ capability: 7
795
+ };
796
+ var PixelKilnError = class extends Error {
797
+ code;
798
+ hint;
799
+ constructor(message6, code, opts = {}) {
800
+ super(message6, opts.cause === void 0 ? void 0 : { cause: opts.cause });
801
+ this.name = "PixelKilnError";
802
+ this.code = code;
803
+ if (opts.hint) this.hint = opts.hint;
804
+ }
805
+ };
806
+ var UsageError = class extends PixelKilnError {
807
+ constructor(message6, opts) {
808
+ super(message6, "usage", opts);
809
+ this.name = "UsageError";
810
+ }
811
+ };
812
+ var ProjectError = class extends PixelKilnError {
813
+ constructor(message6, opts) {
814
+ super(message6, "project", opts);
815
+ this.name = "ProjectError";
816
+ }
817
+ };
818
+ var ProviderError = class extends PixelKilnError {
819
+ /** Registry id of the provider that failed. */
820
+ provider;
821
+ /** HTTP status when the failure was a response, otherwise undefined. */
822
+ status;
823
+ constructor(provider, message6, opts = {}) {
824
+ super(message6, "provider", opts);
825
+ this.name = "ProviderError";
826
+ this.provider = provider;
827
+ if (opts.status !== void 0) this.status = opts.status;
828
+ }
829
+ };
830
+ var OverwriteRefusedError = class extends PixelKilnError {
831
+ /** The file(s) left untouched. */
832
+ files;
833
+ constructor(message6, files, opts) {
834
+ super(message6, "refused-overwrite", opts);
835
+ this.name = "OverwriteRefusedError";
836
+ this.files = files;
837
+ }
838
+ };
839
+ var BudgetError = class extends PixelKilnError {
840
+ constructor(message6, opts) {
841
+ super(message6, "budget", opts);
842
+ this.name = "BudgetError";
843
+ }
844
+ };
845
+ function errorCode(err) {
846
+ return err instanceof PixelKilnError ? err.code : void 0;
847
+ }
848
+ function exitCodeFor(err) {
849
+ const code = errorCode(err);
850
+ return code ? EXIT_CODES[code] : 1;
851
+ }
852
+
748
853
  // src/client.ts
749
854
  var import_zod2 = require("zod");
750
855
 
@@ -861,14 +966,12 @@ function validateResponse(schema, raw, operation) {
861
966
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
862
967
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
863
968
  }
864
- var PixelLabError = class extends Error {
969
+ var PixelLabError = class extends ProviderError {
865
970
  constructor(message6, status, body) {
866
- super(message6);
867
- this.status = status;
971
+ super("pixellab", message6, { status });
868
972
  this.body = body;
869
973
  this.name = "PixelLabError";
870
974
  }
871
- status;
872
975
  body;
873
976
  };
874
977
  var PixelLabClient = class {
@@ -1148,10 +1251,11 @@ function measureBalanceChange(before, after) {
1148
1251
  };
1149
1252
  }
1150
1253
  var DEFAULT_RATE_LIMIT = { spacingMs: 2500, maxInFlight: 8 };
1151
- var UnsupportedCapabilityError = class extends Error {
1254
+ var UnsupportedCapabilityError = class extends PixelKilnError {
1152
1255
  constructor(providerId, capability) {
1153
1256
  super(
1154
- `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`
1257
+ `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`,
1258
+ "capability"
1155
1259
  );
1156
1260
  this.name = "UnsupportedCapabilityError";
1157
1261
  }
@@ -1976,7 +2080,7 @@ var RetroDiffusionClient = class {
1976
2080
  /** Result URLs are signed; no token header, and the same retry policy as the API. */
1977
2081
  async download(url) {
1978
2082
  const response = await this.request(url);
1979
- if (!response.ok) throw new Error(`Retro Diffusion download failed (${response.status})`);
2083
+ if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
1980
2084
  return Buffer.from(await response.arrayBuffer());
1981
2085
  }
1982
2086
  async call(path32, init = {}) {
@@ -1999,8 +2103,10 @@ var RetroDiffusionClient = class {
1999
2103
  if (!response.ok) {
2000
2104
  const retry = response.headers.get("retry-after");
2001
2105
  const detail = retroError(value);
2002
- throw new Error(
2003
- `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
2106
+ throw new ProviderError(
2107
+ "retrodiffusion",
2108
+ `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : ""),
2109
+ { status: response.status }
2004
2110
  );
2005
2111
  }
2006
2112
  return value;
@@ -2392,7 +2498,7 @@ var ScenarioClient = class {
2392
2498
  throw new Error("Scenario downloads must use HTTP or HTTPS");
2393
2499
  }
2394
2500
  const response = await this.request(parsed);
2395
- if (!response.ok) throw new Error(`Scenario download failed (${response.status})`);
2501
+ if (!response.ok) throw new ProviderError("scenario", `Scenario download failed (${response.status})`, { status: response.status });
2396
2502
  const declared = Number(response.headers.get("content-length"));
2397
2503
  if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) {
2398
2504
  throw new Error(`Scenario download exceeds the ${MAX_DOWNLOAD_BYTES}-byte safety limit`);
@@ -2439,8 +2545,10 @@ var ScenarioClient = class {
2439
2545
  if (!response.ok) {
2440
2546
  const retry = response.headers.get("retry-after");
2441
2547
  const detail = redactScenarioError(value, [this.apiKey, this.apiSecret, auth]);
2442
- throw new Error(
2443
- `Scenario request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
2548
+ throw new ProviderError(
2549
+ "scenario",
2550
+ `Scenario request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : ""),
2551
+ { status: response.status }
2444
2552
  );
2445
2553
  }
2446
2554
  return value;
@@ -2890,8 +2998,10 @@ var ComfyUIClient = class {
2890
2998
  value = null;
2891
2999
  }
2892
3000
  if (!response.ok) {
2893
- throw new Error(
2894
- `ComfyUI image upload failed (${response.status})` + (apiError(value) ? `: ${apiError(value)}` : "")
3001
+ throw new ProviderError(
3002
+ "comfyui",
3003
+ `ComfyUI image upload failed (${response.status})` + (apiError(value) ? `: ${apiError(value)}` : ""),
3004
+ { status: response.status }
2895
3005
  );
2896
3006
  }
2897
3007
  if (!isObject(value) || typeof value.name !== "string" || !value.name) {
@@ -2914,7 +3024,7 @@ var ComfyUIClient = class {
2914
3024
  let target = source;
2915
3025
  if (source.startsWith("comfyui://")) target = this.viewUrl(parseSource(source));
2916
3026
  const response = await this.request(target);
2917
- if (!response.ok) throw new Error(`ComfyUI download failed (${response.status})`);
3027
+ if (!response.ok) throw new ProviderError("comfyui", `ComfyUI download failed (${response.status})`, { status: response.status });
2918
3028
  return Buffer.from(await response.arrayBuffer());
2919
3029
  }
2920
3030
  endpoint(route) {
@@ -2939,8 +3049,10 @@ var ComfyUIClient = class {
2939
3049
  }
2940
3050
  if (!response.ok) {
2941
3051
  const detail = apiError(value);
2942
- throw new Error(
2943
- `ComfyUI request failed (${response.status}) at ${url.pathname}` + (detail ? `: ${detail}` : "")
3052
+ throw new ProviderError(
3053
+ "comfyui",
3054
+ `ComfyUI request failed (${response.status}) at ${url.pathname}` + (detail ? `: ${detail}` : ""),
3055
+ { status: response.status }
2944
3056
  );
2945
3057
  }
2946
3058
  return value;
@@ -3930,9 +4042,10 @@ async function loadLock(lockPath) {
3930
4042
  try {
3931
4043
  return parseLock(JSON.parse(await (0, import_promises2.readFile)(lockPath, "utf8")));
3932
4044
  } catch (err) {
3933
- throw new Error(
4045
+ throw new ProjectError(
3934
4046
  `Lockfile at ${lockPath} is malformed:
3935
- ${err instanceof Error ? err.message : String(err)}`
4047
+ ${err instanceof Error ? err.message : String(err)}`,
4048
+ { cause: err }
3936
4049
  );
3937
4050
  }
3938
4051
  }
@@ -3966,8 +4079,9 @@ async function writeLockWhileHeld(file, lock) {
3966
4079
  try {
3967
4080
  disk = parseLock(JSON.parse(await (0, import_promises2.readFile)(file, "utf8")));
3968
4081
  } catch (err) {
3969
- throw new Error(
3970
- `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
4082
+ throw new ProjectError(
4083
+ `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`,
4084
+ { cause: err }
3971
4085
  );
3972
4086
  }
3973
4087
  }
@@ -4223,18 +4337,18 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
4223
4337
  // src/manifest.ts
4224
4338
  async function loadManifest(manifestPath) {
4225
4339
  const abs = import_node_path5.default.resolve(manifestPath);
4226
- if (!(0, import_node_fs5.existsSync)(abs)) throw new Error(`No manifest at ${abs}`);
4340
+ if (!(0, import_node_fs5.existsSync)(abs)) throw new ProjectError(`No manifest at ${abs}`);
4227
4341
  const input = ManifestInputSchema.safeParse(JSON.parse(await (0, import_promises3.readFile)(abs, "utf8")));
4228
4342
  if (!input.success) {
4229
4343
  const issues = formatManifestIssues(input.error.issues);
4230
- throw new Error(`Manifest at ${abs} is invalid:
4344
+ throw new ProjectError(`Manifest at ${abs} is invalid:
4231
4345
  ${issues}`);
4232
4346
  }
4233
4347
  let styles;
4234
4348
  try {
4235
4349
  styles = resolveStyleInheritance(input.data.styles);
4236
4350
  } catch (error) {
4237
- throw new Error(
4351
+ throw new ProjectError(
4238
4352
  `Manifest at ${abs} is invalid:
4239
4353
  ${error instanceof Error ? error.message : String(error)}`
4240
4354
  );
@@ -4242,7 +4356,7 @@ ${issues}`);
4242
4356
  const parsed = ManifestSchema.safeParse({ ...input.data, styles });
4243
4357
  if (!parsed.success) {
4244
4358
  const issues = formatManifestIssues(parsed.error.issues);
4245
- throw new Error(`Manifest at ${abs} is invalid:
4359
+ throw new ProjectError(`Manifest at ${abs} is invalid:
4246
4360
  ${issues}`);
4247
4361
  }
4248
4362
  const styleIds = new Set(Object.keys(parsed.data.styles));
@@ -4292,7 +4406,7 @@ ${issues}`);
4292
4406
  };
4293
4407
  for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
4294
4408
  if (unknownReferences.length) {
4295
- throw new Error(`Manifest at ${abs} is invalid:
4409
+ throw new ProjectError(`Manifest at ${abs} is invalid:
4296
4410
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
4297
4411
  }
4298
4412
  return { manifest: parsed.data, root: import_node_path5.default.dirname(abs), path: abs };
@@ -4371,14 +4485,14 @@ async function resolveSpecs(loaded, filter) {
4371
4485
  );
4372
4486
  for (const unknownStyle of filter?.styles ?? []) {
4373
4487
  if (!manifest.styles[unknownStyle]) {
4374
- throw new Error(
4488
+ throw new UsageError(
4375
4489
  `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
4376
4490
  );
4377
4491
  }
4378
4492
  }
4379
4493
  for (const unknownAsset of filter?.assets ?? []) {
4380
4494
  if (!manifest.assets[unknownAsset]) {
4381
- throw new Error(`Unknown asset "${unknownAsset}".`);
4495
+ throw new UsageError(`Unknown asset "${unknownAsset}".`);
4382
4496
  }
4383
4497
  }
4384
4498
  const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
@@ -4490,6 +4604,7 @@ async function resolveSpecs(loaded, filter) {
4490
4604
  detail: style.detail,
4491
4605
  seed: style.seed,
4492
4606
  palette: style.palette,
4607
+ enforcePalette: style.enforcePalette,
4493
4608
  noBackground: style.noBackground,
4494
4609
  tileSize: generator === "tiles" ? tileSize : void 0,
4495
4610
  tileType: generator === "tiles" ? style.tileType : void 0,
@@ -4694,7 +4809,7 @@ function imageMetadata(buf) {
4694
4809
  }
4695
4810
 
4696
4811
  // src/project.ts
4697
- var import_node_path13 = __toESM(require("path"), 1);
4812
+ var import_node_path12 = __toESM(require("path"), 1);
4698
4813
 
4699
4814
  // src/env.ts
4700
4815
  var import_node_fs6 = require("fs");
@@ -4741,17 +4856,17 @@ function parseEnv(contents) {
4741
4856
  }
4742
4857
 
4743
4858
  // src/pipeline/plan.ts
4744
- var import_node_fs10 = require("fs");
4745
- var import_node_path12 = __toESM(require("path"), 1);
4859
+ var import_node_fs9 = require("fs");
4860
+ var import_node_path11 = __toESM(require("path"), 1);
4746
4861
 
4747
4862
  // src/pipeline/quality-profile.ts
4748
- var import_node_fs8 = require("fs");
4749
- var import_node_path10 = __toESM(require("path"), 1);
4863
+ var import_node_fs7 = require("fs");
4864
+ var import_node_path9 = __toESM(require("path"), 1);
4750
4865
 
4751
4866
  // src/pipeline/refine.ts
4752
- var import_node_path9 = __toESM(require("path"), 1);
4867
+ var import_node_path8 = __toESM(require("path"), 1);
4753
4868
  var import_node_child_process2 = require("child_process");
4754
- var import_promises6 = require("fs/promises");
4869
+ var import_promises5 = require("fs/promises");
4755
4870
  var import_node_os2 = require("os");
4756
4871
  var import_node_util = require("util");
4757
4872
 
@@ -4989,10 +5104,11 @@ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, opt
4989
5104
  if (!expected || digest(current) !== expected) conflicts.push(destination);
4990
5105
  }
4991
5106
  if (conflicts.length) {
4992
- throw new Error(
5107
+ throw new OverwriteRefusedError(
4993
5108
  `Refusing to overwrite modified or unowned artifact file(s):
4994
5109
  ` + conflicts.map((file) => ` ${file}`).join("\n") + `
4995
- Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
5110
+ Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`,
5111
+ conflicts
4996
5112
  );
4997
5113
  }
4998
5114
  }
@@ -5211,10 +5327,19 @@ async function writeArtifactBundle(files, options = {}) {
5211
5327
  };
5212
5328
  }
5213
5329
 
5214
- // src/pipeline/audit.ts
5215
- var import_promises5 = require("fs/promises");
5216
- var import_node_fs7 = require("fs");
5217
- var import_node_path8 = __toESM(require("path"), 1);
5330
+ // src/palette.ts
5331
+ function normalizePalette(values) {
5332
+ const colors = /* @__PURE__ */ new Map();
5333
+ for (const value of values) {
5334
+ const rgb = parseHex(value);
5335
+ const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5336
+ if (!colors.has(hex2)) colors.set(hex2, { hex: hex2, ...rgb });
5337
+ }
5338
+ if (colors.size < 2 || colors.size > 256) {
5339
+ throw new Error(`A palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5340
+ }
5341
+ return [...colors.values()];
5342
+ }
5218
5343
  function colorDistance(a, b) {
5219
5344
  const rmean = (a.r + b.r) / 2;
5220
5345
  const dr = a.r - b.r;
@@ -5224,146 +5349,45 @@ function colorDistance(a, b) {
5224
5349
  (512 + rmean) * dr * dr / 256 + 4 * dg * dg + (767 - rmean) * db * db / 256
5225
5350
  );
5226
5351
  }
5227
- function paletteDistance(asset, reference) {
5228
- if (!asset.length || !reference.length) return 0;
5229
- let total = 0;
5230
- let weight = 0;
5231
- for (const color of asset) {
5232
- let nearest = Infinity;
5233
- for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
5234
- total += nearest * color.weight;
5235
- weight += color.weight;
5236
- }
5237
- return weight === 0 ? 0 : total / weight;
5238
- }
5239
- function mergePalettes(palettes, topN = 24) {
5240
- const totals = /* @__PURE__ */ new Map();
5241
- for (const palette of palettes) {
5242
- for (const color of palette) {
5243
- const key = `${color.r},${color.g},${color.b}`;
5244
- const existing = totals.get(key);
5245
- if (existing) existing.weight += color.weight;
5246
- else totals.set(key, { ...color });
5352
+ function quantize(png, palette) {
5353
+ const colors = normalizePalette(palette);
5354
+ const pixels = Buffer.from(png.pixels);
5355
+ const nearestOf = /* @__PURE__ */ new Map();
5356
+ let changed = 0;
5357
+ for (let i = 0; i < pixels.length; i += 4) {
5358
+ const alpha = pixels[i + 3];
5359
+ if (alpha === 0) {
5360
+ if (pixels[i] || pixels[i + 1] || pixels[i + 2]) changed++;
5361
+ pixels[i] = 0;
5362
+ pixels[i + 1] = 0;
5363
+ pixels[i + 2] = 0;
5364
+ continue;
5247
5365
  }
5248
- }
5249
- const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
5250
- const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
5251
- return merged.map((c) => ({ ...c, weight: c.weight / sum }));
5252
- }
5253
- async function auditStyle(loaded, specs, styleId, lock) {
5254
- const style = loaded.manifest.styles[styleId];
5255
- if (!style) throw new Error(`Unknown style "${styleId}"`);
5256
- const mine = specs.filter((s) => s.styleId === styleId);
5257
- const assets = [];
5258
- const missing = [];
5259
- const unreadable = [];
5260
- for (const spec of mine) {
5261
- for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
5262
- if (!(0, import_node_fs7.existsSync)(output.absolutePath)) {
5263
- missing.push(output.id);
5264
- continue;
5265
- }
5266
- try {
5267
- const png = decodePng(await (0, import_promises5.readFile)(output.absolutePath));
5268
- const palette = extractPalette(png, 12);
5269
- assets.push({
5270
- assetId: spec.assetId,
5271
- ...output.role ? { outputRole: output.role } : {},
5272
- id: output.id,
5273
- file: output.absolutePath,
5274
- width: png.width,
5275
- height: png.height,
5276
- paletteDistance: 0,
5277
- // filled once the reference is known
5278
- transparency: transparencyRatio(png),
5279
- colorCount: countColors(png),
5280
- palette
5281
- });
5282
- } catch (err) {
5283
- unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
5366
+ const key = pixels[i] << 16 | pixels[i + 1] << 8 | pixels[i + 2];
5367
+ let nearest = nearestOf.get(key);
5368
+ if (!nearest) {
5369
+ const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5370
+ nearest = colors[0];
5371
+ let distance = colorDistance(pixel, nearest);
5372
+ for (let j = 1; j < colors.length; j++) {
5373
+ const candidate = colors[j];
5374
+ const candidateDistance = colorDistance(pixel, candidate);
5375
+ if (candidateDistance < distance) {
5376
+ nearest = candidate;
5377
+ distance = candidateDistance;
5378
+ }
5284
5379
  }
5380
+ nearestOf.set(key, nearest);
5285
5381
  }
5382
+ if (pixels[i] !== nearest.r || pixels[i + 1] !== nearest.g || pixels[i + 2] !== nearest.b) changed++;
5383
+ pixels[i] = nearest.r;
5384
+ pixels[i + 1] = nearest.g;
5385
+ pixels[i + 2] = nearest.b;
5286
5386
  }
5287
- let reference = [];
5288
- let referenceFromStyleImages = false;
5289
- const refPalettes = [];
5290
- for (const img of style.styleImages) {
5291
- const abs = import_node_path8.default.resolve(loaded.root, img.path);
5292
- if (!(0, import_node_fs7.existsSync)(abs)) continue;
5293
- try {
5294
- refPalettes.push(extractPalette(decodePng(await (0, import_promises5.readFile)(abs)), 12));
5295
- } catch {
5296
- }
5297
- }
5298
- if (refPalettes.length) {
5299
- reference = mergePalettes(refPalettes);
5300
- referenceFromStyleImages = true;
5301
- for (const asset of assets) {
5302
- asset.paletteDistance = paletteDistance(asset.palette, reference);
5303
- }
5304
- } else {
5305
- reference = mergePalettes(assets.map((a) => a.palette));
5306
- for (const asset of assets) {
5307
- const others = assets.filter((a) => a !== asset).map((a) => a.palette);
5308
- asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
5309
- }
5310
- }
5311
- assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
5312
- return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
5313
- }
5314
- function countColors(png) {
5315
- const seen = /* @__PURE__ */ new Set();
5316
- for (let i = 0; i < png.pixels.length; i += 4) {
5317
- if (png.pixels[i + 3] < 128) continue;
5318
- seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
5319
- }
5320
- return seen.size;
5387
+ return { png: { width: png.width, height: png.height, pixels }, changed };
5321
5388
  }
5322
- function outliers(audit, sigma = 1.5) {
5323
- const n = audit.assets.length;
5324
- if (n < 3) return [];
5325
- const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
5326
- const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
5327
- const sd = Math.sqrt(variance);
5328
- if (sd === 0) return [];
5329
- return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
5330
- }
5331
- function evaluateAudit(audit, thresholds = {}) {
5332
- const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
5333
- const relative = outliers(audit, resolved.sigma);
5334
- const relativeIds = new Set(relative.map((asset) => asset.id));
5335
- const violations = [];
5336
- for (const asset of audit.assets) {
5337
- const reasons = [];
5338
- if (relativeIds.has(asset.id)) {
5339
- reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
5340
- }
5341
- if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
5342
- reasons.push(
5343
- `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
5344
- );
5345
- }
5346
- if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
5347
- reasons.push(
5348
- `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
5349
- );
5350
- }
5351
- if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
5352
- reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
5353
- }
5354
- if (reasons.length) violations.push({ id: asset.id, reasons });
5355
- }
5356
- return {
5357
- safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
5358
- thresholds: resolved,
5359
- outliers: [...relativeIds],
5360
- violations,
5361
- missing: [...audit.missing],
5362
- unreadable: [...audit.unreadable]
5363
- };
5364
- }
5365
- function hex(c) {
5366
- return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
5389
+ function quantizeToPalette(png, palette) {
5390
+ return quantize(png, palette).png;
5367
5391
  }
5368
5392
 
5369
5393
  // src/pipeline/refine.ts
@@ -5450,48 +5474,12 @@ function parseDetection(raw) {
5450
5474
  confidence: confidenceFor(consensus)
5451
5475
  };
5452
5476
  }
5453
- function normalizePalette(values) {
5454
- const colors = /* @__PURE__ */ new Map();
5455
- for (const value of values) {
5456
- const rgb = parseHex(value);
5457
- const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5458
- colors.set(hex2, { hex: hex2, ...rgb });
5459
- }
5460
- if (colors.size < 2 || colors.size > 256) {
5461
- throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5462
- }
5463
- return [...colors.values()];
5464
- }
5465
5477
  function normalizeRefinementPalette(values) {
5466
- return normalizePalette(values).map((color) => color.hex);
5467
- }
5468
- function quantizeToPalette(png, palette) {
5469
- const colors = normalizePalette(palette);
5470
- const pixels = Buffer.from(png.pixels);
5471
- for (let i = 0; i < pixels.length; i += 4) {
5472
- const alpha = pixels[i + 3];
5473
- if (alpha === 0) {
5474
- pixels[i] = 0;
5475
- pixels[i + 1] = 0;
5476
- pixels[i + 2] = 0;
5477
- continue;
5478
- }
5479
- const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5480
- let nearest = colors[0];
5481
- let distance = colorDistance(pixel, nearest);
5482
- for (let j = 1; j < colors.length; j++) {
5483
- const candidate = colors[j];
5484
- const candidateDistance = colorDistance(pixel, candidate);
5485
- if (candidateDistance < distance) {
5486
- nearest = candidate;
5487
- distance = candidateDistance;
5488
- }
5489
- }
5490
- pixels[i] = nearest.r;
5491
- pixels[i + 1] = nearest.g;
5492
- pixels[i + 2] = nearest.b;
5478
+ try {
5479
+ return normalizePalette(values).map((color) => color.hex);
5480
+ } catch (err) {
5481
+ throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${new Set(values.map((v) => v.toLowerCase())).size}.`, { cause: err });
5493
5482
  }
5494
- return { width: png.width, height: png.height, pixels };
5495
5483
  }
5496
5484
  function colorCount(png) {
5497
5485
  const colors = /* @__PURE__ */ new Set();
@@ -5601,16 +5589,16 @@ function sameFrameGrid(first, next) {
5601
5589
  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;
5602
5590
  }
5603
5591
  async function refineAsset(options) {
5604
- const source = import_node_path9.default.resolve(options.source);
5605
- const output = import_node_path9.default.resolve(options.output);
5592
+ const source = import_node_path8.default.resolve(options.source);
5593
+ const output = import_node_path8.default.resolve(options.output);
5606
5594
  if (source === output) throw new Error("Refinement source and output must be different files.");
5607
- const record = import_node_path9.default.resolve(qualityRecordPath(output));
5595
+ const record = import_node_path8.default.resolve(qualityRecordPath(output));
5608
5596
  const palette = normalizePalette(options.palette).map((color) => color.hex);
5609
5597
  const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
5610
5598
  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.");
5611
5599
  let sourcePng;
5612
5600
  try {
5613
- sourcePng = decodePng(await (0, import_promises6.readFile)(source));
5601
+ sourcePng = decodePng(await (0, import_promises5.readFile)(source));
5614
5602
  } catch (error) {
5615
5603
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
5616
5604
  }
@@ -5627,8 +5615,8 @@ async function refineAsset(options) {
5627
5615
  );
5628
5616
  }
5629
5617
  }
5630
- const temp = await (0, import_promises6.mkdtemp)(import_node_path9.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-"));
5631
- const recovered = import_node_path9.default.join(temp, "native.png");
5618
+ const temp = await (0, import_promises5.mkdtemp)(import_node_path8.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-"));
5619
+ const recovered = import_node_path8.default.join(temp, "native.png");
5632
5620
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
5633
5621
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
5634
5622
  try {
@@ -5649,7 +5637,7 @@ async function refineAsset(options) {
5649
5637
  );
5650
5638
  }
5651
5639
  const detection = parseDetection(firstJsonObject(stdout));
5652
- const native = decodePng(await (0, import_promises6.readFile)(recovered));
5640
+ const native = decodePng(await (0, import_promises5.readFile)(recovered));
5653
5641
  const quantized = quantizeToPalette(native, palette);
5654
5642
  const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
5655
5643
  const audit = auditRefinement(
@@ -5699,7 +5687,7 @@ async function refineAsset(options) {
5699
5687
  );
5700
5688
  return { source, output, record, detection, palette, audit };
5701
5689
  } finally {
5702
- await (0, import_promises6.rm)(temp, { recursive: true, force: true });
5690
+ await (0, import_promises5.rm)(temp, { recursive: true, force: true });
5703
5691
  }
5704
5692
  }
5705
5693
  async function refineFrameSet(options) {
@@ -5713,18 +5701,18 @@ async function refineFrameSet(options) {
5713
5701
  if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
5714
5702
  throw new Error("Frame-set roles must be non-empty and unique.");
5715
5703
  }
5716
- const output = import_node_path9.default.resolve(options.output);
5717
- const record = import_node_path9.default.resolve(qualityRecordPath(output));
5704
+ const output = import_node_path8.default.resolve(options.output);
5705
+ const record = import_node_path8.default.resolve(qualityRecordPath(output));
5718
5706
  const palette = normalizeRefinementPalette(options.palette);
5719
5707
  const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
5720
5708
  if (new Set(finalOutputs).size !== finalOutputs.length) {
5721
5709
  throw new Error("Frame-set roles resolve to duplicate output filenames.");
5722
5710
  }
5723
- const sourceSet = new Set(options.sources.map((source) => import_node_path9.default.resolve(source.path)));
5711
+ const sourceSet = new Set(options.sources.map((source) => import_node_path8.default.resolve(source.path)));
5724
5712
  if (finalOutputs.some((file) => sourceSet.has(file))) {
5725
5713
  throw new Error("Frame-set refinement sources and outputs must be different files.");
5726
5714
  }
5727
- const temp = await (0, import_promises6.mkdtemp)(import_node_path9.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-frames-"));
5715
+ const temp = await (0, import_promises5.mkdtemp)(import_node_path8.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-frames-"));
5728
5716
  try {
5729
5717
  const frames = [];
5730
5718
  const outputs = [];
@@ -5732,8 +5720,8 @@ async function refineFrameSet(options) {
5732
5720
  const sourcePaths = [];
5733
5721
  let firstOptions = null;
5734
5722
  for (const [index, sourceEntry] of options.sources.entries()) {
5735
- const source = import_node_path9.default.resolve(sourceEntry.path);
5736
- const temporaryOutput = import_node_path9.default.join(temp, `${String(index).padStart(2, "0")}.png`);
5723
+ const source = import_node_path8.default.resolve(sourceEntry.path);
5724
+ const temporaryOutput = import_node_path8.default.join(temp, `${String(index).padStart(2, "0")}.png`);
5737
5725
  const refined = await refineAsset({
5738
5726
  source,
5739
5727
  output: temporaryOutput,
@@ -5765,7 +5753,7 @@ async function refineFrameSet(options) {
5765
5753
  });
5766
5754
  outputs.push({
5767
5755
  path: finalOutputs[index],
5768
- data: await (0, import_promises6.readFile)(temporaryOutput)
5756
+ data: await (0, import_promises5.readFile)(temporaryOutput)
5769
5757
  });
5770
5758
  }
5771
5759
  const first = frames[0];
@@ -5808,23 +5796,23 @@ async function refineFrameSet(options) {
5808
5796
  );
5809
5797
  return {
5810
5798
  sources: sourcePaths,
5811
- outputs: outputs.map((item) => import_node_path9.default.resolve(item.path)),
5799
+ outputs: outputs.map((item) => import_node_path8.default.resolve(item.path)),
5812
5800
  record,
5813
5801
  frames,
5814
5802
  palette,
5815
5803
  audit
5816
5804
  };
5817
5805
  } finally {
5818
- await (0, import_promises6.rm)(temp, { recursive: true, force: true });
5806
+ await (0, import_promises5.rm)(temp, { recursive: true, force: true });
5819
5807
  }
5820
5808
  }
5821
5809
  async function checkQualityRecord(recordPath) {
5822
- const record = import_node_path9.default.resolve(recordPath);
5810
+ const record = import_node_path8.default.resolve(recordPath);
5823
5811
  const manifest = await readArtifactBundleManifest(record);
5824
5812
  const options = parseRecordOptions(manifest, record);
5825
5813
  const verification = await verifyArtifactBundle(record);
5826
- const sources = manifest.sources.map((source2) => import_node_path9.default.resolve(import_node_path9.default.dirname(record), source2.path));
5827
- const outputs = manifest.outputs.map((output2) => import_node_path9.default.resolve(import_node_path9.default.dirname(record), output2.path));
5814
+ const sources = manifest.sources.map((source2) => import_node_path8.default.resolve(import_node_path8.default.dirname(record), source2.path));
5815
+ const outputs = manifest.outputs.map((output2) => import_node_path8.default.resolve(import_node_path8.default.dirname(record), output2.path));
5828
5816
  const source = sources[0] ?? "";
5829
5817
  const output = outputs[0] ?? "";
5830
5818
  const expectedCount = options.frameSet?.count ?? 1;
@@ -5857,7 +5845,7 @@ async function checkQualityRecord(recordPath) {
5857
5845
  async function approveQualityRecord(recordPath, approval) {
5858
5846
  const reviewer = approval.reviewer.trim();
5859
5847
  if (!reviewer) throw new Error("A human reviewer name is required.");
5860
- const record = import_node_path9.default.resolve(recordPath);
5848
+ const record = import_node_path8.default.resolve(recordPath);
5861
5849
  const manifest = await readArtifactBundleManifest(record);
5862
5850
  const options = parseRecordOptions(manifest, record);
5863
5851
  const verification = await verifyArtifactBundle(record);
@@ -5878,14 +5866,14 @@ async function approveQualityRecord(recordPath, approval) {
5878
5866
  `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
5879
5867
  );
5880
5868
  }
5881
- const root = import_node_path9.default.dirname(record);
5869
+ const root = import_node_path8.default.dirname(record);
5882
5870
  const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
5883
- const outputPath = import_node_path9.default.resolve(root, entry.path);
5884
- return { path: outputPath, data: await (0, import_promises6.readFile)(outputPath) };
5871
+ const outputPath = import_node_path8.default.resolve(root, entry.path);
5872
+ return { path: outputPath, data: await (0, import_promises5.readFile)(outputPath) };
5885
5873
  }));
5886
5874
  const sources = manifest.sources.map((source) => ({
5887
5875
  ...source,
5888
- path: import_node_path9.default.resolve(root, source.path)
5876
+ path: import_node_path8.default.resolve(root, source.path)
5889
5877
  }));
5890
5878
  const approved = {
5891
5879
  ...options,
@@ -5917,8 +5905,8 @@ async function qualitySource(spec, lock) {
5917
5905
  if (spec.generator === "frames") {
5918
5906
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
5919
5907
  }
5920
- const source = import_node_path10.default.resolve(spec.root, spec.source);
5921
- return (0, import_node_fs8.existsSync)(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5908
+ const source = import_node_path9.default.resolve(spec.root, spec.source);
5909
+ return (0, import_node_fs7.existsSync)(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5922
5910
  }
5923
5911
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5924
5912
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -5947,7 +5935,7 @@ async function qualitySource(spec, lock) {
5947
5935
  }
5948
5936
  if (role) roles.add(role);
5949
5937
  const source = currentEntryOutputPath(entry, spec, index);
5950
- if (!(0, import_node_fs8.existsSync)(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5938
+ if (!(0, import_node_fs7.existsSync)(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5951
5939
  if (await sha256File(source) !== output.sha256) {
5952
5940
  return { items: null, reason: "raw provider output was modified after download" };
5953
5941
  }
@@ -5966,7 +5954,7 @@ async function inspectQualityProfile(spec, lock) {
5966
5954
  }
5967
5955
  const sourcePaths = source.items.map((item) => item.path);
5968
5956
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
5969
- if (!(0, import_node_fs8.existsSync)(record)) {
5957
+ if (!(0, import_node_fs7.existsSync)(record)) {
5970
5958
  return {
5971
5959
  key,
5972
5960
  state: "needs-refinement",
@@ -5984,7 +5972,7 @@ async function inspectQualityProfile(spec, lock) {
5984
5972
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
5985
5973
  const expectedTransparency = spec.quality.minTransparency ?? null;
5986
5974
  const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
5987
- const contractChanged = JSON.stringify(check.sources.map((item) => import_node_path10.default.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => import_node_path10.default.resolve(item))) || JSON.stringify(check.outputs.map((item) => import_node_path10.default.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => import_node_path10.default.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);
5975
+ const contractChanged = JSON.stringify(check.sources.map((item) => import_node_path9.default.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => import_node_path9.default.resolve(item))) || JSON.stringify(check.outputs.map((item) => import_node_path9.default.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => import_node_path9.default.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);
5988
5976
  if (contractChanged) {
5989
5977
  return {
5990
5978
  key,
@@ -6146,9 +6134,38 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
6146
6134
  return sources;
6147
6135
  }
6148
6136
 
6137
+ // src/pipeline/postprocess.ts
6138
+ function postprocessFor(spec) {
6139
+ if (!spec.enforcePalette || spec.palette.length < 2) return void 0;
6140
+ return {
6141
+ palette: {
6142
+ colors: normalizePalette(spec.palette).map((color) => color.hex),
6143
+ dither: "none",
6144
+ distance: "redmean"
6145
+ }
6146
+ };
6147
+ }
6148
+ function samePostprocess(a, b) {
6149
+ const pa = a?.palette;
6150
+ const pb = b?.palette;
6151
+ if (!pa && !pb) return true;
6152
+ if (!pa || !pb) return false;
6153
+ return pa.dither === pb.dither && pa.distance === pb.distance && pa.colors.length === pb.colors.length && pa.colors.every((color, i) => color === pb.colors[i]);
6154
+ }
6155
+ function postprocessCurrent(entry, spec) {
6156
+ return samePostprocess(entry.postprocess, postprocessFor(spec));
6157
+ }
6158
+ function applyPostprocess(bytes, mediaType, record) {
6159
+ if (!record?.palette || mediaType !== MediaType.PNG) return { bytes, changed: false };
6160
+ const png = decodePng(bytes);
6161
+ const { png: snapped, changed } = quantize(png, record.palette.colors);
6162
+ if (!changed) return { bytes, changed: false };
6163
+ return { bytes: encodeRgbaPng(snapped.width, snapped.height, snapped.pixels), changed: true };
6164
+ }
6165
+
6149
6166
  // src/pipeline/revision.ts
6150
- var import_node_fs9 = require("fs");
6151
- var import_node_path11 = __toESM(require("path"), 1);
6167
+ var import_node_fs8 = require("fs");
6168
+ var import_node_path10 = __toESM(require("path"), 1);
6152
6169
  async function inspectRevisionReadiness(spec, lock) {
6153
6170
  if (!spec.revision) return null;
6154
6171
  return inspectRevision(spec, lock, /* @__PURE__ */ new Set());
@@ -6163,14 +6180,14 @@ async function inspectRevision(spec, lock, seen) {
6163
6180
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
6164
6181
  };
6165
6182
  }
6166
- if (!revision.sourceSha256 || !(0, import_node_fs9.existsSync)(revision.sourceFile)) {
6183
+ if (!revision.sourceSha256 || !(0, import_node_fs8.existsSync)(revision.sourceFile)) {
6167
6184
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
6168
6185
  }
6169
6186
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
6170
6187
  return { ready: false, reason: "revision source changed after the manifest was resolved" };
6171
6188
  }
6172
6189
  if (revision.maskFile) {
6173
- if (!revision.maskSha256 || !(0, import_node_fs9.existsSync)(revision.maskFile)) {
6190
+ if (!revision.maskSha256 || !(0, import_node_fs8.existsSync)(revision.maskFile)) {
6174
6191
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
6175
6192
  }
6176
6193
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -6200,8 +6217,8 @@ async function inspectParent(spec, lock, seen) {
6200
6217
  };
6201
6218
  }
6202
6219
  if (spec.source) {
6203
- const source = import_node_path11.default.resolve(spec.root, spec.source);
6204
- return (0, import_node_fs9.existsSync)(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
6220
+ const source = import_node_path10.default.resolve(spec.root, spec.source);
6221
+ return (0, import_node_fs8.existsSync)(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
6205
6222
  }
6206
6223
  const entry = lock.entries[key];
6207
6224
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -6215,7 +6232,7 @@ async function inspectParent(spec, lock, seen) {
6215
6232
  for (let index = 0; index < entry.outputs.length; index++) {
6216
6233
  const output = entry.outputs[index];
6217
6234
  const file = currentEntryOutputPath(entry, spec, index);
6218
- if (!(0, import_node_fs9.existsSync)(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6235
+ if (!(0, import_node_fs8.existsSync)(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6219
6236
  if (await sha256File(file) !== output.sha256) {
6220
6237
  return { ready: false, reason: `parent output was modified: ${file}` };
6221
6238
  }
@@ -6244,7 +6261,7 @@ function resumeActions(specs, lock) {
6244
6261
  const entry = lock.entries[key];
6245
6262
  if (!entry || entry.specHash !== spec.specHash) continue;
6246
6263
  if (entry.submissionComplete === false) continue;
6247
- const command = resumeCommandForStatus(entry.status);
6264
+ const command = resumeCommandForStatus(entry.status) ?? (entry.status === "downloaded" && entry.outputs.length && !postprocessCurrent(entry, spec) ? "fetch" : null);
6248
6265
  if (!command) continue;
6249
6266
  if (command === "poll" && !entry.jobId) continue;
6250
6267
  if (command === "pick" && !entry.reviewObjectId) continue;
@@ -6270,7 +6287,7 @@ async function buildPlan(specs, lock, opts = {}) {
6270
6287
  state = "missing";
6271
6288
  reason = "--force";
6272
6289
  } else if (!entry && spec.source && spec.generator !== "frames") {
6273
- if ((0, import_node_fs10.existsSync)(import_node_path12.default.resolve(spec.root, spec.source))) {
6290
+ if ((0, import_node_fs9.existsSync)(import_node_path11.default.resolve(spec.root, spec.source))) {
6274
6291
  state = "ok";
6275
6292
  reason = `placed from ${spec.source}; not generated`;
6276
6293
  } else {
@@ -6278,7 +6295,7 @@ async function buildPlan(specs, lock, opts = {}) {
6278
6295
  reason = `declared source is missing: ${spec.source}`;
6279
6296
  }
6280
6297
  } else if (!entry) {
6281
- if ((0, import_node_fs10.existsSync)(spec.outFile)) {
6298
+ if ((0, import_node_fs9.existsSync)(spec.outFile)) {
6282
6299
  state = "untracked";
6283
6300
  reason = "file on disk, no provenance recorded";
6284
6301
  } else {
@@ -6310,12 +6327,15 @@ async function buildPlan(specs, lock, opts = {}) {
6310
6327
  } else if (entry.status !== "downloaded") {
6311
6328
  state = "in-flight";
6312
6329
  reason = `awaiting ${entry.status}`;
6313
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !(0, import_node_fs10.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
6330
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !(0, import_node_fs9.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
6314
6331
  state = "orphaned";
6315
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !(0, import_node_fs10.existsSync)(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6332
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !(0, import_node_fs9.existsSync)(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6316
6333
  } else if (await anyOutputModified(entry, spec)) {
6317
6334
  state = "orphaned";
6318
6335
  reason = "output modified since download";
6336
+ } else if (!postprocessCurrent(entry, spec)) {
6337
+ state = "recoverable";
6338
+ 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)";
6319
6339
  } else {
6320
6340
  state = "ok";
6321
6341
  reason = "up to date";
@@ -6378,13 +6398,13 @@ function summarize(plan) {
6378
6398
 
6379
6399
  // src/project.ts
6380
6400
  async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {}) {
6381
- const absolute = import_node_path13.default.resolve(manifestPath);
6382
- const root = import_node_path13.default.dirname(absolute);
6401
+ const absolute = import_node_path12.default.resolve(manifestPath);
6402
+ const root = import_node_path12.default.dirname(absolute);
6383
6403
  if (opts.env !== false) {
6384
6404
  loadEnvFiles(root);
6385
- if (import_node_path13.default.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
6405
+ if (import_node_path12.default.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
6386
6406
  }
6387
- const lockPath = import_node_path13.default.resolve(opts.lockPath ?? import_node_path13.default.join(root, "pixelkiln.lock.json"));
6407
+ const lockPath = import_node_path12.default.resolve(opts.lockPath ?? import_node_path12.default.join(root, "pixelkiln.lock.json"));
6388
6408
  const loaded = await loadManifest(absolute);
6389
6409
  const specs = await resolveSpecs(loaded, { styles: opts.styles ?? [], assets: opts.assets ?? [] });
6390
6410
  const lock = await loadLock(lockPath);
@@ -6451,9 +6471,9 @@ function shouldPersistSourceUrl(value) {
6451
6471
  }
6452
6472
 
6453
6473
  // src/recipes.ts
6454
- var import_node_path14 = __toESM(require("path"), 1);
6455
- var import_node_fs11 = require("fs");
6456
- var import_promises7 = require("fs/promises");
6474
+ var import_node_path13 = __toESM(require("path"), 1);
6475
+ var import_node_fs10 = require("fs");
6476
+ var import_promises6 = require("fs/promises");
6457
6477
  var import_node_url = require("url");
6458
6478
  var import_zod3 = require("zod");
6459
6479
  var SHA256_RE = /^[0-9a-f]{64}$/;
@@ -6462,7 +6482,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
6462
6482
  var RECIPE_FILE = "pixelkiln.recipe.json";
6463
6483
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
6464
6484
  var PortablePathSchema = import_zod3.z.string().min(1).refine((value) => {
6465
- if (value.includes("\\") || import_node_path14.default.posix.isAbsolute(value)) return false;
6485
+ if (value.includes("\\") || import_node_path13.default.posix.isAbsolute(value)) return false;
6466
6486
  const parts = value.split("/");
6467
6487
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
6468
6488
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -6638,10 +6658,10 @@ function message3(error) {
6638
6658
  return error instanceof Error ? error.message : String(error);
6639
6659
  }
6640
6660
  async function readRecipeFile(recipePath, bundled) {
6641
- const absolute = import_node_path14.default.resolve(recipePath);
6661
+ const absolute = import_node_path13.default.resolve(recipePath);
6642
6662
  let raw;
6643
6663
  try {
6644
- raw = JSON.parse(await (0, import_promises7.readFile)(absolute, "utf8"));
6664
+ raw = JSON.parse(await (0, import_promises6.readFile)(absolute, "utf8"));
6645
6665
  } catch (error) {
6646
6666
  throw new Error(`Could not read recipe ${absolute}: ${message3(error)}`, { cause: error });
6647
6667
  }
@@ -6652,13 +6672,13 @@ async function readRecipeFile(recipePath, bundled) {
6652
6672
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
6653
6673
  );
6654
6674
  }
6655
- return { recipe: parsed.data, path: absolute, dir: import_node_path14.default.dirname(absolute), bundled };
6675
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: import_node_path13.default.dirname(absolute), bundled };
6656
6676
  }
6657
6677
  async function walkRecipeFiles(root) {
6658
- if (!(0, import_node_fs11.existsSync)(root)) return [];
6678
+ if (!(0, import_node_fs10.existsSync)(root)) return [];
6659
6679
  const out = [];
6660
- for (const entry of await (0, import_promises7.readdir)(root, { withFileTypes: true })) {
6661
- const resolved = import_node_path14.default.join(root, entry.name);
6680
+ for (const entry of await (0, import_promises6.readdir)(root, { withFileTypes: true })) {
6681
+ const resolved = import_node_path13.default.join(root, entry.name);
6662
6682
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
6663
6683
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
6664
6684
  }
@@ -6669,7 +6689,7 @@ function bundledRecipeRoot() {
6669
6689
  }
6670
6690
  async function listBundledRecipes(root = bundledRecipeRoot()) {
6671
6691
  const recipes = await Promise.all(
6672
- (await walkRecipeFiles(import_node_path14.default.resolve(root))).map((file) => readRecipeFile(file, true))
6692
+ (await walkRecipeFiles(import_node_path13.default.resolve(root))).map((file) => readRecipeFile(file, true))
6673
6693
  );
6674
6694
  return recipes.sort(
6675
6695
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -6692,9 +6712,9 @@ function parseSelector(selector) {
6692
6712
  return { id: match[1], version: match[2] };
6693
6713
  }
6694
6714
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6695
- const local = import_node_path14.default.resolve(target);
6696
- if ((0, import_node_fs11.existsSync)(local)) {
6697
- const recipePath = import_node_path14.default.basename(local) === RECIPE_FILE ? local : import_node_path14.default.join(local, RECIPE_FILE);
6715
+ const local = import_node_path13.default.resolve(target);
6716
+ if ((0, import_node_fs10.existsSync)(local)) {
6717
+ const recipePath = import_node_path13.default.basename(local) === RECIPE_FILE ? local : import_node_path13.default.join(local, RECIPE_FILE);
6698
6718
  return readRecipeFile(recipePath, false);
6699
6719
  }
6700
6720
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -6708,8 +6728,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6708
6728
  return matches[0];
6709
6729
  }
6710
6730
  async function verifyFile(root, file) {
6711
- const absolute = import_node_path14.default.resolve(root, ...file.path.split("/"));
6712
- if (!(0, import_node_fs11.existsSync)(absolute)) {
6731
+ const absolute = import_node_path13.default.resolve(root, ...file.path.split("/"));
6732
+ if (!(0, import_node_fs10.existsSync)(absolute)) {
6713
6733
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
6714
6734
  }
6715
6735
  const actualSha256 = await sha256File(absolute);
@@ -6722,9 +6742,9 @@ async function verifyFile(root, file) {
6722
6742
  }
6723
6743
  async function verifyRecipe(target, options = {}) {
6724
6744
  const loaded = await resolveRecipe(target, options.bundledRoot);
6725
- const actualIntegrity = recipeDigest(loaded.recipe);
6745
+ const actualIntegrity = recipeDigest(loaded.authored);
6726
6746
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
6727
- const modelRoot = options.modelRoot ? import_node_path14.default.resolve(options.modelRoot) : null;
6747
+ const modelRoot = options.modelRoot ? import_node_path13.default.resolve(options.modelRoot) : null;
6728
6748
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
6729
6749
  ...await verifyFile(modelRoot, model),
6730
6750
  source: model.source,
@@ -6758,7 +6778,7 @@ async function verifyRecipe(target, options = {}) {
6758
6778
  };
6759
6779
  }
6760
6780
  function renderStyle(style, destination, cwd) {
6761
- const portableDir = import_node_path14.default.relative(cwd, destination).split(import_node_path14.default.sep).join("/") || ".";
6781
+ const portableDir = import_node_path13.default.relative(cwd, destination).split(import_node_path13.default.sep).join("/") || ".";
6762
6782
  const replace = (value) => {
6763
6783
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
6764
6784
  if (Array.isArray(value)) return value.map(replace);
@@ -6770,28 +6790,29 @@ function renderStyle(style, destination, cwd) {
6770
6790
  return replace(style);
6771
6791
  }
6772
6792
  async function installRecipe(target, options = {}) {
6773
- const cwd = import_node_path14.default.resolve(options.cwd ?? process.cwd());
6793
+ const cwd = import_node_path13.default.resolve(options.cwd ?? process.cwd());
6774
6794
  const loaded = await resolveRecipe(target, options.bundledRoot);
6775
6795
  const verification = await verifyRecipe(loaded.path);
6776
6796
  if (!verification.ok) {
6777
6797
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
6778
6798
  }
6779
- const destination = options.out ? import_node_path14.default.resolve(cwd, options.out) : import_node_path14.default.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6799
+ const destination = options.out ? import_node_path13.default.resolve(cwd, options.out) : import_node_path13.default.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6780
6800
  const sources = [
6781
6801
  { path: RECIPE_FILE, source: loaded.path },
6782
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: import_node_path14.default.join(loaded.dir, ...file.path.split("/")) }))
6802
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: import_node_path13.default.join(loaded.dir, ...file.path.split("/")) }))
6783
6803
  ];
6784
6804
  const files = await Promise.all(sources.map(async (file) => ({
6785
- path: import_node_path14.default.join(destination, ...file.path.split("/")),
6786
- data: await (0, import_promises7.readFile)(file.source)
6805
+ path: import_node_path13.default.join(destination, ...file.path.split("/")),
6806
+ data: await (0, import_promises6.readFile)(file.source)
6787
6807
  })));
6788
6808
  if (!options.force) {
6789
6809
  for (const file of files) {
6790
- if (!(0, import_node_fs11.existsSync)(file.path)) continue;
6791
- const current = await (0, import_promises7.readFile)(file.path);
6810
+ if (!(0, import_node_fs10.existsSync)(file.path)) continue;
6811
+ const current = await (0, import_promises6.readFile)(file.path);
6792
6812
  if (!current.equals(file.data)) {
6793
- throw new Error(
6794
- `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`
6813
+ throw new OverwriteRefusedError(
6814
+ `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
6815
+ [file.path]
6795
6816
  );
6796
6817
  }
6797
6818
  }
@@ -6810,6 +6831,152 @@ async function installRecipe(target, options = {}) {
6810
6831
  };
6811
6832
  }
6812
6833
 
6834
+ // src/pipeline/audit.ts
6835
+ var import_promises7 = require("fs/promises");
6836
+ var import_node_fs11 = require("fs");
6837
+ var import_node_path14 = __toESM(require("path"), 1);
6838
+ function paletteDistance(asset, reference) {
6839
+ if (!asset.length || !reference.length) return 0;
6840
+ let total = 0;
6841
+ let weight = 0;
6842
+ for (const color of asset) {
6843
+ let nearest = Infinity;
6844
+ for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
6845
+ total += nearest * color.weight;
6846
+ weight += color.weight;
6847
+ }
6848
+ return weight === 0 ? 0 : total / weight;
6849
+ }
6850
+ function mergePalettes(palettes, topN = 24) {
6851
+ const totals = /* @__PURE__ */ new Map();
6852
+ for (const palette of palettes) {
6853
+ for (const color of palette) {
6854
+ const key = `${color.r},${color.g},${color.b}`;
6855
+ const existing = totals.get(key);
6856
+ if (existing) existing.weight += color.weight;
6857
+ else totals.set(key, { ...color });
6858
+ }
6859
+ }
6860
+ const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
6861
+ const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
6862
+ return merged.map((c) => ({ ...c, weight: c.weight / sum }));
6863
+ }
6864
+ async function auditStyle(loaded, specs, styleId, lock) {
6865
+ const style = loaded.manifest.styles[styleId];
6866
+ if (!style) throw new Error(`Unknown style "${styleId}"`);
6867
+ const mine = specs.filter((s) => s.styleId === styleId);
6868
+ const assets = [];
6869
+ const missing = [];
6870
+ const unreadable = [];
6871
+ for (const spec of mine) {
6872
+ for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
6873
+ if (!(0, import_node_fs11.existsSync)(output.absolutePath)) {
6874
+ missing.push(output.id);
6875
+ continue;
6876
+ }
6877
+ try {
6878
+ const png = decodePng(await (0, import_promises7.readFile)(output.absolutePath));
6879
+ const palette = extractPalette(png, 12);
6880
+ assets.push({
6881
+ assetId: spec.assetId,
6882
+ ...output.role ? { outputRole: output.role } : {},
6883
+ id: output.id,
6884
+ file: output.absolutePath,
6885
+ width: png.width,
6886
+ height: png.height,
6887
+ paletteDistance: 0,
6888
+ // filled once the reference is known
6889
+ transparency: transparencyRatio(png),
6890
+ colorCount: countColors(png),
6891
+ palette
6892
+ });
6893
+ } catch (err) {
6894
+ unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
6895
+ }
6896
+ }
6897
+ }
6898
+ let reference = [];
6899
+ let referenceFromStyleImages = false;
6900
+ const refPalettes = [];
6901
+ for (const img of style.styleImages) {
6902
+ const abs = import_node_path14.default.resolve(loaded.root, img.path);
6903
+ if (!(0, import_node_fs11.existsSync)(abs)) continue;
6904
+ try {
6905
+ refPalettes.push(extractPalette(decodePng(await (0, import_promises7.readFile)(abs)), 12));
6906
+ } catch {
6907
+ }
6908
+ }
6909
+ if (refPalettes.length) {
6910
+ reference = mergePalettes(refPalettes);
6911
+ referenceFromStyleImages = true;
6912
+ for (const asset of assets) {
6913
+ asset.paletteDistance = paletteDistance(asset.palette, reference);
6914
+ }
6915
+ } else {
6916
+ reference = mergePalettes(assets.map((a) => a.palette));
6917
+ for (const asset of assets) {
6918
+ const others = assets.filter((a) => a !== asset).map((a) => a.palette);
6919
+ asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
6920
+ }
6921
+ }
6922
+ assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
6923
+ return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
6924
+ }
6925
+ function countColors(png) {
6926
+ const seen = /* @__PURE__ */ new Set();
6927
+ for (let i = 0; i < png.pixels.length; i += 4) {
6928
+ if (png.pixels[i + 3] < 128) continue;
6929
+ seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
6930
+ }
6931
+ return seen.size;
6932
+ }
6933
+ function outliers(audit, sigma = 1.5) {
6934
+ const n = audit.assets.length;
6935
+ if (n < 3) return [];
6936
+ const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
6937
+ const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
6938
+ const sd = Math.sqrt(variance);
6939
+ if (sd === 0) return [];
6940
+ return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
6941
+ }
6942
+ function evaluateAudit(audit, thresholds = {}) {
6943
+ const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
6944
+ const relative = outliers(audit, resolved.sigma);
6945
+ const relativeIds = new Set(relative.map((asset) => asset.id));
6946
+ const violations = [];
6947
+ for (const asset of audit.assets) {
6948
+ const reasons = [];
6949
+ if (relativeIds.has(asset.id)) {
6950
+ reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
6951
+ }
6952
+ if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
6953
+ reasons.push(
6954
+ `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
6955
+ );
6956
+ }
6957
+ if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
6958
+ reasons.push(
6959
+ `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
6960
+ );
6961
+ }
6962
+ if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
6963
+ reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
6964
+ }
6965
+ if (reasons.length) violations.push({ id: asset.id, reasons });
6966
+ }
6967
+ return {
6968
+ safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
6969
+ thresholds: resolved,
6970
+ outliers: [...relativeIds],
6971
+ violations,
6972
+ missing: [...audit.missing],
6973
+ unreadable: [...audit.unreadable]
6974
+ };
6975
+ }
6976
+ function hex(c) {
6977
+ return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
6978
+ }
6979
+
6813
6980
  // src/pipeline/quality-regression.ts
6814
6981
  var import_node_path15 = __toESM(require("path"), 1);
6815
6982
  var import_node_fs12 = require("fs");
@@ -7058,7 +7225,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
7058
7225
  if ((0, import_node_fs12.existsSync)(absolute) && !options.force) {
7059
7226
  const current = await (0, import_promises8.readFile)(absolute);
7060
7227
  if (!current.equals(data)) {
7061
- throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
7228
+ throw new OverwriteRefusedError(
7229
+ `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
7230
+ [absolute]
7231
+ );
7062
7232
  }
7063
7233
  }
7064
7234
  const result = await writeArtifactBundle([{ path: absolute, data }]);
@@ -7279,8 +7449,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7279
7449
  if (e.status === "selected" || e.status === "download-failed") return true;
7280
7450
  if (e.status !== "downloaded") return false;
7281
7451
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7282
- if (!opts.repair) return false;
7283
7452
  const spec = specByKey.get(key);
7453
+ if (spec && e.specHash === spec.specHash && e.outputs.length && !postprocessCurrent(e, spec)) {
7454
+ return Boolean(opts.force) || e.outputs.every((output) => {
7455
+ const file = resolveOutputPath(output.path, spec.root);
7456
+ return (0, import_node_fs14.existsSync)(file) && sha256((0, import_node_fs14.readFileSync)(file)) === output.sha256;
7457
+ });
7458
+ }
7459
+ if (!opts.repair) return false;
7284
7460
  if (e.outputs.length === 0 || !spec) return true;
7285
7461
  return e.outputs.some((output) => {
7286
7462
  const file = resolveOutputPath(output.path, spec.root);
@@ -7319,6 +7495,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7319
7495
  }
7320
7496
  const outputs = [];
7321
7497
  let wrote = false;
7498
+ const wanted = postprocessFor(spec);
7499
+ const reapply = entry.status === "downloaded" && !postprocessCurrent(entry, spec);
7322
7500
  try {
7323
7501
  for (let index = 0; index < sources.length; index++) {
7324
7502
  const source = sources[index];
@@ -7326,20 +7504,24 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7326
7504
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
7327
7505
  );
7328
7506
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
7507
+ const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7508
+ const recordedRaw = recorded?.raw ?? recorded?.sha256;
7329
7509
  let fresh = null;
7330
7510
  if (opts.refresh) {
7331
7511
  if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
7332
7512
  fresh = await provider.download(source.url);
7333
- if (recorded && sha256(fresh) === recorded.sha256) {
7513
+ if (recorded && sha256(fresh) === recordedRaw && !reapply) {
7334
7514
  log(` current ${import_node_path17.default.relative(process.cwd(), target)}`);
7335
7515
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7336
7516
  continue;
7337
7517
  }
7338
7518
  }
7519
+ let raw = null;
7520
+ let final = null;
7339
7521
  if ((0, import_node_fs14.existsSync)(target)) {
7340
7522
  const currentHash = await sha256File(target);
7341
7523
  if (recorded && currentHash === recorded.sha256) {
7342
- if (!fresh) {
7524
+ if (!fresh && !reapply) {
7343
7525
  if (cacheDir) {
7344
7526
  await cacheMedia(
7345
7527
  cacheDir,
@@ -7351,7 +7533,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7351
7533
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7352
7534
  continue;
7353
7535
  }
7354
- log(` changed ${import_node_path17.default.relative(process.cwd(), target)} (upstream)`);
7536
+ if (reapply && !fresh) {
7537
+ if (!recorded.raw) raw = await (0, import_promises10.readFile)(target);
7538
+ log(` reapply ${import_node_path17.default.relative(process.cwd(), target)}`);
7539
+ } else {
7540
+ log(` changed ${import_node_path17.default.relative(process.cwd(), target)} (upstream)`);
7541
+ }
7355
7542
  } else {
7356
7543
  const superseded = (entry.supersededOutputs ?? []).find(
7357
7544
  (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
@@ -7373,26 +7560,41 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7373
7560
  );
7374
7561
  }
7375
7562
  }
7376
- const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7377
- let buf = fresh ?? (recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null);
7378
- if (buf) {
7379
- log(` cached ${import_node_path17.default.relative(process.cwd(), target)}`);
7380
- } else {
7563
+ if (fresh) {
7564
+ raw = fresh;
7565
+ } else if (!raw && recorded && cacheDir) {
7566
+ if (!reapply) final = await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType);
7567
+ if (!final && recordedRaw) raw = await readCachedMedia(cacheDir, recordedRaw, expectedMediaType);
7568
+ if (final || raw) log(` cached ${import_node_path17.default.relative(process.cwd(), target)}`);
7569
+ }
7570
+ if (!final && !raw) {
7381
7571
  if (!source.url) {
7382
7572
  throw new Error(`no source URL or cached bytes remain for ${source.role ?? "asset"}`);
7383
7573
  }
7384
- buf = await provider.download(source.url);
7574
+ raw = await provider.download(source.url);
7385
7575
  }
7386
7576
  let mediaType;
7577
+ const bytes = final ?? raw;
7387
7578
  try {
7388
- mediaType = validateMedia(buf, expectedMediaType);
7579
+ mediaType = validateMedia(bytes, expectedMediaType);
7389
7580
  } catch (err) {
7390
7581
  const label = expectedMediaType === MediaType.GIF ? "GIF" : "PNG";
7391
- const mismatch = detectMediaType(buf) !== expectedMediaType;
7582
+ const mismatch = detectMediaType(bytes) !== expectedMediaType;
7392
7583
  throw new Error(
7393
- `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${buf.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
7584
+ `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${bytes.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
7394
7585
  );
7395
7586
  }
7587
+ let rawHash = final ? recorded?.raw : void 0;
7588
+ let buf = bytes;
7589
+ if (!final) {
7590
+ if (cacheDir) await cacheMedia(cacheDir, raw, mediaType);
7591
+ const processed = applyPostprocess(raw, mediaType, wanted);
7592
+ buf = processed.bytes;
7593
+ if (processed.changed) {
7594
+ rawHash = sha256(raw);
7595
+ log(` snapped ${import_node_path17.default.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
7596
+ }
7597
+ }
7396
7598
  if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
7397
7599
  await (0, import_promises10.mkdir)(import_node_path17.default.dirname(target), { recursive: true });
7398
7600
  const tmp = `${target}.pixelkiln.tmp`;
@@ -7401,7 +7603,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7401
7603
  path: portableOutputPath(target, spec.root),
7402
7604
  sha256: sha256(buf),
7403
7605
  ...source.role ? { role: source.role } : {},
7404
- mediaType
7606
+ mediaType,
7607
+ ...rawHash ? { raw: rawHash } : {}
7405
7608
  });
7406
7609
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
7407
7610
  await saveLock(lockPath, lock);
@@ -7425,7 +7628,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7425
7628
  sourceUrls: persistentSources,
7426
7629
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
7427
7630
  error: null,
7428
- supersededOutputs: []
7631
+ supersededOutputs: [],
7632
+ postprocess: wanted
7429
7633
  });
7430
7634
  result.downloaded++;
7431
7635
  } catch (err) {
@@ -7532,6 +7736,7 @@ function retireGeneration(entry, retiredAt) {
7532
7736
  cost: entry.cost,
7533
7737
  costUnit: entry.costUnit,
7534
7738
  provider: entry.provider,
7739
+ postprocess: entry.postprocess,
7535
7740
  retiredAt
7536
7741
  };
7537
7742
  }
@@ -7544,9 +7749,13 @@ function historyAfterReplacing(previous, limit, retiredAt) {
7544
7749
  }
7545
7750
  function referencedHashes(lock) {
7546
7751
  const hashes = /* @__PURE__ */ new Set();
7752
+ const add = (output) => {
7753
+ hashes.add(output.sha256);
7754
+ if (output.raw) hashes.add(output.raw);
7755
+ };
7547
7756
  for (const entry of Object.values(lock.entries)) {
7548
- for (const output of entry.outputs) hashes.add(output.sha256);
7549
- for (const generation of entry.history ?? []) for (const output of generation.outputs) hashes.add(output.sha256);
7757
+ for (const output of entry.outputs) add(output);
7758
+ for (const generation of entry.history ?? []) for (const output of generation.outputs) add(output);
7550
7759
  }
7551
7760
  return hashes;
7552
7761
  }
@@ -7583,8 +7792,9 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
7583
7792
  const file = currentEntryOutputPath(entry, spec, i);
7584
7793
  if (!(0, import_node_fs15.existsSync)(file)) continue;
7585
7794
  if (await sha256File(file) !== entry.outputs[i].sha256 && !opts.force) {
7586
- throw new Error(
7587
- `${import_node_path18.default.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`
7795
+ throw new OverwriteRefusedError(
7796
+ `${import_node_path18.default.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
7797
+ [file]
7588
7798
  );
7589
7799
  }
7590
7800
  }
@@ -7616,6 +7826,7 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
7616
7826
  cost: generation.cost,
7617
7827
  costUnit: generation.costUnit,
7618
7828
  provider: generation.provider,
7829
+ postprocess: generation.postprocess,
7619
7830
  history
7620
7831
  });
7621
7832
  await saveLock(lockPath, lock);
@@ -8334,7 +8545,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
8334
8545
  if (opts.budget != null) {
8335
8546
  const cost = [...estimates.values()].reduce((sum, estimate) => sum + estimate.amount, 0);
8336
8547
  if (cost > opts.budget) {
8337
- throw new Error(
8548
+ throw new BudgetError(
8338
8549
  `This run would spend ${formatCost(unit, cost)} but the budget is ${formatCost(unit, opts.budget)}. Narrow it with --only/--style, or raise --budget.`
8339
8550
  );
8340
8551
  }
@@ -8972,6 +9183,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
8972
9183
  promptSuffix: "",
8973
9184
  styleImages: [],
8974
9185
  palette: [],
9186
+ enforcePalette: false,
8975
9187
  outDir,
8976
9188
  tags: [name],
8977
9189
  providerOptions: {}
@@ -9661,7 +9873,7 @@ var WorkspaceSchema = import_zod6.z.object({
9661
9873
  function parseWorkspace(raw) {
9662
9874
  const parsed = WorkspaceSchema.safeParse(raw);
9663
9875
  if (parsed.success) return parsed.data;
9664
- throw new Error(
9876
+ throw new ProjectError(
9665
9877
  `Workspace catalog is not valid v1:
9666
9878
  ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
9667
9879
  );
@@ -9672,7 +9884,7 @@ async function loadWorkspace(workspacePath) {
9672
9884
  try {
9673
9885
  raw = JSON.parse(await (0, import_promises17.readFile)(workspacePath, "utf8"));
9674
9886
  } catch (err) {
9675
- throw new Error(
9887
+ throw new ProjectError(
9676
9888
  `Workspace catalog at ${workspacePath} is malformed:
9677
9889
  ${err instanceof Error ? err.message : String(err)}`
9678
9890
  );
@@ -9852,6 +10064,8 @@ var ManifestEditSchema = import_zod7.z.discriminatedUnion("action", [
9852
10064
  promptSuffix: import_zod7.z.string().optional(),
9853
10065
  /** `#rrggbb` values; null or empty clears the style's own palette. */
9854
10066
  palette: import_zod7.z.array(import_zod7.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
10067
+ /** Snap downloaded art to the palette; false clears the style's own value. */
10068
+ enforcePalette: import_zod7.z.boolean().optional(),
9855
10069
  /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
9856
10070
  view: import_zod7.z.string().max(64).nullable().optional(),
9857
10071
  /** pixflux only; null clears the style's own value. */
@@ -9943,6 +10157,7 @@ function applyEdit(raw, edit) {
9943
10157
  const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
9944
10158
  setOrDelete(style, "palette", colors.length ? colors : null);
9945
10159
  }
10160
+ if (patch2.enforcePalette !== void 0) setOrDelete(style, "enforcePalette", patch2.enforcePalette || null);
9946
10161
  if (patch2.view !== void 0) setOrDelete(style, "view", patch2.view?.trim() || null);
9947
10162
  if (patch2.noBackground !== void 0) setOrDelete(style, "noBackground", patch2.noBackground);
9948
10163
  return;
@@ -10654,6 +10869,7 @@ async function buildGallerySnapshot(opts) {
10654
10869
  outputs,
10655
10870
  quality,
10656
10871
  providerMetadata: entry?.providerMetadata ?? {},
10872
+ postprocess: entry?.postprocess ?? null,
10657
10873
  asset,
10658
10874
  source: spec.source ?? null,
10659
10875
  edit,
@@ -10714,6 +10930,7 @@ async function buildGallerySnapshot(opts) {
10714
10930
  outputs,
10715
10931
  quality: null,
10716
10932
  providerMetadata: entry.providerMetadata ?? {},
10933
+ postprocess: entry.postprocess ?? null,
10717
10934
  asset: null,
10718
10935
  source: null,
10719
10936
  edit: null,
@@ -10762,6 +10979,7 @@ async function buildGallerySnapshot(opts) {
10762
10979
  promptPrefix: style?.promptPrefix ?? "",
10763
10980
  promptSuffix: style?.promptSuffix ?? "",
10764
10981
  palette: style?.palette ?? [],
10982
+ enforcePalette: style?.enforcePalette ?? false,
10765
10983
  view: style?.view ?? null,
10766
10984
  noBackground: style?.noBackground ?? true,
10767
10985
  quality: Boolean(style?.quality),
@@ -12465,6 +12683,7 @@ async function workspaceStatus(ws, dir) {
12465
12683
  // Annotate the CommonJS export names for ESM import in node:
12466
12684
  0 && (module.exports = {
12467
12685
  AssetSchema,
12686
+ BudgetError,
12468
12687
  ComfyUIProvider,
12469
12688
  DEFAULT_HISTORY_LIMIT,
12470
12689
  DEFAULT_QUALITY_TOLERANCES,
@@ -12472,6 +12691,7 @@ async function workspaceStatus(ws, dir) {
12472
12691
  DEFAULT_TIMEOUT_MS,
12473
12692
  EDITOR_PIN,
12474
12693
  EDITOR_RELEASE_BASE,
12694
+ EXIT_CODES,
12475
12695
  EditorInstallError,
12476
12696
  FAKE_PNG,
12477
12697
  FakeProvider,
@@ -12496,11 +12716,16 @@ async function workspaceStatus(ws, dir) {
12496
12716
  ManifestInputSchema,
12497
12717
  ManifestSchema,
12498
12718
  MediaType,
12719
+ OverwriteRefusedError,
12499
12720
  PIXEL_ART_FIXER_REVISION,
12500
12721
  PIXEL_ART_FIXER_URL,
12722
+ PixelKilnError,
12501
12723
  PixelLabClient,
12502
12724
  PixelLabError,
12503
12725
  PixelLabProvider,
12726
+ PostprocessSchema,
12727
+ ProjectError,
12728
+ ProviderError,
12504
12729
  QualityBaselineSchema,
12505
12730
  QualityProfileSchema,
12506
12731
  QualityToleranceSchema,
@@ -12512,6 +12737,7 @@ async function workspaceStatus(ws, dir) {
12512
12737
  StyleInputSchema,
12513
12738
  StyleSchema,
12514
12739
  UnsupportedCapabilityError,
12740
+ UsageError,
12515
12741
  WorkspaceProjectSchema,
12516
12742
  WorkspaceSchema,
12517
12743
  adopt,
@@ -12549,7 +12775,9 @@ async function workspaceStatus(ws, dir) {
12549
12775
  editorRoute,
12550
12776
  editorStatus,
12551
12777
  editorToolsRoot,
12778
+ errorCode,
12552
12779
  evaluateAudit,
12780
+ exitCodeFor,
12553
12781
  expectedOutputPath,
12554
12782
  exportTileset,
12555
12783
  fallbackOutputRole,