pixelkiln 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -71,6 +71,7 @@ __export(index_exports, {
71
71
  PixelLabClient: () => PixelLabClient,
72
72
  PixelLabError: () => PixelLabError,
73
73
  PixelLabProvider: () => PixelLabProvider,
74
+ PostprocessSchema: () => PostprocessSchema,
74
75
  ProjectError: () => ProjectError,
75
76
  ProviderError: () => ProviderError,
76
77
  QualityBaselineSchema: () => QualityBaselineSchema,
@@ -450,6 +451,18 @@ var StyleObjectSchema = import_zod.z.object({
450
451
  * chocolate bar.
451
452
  */
452
453
  palette: import_zod.z.array(HexColorSchema).default([]),
454
+ /**
455
+ * Snap every visible pixel of the downloaded art to the nearest `palette`
456
+ * colour, without dithering. The palette is sent to providers that take
457
+ * one, and most honour it most of the time; this makes it a guarantee
458
+ * for every generator, including the ones with no palette parameter.
459
+ * Applied by `fetch` as the file is written and recorded on the lock
460
+ * entry, so a restore reproduces the same bytes. Turning it on or off
461
+ * does not change the spec hash: `plan` reports the entry as recoverable
462
+ * and the next `fetch` or `restore` re-applies it from the art already
463
+ * on disk or in the cache, spending nothing.
464
+ */
465
+ enforcePalette: import_zod.z.boolean().default(false),
453
466
  /** Fail-closed native-grid, palette, and human-approval policy for derived art. */
454
467
  quality: QualityProfileSchema.optional(),
455
468
  /**
@@ -491,6 +504,13 @@ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImag
491
504
  path: ["quality"]
492
505
  });
493
506
  }
507
+ if (style.enforcePalette && style.palette.length < 2) {
508
+ ctx.addIssue({
509
+ code: import_zod.z.ZodIssueCode.custom,
510
+ message: "enforcePalette needs a palette of at least two colours to snap to",
511
+ path: ["enforcePalette"]
512
+ });
513
+ }
494
514
  });
495
515
  var InheritedStyleSchema = StyleObjectSchema.partial().extend({
496
516
  extends: import_zod.z.string().min(1),
@@ -613,7 +633,21 @@ var LockOutputSchema = import_zod.z.object({
613
633
  path: import_zod.z.string(),
614
634
  sha256: import_zod.z.string(),
615
635
  role: import_zod.z.string().optional(),
616
- mediaType: MediaTypeSchema.optional()
636
+ mediaType: MediaTypeSchema.optional(),
637
+ /**
638
+ * Hash of the provider's bytes before post-processing changed them, when
639
+ * it did. The content cache keeps both, so the post-processing can be
640
+ * undone or redone without contacting the provider.
641
+ */
642
+ raw: import_zod.z.string().optional()
643
+ });
644
+ var PostprocessSchema = import_zod.z.object({
645
+ palette: import_zod.z.object({
646
+ /** Canonical lowercase `#rrggbb` values, in manifest order. */
647
+ colors: import_zod.z.array(import_zod.z.string()).min(2).max(256),
648
+ dither: import_zod.z.literal("none"),
649
+ distance: import_zod.z.literal("redmean")
650
+ }).optional()
617
651
  });
618
652
  var LockSourceSchema = import_zod.z.object({
619
653
  url: import_zod.z.string(),
@@ -639,6 +673,7 @@ var LockHistoryEntrySchema = import_zod.z.object({
639
673
  cost: import_zod.z.number().finite().nonnegative().default(0),
640
674
  costUnit: import_zod.z.string().min(1).default("generations"),
641
675
  provider: import_zod.z.string().default("pixellab"),
676
+ postprocess: PostprocessSchema.optional(),
642
677
  /** When the replacement was submitted. */
643
678
  retiredAt: import_zod.z.string()
644
679
  });
@@ -710,14 +745,7 @@ var LockEntrySchema = import_zod.z.object({
710
745
  * `role` labels non-primary artifacts (e.g. "portrait", "spriteframes") so a
711
746
  * consumer can find the one it wants without pattern-matching on paths.
712
747
  */
713
- outputs: import_zod.z.array(
714
- import_zod.z.object({
715
- path: import_zod.z.string(),
716
- sha256: import_zod.z.string(),
717
- role: import_zod.z.string().optional(),
718
- mediaType: MediaTypeSchema.optional()
719
- })
720
- ).default([]),
748
+ outputs: import_zod.z.array(LockOutputSchema).default([]),
721
749
  /**
722
750
  * Provider-owned data retained for downstream consumers, namespaced by
723
751
  * provider id. PixelLab connectable sets keep their exact `tileRules` here
@@ -733,7 +761,9 @@ var LockEntrySchema = import_zod.z.object({
733
761
  /** Which provider produced this. Absent on entries written before providers. */
734
762
  provider: import_zod.z.string().default("pixellab"),
735
763
  /** Generations this entry replaced, newest first; see LockHistoryEntrySchema. */
736
- history: import_zod.z.array(LockHistoryEntrySchema).optional()
764
+ history: import_zod.z.array(LockHistoryEntrySchema).optional(),
765
+ /** Post-processing applied to the recorded outputs; absent when the bytes are the provider's. */
766
+ postprocess: PostprocessSchema.optional()
737
767
  });
738
768
  var LockSchema = import_zod.z.object({
739
769
  version: import_zod.z.literal(2),
@@ -4574,6 +4604,7 @@ async function resolveSpecs(loaded, filter) {
4574
4604
  detail: style.detail,
4575
4605
  seed: style.seed,
4576
4606
  palette: style.palette,
4607
+ enforcePalette: style.enforcePalette,
4577
4608
  noBackground: style.noBackground,
4578
4609
  tileSize: generator === "tiles" ? tileSize : void 0,
4579
4610
  tileType: generator === "tiles" ? style.tileType : void 0,
@@ -4778,7 +4809,7 @@ function imageMetadata(buf) {
4778
4809
  }
4779
4810
 
4780
4811
  // src/project.ts
4781
- var import_node_path13 = __toESM(require("path"), 1);
4812
+ var import_node_path12 = __toESM(require("path"), 1);
4782
4813
 
4783
4814
  // src/env.ts
4784
4815
  var import_node_fs6 = require("fs");
@@ -4825,17 +4856,17 @@ function parseEnv(contents) {
4825
4856
  }
4826
4857
 
4827
4858
  // src/pipeline/plan.ts
4828
- var import_node_fs10 = require("fs");
4829
- var import_node_path12 = __toESM(require("path"), 1);
4859
+ var import_node_fs9 = require("fs");
4860
+ var import_node_path11 = __toESM(require("path"), 1);
4830
4861
 
4831
4862
  // src/pipeline/quality-profile.ts
4832
- var import_node_fs8 = require("fs");
4833
- var import_node_path10 = __toESM(require("path"), 1);
4863
+ var import_node_fs7 = require("fs");
4864
+ var import_node_path9 = __toESM(require("path"), 1);
4834
4865
 
4835
4866
  // src/pipeline/refine.ts
4836
- var import_node_path9 = __toESM(require("path"), 1);
4867
+ var import_node_path8 = __toESM(require("path"), 1);
4837
4868
  var import_node_child_process2 = require("child_process");
4838
- var import_promises6 = require("fs/promises");
4869
+ var import_promises5 = require("fs/promises");
4839
4870
  var import_node_os2 = require("os");
4840
4871
  var import_node_util = require("util");
4841
4872
 
@@ -5296,10 +5327,19 @@ async function writeArtifactBundle(files, options = {}) {
5296
5327
  };
5297
5328
  }
5298
5329
 
5299
- // src/pipeline/audit.ts
5300
- var import_promises5 = require("fs/promises");
5301
- var import_node_fs7 = require("fs");
5302
- var import_node_path8 = __toESM(require("path"), 1);
5330
+ // src/palette.ts
5331
+ function normalizePalette(values) {
5332
+ const colors = /* @__PURE__ */ new Map();
5333
+ for (const value of values) {
5334
+ const rgb = parseHex(value);
5335
+ const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5336
+ if (!colors.has(hex2)) colors.set(hex2, { hex: hex2, ...rgb });
5337
+ }
5338
+ if (colors.size < 2 || colors.size > 256) {
5339
+ throw new Error(`A palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5340
+ }
5341
+ return [...colors.values()];
5342
+ }
5303
5343
  function colorDistance(a, b) {
5304
5344
  const rmean = (a.r + b.r) / 2;
5305
5345
  const dr = a.r - b.r;
@@ -5309,146 +5349,45 @@ function colorDistance(a, b) {
5309
5349
  (512 + rmean) * dr * dr / 256 + 4 * dg * dg + (767 - rmean) * db * db / 256
5310
5350
  );
5311
5351
  }
5312
- function paletteDistance(asset, reference) {
5313
- if (!asset.length || !reference.length) return 0;
5314
- let total = 0;
5315
- let weight = 0;
5316
- for (const color of asset) {
5317
- let nearest = Infinity;
5318
- for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
5319
- total += nearest * color.weight;
5320
- weight += color.weight;
5321
- }
5322
- return weight === 0 ? 0 : total / weight;
5323
- }
5324
- function mergePalettes(palettes, topN = 24) {
5325
- const totals = /* @__PURE__ */ new Map();
5326
- for (const palette of palettes) {
5327
- for (const color of palette) {
5328
- const key = `${color.r},${color.g},${color.b}`;
5329
- const existing = totals.get(key);
5330
- if (existing) existing.weight += color.weight;
5331
- else totals.set(key, { ...color });
5352
+ function quantize(png, palette) {
5353
+ const colors = normalizePalette(palette);
5354
+ const pixels = Buffer.from(png.pixels);
5355
+ const nearestOf = /* @__PURE__ */ new Map();
5356
+ let changed = 0;
5357
+ for (let i = 0; i < pixels.length; i += 4) {
5358
+ const alpha = pixels[i + 3];
5359
+ if (alpha === 0) {
5360
+ if (pixels[i] || pixels[i + 1] || pixels[i + 2]) changed++;
5361
+ pixels[i] = 0;
5362
+ pixels[i + 1] = 0;
5363
+ pixels[i + 2] = 0;
5364
+ continue;
5332
5365
  }
5333
- }
5334
- const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
5335
- const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
5336
- return merged.map((c) => ({ ...c, weight: c.weight / sum }));
5337
- }
5338
- async function auditStyle(loaded, specs, styleId, lock) {
5339
- const style = loaded.manifest.styles[styleId];
5340
- if (!style) throw new Error(`Unknown style "${styleId}"`);
5341
- const mine = specs.filter((s) => s.styleId === styleId);
5342
- const assets = [];
5343
- const missing = [];
5344
- const unreadable = [];
5345
- for (const spec of mine) {
5346
- for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
5347
- if (!(0, import_node_fs7.existsSync)(output.absolutePath)) {
5348
- missing.push(output.id);
5349
- continue;
5350
- }
5351
- try {
5352
- const png = decodePng(await (0, import_promises5.readFile)(output.absolutePath));
5353
- const palette = extractPalette(png, 12);
5354
- assets.push({
5355
- assetId: spec.assetId,
5356
- ...output.role ? { outputRole: output.role } : {},
5357
- id: output.id,
5358
- file: output.absolutePath,
5359
- width: png.width,
5360
- height: png.height,
5361
- paletteDistance: 0,
5362
- // filled once the reference is known
5363
- transparency: transparencyRatio(png),
5364
- colorCount: countColors(png),
5365
- palette
5366
- });
5367
- } catch (err) {
5368
- unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
5366
+ const key = pixels[i] << 16 | pixels[i + 1] << 8 | pixels[i + 2];
5367
+ let nearest = nearestOf.get(key);
5368
+ if (!nearest) {
5369
+ const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5370
+ nearest = colors[0];
5371
+ let distance = colorDistance(pixel, nearest);
5372
+ for (let j = 1; j < colors.length; j++) {
5373
+ const candidate = colors[j];
5374
+ const candidateDistance = colorDistance(pixel, candidate);
5375
+ if (candidateDistance < distance) {
5376
+ nearest = candidate;
5377
+ distance = candidateDistance;
5378
+ }
5369
5379
  }
5380
+ nearestOf.set(key, nearest);
5370
5381
  }
5382
+ if (pixels[i] !== nearest.r || pixels[i + 1] !== nearest.g || pixels[i + 2] !== nearest.b) changed++;
5383
+ pixels[i] = nearest.r;
5384
+ pixels[i + 1] = nearest.g;
5385
+ pixels[i + 2] = nearest.b;
5371
5386
  }
5372
- let reference = [];
5373
- let referenceFromStyleImages = false;
5374
- const refPalettes = [];
5375
- for (const img of style.styleImages) {
5376
- const abs = import_node_path8.default.resolve(loaded.root, img.path);
5377
- if (!(0, import_node_fs7.existsSync)(abs)) continue;
5378
- try {
5379
- refPalettes.push(extractPalette(decodePng(await (0, import_promises5.readFile)(abs)), 12));
5380
- } catch {
5381
- }
5382
- }
5383
- if (refPalettes.length) {
5384
- reference = mergePalettes(refPalettes);
5385
- referenceFromStyleImages = true;
5386
- for (const asset of assets) {
5387
- asset.paletteDistance = paletteDistance(asset.palette, reference);
5388
- }
5389
- } else {
5390
- reference = mergePalettes(assets.map((a) => a.palette));
5391
- for (const asset of assets) {
5392
- const others = assets.filter((a) => a !== asset).map((a) => a.palette);
5393
- asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
5394
- }
5395
- }
5396
- assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
5397
- return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
5398
- }
5399
- function countColors(png) {
5400
- const seen = /* @__PURE__ */ new Set();
5401
- for (let i = 0; i < png.pixels.length; i += 4) {
5402
- if (png.pixels[i + 3] < 128) continue;
5403
- seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
5404
- }
5405
- return seen.size;
5406
- }
5407
- function outliers(audit, sigma = 1.5) {
5408
- const n = audit.assets.length;
5409
- if (n < 3) return [];
5410
- const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
5411
- const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
5412
- const sd = Math.sqrt(variance);
5413
- if (sd === 0) return [];
5414
- return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
5415
- }
5416
- function evaluateAudit(audit, thresholds = {}) {
5417
- const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
5418
- const relative = outliers(audit, resolved.sigma);
5419
- const relativeIds = new Set(relative.map((asset) => asset.id));
5420
- const violations = [];
5421
- for (const asset of audit.assets) {
5422
- const reasons = [];
5423
- if (relativeIds.has(asset.id)) {
5424
- reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
5425
- }
5426
- if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
5427
- reasons.push(
5428
- `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
5429
- );
5430
- }
5431
- if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
5432
- reasons.push(
5433
- `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
5434
- );
5435
- }
5436
- if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
5437
- reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
5438
- }
5439
- if (reasons.length) violations.push({ id: asset.id, reasons });
5440
- }
5441
- return {
5442
- safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
5443
- thresholds: resolved,
5444
- outliers: [...relativeIds],
5445
- violations,
5446
- missing: [...audit.missing],
5447
- unreadable: [...audit.unreadable]
5448
- };
5387
+ return { png: { width: png.width, height: png.height, pixels }, changed };
5449
5388
  }
5450
- function hex(c) {
5451
- return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
5389
+ function quantizeToPalette(png, palette) {
5390
+ return quantize(png, palette).png;
5452
5391
  }
5453
5392
 
5454
5393
  // src/pipeline/refine.ts
@@ -5535,48 +5474,12 @@ function parseDetection(raw) {
5535
5474
  confidence: confidenceFor(consensus)
5536
5475
  };
5537
5476
  }
5538
- function normalizePalette(values) {
5539
- const colors = /* @__PURE__ */ new Map();
5540
- for (const value of values) {
5541
- const rgb = parseHex(value);
5542
- const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5543
- colors.set(hex2, { hex: hex2, ...rgb });
5544
- }
5545
- if (colors.size < 2 || colors.size > 256) {
5546
- throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5547
- }
5548
- return [...colors.values()];
5549
- }
5550
5477
  function normalizeRefinementPalette(values) {
5551
- return normalizePalette(values).map((color) => color.hex);
5552
- }
5553
- function quantizeToPalette(png, palette) {
5554
- const colors = normalizePalette(palette);
5555
- const pixels = Buffer.from(png.pixels);
5556
- for (let i = 0; i < pixels.length; i += 4) {
5557
- const alpha = pixels[i + 3];
5558
- if (alpha === 0) {
5559
- pixels[i] = 0;
5560
- pixels[i + 1] = 0;
5561
- pixels[i + 2] = 0;
5562
- continue;
5563
- }
5564
- const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5565
- let nearest = colors[0];
5566
- let distance = colorDistance(pixel, nearest);
5567
- for (let j = 1; j < colors.length; j++) {
5568
- const candidate = colors[j];
5569
- const candidateDistance = colorDistance(pixel, candidate);
5570
- if (candidateDistance < distance) {
5571
- nearest = candidate;
5572
- distance = candidateDistance;
5573
- }
5574
- }
5575
- pixels[i] = nearest.r;
5576
- pixels[i + 1] = nearest.g;
5577
- pixels[i + 2] = nearest.b;
5478
+ try {
5479
+ return normalizePalette(values).map((color) => color.hex);
5480
+ } catch (err) {
5481
+ throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${new Set(values.map((v) => v.toLowerCase())).size}.`, { cause: err });
5578
5482
  }
5579
- return { width: png.width, height: png.height, pixels };
5580
5483
  }
5581
5484
  function colorCount(png) {
5582
5485
  const colors = /* @__PURE__ */ new Set();
@@ -5686,16 +5589,16 @@ function sameFrameGrid(first, next) {
5686
5589
  return next.detection.stepX === first.detection.stepX && next.detection.stepY === first.detection.stepY && next.detection.columns === first.detection.columns && next.detection.rows === first.detection.rows && phaseMatches(next.detection.phaseX, first.detection.phaseX, first.detection.stepX) && phaseMatches(next.detection.phaseY, first.detection.phaseY, first.detection.stepY) && next.audit.width === first.audit.width && next.audit.height === first.audit.height && next.sourceWidth === first.sourceWidth && next.sourceHeight === first.sourceHeight;
5687
5590
  }
5688
5591
  async function refineAsset(options) {
5689
- const source = import_node_path9.default.resolve(options.source);
5690
- const output = import_node_path9.default.resolve(options.output);
5592
+ const source = import_node_path8.default.resolve(options.source);
5593
+ const output = import_node_path8.default.resolve(options.output);
5691
5594
  if (source === output) throw new Error("Refinement source and output must be different files.");
5692
- const record = import_node_path9.default.resolve(qualityRecordPath(output));
5595
+ const record = import_node_path8.default.resolve(qualityRecordPath(output));
5693
5596
  const palette = normalizePalette(options.palette).map((color) => color.hex);
5694
5597
  const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
5695
5598
  if (options.minTransparency !== void 0 && (!Number.isFinite(options.minTransparency) || options.minTransparency < 0 || options.minTransparency > 1)) throw new Error("minTransparency must be between 0 and 1.");
5696
5599
  let sourcePng;
5697
5600
  try {
5698
- sourcePng = decodePng(await (0, import_promises6.readFile)(source));
5601
+ sourcePng = decodePng(await (0, import_promises5.readFile)(source));
5699
5602
  } catch (error) {
5700
5603
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
5701
5604
  }
@@ -5712,8 +5615,8 @@ async function refineAsset(options) {
5712
5615
  );
5713
5616
  }
5714
5617
  }
5715
- const temp = await (0, import_promises6.mkdtemp)(import_node_path9.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-"));
5716
- const recovered = import_node_path9.default.join(temp, "native.png");
5618
+ const temp = await (0, import_promises5.mkdtemp)(import_node_path8.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-"));
5619
+ const recovered = import_node_path8.default.join(temp, "native.png");
5717
5620
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
5718
5621
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
5719
5622
  try {
@@ -5734,7 +5637,7 @@ async function refineAsset(options) {
5734
5637
  );
5735
5638
  }
5736
5639
  const detection = parseDetection(firstJsonObject(stdout));
5737
- const native = decodePng(await (0, import_promises6.readFile)(recovered));
5640
+ const native = decodePng(await (0, import_promises5.readFile)(recovered));
5738
5641
  const quantized = quantizeToPalette(native, palette);
5739
5642
  const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
5740
5643
  const audit = auditRefinement(
@@ -5784,7 +5687,7 @@ async function refineAsset(options) {
5784
5687
  );
5785
5688
  return { source, output, record, detection, palette, audit };
5786
5689
  } finally {
5787
- await (0, import_promises6.rm)(temp, { recursive: true, force: true });
5690
+ await (0, import_promises5.rm)(temp, { recursive: true, force: true });
5788
5691
  }
5789
5692
  }
5790
5693
  async function refineFrameSet(options) {
@@ -5798,18 +5701,18 @@ async function refineFrameSet(options) {
5798
5701
  if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
5799
5702
  throw new Error("Frame-set roles must be non-empty and unique.");
5800
5703
  }
5801
- const output = import_node_path9.default.resolve(options.output);
5802
- const record = import_node_path9.default.resolve(qualityRecordPath(output));
5704
+ const output = import_node_path8.default.resolve(options.output);
5705
+ const record = import_node_path8.default.resolve(qualityRecordPath(output));
5803
5706
  const palette = normalizeRefinementPalette(options.palette);
5804
5707
  const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
5805
5708
  if (new Set(finalOutputs).size !== finalOutputs.length) {
5806
5709
  throw new Error("Frame-set roles resolve to duplicate output filenames.");
5807
5710
  }
5808
- const sourceSet = new Set(options.sources.map((source) => import_node_path9.default.resolve(source.path)));
5711
+ const sourceSet = new Set(options.sources.map((source) => import_node_path8.default.resolve(source.path)));
5809
5712
  if (finalOutputs.some((file) => sourceSet.has(file))) {
5810
5713
  throw new Error("Frame-set refinement sources and outputs must be different files.");
5811
5714
  }
5812
- const temp = await (0, import_promises6.mkdtemp)(import_node_path9.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-frames-"));
5715
+ const temp = await (0, import_promises5.mkdtemp)(import_node_path8.default.join((0, import_node_os2.tmpdir)(), "pixelkiln-refine-frames-"));
5813
5716
  try {
5814
5717
  const frames = [];
5815
5718
  const outputs = [];
@@ -5817,8 +5720,8 @@ async function refineFrameSet(options) {
5817
5720
  const sourcePaths = [];
5818
5721
  let firstOptions = null;
5819
5722
  for (const [index, sourceEntry] of options.sources.entries()) {
5820
- const source = import_node_path9.default.resolve(sourceEntry.path);
5821
- const temporaryOutput = import_node_path9.default.join(temp, `${String(index).padStart(2, "0")}.png`);
5723
+ const source = import_node_path8.default.resolve(sourceEntry.path);
5724
+ const temporaryOutput = import_node_path8.default.join(temp, `${String(index).padStart(2, "0")}.png`);
5822
5725
  const refined = await refineAsset({
5823
5726
  source,
5824
5727
  output: temporaryOutput,
@@ -5850,7 +5753,7 @@ async function refineFrameSet(options) {
5850
5753
  });
5851
5754
  outputs.push({
5852
5755
  path: finalOutputs[index],
5853
- data: await (0, import_promises6.readFile)(temporaryOutput)
5756
+ data: await (0, import_promises5.readFile)(temporaryOutput)
5854
5757
  });
5855
5758
  }
5856
5759
  const first = frames[0];
@@ -5893,23 +5796,23 @@ async function refineFrameSet(options) {
5893
5796
  );
5894
5797
  return {
5895
5798
  sources: sourcePaths,
5896
- outputs: outputs.map((item) => import_node_path9.default.resolve(item.path)),
5799
+ outputs: outputs.map((item) => import_node_path8.default.resolve(item.path)),
5897
5800
  record,
5898
5801
  frames,
5899
5802
  palette,
5900
5803
  audit
5901
5804
  };
5902
5805
  } finally {
5903
- await (0, import_promises6.rm)(temp, { recursive: true, force: true });
5806
+ await (0, import_promises5.rm)(temp, { recursive: true, force: true });
5904
5807
  }
5905
5808
  }
5906
5809
  async function checkQualityRecord(recordPath) {
5907
- const record = import_node_path9.default.resolve(recordPath);
5810
+ const record = import_node_path8.default.resolve(recordPath);
5908
5811
  const manifest = await readArtifactBundleManifest(record);
5909
5812
  const options = parseRecordOptions(manifest, record);
5910
5813
  const verification = await verifyArtifactBundle(record);
5911
- const sources = manifest.sources.map((source2) => import_node_path9.default.resolve(import_node_path9.default.dirname(record), source2.path));
5912
- const outputs = manifest.outputs.map((output2) => import_node_path9.default.resolve(import_node_path9.default.dirname(record), output2.path));
5814
+ const sources = manifest.sources.map((source2) => import_node_path8.default.resolve(import_node_path8.default.dirname(record), source2.path));
5815
+ const outputs = manifest.outputs.map((output2) => import_node_path8.default.resolve(import_node_path8.default.dirname(record), output2.path));
5913
5816
  const source = sources[0] ?? "";
5914
5817
  const output = outputs[0] ?? "";
5915
5818
  const expectedCount = options.frameSet?.count ?? 1;
@@ -5942,7 +5845,7 @@ async function checkQualityRecord(recordPath) {
5942
5845
  async function approveQualityRecord(recordPath, approval) {
5943
5846
  const reviewer = approval.reviewer.trim();
5944
5847
  if (!reviewer) throw new Error("A human reviewer name is required.");
5945
- const record = import_node_path9.default.resolve(recordPath);
5848
+ const record = import_node_path8.default.resolve(recordPath);
5946
5849
  const manifest = await readArtifactBundleManifest(record);
5947
5850
  const options = parseRecordOptions(manifest, record);
5948
5851
  const verification = await verifyArtifactBundle(record);
@@ -5963,14 +5866,14 @@ async function approveQualityRecord(recordPath, approval) {
5963
5866
  `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
5964
5867
  );
5965
5868
  }
5966
- const root = import_node_path9.default.dirname(record);
5869
+ const root = import_node_path8.default.dirname(record);
5967
5870
  const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
5968
- const outputPath = import_node_path9.default.resolve(root, entry.path);
5969
- return { path: outputPath, data: await (0, import_promises6.readFile)(outputPath) };
5871
+ const outputPath = import_node_path8.default.resolve(root, entry.path);
5872
+ return { path: outputPath, data: await (0, import_promises5.readFile)(outputPath) };
5970
5873
  }));
5971
5874
  const sources = manifest.sources.map((source) => ({
5972
5875
  ...source,
5973
- path: import_node_path9.default.resolve(root, source.path)
5876
+ path: import_node_path8.default.resolve(root, source.path)
5974
5877
  }));
5975
5878
  const approved = {
5976
5879
  ...options,
@@ -6002,8 +5905,8 @@ async function qualitySource(spec, lock) {
6002
5905
  if (spec.generator === "frames") {
6003
5906
  return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
6004
5907
  }
6005
- const source = import_node_path10.default.resolve(spec.root, spec.source);
6006
- return (0, import_node_fs8.existsSync)(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5908
+ const source = import_node_path9.default.resolve(spec.root, spec.source);
5909
+ return (0, import_node_fs7.existsSync)(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
6007
5910
  }
6008
5911
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
6009
5912
  if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
@@ -6032,7 +5935,7 @@ async function qualitySource(spec, lock) {
6032
5935
  }
6033
5936
  if (role) roles.add(role);
6034
5937
  const source = currentEntryOutputPath(entry, spec, index);
6035
- if (!(0, import_node_fs8.existsSync)(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5938
+ if (!(0, import_node_fs7.existsSync)(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
6036
5939
  if (await sha256File(source) !== output.sha256) {
6037
5940
  return { items: null, reason: "raw provider output was modified after download" };
6038
5941
  }
@@ -6051,7 +5954,7 @@ async function inspectQualityProfile(spec, lock) {
6051
5954
  }
6052
5955
  const sourcePaths = source.items.map((item) => item.path);
6053
5956
  const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
6054
- if (!(0, import_node_fs8.existsSync)(record)) {
5957
+ if (!(0, import_node_fs7.existsSync)(record)) {
6055
5958
  return {
6056
5959
  key,
6057
5960
  state: "needs-refinement",
@@ -6069,7 +5972,7 @@ async function inspectQualityProfile(spec, lock) {
6069
5972
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
6070
5973
  const expectedTransparency = spec.quality.minTransparency ?? null;
6071
5974
  const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
6072
- const contractChanged = JSON.stringify(check.sources.map((item) => import_node_path10.default.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => import_node_path10.default.resolve(item))) || JSON.stringify(check.outputs.map((item) => import_node_path10.default.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => import_node_path10.default.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
5975
+ const contractChanged = JSON.stringify(check.sources.map((item) => import_node_path9.default.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => import_node_path9.default.resolve(item))) || JSON.stringify(check.outputs.map((item) => import_node_path9.default.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => import_node_path9.default.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
6073
5976
  if (contractChanged) {
6074
5977
  return {
6075
5978
  key,
@@ -6231,9 +6134,38 @@ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles)
6231
6134
  return sources;
6232
6135
  }
6233
6136
 
6137
+ // src/pipeline/postprocess.ts
6138
+ function postprocessFor(spec) {
6139
+ if (!spec.enforcePalette || spec.palette.length < 2) return void 0;
6140
+ return {
6141
+ palette: {
6142
+ colors: normalizePalette(spec.palette).map((color) => color.hex),
6143
+ dither: "none",
6144
+ distance: "redmean"
6145
+ }
6146
+ };
6147
+ }
6148
+ function samePostprocess(a, b) {
6149
+ const pa = a?.palette;
6150
+ const pb = b?.palette;
6151
+ if (!pa && !pb) return true;
6152
+ if (!pa || !pb) return false;
6153
+ return pa.dither === pb.dither && pa.distance === pb.distance && pa.colors.length === pb.colors.length && pa.colors.every((color, i) => color === pb.colors[i]);
6154
+ }
6155
+ function postprocessCurrent(entry, spec) {
6156
+ return samePostprocess(entry.postprocess, postprocessFor(spec));
6157
+ }
6158
+ function applyPostprocess(bytes, mediaType, record) {
6159
+ if (!record?.palette || mediaType !== MediaType.PNG) return { bytes, changed: false };
6160
+ const png = decodePng(bytes);
6161
+ const { png: snapped, changed } = quantize(png, record.palette.colors);
6162
+ if (!changed) return { bytes, changed: false };
6163
+ return { bytes: encodeRgbaPng(snapped.width, snapped.height, snapped.pixels), changed: true };
6164
+ }
6165
+
6234
6166
  // src/pipeline/revision.ts
6235
- var import_node_fs9 = require("fs");
6236
- var import_node_path11 = __toESM(require("path"), 1);
6167
+ var import_node_fs8 = require("fs");
6168
+ var import_node_path10 = __toESM(require("path"), 1);
6237
6169
  async function inspectRevisionReadiness(spec, lock) {
6238
6170
  if (!spec.revision) return null;
6239
6171
  return inspectRevision(spec, lock, /* @__PURE__ */ new Set());
@@ -6248,14 +6180,14 @@ async function inspectRevision(spec, lock, seen) {
6248
6180
  reason: `revision source ${spec.styleId}/${revision.sourceAssetId} is not ready: ${dependency.reason}`
6249
6181
  };
6250
6182
  }
6251
- if (!revision.sourceSha256 || !(0, import_node_fs9.existsSync)(revision.sourceFile)) {
6183
+ if (!revision.sourceSha256 || !(0, import_node_fs8.existsSync)(revision.sourceFile)) {
6252
6184
  return { ready: false, reason: `revision source is missing: ${revision.sourceFile}` };
6253
6185
  }
6254
6186
  if (await sha256File(revision.sourceFile) !== revision.sourceSha256) {
6255
6187
  return { ready: false, reason: "revision source changed after the manifest was resolved" };
6256
6188
  }
6257
6189
  if (revision.maskFile) {
6258
- if (!revision.maskSha256 || !(0, import_node_fs9.existsSync)(revision.maskFile)) {
6190
+ if (!revision.maskSha256 || !(0, import_node_fs8.existsSync)(revision.maskFile)) {
6259
6191
  return { ready: false, reason: `revision mask is missing: ${revision.maskFile}` };
6260
6192
  }
6261
6193
  if (await sha256File(revision.maskFile) !== revision.maskSha256) {
@@ -6285,8 +6217,8 @@ async function inspectParent(spec, lock, seen) {
6285
6217
  };
6286
6218
  }
6287
6219
  if (spec.source) {
6288
- const source = import_node_path11.default.resolve(spec.root, spec.source);
6289
- return (0, import_node_fs9.existsSync)(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
6220
+ const source = import_node_path10.default.resolve(spec.root, spec.source);
6221
+ return (0, import_node_fs8.existsSync)(source) ? { ready: true, reason: "declared source is present" } : { ready: false, reason: `declared source is missing: ${spec.source}` };
6290
6222
  }
6291
6223
  const entry = lock.entries[key];
6292
6224
  if (!entry) return { ready: false, reason: "parent is not in the lockfile" };
@@ -6300,7 +6232,7 @@ async function inspectParent(spec, lock, seen) {
6300
6232
  for (let index = 0; index < entry.outputs.length; index++) {
6301
6233
  const output = entry.outputs[index];
6302
6234
  const file = currentEntryOutputPath(entry, spec, index);
6303
- if (!(0, import_node_fs9.existsSync)(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6235
+ if (!(0, import_node_fs8.existsSync)(file)) return { ready: false, reason: `parent output is missing: ${file}` };
6304
6236
  if (await sha256File(file) !== output.sha256) {
6305
6237
  return { ready: false, reason: `parent output was modified: ${file}` };
6306
6238
  }
@@ -6329,7 +6261,7 @@ function resumeActions(specs, lock) {
6329
6261
  const entry = lock.entries[key];
6330
6262
  if (!entry || entry.specHash !== spec.specHash) continue;
6331
6263
  if (entry.submissionComplete === false) continue;
6332
- const command = resumeCommandForStatus(entry.status);
6264
+ const command = resumeCommandForStatus(entry.status) ?? (entry.status === "downloaded" && entry.outputs.length && !postprocessCurrent(entry, spec) ? "fetch" : null);
6333
6265
  if (!command) continue;
6334
6266
  if (command === "poll" && !entry.jobId) continue;
6335
6267
  if (command === "pick" && !entry.reviewObjectId) continue;
@@ -6355,7 +6287,7 @@ async function buildPlan(specs, lock, opts = {}) {
6355
6287
  state = "missing";
6356
6288
  reason = "--force";
6357
6289
  } else if (!entry && spec.source && spec.generator !== "frames") {
6358
- if ((0, import_node_fs10.existsSync)(import_node_path12.default.resolve(spec.root, spec.source))) {
6290
+ if ((0, import_node_fs9.existsSync)(import_node_path11.default.resolve(spec.root, spec.source))) {
6359
6291
  state = "ok";
6360
6292
  reason = `placed from ${spec.source}; not generated`;
6361
6293
  } else {
@@ -6363,7 +6295,7 @@ async function buildPlan(specs, lock, opts = {}) {
6363
6295
  reason = `declared source is missing: ${spec.source}`;
6364
6296
  }
6365
6297
  } else if (!entry) {
6366
- if ((0, import_node_fs10.existsSync)(spec.outFile)) {
6298
+ if ((0, import_node_fs9.existsSync)(spec.outFile)) {
6367
6299
  state = "untracked";
6368
6300
  reason = "file on disk, no provenance recorded";
6369
6301
  } else {
@@ -6395,12 +6327,15 @@ async function buildPlan(specs, lock, opts = {}) {
6395
6327
  } else if (entry.status !== "downloaded") {
6396
6328
  state = "in-flight";
6397
6329
  reason = `awaiting ${entry.status}`;
6398
- } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !(0, import_node_fs10.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
6330
+ } else if (entry.outputs.length === 0 || entry.outputs.some((_output, index) => !(0, import_node_fs9.existsSync)(currentEntryOutputPath(entry, spec, index)))) {
6399
6331
  state = "orphaned";
6400
- reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !(0, import_node_fs10.existsSync)(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6332
+ reason = entry.outputs.length === 0 ? "no recorded output files" : `missing on disk: ${entry.outputs.filter((_output, index) => !(0, import_node_fs9.existsSync)(currentEntryOutputPath(entry, spec, index))).map((o) => o.role ?? "asset").join(", ")}`;
6401
6333
  } else if (await anyOutputModified(entry, spec)) {
6402
6334
  state = "orphaned";
6403
6335
  reason = "output modified since download";
6336
+ } else if (!postprocessCurrent(entry, spec)) {
6337
+ state = "recoverable";
6338
+ reason = spec.enforcePalette ? "palette enforcement turned on; run pixelkiln fetch to snap the file (no generation cost)" : "palette enforcement turned off; run pixelkiln fetch to restore the provider's bytes (no generation cost)";
6404
6339
  } else {
6405
6340
  state = "ok";
6406
6341
  reason = "up to date";
@@ -6463,13 +6398,13 @@ function summarize(plan) {
6463
6398
 
6464
6399
  // src/project.ts
6465
6400
  async function openProject(manifestPath = "pixelkiln.manifest.json", opts = {}) {
6466
- const absolute = import_node_path13.default.resolve(manifestPath);
6467
- const root = import_node_path13.default.dirname(absolute);
6401
+ const absolute = import_node_path12.default.resolve(manifestPath);
6402
+ const root = import_node_path12.default.dirname(absolute);
6468
6403
  if (opts.env !== false) {
6469
6404
  loadEnvFiles(root);
6470
- if (import_node_path13.default.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
6405
+ if (import_node_path12.default.resolve(process.cwd()) !== root) loadEnvFiles(process.cwd());
6471
6406
  }
6472
- const lockPath = import_node_path13.default.resolve(opts.lockPath ?? import_node_path13.default.join(root, "pixelkiln.lock.json"));
6407
+ const lockPath = import_node_path12.default.resolve(opts.lockPath ?? import_node_path12.default.join(root, "pixelkiln.lock.json"));
6473
6408
  const loaded = await loadManifest(absolute);
6474
6409
  const specs = await resolveSpecs(loaded, { styles: opts.styles ?? [], assets: opts.assets ?? [] });
6475
6410
  const lock = await loadLock(lockPath);
@@ -6536,9 +6471,9 @@ function shouldPersistSourceUrl(value) {
6536
6471
  }
6537
6472
 
6538
6473
  // src/recipes.ts
6539
- var import_node_path14 = __toESM(require("path"), 1);
6540
- var import_node_fs11 = require("fs");
6541
- var import_promises7 = require("fs/promises");
6474
+ var import_node_path13 = __toESM(require("path"), 1);
6475
+ var import_node_fs10 = require("fs");
6476
+ var import_promises6 = require("fs/promises");
6542
6477
  var import_node_url = require("url");
6543
6478
  var import_zod3 = require("zod");
6544
6479
  var SHA256_RE = /^[0-9a-f]{64}$/;
@@ -6547,7 +6482,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
6547
6482
  var RECIPE_FILE = "pixelkiln.recipe.json";
6548
6483
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
6549
6484
  var PortablePathSchema = import_zod3.z.string().min(1).refine((value) => {
6550
- if (value.includes("\\") || import_node_path14.default.posix.isAbsolute(value)) return false;
6485
+ if (value.includes("\\") || import_node_path13.default.posix.isAbsolute(value)) return false;
6551
6486
  const parts = value.split("/");
6552
6487
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
6553
6488
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -6723,10 +6658,10 @@ function message3(error) {
6723
6658
  return error instanceof Error ? error.message : String(error);
6724
6659
  }
6725
6660
  async function readRecipeFile(recipePath, bundled) {
6726
- const absolute = import_node_path14.default.resolve(recipePath);
6661
+ const absolute = import_node_path13.default.resolve(recipePath);
6727
6662
  let raw;
6728
6663
  try {
6729
- raw = JSON.parse(await (0, import_promises7.readFile)(absolute, "utf8"));
6664
+ raw = JSON.parse(await (0, import_promises6.readFile)(absolute, "utf8"));
6730
6665
  } catch (error) {
6731
6666
  throw new Error(`Could not read recipe ${absolute}: ${message3(error)}`, { cause: error });
6732
6667
  }
@@ -6737,13 +6672,13 @@ async function readRecipeFile(recipePath, bundled) {
6737
6672
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
6738
6673
  );
6739
6674
  }
6740
- return { recipe: parsed.data, path: absolute, dir: import_node_path14.default.dirname(absolute), bundled };
6675
+ return { recipe: parsed.data, authored: raw, path: absolute, dir: import_node_path13.default.dirname(absolute), bundled };
6741
6676
  }
6742
6677
  async function walkRecipeFiles(root) {
6743
- if (!(0, import_node_fs11.existsSync)(root)) return [];
6678
+ if (!(0, import_node_fs10.existsSync)(root)) return [];
6744
6679
  const out = [];
6745
- for (const entry of await (0, import_promises7.readdir)(root, { withFileTypes: true })) {
6746
- const resolved = import_node_path14.default.join(root, entry.name);
6680
+ for (const entry of await (0, import_promises6.readdir)(root, { withFileTypes: true })) {
6681
+ const resolved = import_node_path13.default.join(root, entry.name);
6747
6682
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
6748
6683
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
6749
6684
  }
@@ -6754,7 +6689,7 @@ function bundledRecipeRoot() {
6754
6689
  }
6755
6690
  async function listBundledRecipes(root = bundledRecipeRoot()) {
6756
6691
  const recipes = await Promise.all(
6757
- (await walkRecipeFiles(import_node_path14.default.resolve(root))).map((file) => readRecipeFile(file, true))
6692
+ (await walkRecipeFiles(import_node_path13.default.resolve(root))).map((file) => readRecipeFile(file, true))
6758
6693
  );
6759
6694
  return recipes.sort(
6760
6695
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -6777,9 +6712,9 @@ function parseSelector(selector) {
6777
6712
  return { id: match[1], version: match[2] };
6778
6713
  }
6779
6714
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6780
- const local = import_node_path14.default.resolve(target);
6781
- if ((0, import_node_fs11.existsSync)(local)) {
6782
- const recipePath = import_node_path14.default.basename(local) === RECIPE_FILE ? local : import_node_path14.default.join(local, RECIPE_FILE);
6715
+ const local = import_node_path13.default.resolve(target);
6716
+ if ((0, import_node_fs10.existsSync)(local)) {
6717
+ const recipePath = import_node_path13.default.basename(local) === RECIPE_FILE ? local : import_node_path13.default.join(local, RECIPE_FILE);
6783
6718
  return readRecipeFile(recipePath, false);
6784
6719
  }
6785
6720
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -6793,8 +6728,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
6793
6728
  return matches[0];
6794
6729
  }
6795
6730
  async function verifyFile(root, file) {
6796
- const absolute = import_node_path14.default.resolve(root, ...file.path.split("/"));
6797
- if (!(0, import_node_fs11.existsSync)(absolute)) {
6731
+ const absolute = import_node_path13.default.resolve(root, ...file.path.split("/"));
6732
+ if (!(0, import_node_fs10.existsSync)(absolute)) {
6798
6733
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
6799
6734
  }
6800
6735
  const actualSha256 = await sha256File(absolute);
@@ -6807,9 +6742,9 @@ async function verifyFile(root, file) {
6807
6742
  }
6808
6743
  async function verifyRecipe(target, options = {}) {
6809
6744
  const loaded = await resolveRecipe(target, options.bundledRoot);
6810
- const actualIntegrity = recipeDigest(loaded.recipe);
6745
+ const actualIntegrity = recipeDigest(loaded.authored);
6811
6746
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
6812
- const modelRoot = options.modelRoot ? import_node_path14.default.resolve(options.modelRoot) : null;
6747
+ const modelRoot = options.modelRoot ? import_node_path13.default.resolve(options.modelRoot) : null;
6813
6748
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
6814
6749
  ...await verifyFile(modelRoot, model),
6815
6750
  source: model.source,
@@ -6843,7 +6778,7 @@ async function verifyRecipe(target, options = {}) {
6843
6778
  };
6844
6779
  }
6845
6780
  function renderStyle(style, destination, cwd) {
6846
- const portableDir = import_node_path14.default.relative(cwd, destination).split(import_node_path14.default.sep).join("/") || ".";
6781
+ const portableDir = import_node_path13.default.relative(cwd, destination).split(import_node_path13.default.sep).join("/") || ".";
6847
6782
  const replace = (value) => {
6848
6783
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
6849
6784
  if (Array.isArray(value)) return value.map(replace);
@@ -6855,25 +6790,25 @@ function renderStyle(style, destination, cwd) {
6855
6790
  return replace(style);
6856
6791
  }
6857
6792
  async function installRecipe(target, options = {}) {
6858
- const cwd = import_node_path14.default.resolve(options.cwd ?? process.cwd());
6793
+ const cwd = import_node_path13.default.resolve(options.cwd ?? process.cwd());
6859
6794
  const loaded = await resolveRecipe(target, options.bundledRoot);
6860
6795
  const verification = await verifyRecipe(loaded.path);
6861
6796
  if (!verification.ok) {
6862
6797
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
6863
6798
  }
6864
- const destination = options.out ? import_node_path14.default.resolve(cwd, options.out) : import_node_path14.default.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6799
+ const destination = options.out ? import_node_path13.default.resolve(cwd, options.out) : import_node_path13.default.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
6865
6800
  const sources = [
6866
6801
  { path: RECIPE_FILE, source: loaded.path },
6867
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: import_node_path14.default.join(loaded.dir, ...file.path.split("/")) }))
6802
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: import_node_path13.default.join(loaded.dir, ...file.path.split("/")) }))
6868
6803
  ];
6869
6804
  const files = await Promise.all(sources.map(async (file) => ({
6870
- path: import_node_path14.default.join(destination, ...file.path.split("/")),
6871
- data: await (0, import_promises7.readFile)(file.source)
6805
+ path: import_node_path13.default.join(destination, ...file.path.split("/")),
6806
+ data: await (0, import_promises6.readFile)(file.source)
6872
6807
  })));
6873
6808
  if (!options.force) {
6874
6809
  for (const file of files) {
6875
- if (!(0, import_node_fs11.existsSync)(file.path)) continue;
6876
- const current = await (0, import_promises7.readFile)(file.path);
6810
+ if (!(0, import_node_fs10.existsSync)(file.path)) continue;
6811
+ const current = await (0, import_promises6.readFile)(file.path);
6877
6812
  if (!current.equals(file.data)) {
6878
6813
  throw new OverwriteRefusedError(
6879
6814
  `Recipe destination has local changes: ${file.path}. Choose another --out or pass --force to replace declared recipe files.`,
@@ -6896,6 +6831,152 @@ async function installRecipe(target, options = {}) {
6896
6831
  };
6897
6832
  }
6898
6833
 
6834
+ // src/pipeline/audit.ts
6835
+ var import_promises7 = require("fs/promises");
6836
+ var import_node_fs11 = require("fs");
6837
+ var import_node_path14 = __toESM(require("path"), 1);
6838
+ function paletteDistance(asset, reference) {
6839
+ if (!asset.length || !reference.length) return 0;
6840
+ let total = 0;
6841
+ let weight = 0;
6842
+ for (const color of asset) {
6843
+ let nearest = Infinity;
6844
+ for (const ref of reference) nearest = Math.min(nearest, colorDistance(color, ref));
6845
+ total += nearest * color.weight;
6846
+ weight += color.weight;
6847
+ }
6848
+ return weight === 0 ? 0 : total / weight;
6849
+ }
6850
+ function mergePalettes(palettes, topN = 24) {
6851
+ const totals = /* @__PURE__ */ new Map();
6852
+ for (const palette of palettes) {
6853
+ for (const color of palette) {
6854
+ const key = `${color.r},${color.g},${color.b}`;
6855
+ const existing = totals.get(key);
6856
+ if (existing) existing.weight += color.weight;
6857
+ else totals.set(key, { ...color });
6858
+ }
6859
+ }
6860
+ const merged = [...totals.values()].sort((a, b) => b.weight - a.weight).slice(0, topN);
6861
+ const sum = merged.reduce((s, c) => s + c.weight, 0) || 1;
6862
+ return merged.map((c) => ({ ...c, weight: c.weight / sum }));
6863
+ }
6864
+ async function auditStyle(loaded, specs, styleId, lock) {
6865
+ const style = loaded.manifest.styles[styleId];
6866
+ if (!style) throw new Error(`Unknown style "${styleId}"`);
6867
+ const mine = specs.filter((s) => s.styleId === styleId);
6868
+ const assets = [];
6869
+ const missing = [];
6870
+ const unreadable = [];
6871
+ for (const spec of mine) {
6872
+ for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
6873
+ if (!(0, import_node_fs11.existsSync)(output.absolutePath)) {
6874
+ missing.push(output.id);
6875
+ continue;
6876
+ }
6877
+ try {
6878
+ const png = decodePng(await (0, import_promises7.readFile)(output.absolutePath));
6879
+ const palette = extractPalette(png, 12);
6880
+ assets.push({
6881
+ assetId: spec.assetId,
6882
+ ...output.role ? { outputRole: output.role } : {},
6883
+ id: output.id,
6884
+ file: output.absolutePath,
6885
+ width: png.width,
6886
+ height: png.height,
6887
+ paletteDistance: 0,
6888
+ // filled once the reference is known
6889
+ transparency: transparencyRatio(png),
6890
+ colorCount: countColors(png),
6891
+ palette
6892
+ });
6893
+ } catch (err) {
6894
+ unreadable.push(`${output.id}: ${err instanceof Error ? err.message : String(err)}`);
6895
+ }
6896
+ }
6897
+ }
6898
+ let reference = [];
6899
+ let referenceFromStyleImages = false;
6900
+ const refPalettes = [];
6901
+ for (const img of style.styleImages) {
6902
+ const abs = import_node_path14.default.resolve(loaded.root, img.path);
6903
+ if (!(0, import_node_fs11.existsSync)(abs)) continue;
6904
+ try {
6905
+ refPalettes.push(extractPalette(decodePng(await (0, import_promises7.readFile)(abs)), 12));
6906
+ } catch {
6907
+ }
6908
+ }
6909
+ if (refPalettes.length) {
6910
+ reference = mergePalettes(refPalettes);
6911
+ referenceFromStyleImages = true;
6912
+ for (const asset of assets) {
6913
+ asset.paletteDistance = paletteDistance(asset.palette, reference);
6914
+ }
6915
+ } else {
6916
+ reference = mergePalettes(assets.map((a) => a.palette));
6917
+ for (const asset of assets) {
6918
+ const others = assets.filter((a) => a !== asset).map((a) => a.palette);
6919
+ asset.paletteDistance = others.length ? paletteDistance(asset.palette, mergePalettes(others)) : 0;
6920
+ }
6921
+ }
6922
+ assets.sort((a, b) => b.paletteDistance - a.paletteDistance);
6923
+ return { styleId, referenceFromStyleImages, reference, assets, missing, unreadable };
6924
+ }
6925
+ function countColors(png) {
6926
+ const seen = /* @__PURE__ */ new Set();
6927
+ for (let i = 0; i < png.pixels.length; i += 4) {
6928
+ if (png.pixels[i + 3] < 128) continue;
6929
+ seen.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
6930
+ }
6931
+ return seen.size;
6932
+ }
6933
+ function outliers(audit, sigma = 1.5) {
6934
+ const n = audit.assets.length;
6935
+ if (n < 3) return [];
6936
+ const mean = audit.assets.reduce((s, a) => s + a.paletteDistance, 0) / n;
6937
+ const variance = audit.assets.reduce((s, a) => s + (a.paletteDistance - mean) ** 2, 0) / n;
6938
+ const sd = Math.sqrt(variance);
6939
+ if (sd === 0) return [];
6940
+ return audit.assets.filter((a) => a.paletteDistance > mean + sigma * sd);
6941
+ }
6942
+ function evaluateAudit(audit, thresholds = {}) {
6943
+ const resolved = { ...thresholds, sigma: thresholds.sigma ?? 1.5 };
6944
+ const relative = outliers(audit, resolved.sigma);
6945
+ const relativeIds = new Set(relative.map((asset) => asset.id));
6946
+ const violations = [];
6947
+ for (const asset of audit.assets) {
6948
+ const reasons = [];
6949
+ if (relativeIds.has(asset.id)) {
6950
+ reasons.push(`palette outlier beyond ${resolved.sigma} standard deviations`);
6951
+ }
6952
+ if (resolved.maxDistance !== void 0 && asset.paletteDistance > resolved.maxDistance) {
6953
+ reasons.push(
6954
+ `palette distance ${asset.paletteDistance.toFixed(1)} exceeds ${resolved.maxDistance}`
6955
+ );
6956
+ }
6957
+ if (resolved.minTransparency !== void 0 && asset.transparency < resolved.minTransparency) {
6958
+ reasons.push(
6959
+ `transparency ${(asset.transparency * 100).toFixed(1)}% is below ${(resolved.minTransparency * 100).toFixed(1)}%`
6960
+ );
6961
+ }
6962
+ if (resolved.maxColors !== void 0 && asset.colorCount > resolved.maxColors) {
6963
+ reasons.push(`color count ${asset.colorCount} exceeds ${resolved.maxColors}`);
6964
+ }
6965
+ if (reasons.length) violations.push({ id: asset.id, reasons });
6966
+ }
6967
+ return {
6968
+ safe: violations.length === 0 && audit.missing.length === 0 && audit.unreadable.length === 0,
6969
+ thresholds: resolved,
6970
+ outliers: [...relativeIds],
6971
+ violations,
6972
+ missing: [...audit.missing],
6973
+ unreadable: [...audit.unreadable]
6974
+ };
6975
+ }
6976
+ function hex(c) {
6977
+ return `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
6978
+ }
6979
+
6899
6980
  // src/pipeline/quality-regression.ts
6900
6981
  var import_node_path15 = __toESM(require("path"), 1);
6901
6982
  var import_node_fs12 = require("fs");
@@ -7368,8 +7449,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7368
7449
  if (e.status === "selected" || e.status === "download-failed") return true;
7369
7450
  if (e.status !== "downloaded") return false;
7370
7451
  if (opts.refresh) return e.outputs.length > 0 && Boolean(e.sourceUrls?.length || e.sourceUrl);
7371
- if (!opts.repair) return false;
7372
7452
  const spec = specByKey.get(key);
7453
+ if (spec && e.specHash === spec.specHash && e.outputs.length && !postprocessCurrent(e, spec)) {
7454
+ return Boolean(opts.force) || e.outputs.every((output) => {
7455
+ const file = resolveOutputPath(output.path, spec.root);
7456
+ return (0, import_node_fs14.existsSync)(file) && sha256((0, import_node_fs14.readFileSync)(file)) === output.sha256;
7457
+ });
7458
+ }
7459
+ if (!opts.repair) return false;
7373
7460
  if (e.outputs.length === 0 || !spec) return true;
7374
7461
  return e.outputs.some((output) => {
7375
7462
  const file = resolveOutputPath(output.path, spec.root);
@@ -7408,6 +7495,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7408
7495
  }
7409
7496
  const outputs = [];
7410
7497
  let wrote = false;
7498
+ const wanted = postprocessFor(spec);
7499
+ const reapply = entry.status === "downloaded" && !postprocessCurrent(entry, spec);
7411
7500
  try {
7412
7501
  for (let index = 0; index < sources.length; index++) {
7413
7502
  const source = sources[index];
@@ -7415,20 +7504,24 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7415
7504
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
7416
7505
  );
7417
7506
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
7507
+ const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7508
+ const recordedRaw = recorded?.raw ?? recorded?.sha256;
7418
7509
  let fresh = null;
7419
7510
  if (opts.refresh) {
7420
7511
  if (!source.url) throw new Error(`no durable source URL to refresh ${source.role ?? "asset"} from`);
7421
7512
  fresh = await provider.download(source.url);
7422
- if (recorded && sha256(fresh) === recorded.sha256) {
7513
+ if (recorded && sha256(fresh) === recordedRaw && !reapply) {
7423
7514
  log(` current ${import_node_path17.default.relative(process.cwd(), target)}`);
7424
7515
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7425
7516
  continue;
7426
7517
  }
7427
7518
  }
7519
+ let raw = null;
7520
+ let final = null;
7428
7521
  if ((0, import_node_fs14.existsSync)(target)) {
7429
7522
  const currentHash = await sha256File(target);
7430
7523
  if (recorded && currentHash === recorded.sha256) {
7431
- if (!fresh) {
7524
+ if (!fresh && !reapply) {
7432
7525
  if (cacheDir) {
7433
7526
  await cacheMedia(
7434
7527
  cacheDir,
@@ -7440,7 +7533,12 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7440
7533
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
7441
7534
  continue;
7442
7535
  }
7443
- log(` changed ${import_node_path17.default.relative(process.cwd(), target)} (upstream)`);
7536
+ if (reapply && !fresh) {
7537
+ if (!recorded.raw) raw = await (0, import_promises10.readFile)(target);
7538
+ log(` reapply ${import_node_path17.default.relative(process.cwd(), target)}`);
7539
+ } else {
7540
+ log(` changed ${import_node_path17.default.relative(process.cwd(), target)} (upstream)`);
7541
+ }
7444
7542
  } else {
7445
7543
  const superseded = (entry.supersededOutputs ?? []).find(
7446
7544
  (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
@@ -7462,26 +7560,41 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7462
7560
  );
7463
7561
  }
7464
7562
  }
7465
- const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
7466
- let buf = fresh ?? (recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null);
7467
- if (buf) {
7468
- log(` cached ${import_node_path17.default.relative(process.cwd(), target)}`);
7469
- } else {
7563
+ if (fresh) {
7564
+ raw = fresh;
7565
+ } else if (!raw && recorded && cacheDir) {
7566
+ if (!reapply) final = await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType);
7567
+ if (!final && recordedRaw) raw = await readCachedMedia(cacheDir, recordedRaw, expectedMediaType);
7568
+ if (final || raw) log(` cached ${import_node_path17.default.relative(process.cwd(), target)}`);
7569
+ }
7570
+ if (!final && !raw) {
7470
7571
  if (!source.url) {
7471
7572
  throw new Error(`no source URL or cached bytes remain for ${source.role ?? "asset"}`);
7472
7573
  }
7473
- buf = await provider.download(source.url);
7574
+ raw = await provider.download(source.url);
7474
7575
  }
7475
7576
  let mediaType;
7577
+ const bytes = final ?? raw;
7476
7578
  try {
7477
- mediaType = validateMedia(buf, expectedMediaType);
7579
+ mediaType = validateMedia(bytes, expectedMediaType);
7478
7580
  } catch (err) {
7479
7581
  const label = expectedMediaType === MediaType.GIF ? "GIF" : "PNG";
7480
- const mismatch = detectMediaType(buf) !== expectedMediaType;
7582
+ const mismatch = detectMediaType(bytes) !== expectedMediaType;
7481
7583
  throw new Error(
7482
- `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${buf.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
7584
+ `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${bytes.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
7483
7585
  );
7484
7586
  }
7587
+ let rawHash = final ? recorded?.raw : void 0;
7588
+ let buf = bytes;
7589
+ if (!final) {
7590
+ if (cacheDir) await cacheMedia(cacheDir, raw, mediaType);
7591
+ const processed = applyPostprocess(raw, mediaType, wanted);
7592
+ buf = processed.bytes;
7593
+ if (processed.changed) {
7594
+ rawHash = sha256(raw);
7595
+ log(` snapped ${import_node_path17.default.relative(process.cwd(), target)} to ${wanted.palette.colors.length} colours`);
7596
+ }
7597
+ }
7485
7598
  if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
7486
7599
  await (0, import_promises10.mkdir)(import_node_path17.default.dirname(target), { recursive: true });
7487
7600
  const tmp = `${target}.pixelkiln.tmp`;
@@ -7490,7 +7603,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7490
7603
  path: portableOutputPath(target, spec.root),
7491
7604
  sha256: sha256(buf),
7492
7605
  ...source.role ? { role: source.role } : {},
7493
- mediaType
7606
+ mediaType,
7607
+ ...rawHash ? { raw: rawHash } : {}
7494
7608
  });
7495
7609
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
7496
7610
  await saveLock(lockPath, lock);
@@ -7514,7 +7628,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
7514
7628
  sourceUrls: persistentSources,
7515
7629
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
7516
7630
  error: null,
7517
- supersededOutputs: []
7631
+ supersededOutputs: [],
7632
+ postprocess: wanted
7518
7633
  });
7519
7634
  result.downloaded++;
7520
7635
  } catch (err) {
@@ -7621,6 +7736,7 @@ function retireGeneration(entry, retiredAt) {
7621
7736
  cost: entry.cost,
7622
7737
  costUnit: entry.costUnit,
7623
7738
  provider: entry.provider,
7739
+ postprocess: entry.postprocess,
7624
7740
  retiredAt
7625
7741
  };
7626
7742
  }
@@ -7633,9 +7749,13 @@ function historyAfterReplacing(previous, limit, retiredAt) {
7633
7749
  }
7634
7750
  function referencedHashes(lock) {
7635
7751
  const hashes = /* @__PURE__ */ new Set();
7752
+ const add = (output) => {
7753
+ hashes.add(output.sha256);
7754
+ if (output.raw) hashes.add(output.raw);
7755
+ };
7636
7756
  for (const entry of Object.values(lock.entries)) {
7637
- for (const output of entry.outputs) hashes.add(output.sha256);
7638
- for (const generation of entry.history ?? []) for (const output of generation.outputs) hashes.add(output.sha256);
7757
+ for (const output of entry.outputs) add(output);
7758
+ for (const generation of entry.history ?? []) for (const output of generation.outputs) add(output);
7639
7759
  }
7640
7760
  return hashes;
7641
7761
  }
@@ -7706,6 +7826,7 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
7706
7826
  cost: generation.cost,
7707
7827
  costUnit: generation.costUnit,
7708
7828
  provider: generation.provider,
7829
+ postprocess: generation.postprocess,
7709
7830
  history
7710
7831
  });
7711
7832
  await saveLock(lockPath, lock);
@@ -9062,6 +9183,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
9062
9183
  promptSuffix: "",
9063
9184
  styleImages: [],
9064
9185
  palette: [],
9186
+ enforcePalette: false,
9065
9187
  outDir,
9066
9188
  tags: [name],
9067
9189
  providerOptions: {}
@@ -9942,6 +10064,8 @@ var ManifestEditSchema = import_zod7.z.discriminatedUnion("action", [
9942
10064
  promptSuffix: import_zod7.z.string().optional(),
9943
10065
  /** `#rrggbb` values; null or empty clears the style's own palette. */
9944
10066
  palette: import_zod7.z.array(import_zod7.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional(),
10067
+ /** Snap downloaded art to the palette; false clears the style's own value. */
10068
+ enforcePalette: import_zod7.z.boolean().optional(),
9945
10069
  /** Provider view name (PixelLab: low top-down, high top-down, side); empty clears. */
9946
10070
  view: import_zod7.z.string().max(64).nullable().optional(),
9947
10071
  /** pixflux only; null clears the style's own value. */
@@ -10033,6 +10157,7 @@ function applyEdit(raw, edit) {
10033
10157
  const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
10034
10158
  setOrDelete(style, "palette", colors.length ? colors : null);
10035
10159
  }
10160
+ if (patch2.enforcePalette !== void 0) setOrDelete(style, "enforcePalette", patch2.enforcePalette || null);
10036
10161
  if (patch2.view !== void 0) setOrDelete(style, "view", patch2.view?.trim() || null);
10037
10162
  if (patch2.noBackground !== void 0) setOrDelete(style, "noBackground", patch2.noBackground);
10038
10163
  return;
@@ -10744,6 +10869,7 @@ async function buildGallerySnapshot(opts) {
10744
10869
  outputs,
10745
10870
  quality,
10746
10871
  providerMetadata: entry?.providerMetadata ?? {},
10872
+ postprocess: entry?.postprocess ?? null,
10747
10873
  asset,
10748
10874
  source: spec.source ?? null,
10749
10875
  edit,
@@ -10804,6 +10930,7 @@ async function buildGallerySnapshot(opts) {
10804
10930
  outputs,
10805
10931
  quality: null,
10806
10932
  providerMetadata: entry.providerMetadata ?? {},
10933
+ postprocess: entry.postprocess ?? null,
10807
10934
  asset: null,
10808
10935
  source: null,
10809
10936
  edit: null,
@@ -10852,6 +10979,7 @@ async function buildGallerySnapshot(opts) {
10852
10979
  promptPrefix: style?.promptPrefix ?? "",
10853
10980
  promptSuffix: style?.promptSuffix ?? "",
10854
10981
  palette: style?.palette ?? [],
10982
+ enforcePalette: style?.enforcePalette ?? false,
10855
10983
  view: style?.view ?? null,
10856
10984
  noBackground: style?.noBackground ?? true,
10857
10985
  quality: Boolean(style?.quality),
@@ -12595,6 +12723,7 @@ async function workspaceStatus(ws, dir) {
12595
12723
  PixelLabClient,
12596
12724
  PixelLabError,
12597
12725
  PixelLabProvider,
12726
+ PostprocessSchema,
12598
12727
  ProjectError,
12599
12728
  ProviderError,
12600
12729
  QualityBaselineSchema,