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/cli.js CHANGED
@@ -1,5 +1,71 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/errors.ts
4
+ var EXIT_CODES = {
5
+ usage: 2,
6
+ project: 3,
7
+ provider: 4,
8
+ "refused-overwrite": 5,
9
+ budget: 6,
10
+ capability: 7
11
+ };
12
+ var PixelKilnError = class extends Error {
13
+ code;
14
+ hint;
15
+ constructor(message6, code, opts = {}) {
16
+ super(message6, opts.cause === void 0 ? void 0 : { cause: opts.cause });
17
+ this.name = "PixelKilnError";
18
+ this.code = code;
19
+ if (opts.hint) this.hint = opts.hint;
20
+ }
21
+ };
22
+ var UsageError = class extends PixelKilnError {
23
+ constructor(message6, opts) {
24
+ super(message6, "usage", opts);
25
+ this.name = "UsageError";
26
+ }
27
+ };
28
+ var ProjectError = class extends PixelKilnError {
29
+ constructor(message6, opts) {
30
+ super(message6, "project", opts);
31
+ this.name = "ProjectError";
32
+ }
33
+ };
34
+ var ProviderError = class extends PixelKilnError {
35
+ /** Registry id of the provider that failed. */
36
+ provider;
37
+ /** HTTP status when the failure was a response, otherwise undefined. */
38
+ status;
39
+ constructor(provider, message6, opts = {}) {
40
+ super(message6, "provider", opts);
41
+ this.name = "ProviderError";
42
+ this.provider = provider;
43
+ if (opts.status !== void 0) this.status = opts.status;
44
+ }
45
+ };
46
+ var OverwriteRefusedError = class extends PixelKilnError {
47
+ /** The file(s) left untouched. */
48
+ files;
49
+ constructor(message6, files, opts) {
50
+ super(message6, "refused-overwrite", opts);
51
+ this.name = "OverwriteRefusedError";
52
+ this.files = files;
53
+ }
54
+ };
55
+ var BudgetError = class extends PixelKilnError {
56
+ constructor(message6, opts) {
57
+ super(message6, "budget", opts);
58
+ this.name = "BudgetError";
59
+ }
60
+ };
61
+ function errorCode(err) {
62
+ return err instanceof PixelKilnError ? err.code : void 0;
63
+ }
64
+ function exitCodeFor(err) {
65
+ const code = errorCode(err);
66
+ return code ? EXIT_CODES[code] : 1;
67
+ }
68
+
3
69
  // src/cli/main.ts
4
70
  import { existsSync as existsSync31 } from "fs";
5
71
  import { readFile as readFile27 } from "fs/promises";
