pixelkiln 0.9.0 → 0.11.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,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path19 from "path";
4
+ import path20 from "path";
5
5
  import { existsSync as existsSync15 } from "fs";
6
- import { readFile as readFile15 } from "fs/promises";
6
+ import { readFile as readFile16 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
9
9
  import { readFileSync, existsSync } from "fs";
@@ -114,8 +114,8 @@ import { readFile } from "fs/promises";
114
114
  function sha256(data) {
115
115
  return createHash("sha256").update(data).digest("hex");
116
116
  }
117
- async function sha256File(path20) {
118
- return sha256(await readFile(path20));
117
+ async function sha256File(path21) {
118
+ return sha256(await readFile(path21));
119
119
  }
120
120
  function specHash(spec, styleImageHashes, providerOptionIdentity = spec.providerOptions) {
121
121
  return sha256(
@@ -974,9 +974,9 @@ function normalizeBaseUrl(value) {
974
974
  }
975
975
  function historyError(status) {
976
976
  const messages = Array.isArray(status.messages) ? status.messages : [];
977
- for (const message2 of messages) {
978
- if (!Array.isArray(message2) || message2[0] !== "execution_error" || !isObject(message2[1])) continue;
979
- const detail = message2[1].exception_message;
977
+ for (const message3 of messages) {
978
+ if (!Array.isArray(message3) || message3[0] !== "execution_error" || !isObject(message3[1])) continue;
979
+ const detail = message3[1].exception_message;
980
980
  if (typeof detail === "string" && detail.trim()) {
981
981
  return `ComfyUI execution failed: ${detail.trim().slice(0, 300)}`;
982
982
  }
@@ -1097,8 +1097,8 @@ function validateResponse(schema, raw, operation) {
1097
1097
  throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
1098
1098
  }
1099
1099
  var PixelLabError = class extends Error {
1100
- constructor(message2, status, body) {
1101
- super(message2);
1100
+ constructor(message3, status, body) {
1101
+ super(message3);
1102
1102
  this.status = status;
1103
1103
  this.body = body;
1104
1104
  this.name = "PixelLabError";
@@ -1123,11 +1123,11 @@ var PixelLabClient = class {
1123
1123
  * common than the failure mode of retrying (a duplicate object), and a
1124
1124
  * duplicate is visible and free to delete whereas a silent gap is neither.
1125
1125
  */
1126
- async request(path20, init, attempt = 0) {
1126
+ async request(path21, init, attempt = 0) {
1127
1127
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1128
1128
  let res;
1129
1129
  try {
1130
- res = await fetch(`${BASE}${path20}`, {
1130
+ res = await fetch(`${BASE}${path21}`, {
1131
1131
  ...init,
1132
1132
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
1133
1133
  headers: {
@@ -1139,24 +1139,24 @@ var PixelLabClient = class {
1139
1139
  } catch (err) {
1140
1140
  if (attempt < MAX_RETRIES) {
1141
1141
  await sleep(backoffMs(attempt));
1142
- return this.request(path20, init, attempt + 1);
1142
+ return this.request(path21, init, attempt + 1);
1143
1143
  }
1144
1144
  throw err;
1145
1145
  }
1146
1146
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
1147
1147
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
1148
1148
  await sleep(waitMs);
1149
- return this.request(path20, init, attempt + 1);
1149
+ return this.request(path21, init, attempt + 1);
1150
1150
  }
1151
1151
  const text = await res.text();
1152
1152
  if (!res.ok) {
1153
- throw new PixelLabError(`${init?.method ?? "GET"} ${path20} \u2192 ${res.status}`, res.status, text);
1153
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path21} \u2192 ${res.status}`, res.status, text);
1154
1154
  }
1155
1155
  if (!text) return {};
1156
1156
  try {
1157
1157
  return JSON.parse(text);
1158
1158
  } catch {
1159
- throw new Error(`${init?.method ?? "GET"} ${path20} returned invalid JSON`);
1159
+ throw new Error(`${init?.method ?? "GET"} ${path21} returned invalid JSON`);
1160
1160
  }
1161
1161
  }
1162
1162
  async balance() {
@@ -1996,9 +1996,9 @@ var RetroDiffusionClient = class {
1996
1996
  }
1997
1997
  return balance;
1998
1998
  }
1999
- async call(path20, init = {}) {
1999
+ async call(path21, init = {}) {
2000
2000
  if (!this.token) throw new Error("RD_API_KEY is not set");
2001
- const response = await this.request(`${this.baseUrl}${path20}`, {
2001
+ const response = await this.request(`${this.baseUrl}${path21}`, {
2002
2002
  ...init,
2003
2003
  headers: {
2004
2004
  "Content-Type": "application/json",
@@ -2262,8 +2262,8 @@ function retroError(value) {
2262
2262
  if (details.length) return details.join("; ");
2263
2263
  }
2264
2264
  if (record.detail && typeof record.detail === "object") {
2265
- const message2 = record.detail.message;
2266
- if (typeof message2 === "string") return message2;
2265
+ const message3 = record.detail.message;
2266
+ if (typeof message3 === "string") return message3;
2267
2267
  }
2268
2268
  return JSON.stringify(value);
2269
2269
  }
@@ -3262,9 +3262,9 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3262
3262
  );
3263
3263
  } catch (err) {
3264
3264
  failed++;
3265
- const message2 = err instanceof Error ? err.message : String(err);
3266
- upsert(lock, key, { status: "failed", error: message2, cost: 0 });
3267
- log2(` FAILED ${key}: ${message2}`);
3265
+ const message3 = err instanceof Error ? err.message : String(err);
3266
+ upsert(lock, key, { status: "failed", error: message3, cost: 0 });
3267
+ log2(` FAILED ${key}: ${message3}`);
3268
3268
  }
3269
3269
  await saveLock(lockPath, lock);
3270
3270
  }
@@ -3452,14 +3452,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3452
3452
  });
3453
3453
  result.downloaded++;
3454
3454
  } catch (err) {
3455
- const message2 = err instanceof Error ? err.message : String(err);
3455
+ const message3 = err instanceof Error ? err.message : String(err);
3456
3456
  upsert(lock, key, {
3457
3457
  status: "download-failed",
3458
3458
  outputs: mergeOutputs(entry.outputs, outputs),
3459
- error: `download failed: ${message2}`
3459
+ error: `download failed: ${message3}`
3460
3460
  });
3461
3461
  result.failed++;
3462
- log2(` FAILED ${key}: ${message2}`);
3462
+ log2(` FAILED ${key}: ${message3}`);
3463
3463
  }
3464
3464
  await saveLock(lockPath, lock);
3465
3465
  }
@@ -3529,7 +3529,7 @@ import { access, stat as stat2 } from "fs/promises";
3529
3529
  import path10 from "path";
3530
3530
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
3531
3531
  const checks = [];
3532
- const add = (id, level, message2) => checks.push({ id, level, message: message2 });
3532
+ const add = (id, level, message3) => checks.push({ id, level, message: message3 });
3533
3533
  add(
3534
3534
  "manifest",
3535
3535
  "ok",
@@ -3679,10 +3679,10 @@ function isSha256Hash(value) {
3679
3679
  function parseCache(value) {
3680
3680
  return HashCacheSchema.parse(value);
3681
3681
  }
3682
- async function loadCache(path20) {
3683
- if (!existsSync8(path20)) return { version: 1, hashes: {} };
3682
+ async function loadCache(path21) {
3683
+ if (!existsSync8(path21)) return { version: 1, hashes: {} };
3684
3684
  try {
3685
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile6(path20, "utf8")));
3685
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile6(path21, "utf8")));
3686
3686
  if (!parsed.success) return { version: 1, hashes: {} };
3687
3687
  return {
3688
3688
  version: 1,
@@ -3694,18 +3694,18 @@ async function loadCache(path20) {
3694
3694
  return { version: 1, hashes: {} };
3695
3695
  }
3696
3696
  }
3697
- async function saveCache(path20, cache) {
3697
+ async function saveCache(path21, cache) {
3698
3698
  const sorted = {};
3699
3699
  for (const key of Object.keys(cache.hashes).sort()) {
3700
3700
  const hash = cache.hashes[key];
3701
3701
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
3702
3702
  sorted[key] = hash;
3703
3703
  }
3704
- await mkdir3(pathModule.dirname(pathModule.resolve(path20)), { recursive: true });
3705
- const tmp = `${path20}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3704
+ await mkdir3(pathModule.dirname(pathModule.resolve(path21)), { recursive: true });
3705
+ const tmp = `${path21}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3706
3706
  try {
3707
3707
  await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
3708
- await rename3(tmp, path20);
3708
+ await rename3(tmp, path21);
3709
3709
  } finally {
3710
3710
  await rm3(tmp, { force: true });
3711
3711
  }
@@ -4156,10 +4156,10 @@ function serveReviewPage(opts) {
4156
4156
  res.end(JSON.stringify(result));
4157
4157
  server.close(() => resolve(result));
4158
4158
  } catch (err) {
4159
- const message2 = err instanceof Error ? err.message : String(err);
4160
- log2(` apply failed: ${message2}`);
4159
+ const message3 = err instanceof Error ? err.message : String(err);
4160
+ log2(` apply failed: ${message3}`);
4161
4161
  res.writeHead(500, { "Content-Type": "text/plain" });
4162
- res.end(message2);
4162
+ res.end(message3);
4163
4163
  }
4164
4164
  return;
4165
4165
  }
@@ -5044,938 +5044,1341 @@ async function inspectRemoteHashCache(remotePath) {
5044
5044
  return report;
5045
5045
  }
5046
5046
 
5047
- // src/pipeline/tileset-export.ts
5048
- function normalizeTileRules(raw) {
5049
- if (!isRecord(raw)) return null;
5050
- const ruleType = raw.rule_type;
5051
- const arity = raw.arity;
5052
- if (ruleType !== "edge" && ruleType !== "corner" && ruleType !== "outline") return null;
5053
- if (arity !== 4 && arity !== 6) return null;
5054
- const masks = {};
5055
- if (isRecord(raw.tiles)) {
5056
- for (const [key, value] of Object.entries(raw.tiles)) {
5057
- const index = tileIndex(key);
5058
- const mask = typeof value === "number" ? value : isRecord(value) && typeof value.bitmask === "number" ? value.bitmask : isRecord(value) && typeof value.mask === "number" ? value.mask : null;
5059
- if (index !== null && mask !== null && Number.isInteger(mask) && mask >= 0) masks[index] = mask;
5047
+ // src/pipeline/refine.ts
5048
+ import path18 from "path";
5049
+ import { execFile } from "child_process";
5050
+ import { mkdtemp, readFile as readFile14, rm as rm7 } from "fs/promises";
5051
+ import { tmpdir } from "os";
5052
+ import { promisify } from "util";
5053
+
5054
+ // src/artifacts.ts
5055
+ import path17 from "path";
5056
+ import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
5057
+ import { mkdir as mkdir5, readFile as readFile13, rename as rename5, rm as rm6, writeFile as writeFile7 } from "fs/promises";
5058
+ var activeTransactions = /* @__PURE__ */ new Set();
5059
+ function message(error) {
5060
+ return error instanceof Error ? error.message : String(error);
5061
+ }
5062
+ function digest(data) {
5063
+ return createHash2("sha256").update(data).digest("hex");
5064
+ }
5065
+ function portableRelative(from, to) {
5066
+ return path17.relative(from, path17.resolve(to)).split(path17.sep).join("/") || ".";
5067
+ }
5068
+ function canonical2(value) {
5069
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
5070
+ if (typeof value === "number") {
5071
+ if (!Number.isFinite(value)) throw new Error("Artifact provenance numbers must be finite.");
5072
+ return value;
5073
+ }
5074
+ if (Array.isArray(value)) return value.map(canonical2);
5075
+ if (typeof value === "object") {
5076
+ const sorted = {};
5077
+ for (const key of Object.keys(value).sort()) {
5078
+ const item = value[key];
5079
+ if (item !== void 0) sorted[key] = canonical2(item);
5060
5080
  }
5081
+ return sorted;
5061
5082
  }
5062
- const connectivity = raw.connectivity === "same" || raw.connectivity === "other" ? raw.connectivity : void 0;
5063
- const terrains = Array.isArray(raw.terrains) ? raw.terrains.filter((item) => typeof item === "string") : [];
5064
- return {
5065
- ruleType,
5066
- arity,
5067
- ...connectivity ? { connectivity } : {},
5068
- terrains,
5069
- masks,
5070
- raw
5071
- };
5083
+ throw new Error(`Artifact provenance cannot contain ${typeof value} values.`);
5072
5084
  }
5073
- function exportTileset(entry, spec, options) {
5074
- const outputs = resolveSpecEntryOutputs(entry, spec);
5075
- if (!outputs.length) throw new Error(`${spec.styleId}/${spec.assetId} has no downloaded outputs`);
5076
- const packed = packSprites(
5077
- outputs.map((output) => ({
5078
- id: output.role ?? fallbackOutputRole(output.index),
5079
- path: output.absolutePath
5080
- })),
5081
- { columns: options.columns, order: "input" }
5082
- );
5083
- if (packed.skipped.length) {
5084
- throw new Error(
5085
- `Cannot export ${spec.styleId}/${spec.assetId}: ` + packed.skipped.map((item) => `${item.id}: ${item.reason}`).join("; ")
5086
- );
5085
+ function fingerprint(provenance) {
5086
+ return digest(JSON.stringify(canonical2({
5087
+ kind: provenance.kind,
5088
+ sources: provenance.sources,
5089
+ options: provenance.options,
5090
+ outputs: provenance.outputs
5091
+ })));
5092
+ }
5093
+ function parseArtifactManifest(absolute, data) {
5094
+ let raw;
5095
+ try {
5096
+ raw = JSON.parse(data);
5097
+ } catch (error) {
5098
+ throw new Error(`${absolute} is not valid JSON: ${message(error)}`, { cause: error });
5087
5099
  }
5088
- const metadata = entry.providerMetadata?.[entry.provider];
5089
- const rawRules = isRecord(metadata) ? metadata.tileRules : void 0;
5090
- const rules = normalizeTileRules(rawRules);
5091
- const frames = packed.atlas.frames;
5092
- const generic = buildGeneric(
5093
- spec,
5094
- options.imageName,
5095
- packed.atlas,
5096
- frames,
5097
- isRecord(rawRules) ? rawRules : null,
5098
- rules
5100
+ if (raw.format !== "pixelkiln-artifact-bundle" || raw.version !== 1 || raw.kind !== "pack" && raw.kind !== "mount" && raw.kind !== "tileset" && raw.kind !== "refine" || typeof raw.fingerprint !== "string" || raw.options === void 0 || !Array.isArray(raw.sources) || !Array.isArray(raw.outputs)) {
5101
+ throw new Error(`${absolute} is not a PixelKiln artifact bundle manifest.`);
5102
+ }
5103
+ for (const source of raw.sources) {
5104
+ if (!source || typeof source.id !== "string" || typeof source.path !== "string" || source.sha256 !== null && typeof source.sha256 !== "string" || typeof source.included !== "boolean") {
5105
+ throw new Error(`${absolute} contains an invalid artifact source.`);
5106
+ }
5107
+ }
5108
+ for (const output of raw.outputs) {
5109
+ if (!output || typeof output.path !== "string" || typeof output.sha256 !== "string") {
5110
+ throw new Error(`${absolute} contains an invalid artifact output.`);
5111
+ }
5112
+ }
5113
+ return raw;
5114
+ }
5115
+ async function readArtifactBundleManifest(manifestPath) {
5116
+ const absolute = path17.resolve(manifestPath);
5117
+ return parseArtifactManifest(absolute, await readFile13(absolute, "utf8"));
5118
+ }
5119
+ async function readOptional(file) {
5120
+ try {
5121
+ return await readFile13(file);
5122
+ } catch (error) {
5123
+ if (isCode(error, "ENOENT")) return null;
5124
+ throw error;
5125
+ }
5126
+ }
5127
+ function transactionMarker(journal) {
5128
+ return `${journal}.committed`;
5129
+ }
5130
+ function processIsAlive(pid) {
5131
+ if (!Number.isInteger(pid) || pid < 1) return false;
5132
+ try {
5133
+ process.kill(pid, 0);
5134
+ return true;
5135
+ } catch (error) {
5136
+ return !isCode(error, "ESRCH");
5137
+ }
5138
+ }
5139
+ function validTemporaryPath(candidate, destination, type) {
5140
+ return path17.dirname(candidate) === path17.dirname(destination) && path17.basename(candidate).startsWith(
5141
+ `.${path17.basename(destination)}.pixelkiln-${type}-`
5099
5142
  );
5100
- if (options.format === "generic") {
5101
- return {
5102
- png: packed.png,
5103
- extension: ".json",
5104
- document: JSON.stringify(generic, null, 2) + "\n",
5105
- generic,
5106
- sources: packed.sources
5107
- };
5143
+ }
5144
+ function parseTransaction(journal, data) {
5145
+ let raw;
5146
+ try {
5147
+ raw = JSON.parse(data);
5148
+ } catch (error) {
5149
+ throw new Error(`${journal} is not valid transaction JSON: ${message(error)}`, { cause: error });
5108
5150
  }
5109
- assertUniformTiles(generic);
5110
- if ((rawRules !== void 0 || entry.tileFeature) && !rules) {
5111
- throw new Error(
5112
- `${options.format} export needs recognized adjacency rules; use --format generic to retain this provider rule object without guessing`
5113
- );
5151
+ if (raw.format !== "pixelkiln-artifact-transaction" || raw.version !== 1 || !Number.isInteger(raw.pid) || typeof raw.createdAt !== "string" || !Array.isArray(raw.entries) || !raw.entries.length) {
5152
+ throw new Error(`${journal} is not a PixelKiln artifact transaction.`);
5114
5153
  }
5115
- if (rules?.arity === 6) {
5154
+ return raw;
5155
+ }
5156
+ async function removeTransactionFiles(journal) {
5157
+ await rm6(journal, { force: true });
5158
+ await rm6(transactionMarker(journal), { force: true });
5159
+ }
5160
+ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
5161
+ const journal = path17.resolve(recoveryFile);
5162
+ const bytes = await readOptional(journal);
5163
+ if (!bytes) {
5164
+ await rm6(transactionMarker(journal), { force: true });
5165
+ return;
5166
+ }
5167
+ if (activeTransactions.has(journal)) {
5168
+ throw new Error(`Another artifact write in this process is already using ${journal}.`);
5169
+ }
5170
+ const transaction = parseTransaction(journal, bytes.toString("utf8"));
5171
+ if (transaction.pid !== process.pid && processIsAlive(transaction.pid)) {
5116
5172
  throw new Error(
5117
- `${options.format} export does not yet map six-edge hex masks safely; use --format generic`
5173
+ `Artifact transaction ${journal} is still owned by live process ${transaction.pid}; refusing concurrent recovery.`
5118
5174
  );
5119
5175
  }
5120
- if (rules?.ruleType === "outline") {
5176
+ for (const entry of transaction.entries) {
5177
+ if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path17.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
5178
+ throw new Error(
5179
+ `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
5180
+ );
5181
+ }
5182
+ }
5183
+ const committed = await readOptional(transactionMarker(journal)) !== null;
5184
+ const errors = [];
5185
+ if (committed) {
5186
+ for (const entry of transaction.entries) {
5187
+ if (entry.backup) await remove2(entry.backup, errors);
5188
+ await remove2(entry.stage, errors);
5189
+ }
5190
+ } else {
5191
+ for (const entry of [...transaction.entries].reverse()) {
5192
+ const backup = entry.backup ? await readOptional(entry.backup) : null;
5193
+ const destination = await readOptional(entry.destination);
5194
+ if (backup) {
5195
+ if (destination && digest(destination) !== entry.sha256) {
5196
+ errors.push(
5197
+ `destination changed during interrupted recovery: ${entry.destination}; previous file remains at ${entry.backup}`
5198
+ );
5199
+ continue;
5200
+ }
5201
+ await remove2(entry.destination, errors);
5202
+ try {
5203
+ await rename5(entry.backup, entry.destination);
5204
+ } catch (error) {
5205
+ errors.push(
5206
+ `could not restore ${entry.destination}; previous file remains at ${entry.backup}: ${message(error)}`
5207
+ );
5208
+ }
5209
+ } else if (destination && digest(destination) === entry.sha256) {
5210
+ await remove2(entry.destination, errors);
5211
+ }
5212
+ await remove2(entry.stage, errors);
5213
+ }
5214
+ }
5215
+ if (errors.length) {
5121
5216
  throw new Error(
5122
- `${options.format} export cannot infer adjacency from outline/stamp-only rules; use --format generic`
5217
+ `Artifact transaction recovery needs attention; journal retained at ${journal}: ` + errors.join("; ")
5123
5218
  );
5124
5219
  }
5125
- if (options.format === "tiled") {
5126
- return {
5127
- png: packed.png,
5128
- extension: ".tsj",
5129
- document: JSON.stringify(buildTiled(generic), null, 2) + "\n",
5130
- generic,
5131
- sources: packed.sources
5132
- };
5133
- }
5134
- return {
5135
- png: packed.png,
5136
- extension: ".tres",
5137
- document: buildGodot(generic, spec),
5138
- generic,
5139
- sources: packed.sources
5140
- };
5220
+ await removeTransactionFiles(journal);
5141
5221
  }
5142
- function buildGeneric(spec, imageName, atlas, frames, providerRules, rules) {
5222
+ function createArtifactBundleManifest(manifestPath, outputs, provenance) {
5223
+ const root = path17.dirname(path17.resolve(manifestPath));
5224
+ const sources = provenance.sources.map((source) => ({
5225
+ ...source,
5226
+ path: portableRelative(root, source.path)
5227
+ }));
5228
+ const normalized = {
5229
+ kind: provenance.kind,
5230
+ sources,
5231
+ options: canonical2(provenance.options)
5232
+ };
5233
+ const manifestOutputs = outputs.map((output) => ({
5234
+ path: portableRelative(root, output.path),
5235
+ sha256: digest(output.data)
5236
+ }));
5143
5237
  return {
5144
- format: "pixelkiln-tileset",
5238
+ format: "pixelkiln-artifact-bundle",
5145
5239
  version: 1,
5146
- name: `${spec.styleId}/${spec.assetId}`,
5147
- style: spec.styleId,
5148
- asset: spec.assetId,
5149
- image: imageName,
5150
- tile: { ...atlas.cell },
5151
- sheet: { ...atlas.sheet, columns: atlas.columns },
5152
- tiles: frames.map((frame, index) => {
5153
- const sourceIndex = tileIndex(frame.id) ?? index;
5154
- return {
5155
- id: index,
5156
- sourceIndex,
5157
- role: frame.id,
5158
- x: frame.x,
5159
- y: frame.y,
5160
- width: frame.width,
5161
- height: frame.height,
5162
- ...rules?.masks[sourceIndex] !== void 0 ? { bitmask: rules.masks[sourceIndex] } : {},
5163
- stampOnly: rules ? rules.masks[sourceIndex] === void 0 : true
5164
- };
5165
- }),
5166
- providerRules,
5167
- rules
5168
- };
5169
- }
5170
- function buildTiled(generic) {
5171
- const rules = generic.rules;
5172
- const tiled = {
5173
- type: "tileset",
5174
- version: "1.10",
5175
- tiledversion: "1.12.2",
5176
- name: generic.name.replace("/", "-"),
5177
- tilewidth: generic.tile.width,
5178
- tileheight: generic.tile.height,
5179
- tilecount: generic.tiles.length,
5180
- columns: generic.sheet.columns,
5181
- image: generic.image,
5182
- imagewidth: generic.sheet.width,
5183
- imageheight: generic.sheet.height,
5184
- margin: 0,
5185
- spacing: 0
5240
+ ...normalized,
5241
+ fingerprint: fingerprint({ ...normalized, outputs: manifestOutputs }),
5242
+ outputs: manifestOutputs
5186
5243
  };
5187
- if (!rules || rules.ruleType === "outline") return tiled;
5188
- const terrainNames = terrainNamesFor(rules);
5189
- tiled.wangsets = [{
5190
- name: generic.name,
5191
- type: rules.ruleType,
5192
- tile: representativeTile(generic, 0),
5193
- colors: terrainNames.map((name, index) => ({
5194
- name,
5195
- color: index === 0 ? "#5ab552" : "#4c78a8",
5196
- tile: representativeTile(generic, index),
5197
- probability: 1
5198
- })),
5199
- properties: rules.connectivity ? [{ name: "pixelkiln:connectivity", type: "string", value: rules.connectivity }] : [],
5200
- wangtiles: generic.tiles.filter((tile) => tile.bitmask !== void 0).map((tile) => ({ tileid: tile.id, wangid: tiledWangId(rules, tile.bitmask) }))
5201
- }];
5202
- return tiled;
5203
- }
5204
- function tiledWangId(rules, mask) {
5205
- const bitColor = (bit) => (mask & 1 << bit) !== 0 ? 1 : 2;
5206
- if (rules.ruleType === "corner") {
5207
- return [0, bitColor(2), 0, bitColor(0), 0, bitColor(1), 0, bitColor(3)];
5208
- }
5209
- return [bitColor(0), 0, bitColor(1), 0, bitColor(2), 0, bitColor(3), 0];
5210
5244
  }
5211
- function buildGodot(generic, spec) {
5212
- const rules = generic.rules;
5213
- const lines = [
5214
- `[gd_resource type="TileSet" load_steps=3 format=3]`,
5215
- "",
5216
- `[ext_resource type="Texture2D" path=${JSON.stringify(`./${generic.image}`)} id="1_texture"]`,
5217
- "",
5218
- `[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_pixelkiln"]`,
5219
- `texture = ExtResource("1_texture")`,
5220
- `texture_region_size = Vector2i(${generic.tile.width}, ${generic.tile.height})`
5245
+ function withArtifactManifest(manifestPath, outputs, provenance) {
5246
+ const manifest = createArtifactBundleManifest(manifestPath, outputs, provenance);
5247
+ return [
5248
+ ...outputs,
5249
+ { path: manifestPath, data: JSON.stringify(manifest, null, 2) + "\n" }
5221
5250
  ];
5222
- for (const tile of generic.tiles) {
5223
- const x = tile.id % generic.sheet.columns;
5224
- const y = Math.floor(tile.id / generic.sheet.columns);
5225
- lines.push(`${x}:${y}/0 = 0`);
5226
- if (!rules || tile.bitmask === void 0 || rules.ruleType === "outline") continue;
5227
- const values = godotTerrainBits(rules, tile.bitmask);
5228
- lines.push(`${x}:${y}/0/terrain_set = 0`);
5229
- lines.push(`${x}:${y}/0/terrain = ${dominantTerrain(values)}`);
5230
- for (const [side, value] of Object.entries(values)) {
5231
- lines.push(`${x}:${y}/0/terrains_peering_bit/${side} = ${value}`);
5251
+ }
5252
+ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
5253
+ const absoluteManifest = path17.resolve(manifestPath);
5254
+ const recoveryFile = `${absoluteManifest}.transaction`;
5255
+ const allowedDestinations = /* @__PURE__ */ new Set([
5256
+ absoluteManifest,
5257
+ ...outputs.map((output) => path17.resolve(output.path))
5258
+ ]);
5259
+ await recoverArtifactTransaction(recoveryFile, allowedDestinations);
5260
+ const existingManifestBytes = await readOptional(absoluteManifest);
5261
+ let previous = null;
5262
+ if (existingManifestBytes && !options.force) {
5263
+ try {
5264
+ previous = parseArtifactManifest(absoluteManifest, existingManifestBytes.toString("utf8"));
5265
+ const valid = previous.fingerprint === fingerprint(previous);
5266
+ if (!valid) throw new Error("its integrity fingerprint does not match");
5267
+ } catch (error) {
5268
+ throw new Error(
5269
+ `Refusing to replace unrecognized or modified provenance at ${absoluteManifest}: ${message(error)}. Pass { force: true } (CLI: --force) to take ownership of this artifact bundle.`,
5270
+ { cause: error }
5271
+ );
5232
5272
  }
5233
5273
  }
5234
- lines.push("", "[resource]");
5235
- lines.push(`tile_size = Vector2i(${generic.tile.width}, ${generic.tile.height})`);
5236
- const shape = godotTileShape(spec.tileType ?? "isometric");
5237
- if (shape !== 0) lines.push(`tile_shape = ${shape}`);
5238
- if (rules && rules.ruleType !== "outline") {
5239
- lines.push(`terrain_set_0/mode = ${rules.ruleType === "corner" ? 1 : 2}`);
5240
- for (const [index, name] of terrainNamesFor(rules).entries()) {
5241
- lines.push(`terrain_set_0/terrain_${index}/name = ${JSON.stringify(name)}`);
5242
- lines.push(
5243
- `terrain_set_0/terrain_${index}/color = ` + (index === 0 ? "Color(0.3529, 0.7098, 0.3216, 1)" : "Color(0.298, 0.4706, 0.6588, 1)")
5274
+ if (!options.force) {
5275
+ const root = path17.dirname(absoluteManifest);
5276
+ const recorded = new Map(
5277
+ previous?.outputs.map((output) => [path17.resolve(root, output.path), output.sha256]) ?? []
5278
+ );
5279
+ const conflicts = [];
5280
+ for (const output of outputs) {
5281
+ const destination = path17.resolve(output.path);
5282
+ const current = await readOptional(destination);
5283
+ if (!current || digest(current) === digest(output.data)) continue;
5284
+ const expected = recorded.get(destination);
5285
+ if (!expected || digest(current) !== expected) conflicts.push(destination);
5286
+ }
5287
+ if (conflicts.length) {
5288
+ throw new Error(
5289
+ `Refusing to overwrite modified or unowned artifact file(s):
5290
+ ` + conflicts.map((file) => ` ${file}`).join("\n") + `
5291
+ Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
5244
5292
  );
5245
5293
  }
5246
5294
  }
5247
- lines.push(`sources/0 = SubResource("TileSetAtlasSource_pixelkiln")`, "");
5248
- return lines.join("\n");
5295
+ const { force: _force, ...bundleOptions } = options;
5296
+ return writeArtifactBundle(
5297
+ withArtifactManifest(absoluteManifest, outputs, provenance),
5298
+ { ...bundleOptions, recoveryFile }
5299
+ );
5249
5300
  }
5250
- function godotTerrainBits(rules, mask) {
5251
- const terrain = (bit) => (mask & 1 << bit) !== 0 ? 0 : 1;
5252
- if (rules.ruleType === "corner") {
5253
- return {
5254
- top_left_corner: terrain(3),
5255
- top_right_corner: terrain(2),
5256
- bottom_left_corner: terrain(1),
5257
- bottom_right_corner: terrain(0)
5258
- };
5301
+ async function verifyArtifactBundle(manifestPath) {
5302
+ const absolute = path17.resolve(manifestPath);
5303
+ const raw = await readArtifactBundleManifest(absolute);
5304
+ const root = path17.dirname(absolute);
5305
+ const changedSources = [];
5306
+ const changedOutputs = [];
5307
+ for (const source of raw.sources) {
5308
+ let actual = null;
5309
+ try {
5310
+ actual = digest(await readFile13(path17.resolve(root, source.path)));
5311
+ } catch (error) {
5312
+ if (!isCode(error, "ENOENT")) throw error;
5313
+ }
5314
+ if (actual !== source.sha256) changedSources.push(source.id);
5259
5315
  }
5316
+ for (const output of raw.outputs) {
5317
+ try {
5318
+ if (digest(await readFile13(path17.resolve(root, output.path))) !== output.sha256) {
5319
+ changedOutputs.push(output.path);
5320
+ }
5321
+ } catch (error) {
5322
+ if (!isCode(error, "ENOENT")) throw error;
5323
+ changedOutputs.push(output.path);
5324
+ }
5325
+ }
5326
+ const fingerprintValid = raw.fingerprint === fingerprint({
5327
+ kind: raw.kind,
5328
+ sources: raw.sources,
5329
+ options: raw.options,
5330
+ outputs: raw.outputs
5331
+ });
5260
5332
  return {
5261
- top_side: terrain(0),
5262
- right_side: terrain(1),
5263
- bottom_side: terrain(2),
5264
- left_side: terrain(3)
5333
+ current: fingerprintValid && !changedSources.length && !changedOutputs.length,
5334
+ fingerprintValid,
5335
+ changedSources,
5336
+ changedOutputs
5265
5337
  };
5266
5338
  }
5267
- function dominantTerrain(values) {
5268
- const terrainZero = Object.values(values).filter((value) => value === 0).length;
5269
- return terrainZero * 2 >= Object.keys(values).length ? 0 : 1;
5270
- }
5271
- function godotTileShape(tileType) {
5272
- if (tileType === "isometric" || tileType === "oblique") return 1;
5273
- if (tileType === "hex" || tileType === "hex_pointy") return 3;
5274
- return 0;
5275
- }
5276
- function representativeTile(generic, terrainIndex) {
5277
- const rules = generic.rules;
5278
- const full = (1 << rules.arity) - 1;
5279
- const wanted = terrainIndex === 0 ? full : 0;
5280
- return generic.tiles.find((tile) => tile.bitmask === wanted)?.id ?? -1;
5281
- }
5282
- function terrainNamesFor(rules) {
5283
- return [rules.terrains[0] ?? "feature", rules.terrains[1] ?? "background"];
5339
+ function isCode(error, code) {
5340
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
5284
5341
  }
5285
- function assertUniformTiles(generic) {
5286
- const mismatched = generic.tiles.filter(
5287
- (tile) => tile.width !== generic.tile.width || tile.height !== generic.tile.height
5288
- );
5289
- if (mismatched.length) {
5290
- throw new Error(
5291
- `Engine tilesets require uniform ${generic.tile.width}x${generic.tile.height} cells; mismatched: ${mismatched.map((tile) => tile.role).join(", ")}. Use --format generic.`
5292
- );
5342
+ async function remove2(file, errors) {
5343
+ try {
5344
+ await rm6(file, { force: true });
5345
+ } catch (error) {
5346
+ errors?.push(`could not remove ${file}: ${message(error)}`);
5293
5347
  }
5294
5348
  }
5295
- function tileIndex(key) {
5296
- const match = key.match(/(?:^|[_-])(\d+)$/);
5297
- return match ? Number(match[1]) : null;
5298
- }
5299
- function isRecord(value) {
5300
- return typeof value === "object" && value !== null && !Array.isArray(value);
5349
+ async function rollback(prepared) {
5350
+ const errors = [];
5351
+ for (const artifact of [...prepared].reverse()) {
5352
+ if (artifact.promoted) await remove2(artifact.destination, errors);
5353
+ }
5354
+ for (const artifact of [...prepared].reverse()) {
5355
+ if (!artifact.backup || !artifact.backedUp) continue;
5356
+ try {
5357
+ await rename5(artifact.backup, artifact.destination);
5358
+ artifact.backedUp = false;
5359
+ } catch (error) {
5360
+ errors.push(
5361
+ `could not restore ${artifact.destination}; previous file remains at ${artifact.backup}: ${message(error)}`
5362
+ );
5363
+ }
5364
+ }
5365
+ for (const artifact of prepared) {
5366
+ if (artifact.stage) await remove2(artifact.stage, errors);
5367
+ }
5368
+ return errors;
5301
5369
  }
5302
-
5303
- // src/pick/salvage-server.ts
5304
- import { mkdir as mkdir5, writeFile as writeFile7, readFile as readFile13 } from "fs/promises";
5305
- import path17 from "path";
5306
-
5307
- // src/pick/salvage-sheet.ts
5308
- var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
5309
- function renderSalvageSheet(orphans, ctx) {
5310
- const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml2(o.prompt) }));
5311
- const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
5312
- const styleId = ctx ? escapeHtml2(ctx.styleId) : null;
5313
- const importDir = ctx ? escapeHtml2(ctx.importDir) : null;
5314
- return `<!doctype html>
5315
- <html lang="en">
5316
- <head>
5317
- <meta charset="utf-8">
5318
- <title>pixelkiln salvage${styleId ? ` \u2014 ${styleId}` : ""}</title>
5319
- <style>
5320
- :root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --text:#e6e8ee; --dim:#8b93a7;
5321
- --import:#6ee7a8; --keep:#7cc4ff; --discard:#f87171; }
5322
- @media (prefers-color-scheme: light) {
5323
- :root { --bg:#f6f7f9; --panel:#fff; --line:#dfe3ea; --text:#1a1d23; --dim:#666e80;
5324
- --import:#0a8f52; --keep:#1a6fd4; --discard:#c0392b; }
5370
+ async function writeArtifactBundle(files, options = {}) {
5371
+ if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
5372
+ const normalized = files.map((file) => {
5373
+ if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
5374
+ return { destination: path17.resolve(file.path), data: Buffer.from(file.data) };
5375
+ });
5376
+ const destinations = /* @__PURE__ */ new Set();
5377
+ for (const artifact of normalized) {
5378
+ if (destinations.has(artifact.destination)) {
5379
+ throw new Error(`Artifact bundle contains duplicate destination: ${artifact.destination}`);
5380
+ }
5381
+ destinations.add(artifact.destination);
5325
5382
  }
5326
- * { box-sizing:border-box; }
5327
- body { margin:0; background:var(--bg); color:var(--text);
5328
- font:14px/1.5 ui-sans-serif,-apple-system,"SF Pro Text",Inter,system-ui,sans-serif; }
5329
- header { position:sticky; top:0; z-index:10; background:var(--panel);
5330
- border-bottom:1px solid var(--line); padding:12px 20px; display:flex; gap:14px;
5331
- align-items:center; flex-wrap:wrap; }
5332
- h1 { font-size:15px; margin:0; font-weight:600; }
5333
- h1 .ctx { font-weight:500; color:var(--dim); }
5334
- h1 .dest { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12.5px; }
5335
- .tally { color:var(--dim); font-variant-numeric:tabular-nums; }
5336
- .tally b { color:var(--text); font-weight:600; }
5337
- button { font:inherit; font-weight:550; border-radius:7px; padding:7px 14px;
5338
- border:1px solid var(--line); background:transparent; color:var(--text); cursor:pointer; }
5339
- button.primary { background:var(--import); border-color:var(--import); color:#08130d; }
5340
- button:disabled { opacity:.45; cursor:not-allowed; }
5341
- main { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:12px; padding:18px 20px 80px; }
5342
- .card { border:2px solid var(--line); border-radius:10px; background:var(--panel);
5343
- padding:10px; display:flex; flex-direction:column; gap:8px; }
5344
- .card.import { border-color:var(--import); }
5345
- .card.keep { border-color:var(--keep); }
5346
- .card.discard { border-color:var(--discard); opacity:.5; }
5347
- .thumb { height:120px; display:flex; align-items:center; justify-content:center;
5348
- border-radius:6px;
5349
- background-image:
5350
- linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
5351
- linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
5352
- background-size:12px 12px; background-position:0 0,6px 6px; }
5353
- .thumb img { image-rendering:pixelated; max-height:112px; max-width:100%; }
5354
- .meta { font-size:11px; color:var(--dim); font-variant-numeric:tabular-nums; }
5355
- .prompt { font-size:11.5px; color:var(--dim); max-height:3.2em; overflow:hidden; }
5356
- .acts { display:flex; gap:5px; }
5357
- .acts button { flex:1; padding:5px 0; font-size:11.5px; }
5358
- .acts button.on-import { background:var(--import); border-color:var(--import); color:#08130d; }
5359
- .acts button.on-keep { background:var(--keep); border-color:var(--keep); color:#04121f; }
5360
- .acts button.on-discard { background:var(--discard); border-color:var(--discard); color:#fff; }
5361
- footer { position:fixed; bottom:0; left:0; right:0; background:var(--panel);
5362
- border-top:1px solid var(--line); padding:10px 20px; font-size:12px; color:var(--dim); }
5363
- kbd { border:1px solid var(--line); border-bottom-width:2px; border-radius:4px;
5364
- padding:1px 5px; font-size:11px; font-family:ui-monospace,Menlo,monospace; }
5365
- </style>
5366
- </head>
5367
- <body>
5368
- <header>
5369
- <h1>pixelkiln salvage${styleId ? ` <span class="ctx">\u2014 ${styleId}<span class="dest"> \u2192 ${importDir}/_salvaged/</span></span>` : ""}</h1>
5370
- <span class="tally"><b id="ti">0</b> import \xB7 <b id="tk">0</b> keep \xB7 <b id="td">0</b> discard \xB7
5371
- <b id="tu">0</b> undecided</span>
5372
- <span style="flex:1"></span>
5373
- <button id="allDiscard">Discard all undecided</button>
5374
- <button id="allKeep">Keep all undecided</button>
5375
- <button id="submit" class="primary" disabled>Apply</button>
5376
- </header>
5377
- <main id="grid"></main>
5378
- <footer>
5379
- <b>import</b> downloads it and adds it to your manifest \xB7 <b>keep</b> tags it and leaves it \xB7
5380
- <b>discard</b> only tags it \u2014 nothing is deleted here.
5381
- Hover a card and press <kbd>i</kbd> / <kbd>k</kbd> / <kbd>d</kbd>, or <kbd>u</kbd> to undo.
5382
- </footer>
5383
- <script>
5384
- const ITEMS = ${data};
5385
- const picks = new Map();
5386
- const grid = document.getElementById('grid');
5387
- let hovered = null;
5388
-
5389
- ITEMS.forEach((o, i) => {
5390
- const el = document.createElement('div');
5391
- el.className = 'card';
5392
- el.dataset.i = String(i);
5393
- el.innerHTML =
5394
- '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
5395
- '<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
5396
- '<div class="prompt">' + o.prompt + '</div>' +
5397
- '<div class="acts">' +
5398
- '<button data-a="import">import</button>' +
5399
- '<button data-a="keep">keep</button>' +
5400
- '<button data-a="discard">discard</button>' +
5401
- '</div>';
5402
- el.querySelectorAll('.acts button').forEach(b => {
5403
- b.onclick = () => choose(i, b.dataset.a);
5404
- });
5405
- el.onmouseenter = () => { hovered = i; };
5406
- grid.appendChild(el);
5407
- });
5408
-
5409
- function choose(i, action) {
5410
- if (picks.get(i) === action) picks.delete(i); else picks.set(i, action);
5411
- const el = grid.children[i];
5412
- el.className = 'card' + (picks.has(i) ? ' ' + picks.get(i) : '');
5413
- el.querySelectorAll('.acts button').forEach(b => {
5414
- b.className = picks.get(i) === b.dataset.a ? 'on-' + b.dataset.a : '';
5415
- });
5416
- refresh();
5417
- }
5418
-
5419
- function refresh() {
5420
- const c = { import: 0, keep: 0, discard: 0 };
5421
- for (const a of picks.values()) c[a]++;
5422
- ti.textContent = c.import; tk.textContent = c.keep; td.textContent = c.discard;
5423
- tu.textContent = ITEMS.length - picks.size;
5424
- document.getElementById('submit').disabled = picks.size === 0;
5425
- }
5426
-
5427
- document.addEventListener('keydown', e => {
5428
- if (hovered === null) return;
5429
- const map = { i: 'import', k: 'keep', d: 'discard' };
5430
- if (map[e.key]) { choose(hovered, map[e.key]); e.preventDefault(); }
5431
- else if (e.key === 'u') { picks.delete(hovered); choose(hovered, '__none__'); }
5432
- });
5433
-
5434
- document.getElementById('allKeep').onclick = () => {
5435
- ITEMS.forEach((_, i) => { if (!picks.has(i)) choose(i, 'keep'); });
5436
- };
5437
- document.getElementById('allDiscard').onclick = () => {
5438
- ITEMS.forEach((_, i) => { if (!picks.has(i)) choose(i, 'discard'); });
5439
- };
5440
-
5441
- document.getElementById('submit').onclick = async () => {
5442
- const btn = document.getElementById('submit');
5443
- btn.disabled = true; btn.textContent = 'applying\u2026';
5444
- const decisions = [...picks.entries()].map(([i, action]) => ({ id: ITEMS[i].id, action }));
5445
- try {
5446
- const res = await fetch('/apply', {
5447
- method: 'POST', headers: { 'Content-Type': 'application/json' },
5448
- body: JSON.stringify({ decisions }),
5449
- });
5450
- if (!res.ok) throw new Error(await res.text());
5451
- btn.textContent = 'done \u2014 close this tab';
5452
- document.body.style.opacity = '.6';
5453
- } catch (err) {
5454
- btn.textContent = 'failed: ' + err.message; btn.disabled = false;
5383
+ if (options.recoveryFile) {
5384
+ await recoverArtifactTransaction(options.recoveryFile, destinations);
5455
5385
  }
5456
- };
5457
- refresh();
5458
- </script>
5459
- </body>
5460
- </html>`;
5461
- }
5462
-
5463
- // src/pick/salvage-server.ts
5464
- var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5465
- async function runSalvage(provider, orphans, ctx, opts = {}) {
5466
- const log2 = opts.onProgress ?? (() => {
5467
- });
5468
- const html = renderSalvageSheet(orphans, {
5469
- styleId: ctx.styleId,
5470
- importDir: path17.relative(process.cwd(), ctx.importDir) || "."
5471
- });
5472
- const byId = new Map(orphans.map((o) => [o.id, o]));
5473
- const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
5474
- return serveReviewPage({
5475
- html,
5476
- port: opts.port,
5477
- open: opts.open,
5478
- onProgress: log2,
5479
- onReady: (url) => {
5480
- log2(`
5481
- ${orphans.length} unclaimed object(s) to triage: ${url}`);
5482
- log2(` (nothing is deleted here \u2014 discard only tags)
5483
- `);
5484
- },
5485
- handleApply: async (body) => {
5486
- const { decisions } = body;
5487
- const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
5488
- const taken = new Set(Object.keys(ctx.manifest.assets));
5489
- const importedAssetIds = [];
5490
- for (const decision of decisions) {
5491
- const orphan = byId.get(decision.id);
5492
- if (!orphan) continue;
5493
- if (decision.action === "import") {
5494
- try {
5495
- const buf = await provider.download(orphan.previewUrl);
5496
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
5497
- decodePng(buf);
5498
- const assetId = idFromPrompt(orphan.prompt, taken);
5499
- const rel = path17.join("_salvaged", `${assetId}.png`);
5500
- const outFile = path17.resolve(ctx.importDir, rel);
5501
- await mkdir5(path17.dirname(outFile), { recursive: true });
5502
- await writeFile7(outFile, buf);
5503
- ctx.manifest.assets[assetId] = {
5504
- prompt: orphan.prompt,
5505
- promptByStyle: {},
5506
- category: "_salvaged",
5507
- file: rel,
5508
- tags: ["salvaged"],
5509
- styles: [ctx.styleId],
5510
- ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
5511
- };
5512
- upsert(ctx.lock, lockKey(ctx.styleId, assetId), {
5513
- styleId: ctx.styleId,
5514
- assetId,
5515
- specHash: SALVAGED_SPEC_HASH,
5516
- generator: orphan.width === orphan.height ? "1dir" : "map",
5517
- prompt: orphan.prompt,
5518
- width: orphan.width,
5519
- height: orphan.height,
5520
- jobId: orphan.id,
5521
- reviewObjectId: null,
5522
- objectId: orphan.id,
5523
- candidateIndex: null,
5524
- status: "downloaded",
5525
- error: null,
5526
- sourceUrl: orphan.previewUrl,
5527
- outputs: [{
5528
- path: portableOutputPath(outFile, path17.dirname(ctx.manifestPath)),
5529
- sha256: sha256(buf)
5530
- }],
5531
- submittedAt: orphan.createdAt,
5532
- downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
5533
- cost: 0,
5534
- // already paid for, in an earlier period
5535
- costUnit: "generations",
5536
- provider: provider.id
5537
- });
5538
- importedAssetIds.push(assetId);
5539
- result.imported++;
5540
- log2(` imported ${assetId} \u2190 ${orphan.id}`);
5541
- } catch (err) {
5542
- result.failed++;
5543
- log2(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
5544
- }
5545
- } else if (decision.action === "keep") {
5546
- result.kept++;
5547
- } else {
5548
- result.discarded++;
5549
- }
5386
+ const changed = [];
5387
+ const unchanged = [];
5388
+ for (const artifact of normalized) {
5389
+ try {
5390
+ const current = await readFile13(artifact.destination);
5391
+ if (current.equals(artifact.data)) {
5392
+ unchanged.push(artifact.destination);
5393
+ } else {
5394
+ changed.push({ ...artifact, existed: true, backedUp: false, promoted: false });
5550
5395
  }
5551
- await applyTags(provider, decisions, existingTags, { onProgress: log2 });
5552
- const raw = JSON.parse(await readFile13(ctx.manifestPath, "utf8"));
5553
- for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
5554
- await writeFile7(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
5555
- await saveLock(ctx.lockPath, ctx.lock);
5556
- return result;
5396
+ } catch (error) {
5397
+ if (!isCode(error, "ENOENT")) throw error;
5398
+ changed.push({ ...artifact, existed: false, backedUp: false, promoted: false });
5557
5399
  }
5558
- });
5400
+ }
5401
+ if (!changed.length) return { changed: [], unchanged };
5402
+ const token = randomUUID3();
5403
+ for (const [index, artifact] of changed.entries()) {
5404
+ const basename = path17.basename(artifact.destination);
5405
+ artifact.stage = path17.join(
5406
+ path17.dirname(artifact.destination),
5407
+ `.${basename}.pixelkiln-stage-${token}-${index}`
5408
+ );
5409
+ if (artifact.existed) {
5410
+ artifact.backup = path17.join(
5411
+ path17.dirname(artifact.destination),
5412
+ `.${basename}.pixelkiln-backup-${token}-${index}`
5413
+ );
5414
+ }
5415
+ }
5416
+ const journal = options.recoveryFile ? path17.resolve(options.recoveryFile) : null;
5417
+ if (journal) {
5418
+ await mkdir5(path17.dirname(journal), { recursive: true });
5419
+ const transaction = {
5420
+ format: "pixelkiln-artifact-transaction",
5421
+ version: 1,
5422
+ pid: process.pid,
5423
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5424
+ entries: changed.map((artifact) => ({
5425
+ destination: artifact.destination,
5426
+ stage: artifact.stage,
5427
+ ...artifact.backup ? { backup: artifact.backup } : {},
5428
+ sha256: digest(artifact.data)
5429
+ }))
5430
+ };
5431
+ try {
5432
+ await writeFile7(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
5433
+ activeTransactions.add(journal);
5434
+ } catch (error) {
5435
+ throw new Error(
5436
+ `Could not start durable artifact transaction at ${journal}: ${message(error)}`,
5437
+ { cause: error }
5438
+ );
5439
+ }
5440
+ }
5441
+ try {
5442
+ for (const [index, artifact] of changed.entries()) {
5443
+ await mkdir5(path17.dirname(artifact.destination), { recursive: true });
5444
+ await options.beforeStage?.(artifact.destination, index);
5445
+ await writeFile7(artifact.stage, artifact.data, { flag: "wx" });
5446
+ }
5447
+ } catch (error) {
5448
+ const cleanupErrors2 = await rollback(changed);
5449
+ if (journal && !cleanupErrors2.length) await removeTransactionFiles(journal);
5450
+ if (journal) activeTransactions.delete(journal);
5451
+ const suffix = cleanupErrors2.length ? ` Cleanup also failed: ${cleanupErrors2.join("; ")}` : "";
5452
+ throw new Error(`Could not stage artifact bundle: ${message(error)}.${suffix}`, { cause: error });
5453
+ }
5454
+ let durableCommit = false;
5455
+ try {
5456
+ for (const artifact of changed) {
5457
+ if (!artifact.existed) continue;
5458
+ await rename5(artifact.destination, artifact.backup);
5459
+ artifact.backedUp = true;
5460
+ }
5461
+ for (const [index, artifact] of changed.entries()) {
5462
+ await options.beforePromote?.(artifact.destination, index);
5463
+ await rename5(artifact.stage, artifact.destination);
5464
+ artifact.stage = void 0;
5465
+ artifact.promoted = true;
5466
+ }
5467
+ if (journal) {
5468
+ await writeFile7(transactionMarker(journal), `${token}
5469
+ `, { flag: "wx" });
5470
+ durableCommit = true;
5471
+ await options.afterCommit?.();
5472
+ }
5473
+ } catch (error) {
5474
+ if (durableCommit) {
5475
+ if (journal) activeTransactions.delete(journal);
5476
+ throw new Error(
5477
+ `Artifact bundle was committed, but final cleanup was interrupted: ${message(error)}. The next write will finish cleanup from ${journal}.`,
5478
+ { cause: error }
5479
+ );
5480
+ }
5481
+ const rollbackErrors = await rollback(changed);
5482
+ if (journal && !rollbackErrors.length) await removeTransactionFiles(journal);
5483
+ if (journal) activeTransactions.delete(journal);
5484
+ const suffix = rollbackErrors.length ? ` Rollback needs attention: ${rollbackErrors.join("; ")}` : " Previous bundle restored.";
5485
+ throw new Error(`Could not promote artifact bundle: ${message(error)}.${suffix}`, { cause: error });
5486
+ }
5487
+ const cleanupErrors = [];
5488
+ for (const artifact of changed) {
5489
+ if (artifact.backup) await remove2(artifact.backup, cleanupErrors);
5490
+ }
5491
+ if (cleanupErrors.length) {
5492
+ if (journal) activeTransactions.delete(journal);
5493
+ throw new Error(
5494
+ `Artifact bundle was written, but old backup cleanup failed: ${cleanupErrors.join("; ")}`
5495
+ );
5496
+ }
5497
+ if (journal) {
5498
+ try {
5499
+ await removeTransactionFiles(journal);
5500
+ } finally {
5501
+ activeTransactions.delete(journal);
5502
+ }
5503
+ }
5504
+ return {
5505
+ changed: changed.map((artifact) => artifact.destination),
5506
+ unchanged
5507
+ };
5559
5508
  }
5560
5509
 
5561
- // src/artifacts.ts
5562
- import path18 from "path";
5563
- import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
5564
- import { mkdir as mkdir6, readFile as readFile14, rename as rename5, rm as rm6, writeFile as writeFile8 } from "fs/promises";
5565
- var activeTransactions = /* @__PURE__ */ new Set();
5566
- function message(error) {
5510
+ // src/pipeline/refine.ts
5511
+ var execFileAsync = promisify(execFile);
5512
+ var PIXEL_ART_FIXER_URL = "https://github.com/Retro-Diffusion/pixel-art-fixer";
5513
+ var PIXEL_ART_FIXER_REVISION = "ef376e57e1c272633ca2dbf5f29ec3fcf6596465";
5514
+ function message2(error) {
5567
5515
  return error instanceof Error ? error.message : String(error);
5568
5516
  }
5569
- function digest(data) {
5570
- return createHash2("sha256").update(data).digest("hex");
5571
- }
5572
- function portableRelative(from, to) {
5573
- return path18.relative(from, path18.resolve(to)).split(path18.sep).join("/") || ".";
5574
- }
5575
- function canonical2(value) {
5576
- if (value === null || typeof value === "string" || typeof value === "boolean") return value;
5577
- if (typeof value === "number") {
5578
- if (!Number.isFinite(value)) throw new Error("Artifact provenance numbers must be finite.");
5579
- return value;
5517
+ function qualityRecordPath(output) {
5518
+ if (!/\.png$/i.test(output)) throw new Error("Refined output must be a .png file.");
5519
+ return output.replace(/\.png$/i, ".pixelkiln.json");
5520
+ }
5521
+ function confidenceFor(consensus) {
5522
+ if (consensus.startsWith("fast:")) return "high";
5523
+ if (consensus === "arbitrated" || consensus === "forced" || consensus.startsWith("fastmode:") && consensus.includes("+")) return "medium";
5524
+ return "low";
5525
+ }
5526
+ var confidenceRank = { low: 0, medium: 1, high: 2 };
5527
+ function requireConfidence(value) {
5528
+ if (value === "low" || value === "medium" || value === "high") return value;
5529
+ throw new Error(`Unknown grid confidence "${value}"; expected low, medium, or high.`);
5530
+ }
5531
+ function firstJsonObject(text) {
5532
+ const start = text.indexOf("{");
5533
+ if (start < 0) throw new Error("Pixel Art Fixer did not print detection JSON.");
5534
+ let depth = 0;
5535
+ let string = false;
5536
+ let escaped = false;
5537
+ for (let i = start; i < text.length; i++) {
5538
+ const char = text[i];
5539
+ if (string) {
5540
+ if (escaped) escaped = false;
5541
+ else if (char === "\\") escaped = true;
5542
+ else if (char === '"') string = false;
5543
+ continue;
5544
+ }
5545
+ if (char === '"') string = true;
5546
+ else if (char === "{") depth++;
5547
+ else if (char === "}" && --depth === 0) {
5548
+ try {
5549
+ return JSON.parse(text.slice(start, i + 1));
5550
+ } catch (error) {
5551
+ throw new Error(`Pixel Art Fixer printed invalid detection JSON: ${message2(error)}`);
5552
+ }
5553
+ }
5580
5554
  }
5581
- if (Array.isArray(value)) return value.map(canonical2);
5582
- if (typeof value === "object") {
5583
- const sorted = {};
5584
- for (const key of Object.keys(value).sort()) {
5585
- const item = value[key];
5586
- if (item !== void 0) sorted[key] = canonical2(item);
5555
+ throw new Error("Pixel Art Fixer printed incomplete detection JSON.");
5556
+ }
5557
+ var PYTHON_FIXER_BRIDGE = [
5558
+ "import json, sys",
5559
+ "from pixelfixer.api import process",
5560
+ "result = process(open(sys.argv[1], 'rb').read(), mode='full')",
5561
+ "open(sys.argv[2], 'wb').write(result.pop('png'))",
5562
+ "print(json.dumps(result))"
5563
+ ].join("; ");
5564
+ function parseDetection(raw) {
5565
+ if (!raw || typeof raw !== "object") throw new Error("Pixel Art Fixer returned no detection result.");
5566
+ const value = raw;
5567
+ const stepX = Number(value.step_x);
5568
+ const stepY = Number(value.step_y);
5569
+ const columns = Number(value.cols);
5570
+ const rows = Number(value.rows);
5571
+ const consensus = value.consensus;
5572
+ if (!Number.isFinite(stepX) || stepX <= 0 || !Number.isFinite(stepY) || stepY <= 0 || !Number.isInteger(columns) || columns < 1 || !Number.isInteger(rows) || rows < 1 || typeof consensus !== "string" || !consensus) {
5573
+ throw new Error("Pixel Art Fixer returned an invalid grid detection result.");
5574
+ }
5575
+ return {
5576
+ stepX,
5577
+ stepY,
5578
+ columns,
5579
+ rows,
5580
+ consensus,
5581
+ confidence: confidenceFor(consensus)
5582
+ };
5583
+ }
5584
+ function normalizePalette(values) {
5585
+ const colors = /* @__PURE__ */ new Map();
5586
+ for (const value of values) {
5587
+ const rgb = parseHex(value);
5588
+ const hex2 = `#${rgb.r.toString(16).padStart(2, "0")}${rgb.g.toString(16).padStart(2, "0")}${rgb.b.toString(16).padStart(2, "0")}`;
5589
+ colors.set(hex2, { hex: hex2, ...rgb });
5590
+ }
5591
+ if (colors.size < 2 || colors.size > 256) {
5592
+ throw new Error(`A refinement palette must contain 2\u2013256 unique colors; got ${colors.size}.`);
5593
+ }
5594
+ return [...colors.values()];
5595
+ }
5596
+ function quantizeToPalette(png, palette) {
5597
+ const colors = normalizePalette(palette);
5598
+ const pixels = Buffer.from(png.pixels);
5599
+ for (let i = 0; i < pixels.length; i += 4) {
5600
+ const alpha = pixels[i + 3];
5601
+ if (alpha === 0) {
5602
+ pixels[i] = 0;
5603
+ pixels[i + 1] = 0;
5604
+ pixels[i + 2] = 0;
5605
+ continue;
5587
5606
  }
5588
- return sorted;
5607
+ const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2] };
5608
+ let nearest = colors[0];
5609
+ let distance = colorDistance(pixel, nearest);
5610
+ for (let j = 1; j < colors.length; j++) {
5611
+ const candidate = colors[j];
5612
+ const candidateDistance = colorDistance(pixel, candidate);
5613
+ if (candidateDistance < distance) {
5614
+ nearest = candidate;
5615
+ distance = candidateDistance;
5616
+ }
5617
+ }
5618
+ pixels[i] = nearest.r;
5619
+ pixels[i + 1] = nearest.g;
5620
+ pixels[i + 2] = nearest.b;
5589
5621
  }
5590
- throw new Error(`Artifact provenance cannot contain ${typeof value} values.`);
5622
+ return { width: png.width, height: png.height, pixels };
5591
5623
  }
5592
- function fingerprint(provenance) {
5593
- return digest(JSON.stringify(canonical2({
5594
- kind: provenance.kind,
5595
- sources: provenance.sources,
5596
- options: provenance.options,
5597
- outputs: provenance.outputs
5598
- })));
5624
+ function colorCount(png) {
5625
+ const colors = /* @__PURE__ */ new Set();
5626
+ for (let i = 0; i < png.pixels.length; i += 4) {
5627
+ if (png.pixels[i + 3] < 128) continue;
5628
+ colors.add(png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2]);
5629
+ }
5630
+ return colors.size;
5599
5631
  }
5600
- function parseArtifactManifest(absolute, data) {
5601
- let raw;
5602
- try {
5603
- raw = JSON.parse(data);
5604
- } catch (error) {
5605
- throw new Error(`${absolute} is not valid JSON: ${message(error)}`, { cause: error });
5632
+ function unexpectedPaletteColors(png, palette) {
5633
+ const allowed = new Set(palette.map((hex2) => {
5634
+ const color = parseHex(hex2);
5635
+ return color.r << 16 | color.g << 8 | color.b;
5636
+ }));
5637
+ const unexpected = /* @__PURE__ */ new Set();
5638
+ for (let i = 0; i < png.pixels.length; i += 4) {
5639
+ if (png.pixels[i + 3] === 0) continue;
5640
+ const color = png.pixels[i] << 16 | png.pixels[i + 1] << 8 | png.pixels[i + 2];
5641
+ if (!allowed.has(color)) unexpected.add(color);
5642
+ }
5643
+ return unexpected.size;
5644
+ }
5645
+ function auditRefinement(png, detection, palette, minTransparency, minGridConfidence) {
5646
+ const colors = colorCount(png);
5647
+ const unexpectedColors = unexpectedPaletteColors(png, palette);
5648
+ const transparency = transparencyRatio(png);
5649
+ const reasons = [];
5650
+ if (png.width !== detection.columns || png.height !== detection.rows) {
5651
+ reasons.push(
5652
+ `native output is ${png.width}x${png.height}, expected detected grid ${detection.columns}x${detection.rows}`
5653
+ );
5606
5654
  }
5607
- if (raw.format !== "pixelkiln-artifact-bundle" || raw.version !== 1 || raw.kind !== "pack" && raw.kind !== "mount" && raw.kind !== "tileset" || typeof raw.fingerprint !== "string" || raw.options === void 0 || !Array.isArray(raw.sources) || !Array.isArray(raw.outputs)) {
5608
- throw new Error(`${absolute} is not a PixelKiln artifact bundle manifest.`);
5655
+ if (colors > palette.length) reasons.push(`opaque color count ${colors} exceeds ${palette.length}`);
5656
+ if (unexpectedColors) reasons.push(`${unexpectedColors} visible color(s) are outside the final palette`);
5657
+ if (minTransparency !== void 0 && transparency < minTransparency) {
5658
+ reasons.push(
5659
+ `transparency ${(transparency * 100).toFixed(1)}% is below ${(minTransparency * 100).toFixed(1)}%`
5660
+ );
5609
5661
  }
5610
- for (const source of raw.sources) {
5611
- if (!source || typeof source.id !== "string" || typeof source.path !== "string" || source.sha256 !== null && typeof source.sha256 !== "string" || typeof source.included !== "boolean") {
5612
- throw new Error(`${absolute} contains an invalid artifact source.`);
5613
- }
5662
+ if (confidenceRank[detection.confidence] < confidenceRank[minGridConfidence]) {
5663
+ reasons.push(
5664
+ `grid confidence ${detection.confidence} is below required ${minGridConfidence} (${detection.consensus})`
5665
+ );
5614
5666
  }
5615
- for (const output of raw.outputs) {
5616
- if (!output || typeof output.path !== "string" || typeof output.sha256 !== "string") {
5617
- throw new Error(`${absolute} contains an invalid artifact output.`);
5618
- }
5667
+ return {
5668
+ width: png.width,
5669
+ height: png.height,
5670
+ colorCount: colors,
5671
+ paletteConformant: unexpectedColors === 0,
5672
+ transparency,
5673
+ thresholds: {
5674
+ maxColors: palette.length,
5675
+ minTransparency: minTransparency ?? null,
5676
+ minGridConfidence
5677
+ },
5678
+ safe: reasons.length === 0,
5679
+ reasons
5680
+ };
5681
+ }
5682
+ function isGridConfidence(value) {
5683
+ return value === "low" || value === "medium" || value === "high";
5684
+ }
5685
+ function parseRecordOptions(manifest, record) {
5686
+ if (manifest.kind !== "refine") throw new Error(`${record} is not a PixelKiln refinement record.`);
5687
+ const raw = manifest.options;
5688
+ if (!raw || typeof raw !== "object") throw new Error(`${record} has invalid quality metadata.`);
5689
+ const options = raw;
5690
+ const grid = options.nativeGrid;
5691
+ const palette = options.palette;
5692
+ const audit = options.audit;
5693
+ const review = options.review;
5694
+ if (options.schema !== "pixelkiln-quality" || options.version !== 1 || !grid || grid.tool !== "pixel-art-fixer" || grid.url !== PIXEL_ART_FIXER_URL || typeof grid.revision !== "string" || !grid.revision || grid.protocol !== "python-api-v1" && grid.protocol !== "python-cli-v1" || !isGridConfidence(grid.confidence) || typeof grid.consensus !== "string" || !Number.isFinite(grid.stepX) || !Number.isFinite(grid.stepY) || !Number.isInteger(grid.sourceWidth) || !Number.isInteger(grid.sourceHeight) || !Number.isInteger(grid.nativeWidth) || !Number.isInteger(grid.nativeHeight) || !palette || !Array.isArray(palette.colors) || palette.colors.length < 2 || !palette.colors.every((color) => typeof color === "string") || palette.dither !== "none" || palette.distance !== "redmean" || !audit || typeof audit.safe !== "boolean" || typeof audit.paletteConformant !== "boolean" || !Array.isArray(audit.reasons) || !review || review.status !== "pending" && review.status !== "approved") {
5695
+ throw new Error(`${record} has invalid quality metadata.`);
5619
5696
  }
5620
- return raw;
5697
+ return options;
5621
5698
  }
5622
- async function readOptional(file) {
5699
+ async function refineAsset(options) {
5700
+ const source = path18.resolve(options.source);
5701
+ const output = path18.resolve(options.output);
5702
+ if (source === output) throw new Error("Refinement source and output must be different files.");
5703
+ const record = path18.resolve(qualityRecordPath(output));
5704
+ const palette = normalizePalette(options.palette).map((color) => color.hex);
5705
+ const minGridConfidence = requireConfidence(options.minGridConfidence ?? "high");
5706
+ 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.");
5707
+ let sourcePng;
5623
5708
  try {
5624
- return await readFile14(file);
5709
+ sourcePng = decodePng(await readFile14(source));
5625
5710
  } catch (error) {
5626
- if (isCode(error, "ENOENT")) return null;
5627
- throw error;
5711
+ throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
5628
5712
  }
5713
+ const temp = await mkdtemp(path18.join(tmpdir(), "pixelkiln-refine-"));
5714
+ const recovered = path18.join(temp, "native.png");
5715
+ const compatibilityCommand = options.fixerCommand;
5716
+ const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
5717
+ const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
5718
+ try {
5719
+ let stdout;
5720
+ try {
5721
+ const result = await execFileAsync(fixerCommand, args, {
5722
+ encoding: "utf8",
5723
+ maxBuffer: 4 * 1024 * 1024,
5724
+ timeout: 10 * 60 * 1e3
5725
+ });
5726
+ stdout = result.stdout;
5727
+ } catch (error) {
5728
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
5729
+ const install = `Install the pinned Python package from ${PIXEL_ART_FIXER_URL} at ${options.fixerRevision ?? PIXEL_ART_FIXER_REVISION}, or pass --fixer-python.`;
5730
+ throw new Error(
5731
+ code === "ENOENT" ? `Pixel Art Fixer command not found: ${fixerCommand}. ${install}` : `Pixel Art Fixer failed: ${message2(error)}. ${install}`,
5732
+ { cause: error }
5733
+ );
5734
+ }
5735
+ const detection = parseDetection(firstJsonObject(stdout));
5736
+ const native = decodePng(await readFile14(recovered));
5737
+ const quantized = quantizeToPalette(native, palette);
5738
+ const png = encodeRgbaPng(quantized.width, quantized.height, quantized.pixels);
5739
+ const audit = auditRefinement(
5740
+ quantized,
5741
+ detection,
5742
+ palette,
5743
+ options.minTransparency,
5744
+ minGridConfidence
5745
+ );
5746
+ if (!audit.safe) {
5747
+ throw new Error(`Refinement failed its quality audit: ${audit.reasons.join("; ")}`);
5748
+ }
5749
+ const provenanceSource = {
5750
+ id: "source",
5751
+ path: source,
5752
+ sha256: await sha256File(source),
5753
+ included: true
5754
+ };
5755
+ const recordOptions = {
5756
+ schema: "pixelkiln-quality",
5757
+ version: 1,
5758
+ nativeGrid: {
5759
+ tool: "pixel-art-fixer",
5760
+ url: PIXEL_ART_FIXER_URL,
5761
+ revision: options.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
5762
+ protocol: compatibilityCommand ? "python-cli-v1" : "python-api-v1",
5763
+ confidence: detection.confidence,
5764
+ consensus: detection.consensus,
5765
+ stepX: detection.stepX,
5766
+ stepY: detection.stepY,
5767
+ sourceWidth: sourcePng.width,
5768
+ sourceHeight: sourcePng.height,
5769
+ nativeWidth: quantized.width,
5770
+ nativeHeight: quantized.height
5771
+ },
5772
+ palette: { colors: palette, dither: "none", distance: "redmean" },
5773
+ audit,
5774
+ review: { status: "pending" }
5775
+ };
5776
+ await writeManagedArtifactBundle(
5777
+ record,
5778
+ [{ path: output, data: png }],
5779
+ { kind: "refine", sources: [provenanceSource], options: recordOptions },
5780
+ { force: options.force }
5781
+ );
5782
+ return { source, output, record, detection, palette, audit };
5783
+ } finally {
5784
+ await rm7(temp, { recursive: true, force: true });
5785
+ }
5786
+ }
5787
+ async function checkQualityRecord(recordPath) {
5788
+ const record = path18.resolve(recordPath);
5789
+ const manifest = await readArtifactBundleManifest(record);
5790
+ const options = parseRecordOptions(manifest, record);
5791
+ const verification = await verifyArtifactBundle(record);
5792
+ const output = manifest.outputs.length === 1 ? path18.resolve(path18.dirname(record), manifest.outputs[0].path) : "";
5793
+ const reasons = [];
5794
+ if (!verification.fingerprintValid) reasons.push("quality record fingerprint changed");
5795
+ for (const source of verification.changedSources) reasons.push(`source changed: ${source}`);
5796
+ for (const changed of verification.changedOutputs) reasons.push(`output changed: ${changed}`);
5797
+ if (manifest.sources.length !== 1) reasons.push("quality record must contain exactly one source");
5798
+ if (manifest.outputs.length !== 1) reasons.push("quality record must contain exactly one output");
5799
+ if (!options.audit.safe) reasons.push(...options.audit.reasons.map((reason) => `audit: ${reason}`));
5800
+ if (options.review.status !== "approved") reasons.push("human 1\xD7 review is pending");
5801
+ return {
5802
+ safe: reasons.length === 0,
5803
+ current: verification.current,
5804
+ approved: options.review.status === "approved",
5805
+ auditSafe: options.audit.safe,
5806
+ record,
5807
+ output,
5808
+ options,
5809
+ reasons
5810
+ };
5629
5811
  }
5630
- function transactionMarker(journal) {
5631
- return `${journal}.committed`;
5812
+ async function approveQualityRecord(recordPath, approval) {
5813
+ const reviewer = approval.reviewer.trim();
5814
+ if (!reviewer) throw new Error("A human reviewer name is required.");
5815
+ const record = path18.resolve(recordPath);
5816
+ const manifest = await readArtifactBundleManifest(record);
5817
+ const options = parseRecordOptions(manifest, record);
5818
+ const verification = await verifyArtifactBundle(record);
5819
+ if (!verification.current) {
5820
+ const changes = [
5821
+ ...verification.changedSources.map((source) => `source ${source}`),
5822
+ ...verification.changedOutputs.map((output2) => `output ${output2}`),
5823
+ ...!verification.fingerprintValid ? ["quality metadata"] : []
5824
+ ];
5825
+ throw new Error(`Cannot approve a stale refinement; changed: ${changes.join(", ")}. Run refine again.`);
5826
+ }
5827
+ if (!options.audit.safe) {
5828
+ throw new Error(`Cannot approve a failed quality audit: ${options.audit.reasons.join("; ")}`);
5829
+ }
5830
+ if (manifest.sources.length !== 1 || manifest.outputs.length !== 1) {
5831
+ throw new Error("A refinement record must contain exactly one source and one output.");
5832
+ }
5833
+ const root = path18.dirname(record);
5834
+ const outputPath = path18.resolve(root, manifest.outputs[0].path);
5835
+ const output = await readFile14(outputPath);
5836
+ const sources = manifest.sources.map((source) => ({
5837
+ ...source,
5838
+ path: path18.resolve(root, source.path)
5839
+ }));
5840
+ const approved = {
5841
+ ...options,
5842
+ review: {
5843
+ status: "approved",
5844
+ reviewer,
5845
+ approvedAt: (approval.approvedAt ?? /* @__PURE__ */ new Date()).toISOString(),
5846
+ checklist: {
5847
+ nativeScale: true,
5848
+ crispEdges: true,
5849
+ subjectReadable: true,
5850
+ paletteSeparation: true,
5851
+ alphaAndSeams: true
5852
+ },
5853
+ ...approval.note?.trim() ? { note: approval.note.trim() } : {}
5854
+ }
5855
+ };
5856
+ await writeManagedArtifactBundle(
5857
+ record,
5858
+ [{ path: outputPath, data: output }],
5859
+ { kind: "refine", sources, options: approved }
5860
+ );
5861
+ return checkQualityRecord(record);
5632
5862
  }
5633
- function processIsAlive(pid) {
5634
- if (!Number.isInteger(pid) || pid < 1) return false;
5635
- try {
5636
- process.kill(pid, 0);
5637
- return true;
5638
- } catch (error) {
5639
- return !isCode(error, "ESRCH");
5863
+
5864
+ // src/pipeline/tileset-export.ts
5865
+ function normalizeTileRules(raw) {
5866
+ if (!isRecord(raw)) return null;
5867
+ const ruleType = raw.rule_type;
5868
+ const arity = raw.arity;
5869
+ if (ruleType !== "edge" && ruleType !== "corner" && ruleType !== "outline") return null;
5870
+ if (arity !== 4 && arity !== 6) return null;
5871
+ const masks = {};
5872
+ if (isRecord(raw.tiles)) {
5873
+ for (const [key, value] of Object.entries(raw.tiles)) {
5874
+ const index = tileIndex(key);
5875
+ const mask = typeof value === "number" ? value : isRecord(value) && typeof value.bitmask === "number" ? value.bitmask : isRecord(value) && typeof value.mask === "number" ? value.mask : null;
5876
+ if (index !== null && mask !== null && Number.isInteger(mask) && mask >= 0) masks[index] = mask;
5877
+ }
5640
5878
  }
5879
+ const connectivity = raw.connectivity === "same" || raw.connectivity === "other" ? raw.connectivity : void 0;
5880
+ const terrains = Array.isArray(raw.terrains) ? raw.terrains.filter((item) => typeof item === "string") : [];
5881
+ return {
5882
+ ruleType,
5883
+ arity,
5884
+ ...connectivity ? { connectivity } : {},
5885
+ terrains,
5886
+ masks,
5887
+ raw
5888
+ };
5641
5889
  }
5642
- function validTemporaryPath(candidate, destination, type) {
5643
- return path18.dirname(candidate) === path18.dirname(destination) && path18.basename(candidate).startsWith(
5644
- `.${path18.basename(destination)}.pixelkiln-${type}-`
5890
+ function exportTileset(entry, spec, options) {
5891
+ const outputs = resolveSpecEntryOutputs(entry, spec);
5892
+ if (!outputs.length) throw new Error(`${spec.styleId}/${spec.assetId} has no downloaded outputs`);
5893
+ const packed = packSprites(
5894
+ outputs.map((output) => ({
5895
+ id: output.role ?? fallbackOutputRole(output.index),
5896
+ path: output.absolutePath
5897
+ })),
5898
+ { columns: options.columns, order: "input" }
5645
5899
  );
5646
- }
5647
- function parseTransaction(journal, data) {
5648
- let raw;
5649
- try {
5650
- raw = JSON.parse(data);
5651
- } catch (error) {
5652
- throw new Error(`${journal} is not valid transaction JSON: ${message(error)}`, { cause: error });
5653
- }
5654
- if (raw.format !== "pixelkiln-artifact-transaction" || raw.version !== 1 || !Number.isInteger(raw.pid) || typeof raw.createdAt !== "string" || !Array.isArray(raw.entries) || !raw.entries.length) {
5655
- throw new Error(`${journal} is not a PixelKiln artifact transaction.`);
5656
- }
5657
- return raw;
5658
- }
5659
- async function removeTransactionFiles(journal) {
5660
- await rm6(journal, { force: true });
5661
- await rm6(transactionMarker(journal), { force: true });
5662
- }
5663
- async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
5664
- const journal = path18.resolve(recoveryFile);
5665
- const bytes = await readOptional(journal);
5666
- if (!bytes) {
5667
- await rm6(transactionMarker(journal), { force: true });
5668
- return;
5900
+ if (packed.skipped.length) {
5901
+ throw new Error(
5902
+ `Cannot export ${spec.styleId}/${spec.assetId}: ` + packed.skipped.map((item) => `${item.id}: ${item.reason}`).join("; ")
5903
+ );
5669
5904
  }
5670
- if (activeTransactions.has(journal)) {
5671
- throw new Error(`Another artifact write in this process is already using ${journal}.`);
5905
+ const metadata = entry.providerMetadata?.[entry.provider];
5906
+ const rawRules = isRecord(metadata) ? metadata.tileRules : void 0;
5907
+ const rules = normalizeTileRules(rawRules);
5908
+ const frames = packed.atlas.frames;
5909
+ const generic = buildGeneric(
5910
+ spec,
5911
+ options.imageName,
5912
+ packed.atlas,
5913
+ frames,
5914
+ isRecord(rawRules) ? rawRules : null,
5915
+ rules
5916
+ );
5917
+ if (options.format === "generic") {
5918
+ return {
5919
+ png: packed.png,
5920
+ extension: ".json",
5921
+ document: JSON.stringify(generic, null, 2) + "\n",
5922
+ generic,
5923
+ sources: packed.sources
5924
+ };
5672
5925
  }
5673
- const transaction = parseTransaction(journal, bytes.toString("utf8"));
5674
- if (transaction.pid !== process.pid && processIsAlive(transaction.pid)) {
5926
+ assertUniformTiles(generic);
5927
+ if ((rawRules !== void 0 || entry.tileFeature) && !rules) {
5675
5928
  throw new Error(
5676
- `Artifact transaction ${journal} is still owned by live process ${transaction.pid}; refusing concurrent recovery.`
5929
+ `${options.format} export needs recognized adjacency rules; use --format generic to retain this provider rule object without guessing`
5677
5930
  );
5678
5931
  }
5679
- for (const entry of transaction.entries) {
5680
- if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path18.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
5681
- throw new Error(
5682
- `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
5683
- );
5684
- }
5685
- }
5686
- const committed = await readOptional(transactionMarker(journal)) !== null;
5687
- const errors = [];
5688
- if (committed) {
5689
- for (const entry of transaction.entries) {
5690
- if (entry.backup) await remove2(entry.backup, errors);
5691
- await remove2(entry.stage, errors);
5692
- }
5693
- } else {
5694
- for (const entry of [...transaction.entries].reverse()) {
5695
- const backup = entry.backup ? await readOptional(entry.backup) : null;
5696
- const destination = await readOptional(entry.destination);
5697
- if (backup) {
5698
- if (destination && digest(destination) !== entry.sha256) {
5699
- errors.push(
5700
- `destination changed during interrupted recovery: ${entry.destination}; previous file remains at ${entry.backup}`
5701
- );
5702
- continue;
5703
- }
5704
- await remove2(entry.destination, errors);
5705
- try {
5706
- await rename5(entry.backup, entry.destination);
5707
- } catch (error) {
5708
- errors.push(
5709
- `could not restore ${entry.destination}; previous file remains at ${entry.backup}: ${message(error)}`
5710
- );
5711
- }
5712
- } else if (destination && digest(destination) === entry.sha256) {
5713
- await remove2(entry.destination, errors);
5714
- }
5715
- await remove2(entry.stage, errors);
5716
- }
5932
+ if (rules?.arity === 6) {
5933
+ throw new Error(
5934
+ `${options.format} export does not yet map six-edge hex masks safely; use --format generic`
5935
+ );
5717
5936
  }
5718
- if (errors.length) {
5937
+ if (rules?.ruleType === "outline") {
5719
5938
  throw new Error(
5720
- `Artifact transaction recovery needs attention; journal retained at ${journal}: ` + errors.join("; ")
5939
+ `${options.format} export cannot infer adjacency from outline/stamp-only rules; use --format generic`
5721
5940
  );
5722
5941
  }
5723
- await removeTransactionFiles(journal);
5724
- }
5725
- function createArtifactBundleManifest(manifestPath, outputs, provenance) {
5726
- const root = path18.dirname(path18.resolve(manifestPath));
5727
- const sources = provenance.sources.map((source) => ({
5728
- ...source,
5729
- path: portableRelative(root, source.path)
5730
- }));
5731
- const normalized = {
5732
- kind: provenance.kind,
5733
- sources,
5734
- options: canonical2(provenance.options)
5942
+ if (options.format === "tiled") {
5943
+ return {
5944
+ png: packed.png,
5945
+ extension: ".tsj",
5946
+ document: JSON.stringify(buildTiled(generic), null, 2) + "\n",
5947
+ generic,
5948
+ sources: packed.sources
5949
+ };
5950
+ }
5951
+ return {
5952
+ png: packed.png,
5953
+ extension: ".tres",
5954
+ document: buildGodot(generic, spec),
5955
+ generic,
5956
+ sources: packed.sources
5735
5957
  };
5736
- const manifestOutputs = outputs.map((output) => ({
5737
- path: portableRelative(root, output.path),
5738
- sha256: digest(output.data)
5739
- }));
5958
+ }
5959
+ function buildGeneric(spec, imageName, atlas, frames, providerRules, rules) {
5740
5960
  return {
5741
- format: "pixelkiln-artifact-bundle",
5961
+ format: "pixelkiln-tileset",
5742
5962
  version: 1,
5743
- ...normalized,
5744
- fingerprint: fingerprint({ ...normalized, outputs: manifestOutputs }),
5745
- outputs: manifestOutputs
5963
+ name: `${spec.styleId}/${spec.assetId}`,
5964
+ style: spec.styleId,
5965
+ asset: spec.assetId,
5966
+ image: imageName,
5967
+ tile: { ...atlas.cell },
5968
+ sheet: { ...atlas.sheet, columns: atlas.columns },
5969
+ tiles: frames.map((frame, index) => {
5970
+ const sourceIndex = tileIndex(frame.id) ?? index;
5971
+ return {
5972
+ id: index,
5973
+ sourceIndex,
5974
+ role: frame.id,
5975
+ x: frame.x,
5976
+ y: frame.y,
5977
+ width: frame.width,
5978
+ height: frame.height,
5979
+ ...rules?.masks[sourceIndex] !== void 0 ? { bitmask: rules.masks[sourceIndex] } : {},
5980
+ stampOnly: rules ? rules.masks[sourceIndex] === void 0 : true
5981
+ };
5982
+ }),
5983
+ providerRules,
5984
+ rules
5746
5985
  };
5747
5986
  }
5748
- function withArtifactManifest(manifestPath, outputs, provenance) {
5749
- const manifest = createArtifactBundleManifest(manifestPath, outputs, provenance);
5750
- return [
5751
- ...outputs,
5752
- { path: manifestPath, data: JSON.stringify(manifest, null, 2) + "\n" }
5753
- ];
5987
+ function buildTiled(generic) {
5988
+ const rules = generic.rules;
5989
+ const tiled = {
5990
+ type: "tileset",
5991
+ version: "1.10",
5992
+ tiledversion: "1.12.2",
5993
+ name: generic.name.replace("/", "-"),
5994
+ tilewidth: generic.tile.width,
5995
+ tileheight: generic.tile.height,
5996
+ tilecount: generic.tiles.length,
5997
+ columns: generic.sheet.columns,
5998
+ image: generic.image,
5999
+ imagewidth: generic.sheet.width,
6000
+ imageheight: generic.sheet.height,
6001
+ margin: 0,
6002
+ spacing: 0
6003
+ };
6004
+ if (!rules || rules.ruleType === "outline") return tiled;
6005
+ const terrainNames = terrainNamesFor(rules);
6006
+ tiled.wangsets = [{
6007
+ name: generic.name,
6008
+ type: rules.ruleType,
6009
+ tile: representativeTile(generic, 0),
6010
+ colors: terrainNames.map((name, index) => ({
6011
+ name,
6012
+ color: index === 0 ? "#5ab552" : "#4c78a8",
6013
+ tile: representativeTile(generic, index),
6014
+ probability: 1
6015
+ })),
6016
+ properties: rules.connectivity ? [{ name: "pixelkiln:connectivity", type: "string", value: rules.connectivity }] : [],
6017
+ wangtiles: generic.tiles.filter((tile) => tile.bitmask !== void 0).map((tile) => ({ tileid: tile.id, wangid: tiledWangId(rules, tile.bitmask) }))
6018
+ }];
6019
+ return tiled;
5754
6020
  }
5755
- async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
5756
- const absoluteManifest = path18.resolve(manifestPath);
5757
- const recoveryFile = `${absoluteManifest}.transaction`;
5758
- const allowedDestinations = /* @__PURE__ */ new Set([
5759
- absoluteManifest,
5760
- ...outputs.map((output) => path18.resolve(output.path))
5761
- ]);
5762
- await recoverArtifactTransaction(recoveryFile, allowedDestinations);
5763
- const existingManifestBytes = await readOptional(absoluteManifest);
5764
- let previous = null;
5765
- if (existingManifestBytes && !options.force) {
5766
- try {
5767
- previous = parseArtifactManifest(absoluteManifest, existingManifestBytes.toString("utf8"));
5768
- const valid = previous.fingerprint === fingerprint(previous);
5769
- if (!valid) throw new Error("its integrity fingerprint does not match");
5770
- } catch (error) {
5771
- throw new Error(
5772
- `Refusing to replace unrecognized or modified provenance at ${absoluteManifest}: ${message(error)}. Pass { force: true } (CLI: --force) to take ownership of this artifact bundle.`,
5773
- { cause: error }
5774
- );
6021
+ function tiledWangId(rules, mask) {
6022
+ const bitColor = (bit) => (mask & 1 << bit) !== 0 ? 1 : 2;
6023
+ if (rules.ruleType === "corner") {
6024
+ return [0, bitColor(2), 0, bitColor(0), 0, bitColor(1), 0, bitColor(3)];
6025
+ }
6026
+ return [bitColor(0), 0, bitColor(1), 0, bitColor(2), 0, bitColor(3), 0];
6027
+ }
6028
+ function buildGodot(generic, spec) {
6029
+ const rules = generic.rules;
6030
+ const lines = [
6031
+ `[gd_resource type="TileSet" load_steps=3 format=3]`,
6032
+ "",
6033
+ `[ext_resource type="Texture2D" path=${JSON.stringify(`./${generic.image}`)} id="1_texture"]`,
6034
+ "",
6035
+ `[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_pixelkiln"]`,
6036
+ `texture = ExtResource("1_texture")`,
6037
+ `texture_region_size = Vector2i(${generic.tile.width}, ${generic.tile.height})`
6038
+ ];
6039
+ for (const tile of generic.tiles) {
6040
+ const x = tile.id % generic.sheet.columns;
6041
+ const y = Math.floor(tile.id / generic.sheet.columns);
6042
+ lines.push(`${x}:${y}/0 = 0`);
6043
+ if (!rules || tile.bitmask === void 0 || rules.ruleType === "outline") continue;
6044
+ const values = godotTerrainBits(rules, tile.bitmask);
6045
+ lines.push(`${x}:${y}/0/terrain_set = 0`);
6046
+ lines.push(`${x}:${y}/0/terrain = ${dominantTerrain(values)}`);
6047
+ for (const [side, value] of Object.entries(values)) {
6048
+ lines.push(`${x}:${y}/0/terrains_peering_bit/${side} = ${value}`);
5775
6049
  }
5776
6050
  }
5777
- if (!options.force) {
5778
- const root = path18.dirname(absoluteManifest);
5779
- const recorded = new Map(
5780
- previous?.outputs.map((output) => [path18.resolve(root, output.path), output.sha256]) ?? []
5781
- );
5782
- const conflicts = [];
5783
- for (const output of outputs) {
5784
- const destination = path18.resolve(output.path);
5785
- const current = await readOptional(destination);
5786
- if (!current || digest(current) === digest(output.data)) continue;
5787
- const expected = recorded.get(destination);
5788
- if (!expected || digest(current) !== expected) conflicts.push(destination);
5789
- }
5790
- if (conflicts.length) {
5791
- throw new Error(
5792
- `Refusing to overwrite modified or unowned artifact file(s):
5793
- ` + conflicts.map((file) => ` ${file}`).join("\n") + `
5794
- Pass { force: true } (CLI: --force) to take ownership and replace the complete bundle.`
6051
+ lines.push("", "[resource]");
6052
+ lines.push(`tile_size = Vector2i(${generic.tile.width}, ${generic.tile.height})`);
6053
+ const shape = godotTileShape(spec.tileType ?? "isometric");
6054
+ if (shape !== 0) lines.push(`tile_shape = ${shape}`);
6055
+ if (rules && rules.ruleType !== "outline") {
6056
+ lines.push(`terrain_set_0/mode = ${rules.ruleType === "corner" ? 1 : 2}`);
6057
+ for (const [index, name] of terrainNamesFor(rules).entries()) {
6058
+ lines.push(`terrain_set_0/terrain_${index}/name = ${JSON.stringify(name)}`);
6059
+ lines.push(
6060
+ `terrain_set_0/terrain_${index}/color = ` + (index === 0 ? "Color(0.3529, 0.7098, 0.3216, 1)" : "Color(0.298, 0.4706, 0.6588, 1)")
5795
6061
  );
5796
6062
  }
5797
6063
  }
5798
- const { force: _force, ...bundleOptions } = options;
5799
- return writeArtifactBundle(
5800
- withArtifactManifest(absoluteManifest, outputs, provenance),
5801
- { ...bundleOptions, recoveryFile }
5802
- );
5803
- }
5804
- function isCode(error, code) {
5805
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
6064
+ lines.push(`sources/0 = SubResource("TileSetAtlasSource_pixelkiln")`, "");
6065
+ return lines.join("\n");
5806
6066
  }
5807
- async function remove2(file, errors) {
5808
- try {
5809
- await rm6(file, { force: true });
5810
- } catch (error) {
5811
- errors?.push(`could not remove ${file}: ${message(error)}`);
6067
+ function godotTerrainBits(rules, mask) {
6068
+ const terrain = (bit) => (mask & 1 << bit) !== 0 ? 0 : 1;
6069
+ if (rules.ruleType === "corner") {
6070
+ return {
6071
+ top_left_corner: terrain(3),
6072
+ top_right_corner: terrain(2),
6073
+ bottom_left_corner: terrain(1),
6074
+ bottom_right_corner: terrain(0)
6075
+ };
5812
6076
  }
6077
+ return {
6078
+ top_side: terrain(0),
6079
+ right_side: terrain(1),
6080
+ bottom_side: terrain(2),
6081
+ left_side: terrain(3)
6082
+ };
5813
6083
  }
5814
- async function rollback(prepared) {
5815
- const errors = [];
5816
- for (const artifact of [...prepared].reverse()) {
5817
- if (artifact.promoted) await remove2(artifact.destination, errors);
6084
+ function dominantTerrain(values) {
6085
+ const terrainZero = Object.values(values).filter((value) => value === 0).length;
6086
+ return terrainZero * 2 >= Object.keys(values).length ? 0 : 1;
6087
+ }
6088
+ function godotTileShape(tileType) {
6089
+ if (tileType === "isometric" || tileType === "oblique") return 1;
6090
+ if (tileType === "hex" || tileType === "hex_pointy") return 3;
6091
+ return 0;
6092
+ }
6093
+ function representativeTile(generic, terrainIndex) {
6094
+ const rules = generic.rules;
6095
+ const full = (1 << rules.arity) - 1;
6096
+ const wanted = terrainIndex === 0 ? full : 0;
6097
+ return generic.tiles.find((tile) => tile.bitmask === wanted)?.id ?? -1;
6098
+ }
6099
+ function terrainNamesFor(rules) {
6100
+ return [rules.terrains[0] ?? "feature", rules.terrains[1] ?? "background"];
6101
+ }
6102
+ function assertUniformTiles(generic) {
6103
+ const mismatched = generic.tiles.filter(
6104
+ (tile) => tile.width !== generic.tile.width || tile.height !== generic.tile.height
6105
+ );
6106
+ if (mismatched.length) {
6107
+ throw new Error(
6108
+ `Engine tilesets require uniform ${generic.tile.width}x${generic.tile.height} cells; mismatched: ${mismatched.map((tile) => tile.role).join(", ")}. Use --format generic.`
6109
+ );
5818
6110
  }
5819
- for (const artifact of [...prepared].reverse()) {
5820
- if (!artifact.backup || !artifact.backedUp) continue;
5821
- try {
5822
- await rename5(artifact.backup, artifact.destination);
5823
- artifact.backedUp = false;
5824
- } catch (error) {
5825
- errors.push(
5826
- `could not restore ${artifact.destination}; previous file remains at ${artifact.backup}: ${message(error)}`
5827
- );
5828
- }
6111
+ }
6112
+ function tileIndex(key) {
6113
+ const match = key.match(/(?:^|[_-])(\d+)$/);
6114
+ return match ? Number(match[1]) : null;
6115
+ }
6116
+ function isRecord(value) {
6117
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6118
+ }
6119
+
6120
+ // src/pick/salvage-server.ts
6121
+ import { mkdir as mkdir6, writeFile as writeFile8, readFile as readFile15 } from "fs/promises";
6122
+ import path19 from "path";
6123
+
6124
+ // src/pick/salvage-sheet.ts
6125
+ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
6126
+ function renderSalvageSheet(orphans, ctx) {
6127
+ const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml2(o.prompt) }));
6128
+ const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
6129
+ const styleId = ctx ? escapeHtml2(ctx.styleId) : null;
6130
+ const importDir = ctx ? escapeHtml2(ctx.importDir) : null;
6131
+ return `<!doctype html>
6132
+ <html lang="en">
6133
+ <head>
6134
+ <meta charset="utf-8">
6135
+ <title>pixelkiln salvage${styleId ? ` \u2014 ${styleId}` : ""}</title>
6136
+ <style>
6137
+ :root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --text:#e6e8ee; --dim:#8b93a7;
6138
+ --import:#6ee7a8; --keep:#7cc4ff; --discard:#f87171; }
6139
+ @media (prefers-color-scheme: light) {
6140
+ :root { --bg:#f6f7f9; --panel:#fff; --line:#dfe3ea; --text:#1a1d23; --dim:#666e80;
6141
+ --import:#0a8f52; --keep:#1a6fd4; --discard:#c0392b; }
5829
6142
  }
5830
- for (const artifact of prepared) {
5831
- if (artifact.stage) await remove2(artifact.stage, errors);
6143
+ * { box-sizing:border-box; }
6144
+ body { margin:0; background:var(--bg); color:var(--text);
6145
+ font:14px/1.5 ui-sans-serif,-apple-system,"SF Pro Text",Inter,system-ui,sans-serif; }
6146
+ header { position:sticky; top:0; z-index:10; background:var(--panel);
6147
+ border-bottom:1px solid var(--line); padding:12px 20px; display:flex; gap:14px;
6148
+ align-items:center; flex-wrap:wrap; }
6149
+ h1 { font-size:15px; margin:0; font-weight:600; }
6150
+ h1 .ctx { font-weight:500; color:var(--dim); }
6151
+ h1 .dest { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12.5px; }
6152
+ .tally { color:var(--dim); font-variant-numeric:tabular-nums; }
6153
+ .tally b { color:var(--text); font-weight:600; }
6154
+ button { font:inherit; font-weight:550; border-radius:7px; padding:7px 14px;
6155
+ border:1px solid var(--line); background:transparent; color:var(--text); cursor:pointer; }
6156
+ button.primary { background:var(--import); border-color:var(--import); color:#08130d; }
6157
+ button:disabled { opacity:.45; cursor:not-allowed; }
6158
+ main { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:12px; padding:18px 20px 80px; }
6159
+ .card { border:2px solid var(--line); border-radius:10px; background:var(--panel);
6160
+ padding:10px; display:flex; flex-direction:column; gap:8px; }
6161
+ .card.import { border-color:var(--import); }
6162
+ .card.keep { border-color:var(--keep); }
6163
+ .card.discard { border-color:var(--discard); opacity:.5; }
6164
+ .thumb { height:120px; display:flex; align-items:center; justify-content:center;
6165
+ border-radius:6px;
6166
+ background-image:
6167
+ linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
6168
+ linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
6169
+ background-size:12px 12px; background-position:0 0,6px 6px; }
6170
+ .thumb img { image-rendering:pixelated; max-height:112px; max-width:100%; }
6171
+ .meta { font-size:11px; color:var(--dim); font-variant-numeric:tabular-nums; }
6172
+ .prompt { font-size:11.5px; color:var(--dim); max-height:3.2em; overflow:hidden; }
6173
+ .acts { display:flex; gap:5px; }
6174
+ .acts button { flex:1; padding:5px 0; font-size:11.5px; }
6175
+ .acts button.on-import { background:var(--import); border-color:var(--import); color:#08130d; }
6176
+ .acts button.on-keep { background:var(--keep); border-color:var(--keep); color:#04121f; }
6177
+ .acts button.on-discard { background:var(--discard); border-color:var(--discard); color:#fff; }
6178
+ footer { position:fixed; bottom:0; left:0; right:0; background:var(--panel);
6179
+ border-top:1px solid var(--line); padding:10px 20px; font-size:12px; color:var(--dim); }
6180
+ kbd { border:1px solid var(--line); border-bottom-width:2px; border-radius:4px;
6181
+ padding:1px 5px; font-size:11px; font-family:ui-monospace,Menlo,monospace; }
6182
+ </style>
6183
+ </head>
6184
+ <body>
6185
+ <header>
6186
+ <h1>pixelkiln salvage${styleId ? ` <span class="ctx">\u2014 ${styleId}<span class="dest"> \u2192 ${importDir}/_salvaged/</span></span>` : ""}</h1>
6187
+ <span class="tally"><b id="ti">0</b> import \xB7 <b id="tk">0</b> keep \xB7 <b id="td">0</b> discard \xB7
6188
+ <b id="tu">0</b> undecided</span>
6189
+ <span style="flex:1"></span>
6190
+ <button id="allDiscard">Discard all undecided</button>
6191
+ <button id="allKeep">Keep all undecided</button>
6192
+ <button id="submit" class="primary" disabled>Apply</button>
6193
+ </header>
6194
+ <main id="grid"></main>
6195
+ <footer>
6196
+ <b>import</b> downloads it and adds it to your manifest \xB7 <b>keep</b> tags it and leaves it \xB7
6197
+ <b>discard</b> only tags it \u2014 nothing is deleted here.
6198
+ Hover a card and press <kbd>i</kbd> / <kbd>k</kbd> / <kbd>d</kbd>, or <kbd>u</kbd> to undo.
6199
+ </footer>
6200
+ <script>
6201
+ const ITEMS = ${data};
6202
+ const picks = new Map();
6203
+ const grid = document.getElementById('grid');
6204
+ let hovered = null;
6205
+
6206
+ ITEMS.forEach((o, i) => {
6207
+ const el = document.createElement('div');
6208
+ el.className = 'card';
6209
+ el.dataset.i = String(i);
6210
+ el.innerHTML =
6211
+ '<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
6212
+ '<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
6213
+ '<div class="prompt">' + o.prompt + '</div>' +
6214
+ '<div class="acts">' +
6215
+ '<button data-a="import">import</button>' +
6216
+ '<button data-a="keep">keep</button>' +
6217
+ '<button data-a="discard">discard</button>' +
6218
+ '</div>';
6219
+ el.querySelectorAll('.acts button').forEach(b => {
6220
+ b.onclick = () => choose(i, b.dataset.a);
6221
+ });
6222
+ el.onmouseenter = () => { hovered = i; };
6223
+ grid.appendChild(el);
6224
+ });
6225
+
6226
+ function choose(i, action) {
6227
+ if (picks.get(i) === action) picks.delete(i); else picks.set(i, action);
6228
+ const el = grid.children[i];
6229
+ el.className = 'card' + (picks.has(i) ? ' ' + picks.get(i) : '');
6230
+ el.querySelectorAll('.acts button').forEach(b => {
6231
+ b.className = picks.get(i) === b.dataset.a ? 'on-' + b.dataset.a : '';
6232
+ });
6233
+ refresh();
6234
+ }
6235
+
6236
+ function refresh() {
6237
+ const c = { import: 0, keep: 0, discard: 0 };
6238
+ for (const a of picks.values()) c[a]++;
6239
+ ti.textContent = c.import; tk.textContent = c.keep; td.textContent = c.discard;
6240
+ tu.textContent = ITEMS.length - picks.size;
6241
+ document.getElementById('submit').disabled = picks.size === 0;
6242
+ }
6243
+
6244
+ document.addEventListener('keydown', e => {
6245
+ if (hovered === null) return;
6246
+ const map = { i: 'import', k: 'keep', d: 'discard' };
6247
+ if (map[e.key]) { choose(hovered, map[e.key]); e.preventDefault(); }
6248
+ else if (e.key === 'u') { picks.delete(hovered); choose(hovered, '__none__'); }
6249
+ });
6250
+
6251
+ document.getElementById('allKeep').onclick = () => {
6252
+ ITEMS.forEach((_, i) => { if (!picks.has(i)) choose(i, 'keep'); });
6253
+ };
6254
+ document.getElementById('allDiscard').onclick = () => {
6255
+ ITEMS.forEach((_, i) => { if (!picks.has(i)) choose(i, 'discard'); });
6256
+ };
6257
+
6258
+ document.getElementById('submit').onclick = async () => {
6259
+ const btn = document.getElementById('submit');
6260
+ btn.disabled = true; btn.textContent = 'applying\u2026';
6261
+ const decisions = [...picks.entries()].map(([i, action]) => ({ id: ITEMS[i].id, action }));
6262
+ try {
6263
+ const res = await fetch('/apply', {
6264
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
6265
+ body: JSON.stringify({ decisions }),
6266
+ });
6267
+ if (!res.ok) throw new Error(await res.text());
6268
+ btn.textContent = 'done \u2014 close this tab';
6269
+ document.body.style.opacity = '.6';
6270
+ } catch (err) {
6271
+ btn.textContent = 'failed: ' + err.message; btn.disabled = false;
5832
6272
  }
5833
- return errors;
6273
+ };
6274
+ refresh();
6275
+ </script>
6276
+ </body>
6277
+ </html>`;
5834
6278
  }
5835
- async function writeArtifactBundle(files, options = {}) {
5836
- if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
5837
- const normalized = files.map((file) => {
5838
- if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
5839
- return { destination: path18.resolve(file.path), data: Buffer.from(file.data) };
6279
+
6280
+ // src/pick/salvage-server.ts
6281
+ var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
6282
+ async function runSalvage(provider, orphans, ctx, opts = {}) {
6283
+ const log2 = opts.onProgress ?? (() => {
5840
6284
  });
5841
- const destinations = /* @__PURE__ */ new Set();
5842
- for (const artifact of normalized) {
5843
- if (destinations.has(artifact.destination)) {
5844
- throw new Error(`Artifact bundle contains duplicate destination: ${artifact.destination}`);
5845
- }
5846
- destinations.add(artifact.destination);
5847
- }
5848
- if (options.recoveryFile) {
5849
- await recoverArtifactTransaction(options.recoveryFile, destinations);
5850
- }
5851
- const changed = [];
5852
- const unchanged = [];
5853
- for (const artifact of normalized) {
5854
- try {
5855
- const current = await readFile14(artifact.destination);
5856
- if (current.equals(artifact.data)) {
5857
- unchanged.push(artifact.destination);
5858
- } else {
5859
- changed.push({ ...artifact, existed: true, backedUp: false, promoted: false });
6285
+ const html = renderSalvageSheet(orphans, {
6286
+ styleId: ctx.styleId,
6287
+ importDir: path19.relative(process.cwd(), ctx.importDir) || "."
6288
+ });
6289
+ const byId = new Map(orphans.map((o) => [o.id, o]));
6290
+ const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
6291
+ return serveReviewPage({
6292
+ html,
6293
+ port: opts.port,
6294
+ open: opts.open,
6295
+ onProgress: log2,
6296
+ onReady: (url) => {
6297
+ log2(`
6298
+ ${orphans.length} unclaimed object(s) to triage: ${url}`);
6299
+ log2(` (nothing is deleted here \u2014 discard only tags)
6300
+ `);
6301
+ },
6302
+ handleApply: async (body) => {
6303
+ const { decisions } = body;
6304
+ const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
6305
+ const taken = new Set(Object.keys(ctx.manifest.assets));
6306
+ const importedAssetIds = [];
6307
+ for (const decision of decisions) {
6308
+ const orphan = byId.get(decision.id);
6309
+ if (!orphan) continue;
6310
+ if (decision.action === "import") {
6311
+ try {
6312
+ const buf = await provider.download(orphan.previewUrl);
6313
+ if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
6314
+ decodePng(buf);
6315
+ const assetId = idFromPrompt(orphan.prompt, taken);
6316
+ const rel = path19.join("_salvaged", `${assetId}.png`);
6317
+ const outFile = path19.resolve(ctx.importDir, rel);
6318
+ await mkdir6(path19.dirname(outFile), { recursive: true });
6319
+ await writeFile8(outFile, buf);
6320
+ ctx.manifest.assets[assetId] = {
6321
+ prompt: orphan.prompt,
6322
+ promptByStyle: {},
6323
+ category: "_salvaged",
6324
+ file: rel,
6325
+ tags: ["salvaged"],
6326
+ styles: [ctx.styleId],
6327
+ ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
6328
+ };
6329
+ upsert(ctx.lock, lockKey(ctx.styleId, assetId), {
6330
+ styleId: ctx.styleId,
6331
+ assetId,
6332
+ specHash: SALVAGED_SPEC_HASH,
6333
+ generator: orphan.width === orphan.height ? "1dir" : "map",
6334
+ prompt: orphan.prompt,
6335
+ width: orphan.width,
6336
+ height: orphan.height,
6337
+ jobId: orphan.id,
6338
+ reviewObjectId: null,
6339
+ objectId: orphan.id,
6340
+ candidateIndex: null,
6341
+ status: "downloaded",
6342
+ error: null,
6343
+ sourceUrl: orphan.previewUrl,
6344
+ outputs: [{
6345
+ path: portableOutputPath(outFile, path19.dirname(ctx.manifestPath)),
6346
+ sha256: sha256(buf)
6347
+ }],
6348
+ submittedAt: orphan.createdAt,
6349
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
6350
+ cost: 0,
6351
+ // already paid for, in an earlier period
6352
+ costUnit: "generations",
6353
+ provider: provider.id
6354
+ });
6355
+ importedAssetIds.push(assetId);
6356
+ result.imported++;
6357
+ log2(` imported ${assetId} \u2190 ${orphan.id}`);
6358
+ } catch (err) {
6359
+ result.failed++;
6360
+ log2(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
6361
+ }
6362
+ } else if (decision.action === "keep") {
6363
+ result.kept++;
6364
+ } else {
6365
+ result.discarded++;
6366
+ }
5860
6367
  }
5861
- } catch (error) {
5862
- if (!isCode(error, "ENOENT")) throw error;
5863
- changed.push({ ...artifact, existed: false, backedUp: false, promoted: false });
5864
- }
5865
- }
5866
- if (!changed.length) return { changed: [], unchanged };
5867
- const token = randomUUID3();
5868
- for (const [index, artifact] of changed.entries()) {
5869
- const basename = path18.basename(artifact.destination);
5870
- artifact.stage = path18.join(
5871
- path18.dirname(artifact.destination),
5872
- `.${basename}.pixelkiln-stage-${token}-${index}`
5873
- );
5874
- if (artifact.existed) {
5875
- artifact.backup = path18.join(
5876
- path18.dirname(artifact.destination),
5877
- `.${basename}.pixelkiln-backup-${token}-${index}`
5878
- );
5879
- }
5880
- }
5881
- const journal = options.recoveryFile ? path18.resolve(options.recoveryFile) : null;
5882
- if (journal) {
5883
- await mkdir6(path18.dirname(journal), { recursive: true });
5884
- const transaction = {
5885
- format: "pixelkiln-artifact-transaction",
5886
- version: 1,
5887
- pid: process.pid,
5888
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5889
- entries: changed.map((artifact) => ({
5890
- destination: artifact.destination,
5891
- stage: artifact.stage,
5892
- ...artifact.backup ? { backup: artifact.backup } : {},
5893
- sha256: digest(artifact.data)
5894
- }))
5895
- };
5896
- try {
5897
- await writeFile8(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
5898
- activeTransactions.add(journal);
5899
- } catch (error) {
5900
- throw new Error(
5901
- `Could not start durable artifact transaction at ${journal}: ${message(error)}`,
5902
- { cause: error }
5903
- );
5904
- }
5905
- }
5906
- try {
5907
- for (const [index, artifact] of changed.entries()) {
5908
- await mkdir6(path18.dirname(artifact.destination), { recursive: true });
5909
- await options.beforeStage?.(artifact.destination, index);
5910
- await writeFile8(artifact.stage, artifact.data, { flag: "wx" });
5911
- }
5912
- } catch (error) {
5913
- const cleanupErrors2 = await rollback(changed);
5914
- if (journal && !cleanupErrors2.length) await removeTransactionFiles(journal);
5915
- if (journal) activeTransactions.delete(journal);
5916
- const suffix = cleanupErrors2.length ? ` Cleanup also failed: ${cleanupErrors2.join("; ")}` : "";
5917
- throw new Error(`Could not stage artifact bundle: ${message(error)}.${suffix}`, { cause: error });
5918
- }
5919
- let durableCommit = false;
5920
- try {
5921
- for (const artifact of changed) {
5922
- if (!artifact.existed) continue;
5923
- await rename5(artifact.destination, artifact.backup);
5924
- artifact.backedUp = true;
5925
- }
5926
- for (const [index, artifact] of changed.entries()) {
5927
- await options.beforePromote?.(artifact.destination, index);
5928
- await rename5(artifact.stage, artifact.destination);
5929
- artifact.stage = void 0;
5930
- artifact.promoted = true;
5931
- }
5932
- if (journal) {
5933
- await writeFile8(transactionMarker(journal), `${token}
5934
- `, { flag: "wx" });
5935
- durableCommit = true;
5936
- await options.afterCommit?.();
5937
- }
5938
- } catch (error) {
5939
- if (durableCommit) {
5940
- if (journal) activeTransactions.delete(journal);
5941
- throw new Error(
5942
- `Artifact bundle was committed, but final cleanup was interrupted: ${message(error)}. The next write will finish cleanup from ${journal}.`,
5943
- { cause: error }
5944
- );
5945
- }
5946
- const rollbackErrors = await rollback(changed);
5947
- if (journal && !rollbackErrors.length) await removeTransactionFiles(journal);
5948
- if (journal) activeTransactions.delete(journal);
5949
- const suffix = rollbackErrors.length ? ` Rollback needs attention: ${rollbackErrors.join("; ")}` : " Previous bundle restored.";
5950
- throw new Error(`Could not promote artifact bundle: ${message(error)}.${suffix}`, { cause: error });
5951
- }
5952
- const cleanupErrors = [];
5953
- for (const artifact of changed) {
5954
- if (artifact.backup) await remove2(artifact.backup, cleanupErrors);
5955
- }
5956
- if (cleanupErrors.length) {
5957
- if (journal) activeTransactions.delete(journal);
5958
- throw new Error(
5959
- `Artifact bundle was written, but old backup cleanup failed: ${cleanupErrors.join("; ")}`
5960
- );
5961
- }
5962
- if (journal) {
5963
- try {
5964
- await removeTransactionFiles(journal);
5965
- } finally {
5966
- activeTransactions.delete(journal);
6368
+ await applyTags(provider, decisions, existingTags, { onProgress: log2 });
6369
+ const raw = JSON.parse(await readFile15(ctx.manifestPath, "utf8"));
6370
+ for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
6371
+ await writeFile8(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
6372
+ await saveLock(ctx.lockPath, ctx.lock);
6373
+ return result;
5967
6374
  }
5968
- }
5969
- return {
5970
- changed: changed.map((artifact) => artifact.destination),
5971
- unchanged
5972
- };
6375
+ });
5973
6376
  }
5974
6377
 
5975
6378
  // src/cli.ts
5976
6379
  var log = (msg = "") => console.log(msg);
5977
6380
  async function provenanceFile(id, file) {
5978
- const absolute = path19.resolve(file);
6381
+ const absolute = path20.resolve(file);
5979
6382
  return {
5980
6383
  id,
5981
6384
  path: absolute,
@@ -6006,7 +6409,13 @@ var VALUE_FLAGS = [
6006
6409
  "--sigma",
6007
6410
  "--workspace",
6008
6411
  "--provider",
6009
- "--account"
6412
+ "--account",
6413
+ "--palette",
6414
+ "--fixer-python",
6415
+ "--fixer-revision",
6416
+ "--min-grid-confidence",
6417
+ "--reviewer",
6418
+ "--note"
6010
6419
  ];
6011
6420
  var BOOL_FLAGS = [
6012
6421
  "--force",
@@ -6045,6 +6454,7 @@ var COMMANDS = [
6045
6454
  "tag",
6046
6455
  "balance",
6047
6456
  "status",
6457
+ "refine",
6048
6458
  "workspace",
6049
6459
  "help",
6050
6460
  "--help",
@@ -6053,6 +6463,7 @@ var COMMANDS = [
6053
6463
  "-v"
6054
6464
  ];
6055
6465
  var WORKSPACE_SUBCOMMANDS = ["add", "remove", "list", "status", "claims"];
6466
+ var REFINE_SUBCOMMANDS = ["run", "approve", "check"];
6056
6467
  function parseArgs(argv) {
6057
6468
  const [command = "help"] = argv;
6058
6469
  if (!COMMANDS.includes(command)) {
@@ -6081,6 +6492,14 @@ function parseArgs(argv) {
6081
6492
  }
6082
6493
  rest = rest.slice(1);
6083
6494
  }
6495
+ } else if (command === "refine") {
6496
+ subcommand = rest[0]?.startsWith("-") || rest[0] === void 0 ? "run" : rest[0];
6497
+ if (!REFINE_SUBCOMMANDS.includes(subcommand)) {
6498
+ throw new Error(
6499
+ `Unknown refine subcommand "${subcommand}". Known: ${REFINE_SUBCOMMANDS.join(", ")}`
6500
+ );
6501
+ }
6502
+ if (rest[0] === subcommand) rest = rest.slice(1);
6084
6503
  }
6085
6504
  for (let i = 0; i < rest.length; i++) {
6086
6505
  const token = rest[i];
@@ -6155,10 +6574,16 @@ function parseArgs(argv) {
6155
6574
  }
6156
6575
  return value;
6157
6576
  };
6577
+ const rawGridConfidence = get("--min-grid-confidence");
6578
+ if (rawGridConfidence !== void 0 && rawGridConfidence !== "low" && rawGridConfidence !== "medium" && rawGridConfidence !== "high") {
6579
+ throw new Error(
6580
+ `--min-grid-confidence must be low, medium, or high, got "${rawGridConfidence}"`
6581
+ );
6582
+ }
6158
6583
  return {
6159
6584
  command,
6160
6585
  manifest,
6161
- lock: get("--lock") ?? path19.join(path19.dirname(path19.resolve(manifest)), "pixelkiln.lock.json"),
6586
+ lock: get("--lock") ?? path20.join(path20.dirname(path20.resolve(manifest)), "pixelkiln.lock.json"),
6162
6587
  explicitLock: get("--lock"),
6163
6588
  styles: list("--style"),
6164
6589
  assets: list("--only"),
@@ -6189,6 +6614,12 @@ function parseArgs(argv) {
6189
6614
  minTransparency: numberOption("--min-transparency", { min: 0, max: 1 }),
6190
6615
  maxColors: numberOption("--max-colors", { min: 1, integer: true }),
6191
6616
  sigma: numberOption("--sigma", { min: Number.EPSILON }),
6617
+ palette: list("--palette"),
6618
+ fixerPython: get("--fixer-python"),
6619
+ fixerRevision: get("--fixer-revision"),
6620
+ minGridConfidence: rawGridConfidence,
6621
+ reviewer: get("--reviewer"),
6622
+ note: get("--note"),
6192
6623
  subcommand,
6193
6624
  workspace: get("--workspace"),
6194
6625
  target,
@@ -6215,6 +6646,8 @@ Commands
6215
6646
  salvage Triage account objects no lockfile claims. Recovers usable art.
6216
6647
  One session per matching style unless --style forces a single one.
6217
6648
  audit Measure how consistently a style's assets hold together. Offline.
6649
+ refine Recover a native pixel grid, enforce a final palette, audit it,
6650
+ and attach a verifiable human approval. run/approve/check. Offline.
6218
6651
  cache Inspect local recovery/object-hash caches; optionally verify or prune.
6219
6652
  pack Composite sprites into a PNG/atlas/provenance bundle. Offline.
6220
6653
  mount Write a style's sprites into their declared cells of an existing
@@ -6240,6 +6673,12 @@ Options
6240
6673
  --min-transparency <0..1> audit: minimum transparent canvas share
6241
6674
  --max-colors <n> audit: maximum distinct opaque colors
6242
6675
  --sigma <n> audit: relative outlier cutoff (default: 1.5)
6676
+ --palette <hexes> refine: final comma-separated #rrggbb colors (repeatable)
6677
+ --fixer-python <path> refine: Python with Pixel Art Fixer installed
6678
+ --fixer-revision <sha> refine: Pixel Art Fixer revision to record
6679
+ --min-grid-confidence <level> refine: high (default), medium, or low
6680
+ --reviewer <name> refine approve: human reviewer recorded in provenance
6681
+ --note <text> refine approve: optional review note
6243
6682
  --prune cache: remove invalid/unreferenced local cache data
6244
6683
  --manifest <path> Default: pixelkiln.manifest.json
6245
6684
  --lock <path> Default: pixelkiln.lock.json beside the manifest
@@ -6269,6 +6708,9 @@ Examples
6269
6708
  pixelkiln pack --inputs sprites.json --out dist/sheet # no manifest needed
6270
6709
  pixelkiln mount --style ground
6271
6710
  pixelkiln export --style ground --only terrain --format tiled
6711
+ pixelkiln refine --from source.png --out final.png --palette "#101820,#f2aa4c"
6712
+ pixelkiln refine approve --from final.pixelkiln.json --reviewer "Your Name"
6713
+ pixelkiln refine check --from final.pixelkiln.json
6272
6714
  pixelkiln workspace add ../other-game/pixelkiln.manifest.json
6273
6715
  pixelkiln workspace status --json
6274
6716
  pixelkiln salvage --workspace pixelkiln.workspace.json
@@ -6316,7 +6758,7 @@ async function requireCompleteWorkspaceClaims(workspacePath) {
6316
6758
  if (!existsSync15(workspacePath)) {
6317
6759
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
6318
6760
  }
6319
- const dir = path19.dirname(path19.resolve(workspacePath));
6761
+ const dir = path20.dirname(path20.resolve(workspacePath));
6320
6762
  const ws = await loadWorkspace(workspacePath);
6321
6763
  const diagnostics = validateWorkspace(ws, dir);
6322
6764
  const errors = diagnostics.filter((d) => d.level === "error");
@@ -6337,13 +6779,79 @@ async function main() {
6337
6779
  }
6338
6780
  if (args.command === "--version" || args.command === "-v") {
6339
6781
  const pkg = JSON.parse(
6340
- await readFile15(new URL("../package.json", import.meta.url), "utf8")
6782
+ await readFile16(new URL("../package.json", import.meta.url), "utf8")
6341
6783
  );
6342
6784
  log(`${pkg.name} ${pkg.version}`);
6343
6785
  return;
6344
6786
  }
6787
+ if (args.command === "refine") {
6788
+ if (!args.from) {
6789
+ throw new Error(
6790
+ args.subcommand === "run" ? "refine needs --from <source.png>." : `refine ${args.subcommand} needs --from <output.pixelkiln.json>.`
6791
+ );
6792
+ }
6793
+ if (args.subcommand === "run") {
6794
+ if (!args.out) throw new Error("refine needs --out <final.png>.");
6795
+ if (!args.palette.length) {
6796
+ throw new Error("refine needs an explicit final palette via --palette <#rrggbb,...>.");
6797
+ }
6798
+ const result2 = await refineAsset({
6799
+ source: args.from,
6800
+ output: args.out,
6801
+ palette: args.palette,
6802
+ fixerPython: args.fixerPython,
6803
+ fixerRevision: args.fixerRevision,
6804
+ minGridConfidence: args.minGridConfidence,
6805
+ minTransparency: args.minTransparency,
6806
+ force: args.force
6807
+ });
6808
+ if (args.json) {
6809
+ log(JSON.stringify(result2, null, 2));
6810
+ } else {
6811
+ log(
6812
+ ` recovered ${result2.detection.columns}x${result2.detection.rows} native grid (${result2.detection.confidence}: ${result2.detection.consensus})`
6813
+ );
6814
+ log(` enforced ${result2.palette.length}-color palette without dithering`);
6815
+ log(` wrote ${path20.relative(process.cwd(), result2.output)}`);
6816
+ log(` quality record: ${path20.relative(process.cwd(), result2.record)}`);
6817
+ log(`
6818
+ Automated checks passed. Human 1\xD7 review is still required:`);
6819
+ log(
6820
+ ` pixelkiln refine approve --from ${path20.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
6821
+ );
6822
+ }
6823
+ return;
6824
+ }
6825
+ if (args.subcommand === "approve") {
6826
+ if (!args.reviewer?.trim()) {
6827
+ throw new Error("refine approve needs --reviewer <name>.");
6828
+ }
6829
+ log(` Inspect the final PNG at 1\xD7 and integer zoom.`);
6830
+ log(` Confirm crisp edges, readable forms, palette separation, alpha, and seams.`);
6831
+ if (!await confirm(" Record this asset as human-approved?", args.yes)) {
6832
+ log(" approval not recorded");
6833
+ return;
6834
+ }
6835
+ const result2 = await approveQualityRecord(args.from, {
6836
+ reviewer: args.reviewer,
6837
+ note: args.note
6838
+ });
6839
+ if (args.json) log(JSON.stringify(result2, null, 2));
6840
+ else log(` approved ${path20.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`);
6841
+ return;
6842
+ }
6843
+ const result = await checkQualityRecord(args.from);
6844
+ if (args.json) {
6845
+ log(JSON.stringify(result, null, 2));
6846
+ } else {
6847
+ log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path20.relative(process.cwd(), result.output || result.record)}`);
6848
+ for (const reason of result.reasons) log(` ${reason}`);
6849
+ }
6850
+ if (!result.safe) process.exitCode = 1;
6851
+ return;
6852
+ }
6345
6853
  if (args.command === "balance") {
6346
- loadEnvFiles(path19.dirname(path19.resolve(args.manifest)));
6854
+ loadEnvFiles(path20.dirname(path20.resolve(args.manifest)));
6347
6855
  loadEnvFiles(process.cwd());
6348
6856
  const loaded2 = await loadManifest(args.manifest);
6349
6857
  const p = createProvider(loaded2.manifest.provider, "online");
@@ -6355,31 +6863,31 @@ async function main() {
6355
6863
  }
6356
6864
  if (args.command === "init") {
6357
6865
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
6358
- const root = path19.resolve(args.from);
6866
+ const root = path20.resolve(args.from);
6359
6867
  if (!existsSync15(root)) throw new Error(`No directory at ${root}`);
6360
6868
  const generator = args.generator ?? "map";
6361
6869
  if (generator !== "1dir" && generator !== "map") {
6362
6870
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
6363
6871
  }
6364
- const target = path19.resolve(args.out ?? "pixelkiln.manifest.json");
6872
+ const target = path20.resolve(args.out ?? "pixelkiln.manifest.json");
6365
6873
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
6366
6874
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
6367
6875
  const manifest = buildManifest(
6368
- args.name ?? path19.basename(path19.dirname(target)),
6876
+ args.name ?? path20.basename(path20.dirname(target)),
6369
6877
  args.styles[0] ?? "base",
6370
6878
  generator,
6371
- path19.relative(path19.dirname(target), root) || ".",
6879
+ path20.relative(path20.dirname(target), root) || ".",
6372
6880
  assets
6373
6881
  );
6374
6882
  await writeManifestFile(target, manifest);
6375
- log(` scanned ${assets.length} PNG(s) under ${path19.relative(process.cwd(), root)}`);
6883
+ log(` scanned ${assets.length} PNG(s) under ${path20.relative(process.cwd(), root)}`);
6376
6884
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
6377
- log(` wrote ${path19.relative(process.cwd(), target)}`);
6885
+ log(` wrote ${path20.relative(process.cwd(), target)}`);
6378
6886
  log(`
6379
6887
  Prompts are intentionally empty. To recover the real ones from your`);
6380
6888
  log(` PixelLab account instead of inventing them:`);
6381
6889
  log(`
6382
- pixelkiln adopt --manifest ${path19.relative(process.cwd(), target)} --write-prompts
6890
+ pixelkiln adopt --manifest ${path20.relative(process.cwd(), target)} --write-prompts
6383
6891
  `);
6384
6892
  return;
6385
6893
  }
@@ -6427,10 +6935,10 @@ async function main() {
6427
6935
  if (args.primaryOnly || args.outputRoles.length) {
6428
6936
  throw new Error("--primary-only and --output-role require manifest-driven pack");
6429
6937
  }
6430
- const raw = JSON.parse(await readFile15(path19.resolve(args.inputs), "utf8"));
6938
+ const raw = JSON.parse(await readFile16(path20.resolve(args.inputs), "utf8"));
6431
6939
  const inputs = resolvePackInputs(raw, args.inputs);
6432
6940
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
6433
- const base = path19.resolve(args.out.replace(/\.png$/, ""));
6941
+ const base = path20.resolve(args.out.replace(/\.png$/, ""));
6434
6942
  const outputs = [
6435
6943
  { path: `${base}.png`, data: png },
6436
6944
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -6443,17 +6951,17 @@ async function main() {
6443
6951
  log(
6444
6952
  ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
6445
6953
  );
6446
- log(` ${path19.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
6954
+ log(` ${path20.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
6447
6955
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
6448
6956
  return;
6449
6957
  }
6450
6958
  if (args.command === "workspace") {
6451
- const workspacePath = path19.resolve(args.workspace ?? "pixelkiln.workspace.json");
6452
- const dir = path19.dirname(workspacePath);
6959
+ const workspacePath = path20.resolve(args.workspace ?? "pixelkiln.workspace.json");
6960
+ const dir = path20.dirname(workspacePath);
6453
6961
  if (args.subcommand === "add") {
6454
- const manifestPath = path19.resolve(args.target);
6962
+ const manifestPath = path20.resolve(args.target);
6455
6963
  const loadedTarget = await loadManifest(manifestPath);
6456
- const lockPath = args.explicitLock ? path19.resolve(args.explicitLock) : path19.join(path19.dirname(manifestPath), "pixelkiln.lock.json");
6964
+ const lockPath = args.explicitLock ? path20.resolve(args.explicitLock) : path20.join(path20.dirname(manifestPath), "pixelkiln.lock.json");
6457
6965
  const ws = await loadWorkspace(workspacePath);
6458
6966
  const id = args.name ?? loadedTarget.manifest.name;
6459
6967
  if (ws.projects.some((p) => p.id === id)) {
@@ -6473,7 +6981,7 @@ async function main() {
6473
6981
  ...args.account ? { account: args.account } : {}
6474
6982
  };
6475
6983
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
6476
- log(` registered "${id}" in ${path19.relative(process.cwd(), workspacePath)}`);
6984
+ log(` registered "${id}" in ${path20.relative(process.cwd(), workspacePath)}`);
6477
6985
  log(` manifest: ${project.manifest}`);
6478
6986
  log(` lock: ${project.lock}`);
6479
6987
  if (!existsSync15(lockPath)) {
@@ -6485,7 +6993,7 @@ async function main() {
6485
6993
  }
6486
6994
  if (args.subcommand === "remove") {
6487
6995
  const ws = await loadWorkspace(workspacePath);
6488
- const resolvedTarget = path19.resolve(args.target);
6996
+ const resolvedTarget = path20.resolve(args.target);
6489
6997
  const match = ws.projects.find(
6490
6998
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
6491
6999
  );
@@ -6496,7 +7004,7 @@ async function main() {
6496
7004
  version: 1,
6497
7005
  projects: ws.projects.filter((p) => p !== match)
6498
7006
  });
6499
- log(` removed "${match.id}" from ${path19.relative(process.cwd(), workspacePath)}`);
7007
+ log(` removed "${match.id}" from ${path20.relative(process.cwd(), workspacePath)}`);
6500
7008
  return;
6501
7009
  }
6502
7010
  if (args.subcommand === "list") {
@@ -6508,9 +7016,9 @@ async function main() {
6508
7016
  if (args.json) {
6509
7017
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
6510
7018
  } else if (!ws.projects.length) {
6511
- log(` no projects registered in ${path19.relative(process.cwd(), workspacePath)}`);
7019
+ log(` no projects registered in ${path20.relative(process.cwd(), workspacePath)}`);
6512
7020
  } else {
6513
- log(` ${ws.projects.length} project(s) in ${path19.relative(process.cwd(), workspacePath)}:`);
7021
+ log(` ${ws.projects.length} project(s) in ${path20.relative(process.cwd(), workspacePath)}:`);
6514
7022
  for (const p of ws.projects) {
6515
7023
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
6516
7024
  }
@@ -6528,7 +7036,7 @@ async function main() {
6528
7036
  if (args.json) {
6529
7037
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
6530
7038
  } else {
6531
- log(` workspace: ${path19.relative(process.cwd(), workspacePath)}`);
7039
+ log(` workspace: ${path20.relative(process.cwd(), workspacePath)}`);
6532
7040
  for (const p of report.projects) {
6533
7041
  if (p.error) {
6534
7042
  log(`
@@ -6572,14 +7080,14 @@ async function main() {
6572
7080
  return;
6573
7081
  }
6574
7082
  }
6575
- if (!existsSync15(path19.resolve(args.manifest))) {
7083
+ if (!existsSync15(path20.resolve(args.manifest))) {
6576
7084
  throw new Error(
6577
- `No manifest at ${path19.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
7085
+ `No manifest at ${path20.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
6578
7086
  );
6579
7087
  }
6580
- const manifestDir = path19.dirname(path19.resolve(args.manifest));
7088
+ const manifestDir = path20.dirname(path20.resolve(args.manifest));
6581
7089
  const envFiles = [...loadEnvFiles(manifestDir)];
6582
- if (path19.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
7090
+ if (path20.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
6583
7091
  const loaded = await loadManifest(args.manifest);
6584
7092
  const estimator = createProvider(loaded.manifest.provider, "offline");
6585
7093
  const specs = await resolveSpecs(loaded, {
@@ -6714,7 +7222,7 @@ async function main() {
6714
7222
  if (args.primaryOnly && args.outputRoles.length) {
6715
7223
  throw new Error("pack accepts either --primary-only or --output-role, not both");
6716
7224
  }
6717
- const manifestDir2 = path19.dirname(path19.resolve(args.manifest));
7225
+ const manifestDir2 = path20.dirname(path20.resolve(args.manifest));
6718
7226
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
6719
7227
  for (const styleId of styleIds) {
6720
7228
  const { png, atlas, skipped, sources } = packStyle(lock, styleId, manifestDir2, {
@@ -6723,7 +7231,7 @@ async function main() {
6723
7231
  primaryOnly: args.primaryOnly
6724
7232
  });
6725
7233
  const style = loaded.manifest.styles[styleId];
6726
- const base = args.out ? path19.resolve(args.out.replace(/\.png$/, "")) : path19.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
7234
+ const base = args.out ? path20.resolve(args.out.replace(/\.png$/, "")) : path20.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
6727
7235
  const outputs = [
6728
7236
  { path: `${base}.png`, data: png },
6729
7237
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -6746,13 +7254,13 @@ async function main() {
6746
7254
  log(
6747
7255
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
6748
7256
  );
6749
- log(` ${path19.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
7257
+ log(` ${path20.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
6750
7258
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
6751
7259
  }
6752
7260
  return;
6753
7261
  }
6754
7262
  if (args.command === "mount") {
6755
- const manifestDir2 = path19.dirname(path19.resolve(args.manifest));
7263
+ const manifestDir2 = path20.dirname(path20.resolve(args.manifest));
6756
7264
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
6757
7265
  for (const styleId of styleIds) {
6758
7266
  const style = loaded.manifest.styles[styleId];
@@ -6782,7 +7290,7 @@ async function main() {
6782
7290
  sources,
6783
7291
  outputRoles
6784
7292
  );
6785
- const out = path19.resolve(manifestDir2, style.mount.out);
7293
+ const out = path20.resolve(manifestDir2, style.mount.out);
6786
7294
  const metadata = out.replace(/\.png$/, "") + ".json";
6787
7295
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
6788
7296
  const outputs = [
@@ -6795,7 +7303,7 @@ async function main() {
6795
7303
  await provenanceFile("$manifest", args.manifest),
6796
7304
  await provenanceFile("$lock", args.lock),
6797
7305
  ...artifactSources.filter(
6798
- (source) => source.id !== "$base" || path19.resolve(source.path) !== out
7306
+ (source) => source.id !== "$base" || path20.resolve(source.path) !== out
6799
7307
  )
6800
7308
  ],
6801
7309
  options: {
@@ -6808,7 +7316,7 @@ async function main() {
6808
7316
  log(
6809
7317
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
6810
7318
  );
6811
- log(` ${path19.relative(process.cwd(), out)} + atlas/provenance JSON`);
7319
+ log(` ${path20.relative(process.cwd(), out)} + atlas/provenance JSON`);
6812
7320
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
6813
7321
  }
6814
7322
  return;
@@ -6864,7 +7372,7 @@ async function main() {
6864
7372
  }
6865
7373
  if (args.command === "export") {
6866
7374
  const format = args.format ?? "generic";
6867
- const manifestDir2 = path19.dirname(path19.resolve(args.manifest));
7375
+ const manifestDir2 = path20.dirname(path20.resolve(args.manifest));
6868
7376
  const selected = specs.filter((spec) => {
6869
7377
  if (spec.generator !== "tiles") return false;
6870
7378
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -6880,12 +7388,12 @@ async function main() {
6880
7388
  for (const spec of selected) {
6881
7389
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
6882
7390
  const style = loaded.manifest.styles[spec.styleId];
6883
- const defaultBase = path19.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
6884
- const base = args.out ? path19.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
7391
+ const defaultBase = path20.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
7392
+ const base = args.out ? path20.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
6885
7393
  const result = exportTileset(entry, spec, {
6886
7394
  format,
6887
7395
  manifestDir: manifestDir2,
6888
- imageName: path19.basename(`${base}.png`),
7396
+ imageName: path20.basename(`${base}.png`),
6889
7397
  columns: args.columns
6890
7398
  });
6891
7399
  const outputs = [
@@ -6903,7 +7411,7 @@ async function main() {
6903
7411
  asset: spec.assetId,
6904
7412
  columns: args.columns ?? null,
6905
7413
  format,
6906
- image: path19.basename(`${base}.png`),
7414
+ image: path20.basename(`${base}.png`),
6907
7415
  providerRules: result.generic.providerRules,
6908
7416
  style: spec.styleId,
6909
7417
  tileType: spec.tileType ?? null
@@ -6913,7 +7421,7 @@ async function main() {
6913
7421
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
6914
7422
  );
6915
7423
  log(
6916
- ` ${path19.relative(process.cwd(), base)}.png + ${path19.basename(base)}${result.extension} + .pixelkiln.json`
7424
+ ` ${path20.relative(process.cwd(), base)}.png + ${path20.basename(base)}${result.extension} + .pixelkiln.json`
6917
7425
  );
6918
7426
  }
6919
7427
  return;
@@ -6953,10 +7461,10 @@ async function main() {
6953
7461
  tagged ${n} object(s) upstream`);
6954
7462
  }
6955
7463
  if (args.writePrompts) {
6956
- const { filled, stillEmpty } = await writePromptsBack(path19.resolve(args.manifest), lock, {
7464
+ const { filled, stillEmpty } = await writePromptsBack(path20.resolve(args.manifest), lock, {
6957
7465
  onProgress: log
6958
7466
  });
6959
- log(` recovered ${filled} prompt(s) into ${path19.relative(process.cwd(), args.manifest)}`);
7467
+ log(` recovered ${filled} prompt(s) into ${path20.relative(process.cwd(), args.manifest)}`);
6960
7468
  const reloaded = await loadManifest(args.manifest);
6961
7469
  const rebased = await resolveSpecs(reloaded, {
6962
7470
  styles: args.styles,
@@ -6985,12 +7493,12 @@ async function main() {
6985
7493
  if (args.command === "salvage") {
6986
7494
  const jsonMode = args.dryRun && args.json;
6987
7495
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
6988
- const ownLock = path19.resolve(args.lock);
7496
+ const ownLock = path20.resolve(args.lock);
6989
7497
  let workspaceProjects = [];
6990
7498
  let workspaceDir = "";
6991
7499
  if (args.workspace) {
6992
- const workspacePath = path19.resolve(args.workspace);
6993
- workspaceDir = path19.dirname(workspacePath);
7500
+ const workspacePath = path20.resolve(args.workspace);
7501
+ workspaceDir = path20.dirname(workspacePath);
6994
7502
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
6995
7503
  workspaceProjects = complete.ws.projects;
6996
7504
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -7000,11 +7508,11 @@ async function main() {
7000
7508
  .../* @__PURE__ */ new Set([
7001
7509
  ...workspaceLockPaths,
7002
7510
  ...existsSync15(ownLock) ? [ownLock] : [],
7003
- ...args.claims.map((c) => path19.resolve(c))
7511
+ ...args.claims.map((c) => path20.resolve(c))
7004
7512
  ])
7005
7513
  ];
7006
7514
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
7007
- for (const p of lockPaths) diag(` ${path19.relative(process.cwd(), p)}`);
7515
+ for (const p of lockPaths) diag(` ${path20.relative(process.cwd(), p)}`);
7008
7516
  if (!args.claims.length && !args.workspace) {
7009
7517
  diag(
7010
7518
  `
@@ -7012,7 +7520,7 @@ async function main() {
7012
7520
  pass every other project's lockfile via --claims a.json,b.json, or
7013
7521
  register every project in a workspace catalog and pass --workspace.`
7014
7522
  );
7015
- } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path19.resolve(args.manifest))) {
7523
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path20.resolve(args.manifest))) {
7016
7524
  diag(
7017
7525
  `
7018
7526
  This project's manifest is not registered in the workspace catalog. Its own
@@ -7096,7 +7604,7 @@ async function main() {
7096
7604
  manifestPath: loaded.path,
7097
7605
  manifest: loaded.manifest,
7098
7606
  styleId,
7099
- importDir: path19.resolve(loaded.root, style.outDir),
7607
+ importDir: path20.resolve(loaded.root, style.outDir),
7100
7608
  lock,
7101
7609
  lockPath: args.lock
7102
7610
  },