@@ -103,7 +169,7 @@ var TOOLS = ["editor"];
103
169
  function parseArgs(argv) {
104
170
  const [command = "help"] = argv;
105
171
  if (!COMMANDS.includes(command)) {
106
- throw new Error(`Unknown command "${command}". Run \`pixelkiln help\` for the list.`);
172
+ throw new UsageError(`Unknown command "${command}". Run \`pixelkiln help\` for the list.`);
107
173
  }
108
174
  let rest = argv.slice(1);
109
175
  let subcommand;
@@ -111,10 +177,10 @@ function parseArgs(argv) {
111
177
  if (command === "quality") {
112
178
  subcommand = rest[0];
113
179
  if (subcommand === void 0 || subcommand.startsWith("-")) {
114
- throw new Error(`quality needs a subcommand: ${QUALITY_SUBCOMMANDS.join(", ")}`);
180
+ throw new UsageError(`quality needs a subcommand: ${QUALITY_SUBCOMMANDS.join(", ")}`);
115
181
  }
116
182
  if (!QUALITY_SUBCOMMANDS.includes(subcommand)) {
117
- throw new Error(
183
+ throw new UsageError(
118
184
  `Unknown quality subcommand "${subcommand}". Known: ${QUALITY_SUBCOMMANDS.join(", ")}`
119
185
  );
120
186
  }
@@ -122,10 +188,10 @@ function parseArgs(argv) {
122
188
  } else if (command === "recipe") {
123
189
  subcommand = rest[0];
124
190
  if (subcommand === void 0 || subcommand.startsWith("-")) {
125
- throw new Error(`recipe needs a subcommand: ${RECIPE_SUBCOMMANDS.join(", ")}`);
191
+ throw new UsageError(`recipe needs a subcommand: ${RECIPE_SUBCOMMANDS.join(", ")}`);
126
192
  }
127
193
  if (!RECIPE_SUBCOMMANDS.includes(subcommand)) {
128
- throw new Error(
194
+ throw new UsageError(
129
195
  `Unknown recipe subcommand "${subcommand}". Known: ${RECIPE_SUBCOMMANDS.join(", ")}`
130
196
  );
131
197
  }
@@ -133,17 +199,17 @@ function parseArgs(argv) {
133
199
  if (subcommand !== "list") {
134
200
  target = rest[0];
135
201
  if (target === void 0 || target.startsWith("-")) {
136
- throw new Error(`recipe ${subcommand} needs a recipe id, selector, or path.`);
202
+ throw new UsageError(`recipe ${subcommand} needs a recipe id, selector, or path.`);
137
203
  }
138
204
  rest = rest.slice(1);
139
205
  }
140
206
  } else if (command === "workspace") {
141
207
  subcommand = rest[0];
142
208
  if (subcommand === void 0 || subcommand.startsWith("-")) {
143
- throw new Error(`workspace needs a subcommand: ${WORKSPACE_SUBCOMMANDS.join(", ")}`);
209
+ throw new UsageError(`workspace needs a subcommand: ${WORKSPACE_SUBCOMMANDS.join(", ")}`);
144
210
  }
145
211
  if (!WORKSPACE_SUBCOMMANDS.includes(subcommand)) {
146
- throw new Error(
212
+ throw new UsageError(
147
213
  `Unknown workspace subcommand "${subcommand}". Known: ${WORKSPACE_SUBCOMMANDS.join(", ")}`
148
214
  );
149
215
  }
@@ -151,7 +217,7 @@ function parseArgs(argv) {
151
217
  if (subcommand === "add" || subcommand === "remove") {
152
218
  target = rest[0];
153
219
  if (target === void 0 || target.startsWith("-")) {
154
- throw new Error(
220
+ throw new UsageError(
155
221
  subcommand === "add" ? "workspace add needs a manifest path." : "workspace remove needs a project id or manifest path."
156
222
  );
157
223
  }
@@ -160,29 +226,29 @@ function parseArgs(argv) {
160
226
  } else if (command === "edit") {
161
227
  subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "start" : rest[0];
162
228
  if (!EDIT_SUBCOMMANDS.includes(subcommand)) {
163
- throw new Error(`Unknown edit subcommand "${subcommand}". Known: ${EDIT_SUBCOMMANDS.join(", ")}`);
229
+ throw new UsageError(`Unknown edit subcommand "${subcommand}". Known: ${EDIT_SUBCOMMANDS.join(", ")}`);
164
230
  }
165
231
  if (rest[0] === subcommand) rest = rest.slice(1);
166
232
  } else if (command === "tools") {
167
233
  subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "status" : rest[0];
168
234
  if (!TOOLS_SUBCOMMANDS.includes(subcommand)) {
169
- throw new Error(`Unknown tools subcommand "${subcommand}". Known: ${TOOLS_SUBCOMMANDS.join(", ")}`);
235
+ throw new UsageError(`Unknown tools subcommand "${subcommand}". Known: ${TOOLS_SUBCOMMANDS.join(", ")}`);
170
236
  }
171
237
  if (rest[0] === subcommand) rest = rest.slice(1);
172
238
  target = rest[0]?.startsWith("-") ? void 0 : rest[0];
173
239
  if (subcommand === "install" && target === void 0) {
174
- throw new Error(`tools install needs a tool name: ${TOOLS.join(", ")}`);
240
+ throw new UsageError(`tools install needs a tool name: ${TOOLS.join(", ")}`);
175
241
  }
176
242
  if (target !== void 0) {
177
243
  if (!TOOLS.includes(target)) {
178
- throw new Error(`Unknown tool "${target}". Known: ${TOOLS.join(", ")}`);
244
+ throw new UsageError(`Unknown tool "${target}". Known: ${TOOLS.join(", ")}`);
179
245
  }
180
246
  rest = rest.slice(1);
181
247
  }
182
248
  } else if (command === "refine") {
183
249
  subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "run" : rest[0];
184
250
  if (!REFINE_SUBCOMMANDS.includes(subcommand)) {
185
- throw new Error(
251
+ throw new UsageError(
186
252
  `Unknown refine subcommand "${subcommand}". Known: ${REFINE_SUBCOMMANDS.join(", ")}`
187
253
  );
188
254
  }
@@ -191,19 +257,19 @@ function parseArgs(argv) {
191
257
  for (let i = 0; i < rest.length; i++) {
192
258
  const token = rest[i];
193
259
  if (!token.startsWith("-")) {
194
- throw new Error(`Unexpected argument "${token}". Options must be passed with a named flag.`);
260
+ throw new UsageError(`Unexpected argument "${token}". Options must be passed with a named flag.`);
195
261
  }
196
262
  if (BOOL_FLAGS.includes(token)) continue;
197
263
  if (VALUE_FLAGS.includes(token)) {
198
264
  const value = rest[i + 1];
199
265
  const looksLikeFlag = value !== void 0 && value.startsWith("-") && !Number.isFinite(Number(value));
200
266
  if (value === void 0 || looksLikeFlag) {
201
- throw new Error(`${token} needs a value.`);
267
+ throw new UsageError(`${token} needs a value.`);
202
268
  }
203
269
  i++;
204
270
  continue;
205
271
  }
206
- throw new Error(
272
+ throw new UsageError(
207
273
  `Unknown flag "${token}". Known flags: ${[...VALUE_FLAGS, ...BOOL_FLAGS].join(", ")}`
208
274
  );
209
275
  }
@@ -227,7 +293,7 @@ function parseArgs(argv) {
227
293
  if (rawColumns !== void 0) {
228
294
  columns = Number(rawColumns);
229
295
  if (!Number.isInteger(columns) || columns < 1 || columns > 1024) {
230
- throw new Error(`--columns must be a whole number between 1 and 1024, got "${rawColumns}"`);
296
+ throw new UsageError(`--columns must be a whole number between 1 and 1024, got "${rawColumns}"`);
231
297
  }
232
298
  }
233
299
  const rawPort = get("--port");
@@ -235,7 +301,7 @@ function parseArgs(argv) {
235
301
  if (rawPort !== void 0) {
236
302
  port = Number(rawPort);
237
303
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
238
- throw new Error(`--port must be a whole number between 1 and 65535, got "${rawPort}"`);
304
+ throw new UsageError(`--port must be a whole number between 1 and 65535, got "${rawPort}"`);
239
305
  }
240
306
  }
241
307
  let budget;
@@ -248,30 +314,30 @@ function parseArgs(argv) {
248
314
  const rawAmount = rawBudget.slice(separator + 1).trim();
249
315
  const amount2 = Number(rawAmount);
250
316
  if (!providerId || /[=\s]/.test(providerId) || !Number.isFinite(amount2) || amount2 < 0) {
251
- throw new Error(
317
+ throw new UsageError(
252
318
  `--budget must be a non-negative number or provider=number, got "${rawBudget}".`
253
319
  );
254
320
  }
255
321
  if (Object.hasOwn(providerBudgets, providerId)) {
256
- throw new Error(`--budget repeats provider "${providerId}".`);
322
+ throw new UsageError(`--budget repeats provider "${providerId}".`);
257
323
  }
258
324
  providerBudgets[providerId] = amount2;
259
325
  continue;
260
326
  }
261
327
  const amount = Number(rawBudget);
262
328
  if (!Number.isFinite(amount) || amount < 0) {
263
- throw new Error(`--budget must be a non-negative number, got "${rawBudget}".`);
329
+ throw new UsageError(`--budget must be a non-negative number, got "${rawBudget}".`);
264
330
  }
265
- if (budget !== void 0) throw new Error("Only one unkeyed --budget may be passed.");
331
+ if (budget !== void 0) throw new UsageError("Only one unkeyed --budget may be passed.");
266
332
  budget = amount;
267
333
  }
268
334
  if (budget !== void 0 && Object.keys(providerBudgets).length) {
269
- throw new Error("Do not mix an unkeyed --budget with provider-keyed budgets.");
335
+ throw new UsageError("Do not mix an unkeyed --budget with provider-keyed budgets.");
270
336
  }
271
337
  const manifest = get("--manifest") ?? "pixelkiln.manifest.json";
272
338
  const rawFormat = get("--format");
273
339
  if (rawFormat && rawFormat !== "generic" && rawFormat !== "tiled" && rawFormat !== "godot") {
274
- throw new Error(`--format must be generic, tiled, or godot, got "${rawFormat}"`);
340
+ throw new UsageError(`--format must be generic, tiled, or godot, got "${rawFormat}"`);
275
341
  }
276
342
  const numberOption = (flag, opts) => {
277
343
  const raw = get(flag);
@@ -279,13 +345,13 @@ function parseArgs(argv) {
279
345
  const value = Number(raw);
280
346
  if (!Number.isFinite(value) || value < opts.min || opts.max !== void 0 && value > opts.max || opts.integer && !Number.isInteger(value)) {
281
347
  const range = opts.max === void 0 ? `at least ${opts.min}` : `${opts.min} to ${opts.max}`;
282
- throw new Error(`${flag} must be ${opts.integer ? "a whole number " : "a number "}${range}, got "${raw}"`);
348
+ throw new UsageError(`${flag} must be ${opts.integer ? "a whole number " : "a number "}${range}, got "${raw}"`);
283
349
  }
284
350
  return value;
285
351
  };
286
352
  const rawGridConfidence = get("--min-grid-confidence");
287
353
  if (rawGridConfidence !== void 0 && rawGridConfidence !== "low" && rawGridConfidence !== "medium" && rawGridConfidence !== "high") {
288
- throw new Error(
354
+ throw new UsageError(
289
355
  `--min-grid-confidence must be low, medium, or high, got "${rawGridConfidence}"`
290
356
  );
291
357
  }
@@ -505,10 +571,11 @@ function measureBalanceChange(before, after) {
505
571
  };
506
572
  }
507
573
  var DEFAULT_RATE_LIMIT = { spacingMs: 2500, maxInFlight: 8 };
508
- var UnsupportedCapabilityError = class extends Error {
574
+ var UnsupportedCapabilityError = class extends PixelKilnError {
509
575
  constructor(providerId, capability) {
510
576
  super(
511
- `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`
577
+ `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`,
578
+ "capability"
512
579
  );
513
580
  this.name = "UnsupportedCapabilityError";
514
581
  }
@@ -539,8 +606,8 @@ function formatCost(unit, amount) {
539
606
  }
540
607
 
541
608
  // src/pipeline/plan.ts
542
- import { existsSync as existsSync6 } from "fs";
543
- import path9 from "path";
609
+ import { existsSync as existsSync5 } from "fs";
610
+ import path8 from "path";
544
611
 
545
612
  // src/hash.ts
546
613
  import { createHash } from "crypto";
@@ -776,6 +843,18 @@ var StyleObjectSchema = z.object({
776
843
  * chocolate bar.
777
844
  */
778
845
  palette: z.array(HexColorSchema).default([]),
846
+ /**
847
+ * Snap every visible pixel of the downloaded art to the nearest `palette`
848
+ * colour, without dithering. The palette is sent to providers that take
849
+ * one, and most honour it most of the time; this makes it a guarantee
850
+ * for every generator, including the ones with no palette parameter.
851
+ * Applied by `fetch` as the file is written and recorded on the lock
852
+ * entry, so a restore reproduces the same bytes. Turning it on or off
853
+ * does not change the spec hash: `plan` reports the entry as recoverable
854
+ * and the next `fetch` or `restore` re-applies it from the art already
855
+ * on disk or in the cache, spending nothing.
856
+ */
857
+ enforcePalette: z.boolean().default(false),
779
858
  /** Fail-closed native-grid, palette, and human-approval policy for derived art. */
780
859
  quality: QualityProfileSchema.optional(),
781
860
  /**
@@ -817,6 +896,13 @@ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImag
817
896
  path: ["quality"]
818
897
  });
819
898
  }
899
+ if (style.enforcePalette && style.palette.length < 2) {
900
+ ctx.addIssue({
901
+ code: z.ZodIssueCode.custom,
902
+ message: "enforcePalette needs a palette of at least two colours to snap to",
903
+ path: ["enforcePalette"]
904
+ });
905
+ }
820
906
  });
821
907
  var InheritedStyleSchema = StyleObjectSchema.partial().extend({
822
908
  extends: z.string().min(1),
@@ -939,7 +1025,21 @@ var LockOutputSchema = z.object({
939
1025
  path: z.string(),
940
1026
  sha256: z.string(),
941
1027
  role: z.string().optional(),
942
- mediaType: MediaTypeSchema.optional()
1028
+ mediaType: MediaTypeSchema.optional(),
1029
+ /**
1030
+ * Hash of the provider's bytes before post-processing changed them, when
1031
+ * it did. The content cache keeps both, so the post-processing can be
1032
+ * undone or redone without contacting the provider.
1033
+ */
1034
+ raw: z.string().optional()
1035
+ });
1036
+ var PostprocessSchema = z.object({
1037
+ palette: z.object({
1038
+ /** Canonical lowercase `#rrggbb` values, in manifest order. */
1039
+ colors: z.array(z.string()).min(2).max(256),
1040
+ dither: z.literal("none"),
1041
+ distance: z.literal("redmean")
1042
+ }).optional()
943
1043
  });
944
1044
  var LockSourceSchema = z.object({
945
1045
  url: z.string(),
@@ -965,6 +1065,7 @@ var LockHistoryEntrySchema = z.object({
965
1065
  cost: z.number().finite().nonnegative().default(0),
966
1066
  costUnit: z.string().min(1).default("generations"),
967
1067
  provider: z.string().default("pixellab"),
1068
+ postprocess: PostprocessSchema.optional(),
968
1069
  /** When the replacement was submitted. */
969
1070
  retiredAt: z.string()
970
1071
  });
@@ -1036,14 +1137,7 @@ var LockEntrySchema = z.object({
1036
1137
  * `role` labels non-primary artifacts (e.g. "portrait", "spriteframes") so a
1037
1138
  * consumer can find the one it wants without pattern-matching on paths.
1038
1139
  */
1039
- outputs: z.array(
1040
- z.object({
1041
- path: z.string(),
1042
- sha256: z.string(),
1043
- role: z.string().optional(),
1044
- mediaType: MediaTypeSchema.optional()
1045
- })
1046
- ).default([]),
1140
+ outputs: z.array(LockOutputSchema).default([]),
1047
1141
  /**
1048
1142
  * Provider-owned data retained for downstream consumers, namespaced by
1049
1143
  * provider id. PixelLab connectable sets keep their exact `tileRules` here
@@ -1059,7 +1153,9 @@ var LockEntrySchema = z.object({
1059
1153
  /** Which provider produced this. Absent on entries written before providers. */
1060
1154
  provider: z.string().default("pixellab"),
1061
1155
  /** Generations this entry replaced, newest first; see LockHistoryEntrySchema. */
1062
- history: z.array(LockHistoryEntrySchema).optional()
1156
+ history: z.array(LockHistoryEntrySchema).optional(),
1157
+ /** Post-processing applied to the recorded outputs; absent when the bytes are the provider's. */
1158
+ postprocess: PostprocessSchema.optional()
1063
1159
  });
1064
1160
  var LockSchema = z.object({
1065
1161
  version: z.literal(2),
@@ -1086,9 +1182,10 @@ async function loadLock(lockPath) {
1086
1182
  try {
1087
1183
  return parseLock(JSON.parse(await readFile(lockPath, "utf8")));
1088
1184
  } catch (err) {
1089
- throw new Error(
1185
+ throw new ProjectError(
1090
1186
  `Lockfile at ${lockPath} is malformed:
1091
- ${err instanceof Error ? err.message : String(err)}`
1187
+ ${err instanceof Error ? err.message : String(err)}`,
1188
+ { cause: err }
1092
1189
  );
1093
1190
  }
1094
1191
  }
@@ -1122,8 +1219,9 @@ async function writeLockWhileHeld(file, lock) {
1122
1219
  try {
1123
1220
  disk = parseLock(JSON.parse(await readFile(file, "utf8")));
1124
1221
  } catch (err) {
1125
- throw new Error(
1126
- `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
1222
+ throw new ProjectError(
1223
+ `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`,
1224
+ { cause: err }
1127
1225
  );
1128
1226
  }
1129
1227
  }
@@ -1807,13 +1905,13 @@ function resolveSpecOutputs(spec, lock, manifestDir) {
1807
1905
  }
1808
1906
 
1809
1907
  // src/pipeline/quality-profile.ts
1810
- import { existsSync as existsSync4 } from "fs";
1811
- import path7 from "path";
1908
+ import { existsSync as existsSync3 } from "fs";
1909
+ import path6 from "path";
1812
1910
 
1813
1911
  // src/pipeline/refine.ts
1814
- import path6 from "path";
1912
+ import path5 from "path";
1815
1913
  import { execFile } from "child_process";
1816
- import { mkdtemp, readFile as readFile4, rm as rm3 } from "fs/promises";
1914
+ import { mkdtemp, readFile as readFile3, rm as rm3 } from "fs/promises";
1817
1915
  import { tmpdir } from "os";
1818
1916
  import { promisify } from "util";
1819
1917
 
@@ -2051,10 +2149,11 @@ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, opt
2051
2149
  if (!expected || digest(current) !== expected) conflicts.push(destination);
2052
2150
  }
2053
2151
  if (conflicts.length) {
2054
- throw new Error(
2152
+ throw new OverwriteRefusedError(
2055
2153
  `Refusing to overwrite modified or unowned artifact file(s):
2056
2154
  ` + conflicts.map((file) => ` ${file}`).join("\n") + `
2057
- Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
2155
+ Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`,
2156
+ conflicts
2058
2157
  );
2059
2158
  }
2060
2159
  }
@@ -2273,10 +2372,19 @@ async function writeArtifactBundle(files, options = {}) {
2273
2372
  };
2274
2373
  }
2275
2374
 
2276
- // src/pipeline/audit.ts
2277
- import { readFile as readFile3 } from "fs/promises";
2278
- import { existsSync as existsSync3 } from "fs";
2279
- import path5 from "path";
2375
+ // src/palette.ts
2376
+ function normalizePalette(values) {
2377
+ const colors = /* @__PURE__ */ new Map();
2378
+ for (const value of values) {
2379
+ const rgb = parseHex(value);
2380
+ const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
2381
+ if (!colors.has(hex2)) colors.set(hex2, { hex: hex2, ...rgb });
2382
+ }
2383
+ if (colors.size < 2 || colors.size > 256) {
2384
+ throw new Error(`A palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
2385
+ }
2386
+ return [...colors.values()];
2387
+ }
2280
2388
  function colorDistance(a, b) {
2281
2389
  const rmean = (a.r + b.r) / 2;
2282
2390
  const dr = a.r - b.r;
@@ -2286,146 +2394,45 @@ function colorDistance(a, b) {
2286
2394
  (512 + rmean) * dr * dr / 256 + 4 * dg * dg + (767 - rmean) * db * db / 256
2287
2395
  );
2288
2396
  }
2289
- function paletteDistance(asset, reference) {
2290
- if (!asset.length || !reference.length) return 0;
2291
- let total = 0;
2292
- let weight = 0;
2293
- for (const color of asset) {
2294
- let nearest = Infinity;
2295
- for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
2296
- total += nearest * color.weight;
2297
- weight += color.weight;
2298
- }
2299
- return weight === 0 ? 0 : total / weight;
2300
- }
2301
- function mergePalettes(palettes, topN = 24) {
2302
- const totals = /* @__PURE__ */ new Map();
2303
- for (const palette of palettes) {
2304
- for (const color of palette) {
2305
- const key = `${color.r},${color.g},${color.b}`;
2306
- const existing = totals.get(key);
2307
- if (existing) existing.weight += color.weight;
2308
- else totals.set(key, { ...color });
2397
+ function quantize(png, palette) {
2398
+ const colors = normalizePalette(palette);
2399
+ const pixels = Buffer.from(png.pixels);
2400
+ const nearestOf = /* @__PURE__ */ new Map();
2401
+ let changed = 0;
2402
+ for (let i = 0; i < pixels.length; i += 4) {
2403
+ const alpha = pixels[i + 3];
2404
+ if (alpha === 0) {
2405
+ if (pixels[i] || pixels[i + 1] || pixels[i + 2]) changed++;
2406
+ pixels[i] = 0;
2407
+ pixels[i + 1] = 0;
2408
+ pixels[i + 2] = 0;
2409
+ continue;
2309
2410
  }
2310
- }
2311
- const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
2312
- const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
2313
- return merged.map((c) => ({ ...c, weight: c.weight / sum }));
2314
- }
2315
- async function auditStyle(loaded, specs, styleId, lock) {
2316
- const style = loaded.manifest.styles[styleId];
2317
- if (!style) throw new Error(`Unknown style "${styleId}"`);
2318
- const mine = specs.filter((s) => s.styleId === styleId);
2319
- const assets = [];
2320
- const missing = [];
2321
- const unreadable = [];
2322
- for (const spec of mine) {
2323
- for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
2324
- if (!existsSync3(output.absolutePath)) {
2325
- missing.push(output.id);
2326
- continue;
2327
- }
2328
- try {
2329
- const png = decodePng(await readFile3(output.absolutePath));
2330
- const palette = extractPalette(png, 12);
2331
- assets.push({
2332
- assetId: spec.assetId,
2333
- ...output.role ? { outputRole: output.role } : {},
2334
- id: output.id,
2335
- file: output.absolutePath,
2336
- width: png.width,
2337
- height: png.height,
2338
- paletteDistance: 0,
2339
- // filled once the reference is known
2340
- transparency: transparencyRatio(png),
2341
- colorCount: countColors(png),
2342
- palette
2343
- });
2344
- } catch (err) {
2345
- unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
2411
+ const key = pixels[i] << 16 | pixels[i + 1] << 8 | pixels[i + 2];
2412
+ let nearest = nearestOf.get(key);
2413
+ if (!nearest) {
2414
+ const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
2415
+ nearest = colors[0];
2416
+ let distance = colorDistance(pixel, nearest);
2417
+ for (let j = 1; j < colors.length; j++) {
2418
+ const candidate = colors[j];
2419
+ const candidateDistance = colorDistance(pixel, candidate);
2420
+ if (candidateDistance < distance) {
2421
+ nearest = candidate;
2422
+ distance = candidateDistance;
2423
+ }
2346
2424
  }
2425
+ nearestOf.set(key, nearest);
2347
2426
  }
2427
+ if (pixels[i] !== nearest.r || pixels[i + 1] !== nearest.g || pixels[i + 2] !== nearest.b) changed++;
2428
+ pixels[i] = nearest.r;
2429
+ pixels[i + 1] = nearest.g;
2430
+ pixels[i + 2] = nearest.b;
2348
2431
  }
2349
- let reference = [];
2350
- let referenceFromStyleImages = false;
2351
- const refPalettes = [];
2352
- for (const img of style.styleImages) {
2353
- const abs = path5.resolve(loaded.root, img.path);
2354
- if (!existsSync3(abs)) continue;
2355
- try {
2356
- refPalettes.push(extractPalette(decodePng(await readFile3(abs)), 12));
2357
- } catch {
2358
- }
2359
- }
2360
- if (refPalettes.length) {
2361
- reference = mergePalettes(refPalettes);
2362
- referenceFromStyleImages = true;
2363
- for (const asset of assets) {
2364
- asset.paletteDistance = paletteDistance(asset.palette, reference);
2365
- }
2366
- } else {
2367
- reference = mergePalettes(assets.map((a) => a.palette));
2368
- for (const asset of assets) {
2369
- const others = assets.filter((a) => a !== asset).map((a) => a.palette);
2370
- asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
2371
- }
2372
- }
2373
- assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
2374
- return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
2375
- }
2376
- function countColors(png) {
2377
- const seen = /* @__PURE__ */ new Set();
2378
- for (let i = 0; i < png.pixels.length; i += 4) {
2379
- if (png.pixels[i + 3] < 128) continue;
2380
- seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
2381
- }
2382
- return seen.size;
2383
- }
2384
- function outliers(audit, sigma = 1.5) {
2385
- const n = audit.assets.length;
2386
- if (n < 3) return [];
2387
- const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
2388
- const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
2389
- const sd = Math.sqrt(variance);
2390
- if (sd === 0) return [];
2391
- return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
2392
- }
2393
- function evaluateAudit(audit, thresholds = {}) {
2394
- const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
2395
- const relative = outliers(audit, resolved.sigma);
2396
- const relativeIds = new Set(relative.map((asset) => asset.id));
2397
- const violations = [];
2398
- for (const asset of audit.assets) {
2399
- const reasons = [];
2400
- if (relativeIds.has(asset.id)) {
2401
- reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
2402
- }
2403
- if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
2404
- reasons.push(
2405
- `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
2406
- );
2407
- }
2408
- if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
2409
- reasons.push(
2410
- `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
2411
- );
2412
- }
2413
- if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
2414
- reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
2415
- }
2416
- if (reasons.length) violations.push({ id: asset.id, reasons });
2417
- }
2418
- return {
2419
- safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
2420
- thresholds: resolved,
2421
- outliers: [...relativeIds],
2422
- violations,
2423
- missing: [...audit.missing],
2424
- unreadable: [...audit.unreadable]
2425
- };
2432
+ return { png: { width: png.width, height: png.height, pixels }, changed };
2426
2433
  }
2427
- function hex(c) {
2428
- return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
2434
+ function quantizeToPalette(png, palette) {
2435
+ return quantize(png, palette).png;
2429
2436
  }
2430
2437
 
2431
2438
  // src/pipeline/refine.ts
@@ -2512,48 +2519,12 @@ function parseDetection(raw) {
2512
2519
  confidence: confidenceFor(consensus)
2513
2520
  };
2514
2521
  }
2515
- function normalizePalette(values) {
2516
- const colors = /* @__PURE__ */ new Map();
2517
- for (const value of values) {
2518
- const rgb = parseHex(value);
2519
- const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
2520
- colors.set(hex2, { hex: hex2, ...rgb });
2521
- }
2522
- if (colors.size < 2 || colors.size > 256) {
2523
- throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
2524
- }
2525
- return [...colors.values()];
2526
- }
2527
2522
  function normalizeRefinementPalette(values) {
2528
- return normalizePalette(values).map((color) => color.hex);
2529
- }
2530
- function quantizeToPalette(png, palette) {
2531
- const colors = normalizePalette(palette);
2532
- const pixels = Buffer.from(png.pixels);
2533
- for (let i = 0; i < pixels.length; i += 4) {
2534
- const alpha = pixels[i + 3];
2535
- if (alpha === 0) {
2536
- pixels[i] = 0;
2537
- pixels[i + 1] = 0;
2538
- pixels[i + 2] = 0;
2539
- continue;
2540
- }
2541
- const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
2542
- let nearest = colors[0];
2543
- let distance = colorDistance(pixel, nearest);
2544
- for (let j = 1; j < colors.length; j++) {
2545
- const candidate = colors[j];
2546
- const candidateDistance = colorDistance(pixel, candidate);
2547
- if (candidateDistance < distance) {
2548
- nearest = candidate;
2549
- distance = candidateDistance;
2550
- }
2551
- }
2552
- pixels[i] = nearest.r;
2553
- pixels[i + 1] = nearest.g;
2554
- pixels[i + 2] = nearest.b;
2523
+ try {
2524
+ return normalizePalette(values).map((color) => color.hex);
2525
+ } catch (err) {
2526
+ throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${new Set(values.map((v) => v.toLowerCase())).size}.`, { cause: err });
2555
2527
  }
2556
- return { width: png.width, height: png.height, pixels };
2557
2528
  }
2558
2529
  function colorCount(png) {
2559
2530
  const colors = /* @__PURE__ */ new Set();
@@ -2663,16 +2634,16 @@ function sameFrameGrid(first, next) {
2663
2634
  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;
2664
2635
  }
2665
2636
  async function refineAsset(options) {
2666
- const source = path6.resolve(options.source);
2667
- const output = path6.resolve(options.output);
2637
+ const source = path5.resolve(options.source);
2638
+ const output = path5.resolve(options.output);
2668
2639
  if (source === output) throw new Error("Refinement source and output must be different files.");
2669
- const record = path6.resolve(qualityRecordPath(output));
2640
+ const record = path5.resolve(qualityRecordPath(output));
2670
2641
  const palette = normalizePalette(options.palette).map((color) => color.hex);
2671
2642
  const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
2672
2643
  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.");
2673
2644
  let sourcePng;
2674
2645
  try {
2675
- sourcePng = decodePng(await readFile4(source));
2646
+ sourcePng = decodePng(await readFile3(source));
2676
2647
  } catch (error) {
2677
2648
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
2678
2649
  }
@@ -2689,8 +2660,8 @@ async function refineAsset(options) {
2689
2660
  );
2690
2661
  }
2691
2662
  }
2692
- const temp = await mkdtemp(path6.join(tmpdir(), "pixelkiln-refine-"));
2693
- const recovered = path6.join(temp, "native.png");
2663
+ const temp = await mkdtemp(path5.join(tmpdir(), "pixelkiln-refine-"));
2664
+ const recovered = path5.join(temp, "native.png");
2694
2665
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
2695
2666
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
2696
2667
  try {
@@ -2711,7 +2682,7 @@ async function refineAsset(options) {
2711
2682
  );
2712
2683
  }
2713
2684
  const detection = parseDetection(firstJsonObject(stdout));
2714
- const native = decodePng(await readFile4(recovered));
2685
+ const native = decodePng(await readFile3(recovered));
2715
2686
  const quantized = quantizeToPalette(native, palette);
2716
2687
  const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
2717
2688
  const audit = auditRefinement(
@@ -2775,18 +2746,18 @@ async function refineFrameSet(options) {
2775
2746
  if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
2776
2747
  throw new Error("Frame-set roles must be non-empty and unique.");
2777
2748
  }
2778
- const output = path6.resolve(options.output);
2779
- const record = path6.resolve(qualityRecordPath(output));
2749
+ const output = path5.resolve(options.output);
2750
+ const record = path5.resolve(qualityRecordPath(output));
2780
2751
  const palette = normalizeRefinementPalette(options.palette);
2781
2752
  const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
2782
2753
  if (new Set(finalOutputs).size !== finalOutputs.length) {
2783
2754
  throw new Error("Frame-set roles resolve to duplicate output filenames.");
2784
2755
  }
2785
- const sourceSet = new Set(options.sources.map((source) => path6.resolve(source.path)));
2756
+ const sourceSet = new Set(options.sources.map((source) => path5.resolve(source.path)));
2786
2757
  if (finalOutputs.some((file) => sourceSet.has(file))) {
2787
2758
  throw new Error("Frame-set refinement sources and outputs must be different files.");
2788
2759
  }
2789
- const temp = await mkdtemp(path6.join(tmpdir(), "pixelkiln-refine-frames-"));
2760
+ const temp = await mkdtemp(path5.join(tmpdir(), "pixelkiln-refine-frames-"));
2790
2761
  try {
2791
2762
  const frames = [];
2792
2763
  const outputs = [];
@@ -2794,8 +2765,8 @@ async function refineFrameSet(options) {
2794
2765
  const sourcePaths = [];
2795
2766
  let firstOptions = null;
2796
2767
  for (const [index, sourceEntry] of options.sources.entries()) {
2797
- const source = path6.resolve(sourceEntry.path);
2798
- const temporaryOutput = path6.join(temp, `${String(index).padStart(2, "0")}.png`);
2768
+ const source = path5.resolve(sourceEntry.path);
2769
+ const temporaryOutput = path5.join(temp, `${String(index).padStart(2, "0")}.png`);
2799
2770
  const refined = await refineAsset({
2800
2771
  source,
2801
2772
  output: temporaryOutput,
@@ -2827,7 +2798,7 @@ async function refineFrameSet(options) {
2827
2798
  });
2828
2799
  outputs.push({
2829
2800
  path: finalOutputs[index],
2830
- data: await readFile4(temporaryOutput)
2801
+ data: await readFile3(temporaryOutput)
2831
2802
  });
2832
2803
  }
2833
2804
  const first = frames[0];
@@ -2870,7 +2841,7 @@ async function refineFrameSet(options) {
2870
2841
  );
2871
2842
  return {
2872
2843
  sources: sourcePaths,
2873
- outputs: outputs.map((item) => path6.resolve(item.path)),
2844
+ outputs: outputs.map((item) => path5.resolve(item.path)),
2874
2845
  record,
2875
2846
  frames,
2876
2847
  palette,
@@ -2881,12 +2852,12 @@ async function refineFrameSet(options) {
2881
2852
  }
2882
2853
  }
2883
2854
  async function checkQualityRecord(recordPath) {
2884
- const record = path6.resolve(recordPath);
2855
+ const record = path5.resolve(recordPath);
2885
2856
  const manifest = await readArtifactBundleManifest(record);
2886
2857
  const options = parseRecordOptions(manifest, record);
2887
2858
  const verification = await verifyArtifactBundle(record);
2888
- const sources = manifest.sources.map((source2) => path6.resolve(path6.dirname(record), source2.path));
2889
- const outputs = manifest.outputs.map((output2) => path6.resolve(path6.dirname(record), output2.path));
2859
+ const sources = manifest.sources.map((source2) => path5.resolve(path5.dirname(record), source2.path));
2860
+ const outputs = manifest.outputs.map((output2) => path5.resolve(path5.dirname(record), output2.path));
2890
2861
  const source = sources[0] ?? "";
2891
2862
  const output = outputs[0] ?? "";
2892
2863
  const expectedCount = options.frameSet?.count ?? 1;
@@ -2919,7 +2890,7 @@ async function checkQualityRecord(recordPath) {
2919
2890
  async function approveQualityRecord(recordPath, approval) {
2920
2891
  const reviewer = approval.reviewer.trim();
2921
2892
  if (!reviewer) throw new Error("A human reviewer name is required.");
2922
- const record = path6.resolve(recordPath);
2893
+ const record = path5.resolve(recordPath);
2923
2894
  const manifest = await readArtifactBundleManifest(record);
2924
2895
  const options = parseRecordOptions(manifest, record);
2925
2896
  const verification = await verifyArtifactBundle(record);
@@ -2940,14 +2911,14 @@ async function approveQualityRecord(recordPath, approval) {
2940
2911
  `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
2941
2912
  );
2942
2913
  }
2943
- const root = path6.dirname(record);
2914
+ const root = path5.dirname(record);
2944
2915
  const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
2945
- const outputPath = path6.resolve(root, entry.path);
2946
- return { path: outputPath, data: await readFile4(outputPath) };
2916
+ const outputPath = path5.resolve(root, entry.path);
2917
+ return { path: outputPath, data: await readFile3(outputPath) };
2947
2918
  }));
2948
2919
  const sources = manifest.sources.map((source) => ({
2949
2920
  ...source,
2950
- path: path6.resolve(root, source.path)
2921
+ path: path5.resolve(root, source.path)
2951
2922
  }));
2952
2923
  const approved = {
2953
2924
  ...options,
@@ -2979,8 +2950,8 @@ async function qualitySource(spec, lock) {
2979
2950
  if (spec.generator === "frames") {
2980
2951
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
2981
2952
  }
2982
- const source = path7.resolve(spec.root, spec.source);
2983
- return existsSync4(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
2953
+ const source = path6.resolve(spec.root, spec.source);
2954
+ return existsSync3(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
2984
2955
  }
2985
2956
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
2986
2957
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -3009,7 +2980,7 @@ async function qualitySource(spec, lock) {
3009
2980
  }
3010
2981
  if (role) roles.add(role);
3011
2982
  const source = currentEntryOutputPath(entry, spec, index);
3012
- if (!existsSync4(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
2983
+ if (!existsSync3(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
3013
2984
  if (await sha256File(source) !== output.sha256) {
3014
2985
  return { items: null, reason: "raw provider output was modified after download" };
3015
2986
  }
@@ -3028,7 +2999,7 @@ async function inspectQualityProfile(spec, lock) {
3028
2999
  }
3029
3000
  const sourcePaths = source.items.map((item) => item.path);
3030
3001
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
3031
- if (!existsSync4(record)) {
3002
+ if (!existsSync3(record)) {
3032
3003
  return {
3033
3004
  key,
3034
3005
  state: "needs-refinement",
@@ -3046,7 +3017,7 @@ async function inspectQualityProfile(spec, lock) {
3046
3017
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
3047
3018
  const expectedTransparency = spec.quality.minTransparency ?? null;
3048
3019
  const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
3049
- const contractChanged = JSON.stringify(check.sources.map((item) => path7.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path7.resolve(item))) || JSON.stringify(check.outputs.map((item) => path7.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path7.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);
3020
+ const contractChanged = JSON.stringify(check.sources.map((item) => path6.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path6.resolve(item))) || JSON.stringify(check.outputs.map((item) => path6.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path6.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);
3050
3021
  if (contractChanged) {
3051
3022
  return {
3052
3023
  key,
@@ -3208,9 +3179,38 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
3208
3179
  return sources;
3209
3180
  }
3210
3181
 
3182
+ // src/pipeline/postprocess.ts
3183
+ function postprocessFor(spec) {
3184
+ if (!spec.enforcePalette || spec.palette.length < 2) return void 0;
3185
+ return {
3186
+ palette: {
3187
+ colors: normalizePalette(spec.palette).map((color) => color.hex),
3188
+ dither: "none",
3189
+ distance: "redmean"
3190
+ }
3191
+ };
3192
+ }
3193
+ function samePostprocess(a, b) {
3194
+ const pa = a?.palette;
3195
+ const pb = b?.palette;
3196
+ if (!pa && !pb) return true;
3197
+ if (!pa || !pb) return false;
3198
+ return pa.dither === pb.dither && pa.distance === pb.distance && pa.colors.length === pb.colors.length && pa.colors.every((color, i) => color === pb.colors[i]);
3199
+ }
3200
+ function postprocessCurrent(entry, spec) {
3201
+ return samePostprocess(entry.postprocess, postprocessFor(spec));
3202
+ }
3203
+ function applyPostprocess(bytes, mediaType, record) {
3204
+ if (!record?.palette || mediaType !== MediaType.PNG) return { bytes, changed: false };
3205
+ const png = decodePng(bytes);
3206
+ const { png: snapped, changed } = quantize(png, record.palette.colors);
3207
+ if (!changed) return { bytes, changed: false };
3208
+ return { bytes: encodeRgbaPng(snapped.width, snapped.height, snapped.pixels), changed: true };
3209
+ }
3210
+
3211
3211
  // src/pipeline/revision.ts
3212
- import { existsSync as existsSync5 } from "fs";
3213
- import path8 from "path";
3212
+ import { existsSync as existsSync4 } from "fs";
3213
+ import path7 from "path";
3214
3214
  async function inspectRevisionReadiness(spec, lock) {
3215
3215
  if (!spec.revision) return null;
3216
3216
  return inspectRevision(spec, lock, /* @__PURE__ */ new Set());
@@ -3225,14 +3225,14 @@ async function inspectRevision(spec, lock, seen) {
3225
3225
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
3226
3226
  };
3227
3227
  }
3228
- if (!revision.sourceSha256 || !existsSync5(revision.sourceFile)) {
3228
+ if (!revision.sourceSha256 || !existsSync4(revision.sourceFile)) {
3229
3229
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
3230
3230
  }
3231
3231
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
3232
3232
  return { ready: false, reason: "revision source changed after the manifest was resolved" };
3233
3233
  }
3234
3234
  if (revision.maskFile) {
3235
- if (!revision.maskSha256 || !existsSync5(revision.maskFile)) {
3235
+ if (!revision.maskSha256 || !existsSync4(revision.maskFile)) {
3236
3236
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
3237
3237
  }
3238
3238
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -3262,8 +3262,8 @@ async function inspectParent(spec, lock, seen) {
3262
3262
  };
3263
3263
  }
3264
3264
  if (spec.source) {
3265
- const source = path8.resolve(spec.root, spec.source);
3266
- return existsSync5(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
3265
+ const source = path7.resolve(spec.root, spec.source);
3266
+ return existsSync4(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
3267
3267
  }
3268
3268
  const entry = lock.entries[key];
3269
3269
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -3277,7 +3277,7 @@ async function inspectParent(spec, lock, seen) {
3277
3277
  for (let index = 0; index < entry.outputs.length; index++) {
3278
3278
  const output = entry.outputs[index];
3279
3279
  const file = currentEntryOutputPath(entry, spec, index);
3280
- if (!existsSync5(file)) return { ready: false, reason: `parent output is missing: ${file}` };
3280
+ if (!existsSync4(file)) return { ready: false, reason: `parent output is missing: ${file}` };
3281
3281
  if (await sha256File(file) !== output.sha256) {
3282
3282
  return { ready: false, reason: `parent output was modified: ${file}` };
3283
3283
  }
@@ -3306,7 +3306,7 @@ function resumeActions(specs, lock) {
3306
3306
  const entry = lock.entries[key];
3307
3307
  if (!entry || entry.specHash !== spec.specHash) continue;
3308
3308
  if (entry.submissionComplete === false) continue;
3309
- const command = resumeCommandForStatus(entry.status);
3309
+ const command = resumeCommandForStatus(entry.status) ?? (entry.status === "downloaded" && entry.outputs.length && !postprocessCurrent(entry, spec) ? "fetch" : null);
3310
3310
  if (!command) continue;
3311
3311
  if (command === "poll" && !entry.jobId) continue;
3312
3312
  if (command === "pick" && !entry.reviewObjectId) continue;
@@ -3332,7 +3332,7 @@ async function buildPlan(specs, lock, opts = {}) {
3332
3332
  state = "missing";
3333
3333
  reason = "--force";
3334
3334
  } else if (!entry && spec.source && spec.generator !== "frames") {
3335
- if (existsSync6(path9.resolve(spec.root, spec.source))) {
3335
+ if (existsSync5(path8.resolve(spec.root, spec.source))) {
3336
3336
  state = "ok";
3337
3337
  reason = `placed from ${spec.source}; not generated`;
3338
3338
  } else {
@@ -3340,7 +3340,7 @@ async function buildPlan(specs, lock, opts = {}) {
3340
3340
  reason = `declared source is missing: ${spec.source}`;
3341
3341
  }
3342
3342
  } else if (!entry) {
3343
- if (existsSync6(spec.outFile)) {
3343
+ if (existsSync5(spec.outFile)) {
3344
3344
  state = "untracked";
3345
3345
  reason = "file on disk, no provenance recorded";
3346
3346
  } else {
@@ -3372,12 +3372,15 @@ async function buildPlan(specs, lock, opts = {}) {
3372
3372
  } else if (entry.status !== "downloaded") {
3373
3373
  state = "in-flight";
3374
3374
  reason = `awaiting ${entry.status}`;
3375
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync6(currentEntryOutputPath(entry, spec, index)))) {
3375
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !existsSync5(currentEntryOutputPath(entry, spec, index)))) {
3376
3376
  state = "orphaned";
3377
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync6(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
3377
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !existsSync5(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
3378
3378
  } else if (await anyOutputModified(entry, spec)) {
3379
3379
  state = "orphaned";
3380
3380
  reason = "output modified since download";
3381
+ } else if (!postprocessCurrent(entry, spec)) {
3382
+ state = "recoverable";
3383
+ 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)";
3381
3384
  } else {
3382
3385
  state = "ok";
3383
3386
  reason = "up to date";
@@ -3537,12 +3540,12 @@ async function confirm(question, auto) {
3537
3540
  }
3538
3541
 
3539
3542
  // src/cli/commands/account.ts
3540
- import path21 from "path";
3541
- import { existsSync as existsSync16 } from "fs";
3543
+ import path20 from "path";
3544
+ import { existsSync as existsSync15 } from "fs";
3542
3545
 
3543
3546
  // src/env.ts
3544
- import { readFileSync, existsSync as existsSync7 } from "fs";
3545
- import path10 from "path";
3547
+ import { readFileSync, existsSync as existsSync6 } from "fs";
3548
+ import path9 from "path";
3546
3549
  function loadEnvFiles(dir) {
3547
3550
  const loaded = [];
3548
3551
  for (const [file, vars] of envFiles(dir)) {
@@ -3565,8 +3568,8 @@ function readEnvFiles(dir) {
3565
3568
  function envFiles(dir) {
3566
3569
  const out = [];
3567
3570
  for (const name of [".env.local", ".env"]) {
3568
- const file = path10.join(dir, name);
3569
- if (!existsSync7(file)) continue;
3571
+ const file = path9.join(dir, name);
3572
+ if (!existsSync6(file)) continue;
3570
3573
  try {
3571
3574
  out.push([file, parseEnv(readFileSync(file, "utf8"))]);
3572
3575
  } catch {
@@ -3595,8 +3598,8 @@ function parseEnv(contents) {
3595
3598
 
3596
3599
  // src/providers/comfyui.ts
3597
3600
  import { randomUUID as randomUUID2 } from "crypto";
3598
- import { readFile as readFile5 } from "fs/promises";
3599
- import path11 from "path";
3601
+ import { readFile as readFile4 } from "fs/promises";
3602
+ import path10 from "path";
3600
3603
 
3601
3604
  // src/http.ts
3602
3605
  var MAX_RETRIES = 4;
@@ -3706,8 +3709,10 @@ var ComfyUIClient = class {
3706
3709
  value = null;
3707
3710
  }
3708
3711
  if (!response.ok) {
3709
- throw new Error(
3710
- `ComfyUI image upload failed (${response.status})` + (apiError(value) ? `: ${apiError(value)}` : "")
3712
+ throw new ProviderError(
3713
+ "comfyui",
3714
+ `ComfyUI image upload failed (${response.status})` + (apiError(value) ? `: ${apiError(value)}` : ""),
3715
+ { status: response.status }
3711
3716
  );
3712
3717
  }
3713
3718
  if (!isObject(value) || typeof value.name !== "string" || !value.name) {
@@ -3730,7 +3735,7 @@ var ComfyUIClient = class {
3730
3735
  let target = source;
3731
3736
  if (source.startsWith("comfyui://")) target = this.viewUrl(parseSource(source));
3732
3737
  const response = await this.request(target);
3733
- if (!response.ok) throw new Error(`ComfyUI download failed (${response.status})`);
3738
+ if (!response.ok) throw new ProviderError("comfyui", `ComfyUI download failed (${response.status})`, { status: response.status });
3734
3739
  return Buffer.from(await response.arrayBuffer());
3735
3740
  }
3736
3741
  endpoint(route) {
@@ -3755,8 +3760,10 @@ var ComfyUIClient = class {
3755
3760
  }
3756
3761
  if (!response.ok) {
3757
3762
  const detail = apiError(value);
3758
- throw new Error(
3759
- `ComfyUI request failed (${response.status}) at ${url.pathname}` + (detail ? `: ${detail}` : "")
3763
+ throw new ProviderError(
3764
+ "comfyui",
3765
+ `ComfyUI request failed (${response.status}) at ${url.pathname}` + (detail ? `: ${detail}` : ""),
3766
+ { status: response.status }
3760
3767
  );
3761
3768
  }
3762
3769
  return value;
@@ -3779,10 +3786,10 @@ var ComfyUIProvider = class _ComfyUIProvider {
3779
3786
  }
3780
3787
  async resolveOptions(value, context) {
3781
3788
  const options = parseOptions(value);
3782
- const workflowPath = path11.resolve(context.root, options.workflowFile);
3789
+ const workflowPath = path10.resolve(context.root, options.workflowFile);
3783
3790
  let raw;
3784
3791
  try {
3785
- raw = await readFile5(workflowPath, "utf8");
3792
+ raw = await readFile4(workflowPath, "utf8");
3786
3793
  } catch (err) {
3787
3794
  throw new Error(
3788
3795
  `ComfyUI workflow for style "${context.styleId}" could not be read at ${workflowPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -3834,10 +3841,10 @@ var ComfyUIProvider = class _ComfyUIProvider {
3834
3841
  if (typeof item !== "string") {
3835
3842
  throw new Error(`ComfyUI ${label} must be a manifest-relative PNG or JPEG path`);
3836
3843
  }
3837
- const file = path11.resolve(context.root, item);
3844
+ const file = path10.resolve(context.root, item);
3838
3845
  let bytes;
3839
3846
  try {
3840
- bytes = await readFile5(file);
3847
+ bytes = await readFile4(file);
3841
3848
  } catch (error) {
3842
3849
  throw new Error(
3843
3850
  `ComfyUI ${label} could not be read at ${file}: ${error instanceof Error ? error.message : String(error)}`
@@ -3969,7 +3976,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
3969
3976
  setBinding(workflow2, binding, input);
3970
3977
  return;
3971
3978
  }
3972
- const bytes = await readFile5(input.path);
3979
+ const bytes = await readFile4(input.path);
3973
3980
  if (sha256(bytes) !== input.sha256) {
3974
3981
  throw new Error(`ComfyUI provider input "${name}" changed before upload`);
3975
3982
  }
@@ -4036,7 +4043,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
4036
4043
  if (!spec.revision.sourceSha256 || !spec.revision.sourceFormat) {
4037
4044
  throw new Error("ComfyUI revision source is not ready");
4038
4045
  }
4039
- const sourceBytes = await readFile5(spec.revision.sourceFile);
4046
+ const sourceBytes = await readFile4(spec.revision.sourceFile);
4040
4047
  if (sha256(sourceBytes) !== spec.revision.sourceSha256) {
4041
4048
  throw new Error("ComfyUI revision source changed before upload");
4042
4049
  }
@@ -4050,7 +4057,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
4050
4057
  if (!spec.revision.maskSha256 || !spec.revision.maskFormat) {
4051
4058
  throw new Error("ComfyUI revision mask is not ready");
4052
4059
  }
4053
- const maskBytes = await readFile5(spec.revision.maskFile);
4060
+ const maskBytes = await readFile4(spec.revision.maskFile);
4054
4061
  if (sha256(maskBytes) !== spec.revision.maskSha256) {
4055
4062
  throw new Error("ComfyUI revision mask changed before upload");
4056
4063
  }
@@ -4521,10 +4528,10 @@ function canonical2(value) {
4521
4528
  }
4522
4529
 
4523
4530
  // src/providers/pixellab.ts
4524
- import { mkdirSync, existsSync as existsSync8, readFileSync as readFileSync2, writeFileSync } from "fs";
4531
+ import { mkdirSync, existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync } from "fs";
4525
4532
  import { randomUUID as randomUUID3 } from "crypto";
4526
4533
  import os from "os";
4527
- import path12 from "path";
4534
+ import path11 from "path";
4528
4535
 
4529
4536
  // src/client.ts
4530
4537
  import { z as z2 } from "zod";
@@ -4592,14 +4599,12 @@ function validateResponse(schema, raw, operation) {
4592
4599
  const issues = parsed.error.issues.slice(0, 4).map((i) => `${i.path.join(".") || "response"}: ${i.message}`).join("; ");
4593
4600
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
4594
4601
  }
4595
- var PixelLabError = class extends Error {
4602
+ var PixelLabError = class extends ProviderError {
4596
4603
  constructor(message6, status, body) {
4597
- super(message6);
4598
- this.status = status;
4604
+ super("pixellab", message6, { status });
4599
4605
  this.body = body;
4600
4606
  this.name = "PixelLabError";
4601
4607
  }
4602
- status;
4603
4608
  body;
4604
4609
  };
4605
4610
  var PixelLabClient = class {
@@ -4878,7 +4883,7 @@ var PixelLabProvider = class _PixelLabProvider {
4878
4883
  * filename, polling is an existence check, and downloading is a file read.
4879
4884
  */
4880
4885
  static cacheDir() {
4881
- const dir = path12.join(os.tmpdir(), "pixelkiln-pixflux");
4886
+ const dir = path11.join(os.tmpdir(), "pixelkiln-pixflux");
4882
4887
  mkdirSync(dir, { recursive: true });
4883
4888
  return dir;
4884
4889
  }
@@ -4947,7 +4952,7 @@ var PixelLabProvider = class _PixelLabProvider {
4947
4952
  seed: spec.seed
4948
4953
  });
4949
4954
  const jobId = randomUUID3();
4950
- writeFileSync(path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), png);
4955
+ writeFileSync(path11.join(_PixelLabProvider.cacheDir(), `${jobId}.png`), png);
4951
4956
  return { jobId };
4952
4957
  }
4953
4958
  if (spec.generator === "tiles") {
@@ -4988,8 +4993,8 @@ var PixelLabProvider = class _PixelLabProvider {
4988
4993
  }
4989
4994
  async poll(jobId, generator, context) {
4990
4995
  if (generator === "pixflux") {
4991
- const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
4992
- if (existsSync8(file)) {
4996
+ const file = path11.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
4997
+ if (existsSync7(file)) {
4993
4998
  const sourceUrl = `file://${file}`;
4994
4999
  return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }] };
4995
5000
  }
@@ -5180,7 +5185,7 @@ var RetroDiffusionClient = class {
5180
5185
  /** Result URLs are signed; no token header, and the same retry policy as the API. */
5181
5186
  async download(url) {
5182
5187
  const response = await this.request(url);
5183
- if (!response.ok) throw new Error(`Retro Diffusion download failed (${response.status})`);
5188
+ if (!response.ok) throw new ProviderError("retrodiffusion", `Retro Diffusion download failed (${response.status})`, { status: response.status });
5184
5189
  return Buffer.from(await response.arrayBuffer());
5185
5190
  }
5186
5191
  async call(path43, init = {}) {
@@ -5203,8 +5208,10 @@ var RetroDiffusionClient = class {
5203
5208
  if (!response.ok) {
5204
5209
  const retry = response.headers.get("retry-after");
5205
5210
  const detail = retroError(value);
5206
- throw new Error(
5207
- `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
5211
+ throw new ProviderError(
5212
+ "retrodiffusion",
5213
+ `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : ""),
5214
+ { status: response.status }
5208
5215
  );
5209
5216
  }
5210
5217
  return value;
@@ -5596,7 +5603,7 @@ var ScenarioClient = class {
5596
5603
  throw new Error("Scenario downloads must use HTTP or HTTPS");
5597
5604
  }
5598
5605
  const response = await this.request(parsed);
5599
- if (!response.ok) throw new Error(`Scenario download failed (${response.status})`);
5606
+ if (!response.ok) throw new ProviderError("scenario", `Scenario download failed (${response.status})`, { status: response.status });
5600
5607
  const declared = Number(response.headers.get("content-length"));
5601
5608
  if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) {
5602
5609
  throw new Error(`Scenario download exceeds the ${MAX_DOWNLOAD_BYTES}-byte safety limit`);
@@ -5643,8 +5650,10 @@ var ScenarioClient = class {
5643
5650
  if (!response.ok) {
5644
5651
  const retry = response.headers.get("retry-after");
5645
5652
  const detail = redactScenarioError(value, [this.apiKey, this.apiSecret, auth]);
5646
- throw new Error(
5647
- `Scenario request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
5653
+ throw new ProviderError(
5654
+ "scenario",
5655
+ `Scenario request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : ""),
5656
+ { status: response.status }
5648
5657
  );
5649
5658
  }
5650
5659
  return value;
@@ -6038,23 +6047,23 @@ registerProvider({
6038
6047
  });
6039
6048
 
6040
6049
  // src/manifest.ts
6041
- import { readFile as readFile6 } from "fs/promises";
6042
- import { existsSync as existsSync9 } from "fs";
6043
- import path13 from "path";
6050
+ import { readFile as readFile5 } from "fs/promises";
6051
+ import { existsSync as existsSync8 } from "fs";
6052
+ import path12 from "path";
6044
6053
  async function loadManifest(manifestPath) {
6045
- const abs = path13.resolve(manifestPath);
6046
- if (!existsSync9(abs)) throw new Error(`No manifest at ${abs}`);
6047
- const input = ManifestInputSchema.safeParse(JSON.parse(await readFile6(abs, "utf8")));
6054
+ const abs = path12.resolve(manifestPath);
6055
+ if (!existsSync8(abs)) throw new ProjectError(`No manifest at ${abs}`);
6056
+ const input = ManifestInputSchema.safeParse(JSON.parse(await readFile5(abs, "utf8")));
6048
6057
  if (!input.success) {
6049
6058
  const issues = formatManifestIssues(input.error.issues);
6050
- throw new Error(`Manifest at ${abs} is invalid:
6059
+ throw new ProjectError(`Manifest at ${abs} is invalid:
6051
6060
  ${issues}`);
6052
6061
  }
6053
6062
  let styles;
6054
6063
  try {
6055
6064
  styles = resolveStyleInheritance(input.data.styles);
6056
6065
  } catch (error) {
6057
- throw new Error(
6066
+ throw new ProjectError(
6058
6067
  `Manifest at ${abs} is invalid:
6059
6068
  ${error instanceof Error ? error.message : String(error)}`
6060
6069
  );
@@ -6062,7 +6071,7 @@ ${issues}`);
6062
6071
  const parsed = ManifestSchema.safeParse({ ...input.data, styles });
6063
6072
  if (!parsed.success) {
6064
6073
  const issues = formatManifestIssues(parsed.error.issues);
6065
- throw new Error(`Manifest at ${abs} is invalid:
6074
+ throw new ProjectError(`Manifest at ${abs} is invalid:
6066
6075
  ${issues}`);
6067
6076
  }
6068
6077
  const styleIds = new Set(Object.keys(parsed.data.styles));
@@ -6112,10 +6121,10 @@ ${issues}`);
6112
6121
  };
6113
6122
  for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
6114
6123
  if (unknownReferences.length) {
6115
- throw new Error(`Manifest at ${abs} is invalid:
6124
+ throw new ProjectError(`Manifest at ${abs} is invalid:
6116
6125
  ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
6117
6126
  }
6118
- return { manifest: parsed.data, root: path13.dirname(abs), path: abs };
6127
+ return { manifest: parsed.data, root: path12.dirname(abs), path: abs };
6119
6128
  }
6120
6129
  function formatManifestIssues(issues) {
6121
6130
  return issues.flatMap((issue) => {
@@ -6191,14 +6200,14 @@ async function resolveSpecs(loaded, filter) {
6191
6200
  );
6192
6201
  for (const unknownStyle of filter?.styles ?? []) {
6193
6202
  if (!manifest.styles[unknownStyle]) {
6194
- throw new Error(
6203
+ throw new UsageError(
6195
6204
  `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
6196
6205
  );
6197
6206
  }
6198
6207
  }
6199
6208
  for (const unknownAsset of filter?.assets ?? []) {
6200
6209
  if (!manifest.assets[unknownAsset]) {
6201
- throw new Error(`Unknown asset "${unknownAsset}".`);
6210
+ throw new UsageError(`Unknown asset "${unknownAsset}".`);
6202
6211
  }
6203
6212
  }
6204
6213
  const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
@@ -6212,11 +6221,11 @@ async function resolveSpecs(loaded, filter) {
6212
6221
  }
6213
6222
  const styleImageCache = /* @__PURE__ */ new Map();
6214
6223
  async function loadStyleImage(rel) {
6215
- const abs = path13.resolve(root, rel);
6224
+ const abs = path12.resolve(root, rel);
6216
6225
  let hit = styleImageCache.get(abs);
6217
6226
  if (!hit) {
6218
- if (!existsSync9(abs)) throw new Error(`Style image not found: ${abs}`);
6219
- const buf = await readFile6(abs);
6227
+ if (!existsSync8(abs)) throw new Error(`Style image not found: ${abs}`);
6228
+ const buf = await readFile5(abs);
6220
6229
  const metadata = imageMetadata(buf);
6221
6230
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
6222
6231
  if (metadata.width < 1 || metadata.height < 1) {
@@ -6242,7 +6251,7 @@ async function resolveSpecs(loaded, filter) {
6242
6251
  styleImageDimensions.push(loadedImage);
6243
6252
  }
6244
6253
  const resolvedImages = style.styleImages.map((image) => {
6245
- const hit = styleImageCache.get(path13.resolve(root, image.path));
6254
+ const hit = styleImageCache.get(path12.resolve(root, image.path));
6246
6255
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
6247
6256
  });
6248
6257
  const styleSpecs = /* @__PURE__ */ new Map();
@@ -6287,9 +6296,9 @@ async function resolveSpecs(loaded, filter) {
6287
6296
  }
6288
6297
  const subject = asset.promptByStyle[styleId] ?? asset.prompt;
6289
6298
  const prompt = [style.promptPrefix, subject, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
6290
- const relFile = asset.file ?? path13.join(asset.category ?? "", `${assetId}.png`);
6291
- const outFile = path13.resolve(root, style.outDir, relFile);
6292
- const qualityOutFile = style.quality ? path13.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
6299
+ const relFile = asset.file ?? path12.join(asset.category ?? "", `${assetId}.png`);
6300
+ const outFile = path12.resolve(root, style.outDir, relFile);
6301
+ const qualityOutFile = style.quality ? path12.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
6293
6302
  const tileSize = generator === "tiles" ? size : style.tileSize ?? 32;
6294
6303
  const tileVariations = tileFeatureOutputCount(generator === "tiles" ? style.tileFeature : void 0) ?? tileVariationCount(countNumberedDescriptions(prompt));
6295
6304
  const base = {
@@ -6310,6 +6319,7 @@ async function resolveSpecs(loaded, filter) {
6310
6319
  detail: style.detail,
6311
6320
  seed: style.seed,
6312
6321
  palette: style.palette,
6322
+ enforcePalette: style.enforcePalette,
6313
6323
  noBackground: style.noBackground,
6314
6324
  tileSize: generator === "tiles" ? tileSize : void 0,
6315
6325
  tileType: generator === "tiles" ? style.tileType : void 0,
@@ -6340,7 +6350,7 @@ async function resolveSpecs(loaded, filter) {
6340
6350
  minGridConfidence: style.quality.minGridConfidence,
6341
6351
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
6342
6352
  ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
6343
- ...style.quality.fixerPython ? { fixerPython: path13.resolve(root, style.quality.fixerPython) } : {},
6353
+ ...style.quality.fixerPython ? { fixerPython: path12.resolve(root, style.quality.fixerPython) } : {},
6344
6354
  ...style.quality.fps == null ? {} : { fps: style.quality.fps }
6345
6355
  }
6346
6356
  } : {},
@@ -6369,9 +6379,9 @@ async function resolveSpecs(loaded, filter) {
6369
6379
  );
6370
6380
  }
6371
6381
  const sourceSpec = await finalize(asset.revision.from);
6372
- const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path13.resolve(root, sourceSpec.source) : sourceSpec.outFile);
6382
+ const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path12.resolve(root, sourceSpec.source) : sourceSpec.outFile);
6373
6383
  const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
6374
- const maskFile = asset.revision.mask ? path13.resolve(root, asset.revision.mask) : void 0;
6384
+ const maskFile = asset.revision.mask ? path12.resolve(root, asset.revision.mask) : void 0;
6375
6385
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
6376
6386
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
6377
6387
  throw new Error(
@@ -6452,12 +6462,12 @@ async function resolveSpecs(loaded, filter) {
6452
6462
  return specs;
6453
6463
  }
6454
6464
  function pngPath(file) {
6455
- const extension = path13.extname(file);
6465
+ const extension = path12.extname(file);
6456
6466
  return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
6457
6467
  }
6458
6468
  async function optionalRevisionImage(file, label, requiredFormat) {
6459
- if (!existsSync9(file)) return null;
6460
- const bytes = await readFile6(file);
6469
+ if (!existsSync8(file)) return null;
6470
+ const bytes = await readFile5(file);
6461
6471
  const metadata = imageMetadata(bytes);
6462
6472
  if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
6463
6473
  if (requiredFormat && metadata.format !== requiredFormat) {
@@ -6470,8 +6480,8 @@ async function resolveStyleImages(loaded, styleId) {
6470
6480
  if (!style) throw new Error(`Unknown style "${styleId}"`);
6471
6481
  const out = [];
6472
6482
  for (const img of style.styleImages) {
6473
- const file = path13.resolve(loaded.root, img.path);
6474
- const buf = await readFile6(file);
6483
+ const file = path12.resolve(loaded.root, img.path);
6484
+ const buf = await readFile5(file);
6475
6485
  const metadata = imageMetadata(buf);
6476
6486
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
6477
6487
  if (metadata.width < 1 || metadata.height < 1) {
@@ -6511,9 +6521,9 @@ function imageMetadata(buf) {
6511
6521
  }
6512
6522
 
6513
6523
  // src/pipeline/fetch.ts
6514
- import { existsSync as existsSync10, readFileSync as readFileSync3 } from "fs";
6515
- import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6516
- import path14 from "path";
6524
+ import { existsSync as existsSync9, readFileSync as readFileSync3 } from "fs";
6525
+ import { mkdir as mkdir3, readFile as readFile6, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6526
+ import path13 from "path";
6517
6527
 
6518
6528
  // src/source-url.ts
6519
6529
  var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
@@ -6559,18 +6569,24 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6559
6569
  const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
6560
6570
  const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
6561
6571
  normalizeLockOutputPaths(lock, specs);
6562
- const cacheDir = opts.cacheDir === false ? null : path14.resolve(opts.cacheDir ?? path14.join(path14.dirname(lockPath), ".pixelkiln", "cache"));
6572
+ const cacheDir = opts.cacheDir === false ? null : path13.resolve(opts.cacheDir ?? path13.join(path13.dirname(lockPath), ".pixelkiln", "cache"));
6563
6573
  const pending = Object.entries(lock.entries).filter(([key, e]) => {
6564
6574
  if (e.provider !== provider.id || !specByKey.has(key)) return false;
6565
6575
  if (e.status === "selected" || e.status === "download-failed") return true;
6566
6576
  if (e.status !== "downloaded") return false;
6567
6577
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
6568
- if (!opts.repair) return false;
6569
6578
  const spec = specByKey.get(key);
6579
+ if (spec && e.specHash === spec.specHash && e.outputs.length && !postprocessCurrent(e, spec)) {
6580
+ return Boolean(opts.force) || e.outputs.every((output) => {
6581
+ const file = resolveOutputPath(output.path, spec.root);
6582
+ return existsSync9(file) && sha256(readFileSync3(file)) === output.sha256;
6583
+ });
6584
+ }
6585
+ if (!opts.repair) return false;
6570
6586
  if (e.outputs.length === 0 || !spec) return true;
6571
6587
  return e.outputs.some((output) => {
6572
6588
  const file = resolveOutputPath(output.path, spec.root);
6573
- if (!existsSync10(file)) return true;
6589
+ if (!existsSync9(file)) return true;
6574
6590
  const current = sha256(readFileSync3(file));
6575
6591
  if (current === output.sha256) return false;
6576
6592
  return Boolean(opts.force) || (e.supersededOutputs ?? []).some((old) => old.sha256 === current);
@@ -6605,6 +6621,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6605
6621
  }
6606
6622
  const outputs = [];
6607
6623
  let wrote = false;
6624
+ const wanted = postprocessFor(spec);
6625
+ const reapply = entry.status === "downloaded" && !postprocessCurrent(entry, spec);
6608
6626
  try {
6609
6627
  for (let index = 0; index < sources.length; index++) {
6610
6628
  const source = sources[index];
@@ -6612,24 +6630,28 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6612
6630
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
6613
6631
  );
6614
6632
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
6633
+ const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
6634
+ const recordedRaw = recorded?.raw ?? recorded?.sha256;
6615
6635
  let fresh = null;
6616
6636
  if (opts.refresh) {
6617
6637
  if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
6618
6638
  fresh = await provider.download(source.url);
6619
- if (recorded && sha256(fresh) === recorded.sha256) {
6620
- log2(` current ${path14.relative(process.cwd(), target)}`);
6639
+ if (recorded && sha256(fresh) === recordedRaw && !reapply) {
6640
+ log2(` current ${path13.relative(process.cwd(), target)}`);
6621
6641
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
6622
6642
  continue;
6623
6643
  }
6624
6644
  }
6625
- if (existsSync10(target)) {
6645
+ let raw = null;
6646
+ let final = null;
6647
+ if (existsSync9(target)) {
6626
6648
  const currentHash = await sha256File(target);
6627
6649
  if (recorded && currentHash === recorded.sha256) {
6628
- if (!fresh) {
6650
+ if (!fresh && !reapply) {
6629
6651
  if (cacheDir) {
6630
6652
  await cacheMedia(
6631
6653
  cacheDir,
6632
- await readFile7(target),
6654
+ await readFile6(target),
6633
6655
  recorded.mediaType ?? MediaType.PNG,
6634
6656
  recorded.sha256
6635
6657
  );
@@ -6637,7 +6659,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6637
6659
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
6638
6660
  continue;
6639
6661
  }
6640
- log2(` changed ${path14.relative(process.cwd(), target)} (upstream)`);
6662
+ if (reapply && !fresh) {
6663
+ if (!recorded.raw) raw = await readFile6(target);
6664
+ log2(` reapply ${path13.relative(process.cwd(), target)}`);
6665
+ } else {
6666
+ log2(` changed ${path13.relative(process.cwd(), target)} (upstream)`);
6667
+ }
6641
6668
  } else {
6642
6669
  const superseded = (entry.supersededOutputs ?? []).find(
6643
6670
  (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
@@ -6655,45 +6682,61 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6655
6682
  }
6656
6683
  }
6657
6684
  log2(
6658
- ` replace ${path14.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
6685
+ ` replace ${path13.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
6659
6686
  );
6660
6687
  }
6661
6688
  }
6662
- const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
6663
- let buf = fresh ?? (recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null);
6664
- if (buf) {
6665
- log2(` cached ${path14.relative(process.cwd(), target)}`);
6666
- } else {
6689
+ if (fresh) {
6690
+ raw = fresh;
6691
+ } else if (!raw && recorded && cacheDir) {
6692
+ if (!reapply) final = await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType);
6693
+ if (!final && recordedRaw) raw = await readCachedMedia(cacheDir, recordedRaw, expectedMediaType);
6694
+ if (final || raw) log2(` cached ${path13.relative(process.cwd(), target)}`);
6695
+ }
6696
+ if (!final && !raw) {
6667
6697
  if (!source.url) {
6668
6698
  throw new Error(`no source URL or cached bytes remain for ${source.role ?? "asset"}`);
6669
6699
  }
6670
- buf = await provider.download(source.url);
6700
+ raw = await provider.download(source.url);
6671
6701
  }
6672
6702
  let mediaType;
6703
+ const bytes = final ?? raw;
6673
6704
  try {
6674
- mediaType = validateMedia(buf, expectedMediaType);
6705
+ mediaType = validateMedia(bytes, expectedMediaType);
6675
6706
  } catch (err) {
6676
6707
  const label = expectedMediaType === MediaType.GIF ? "GIF" : "PNG";
6677
- const mismatch = detectMediaType(buf) !== expectedMediaType;
6708
+ const mismatch = detectMediaType(bytes) !== expectedMediaType;
6678
6709
  throw new Error(
6679
- `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${buf.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
6710
+ `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${bytes.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
6680
6711
  );
6681
6712
  }
6713
+ let rawHash = final ? recorded?.raw : void 0;
6714
+ let buf = bytes;
6715
+ if (!final) {
6716
+ if (cacheDir) await cacheMedia(cacheDir, raw, mediaType);
6717
+ const processed = applyPostprocess(raw, mediaType, wanted);
6718
+ buf = processed.bytes;
6719
+ if (processed.changed) {
6720
+ rawHash = sha256(raw);
6721
+ log2(` snapped ${path13.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
6722
+ }
6723
+ }
6682
6724
  if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
6683
- await mkdir3(path14.dirname(target), { recursive: true });
6725
+ await mkdir3(path13.dirname(target), { recursive: true });
6684
6726
  const tmp = `${target}.pixelkiln.tmp`;
6685
6727
  await writeFile3(tmp, buf);
6686
6728
  outputs.push({
6687
6729
  path: portableOutputPath(target, spec.root),
6688
6730
  sha256: sha256(buf),
6689
6731
  ...source.role ? { role: source.role } : {},
6690
- mediaType
6732
+ mediaType,
6733
+ ...rawHash ? { raw: rawHash } : {}
6691
6734
  });
6692
6735
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
6693
6736
  await saveLock(lockPath, lock);
6694
6737
  await rename3(tmp, target);
6695
6738
  wrote = true;
6696
- log2(` wrote ${path14.relative(process.cwd(), target)}`);
6739
+ log2(` wrote ${path13.relative(process.cwd(), target)}`);
6697
6740
  }
6698
6741
  if (opts.refresh && !wrote) {
6699
6742
  result.unchanged = (result.unchanged ?? 0) + 1;
@@ -6711,7 +6754,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6711
6754
  sourceUrls: persistentSources,
6712
6755
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
6713
6756
  error: null,
6714
- supersededOutputs: []
6757
+ supersededOutputs: [],
6758
+ postprocess: wanted
6715
6759
  });
6716
6760
  result.downloaded++;
6717
6761
  } catch (err) {
@@ -6732,10 +6776,10 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6732
6776
  return result;
6733
6777
  }
6734
6778
  async function readCachedMedia(cacheDir, hash, mediaType) {
6735
- const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6736
- if (!existsSync10(file)) return null;
6779
+ const file = path13.join(cacheDir, cacheFileName(hash, mediaType));
6780
+ if (!existsSync9(file)) return null;
6737
6781
  try {
6738
- const buf = await readFile7(file);
6782
+ const buf = await readFile6(file);
6739
6783
  if (sha256(buf) !== hash) return null;
6740
6784
  validateMedia(buf, mediaType);
6741
6785
  return buf;
@@ -6746,7 +6790,7 @@ async function readCachedMedia(cacheDir, hash, mediaType) {
6746
6790
  async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
6747
6791
  const hash = knownHash ?? sha256(buf);
6748
6792
  validateMedia(buf, mediaType);
6749
- const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6793
+ const file = path13.join(cacheDir, cacheFileName(hash, mediaType));
6750
6794
  if (await readCachedMedia(cacheDir, hash, mediaType)) return;
6751
6795
  await mkdir3(cacheDir, { recursive: true });
6752
6796
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
@@ -6754,7 +6798,7 @@ async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
6754
6798
  try {
6755
6799
  await rename3(tmp, file);
6756
6800
  } catch (err) {
6757
- if (!existsSync10(file)) throw err;
6801
+ if (!existsSync9(file)) throw err;
6758
6802
  } finally {
6759
6803
  await rm4(tmp, { force: true });
6760
6804
  }
@@ -6787,13 +6831,13 @@ async function pushTags(provider, specs, lock, opts = {}) {
6787
6831
  }
6788
6832
 
6789
6833
  // src/pipeline/adopt.ts
6790
- import { existsSync as existsSync12 } from "fs";
6791
- import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
6792
- import path15 from "path";
6834
+ import { existsSync as existsSync11 } from "fs";
6835
+ import { readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
6836
+ import path14 from "path";
6793
6837
 
6794
6838
  // src/cache.ts
6795
- import { mkdir as mkdir4, readFile as readFile8, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
6796
- import { existsSync as existsSync11 } from "fs";
6839
+ import { mkdir as mkdir4, readFile as readFile7, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
6840
+ import { existsSync as existsSync10 } from "fs";
6797
6841
  import pathModule from "path";
6798
6842
  import { z as z3 } from "zod";
6799
6843
  var HashCacheSchema = z3.object({
@@ -6809,9 +6853,9 @@ function parseCache(value) {
6809
6853
  return HashCacheSchema.parse(value);
6810
6854
  }
6811
6855
  async function loadCache(path43) {
6812
- if (!existsSync11(path43)) return { version: 1, hashes: {} };
6856
+ if (!existsSync10(path43)) return { version: 1, hashes: {} };
6813
6857
  try {
6814
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path43, "utf8")));
6858
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile7(path43, "utf8")));
6815
6859
  if (!parsed.success) return { version: 1, hashes: {} };
6816
6860
  return {
6817
6861
  version: 1,
@@ -6865,11 +6909,11 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
6865
6909
  const localByHash = /* @__PURE__ */ new Map();
6866
6910
  const unmatchedLocal = [];
6867
6911
  for (const spec of specs) {
6868
- if (!existsSync12(spec.outFile)) {
6912
+ if (!existsSync11(spec.outFile)) {
6869
6913
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
6870
6914
  continue;
6871
6915
  }
6872
- const bytes = await readFile9(spec.outFile);
6916
+ const bytes = await readFile8(spec.outFile);
6873
6917
  try {
6874
6918
  decodePng(bytes);
6875
6919
  } catch (err) {
@@ -6989,7 +7033,7 @@ async function writePromptsBack(manifestPath, lock, opts = {}) {
6989
7033
  const log2 = opts.onProgress ?? (() => {
6990
7034
  });
6991
7035
  const selectedAssets = opts.assetIds ? new Set(opts.assetIds) : null;
6992
- const raw = JSON.parse(await readFile9(manifestPath, "utf8"));
7036
+ const raw = JSON.parse(await readFile8(manifestPath, "utf8"));
6993
7037
  const best = /* @__PURE__ */ new Map();
6994
7038
  for (const entry of Object.values(lock.entries)) {
6995
7039
  if (opts.provider && entry.provider !== opts.provider) continue;
@@ -7022,19 +7066,19 @@ function formatUnmatchedRemote(objects, limit = 15) {
7022
7066
  }
7023
7067
 
7024
7068
  // src/pipeline/salvage.ts
7025
- import { readFile as readFile10 } from "fs/promises";
7026
- import { existsSync as existsSync13 } from "fs";
7027
- import path16 from "path";
7069
+ import { readFile as readFile9 } from "fs/promises";
7070
+ import { existsSync as existsSync12 } from "fs";
7071
+ import path15 from "path";
7028
7072
  function providerClaimId(provider, objectId) {
7029
7073
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
7030
7074
  }
7031
7075
  async function loadClaims(lockPaths, opts = {}) {
7032
7076
  const claimed = /* @__PURE__ */ new Set();
7033
7077
  for (const p of lockPaths) {
7034
- if (!existsSync13(p)) throw new Error(`Claim lockfile not found: ${p}`);
7078
+ if (!existsSync12(p)) throw new Error(`Claim lockfile not found: ${p}`);
7035
7079
  let parsed;
7036
7080
  try {
7037
- parsed = parseLock(JSON.parse(await readFile10(p, "utf8")));
7081
+ parsed = parseLock(JSON.parse(await readFile9(p, "utf8")));
7038
7082
  } catch {
7039
7083
  throw new Error(`Claim lockfile is malformed: ${p}`);
7040
7084
  }
@@ -7082,20 +7126,20 @@ function matchStyleByPattern(prompt, manifest) {
7082
7126
  return null;
7083
7127
  }
7084
7128
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
7085
- const own = path16.resolve(ownManifestPath);
7129
+ const own = path15.resolve(ownManifestPath);
7086
7130
  const siblingManifestPaths = [
7087
7131
  .../* @__PURE__ */ new Set([
7088
7132
  ...workspaceManifestPaths,
7089
- ...claimPaths.map((c) => path16.join(path16.dirname(path16.resolve(c)), "pixelkiln.manifest.json"))
7133
+ ...claimPaths.map((c) => path15.join(path15.dirname(path15.resolve(c)), "pixelkiln.manifest.json"))
7090
7134
  ])
7091
7135
  ];
7092
7136
  const siblings = [];
7093
7137
  for (const siblingManifestPath of siblingManifestPaths) {
7094
- if (path16.resolve(siblingManifestPath) === own) continue;
7095
- if (!existsSync13(siblingManifestPath)) continue;
7138
+ if (path15.resolve(siblingManifestPath) === own) continue;
7139
+ if (!existsSync12(siblingManifestPath)) continue;
7096
7140
  try {
7097
7141
  const { manifest } = await loadManifest(siblingManifestPath);
7098
- siblings.push({ label: path16.basename(path16.dirname(siblingManifestPath)), manifest });
7142
+ siblings.push({ label: path15.basename(path15.dirname(siblingManifestPath)), manifest });
7099
7143
  } catch {
7100
7144
  }
7101
7145
  }
@@ -7211,9 +7255,9 @@ async function applyTags(provider, decisions, existing, opts = {}) {
7211
7255
  }
7212
7256
 
7213
7257
  // src/workspace.ts
7214
- import { mkdir as mkdir5, readFile as readFile11, rename as rename5, rm as rm6, writeFile as writeFile6 } from "fs/promises";
7215
- import { existsSync as existsSync14 } from "fs";
7216
- import path17 from "path";
7258
+ import { mkdir as mkdir5, readFile as readFile10, rename as rename5, rm as rm6, writeFile as writeFile6 } from "fs/promises";
7259
+ import { existsSync as existsSync13 } from "fs";
7260
+ import path16 from "path";
7217
7261
  import { z as z4 } from "zod";
7218
7262
  var WorkspaceProjectSchema = z4.object({
7219
7263
  id: z4.string().min(1),
@@ -7232,18 +7276,18 @@ var WorkspaceSchema = z4.object({
7232
7276
  function parseWorkspace(raw) {
7233
7277
  const parsed = WorkspaceSchema.safeParse(raw);
7234
7278
  if (parsed.success) return parsed.data;
7235
- throw new Error(
7279
+ throw new ProjectError(
7236
7280
  `Workspace catalog is not valid v1:
7237
7281
  ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
7238
7282
  );
7239
7283
  }
7240
7284
  async function loadWorkspace(workspacePath) {
7241
- if (!existsSync14(workspacePath)) return { version: 1, projects: [] };
7285
+ if (!existsSync13(workspacePath)) return { version: 1, projects: [] };
7242
7286
  let raw;
7243
7287
  try {
7244
- raw = JSON.parse(await readFile11(workspacePath, "utf8"));
7288
+ raw = JSON.parse(await readFile10(workspacePath, "utf8"));
7245
7289
  } catch (err) {
7246
- throw new Error(
7290
+ throw new ProjectError(
7247
7291
  `Workspace catalog at ${workspacePath} is malformed:
7248
7292
  ${err instanceof Error ? err.message : String(err)}`
7249
7293
  );
@@ -7255,7 +7299,7 @@ async function saveWorkspace(workspacePath, ws) {
7255
7299
  version: 1,
7256
7300
  projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
7257
7301
  };
7258
- await mkdir5(path17.dirname(path17.resolve(workspacePath)), { recursive: true });
7302
+ await mkdir5(path16.dirname(path16.resolve(workspacePath)), { recursive: true });
7259
7303
  const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7260
7304
  try {
7261
7305
  await writeFile6(tmp, JSON.stringify(sorted, null, 2) + "\n");
@@ -7265,12 +7309,12 @@ async function saveWorkspace(workspacePath, ws) {
7265
7309
  }
7266
7310
  }
7267
7311
  function toPortablePath(dir, absolute) {
7268
- return path17.relative(dir, absolute).split(path17.sep).join("/");
7312
+ return path16.relative(dir, absolute).split(path16.sep).join("/");
7269
7313
  }
7270
7314
  function resolveProject(dir, project) {
7271
7315
  return {
7272
- manifestPath: path17.resolve(dir, project.manifest.split("/").join(path17.sep)),
7273
- lockPath: path17.resolve(dir, project.lock.split("/").join(path17.sep))
7316
+ manifestPath: path16.resolve(dir, project.manifest.split("/").join(path16.sep)),
7317
+ lockPath: path16.resolve(dir, project.lock.split("/").join(path16.sep))
7274
7318
  };
7275
7319
  }
7276
7320
  function validateWorkspace(ws, dir) {
@@ -7283,21 +7327,21 @@ function validateWorkspace(ws, dir) {
7283
7327
  const { manifestPath, lockPath } = resolveProject(dir, project);
7284
7328
  lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
7285
7329
  manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
7286
- if (path17.isAbsolute(project.manifest) || path17.isAbsolute(project.lock)) {
7330
+ if (path16.isAbsolute(project.manifest) || path16.isAbsolute(project.lock)) {
7287
7331
  diagnostics.push({
7288
7332
  id: "absolute-path",
7289
7333
  level: "warning",
7290
7334
  message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
7291
7335
  });
7292
7336
  }
7293
- if (!existsSync14(manifestPath)) {
7337
+ if (!existsSync13(manifestPath)) {
7294
7338
  diagnostics.push({
7295
7339
  id: "missing-manifest",
7296
7340
  level: "error",
7297
7341
  message: `project "${project.id}" manifest not found: ${manifestPath}`
7298
7342
  });
7299
7343
  }
7300
- if (!existsSync14(lockPath)) {
7344
+ if (!existsSync13(lockPath)) {
7301
7345
  diagnostics.push({
7302
7346
  id: "missing-lock",
7303
7347
  level: "error",
@@ -7344,8 +7388,8 @@ function validateWorkspace(ws, dir) {
7344
7388
  }
7345
7389
 
7346
7390
  // src/pick/salvage-server.ts
7347
- import { mkdir as mkdir6, writeFile as writeFile7, readFile as readFile13 } from "fs/promises";
7348
- import path18 from "path";
7391
+ import { mkdir as mkdir6, writeFile as writeFile7, readFile as readFile12 } from "fs/promises";
7392
+ import path17 from "path";
7349
7393
 
7350
7394
  // src/pick/salvage-sheet.ts
7351
7395
  var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -7505,7 +7549,7 @@ refresh();
7505
7549
 
7506
7550
  // src/pick/review-server.ts
7507
7551
  import { createServer } from "http";
7508
- import { readFile as readFile12 } from "fs/promises";
7552
+ import { readFile as readFile11 } from "fs/promises";
7509
7553
 
7510
7554
  // src/open.ts
7511
7555
  import { spawn } from "child_process";
@@ -7532,7 +7576,7 @@ function serveReviewPage(opts) {
7532
7576
  if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
7533
7577
  try {
7534
7578
  const asset = opts.assets.get(req.url);
7535
- const bytes = await readFile12(asset.path);
7579
+ const bytes = await readFile11(asset.path);
7536
7580
  res.writeHead(200, {
7537
7581
  "Content-Type": asset.contentType,
7538
7582
  "Cache-Control": "no-store",
@@ -7609,7 +7653,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7609
7653
  });
7610
7654
  const html = renderSalvageSheet(orphans, {
7611
7655
  styleId: ctx.styleId,
7612
- importDir: path18.relative(process.cwd(), ctx.importDir) || "."
7656
+ importDir: path17.relative(process.cwd(), ctx.importDir) || "."
7613
7657
  });
7614
7658
  const byId = new Map(orphans.map((o) => [o.id, o]));
7615
7659
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -7638,9 +7682,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7638
7682
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
7639
7683
  decodePng(buf);
7640
7684
  const assetId = idFromPrompt(orphan.prompt, taken);
7641
- const rel = path18.join("_salvaged", `${assetId}.png`);
7642
- const outFile = path18.resolve(ctx.importDir, rel);
7643
- await mkdir6(path18.dirname(outFile), { recursive: true });
7685
+ const rel = path17.join("_salvaged", `${assetId}.png`);
7686
+ const outFile = path17.resolve(ctx.importDir, rel);
7687
+ await mkdir6(path17.dirname(outFile), { recursive: true });
7644
7688
  await writeFile7(outFile, buf);
7645
7689
  ctx.manifest.assets[assetId] = {
7646
7690
  prompt: orphan.prompt,
@@ -7673,7 +7717,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7673
7717
  sourceUrl: durableSource,
7674
7718
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
7675
7719
  outputs: [{
7676
- path: portableOutputPath(outFile, path18.dirname(ctx.manifestPath)),
7720
+ path: portableOutputPath(outFile, path17.dirname(ctx.manifestPath)),
7677
7721
  sha256: sha256(buf)
7678
7722
  }],
7679
7723
  submittedAt: orphan.createdAt,
@@ -7697,7 +7741,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7697
7741
  }
7698
7742
  }
7699
7743
  await applyTags(provider, decisions, existingTags, { onProgress: log2 });
7700
- const raw = JSON.parse(await readFile13(ctx.manifestPath, "utf8"));
7744
+ const raw = JSON.parse(await readFile12(ctx.manifestPath, "utf8"));
7701
7745
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
7702
7746
  await writeFile7(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
7703
7747
  await saveLock(ctx.lockPath, ctx.lock);
@@ -7707,19 +7751,19 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7707
7751
  }
7708
7752
 
7709
7753
  // src/cli/project.ts
7710
- import path20 from "path";
7711
- import { existsSync as existsSync15 } from "fs";
7754
+ import path19 from "path";
7755
+ import { existsSync as existsSync14 } from "fs";
7712
7756
 
7713
7757
  // src/project.ts
7714
- import path19 from "path";
7758
+ import path18 from "path";
7715
7759
  async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {}) {
7716
- const absolute = path19.resolve(manifestPath);
7717
- const root = path19.dirname(absolute);
7760
+ const absolute = path18.resolve(manifestPath);
7761
+ const root = path18.dirname(absolute);
7718
7762
  if (opts.env !== false) {
7719
7763
  loadEnvFiles(root);
7720
- if (path19.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
7764
+ if (path18.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
7721
7765
  }
7722
- const lockPath = path19.resolve(opts.lockPath ?? path19.join(root, "pixelkiln.lock.json"));
7766
+ const lockPath = path18.resolve(opts.lockPath ?? path18.join(root, "pixelkiln.lock.json"));
7723
7767
  const loaded = await loadManifest(absolute);
7724
7768
  const specs = await resolveSpecs(loaded, { styles: opts.styles ?? [], assets: opts.assets ?? [] });
7725
7769
  const lock = await loadLock(lockPath);
@@ -7850,11 +7894,11 @@ async function workspaceStatus(ws, dir) {
7850
7894
 
7851
7895
  // src/cli/project.ts
7852
7896
  async function provenanceFile(id, file) {
7853
- const absolute = path20.resolve(file);
7897
+ const absolute = path19.resolve(file);
7854
7898
  return {
7855
7899
  id,
7856
7900
  path: absolute,
7857
- sha256: existsSync15(absolute) ? await sha256File(absolute) : null,
7901
+ sha256: existsSync14(absolute) ? await sha256File(absolute) : null,
7858
7902
  included: true
7859
7903
  };
7860
7904
  }
@@ -7885,14 +7929,14 @@ function accountProviderId(manifest, requested, command) {
7885
7929
  const providers = manifestProviderIds(manifest);
7886
7930
  if (requested) {
7887
7931
  if (!providers.includes(requested)) {
7888
- throw new Error(
7932
+ throw new UsageError(
7889
7933
  `Provider "${requested}" is not used by this manifest. Used: ${providers.join(", ")}.`
7890
7934
  );
7891
7935
  }
7892
7936
  return requested;
7893
7937
  }
7894
7938
  if (providers.length > 1) {
7895
- throw new Error(
7939
+ throw new UsageError(
7896
7940
  `${command} is account-scoped and this manifest uses ${providers.join(", ")}. Pass --provider <id>.`
7897
7941
  );
7898
7942
  }
@@ -7903,13 +7947,13 @@ function budgetsForPlan(plan, args) {
7903
7947
  const keyed = Object.entries(args.providerBudgets);
7904
7948
  for (const [provider] of keyed) {
7905
7949
  if (!providerIds.has(provider)) {
7906
- throw new Error(
7950
+ throw new BudgetError(
7907
7951
  `Budget names provider "${provider}", but this run has work only for ${[...providerIds].join(", ") || "no providers"}.`
7908
7952
  );
7909
7953
  }
7910
7954
  }
7911
7955
  if (plan.groups.length > 1 && args.budget !== void 0) {
7912
- throw new Error(
7956
+ throw new BudgetError(
7913
7957
  "A mixed-provider run needs provider-keyed budgets, for example --budget pixellab=40 --budget retrodiffusion=1.25."
7914
7958
  );
7915
7959
  }
@@ -7917,7 +7961,7 @@ function budgetsForPlan(plan, args) {
7917
7961
  for (const group of plan.groups) {
7918
7962
  const ceiling = args.providerBudgets[group.provider];
7919
7963
  if (plan.groups.length > 1 && group.costUnit !== "free" && group.cost > 0 && ceiling === void 0) {
7920
- throw new Error(
7964
+ throw new BudgetError(
7921
7965
  `Mixed-provider run is missing --budget ${group.provider}=<amount> for ${formatCost(group.costUnit, group.cost)} of planned work.`
7922
7966
  );
7923
7967
  }
@@ -7926,15 +7970,15 @@ function budgetsForPlan(plan, args) {
7926
7970
  return out;
7927
7971
  }
7928
7972
  async function requireCompleteWorkspaceClaims(workspacePath) {
7929
- if (!existsSync15(workspacePath)) {
7930
- throw new Error(`Workspace catalog not found: ${workspacePath}`);
7973
+ if (!existsSync14(workspacePath)) {
7974
+ throw new ProjectError(`Workspace catalog not found: ${workspacePath}`);
7931
7975
  }
7932
- const dir = path20.dirname(path20.resolve(workspacePath));
7976
+ const dir = path19.dirname(path19.resolve(workspacePath));
7933
7977
  const ws = await loadWorkspace(workspacePath);
7934
7978
  const diagnostics = validateWorkspace(ws, dir);
7935
7979
  const errors = diagnostics.filter((d) => d.level === "error");
7936
7980
  if (errors.length) {
7937
- throw new Error(
7981
+ throw new ProjectError(
7938
7982
  `Workspace catalog at ${workspacePath} is not safe to derive a claim set from:
7939
7983
  ` + errors.map((d) => ` ${d.id}: ${d.message}`).join("\n")
7940
7984
  );
@@ -7952,9 +7996,9 @@ async function openAccountProject(args) {
7952
7996
  return project;
7953
7997
  }
7954
7998
  async function open(args) {
7955
- if (!existsSync15(path20.resolve(args.manifest))) {
7956
- throw new Error(
7957
- `No manifest at ${path20.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
7999
+ if (!existsSync14(path19.resolve(args.manifest))) {
8000
+ throw new ProjectError(
8001
+ `No manifest at ${path19.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
7958
8002
  );
7959
8003
  }
7960
8004
  const project = await openProject(args.manifest, { lockPath: args.lock, styles: args.styles, assets: args.assets });
@@ -7962,7 +8006,7 @@ async function open(args) {
7962
8006
  let specs = project.specs;
7963
8007
  const accountProvider = ACCOUNT_COMMANDS.has(args.command) ? accountProviderId(loaded.manifest, args.provider, args.command) : void 0;
7964
8008
  if (args.provider && !accountProvider) {
7965
- throw new Error("--provider is only used by balance, adopt, salvage, purge, or workspace add.");
8009
+ throw new UsageError("--provider is only used by balance, adopt, salvage, purge, or workspace add.");
7966
8010
  }
7967
8011
  if (accountProvider) specs = specs.filter((spec) => spec.provider === accountProvider);
7968
8012
  const accountManifest = accountProvider ? {
@@ -7975,7 +8019,7 @@ async function open(args) {
7975
8019
  )
7976
8020
  } : loaded.manifest;
7977
8021
  if (accountProvider && args.styles.some((styleId) => !accountManifest.styles[styleId])) {
7978
- throw new Error(`Selected style is not assigned to provider "${accountProvider}".`);
8022
+ throw new UsageError(`Selected style is not assigned to provider "${accountProvider}".`);
7979
8023
  }
7980
8024
  return { loaded, specs, lock, accountProvider, accountManifest };
7981
8025
  }
@@ -7996,7 +8040,7 @@ function providerCache(mode2) {
7996
8040
 
7997
8041
  // src/cli/commands/account.ts
7998
8042
  async function runBalance(args) {
7999
- loadEnvFiles(path21.dirname(path21.resolve(args.manifest)));
8043
+ loadEnvFiles(path20.dirname(path20.resolve(args.manifest)));
8000
8044
  loadEnvFiles(process.cwd());
8001
8045
  const loaded = await loadManifest(args.manifest);
8002
8046
  const selectedProvider = accountProviderId(loaded.manifest, args.provider, "balance");
@@ -8043,12 +8087,12 @@ async function runAdopt(args) {
8043
8087
  tagged ${n} object(s) upstream`);
8044
8088
  }
8045
8089
  if (args.writePrompts) {
8046
- const { filled, stillEmpty } = await writePromptsBack(path21.resolve(args.manifest), lock, {
8090
+ const { filled, stillEmpty } = await writePromptsBack(path20.resolve(args.manifest), lock, {
8047
8091
  onProgress: log,
8048
8092
  provider: provider.id,
8049
8093
  assetIds: specs.map((spec) => spec.assetId)
8050
8094
  });
8051
- log(` recovered ${filled} prompt(s) into ${path21.relative(process.cwd(), args.manifest)}`);
8095
+ log(` recovered ${filled} prompt(s) into ${path20.relative(process.cwd(), args.manifest)}`);
8052
8096
  const reloaded = await loadManifest(args.manifest);
8053
8097
  const rebased = (await resolveSpecs(reloaded, {
8054
8098
  styles: args.styles,
@@ -8078,12 +8122,12 @@ async function runSalvage2(args) {
8078
8122
  const provider = providerFor(accountProvider);
8079
8123
  const jsonMode = args.dryRun && args.json;
8080
8124
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
8081
- const ownLock = path21.resolve(args.lock);
8125
+ const ownLock = path20.resolve(args.lock);
8082
8126
  let workspaceProjects = [];
8083
8127
  let workspaceDir = "";
8084
8128
  if (args.workspace) {
8085
- const workspacePath = path21.resolve(args.workspace);
8086
- workspaceDir = path21.dirname(workspacePath);
8129
+ const workspacePath = path20.resolve(args.workspace);
8130
+ workspaceDir = path20.dirname(workspacePath);
8087
8131
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
8088
8132
  workspaceProjects = complete.ws.projects;
8089
8133
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -8092,12 +8136,12 @@ async function runSalvage2(args) {
8092
8136
  const lockPaths = [
8093
8137
  .../* @__PURE__ */ new Set([
8094
8138
  ...workspaceLockPaths,
8095
- ...existsSync16(ownLock) ? [ownLock] : [],
8096
- ...args.claims.map((c) => path21.resolve(c))
8139
+ ...existsSync15(ownLock) ? [ownLock] : [],
8140
+ ...args.claims.map((c) => path20.resolve(c))
8097
8141
  ])
8098
8142
  ];
8099
8143
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
8100
- for (const p of lockPaths) diag(` ${path21.relative(process.cwd(), p)}`);
8144
+ for (const p of lockPaths) diag(` ${path20.relative(process.cwd(), p)}`);
8101
8145
  if (!args.claims.length && !args.workspace) {
8102
8146
  diag(
8103
8147
  `
@@ -8105,7 +8149,7 @@ async function runSalvage2(args) {
8105
8149
  pass every other project's lockfile via --claims a.json,b.json, or
8106
8150
  register every project in a workspace catalog and pass --workspace.`
8107
8151
  );
8108
- } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path21.resolve(args.manifest))) {
8152
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path20.resolve(args.manifest))) {
8109
8153
  diag(
8110
8154
  `
8111
8155
  This project's manifest is not registered in the workspace catalog. Its own
@@ -8189,7 +8233,7 @@ async function runSalvage2(args) {
8189
8233
  manifestPath: loaded.path,
8190
8234
  manifest: accountManifest,
8191
8235
  styleId,
8192
- importDir: path21.resolve(loaded.root, style.outDir),
8236
+ importDir: path20.resolve(loaded.root, style.outDir),
8193
8237
  lock,
8194
8238
  lockPath: args.lock
8195
8239
  },
@@ -8290,17 +8334,17 @@ async function runTag(args) {
8290
8334
  }
8291
8335
 
8292
8336
  // src/cli/commands/edit.ts
8293
- import path24 from "path";
8337
+ import path23 from "path";
8294
8338
 
8295
8339
  // src/pipeline/hand-edit.ts
8296
- import { existsSync as existsSync17 } from "fs";
8297
- import { copyFile, mkdir as mkdir7, readFile as readFile15, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
8298
- import path23 from "path";
8340
+ import { existsSync as existsSync16 } from "fs";
8341
+ import { copyFile, mkdir as mkdir7, readFile as readFile14, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
8342
+ import path22 from "path";
8299
8343
  import { z as z6 } from "zod";
8300
8344
 
8301
8345
  // src/manifest-edit.ts
8302
- import { readFile as readFile14, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
8303
- import path22 from "path";
8346
+ import { readFile as readFile13, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
8347
+ import path21 from "path";
8304
8348
  import { z as z5 } from "zod";
8305
8349
  var HexSha = z5.string().regex(/^[0-9a-f]{64}$/);
8306
8350
  var AssetPatchSchema = z5.object({
@@ -8374,6 +8418,8 @@ var ManifestEditSchema = z5.discriminatedUnion("action", [
8374
8418
  promptSuffix: z5.string().optional(),
8375
8419
  /** `#rrggbb` values; null or empty clears the style's own palette. */
8376
8420
  palette: z5.array(z5.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
8421
+ /** Snap downloaded art to the palette; false clears the style's own value. */
8422
+ enforcePalette: z5.boolean().optional(),
8377
8423
  /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
8378
8424
  view: z5.string().max(64).nullable().optional(),
8379
8425
  /** pixflux only; null clears the style's own value. */
@@ -8465,6 +8511,7 @@ function applyEdit(raw, edit) {
8465
8511
  const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
8466
8512
  setOrDelete(style, "palette", colors.length ? colors : null);
8467
8513
  }
8514
+ if (patch2.enforcePalette !== void 0) setOrDelete(style, "enforcePalette", patch2.enforcePalette || null);
8468
8515
  if (patch2.view !== void 0) setOrDelete(style, "view", patch2.view?.trim() || null);
8469
8516
  if (patch2.noBackground !== void 0) setOrDelete(style, "noBackground", patch2.noBackground);
8470
8517
  return;
@@ -8512,8 +8559,8 @@ function applyEdit(raw, edit) {
8512
8559
  }
8513
8560
  }
8514
8561
  async function applyManifestEdit(manifestPath, edit) {
8515
- const absolute = path22.resolve(manifestPath);
8516
- const original = await readFile14(absolute, "utf8");
8562
+ const absolute = path21.resolve(manifestPath);
8563
+ const original = await readFile13(absolute, "utf8");
8517
8564
  const before = sha256(original);
8518
8565
  if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
8519
8566
  let raw;
@@ -8527,9 +8574,9 @@ async function applyManifestEdit(manifestPath, edit) {
8527
8574
  applyEdit(raw, edit);
8528
8575
  const next = serializeLike(original, raw);
8529
8576
  if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
8530
- const tmp = path22.join(
8531
- path22.dirname(absolute),
8532
- `.${path22.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
8577
+ const tmp = path21.join(
8578
+ path21.dirname(absolute),
8579
+ `.${path21.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
8533
8580
  );
8534
8581
  try {
8535
8582
  await writeFile8(tmp, next);
@@ -8551,11 +8598,11 @@ async function applyManifestEdit(manifestPath, edit) {
8551
8598
  var HAND_EDIT_DIR = "edits";
8552
8599
  function handEditPath(loaded, spec) {
8553
8600
  const style = loaded.manifest.styles[spec.styleId];
8554
- const outDir = path23.resolve(loaded.root, style?.outDir ?? ".");
8555
- const relative = path23.relative(outDir, spec.outFile);
8556
- const inside = relative && !relative.startsWith("..") && !path23.isAbsolute(relative);
8557
- const file = inside ? relative : path23.basename(spec.outFile);
8558
- return path23.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8601
+ const outDir = path22.resolve(loaded.root, style?.outDir ?? ".");
8602
+ const relative = path22.relative(outDir, spec.outFile);
8603
+ const inside = relative && !relative.startsWith("..") && !path22.isAbsolute(relative);
8604
+ const file = inside ? relative : path22.basename(spec.outFile);
8605
+ return path22.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8559
8606
  }
8560
8607
  function handEditBases(spec, lock) {
8561
8608
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -8568,10 +8615,10 @@ function handEditBases(spec, lock) {
8568
8615
  }));
8569
8616
  if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
8570
8617
  if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
8571
- if (!members.every((member) => existsSync17(member.path))) return null;
8618
+ if (!members.every((member) => existsSync16(member.path))) return null;
8572
8619
  return members.map(({ role, path: file }) => ({ role, path: file }));
8573
8620
  }
8574
- return existsSync17(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8621
+ return existsSync16(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8575
8622
  }
8576
8623
  function handEditMembers(loaded, spec, lock, editPath) {
8577
8624
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -8597,18 +8644,18 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
8597
8644
  `${spec.styleId}/${spec.assetId} declares ${spec.source}, one committed file placed for the whole set; clear that source (pixelkiln edit detach) to hand-edit the members one by one`
8598
8645
  );
8599
8646
  }
8600
- const existing = spec.source ? path23.resolve(loaded.root, spec.source) : null;
8647
+ const existing = spec.source ? path22.resolve(loaded.root, spec.source) : null;
8601
8648
  const editPath = existing ?? handEditPath(loaded, spec);
8602
8649
  const members = handEditMembers(loaded, spec, lock, editPath);
8603
8650
  let created = false;
8604
8651
  for (const member of members) {
8605
- if (existsSync17(member.path)) continue;
8652
+ if (existsSync16(member.path)) continue;
8606
8653
  if (!member.base) {
8607
8654
  throw new ManifestEditError(
8608
8655
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
8609
8656
  );
8610
8657
  }
8611
- await mkdir7(path23.dirname(member.path), { recursive: true });
8658
+ await mkdir7(path22.dirname(member.path), { recursive: true });
8612
8659
  await copyFile(member.base, member.path);
8613
8660
  created = true;
8614
8661
  }
@@ -8687,7 +8734,7 @@ function handEditProjectPath(editPath) {
8687
8734
  async function readHandEditCompanion(editPath) {
8688
8735
  let text;
8689
8736
  try {
8690
- text = await readFile15(handEditCompanionPath(editPath), "utf8");
8737
+ text = await readFile14(handEditCompanionPath(editPath), "utf8");
8691
8738
  } catch {
8692
8739
  return null;
8693
8740
  }
@@ -8730,7 +8777,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
8730
8777
  }
8731
8778
  });
8732
8779
  const bases = handEditBases(spec, lock);
8733
- const expectedSize = bases?.[0] ? decodePng(await readFile15(bases[0].path)) : { width: spec.width, height: spec.height };
8780
+ const expectedSize = bases?.[0] ? decodePng(await readFile14(bases[0].path)) : { width: spec.width, height: spec.height };
8734
8781
  for (const [index, image] of decoded.entries()) {
8735
8782
  if (image.width !== expectedSize.width || image.height !== expectedSize.height) {
8736
8783
  const which = memberSet ? `member ${incoming[index].role ?? index}` : "the edit";
@@ -8746,7 +8793,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
8746
8793
  editor: input.editor,
8747
8794
  protocol: input.protocol,
8748
8795
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
8749
- project: projectPath ? path23.basename(projectPath) : null,
8796
+ project: projectPath ? path22.basename(projectPath) : null,
8750
8797
  outputs: incoming.map((frame, index) => ({
8751
8798
  role: frame.role,
8752
8799
  basedOn: basedOnHash(entry, index),
@@ -8806,7 +8853,7 @@ async function runEdit(args) {
8806
8853
  const set = started.members.length > 1;
8807
8854
  const kind = spec.generator === "frames" ? "frames" : "members";
8808
8855
  log(` ${started.created ? "created" : "found"} ${started.source}${set ? ` (${started.members.length} ${kind})` : ""}${started.declared ? " and declared it as the asset's source" : ""}`);
8809
- if (set) for (const member of started.members) log(` ${member.role ?? ""} ${path24.relative(process.cwd(), member.path)}`);
8856
+ if (set) for (const member of started.members) log(` ${member.role ?? ""} ${path23.relative(process.cwd(), member.path)}`);
8810
8857
  if (args.noOpen) {
8811
8858
  log(` edit ${set ? "them" : "it"} with your own tool, then run pixelkiln plan; mount and pack place ${set ? "them" : "it"} in place of the generated art`);
8812
8859
  } else {
@@ -8816,17 +8863,17 @@ async function runEdit(args) {
8816
8863
  }
8817
8864
 
8818
8865
  // src/cli/commands/gallery.ts
8819
- import path31 from "path";
8820
- import { existsSync as existsSync23 } from "fs";
8866
+ import path30 from "path";
8867
+ import { existsSync as existsSync22 } from "fs";
8821
8868
 
8822
8869
  // src/gallery/snapshot.ts
8823
8870
  import { createHash as createHash3 } from "crypto";
8824
- import { existsSync as existsSync19 } from "fs";
8825
- import { readFile as readFile16, stat as stat2 } from "fs/promises";
8826
- import path27 from "path";
8871
+ import { existsSync as existsSync18 } from "fs";
8872
+ import { readFile as readFile15, stat as stat2 } from "fs/promises";
8873
+ import path26 from "path";
8827
8874
 
8828
8875
  // src/gallery/edit.ts
8829
- import path25 from "path";
8876
+ import path24 from "path";
8830
8877
  import { z as z7 } from "zod";
8831
8878
  var Base64 = z7.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
8832
8879
  var HandEditRequestSchema = z7.discriminatedUnion("action", [
@@ -8916,7 +8963,7 @@ function createGalleryEditHandler(opts) {
8916
8963
  }
8917
8964
  const result = await applyManifestEdit(manifestPath, parsed.data);
8918
8965
  if (result.changed) {
8919
- const relative = path25.relative(process.cwd(), result.manifestPath);
8966
+ const relative = path24.relative(process.cwd(), result.manifestPath);
8920
8967
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
8921
8968
  const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.action === "set-source" || parsed.data.action === "clear-source" ? `${parsed.data.styleId}/${parsed.data.assetId} source` : parsed.data.assetId;
8922
8969
  log2(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
@@ -8926,8 +8973,8 @@ function createGalleryEditHandler(opts) {
8926
8973
  }
8927
8974
 
8928
8975
  // src/pipeline/history.ts
8929
- import { existsSync as existsSync18 } from "fs";
8930
- import path26 from "path";
8976
+ import { existsSync as existsSync17 } from "fs";
8977
+ import path25 from "path";
8931
8978
  var HISTORY_ENV = "PIXELKILN_HISTORY";
8932
8979
  function historyLimit(manifest, env = process.env) {
8933
8980
  if (manifest?.history !== void 0) return manifest.history;
@@ -8959,6 +9006,7 @@ function retireGeneration(entry, retiredAt) {
8959
9006
  cost: entry.cost,
8960
9007
  costUnit: entry.costUnit,
8961
9008
  provider: entry.provider,
9009
+ postprocess: entry.postprocess,
8962
9010
  retiredAt
8963
9011
  };
8964
9012
  }
@@ -8971,9 +9019,13 @@ function historyAfterReplacing(previous, limit, retiredAt) {
8971
9019
  }
8972
9020
  function referencedHashes(lock) {
8973
9021
  const hashes = /* @__PURE__ */ new Set();
9022
+ const add = (output) => {
9023
+ hashes.add(output.sha256);
9024
+ if (output.raw) hashes.add(output.raw);
9025
+ };
8974
9026
  for (const entry of Object.values(lock.entries)) {
8975
- for (const output of entry.outputs) hashes.add(output.sha256);
8976
- for (const generation of entry.history ?? []) for (const output of generation.outputs) hashes.add(output.sha256);
9027
+ for (const output of entry.outputs) add(output);
9028
+ for (const generation of entry.history ?? []) for (const output of generation.outputs) add(output);
8977
9029
  }
8978
9030
  return hashes;
8979
9031
  }
@@ -9008,10 +9060,11 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
9008
9060
  }
9009
9061
  for (let i = 0; i < entry.outputs.length; i++) {
9010
9062
  const file = currentEntryOutputPath(entry, spec, i);
9011
- if (!existsSync18(file)) continue;
9063
+ if (!existsSync17(file)) continue;
9012
9064
  if (await sha256File(file) !== entry.outputs[i].sha256 && !opts.force) {
9013
- throw new Error(
9014
- `${path26.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`
9065
+ throw new OverwriteRefusedError(
9066
+ `${path25.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`,
9067
+ [file]
9015
9068
  );
9016
9069
  }
9017
9070
  }
@@ -9043,6 +9096,7 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
9043
9096
  cost: generation.cost,
9044
9097
  costUnit: generation.costUnit,
9045
9098
  provider: generation.provider,
9099
+ postprocess: generation.postprocess,
9046
9100
  history
9047
9101
  });
9048
9102
  await saveLock(lockPath, lock);
@@ -9061,7 +9115,7 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
9061
9115
  // src/gallery/snapshot.ts
9062
9116
  var PROJECT_FILE_TYPE = "application/octet-stream";
9063
9117
  function galleryMediaId(absolutePath) {
9064
- return createHash3("sha256").update(path27.resolve(absolutePath)).digest("hex").slice(0, 24);
9118
+ return createHash3("sha256").update(path26.resolve(absolutePath)).digest("hex").slice(0, 24);
9065
9119
  }
9066
9120
  function galleryMediaRoute(id) {
9067
9121
  return `/media/${id}`;
@@ -9080,9 +9134,9 @@ function describeHistory(media, entry, cacheDir) {
9080
9134
  return (entry.history ?? []).map((generation, i) => {
9081
9135
  const outputs = generation.outputs.map((output) => {
9082
9136
  const mediaType = output.mediaType ?? MediaType.PNG;
9083
- const file = path27.join(cacheDir, cacheFileName(output.sha256, mediaType));
9137
+ const file = path26.join(cacheDir, cacheFileName(output.sha256, mediaType));
9084
9138
  let url = null;
9085
- if (existsSync19(file)) {
9139
+ if (existsSync18(file)) {
9086
9140
  const id = galleryMediaId(file);
9087
9141
  media.set(id, { path: file, contentType: mediaType });
9088
9142
  url = `${galleryMediaRoute(id)}?v=${output.sha256.slice(0, 24)}`;
@@ -9111,7 +9165,7 @@ function describeHistory(media, entry, cacheDir) {
9111
9165
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
9112
9166
  async function samePixels(a, b) {
9113
9167
  try {
9114
- const [left, right] = await Promise.all([readFile16(a), readFile16(b)]);
9168
+ const [left, right] = await Promise.all([readFile15(a), readFile15(b)]);
9115
9169
  if (left.length > MAX_COMPARED_BYTES || right.length > MAX_COMPARED_BYTES) return false;
9116
9170
  const x = decodePng(left);
9117
9171
  const y = decodePng(right);
@@ -9164,10 +9218,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
9164
9218
  async function describeQuality(media, root, inspection) {
9165
9219
  const outputPaths = inspection.outputs ?? [inspection.output];
9166
9220
  const outputs = await Promise.all(
9167
- outputPaths.map((file) => describeOutput(media, root, path27.resolve(root, file)))
9221
+ outputPaths.map((file) => describeOutput(media, root, path26.resolve(root, file)))
9168
9222
  );
9169
- const recordPath = path27.resolve(root, inspection.record);
9170
- const recordExists = existsSync19(recordPath);
9223
+ const recordPath = path26.resolve(root, inspection.record);
9224
+ const recordExists = existsSync18(recordPath);
9171
9225
  let options = null;
9172
9226
  let check = null;
9173
9227
  if (recordExists) {
@@ -9186,7 +9240,7 @@ async function describeQuality(media, root, inspection) {
9186
9240
  return {
9187
9241
  state: inspection.state,
9188
9242
  reason: inspection.reason,
9189
- output: portableOutputPath(path27.resolve(root, inspection.output), root),
9243
+ output: portableOutputPath(path26.resolve(root, inspection.output), root),
9190
9244
  record: portableOutputPath(recordPath, root),
9191
9245
  recordExists,
9192
9246
  outputs,
@@ -9223,9 +9277,9 @@ async function buildGallerySnapshot(opts) {
9223
9277
  const media = /* @__PURE__ */ new Map();
9224
9278
  const plan = await buildPlan(specs, lock);
9225
9279
  const items = [];
9226
- const manifestText = await readFile16(loaded.path, "utf8");
9280
+ const manifestText = await readFile15(loaded.path, "utf8");
9227
9281
  const manifestSha256 = sha256(manifestText);
9228
- const cacheDir = path27.resolve(path27.dirname(opts.lockPath), ".pixelkiln", "cache");
9282
+ const cacheDir = path26.resolve(path26.dirname(opts.lockPath), ".pixelkiln", "cache");
9229
9283
  let rawStyles = {};
9230
9284
  try {
9231
9285
  const raw = JSON.parse(manifestText);
@@ -9249,12 +9303,12 @@ async function buildGallerySnapshot(opts) {
9249
9303
  )
9250
9304
  );
9251
9305
  if (spec.source) {
9252
- const editPath = path27.resolve(root, spec.source);
9306
+ const editPath = path26.resolve(root, spec.source);
9253
9307
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
9254
9308
  const editShas = await Promise.all(
9255
9309
  Array.from({ length: memberCount }, async (_, index) => {
9256
9310
  const file = sourceOutputPath(spec.source, entry, index, root);
9257
- return { file, sha256: existsSync19(file) ? await sha256File(file) : null };
9311
+ return { file, sha256: existsSync18(file) ? await sha256File(file) : null };
9258
9312
  })
9259
9313
  );
9260
9314
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -9262,7 +9316,7 @@ async function buildGallerySnapshot(opts) {
9262
9316
  const companion = await readHandEditCompanion(editPath);
9263
9317
  if (companion) {
9264
9318
  const projectPath = handEditProjectPath(editPath);
9265
- const project = companion.project && existsSync19(projectPath) ? await fileInfo(projectPath) : null;
9319
+ const project = companion.project && existsSync18(projectPath) ? await fileInfo(projectPath) : null;
9266
9320
  let projectUrl = null;
9267
9321
  if (project) {
9268
9322
  const id = galleryMediaId(projectPath);
@@ -9298,8 +9352,8 @@ async function buildGallerySnapshot(opts) {
9298
9352
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
9299
9353
  }
9300
9354
  } else if (spec.source) {
9301
- outputs = [await describeOutput(media, root, path27.resolve(root, spec.source))];
9302
- } else if (!entry && existsSync19(spec.outFile)) {
9355
+ outputs = [await describeOutput(media, root, path26.resolve(root, spec.source))];
9356
+ } else if (!entry && existsSync18(spec.outFile)) {
9303
9357
  outputs = [await describeOutput(media, root, spec.outFile)];
9304
9358
  } else {
9305
9359
  outputs = [];
@@ -9341,6 +9395,7 @@ async function buildGallerySnapshot(opts) {
9341
9395
  outputs,
9342
9396
  quality,
9343
9397
  providerMetadata: entry?.providerMetadata ?? {},
9398
+ postprocess: entry?.postprocess ?? null,
9344
9399
  asset,
9345
9400
  source: spec.source ?? null,
9346
9401
  edit,
@@ -9401,6 +9456,7 @@ async function buildGallerySnapshot(opts) {
9401
9456
  outputs,
9402
9457
  quality: null,
9403
9458
  providerMetadata: entry.providerMetadata ?? {},
9459
+ postprocess: entry.postprocess ?? null,
9404
9460
  asset: null,
9405
9461
  source: null,
9406
9462
  edit: null,
@@ -9449,6 +9505,7 @@ async function buildGallerySnapshot(opts) {
9449
9505
  promptPrefix: style?.promptPrefix ?? "",
9450
9506
  promptSuffix: style?.promptSuffix ?? "",
9451
9507
  palette: style?.palette ?? [],
9508
+ enforcePalette: style?.enforcePalette ?? false,
9452
9509
  view: style?.view ?? null,
9453
9510
  noBackground: style?.noBackground ?? true,
9454
9511
  quality: Boolean(style?.quality),
@@ -9478,7 +9535,7 @@ async function buildGallerySnapshot(opts) {
9478
9535
  id: loaded.manifest.name,
9479
9536
  name: loaded.manifest.name,
9480
9537
  manifest: loaded.path,
9481
- lock: path27.resolve(opts.lockPath),
9538
+ lock: path26.resolve(opts.lockPath),
9482
9539
  root,
9483
9540
  provider: loaded.manifest.provider,
9484
9541
  account: null,
@@ -9504,7 +9561,7 @@ async function buildGallerySnapshot(opts) {
9504
9561
  return { snapshot, media };
9505
9562
  }
9506
9563
  async function buildWorkspaceGallerySnapshot(opts) {
9507
- const dir = path27.dirname(path27.resolve(opts.workspacePath));
9564
+ const dir = path26.dirname(path26.resolve(opts.workspacePath));
9508
9565
  const media = /* @__PURE__ */ new Map();
9509
9566
  const projects = [];
9510
9567
  const items = [];
@@ -9547,11 +9604,11 @@ async function buildWorkspaceGallerySnapshot(opts) {
9547
9604
  id: project.id,
9548
9605
  name: loaded.manifest.name,
9549
9606
  manifest: manifestPath,
9550
- lock: path27.resolve(lockPath),
9607
+ lock: path26.resolve(lockPath),
9551
9608
  root: loaded.root,
9552
9609
  provider: loaded.manifest.provider,
9553
9610
  account: project.account ?? null,
9554
- manifestSha256: manifestSha256 ?? sha256(await readFile16(manifestPath, "utf8")),
9611
+ manifestSha256: manifestSha256 ?? sha256(await readFile15(manifestPath, "utf8")),
9555
9612
  historyLimit: historyLimit(loaded.manifest),
9556
9613
  entries: Object.keys(lock.entries).length,
9557
9614
  items: projectItems.length,
@@ -9563,8 +9620,8 @@ async function buildWorkspaceGallerySnapshot(opts) {
9563
9620
  id: project.id,
9564
9621
  name: project.id,
9565
9622
  manifest: manifestPath,
9566
- lock: path27.resolve(lockPath),
9567
- root: path27.dirname(manifestPath),
9623
+ lock: path26.resolve(lockPath),
9624
+ root: path26.dirname(manifestPath),
9568
9625
  provider: project.provider,
9569
9626
  account: project.account ?? null,
9570
9627
  manifestSha256: null,
@@ -9591,7 +9648,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9591
9648
  version: 1,
9592
9649
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9593
9650
  project: null,
9594
- workspace: { path: path27.resolve(opts.workspacePath), dir, projects },
9651
+ workspace: { path: path26.resolve(opts.workspacePath), dir, projects },
9595
9652
  filter,
9596
9653
  totals: {
9597
9654
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -9610,7 +9667,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9610
9667
  // src/gallery/server.ts
9611
9668
  import { randomBytes } from "crypto";
9612
9669
  import { createServer as createServer2 } from "http";
9613
- import { readFile as readFile17 } from "fs/promises";
9670
+ import { readFile as readFile16 } from "fs/promises";
9614
9671
 
9615
9672
  // src/gallery/page.ts
9616
9673
  import { readFileSync as readFileSync4 } from "fs";
@@ -9851,7 +9908,7 @@ async function serveGallery(opts) {
9851
9908
  const asset = reviewAssets.get(jobId)?.get(url2.pathname);
9852
9909
  if (!asset) return fail(404, "no such review asset");
9853
9910
  try {
9854
- const bytes = await readFile17(asset.path);
9911
+ const bytes = await readFile16(asset.path);
9855
9912
  res.writeHead(200, {
9856
9913
  "Content-Type": asset.contentType,
9857
9914
  "Cache-Control": "no-store",
@@ -9927,7 +9984,7 @@ async function serveGallery(opts) {
9927
9984
  if (!asset) return fail(404, "no such gallery media");
9928
9985
  let bytes;
9929
9986
  try {
9930
- bytes = await readFile17(asset.path);
9987
+ bytes = await readFile16(asset.path);
9931
9988
  } catch {
9932
9989
  return fail(404, "gallery media is no longer on disk; refresh the page");
9933
9990
  }
@@ -10080,7 +10137,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
10080
10137
  if (opts.budget != null) {
10081
10138
  const cost = [...estimates.values()].reduce((sum, estimate) => sum + estimate.amount, 0);
10082
10139
  if (cost > opts.budget) {
10083
- throw new Error(
10140
+ throw new BudgetError(
10084
10141
  `This run would spend ${formatCost(unit, cost)} but the budget is ${formatCost(unit, opts.budget)}. Narrow it with --only/--style, or raise --budget.`
10085
10142
  );
10086
10143
  }
@@ -10240,13 +10297,13 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
10240
10297
  }
10241
10298
 
10242
10299
  // src/pick/server.ts
10243
- import { existsSync as existsSync21 } from "fs";
10244
- import { readFile as readFile19 } from "fs/promises";
10300
+ import { existsSync as existsSync20 } from "fs";
10301
+ import { readFile as readFile18 } from "fs/promises";
10245
10302
 
10246
10303
  // src/pipeline/init.ts
10247
- import { readdir, readFile as readFile18, writeFile as writeFile10 } from "fs/promises";
10248
- import { existsSync as existsSync20 } from "fs";
10249
- import path28 from "path";
10304
+ import { readdir, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
10305
+ import { existsSync as existsSync19 } from "fs";
10306
+ import path27 from "path";
10250
10307
  function pngSize(buf) {
10251
10308
  if (buf.length < 24) return null;
10252
10309
  const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
@@ -10260,7 +10317,7 @@ function slugify(value) {
10260
10317
  async function walk(dir, exclude) {
10261
10318
  const out = [];
10262
10319
  for (const entry of await readdir(dir, { withFileTypes: true })) {
10263
- const full = path28.join(dir, entry.name);
10320
+ const full = path27.join(dir, entry.name);
10264
10321
  if (exclude.some((x) => full.includes(x))) continue;
10265
10322
  if (entry.isDirectory()) out.push(...await walk(full, exclude));
10266
10323
  else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
@@ -10274,14 +10331,14 @@ async function scanAssets(root, opts = {}) {
10274
10331
  const skipped = [];
10275
10332
  const seen = /* @__PURE__ */ new Map();
10276
10333
  for (const file of files.sort()) {
10277
- const size = pngSize(await readFile18(file));
10334
+ const size = pngSize(await readFile17(file));
10278
10335
  if (!size) {
10279
10336
  skipped.push(`${file} (not a readable PNG)`);
10280
10337
  continue;
10281
10338
  }
10282
- const rel = path28.relative(root, file);
10283
- const category = path28.dirname(rel) === "." ? "" : path28.dirname(rel);
10284
- let id = slugify(path28.basename(rel));
10339
+ const rel = path27.relative(root, file);
10340
+ const category = path27.dirname(rel) === "." ? "" : path27.dirname(rel);
10341
+ let id = slugify(path27.basename(rel));
10285
10342
  if (seen.has(id)) {
10286
10343
  const n = seen.get(id) + 1;
10287
10344
  seen.set(id, n);
@@ -10324,6 +10381,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
10324
10381
  promptSuffix: "",
10325
10382
  styleImages: [],
10326
10383
  palette: [],
10384
+ enforcePalette: false,
10327
10385
  outDir,
10328
10386
  tags: [name],
10329
10387
  providerOptions: {}
@@ -10338,7 +10396,7 @@ function mode(values) {
10338
10396
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
10339
10397
  }
10340
10398
  async function writeManifestFile(target, manifest) {
10341
- if (existsSync20(target)) {
10399
+ if (existsSync19(target)) {
10342
10400
  throw new Error(`${target} already exists \u2014 refusing to overwrite an existing manifest.`);
10343
10401
  }
10344
10402
  await writeFile10(target, JSON.stringify(manifest, null, 2) + "\n");
@@ -10741,13 +10799,13 @@ async function currentArt(entry, spec) {
10741
10799
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
10742
10800
  if (!previous) return null;
10743
10801
  const file = resolveOutputPath(previous.path, spec.root);
10744
- if (!existsSync21(file)) return null;
10802
+ if (!existsSync20(file)) return null;
10745
10803
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
10746
10804
  if (!contentType) return null;
10747
10805
  let width = entry.width;
10748
10806
  let height = entry.height;
10749
10807
  if (contentType === "image/png") {
10750
- const size = pngSize(await readFile19(file));
10808
+ const size = pngSize(await readFile18(file));
10751
10809
  if (size) ({ width, height } = size);
10752
10810
  }
10753
10811
  return { path: file, contentType, width, height };
@@ -11240,15 +11298,15 @@ function createGenerateHandlers(opts) {
11240
11298
  }
11241
11299
 
11242
11300
  // src/gallery/editor.ts
11243
- import { readFile as readFile21 } from "fs/promises";
11244
- import path30 from "path";
11301
+ import { readFile as readFile20 } from "fs/promises";
11302
+ import path29 from "path";
11245
11303
 
11246
11304
  // src/editor/install.ts
11247
11305
  import { createHash as createHash4 } from "crypto";
11248
- import { existsSync as existsSync22 } from "fs";
11249
- import { mkdir as mkdir8, readFile as readFile20, rename as rename8, rm as rm9, stat as stat3, writeFile as writeFile11 } from "fs/promises";
11306
+ import { existsSync as existsSync21 } from "fs";
11307
+ import { mkdir as mkdir8, readFile as readFile19, rename as rename8, rm as rm9, stat as stat3, writeFile as writeFile11 } from "fs/promises";
11250
11308
  import os2 from "os";
11251
- import path29 from "path";
11309
+ import path28 from "path";
11252
11310
 
11253
11311
  // tools/pixelorama-bridge/pin.json
11254
11312
  var pin_default = {
@@ -11312,15 +11370,15 @@ var EditorInstallError = class extends Error {
11312
11370
  };
11313
11371
  function editorToolsRoot() {
11314
11372
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
11315
- if (override) return path29.resolve(override);
11316
- if (process.platform === "darwin") return path29.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
11373
+ if (override) return path28.resolve(override);
11374
+ if (process.platform === "darwin") return path28.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
11317
11375
  if (process.platform === "win32") {
11318
- return path29.join(process.env.LOCALAPPDATA ?? path29.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
11376
+ return path28.join(process.env.LOCALAPPDATA ?? path28.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
11319
11377
  }
11320
- return path29.join(process.env.XDG_CACHE_HOME ?? path29.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
11378
+ return path28.join(process.env.XDG_CACHE_HOME ?? path28.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
11321
11379
  }
11322
11380
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
11323
- return path29.join(root, "pixelorama", pin.release ?? "unpublished");
11381
+ return path28.join(root, "pixelorama", pin.release ?? "unpublished");
11324
11382
  }
11325
11383
  function editorBaseUrl(pin = EDITOR_PIN) {
11326
11384
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -11330,7 +11388,7 @@ function editorBaseUrl(pin = EDITOR_PIN) {
11330
11388
  }
11331
11389
  async function sha256Of(file) {
11332
11390
  try {
11333
- return createHash4("sha256").update(await readFile20(file)).digest("hex");
11391
+ return createHash4("sha256").update(await readFile19(file)).digest("hex");
11334
11392
  } catch {
11335
11393
  return null;
11336
11394
  }
@@ -11343,8 +11401,8 @@ async function editorStatus(opts = {}) {
11343
11401
  let totalBytes = 0;
11344
11402
  for (const [name, expected] of Object.entries(pin.files)) {
11345
11403
  totalBytes += expected.bytes;
11346
- const file = path29.join(dir, name);
11347
- const info = existsSync22(file) ? await stat3(file).catch(() => null) : null;
11404
+ const file = path28.join(dir, name);
11405
+ const info = existsSync21(file) ? await stat3(file).catch(() => null) : null;
11348
11406
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
11349
11407
  installedBytes += expected.bytes;
11350
11408
  } else {
@@ -11401,7 +11459,7 @@ async function installEditor(opts = {}) {
11401
11459
  if (digest2 !== expected.sha256) {
11402
11460
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
11403
11461
  }
11404
- const final = path29.join(dir, name);
11462
+ const final = path28.join(dir, name);
11405
11463
  const tmp = `${final}.${process.pid}.part`;
11406
11464
  try {
11407
11465
  await writeFile11(tmp, bytes);
@@ -11447,7 +11505,7 @@ var EDITOR_CONTENT_TYPES = {
11447
11505
  ".json": "application/json; charset=utf-8"
11448
11506
  };
11449
11507
  function editorContentType(name) {
11450
- return EDITOR_CONTENT_TYPES[path29.extname(name).toLowerCase()] ?? null;
11508
+ return EDITOR_CONTENT_TYPES[path28.extname(name).toLowerCase()] ?? null;
11451
11509
  }
11452
11510
 
11453
11511
  // src/gallery/editor.ts
@@ -11523,7 +11581,7 @@ function createGalleryEditorHandlers(opts = {}) {
11523
11581
  if (!status.installed) return null;
11524
11582
  let bytes;
11525
11583
  try {
11526
- bytes = await readFile21(path30.join(dir, name));
11584
+ bytes = await readFile20(path29.join(dir, name));
11527
11585
  } catch {
11528
11586
  verified = null;
11529
11587
  return null;
@@ -11563,13 +11621,13 @@ async function serveUntilStopped(initial, reload, args, access2) {
11563
11621
  };
11564
11622
  const loadProject = async (project) => {
11565
11623
  const ctx = await access2.loadProject(project);
11566
- const dir = path31.dirname(ctx.loaded.path);
11624
+ const dir = path30.dirname(ctx.loaded.path);
11567
11625
  const declared = readEnvFiles(dir);
11568
11626
  const credentialNames = new Set(availableProviders().flatMap((id) => providerCredentialEnvs(providerFactory(id))));
11569
11627
  for (const [name, value] of Object.entries(declared)) {
11570
11628
  if (credentialNames.has(name) && name in process.env && process.env[name] !== value) {
11571
11629
  throw new Error(
11572
- `${path31.relative(process.cwd(), dir) || "."} sets ${name} to a different value than the one this gallery already loaded; run a separate gallery for that project so its work uses its own account.`
11630
+ `${path30.relative(process.cwd(), dir) || "."} sets ${name} to a different value than the one this gallery already loaded; run a separate gallery for that project so its work uses its own account.`
11573
11631
  );
11574
11632
  }
11575
11633
  }
@@ -11614,8 +11672,8 @@ async function serveUntilStopped(initial, reload, args, access2) {
11614
11672
  }
11615
11673
  async function runGallery(args) {
11616
11674
  if (args.workspace) {
11617
- const workspacePath = path31.resolve(args.workspace);
11618
- if (!existsSync23(workspacePath)) {
11675
+ const workspacePath = path30.resolve(args.workspace);
11676
+ if (!existsSync22(workspacePath)) {
11619
11677
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
11620
11678
  }
11621
11679
  const filter2 = { styles: args.styles, assets: args.assets };
@@ -11635,7 +11693,7 @@ async function runGallery(args) {
11635
11693
  const registered = async (projectId) => {
11636
11694
  const project = (await loadWorkspace(workspacePath)).projects.find((candidate) => candidate.id === projectId);
11637
11695
  if (!projectId || !project) throw new Error(`unknown workspace project "${projectId ?? ""}"`);
11638
- return resolveProject(path31.dirname(workspacePath), project);
11696
+ return resolveProject(path30.dirname(workspacePath), project);
11639
11697
  };
11640
11698
  await serveUntilStopped(initial, build2, args, {
11641
11699
  manifestFor: async (projectId) => (await registered(projectId)).manifestPath,
@@ -11660,7 +11718,7 @@ async function runGallery(args) {
11660
11718
  return buildGallerySnapshot({ ...fresh, filter });
11661
11719
  };
11662
11720
  await serveUntilStopped(await build(), reload, args, {
11663
- manifestFor: () => path31.resolve(args.manifest),
11721
+ manifestFor: () => path30.resolve(args.manifest),
11664
11722
  loadProject: () => openProject(args.manifest, { lockPath: args.lock, env: false })
11665
11723
  });
11666
11724
  }
@@ -11690,12 +11748,12 @@ async function runGenerate(args) {
11690
11748
  balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`
11691
11749
  );
11692
11750
  if (balance.unit !== group.costUnit) {
11693
- throw new Error(
11751
+ throw new BudgetError(
11694
11752
  `Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
11695
11753
  );
11696
11754
  }
11697
11755
  if (balance.unit !== "free" && group.cost > balance.remaining) {
11698
- throw new Error(
11756
+ throw new BudgetError(
11699
11757
  `${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
11700
11758
  );
11701
11759
  }
@@ -11707,7 +11765,7 @@ async function runGenerate(args) {
11707
11765
  }
11708
11766
  const ceiling = budgets.get(group.provider);
11709
11767
  if (ceiling !== void 0 && group.cost > ceiling) {
11710
- throw new Error(
11768
+ throw new BudgetError(
11711
11769
  `${group.provider} would spend ${formatCost(group.costUnit, group.cost)} but its budget is ${formatCost(group.costUnit, ceiling)}.`
11712
11770
  );
11713
11771
  }
@@ -11912,18 +11970,18 @@ async function runRestoreGeneration(args) {
11912
11970
  }
11913
11971
 
11914
11972
  // src/cli/commands/housekeeping.ts
11915
- import { existsSync as existsSync25 } from "fs";
11973
+ import { existsSync as existsSync24 } from "fs";
11916
11974
 
11917
11975
  // src/pipeline/cache-health.ts
11918
- import { existsSync as existsSync24 } from "fs";
11919
- import { readFile as readFile22, readdir as readdir2, rm as rm10 } from "fs/promises";
11920
- import path32 from "path";
11976
+ import { existsSync as existsSync23 } from "fs";
11977
+ import { readFile as readFile21, readdir as readdir2, rm as rm10 } from "fs/promises";
11978
+ import path31 from "path";
11921
11979
  async function inspectCaches(lock, lockPath, options = {}) {
11922
- if (options.prune && !existsSync24(lockPath)) {
11923
- throw new Error(`Refusing to prune without an existing lockfile at ${path32.resolve(lockPath)}`);
11980
+ if (options.prune && !existsSync23(lockPath)) {
11981
+ throw new Error(`Refusing to prune without an existing lockfile at ${path31.resolve(lockPath)}`);
11924
11982
  }
11925
- const contentDir = path32.resolve(path32.dirname(lockPath), ".pixelkiln", "cache");
11926
- const remotePath = path32.resolve(cachePathFor(lockPath));
11983
+ const contentDir = path31.resolve(path31.dirname(lockPath), ".pixelkiln", "cache");
11984
+ const remotePath = path31.resolve(cachePathFor(lockPath));
11927
11985
  const referenced = referencedHashes(lock);
11928
11986
  let content = await inspectContentCache(contentDir, referenced);
11929
11987
  let remoteHashes = await inspectRemoteHashCache(remotePath);
@@ -11935,7 +11993,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11935
11993
  ]);
11936
11994
  for (const name of names) {
11937
11995
  try {
11938
- await rm10(path32.join(contentDir, name), { force: true });
11996
+ await rm10(path31.join(contentDir, name), { force: true });
11939
11997
  removed.contentFiles++;
11940
11998
  } catch {
11941
11999
  }
@@ -11944,7 +12002,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11944
12002
  await saveCache(remotePath, { version: 1, hashes: {} });
11945
12003
  removed.resetRemoteHashCache = true;
11946
12004
  } else if (remoteHashes.invalidIds.length) {
11947
- const cache = parseCache(JSON.parse(await readFile22(remotePath, "utf8")));
12005
+ const cache = parseCache(JSON.parse(await readFile21(remotePath, "utf8")));
11948
12006
  for (const id of remoteHashes.invalidIds) delete cache.hashes[id];
11949
12007
  removed.remoteHashEntries = remoteHashes.invalidIds.length;
11950
12008
  await saveCache(remotePath, cache);
@@ -11962,7 +12020,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
11962
12020
  async function inspectContentCache(contentDir, referenced) {
11963
12021
  const report = {
11964
12022
  path: contentDir,
11965
- exists: existsSync24(contentDir),
12023
+ exists: existsSync23(contentDir),
11966
12024
  files: 0,
11967
12025
  bytes: 0,
11968
12026
  valid: 0,
@@ -11983,12 +12041,12 @@ async function inspectContentCache(contentDir, referenced) {
11983
12041
  continue;
11984
12042
  }
11985
12043
  report.files++;
11986
- const file = path32.join(contentDir, entry.name);
12044
+ const file = path31.join(contentDir, entry.name);
11987
12045
  const mediaType = mediaTypeFromExtension(entry.name);
11988
12046
  const expected = mediaType ? entry.name.slice(0, -4) : "";
11989
12047
  let bytes;
11990
12048
  try {
11991
- bytes = await readFile22(file);
12049
+ bytes = await readFile21(file);
11992
12050
  report.bytes += bytes.length;
11993
12051
  } catch (err) {
11994
12052
  report.invalid.push({
@@ -12026,7 +12084,7 @@ async function inspectContentCache(contentDir, referenced) {
12026
12084
  async function inspectRemoteHashCache(remotePath) {
12027
12085
  const report = {
12028
12086
  path: remotePath,
12029
- exists: existsSync24(remotePath),
12087
+ exists: existsSync23(remotePath),
12030
12088
  entries: 0,
12031
12089
  valid: 0,
12032
12090
  invalidIds: [],
@@ -12035,7 +12093,7 @@ async function inspectRemoteHashCache(remotePath) {
12035
12093
  if (!report.exists) return report;
12036
12094
  let cache;
12037
12095
  try {
12038
- cache = parseCache(JSON.parse(await readFile22(remotePath, "utf8")));
12096
+ cache = parseCache(JSON.parse(await readFile21(remotePath, "utf8")));
12039
12097
  } catch (err) {
12040
12098
  report.error = err instanceof Error ? err.message : String(err);
12041
12099
  return report;
@@ -12099,7 +12157,7 @@ async function runAccept(args) {
12099
12157
  let intact = true;
12100
12158
  for (const output of entry.outputs) {
12101
12159
  const file = resolveOutputPath(output.path, item.spec.root);
12102
- if (!existsSync25(file) || await sha256File(file) !== output.sha256) {
12160
+ if (!existsSync24(file) || await sha256File(file) !== output.sha256) {
12103
12161
  intact = false;
12104
12162
  break;
12105
12163
  }
@@ -12154,42 +12212,42 @@ This drops their provenance from the lockfile. The art on disk is`);
12154
12212
  }
12155
12213
 
12156
12214
  // src/cli/commands/init.ts
12157
- import path33 from "path";
12158
- import { existsSync as existsSync26 } from "fs";
12215
+ import path32 from "path";
12216
+ import { existsSync as existsSync25 } from "fs";
12159
12217
  async function runInit(args) {
12160
12218
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
12161
- const root = path33.resolve(args.from);
12162
- if (!existsSync26(root)) throw new Error(`No directory at ${root}`);
12219
+ const root = path32.resolve(args.from);
12220
+ if (!existsSync25(root)) throw new Error(`No directory at ${root}`);
12163
12221
  const generator = args.generator ?? "map";
12164
12222
  if (generator !== "1dir" && generator !== "map") {
12165
12223
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
12166
12224
  }
12167
- const target = path33.resolve(args.out ?? "pixelkiln.manifest.json");
12225
+ const target = path32.resolve(args.out ?? "pixelkiln.manifest.json");
12168
12226
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
12169
12227
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
12170
12228
  const manifest = buildManifest(
12171
- args.name ?? path33.basename(path33.dirname(target)),
12229
+ args.name ?? path32.basename(path32.dirname(target)),
12172
12230
  args.styles[0] ?? "base",
12173
12231
  generator,
12174
- path33.relative(path33.dirname(target), root) || ".",
12232
+ path32.relative(path32.dirname(target), root) || ".",
12175
12233
  assets
12176
12234
  );
12177
12235
  await writeManifestFile(target, manifest);
12178
- log(` scanned ${assets.length} PNG(s) under ${path33.relative(process.cwd(), root)}`);
12236
+ log(` scanned ${assets.length} PNG(s) under ${path32.relative(process.cwd(), root)}`);
12179
12237
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
12180
- log(` wrote ${path33.relative(process.cwd(), target)}`);
12238
+ log(` wrote ${path32.relative(process.cwd(), target)}`);
12181
12239
  log(`
12182
12240
  Prompts are intentionally empty. To recover the real ones from your`);
12183
12241
  log(` PixelLab account instead of inventing them:`);
12184
12242
  log(`
12185
- pixelkiln adopt --manifest ${path33.relative(process.cwd(), target)} --write-prompts
12243
+ pixelkiln adopt --manifest ${path32.relative(process.cwd(), target)} --write-prompts
12186
12244
  `);
12187
12245
  }
12188
12246
 
12189
12247
  // src/pipeline/doctor.ts
12190
- import { constants, existsSync as existsSync27 } from "fs";
12248
+ import { constants, existsSync as existsSync26 } from "fs";
12191
12249
  import { access, stat as stat4 } from "fs/promises";
12192
- import path34 from "path";
12250
+ import path33 from "path";
12193
12251
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12194
12252
  const checks = [];
12195
12253
  const add = (id, level, message6) => checks.push({ id, level, message: message6 });
@@ -12200,8 +12258,8 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12200
12258
  );
12201
12259
  add("lock", "ok", `${Object.keys(lock.entries).length} valid v2 lock entr(ies)`);
12202
12260
  const dirs = [...new Set(specs.flatMap((spec) => [
12203
- path34.dirname(spec.outFile),
12204
- ...spec.quality ? [path34.dirname(spec.quality.outFile)] : []
12261
+ path33.dirname(spec.outFile),
12262
+ ...spec.quality ? [path33.dirname(spec.quality.outFile)] : []
12205
12263
  ]))];
12206
12264
  const unwritable = [];
12207
12265
  for (const dir of dirs) {
@@ -12224,7 +12282,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12224
12282
  for (let index = 0; index < entry.outputs.length; index++) {
12225
12283
  const output = entry.outputs[index];
12226
12284
  const spec = specByKey.get(key);
12227
- const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, path34.dirname(lockPath));
12285
+ const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, path33.dirname(lockPath));
12228
12286
  const owner = outputOwners.get(absolute);
12229
12287
  if (owner && owner !== key) duplicateOutputs.push(`${output.path} (${owner}, ${key})`);
12230
12288
  outputOwners.set(absolute, key);
@@ -12239,7 +12297,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12239
12297
  } else {
12240
12298
  add("lock-outputs", "ok", `${outputOwners.size} recorded output path(s) are uniquely owned`);
12241
12299
  }
12242
- const cacheDir = path34.join(path34.dirname(lockPath), ".pixelkiln", "cache");
12300
+ const cacheDir = path33.join(path33.dirname(lockPath), ".pixelkiln", "cache");
12243
12301
  const stranded = [];
12244
12302
  const invalidState = [];
12245
12303
  const staleMapUrls = [];
@@ -12255,11 +12313,11 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
12255
12313
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
12256
12314
  let hasCache = false;
12257
12315
  for (const output of entry.outputs) {
12258
- const cached = path34.join(
12316
+ const cached = path33.join(
12259
12317
  cacheDir,
12260
12318
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
12261
12319
  );
12262
- if (existsSync27(cached) && await sha256File(cached) === output.sha256) {
12320
+ if (existsSync26(cached) && await sha256File(cached) === output.sha256) {
12263
12321
  hasCache = true;
12264
12322
  break;
12265
12323
  }
@@ -12355,12 +12413,158 @@ async function nearestExistingDirectory(start) {
12355
12413
  if ((await stat4(current)).isDirectory()) return current;
12356
12414
  } catch {
12357
12415
  }
12358
- const parent = path34.dirname(current);
12416
+ const parent = path33.dirname(current);
12359
12417
  if (parent === current) return current;
12360
12418
  current = parent;
12361
12419
  }
12362
12420
  }
12363
12421
 
12422
+ // src/pipeline/audit.ts
12423
+ import { readFile as readFile22 } from "fs/promises";
12424
+ import { existsSync as existsSync27 } from "fs";
12425
+ import path34 from "path";
12426
+ function paletteDistance(asset, reference) {
12427
+ if (!asset.length || !reference.length) return 0;
12428
+ let total = 0;
12429
+ let weight = 0;
12430
+ for (const color of asset) {
12431
+ let nearest = Infinity;
12432
+ for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
12433
+ total += nearest * color.weight;
12434
+ weight += color.weight;
12435
+ }
12436
+ return weight === 0 ? 0 : total / weight;
12437
+ }
12438
+ function mergePalettes(palettes, topN = 24) {
12439
+ const totals = /* @__PURE__ */ new Map();
12440
+ for (const palette of palettes) {
12441
+ for (const color of palette) {
12442
+ const key = `${color.r},${color.g},${color.b}`;
12443
+ const existing = totals.get(key);
12444
+ if (existing) existing.weight += color.weight;
12445
+ else totals.set(key, { ...color });
12446
+ }
12447
+ }
12448
+ const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
12449
+ const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
12450
+ return merged.map((c) => ({ ...c, weight: c.weight / sum }));
12451
+ }
12452
+ async function auditStyle(loaded, specs, styleId, lock) {
12453
+ const style = loaded.manifest.styles[styleId];
12454
+ if (!style) throw new Error(`Unknown style "${styleId}"`);
12455
+ const mine = specs.filter((s) => s.styleId === styleId);
12456
+ const assets = [];
12457
+ const missing = [];
12458
+ const unreadable = [];
12459
+ for (const spec of mine) {
12460
+ for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
12461
+ if (!existsSync27(output.absolutePath)) {
12462
+ missing.push(output.id);
12463
+ continue;
12464
+ }
12465
+ try {
12466
+ const png = decodePng(await readFile22(output.absolutePath));
12467
+ const palette = extractPalette(png, 12);
12468
+ assets.push({
12469
+ assetId: spec.assetId,
12470
+ ...output.role ? { outputRole: output.role } : {},
12471
+ id: output.id,
12472
+ file: output.absolutePath,
12473
+ width: png.width,
12474
+ height: png.height,
12475
+ paletteDistance: 0,
12476
+ // filled once the reference is known
12477
+ transparency: transparencyRatio(png),
12478
+ colorCount: countColors(png),
12479
+ palette
12480
+ });
12481
+ } catch (err) {
12482
+ unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
12483
+ }
12484
+ }
12485
+ }
12486
+ let reference = [];
12487
+ let referenceFromStyleImages = false;
12488
+ const refPalettes = [];
12489
+ for (const img of style.styleImages) {
12490
+ const abs = path34.resolve(loaded.root, img.path);
12491
+ if (!existsSync27(abs)) continue;
12492
+ try {
12493
+ refPalettes.push(extractPalette(decodePng(await readFile22(abs)), 12));
12494
+ } catch {
12495
+ }
12496
+ }
12497
+ if (refPalettes.length) {
12498
+ reference = mergePalettes(refPalettes);
12499
+ referenceFromStyleImages = true;
12500
+ for (const asset of assets) {
12501
+ asset.paletteDistance = paletteDistance(asset.palette, reference);
12502
+ }
12503
+ } else {
12504
+ reference = mergePalettes(assets.map((a) => a.palette));
12505
+ for (const asset of assets) {
12506
+ const others = assets.filter((a) => a !== asset).map((a) => a.palette);
12507
+ asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
12508
+ }
12509
+ }
12510
+ assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
12511
+ return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
12512
+ }
12513
+ function countColors(png) {
12514
+ const seen = /* @__PURE__ */ new Set();
12515
+ for (let i = 0; i < png.pixels.length; i += 4) {
12516
+ if (png.pixels[i + 3] < 128) continue;
12517
+ seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
12518
+ }
12519
+ return seen.size;
12520
+ }
12521
+ function outliers(audit, sigma = 1.5) {
12522
+ const n = audit.assets.length;
12523
+ if (n < 3) return [];
12524
+ const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
12525
+ const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
12526
+ const sd = Math.sqrt(variance);
12527
+ if (sd === 0) return [];
12528
+ return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
12529
+ }
12530
+ function evaluateAudit(audit, thresholds = {}) {
12531
+ const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
12532
+ const relative = outliers(audit, resolved.sigma);
12533
+ const relativeIds = new Set(relative.map((asset) => asset.id));
12534
+ const violations = [];
12535
+ for (const asset of audit.assets) {
12536
+ const reasons = [];
12537
+ if (relativeIds.has(asset.id)) {
12538
+ reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
12539
+ }
12540
+ if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
12541
+ reasons.push(
12542
+ `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
12543
+ );
12544
+ }
12545
+ if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
12546
+ reasons.push(
12547
+ `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
12548
+ );
12549
+ }
12550
+ if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
12551
+ reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
12552
+ }
12553
+ if (reasons.length) violations.push({ id: asset.id, reasons });
12554
+ }
12555
+ return {
12556
+ safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
12557
+ thresholds: resolved,
12558
+ outliers: [...relativeIds],
12559
+ violations,
12560
+ missing: [...audit.missing],
12561
+ unreadable: [...audit.unreadable]
12562
+ };
12563
+ }
12564
+ function hex(c) {
12565
+ return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
12566
+ }
12567
+
12364
12568
  // src/cli/commands/inspect.ts
12365
12569
  async function runStatus(args) {
12366
12570
  const { lock } = await openProject2(args);
@@ -13522,7 +13726,10 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
13522
13726
  if (existsSync28(absolute) && !options.force) {
13523
13727
  const current = await readFile24(absolute);
13524
13728
  if (!current.equals(data)) {
13525
- throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
13729
+ throw new OverwriteRefusedError(
13730
+ `Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`,
13731
+ [absolute]
13732
+ );
13526
13733
  }
13527
13734
  }
13528
13735
  const result = await writeArtifactBundle([{ path: absolute, data }]);
@@ -13913,7 +14120,7 @@ async function readRecipeFile(recipePath, bundled) {
13913
14120
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
13914
14121
  );
13915
14122
  }
13916
- return { recipe: parsed.data, path: absolute, dir: path39.dirname(absolute), bundled };
14123
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: path39.dirname(absolute), bundled };
13917
14124
  }
13918
14125
  async function walkRecipeFiles(root) {
13919
14126
  if (!existsSync29(root)) return [];
@@ -13983,7 +14190,7 @@ async function verifyFile(root, file) {
13983
14190
  }
13984
14191
  async function verifyRecipe(target, options = {}) {
13985
14192
  const loaded = await resolveRecipe(target, options.bundledRoot);
13986
- const actualIntegrity = recipeDigest(loaded.recipe);
14193
+ const actualIntegrity = recipeDigest(loaded.authored);
13987
14194
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
13988
14195
  const modelRoot = options.modelRoot ? path39.resolve(options.modelRoot) : null;
13989
14196
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
@@ -14051,8 +14258,9 @@ async function installRecipe(target, options = {}) {
14051
14258
  if (!existsSync29(file.path)) continue;
14052
14259
  const current = await readFile26(file.path);
14053
14260
  if (!current.equals(file.data)) {
14054
- throw new Error(
14055
- `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`
14261
+ throw new OverwriteRefusedError(
14262
+ `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
14263
+ [file.path]
14056
14264
  );
14057
14265
  }
14058
14266
  }
@@ -14519,9 +14727,12 @@ async function main(argv = process.argv.slice(2)) {
14519
14727
 
14520
14728
  // src/cli.ts
14521
14729
  main().catch((err) => {
14730
+ const message6 = err instanceof Error ? err.message : String(err);
14731
+ const hint = err instanceof PixelKilnError && err.hint ? ` ${err.hint}
14732
+ ` : "";
14522
14733
  console.error(`
14523
- error: ${err instanceof Error ? err.message : String(err)}
14524
- `);
14525
- process.exit(1);
14734
+ error: ${message6}
14735
+ ${hint}`);
14736
+ process.exit(exitCodeFor(err));
14526
14737
  });
14527
14738
  //# sourceMappingURL=cli.js.map