pixelkiln 0.2.0 → 0.4.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 path16 from "path";
5
- import { existsSync as existsSync14 } from "fs";
6
- import { readFile as readFile13 } from "fs/promises";
4
+ import path18 from "path";
5
+ import { existsSync as existsSync15 } from "fs";
6
+ import { readFile as readFile14 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
9
9
  import { readFileSync, existsSync } from "fs";
@@ -38,6 +38,71 @@ function applyEnv(contents) {
38
38
  }
39
39
  }
40
40
 
41
+ // src/provider.ts
42
+ function validateCostEstimate(providerId, value) {
43
+ if (!value || typeof value !== "object") {
44
+ throw new Error(`Provider "${providerId}" returned an invalid cost estimate`);
45
+ }
46
+ const estimate = value;
47
+ if (typeof estimate.unit !== "string" || !estimate.unit.trim()) {
48
+ throw new Error(`Provider "${providerId}" returned an invalid cost unit`);
49
+ }
50
+ if (!Number.isFinite(estimate.amount) || estimate.amount < 0) {
51
+ throw new Error(`Provider "${providerId}" returned an invalid cost amount`);
52
+ }
53
+ if (estimate.unit === "free" && estimate.amount !== 0) {
54
+ throw new Error(`Provider "${providerId}" returned a nonzero amount with the free cost unit`);
55
+ }
56
+ if (!Number.isInteger(estimate.candidates) || estimate.candidates < 1) {
57
+ throw new Error(`Provider "${providerId}" returned an invalid candidate count`);
58
+ }
59
+ return estimate;
60
+ }
61
+ function measureBalanceChange(before, after) {
62
+ if (before.unit !== after.unit || !Number.isFinite(before.remaining) || !Number.isFinite(after.remaining)) return null;
63
+ const delta = before.remaining - after.remaining;
64
+ return {
65
+ unit: before.unit,
66
+ before: before.remaining,
67
+ after: after.remaining,
68
+ spent: Math.max(0, delta),
69
+ credited: Math.max(0, -delta)
70
+ };
71
+ }
72
+ var DEFAULT_RATE_LIMIT = { spacingMs: 2500, maxInFlight: 8 };
73
+ var UnsupportedCapabilityError = class extends Error {
74
+ constructor(providerId, capability) {
75
+ super(
76
+ `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`
77
+ );
78
+ this.name = "UnsupportedCapabilityError";
79
+ }
80
+ };
81
+ function requireList(provider) {
82
+ if (!provider.list) throw new UnsupportedCapabilityError(provider.id, "listing remote assets");
83
+ return provider.list.bind(provider);
84
+ }
85
+ function requireDelete(provider) {
86
+ if (!provider.delete) throw new UnsupportedCapabilityError(provider.id, "deleting remote assets");
87
+ return provider.delete.bind(provider);
88
+ }
89
+ function requireSelectCandidate(provider) {
90
+ if (!provider.selectCandidate) {
91
+ throw new UnsupportedCapabilityError(provider.id, "candidate selection");
92
+ }
93
+ return provider.selectCandidate.bind(provider);
94
+ }
95
+ function requireBalance(provider) {
96
+ if (!provider.balance) throw new UnsupportedCapabilityError(provider.id, "account balance");
97
+ return provider.balance.bind(provider);
98
+ }
99
+ function formatCost(unit, amount) {
100
+ if (unit === "free") return "free";
101
+ if (unit === "usd") return `$${amount.toFixed(2)}`;
102
+ if (unit === "generations") return `${amount} generation${amount === 1 ? "" : "s"}`;
103
+ return `${amount} ${unit}`;
104
+ }
105
+
41
106
  // src/providers/pixellab.ts
42
107
  import { mkdirSync, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
43
108
  import { randomUUID } from "crypto";
@@ -154,11 +219,11 @@ var PixelLabClient = class {
154
219
  * common than the failure mode of retrying (a duplicate object), and a
155
220
  * duplicate is visible and free to delete whereas a silent gap is neither.
156
221
  */
157
- async request(path17, init, attempt = 0) {
222
+ async request(path19, init, attempt = 0) {
158
223
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
159
224
  let res;
160
225
  try {
161
- res = await fetch(`${BASE}${path17}`, {
226
+ res = await fetch(`${BASE}${path19}`, {
162
227
  ...init,
163
228
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
164
229
  headers: {
@@ -170,24 +235,24 @@ var PixelLabClient = class {
170
235
  } catch (err) {
171
236
  if (attempt < MAX_RETRIES) {
172
237
  await sleep(backoffMs(attempt));
173
- return this.request(path17, init, attempt + 1);
238
+ return this.request(path19, init, attempt + 1);
174
239
  }
175
240
  throw err;
176
241
  }
177
242
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
178
243
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
179
244
  await sleep(waitMs);
180
- return this.request(path17, init, attempt + 1);
245
+ return this.request(path19, init, attempt + 1);
181
246
  }
182
247
  const text = await res.text();
183
248
  if (!res.ok) {
184
- throw new PixelLabError(`${init?.method ?? "GET"} ${path17} \u2192 ${res.status}`, res.status, text);
249
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path19} \u2192 ${res.status}`, res.status, text);
185
250
  }
186
251
  if (!text) return {};
187
252
  try {
188
253
  return JSON.parse(text);
189
254
  } catch {
190
- throw new Error(`${init?.method ?? "GET"} ${path17} returned invalid JSON`);
255
+ throw new Error(`${init?.method ?? "GET"} ${path19} returned invalid JSON`);
191
256
  }
192
257
  }
193
258
  async balance() {
@@ -748,7 +813,8 @@ function parseHex(hex2) {
748
813
 
749
814
  // src/types.ts
750
815
  import { z as z2 } from "zod";
751
- var GeneratorSchema = z2.enum(["1dir", "map", "pixflux", "tiles"]);
816
+ var MediaTypeSchema = z2.enum(["image/png", "image/gif"]);
817
+ var GeneratorSchema = z2.enum(["1dir", "map", "pixflux", "tiles", "animation"]);
752
818
  function tileVariationCount(descriptions) {
753
819
  return Math.max(1, descriptions) * 4;
754
820
  }
@@ -780,7 +846,7 @@ function tilesCost(tileSize, variations) {
780
846
  return 40;
781
847
  }
782
848
  var StyleImageSchema = z2.object({
783
- /** Path to a PNG/JPEG, relative to the manifest file. Max 256x256. */
849
+ /** Path to a PNG/JPEG, relative to the manifest; the active provider validates limits. */
784
850
  path: z2.string()
785
851
  });
786
852
  var StyleSchema = z2.object({
@@ -891,7 +957,9 @@ var StyleSchema = z2.object({
891
957
  out: z2.string()
892
958
  }).strict().optional(),
893
959
  /** Tags applied to every object generated in this style, for server-side filtering. */
894
- tags: z2.array(z2.string()).default([])
960
+ tags: z2.array(z2.string()).default([]),
961
+ /** Adapter-owned settings, keyed by provider id. */
962
+ providerOptions: z2.record(z2.record(z2.unknown())).default({})
895
963
  }).strict().refine((s) => !(s.tileFeature && s.styleImages.length), {
896
964
  message: "tileFeature and styleImages cannot be combined \u2014 a connectable set derives its own tile geometry, so remove one or the other",
897
965
  path: ["tileFeature"]
@@ -906,7 +974,7 @@ var AssetSchema = z2.object({
906
974
  height: z2.number().int().min(16).max(400).optional(),
907
975
  /** Overrides the style default. `1dir` generator only. */
908
976
  size: z2.number().int().min(32).max(256).optional(),
909
- /** Explicit output path relative to outDir. Defaults to `<category>/<id>.png`. */
977
+ /** Explicit output path relative to outDir. Media-aware providers may replace its extension. */
910
978
  file: z2.string().optional(),
911
979
  /**
912
980
  * Grid cell this asset owns in a mounted style, as [column, row].
@@ -958,6 +1026,8 @@ var AssetSchema = z2.object({
958
1026
  var ManifestSchema = z2.object({
959
1027
  $schema: z2.string().optional(),
960
1028
  name: z2.string(),
1029
+ /** Generation backend. Existing manifests remain PixelLab by default. */
1030
+ provider: z2.string().min(1).default("pixellab"),
961
1031
  styles: z2.record(StyleSchema),
962
1032
  assets: z2.record(AssetSchema)
963
1033
  }).strict();
@@ -995,7 +1065,8 @@ var LockEntrySchema = z2.object({
995
1065
  sourceUrls: z2.array(
996
1066
  z2.object({
997
1067
  url: z2.string(),
998
- role: z2.string().optional()
1068
+ role: z2.string().optional(),
1069
+ mediaType: MediaTypeSchema.optional()
999
1070
  })
1000
1071
  ).default([]),
1001
1072
  /**
@@ -1010,7 +1081,8 @@ var LockEntrySchema = z2.object({
1010
1081
  z2.object({
1011
1082
  path: z2.string(),
1012
1083
  sha256: z2.string(),
1013
- role: z2.string().optional()
1084
+ role: z2.string().optional(),
1085
+ mediaType: MediaTypeSchema.optional()
1014
1086
  })
1015
1087
  ).default([]),
1016
1088
  /**
@@ -1024,7 +1096,7 @@ var LockEntrySchema = z2.object({
1024
1096
  /** Successful-submission estimate in `costUnit`; may be fractional USD. */
1025
1097
  cost: z2.number().finite().nonnegative().default(0),
1026
1098
  /** Unit for `cost`. Defaults preserve pre-unit PixelLab lockfiles. */
1027
- costUnit: z2.enum(["generations", "usd", "free"]).default("generations"),
1099
+ costUnit: z2.string().min(1).default("generations"),
1028
1100
  /** Which provider produced this. Absent on entries written before providers. */
1029
1101
  provider: z2.string().default("pixellab")
1030
1102
  });
@@ -1094,7 +1166,44 @@ var PixelLabProvider = class _PixelLabProvider {
1094
1166
  candidates: spec.generator === "1dir" ? candidateCount(spec.size) : 1
1095
1167
  };
1096
1168
  }
1169
+ validate(spec, styleImages) {
1170
+ if (spec.generator === "map") {
1171
+ requirePixelLabOption("view", spec.view, ["low top-down", "high top-down", "side"]);
1172
+ requirePixelLabOption("outline", spec.outline, [
1173
+ "single color outline",
1174
+ "selective outline",
1175
+ "lineless"
1176
+ ]);
1177
+ requirePixelLabOption("shading", spec.shading, [
1178
+ "flat shading",
1179
+ "basic shading",
1180
+ "medium shading",
1181
+ "detailed shading"
1182
+ ]);
1183
+ requirePixelLabOption("detail", spec.detail, [
1184
+ "low detail",
1185
+ "medium detail",
1186
+ "high detail"
1187
+ ]);
1188
+ }
1189
+ if ((spec.generator === "map" || spec.generator === "pixflux") && styleImages.length) {
1190
+ throw new Error(`PixelLab ${spec.generator} does not support style images`);
1191
+ }
1192
+ for (const image of styleImages) {
1193
+ if (image.width > 256 || image.height > 256) {
1194
+ throw new Error(
1195
+ `Style image exceeds PixelLab's 256x256 limit (${image.width}x${image.height})`
1196
+ );
1197
+ }
1198
+ }
1199
+ if (spec.tags.length > 20) {
1200
+ throw new Error(
1201
+ `${spec.styleId}/${spec.assetId} resolves to ${spec.tags.length} tags, but PixelLab allows at most 20`
1202
+ );
1203
+ }
1204
+ }
1097
1205
  async submit(spec, styleImages) {
1206
+ this.validate(spec, styleImages);
1098
1207
  if (spec.generator === "pixflux") {
1099
1208
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
1100
1209
  const { png } = await this.client.createImagePixflux({
@@ -1280,6 +1389,11 @@ var PixelLabProvider = class _PixelLabProvider {
1280
1389
  await this.client.deleteObject(assetId);
1281
1390
  }
1282
1391
  };
1392
+ function requirePixelLabOption(name, value, allowed) {
1393
+ if (value != null && !allowed.includes(value)) {
1394
+ throw new Error(`PixelLab map ${name} must be one of: ${allowed.join(", ")}`);
1395
+ }
1396
+ }
1283
1397
  function tilesInIndexOrder(urls) {
1284
1398
  return Object.entries(urls).map(([key, url]) => ({ index: Number(key.replace(/^tile_/, "")), url })).filter((tile) => Number.isFinite(tile.index)).sort((a, b) => a.index - b.index);
1285
1399
  }
@@ -1288,60 +1402,442 @@ function firstUrl(urls) {
1288
1402
  return Object.values(urls).find((u) => typeof u === "string") ?? null;
1289
1403
  }
1290
1404
 
1291
- // src/provider.ts
1292
- function validateCostEstimate(providerId, value) {
1293
- if (!value || typeof value !== "object") {
1294
- throw new Error(`Provider "${providerId}" returned an invalid cost estimate`);
1405
+ // src/media.ts
1406
+ var MediaType = {
1407
+ PNG: "image/png",
1408
+ GIF: "image/gif"
1409
+ };
1410
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
1411
+ function mediaExtension(mediaType) {
1412
+ return mediaType === MediaType.GIF ? ".gif" : ".png";
1413
+ }
1414
+ function mediaTypeFromExtension(file) {
1415
+ const lower = file.toLowerCase();
1416
+ if (lower.endsWith(".png")) return MediaType.PNG;
1417
+ if (lower.endsWith(".gif")) return MediaType.GIF;
1418
+ return null;
1419
+ }
1420
+ function detectMediaType(bytes) {
1421
+ if (bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return MediaType.PNG;
1422
+ const header = bytes.subarray(0, 6).toString("ascii");
1423
+ if (header === "GIF87a" || header === "GIF89a") return MediaType.GIF;
1424
+ return null;
1425
+ }
1426
+ function validateMedia(bytes, expected) {
1427
+ const actual = detectMediaType(bytes);
1428
+ if (!actual) throw new Error(`response was not a supported PNG or GIF (${bytes.length} bytes)`);
1429
+ if (expected && actual !== expected) {
1430
+ throw new Error(`response was ${actual}, expected ${expected}`);
1295
1431
  }
1296
- const estimate = value;
1297
- if (!["generations", "usd", "free"].includes(estimate.unit)) {
1298
- throw new Error(`Provider "${providerId}" returned an invalid cost unit`);
1432
+ if (actual === MediaType.PNG) {
1433
+ decodePng(bytes);
1434
+ } else {
1435
+ validateGif(bytes);
1436
+ }
1437
+ return actual;
1438
+ }
1439
+ function validateGif(bytes) {
1440
+ if (bytes.length < 14) throw new Error("invalid GIF: truncated logical screen descriptor");
1441
+ const width = bytes.readUInt16LE(6);
1442
+ const height = bytes.readUInt16LE(8);
1443
+ if (!width || !height) throw new Error("invalid GIF: zero-sized logical screen");
1444
+ const packed = bytes[10];
1445
+ let offset = 13;
1446
+ if (packed & 128) offset += 3 * 2 ** ((packed & 7) + 1);
1447
+ if (offset > bytes.length) throw new Error("invalid GIF: truncated global color table");
1448
+ let sawImage = false;
1449
+ while (offset < bytes.length) {
1450
+ const marker = bytes[offset];
1451
+ if (marker === 59) {
1452
+ if (!sawImage) throw new Error("invalid GIF: contains no image frame");
1453
+ return;
1454
+ }
1455
+ if (marker === 44) {
1456
+ if (offset + 10 > bytes.length) throw new Error("invalid GIF: truncated image descriptor");
1457
+ const imagePacked = bytes[offset + 9];
1458
+ offset += 10;
1459
+ if (imagePacked & 128) offset += 3 * 2 ** ((imagePacked & 7) + 1);
1460
+ if (offset >= bytes.length) throw new Error("invalid GIF: missing image data");
1461
+ offset++;
1462
+ offset = skipSubBlocks(bytes, offset);
1463
+ sawImage = true;
1464
+ continue;
1465
+ }
1466
+ if (marker === 33) {
1467
+ if (offset + 2 > bytes.length) throw new Error("invalid GIF: truncated extension");
1468
+ offset = skipSubBlocks(bytes, offset + 2);
1469
+ continue;
1470
+ }
1471
+ throw new Error(`invalid GIF: unexpected block marker 0x${marker.toString(16)}`);
1299
1472
  }
1300
- if (!Number.isFinite(estimate.amount) || estimate.amount < 0) {
1301
- throw new Error(`Provider "${providerId}" returned an invalid cost amount`);
1473
+ throw new Error("invalid GIF: missing trailer");
1474
+ }
1475
+ function skipSubBlocks(bytes, start) {
1476
+ let offset = start;
1477
+ for (; ; ) {
1478
+ if (offset >= bytes.length) throw new Error("invalid GIF: truncated data blocks");
1479
+ const size = bytes[offset];
1480
+ offset++;
1481
+ if (size === 0) return offset;
1482
+ offset += size;
1483
+ if (offset > bytes.length) throw new Error("invalid GIF: truncated data block");
1302
1484
  }
1303
- if (estimate.unit === "free" && estimate.amount !== 0) {
1304
- throw new Error(`Provider "${providerId}" returned a nonzero amount with the free cost unit`);
1485
+ }
1486
+ function cacheFileName(hash, mediaType = MediaType.PNG) {
1487
+ return `${hash}${mediaExtension(mediaType)}`;
1488
+ }
1489
+
1490
+ // src/providers/retrodiffusion.ts
1491
+ var DEFAULT_BASE_URL = "https://api.retrodiffusion.ai/v1";
1492
+ var RetroDiffusionClient = class {
1493
+ constructor(token, baseUrl = DEFAULT_BASE_URL, request = fetch) {
1494
+ this.token = token;
1495
+ this.baseUrl = baseUrl;
1496
+ this.request = request;
1497
+ }
1498
+ token;
1499
+ baseUrl;
1500
+ request;
1501
+ async submit(body) {
1502
+ const response = await this.call("/inferences", { method: "POST", body: JSON.stringify(body) });
1503
+ const taskId = response.task_id;
1504
+ if (typeof taskId !== "string" || !taskId) {
1505
+ throw new Error("Retro Diffusion did not return an async task id");
1506
+ }
1507
+ return taskId;
1508
+ }
1509
+ async quote(body) {
1510
+ const response = await this.call("/inferences", {
1511
+ method: "POST",
1512
+ body: JSON.stringify({ ...body, check_cost: true })
1513
+ });
1514
+ const amount = response.balance_cost;
1515
+ if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) {
1516
+ throw new Error("Retro Diffusion returned an invalid cost quote");
1517
+ }
1518
+ return amount;
1305
1519
  }
1306
- if (!Number.isInteger(estimate.candidates) || estimate.candidates < 1) {
1307
- throw new Error(`Provider "${providerId}" returned an invalid candidate count`);
1520
+ async task(id) {
1521
+ return await this.call(`/inferences/tasks/${encodeURIComponent(id)}`);
1308
1522
  }
1309
- return estimate;
1523
+ async balance() {
1524
+ const response = await this.call("/inferences/credits");
1525
+ const balance = response.balance;
1526
+ if (typeof balance !== "number" || !Number.isFinite(balance)) {
1527
+ throw new Error("Retro Diffusion returned an invalid balance");
1528
+ }
1529
+ return balance;
1530
+ }
1531
+ async call(path19, init = {}) {
1532
+ if (!this.token) throw new Error("RD_API_KEY is not set");
1533
+ const response = await this.request(`${this.baseUrl}${path19}`, {
1534
+ ...init,
1535
+ headers: {
1536
+ "Content-Type": "application/json",
1537
+ "X-RD-Token": this.token,
1538
+ ...init.headers
1539
+ }
1540
+ });
1541
+ const text = await response.text();
1542
+ let value = null;
1543
+ try {
1544
+ value = text ? JSON.parse(text) : null;
1545
+ } catch {
1546
+ value = text;
1547
+ }
1548
+ if (!response.ok) {
1549
+ const retry = response.headers.get("retry-after");
1550
+ const detail = retroError(value);
1551
+ throw new Error(
1552
+ `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
1553
+ );
1554
+ }
1555
+ return value;
1556
+ }
1557
+ };
1558
+ var RetroDiffusionProvider = class _RetroDiffusionProvider {
1559
+ constructor(client) {
1560
+ this.client = client;
1561
+ }
1562
+ client;
1563
+ id = "retrodiffusion";
1564
+ static fromEnv() {
1565
+ return new _RetroDiffusionProvider(new RetroDiffusionClient(process.env.RD_API_KEY));
1566
+ }
1567
+ static forOffline() {
1568
+ return new _RetroDiffusionProvider(new RetroDiffusionClient(void 0));
1569
+ }
1570
+ static forDownloads() {
1571
+ return _RetroDiffusionProvider.forOffline();
1572
+ }
1573
+ supports(generator) {
1574
+ return generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "animation";
1575
+ }
1576
+ estimate(spec) {
1577
+ const options = retroOptions(spec);
1578
+ const style = resolvedPromptStyle(spec, options);
1579
+ const count = options.numImages ?? 1;
1580
+ const pixels = spec.width * spec.height;
1581
+ let each;
1582
+ if (style.startsWith("rd_advanced_animation__")) {
1583
+ each = /__(?:custom_action|subtle_motion)$/.test(style) ? 0.25 : 0.14;
1584
+ } else if (style.startsWith("rd_animation__")) {
1585
+ each = /__(?:any_animation|8_dir_rotation)$/.test(style) ? 0.25 : 0.07;
1586
+ } else if (/^rd_tile__tileset(?:_advanced)?$/.test(style)) {
1587
+ each = 0.1;
1588
+ } else if (style.startsWith("rd_pro__")) {
1589
+ each = 0.18;
1590
+ } else if (style.startsWith("rd_fast__")) {
1591
+ each = Math.max(0.015, (pixels + 1e5) / 6e6);
1592
+ } else if (isLowResolutionStyle(style)) {
1593
+ each = Math.max(0.02, (pixels + 13700) / 6e5);
1594
+ } else {
1595
+ each = Math.max(0.025, (pixels + 5e4) / 2e6);
1596
+ }
1597
+ return { unit: "usd", amount: roundUsdEstimate(each * count), candidates: count };
1598
+ }
1599
+ validate(spec, styleImages) {
1600
+ const options = retroOptions(spec);
1601
+ const promptStyle = resolvedPromptStyle(spec, options);
1602
+ const count = options.numImages ?? 1;
1603
+ const isAnimation = /^(?:rd_animation__|rd_advanced_animation__)/.test(promptStyle);
1604
+ const isTile = promptStyle.startsWith("rd_tile__");
1605
+ if (!promptStyle || spec.generator === "animation" && !isAnimation || spec.generator === "tiles" && !isTile || spec.generator !== "animation" && spec.generator !== "tiles" && (isAnimation || isTile)) {
1606
+ throw new Error(
1607
+ `Retro Diffusion style "${promptStyle}" does not match generator "${spec.generator}"`
1608
+ );
1609
+ }
1610
+ if (!Number.isInteger(count) || count < 1 || count > 16) {
1611
+ throw new Error("Retro Diffusion numImages must be a whole number from 1 to 16");
1612
+ }
1613
+ if (spec.width < 16 || spec.height < 16 || spec.width > 512 || spec.height > 512) {
1614
+ throw new Error("Retro Diffusion output dimensions must be between 16 and 512 pixels");
1615
+ }
1616
+ if (styleImages.length > 9) {
1617
+ throw new Error("Retro Diffusion accepts at most 9 reference images");
1618
+ }
1619
+ if (spec.generator === "animation") {
1620
+ if (count !== 1) throw new Error("Retro Diffusion animations currently require numImages: 1");
1621
+ validateAnimation(promptStyle, spec, styleImages, options);
1622
+ } else if (spec.generator === "tiles") {
1623
+ validateTile(promptStyle, spec, styleImages, count, options);
1624
+ } else if (styleImages.length && !/^(?:rd_pro__|user__)/.test(promptStyle)) {
1625
+ throw new Error(
1626
+ `Retro Diffusion style "${promptStyle}" does not accept reference_images; use an RD Pro or user style`
1627
+ );
1628
+ }
1629
+ }
1630
+ async submit(spec, styleImages) {
1631
+ this.validate(spec, styleImages);
1632
+ const options = retroOptions(spec);
1633
+ const promptStyle = resolvedPromptStyle(spec, options);
1634
+ const animation = spec.generator === "animation";
1635
+ const tiles = spec.generator === "tiles";
1636
+ const body = {
1637
+ prompt: spec.prompt,
1638
+ prompt_style: promptStyle,
1639
+ width: spec.width,
1640
+ height: spec.height,
1641
+ num_images: options.numImages ?? 1,
1642
+ ...!animation && !tiles ? { remove_bg: options.removeBg ?? spec.noBackground } : {},
1643
+ ...spec.seed != null ? { seed: spec.seed } : {},
1644
+ ...animation || tiles ? styleImages[0] ? { input_image: styleImages[0].base64 } : {} : styleImages.length ? { reference_images: styleImages.map((image) => image.base64) } : {},
1645
+ ...tiles && styleImages[1] ? { extra_input_image: styleImages[1].base64 } : {},
1646
+ ...tiles && options.extraPrompt ? { extra_prompt: options.extraPrompt } : {},
1647
+ ...animation && options.framesDuration ? { frames_duration: options.framesDuration } : {},
1648
+ ...animation && options.returnSpritesheet ? { return_spritesheet: true } : {},
1649
+ ...!animation && options.tileX != null ? { tile_x: options.tileX } : {},
1650
+ ...!animation && options.tileY != null ? { tile_y: options.tileY } : {},
1651
+ ...spec.palette.length ? { input_palette: paletteSwatch(spec.palette).toString("base64") } : {}
1652
+ };
1653
+ const quoted = await this.client.quote(body);
1654
+ const estimated = this.estimate(spec).amount;
1655
+ if (quoted > estimated + 1e-6) {
1656
+ throw new Error(
1657
+ `Retro Diffusion quoted $${quoted.toFixed(6)}, above the offline estimate $${estimated.toFixed(6)}; no paid request was sent`
1658
+ );
1659
+ }
1660
+ return {
1661
+ jobId: await this.client.submit({ ...body, async: true, upload_outputs: true })
1662
+ };
1663
+ }
1664
+ async poll(jobId, generator, context) {
1665
+ const task = await this.client.task(jobId);
1666
+ if (task.status === "pending" || task.status === "running") return { status: "processing" };
1667
+ if (task.status === "failed") return { status: "failed", error: retroError(task.error) };
1668
+ if (task.status !== "succeeded") {
1669
+ return { status: "failed", error: `Retro Diffusion returned unknown task status "${String(task.status)}"` };
1670
+ }
1671
+ const options = context?.spec ? retroOptions(context.spec) : {};
1672
+ const mediaType = generator === "animation" && !options.returnSpritesheet ? MediaType.GIF : MediaType.PNG;
1673
+ const sources = resultSources(task.result, mediaType);
1674
+ const urls = sources.map((source) => source.url);
1675
+ if (!urls.length) return { status: "failed", error: "Retro Diffusion task returned no images" };
1676
+ if (urls.length > 1) return { status: "review", candidateUrls: urls };
1677
+ return {
1678
+ status: "ready",
1679
+ objectId: `${jobId}#0`,
1680
+ sourceUrl: urls[0],
1681
+ sources,
1682
+ metadata: {
1683
+ balanceCost: task.result?.balance_cost ?? null,
1684
+ remainingBalance: task.result?.remaining_balance ?? null,
1685
+ mediaType,
1686
+ kind: generator === "animation" ? "animation" : generator === "tiles" ? "tileset" : "image",
1687
+ ...context?.spec ? {
1688
+ promptStyle: resolvedPromptStyle(context.spec, options),
1689
+ width: context.spec.width,
1690
+ height: context.spec.height
1691
+ } : {}
1692
+ }
1693
+ };
1694
+ }
1695
+ async selectCandidate(jobId, index) {
1696
+ const task = await this.client.task(jobId);
1697
+ if (task.status !== "succeeded") throw new Error(`Retro Diffusion task ${jobId} is not ready`);
1698
+ const url = resultSources(task.result, MediaType.PNG)[index]?.url;
1699
+ if (!url) throw new Error(`Retro Diffusion task ${jobId} has no candidate at index ${index}`);
1700
+ return { objectId: `${jobId}#${index}`, sourceUrl: url };
1701
+ }
1702
+ async download(url) {
1703
+ const data = /^data:[^;]+;base64,(.+)$/.exec(url)?.[1];
1704
+ if (data) return Buffer.from(data, "base64");
1705
+ const response = await fetch(url);
1706
+ if (!response.ok) throw new Error(`Retro Diffusion download failed (${response.status})`);
1707
+ return Buffer.from(await response.arrayBuffer());
1708
+ }
1709
+ async balance() {
1710
+ return { unit: "usd", remaining: await this.client.balance() };
1711
+ }
1712
+ };
1713
+ function retroOptions(spec) {
1714
+ return spec.providerOptions;
1715
+ }
1716
+ function resultSources(result, mediaType) {
1717
+ const hosted = (result?.output_urls ?? []).filter((url) => typeof url === "string" && url.length > 0).map((url) => ({ url, mediaType }));
1718
+ if (hosted.length) return hosted;
1719
+ return (result?.base64_images ?? []).filter((data) => typeof data === "string" && data.length > 0).map((data) => ({
1720
+ url: `data:${mediaType};base64,${data}`,
1721
+ mediaType
1722
+ }));
1310
1723
  }
1311
- function measureBalanceChange(before, after) {
1312
- if (before.unit !== after.unit || !Number.isFinite(before.remaining) || !Number.isFinite(after.remaining)) return null;
1313
- const delta = before.remaining - after.remaining;
1314
- return {
1315
- unit: before.unit,
1316
- before: before.remaining,
1317
- after: after.remaining,
1318
- spent: Math.max(0, delta),
1319
- credited: Math.max(0, -delta)
1320
- };
1724
+ function resolvedPromptStyle(spec, options) {
1725
+ return options.promptStyle ?? (spec.generator === "animation" ? "rd_animation__any_animation" : spec.generator === "tiles" ? "rd_tile__tileset" : "rd_plus__default");
1321
1726
  }
1322
- var DEFAULT_RATE_LIMIT = { spacingMs: 2500, maxInFlight: 8 };
1323
- var UnsupportedCapabilityError = class extends Error {
1324
- constructor(providerId, capability) {
1325
- super(
1326
- `Provider "${providerId}" does not support ${capability}. That command is unavailable with this backend.`
1327
- );
1328
- this.name = "UnsupportedCapabilityError";
1727
+ function validateAnimation(style, spec, styleImages, options) {
1728
+ if (spec.width !== spec.height) throw new Error("Retro Diffusion animations must be square");
1729
+ if (style.startsWith("rd_advanced_animation__")) {
1730
+ if (styleImages.length !== 1) {
1731
+ throw new Error(`Retro Diffusion advanced animation style "${style}" requires one input image`);
1732
+ }
1733
+ if (spec.width < 32 || spec.width > 256) {
1734
+ throw new Error("Retro Diffusion advanced animations require dimensions from 32 to 256 pixels");
1735
+ }
1736
+ } else if (styleImages.length > 1) {
1737
+ throw new Error("Retro Diffusion prompt animations accept at most one input image");
1738
+ }
1739
+ const exact = style.includes("four_angle_walking") ? 48 : style.endsWith("__small_sprites") ? 32 : style.endsWith("__any_animation") ? 64 : style.endsWith("__big_animation") ? 128 : style.endsWith("__8_dir_rotation") ? 80 : null;
1740
+ if (exact && spec.width !== exact) {
1741
+ throw new Error(`Retro Diffusion style "${style}" requires ${exact}x${exact} dimensions`);
1742
+ }
1743
+ if (style.endsWith("__vfx") && (spec.width < 24 || spec.width > 96)) {
1744
+ throw new Error("Retro Diffusion VFX animations require dimensions from 24 to 96 pixels");
1745
+ }
1746
+ if (options.framesDuration != null && ![4, 6, 8, 10, 12, 16].includes(options.framesDuration)) {
1747
+ throw new Error("Retro Diffusion framesDuration must be 4, 6, 8, 10, 12, or 16");
1329
1748
  }
1330
- };
1331
- function requireList(provider) {
1332
- if (!provider.list) throw new UnsupportedCapabilityError(provider.id, "listing remote assets");
1333
- return provider.list.bind(provider);
1334
1749
  }
1335
- function requireDelete(provider) {
1336
- if (!provider.delete) throw new UnsupportedCapabilityError(provider.id, "deleting remote assets");
1337
- return provider.delete.bind(provider);
1750
+ function validateTile(style, spec, styleImages, count, options) {
1751
+ if (spec.width !== spec.height) throw new Error("Retro Diffusion tiles must be square");
1752
+ const size = spec.width;
1753
+ if (/^rd_tile__tileset(?:_advanced)?$/.test(style)) {
1754
+ if (size < 16 || size > 32) throw new Error("Retro Diffusion tilesets require 16\u201332px tiles");
1755
+ if (count !== 1) throw new Error("Retro Diffusion tilesets require numImages: 1");
1756
+ } else if (style === "rd_tile__single_tile" && (size < 16 || size > 64)) {
1757
+ throw new Error("Retro Diffusion single tiles require dimensions from 16 to 64 pixels");
1758
+ } else if (style === "rd_tile__tile_variation") {
1759
+ if (size < 16 || size > 128) throw new Error("Retro Diffusion tile variations require 16\u2013128px tiles");
1760
+ if (styleImages.length !== 1) throw new Error("Retro Diffusion tile variations require one input image");
1761
+ } else if (style === "rd_tile__tile_object" && (size < 16 || size > 96)) {
1762
+ throw new Error("Retro Diffusion tile objects require dimensions from 16 to 96 pixels");
1763
+ } else if (style === "rd_tile__scene_object" && (size < 64 || size > 384)) {
1764
+ throw new Error("Retro Diffusion tile scene objects require dimensions from 64 to 384 pixels");
1765
+ }
1766
+ if (style === "rd_tile__tileset" && styleImages.length > 1) {
1767
+ throw new Error("Retro Diffusion basic tilesets accept at most one input image");
1768
+ }
1769
+ if (style === "rd_tile__tileset_advanced") {
1770
+ if (styleImages.length > 2) throw new Error("Retro Diffusion advanced tilesets accept at most two input images");
1771
+ if (!options.extraPrompt && styleImages.length < 2) {
1772
+ throw new Error("Retro Diffusion advanced tilesets require extraPrompt or a second input image");
1773
+ }
1774
+ }
1338
1775
  }
1339
- function formatCost(unit, amount) {
1340
- if (unit === "free") return "free";
1341
- if (unit === "usd") return `$${amount.toFixed(2)}`;
1342
- return `${amount} generation${amount === 1 ? "" : "s"}`;
1776
+ function isLowResolutionStyle(style) {
1777
+ return /(?:^|__)(?:mc_|low_res|classic|skill_icon|topdown_item)/.test(style);
1778
+ }
1779
+ function roundUsdEstimate(value) {
1780
+ return Math.ceil((value - 1e-9) * 1e3) / 1e3;
1781
+ }
1782
+ function retroError(value) {
1783
+ if (typeof value === "string") return value || "unknown error";
1784
+ if (!value || typeof value !== "object") return "unknown error";
1785
+ const record = value;
1786
+ if (typeof record.message === "string") return record.message;
1787
+ if (typeof record.detail === "string") return record.detail;
1788
+ if (Array.isArray(record.detail)) {
1789
+ const details = record.detail.map((item) => {
1790
+ if (!item || typeof item !== "object") return String(item);
1791
+ const detail = item;
1792
+ return typeof detail.msg === "string" ? detail.msg : JSON.stringify(item);
1793
+ }).filter(Boolean);
1794
+ if (details.length) return details.join("; ");
1795
+ }
1796
+ if (record.detail && typeof record.detail === "object") {
1797
+ const message2 = record.detail.message;
1798
+ if (typeof message2 === "string") return message2;
1799
+ }
1800
+ return JSON.stringify(value);
1343
1801
  }
1344
1802
 
1803
+ // src/providers/registry.ts
1804
+ var factories = /* @__PURE__ */ new Map();
1805
+ function registerProvider(factory) {
1806
+ const id = factory.id.trim();
1807
+ if (!id) throw new Error("Provider id cannot be empty");
1808
+ if (factories.has(id)) throw new Error(`Provider "${id}" is already registered`);
1809
+ factories.set(id, factory);
1810
+ }
1811
+ function providerFactory(id) {
1812
+ const factory = factories.get(id);
1813
+ if (!factory) {
1814
+ const available = [...factories.keys()].sort().join(", ") || "(none)";
1815
+ throw new Error(`Unknown provider "${id}". Available providers: ${available}`);
1816
+ }
1817
+ return factory;
1818
+ }
1819
+ function createProvider(id, mode2) {
1820
+ return providerFactory(id).create(mode2);
1821
+ }
1822
+ registerProvider({
1823
+ id: "pixellab",
1824
+ credentialEnv: "PIXELLAB_API_KEY",
1825
+ create(mode2) {
1826
+ if (mode2 === "online") return PixelLabProvider.fromEnv();
1827
+ if (mode2 === "downloads") return PixelLabProvider.forDownloads();
1828
+ return PixelLabProvider.forOffline();
1829
+ }
1830
+ });
1831
+ registerProvider({
1832
+ id: "retrodiffusion",
1833
+ credentialEnv: "RD_API_KEY",
1834
+ create(mode2) {
1835
+ if (mode2 === "online") return RetroDiffusionProvider.fromEnv();
1836
+ if (mode2 === "downloads") return RetroDiffusionProvider.forDownloads();
1837
+ return RetroDiffusionProvider.forOffline();
1838
+ }
1839
+ });
1840
+
1345
1841
  // src/manifest.ts
1346
1842
  import { readFile as readFile2 } from "fs/promises";
1347
1843
  import { existsSync as existsSync3 } from "fs";
@@ -1353,12 +1849,16 @@ import { readFile } from "fs/promises";
1353
1849
  function sha256(data) {
1354
1850
  return createHash("sha256").update(data).digest("hex");
1355
1851
  }
1356
- async function sha256File(path17) {
1357
- return sha256(await readFile(path17));
1852
+ async function sha256File(path19) {
1853
+ return sha256(await readFile(path19));
1358
1854
  }
1359
1855
  function specHash(spec, styleImageHashes) {
1360
1856
  return sha256(
1361
1857
  JSON.stringify({
1858
+ // Preserve every existing PixelLab hash while making a provider switch
1859
+ // invalidate the spec. Older manifests implicitly mean pixellab.
1860
+ provider: spec.provider === "pixellab" ? void 0 : spec.provider,
1861
+ providerOptions: Object.keys(spec.providerOptions).length > 0 ? spec.providerOptions : void 0,
1362
1862
  generator: spec.generator,
1363
1863
  prompt: spec.prompt,
1364
1864
  width: spec.width,
@@ -1372,7 +1872,7 @@ function specHash(spec, styleImageHashes) {
1372
1872
  // `noBackground` only reaches the wire for pixflux; the tile fields are
1373
1873
  // undefined for every other generator. `tileSize` is intentionally
1374
1874
  // absent — width/height are derived from it, so it is already covered.
1375
- noBackground: spec.generator === "pixflux" ? spec.noBackground : void 0,
1875
+ noBackground: spec.generator === "pixflux" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
1376
1876
  tileType: spec.tileType,
1377
1877
  tileView: spec.tileView,
1378
1878
  tileFeature: spec.tileFeature,
@@ -1412,6 +1912,7 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
1412
1912
  }
1413
1913
  async function resolveSpecs(loaded, filter) {
1414
1914
  const { manifest, root } = loaded;
1915
+ const activeProvider = filter?.provider ?? createProvider(manifest.provider, "offline");
1415
1916
  const specs = [];
1416
1917
  const styleIds = Object.keys(manifest.styles).filter(
1417
1918
  (id) => !filter?.styles?.length || filter.styles.includes(id)
@@ -1437,10 +1938,8 @@ async function resolveSpecs(loaded, filter) {
1437
1938
  const buf = await readFile2(abs);
1438
1939
  const metadata = imageMetadata(buf);
1439
1940
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
1440
- if (metadata.width < 1 || metadata.height < 1 || metadata.width > 256 || metadata.height > 256) {
1441
- throw new Error(
1442
- `Style image exceeds the API's 256x256 limit: ${abs} (${metadata.width}x${metadata.height})`
1443
- );
1941
+ if (metadata.width < 1 || metadata.height < 1) {
1942
+ throw new Error(`Style image has invalid dimensions: ${abs}`);
1444
1943
  }
1445
1944
  hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
1446
1945
  styleImageCache.set(abs, hit);
@@ -1460,8 +1959,8 @@ async function resolveSpecs(loaded, filter) {
1460
1959
  if (filter?.assets?.length && !filter.assets.includes(assetId)) continue;
1461
1960
  if (asset.styles.length && !asset.styles.includes(styleId)) continue;
1462
1961
  const generator = style.generator;
1463
- if (filter?.provider && !filter.provider.supports(generator)) {
1464
- throw new Error(`Provider "${filter.provider.id}" does not support generator "${generator}"`);
1962
+ if (!activeProvider.supports(generator)) {
1963
+ throw new Error(`Provider "${activeProvider.id}" does not support generator "${generator}"`);
1465
1964
  }
1466
1965
  let width;
1467
1966
  let height;
@@ -1488,6 +1987,8 @@ async function resolveSpecs(loaded, filter) {
1488
1987
  const base = {
1489
1988
  styleId,
1490
1989
  assetId,
1990
+ provider: activeProvider.id,
1991
+ providerOptions: style.providerOptions[activeProvider.id] ?? {},
1491
1992
  generator,
1492
1993
  prompt,
1493
1994
  width,
@@ -1519,11 +2020,6 @@ async function resolveSpecs(loaded, filter) {
1519
2020
  `style:${styleId}`
1520
2021
  ])
1521
2022
  ];
1522
- if (tags.length > 20) {
1523
- throw new Error(
1524
- `${styleId}/${assetId} resolves to ${tags.length} tags, but PixelLab allows at most 20`
1525
- );
1526
- }
1527
2023
  const resolved = {
1528
2024
  ...base,
1529
2025
  root,
@@ -1532,12 +2028,15 @@ async function resolveSpecs(loaded, filter) {
1532
2028
  source: asset.source,
1533
2029
  specHash: specHash(base, styleImageHashes)
1534
2030
  };
1535
- if (filter?.provider) {
1536
- const estimate = validateCostEstimate(filter.provider.id, filter.provider.estimate(resolved));
1537
- resolved.cost = estimate.amount;
1538
- resolved.costUnit = estimate.unit;
1539
- resolved.candidates = estimate.candidates;
1540
- }
2031
+ const resolvedImages = style.styleImages.map((image) => {
2032
+ const hit = styleImageCache.get(path3.resolve(root, image.path));
2033
+ return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
2034
+ });
2035
+ activeProvider.validate?.(resolved, resolvedImages);
2036
+ const estimate = validateCostEstimate(activeProvider.id, activeProvider.estimate(resolved));
2037
+ resolved.cost = estimate.amount;
2038
+ resolved.costUnit = estimate.unit;
2039
+ resolved.candidates = estimate.candidates;
1541
2040
  specs.push(resolved);
1542
2041
  }
1543
2042
  }
@@ -1561,10 +2060,8 @@ async function resolveStyleImages(loaded, styleId) {
1561
2060
  const buf = await readFile2(file);
1562
2061
  const metadata = imageMetadata(buf);
1563
2062
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
1564
- if (metadata.width < 1 || metadata.height < 1 || metadata.width > 256 || metadata.height > 256) {
1565
- throw new Error(
1566
- `Style image exceeds the API's 256x256 limit: ${file} (${metadata.width}x${metadata.height})`
1567
- );
2063
+ if (metadata.width < 1 || metadata.height < 1) {
2064
+ throw new Error(`Style image has invalid dimensions: ${file}`);
1568
2065
  }
1569
2066
  out.push({ base64: buf.toString("base64"), ...metadata });
1570
2067
  }
@@ -1770,7 +2267,8 @@ async function acquireFileLock(file) {
1770
2267
  function spendByUnit(lock) {
1771
2268
  const totals = { generations: 0, usd: 0, free: 0 };
1772
2269
  for (const entry of Object.values(lock.entries)) {
1773
- totals[entry.costUnit ?? "generations"] += entry.cost ?? 0;
2270
+ const unit = entry.costUnit ?? "generations";
2271
+ totals[unit] = (totals[unit] ?? 0) + (entry.cost ?? 0);
1774
2272
  }
1775
2273
  return totals;
1776
2274
  }
@@ -1788,16 +2286,16 @@ function resolveOutputPath(recordedPath, manifestDir) {
1788
2286
  if (path5.win32.isAbsolute(recordedPath)) return recordedPath;
1789
2287
  return path5.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path5.sep));
1790
2288
  }
1791
- function expectedOutputPath(spec, role, index, total) {
1792
- if (total === 1) return spec.outFile;
2289
+ function expectedOutputPath(spec, role, index, total, mediaType) {
1793
2290
  const originalExt = path5.extname(spec.outFile);
1794
- const ext = originalExt || ".png";
2291
+ const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
1795
2292
  const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
2293
+ if (total === 1) return `${stem}${ext}`;
1796
2294
  const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
1797
2295
  return `${stem}-${safeRole}${ext}`;
1798
2296
  }
1799
2297
  function currentOutputPath(output, spec, index, total) {
1800
- return expectedOutputPath(spec, output.role, index, total);
2298
+ return expectedOutputPath(spec, output.role, index, total, output.mediaType);
1801
2299
  }
1802
2300
  function currentEntryOutputPath(entry, spec, index) {
1803
2301
  const output = entry.outputs[index];
@@ -2255,7 +2753,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
2255
2753
  async function pruneInFlight() {
2256
2754
  for (const [id, spec] of [...inFlight]) {
2257
2755
  try {
2258
- const state = await provider.poll(id, spec.generator, spec);
2756
+ const state = await provider.poll(id, spec.generator, { spec, tileFeature: spec.tileFeature });
2259
2757
  if (state.status !== "processing") inFlight.delete(id);
2260
2758
  lastSlotError = null;
2261
2759
  } catch (err) {
@@ -2307,7 +2805,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
2307
2805
  await saveLock(lockPath, lock);
2308
2806
  lastSubmitAt = Date.now();
2309
2807
  try {
2310
- const refs = spec.generator === "1dir" || spec.generator === "tiles" ? styleImages.get(spec.styleId) ?? [] : [];
2808
+ const refs = styleImages.get(spec.styleId) ?? [];
2311
2809
  const { jobId } = await provider.submit(spec, refs);
2312
2810
  upsert(lock, key, {
2313
2811
  jobId,
@@ -2357,7 +2855,8 @@ async function poll(provider, lock, lockPath, opts = {}) {
2357
2855
  try {
2358
2856
  const currentSpec = specByKey.get(key);
2359
2857
  const state = await provider.poll(entry.jobId, entry.generator, {
2360
- tileFeature: entry.tileFeature ?? currentSpec?.tileFeature
2858
+ tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
2859
+ spec: currentSpec
2361
2860
  });
2362
2861
  if (state.status === "review") {
2363
2862
  upsert(lock, key, { status: "review", reviewObjectId: entry.jobId });
@@ -2395,7 +2894,6 @@ async function poll(provider, lock, lockPath, opts = {}) {
2395
2894
  import { existsSync as existsSync6 } from "fs";
2396
2895
  import { mkdir as mkdir2, readFile as readFile4, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
2397
2896
  import path8 from "path";
2398
- var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
2399
2897
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
2400
2898
  const log2 = opts.onProgress ?? (() => {
2401
2899
  });
@@ -2427,7 +2925,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
2427
2925
  result.skipped++;
2428
2926
  continue;
2429
2927
  }
2430
- const sources = entry.sourceUrls?.length ? entry.sourceUrls : entry.sourceUrl ? [{ url: entry.sourceUrl }] : opts.repair && entry.outputs.length ? entry.outputs.map((o) => ({ url: "", role: o.role })) : [];
2928
+ const sources = entry.sourceUrls?.length ? entry.sourceUrls : entry.sourceUrl ? [{ url: entry.sourceUrl }] : opts.repair && entry.outputs.length ? entry.outputs.map((o) => ({ url: "", role: o.role, mediaType: o.mediaType })) : [];
2431
2929
  if (!sources.length) {
2432
2930
  upsert(lock, key, {
2433
2931
  status: "download-failed",
@@ -2443,7 +2941,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
2443
2941
  const recorded = entry.outputs.find(
2444
2942
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
2445
2943
  );
2446
- const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length);
2944
+ const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
2447
2945
  if (existsSync6(target)) {
2448
2946
  if (!recorded) {
2449
2947
  throw new Error(`refusing to overwrite untracked output ${target}`);
@@ -2451,11 +2949,19 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
2451
2949
  if (await sha256File(target) !== recorded.sha256) {
2452
2950
  throw new Error(`refusing to overwrite modified output ${target}`);
2453
2951
  }
2454
- if (cacheDir) await cachePng(cacheDir, await readFile4(target), recorded.sha256);
2952
+ if (cacheDir) {
2953
+ await cacheMedia(
2954
+ cacheDir,
2955
+ await readFile4(target),
2956
+ recorded.mediaType ?? MediaType.PNG,
2957
+ recorded.sha256
2958
+ );
2959
+ }
2455
2960
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
2456
2961
  continue;
2457
2962
  }
2458
- let buf = recorded && cacheDir ? await readCachedPng(cacheDir, recorded.sha256) : null;
2963
+ const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
2964
+ let buf = recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null;
2459
2965
  if (buf) {
2460
2966
  log2(` cached ${path8.relative(process.cwd(), target)}`);
2461
2967
  } else {
@@ -2464,24 +2970,25 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
2464
2970
  }
2465
2971
  buf = await provider.download(source.url);
2466
2972
  }
2467
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) {
2468
- throw new Error(`response for ${source.role ?? "asset"} was not a PNG (${buf.length} bytes)`);
2469
- }
2973
+ let mediaType;
2470
2974
  try {
2471
- decodePng(buf);
2975
+ mediaType = validateMedia(buf, expectedMediaType);
2472
2976
  } catch (err) {
2977
+ const label = expectedMediaType === MediaType.GIF ? "GIF" : "PNG";
2978
+ const mismatch = detectMediaType(buf) !== expectedMediaType;
2473
2979
  throw new Error(
2474
- `response for ${source.role ?? "asset"} was not a valid PNG: ${err instanceof Error ? err.message : String(err)}`
2980
+ `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${buf.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
2475
2981
  );
2476
2982
  }
2477
- if (cacheDir) await cachePng(cacheDir, buf);
2983
+ if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
2478
2984
  await mkdir2(path8.dirname(target), { recursive: true });
2479
2985
  const tmp = `${target}.pixelkiln.tmp`;
2480
2986
  await writeFile2(tmp, buf);
2481
2987
  outputs.push({
2482
2988
  path: portableOutputPath(target, spec.root),
2483
2989
  sha256: sha256(buf),
2484
- ...source.role ? { role: source.role } : {}
2990
+ ...source.role ? { role: source.role } : {},
2991
+ mediaType
2485
2992
  });
2486
2993
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
2487
2994
  await saveLock(lockPath, lock);
@@ -2518,22 +3025,23 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
2518
3025
  await saveLock(lockPath, lock);
2519
3026
  return result;
2520
3027
  }
2521
- async function readCachedPng(cacheDir, hash) {
2522
- const file = path8.join(cacheDir, `${hash}.png`);
3028
+ async function readCachedMedia(cacheDir, hash, mediaType) {
3029
+ const file = path8.join(cacheDir, cacheFileName(hash, mediaType));
2523
3030
  if (!existsSync6(file)) return null;
2524
3031
  try {
2525
3032
  const buf = await readFile4(file);
2526
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE) || sha256(buf) !== hash) return null;
2527
- decodePng(buf);
3033
+ if (sha256(buf) !== hash) return null;
3034
+ validateMedia(buf, mediaType);
2528
3035
  return buf;
2529
3036
  } catch {
2530
3037
  return null;
2531
3038
  }
2532
3039
  }
2533
- async function cachePng(cacheDir, buf, knownHash) {
3040
+ async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
2534
3041
  const hash = knownHash ?? sha256(buf);
2535
- const file = path8.join(cacheDir, `${hash}.png`);
2536
- if (await readCachedPng(cacheDir, hash)) return;
3042
+ validateMedia(buf, mediaType);
3043
+ const file = path8.join(cacheDir, cacheFileName(hash, mediaType));
3044
+ if (await readCachedMedia(cacheDir, hash, mediaType)) return;
2537
3045
  await mkdir2(cacheDir, { recursive: true });
2538
3046
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
2539
3047
  await writeFile2(tmp, buf);
@@ -2638,7 +3146,10 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
2638
3146
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
2639
3147
  let hasCache = false;
2640
3148
  for (const output of entry.outputs) {
2641
- const cached = path9.join(cacheDir, `${output.sha256}.png`);
3149
+ const cached = path9.join(
3150
+ cacheDir,
3151
+ cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
3152
+ );
2642
3153
  if (existsSync7(cached) && await sha256File(cached) === output.sha256) {
2643
3154
  hasCache = true;
2644
3155
  break;
@@ -2668,11 +3179,14 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
2668
3179
  add(
2669
3180
  "provider",
2670
3181
  "error",
2671
- opts.apiKeyPresent === false ? "PIXELLAB_API_KEY is not configured" : "provider is not configured"
3182
+ opts.apiKeyPresent === false ? `${opts.credentialEnv ?? "PIXELLAB_API_KEY"} is not configured` : "provider is not configured"
2672
3183
  );
3184
+ } else if (!opts.provider.balance) {
3185
+ add("provider", "ok", `${opts.provider.id} configured; balance reporting unavailable`);
2673
3186
  } else {
3187
+ const balanceFn = opts.provider.balance.bind(opts.provider);
2674
3188
  try {
2675
- const balance = await opts.provider.balance();
3189
+ const balance = await balanceFn();
2676
3190
  add("provider", "ok", `${opts.provider.id} reachable; ${balance.remaining} ${balance.unit} remaining`);
2677
3191
  } catch (err) {
2678
3192
  add("provider", "error", `provider connectivity failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -2715,10 +3229,10 @@ function isSha256Hash(value) {
2715
3229
  function parseCache(value) {
2716
3230
  return HashCacheSchema.parse(value);
2717
3231
  }
2718
- async function loadCache(path17) {
2719
- if (!existsSync8(path17)) return { version: 1, hashes: {} };
3232
+ async function loadCache(path19) {
3233
+ if (!existsSync8(path19)) return { version: 1, hashes: {} };
2720
3234
  try {
2721
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile5(path17, "utf8")));
3235
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile5(path19, "utf8")));
2722
3236
  if (!parsed.success) return { version: 1, hashes: {} };
2723
3237
  return {
2724
3238
  version: 1,
@@ -2730,18 +3244,18 @@ async function loadCache(path17) {
2730
3244
  return { version: 1, hashes: {} };
2731
3245
  }
2732
3246
  }
2733
- async function saveCache(path17, cache) {
3247
+ async function saveCache(path19, cache) {
2734
3248
  const sorted = {};
2735
3249
  for (const key of Object.keys(cache.hashes).sort()) {
2736
3250
  const hash = cache.hashes[key];
2737
3251
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
2738
3252
  sorted[key] = hash;
2739
3253
  }
2740
- await mkdir3(pathModule.dirname(pathModule.resolve(path17)), { recursive: true });
2741
- const tmp = `${path17}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3254
+ await mkdir3(pathModule.dirname(pathModule.resolve(path19)), { recursive: true });
3255
+ const tmp = `${path19}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
2742
3256
  try {
2743
3257
  await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
2744
- await rename3(tmp, path17);
3258
+ await rename3(tmp, path19);
2745
3259
  } finally {
2746
3260
  await rm3(tmp, { force: true });
2747
3261
  }
@@ -3259,7 +3773,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
3259
3773
  const entry = lock.entries[key];
3260
3774
  if (!group || !entry?.reviewObjectId) continue;
3261
3775
  if (!Number.isInteger(index) || index < 0 || index >= group.frameUrls.length) continue;
3262
- const { objectId, sourceUrl } = await provider.selectCandidate(
3776
+ const { objectId, sourceUrl } = await requireSelectCandidate(provider)(
3263
3777
  entry.reviewObjectId,
3264
3778
  index,
3265
3779
  `asset:${entry.assetId}`,
@@ -3349,6 +3863,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
3349
3863
  }
3350
3864
  return {
3351
3865
  name,
3866
+ provider: "pixellab",
3352
3867
  styles: {
3353
3868
  [styleId]: {
3354
3869
  generator,
@@ -3361,7 +3876,8 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
3361
3876
  styleImages: [],
3362
3877
  palette: [],
3363
3878
  outDir,
3364
- tags: [name]
3879
+ tags: [name],
3880
+ providerOptions: {}
3365
3881
  }
3366
3882
  },
3367
3883
  assets
@@ -3382,6 +3898,7 @@ async function writeManifestFile(target, manifest) {
3382
3898
  // src/pipeline/salvage.ts
3383
3899
  import { readFile as readFile8 } from "fs/promises";
3384
3900
  import { existsSync as existsSync11 } from "fs";
3901
+ import path12 from "path";
3385
3902
  async function loadClaims(lockPaths) {
3386
3903
  const claimed = /* @__PURE__ */ new Set();
3387
3904
  for (const p of lockPaths) {
@@ -3429,6 +3946,26 @@ function matchStyleByPattern(prompt, manifest) {
3429
3946
  }
3430
3947
  return null;
3431
3948
  }
3949
+ async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
3950
+ const own = path12.resolve(ownManifestPath);
3951
+ const siblingManifestPaths = [
3952
+ .../* @__PURE__ */ new Set([
3953
+ ...workspaceManifestPaths,
3954
+ ...claimPaths.map((c) => path12.join(path12.dirname(path12.resolve(c)), "pixelkiln.manifest.json"))
3955
+ ])
3956
+ ];
3957
+ const siblings = [];
3958
+ for (const siblingManifestPath of siblingManifestPaths) {
3959
+ if (path12.resolve(siblingManifestPath) === own) continue;
3960
+ if (!existsSync11(siblingManifestPath)) continue;
3961
+ try {
3962
+ const { manifest } = await loadManifest(siblingManifestPath);
3963
+ siblings.push({ label: path12.basename(path12.dirname(siblingManifestPath)), manifest });
3964
+ } catch {
3965
+ }
3966
+ }
3967
+ return siblings;
3968
+ }
3432
3969
  function groupOrphansByStyle(orphans, manifest, siblings = []) {
3433
3970
  const matched = /* @__PURE__ */ new Map();
3434
3971
  const elsewhere = /* @__PURE__ */ new Map();
@@ -3538,10 +4075,237 @@ async function applyTags(provider, decisions, existing, opts = {}) {
3538
4075
  return { tagged, failed };
3539
4076
  }
3540
4077
 
3541
- // src/pipeline/audit.ts
3542
- import { readFile as readFile9 } from "fs/promises";
4078
+ // src/workspace.ts
4079
+ import { mkdir as mkdir4, readFile as readFile9, rename as rename4, rm as rm4, writeFile as writeFile6 } from "fs/promises";
3543
4080
  import { existsSync as existsSync12 } from "fs";
3544
- import path12 from "path";
4081
+ import path13 from "path";
4082
+ import { z as z4 } from "zod";
4083
+ var WorkspaceProjectSchema = z4.object({
4084
+ id: z4.string().min(1),
4085
+ /** Manifest path, relative to the catalog file's own directory. */
4086
+ manifest: z4.string().min(1),
4087
+ /** Lockfile path, relative to the catalog file's own directory. */
4088
+ lock: z4.string().min(1),
4089
+ provider: z4.string().min(1).default("pixellab"),
4090
+ /** Free-form label for a shared account, e.g. distinguishing sandboxes. */
4091
+ account: z4.string().optional()
4092
+ }).strict();
4093
+ var WorkspaceSchema = z4.object({
4094
+ version: z4.literal(1),
4095
+ projects: z4.array(WorkspaceProjectSchema).default([])
4096
+ }).strict();
4097
+ function parseWorkspace(raw) {
4098
+ const parsed = WorkspaceSchema.safeParse(raw);
4099
+ if (parsed.success) return parsed.data;
4100
+ throw new Error(
4101
+ `Workspace catalog is not valid v1:
4102
+ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
4103
+ );
4104
+ }
4105
+ async function loadWorkspace(workspacePath) {
4106
+ if (!existsSync12(workspacePath)) return { version: 1, projects: [] };
4107
+ let raw;
4108
+ try {
4109
+ raw = JSON.parse(await readFile9(workspacePath, "utf8"));
4110
+ } catch (err) {
4111
+ throw new Error(
4112
+ `Workspace catalog at ${workspacePath} is malformed:
4113
+ ${err instanceof Error ? err.message : String(err)}`
4114
+ );
4115
+ }
4116
+ return parseWorkspace(raw);
4117
+ }
4118
+ async function saveWorkspace(workspacePath, ws) {
4119
+ const sorted = {
4120
+ version: 1,
4121
+ projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
4122
+ };
4123
+ await mkdir4(path13.dirname(path13.resolve(workspacePath)), { recursive: true });
4124
+ const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
4125
+ try {
4126
+ await writeFile6(tmp, JSON.stringify(sorted, null, 2) + "\n");
4127
+ await rename4(tmp, workspacePath);
4128
+ } finally {
4129
+ await rm4(tmp, { force: true });
4130
+ }
4131
+ }
4132
+ function toPortablePath(dir, absolute) {
4133
+ return path13.relative(dir, absolute).split(path13.sep).join("/");
4134
+ }
4135
+ function resolveProject(dir, project) {
4136
+ return {
4137
+ manifestPath: path13.resolve(dir, project.manifest.split("/").join(path13.sep)),
4138
+ lockPath: path13.resolve(dir, project.lock.split("/").join(path13.sep))
4139
+ };
4140
+ }
4141
+ function validateWorkspace(ws, dir) {
4142
+ const diagnostics = [];
4143
+ const idCounts = /* @__PURE__ */ new Map();
4144
+ const lockOwners = /* @__PURE__ */ new Map();
4145
+ const manifestOwners = /* @__PURE__ */ new Map();
4146
+ for (const project of ws.projects) {
4147
+ idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
4148
+ const { manifestPath, lockPath } = resolveProject(dir, project);
4149
+ lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
4150
+ manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
4151
+ if (path13.isAbsolute(project.manifest) || path13.isAbsolute(project.lock)) {
4152
+ diagnostics.push({
4153
+ id: "absolute-path",
4154
+ level: "warning",
4155
+ message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
4156
+ });
4157
+ }
4158
+ if (!existsSync12(manifestPath)) {
4159
+ diagnostics.push({
4160
+ id: "missing-manifest",
4161
+ level: "error",
4162
+ message: `project "${project.id}" manifest not found: ${manifestPath}`
4163
+ });
4164
+ }
4165
+ if (!existsSync12(lockPath)) {
4166
+ diagnostics.push({
4167
+ id: "missing-lock",
4168
+ level: "error",
4169
+ message: `project "${project.id}" lockfile not found: ${lockPath}`
4170
+ });
4171
+ }
4172
+ }
4173
+ for (const [id, count] of idCounts) {
4174
+ if (count > 1) {
4175
+ diagnostics.push({
4176
+ id: "duplicate-id",
4177
+ level: "error",
4178
+ message: `project id "${id}" is registered ${count} times`
4179
+ });
4180
+ }
4181
+ }
4182
+ for (const [lockPath, ids] of lockOwners) {
4183
+ if (ids.length > 1) {
4184
+ diagnostics.push({
4185
+ id: "duplicate-lock",
4186
+ level: "error",
4187
+ message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
4188
+ });
4189
+ }
4190
+ }
4191
+ for (const [manifestPath, ids] of manifestOwners) {
4192
+ if (ids.length > 1) {
4193
+ diagnostics.push({
4194
+ id: "duplicate-manifest",
4195
+ level: "warning",
4196
+ message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
4197
+ });
4198
+ }
4199
+ }
4200
+ const providers = new Set(ws.projects.map((p) => p.provider));
4201
+ if (providers.size > 1) {
4202
+ diagnostics.push({
4203
+ id: "mixed-provider",
4204
+ level: "warning",
4205
+ message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
4206
+ });
4207
+ }
4208
+ return diagnostics;
4209
+ }
4210
+
4211
+ // src/pipeline/workspace.ts
4212
+ async function workspaceClaims(ws, dir) {
4213
+ const lockPaths = [];
4214
+ const byProject = {};
4215
+ const claimed = /* @__PURE__ */ new Set();
4216
+ for (const project of ws.projects) {
4217
+ const { lockPath } = resolveProject(dir, project);
4218
+ lockPaths.push(lockPath);
4219
+ let projectClaims;
4220
+ try {
4221
+ projectClaims = await loadClaims([lockPath]);
4222
+ } catch (err) {
4223
+ throw new Error(
4224
+ `Project "${project.id}" lockfile is unreadable: ${err instanceof Error ? err.message : String(err)}`
4225
+ );
4226
+ }
4227
+ byProject[project.id] = projectClaims.size;
4228
+ for (const id of projectClaims) claimed.add(id);
4229
+ }
4230
+ return { claimed, byProject, lockPaths };
4231
+ }
4232
+ function emptyStateCounts() {
4233
+ return {
4234
+ ok: 0,
4235
+ missing: 0,
4236
+ untracked: 0,
4237
+ stale: 0,
4238
+ orphaned: 0,
4239
+ "in-flight": 0,
4240
+ recoverable: 0,
4241
+ failed: 0
4242
+ };
4243
+ }
4244
+ async function workspaceStatus(ws, dir) {
4245
+ const diagnostics = validateWorkspace(ws, dir);
4246
+ const projects = [];
4247
+ const totalsByState = emptyStateCounts();
4248
+ const totalsSpend = { generations: 0, usd: 0, free: 0 };
4249
+ for (const project of ws.projects) {
4250
+ const { manifestPath, lockPath } = resolveProject(dir, project);
4251
+ const base = {
4252
+ id: project.id,
4253
+ provider: project.provider,
4254
+ account: project.account ?? null,
4255
+ manifest: manifestPath,
4256
+ lock: lockPath
4257
+ };
4258
+ try {
4259
+ const provider = createProvider(project.provider, "offline");
4260
+ const loaded = await loadManifest(manifestPath);
4261
+ const specs = await resolveSpecs(loaded, { provider });
4262
+ const lock = await loadLock(lockPath);
4263
+ normalizeLockOutputPaths(lock, specs);
4264
+ const plan = await buildPlan(specs, lock);
4265
+ const byState = summarize(plan);
4266
+ const spend = spendByUnit(lock);
4267
+ for (const state of Object.keys(byState)) {
4268
+ totalsByState[state] += byState[state];
4269
+ }
4270
+ for (const unit of Object.keys(spend)) {
4271
+ totalsSpend[unit] = (totalsSpend[unit] ?? 0) + (spend[unit] ?? 0);
4272
+ }
4273
+ projects.push({
4274
+ ...base,
4275
+ entries: Object.keys(lock.entries).length,
4276
+ byState,
4277
+ spendByUnit: spend,
4278
+ error: null
4279
+ });
4280
+ } catch (err) {
4281
+ projects.push({
4282
+ ...base,
4283
+ entries: 0,
4284
+ byState: emptyStateCounts(),
4285
+ spendByUnit: { generations: 0, usd: 0, free: 0 },
4286
+ error: err instanceof Error ? err.message : String(err)
4287
+ });
4288
+ }
4289
+ }
4290
+ let claims = 0;
4291
+ try {
4292
+ claims = (await workspaceClaims(ws, dir)).claimed.size;
4293
+ } catch {
4294
+ }
4295
+ return {
4296
+ version: 1,
4297
+ safe: !diagnostics.some((d) => d.level === "error") && projects.every((p) => !p.error),
4298
+ dir,
4299
+ projects,
4300
+ totals: { byState: totalsByState, spendByUnit: totalsSpend, claims },
4301
+ diagnostics
4302
+ };
4303
+ }
4304
+
4305
+ // src/pipeline/audit.ts
4306
+ import { readFile as readFile10 } from "fs/promises";
4307
+ import { existsSync as existsSync13 } from "fs";
4308
+ import path14 from "path";
3545
4309
  function colorDistance(a, b) {
3546
4310
  const rmean = (a.r + b.r) / 2;
3547
4311
  const dr = a.r - b.r;
@@ -3586,12 +4350,12 @@ async function auditStyle(loaded, specs, styleId, lock) {
3586
4350
  const unreadable = [];
3587
4351
  for (const spec of mine) {
3588
4352
  for (const output of resolveSpecOutputs(spec, lock, loaded.root)) {
3589
- if (!existsSync12(output.absolutePath)) {
4353
+ if (!existsSync13(output.absolutePath)) {
3590
4354
  missing.push(output.id);
3591
4355
  continue;
3592
4356
  }
3593
4357
  try {
3594
- const png = decodePng(await readFile9(output.absolutePath));
4358
+ const png = decodePng(await readFile10(output.absolutePath));
3595
4359
  const palette = extractPalette(png, 12);
3596
4360
  assets.push({
3597
4361
  assetId: spec.assetId,
@@ -3615,10 +4379,10 @@ async function auditStyle(loaded, specs, styleId, lock) {
3615
4379
  let referenceFromStyleImages = false;
3616
4380
  const refPalettes = [];
3617
4381
  for (const img of style.styleImages) {
3618
- const abs = path12.resolve(loaded.root, img.path);
3619
- if (!existsSync12(abs)) continue;
4382
+ const abs = path14.resolve(loaded.root, img.path);
4383
+ if (!existsSync13(abs)) continue;
3620
4384
  try {
3621
- refPalettes.push(extractPalette(decodePng(await readFile9(abs)), 12));
4385
+ refPalettes.push(extractPalette(decodePng(await readFile10(abs)), 12));
3622
4386
  } catch {
3623
4387
  }
3624
4388
  }
@@ -3694,16 +4458,15 @@ function hex(c) {
3694
4458
  }
3695
4459
 
3696
4460
  // src/pipeline/cache-health.ts
3697
- import { existsSync as existsSync13 } from "fs";
3698
- import { readFile as readFile10, readdir as readdir2, rm as rm4 } from "fs/promises";
3699
- import path13 from "path";
3700
- var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
4461
+ import { existsSync as existsSync14 } from "fs";
4462
+ import { readFile as readFile11, readdir as readdir2, rm as rm5 } from "fs/promises";
4463
+ import path15 from "path";
3701
4464
  async function inspectCaches(lock, lockPath, options = {}) {
3702
- if (options.prune && !existsSync13(lockPath)) {
3703
- throw new Error(`Refusing to prune without an existing lockfile at ${path13.resolve(lockPath)}`);
4465
+ if (options.prune && !existsSync14(lockPath)) {
4466
+ throw new Error(`Refusing to prune without an existing lockfile at ${path15.resolve(lockPath)}`);
3704
4467
  }
3705
- const contentDir = path13.resolve(path13.dirname(lockPath), ".pixelkiln", "cache");
3706
- const remotePath = path13.resolve(cachePathFor(lockPath));
4468
+ const contentDir = path15.resolve(path15.dirname(lockPath), ".pixelkiln", "cache");
4469
+ const remotePath = path15.resolve(cachePathFor(lockPath));
3707
4470
  const referenced = new Set(
3708
4471
  Object.values(lock.entries).flatMap((entry) => entry.outputs.map((output) => output.sha256))
3709
4472
  );
@@ -3717,7 +4480,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
3717
4480
  ]);
3718
4481
  for (const name of names) {
3719
4482
  try {
3720
- await rm4(path13.join(contentDir, name), { force: true });
4483
+ await rm5(path15.join(contentDir, name), { force: true });
3721
4484
  removed.contentFiles++;
3722
4485
  } catch {
3723
4486
  }
@@ -3726,7 +4489,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
3726
4489
  await saveCache(remotePath, { version: 1, hashes: {} });
3727
4490
  removed.resetRemoteHashCache = true;
3728
4491
  } else if (remoteHashes.invalidIds.length) {
3729
- const cache = parseCache(JSON.parse(await readFile10(remotePath, "utf8")));
4492
+ const cache = parseCache(JSON.parse(await readFile11(remotePath, "utf8")));
3730
4493
  for (const id of remoteHashes.invalidIds) delete cache.hashes[id];
3731
4494
  removed.remoteHashEntries = remoteHashes.invalidIds.length;
3732
4495
  await saveCache(remotePath, cache);
@@ -3744,7 +4507,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
3744
4507
  async function inspectContentCache(contentDir, referenced) {
3745
4508
  const report = {
3746
4509
  path: contentDir,
3747
- exists: existsSync13(contentDir),
4510
+ exists: existsSync14(contentDir),
3748
4511
  files: 0,
3749
4512
  bytes: 0,
3750
4513
  valid: 0,
@@ -3765,11 +4528,12 @@ async function inspectContentCache(contentDir, referenced) {
3765
4528
  continue;
3766
4529
  }
3767
4530
  report.files++;
3768
- const file = path13.join(contentDir, entry.name);
3769
- const expected = entry.name.endsWith(".png") ? entry.name.slice(0, -4) : "";
4531
+ const file = path15.join(contentDir, entry.name);
4532
+ const mediaType = mediaTypeFromExtension(entry.name);
4533
+ const expected = mediaType ? entry.name.slice(0, -4) : "";
3770
4534
  let bytes;
3771
4535
  try {
3772
- bytes = await readFile10(file);
4536
+ bytes = await readFile11(file);
3773
4537
  report.bytes += bytes.length;
3774
4538
  } catch (err) {
3775
4539
  report.invalid.push({
@@ -3779,11 +4543,7 @@ async function inspectContentCache(contentDir, referenced) {
3779
4543
  continue;
3780
4544
  }
3781
4545
  if (!isSha256Hash(expected)) {
3782
- report.invalid.push({ name: entry.name, reason: "filename is not <sha256>.png" });
3783
- continue;
3784
- }
3785
- if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE2)) {
3786
- report.invalid.push({ name: entry.name, reason: "not a PNG" });
4546
+ report.invalid.push({ name: entry.name, reason: "filename is not <sha256>.png or <sha256>.gif" });
3787
4547
  continue;
3788
4548
  }
3789
4549
  if (sha256(bytes) !== expected) {
@@ -3791,11 +4551,11 @@ async function inspectContentCache(contentDir, referenced) {
3791
4551
  continue;
3792
4552
  }
3793
4553
  try {
3794
- decodePng(bytes);
4554
+ validateMedia(bytes, mediaType);
3795
4555
  } catch (err) {
3796
4556
  report.invalid.push({
3797
4557
  name: entry.name,
3798
- reason: `invalid PNG: ${err instanceof Error ? err.message : String(err)}`
4558
+ reason: `invalid ${mediaType === "image/gif" ? "GIF" : "PNG"}: ${err instanceof Error ? err.message : String(err)}`
3799
4559
  });
3800
4560
  continue;
3801
4561
  }
@@ -3811,7 +4571,7 @@ async function inspectContentCache(contentDir, referenced) {
3811
4571
  async function inspectRemoteHashCache(remotePath) {
3812
4572
  const report = {
3813
4573
  path: remotePath,
3814
- exists: existsSync13(remotePath),
4574
+ exists: existsSync14(remotePath),
3815
4575
  entries: 0,
3816
4576
  valid: 0,
3817
4577
  invalidIds: [],
@@ -3820,7 +4580,7 @@ async function inspectRemoteHashCache(remotePath) {
3820
4580
  if (!report.exists) return report;
3821
4581
  let cache;
3822
4582
  try {
3823
- cache = parseCache(JSON.parse(await readFile10(remotePath, "utf8")));
4583
+ cache = parseCache(JSON.parse(await readFile11(remotePath, "utf8")));
3824
4584
  } catch (err) {
3825
4585
  report.error = err instanceof Error ? err.message : String(err);
3826
4586
  return report;
@@ -4091,8 +4851,8 @@ function isRecord(value) {
4091
4851
  }
4092
4852
 
4093
4853
  // src/pick/salvage-server.ts
4094
- import { mkdir as mkdir4, writeFile as writeFile6, readFile as readFile11 } from "fs/promises";
4095
- import path14 from "path";
4854
+ import { mkdir as mkdir5, writeFile as writeFile7, readFile as readFile12 } from "fs/promises";
4855
+ import path16 from "path";
4096
4856
 
4097
4857
  // src/pick/salvage-sheet.ts
4098
4858
  var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -4251,13 +5011,13 @@ refresh();
4251
5011
  }
4252
5012
 
4253
5013
  // src/pick/salvage-server.ts
4254
- var PNG_SIGNATURE3 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5014
+ var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
4255
5015
  async function runSalvage(provider, orphans, ctx, opts = {}) {
4256
5016
  const log2 = opts.onProgress ?? (() => {
4257
5017
  });
4258
5018
  const html = renderSalvageSheet(orphans, {
4259
5019
  styleId: ctx.styleId,
4260
- importDir: path14.relative(process.cwd(), ctx.importDir) || "."
5020
+ importDir: path16.relative(process.cwd(), ctx.importDir) || "."
4261
5021
  });
4262
5022
  const byId = new Map(orphans.map((o) => [o.id, o]));
4263
5023
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -4283,13 +5043,13 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
4283
5043
  if (decision.action === "import") {
4284
5044
  try {
4285
5045
  const buf = await provider.download(orphan.previewUrl);
4286
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE3)) throw new Error("not a PNG");
5046
+ if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
4287
5047
  decodePng(buf);
4288
5048
  const assetId = idFromPrompt(orphan.prompt, taken);
4289
- const rel = path14.join("_salvaged", `${assetId}.png`);
4290
- const outFile = path14.resolve(ctx.importDir, rel);
4291
- await mkdir4(path14.dirname(outFile), { recursive: true });
4292
- await writeFile6(outFile, buf);
5049
+ const rel = path16.join("_salvaged", `${assetId}.png`);
5050
+ const outFile = path16.resolve(ctx.importDir, rel);
5051
+ await mkdir5(path16.dirname(outFile), { recursive: true });
5052
+ await writeFile7(outFile, buf);
4293
5053
  ctx.manifest.assets[assetId] = {
4294
5054
  prompt: orphan.prompt,
4295
5055
  promptByStyle: {},
@@ -4315,7 +5075,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
4315
5075
  error: null,
4316
5076
  sourceUrl: orphan.previewUrl,
4317
5077
  outputs: [{
4318
- path: portableOutputPath(outFile, path14.dirname(ctx.manifestPath)),
5078
+ path: portableOutputPath(outFile, path16.dirname(ctx.manifestPath)),
4319
5079
  sha256: sha256(buf)
4320
5080
  }],
4321
5081
  submittedAt: orphan.createdAt,
@@ -4339,9 +5099,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
4339
5099
  }
4340
5100
  }
4341
5101
  await applyTags(provider, decisions, existingTags, { onProgress: log2 });
4342
- const raw = JSON.parse(await readFile11(ctx.manifestPath, "utf8"));
5102
+ const raw = JSON.parse(await readFile12(ctx.manifestPath, "utf8"));
4343
5103
  for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
4344
- await writeFile6(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
5104
+ await writeFile7(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
4345
5105
  await saveLock(ctx.lockPath, ctx.lock);
4346
5106
  return result;
4347
5107
  }
@@ -4349,9 +5109,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
4349
5109
  }
4350
5110
 
4351
5111
  // src/artifacts.ts
4352
- import path15 from "path";
5112
+ import path17 from "path";
4353
5113
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
4354
- import { mkdir as mkdir5, readFile as readFile12, rename as rename4, rm as rm5, writeFile as writeFile7 } from "fs/promises";
5114
+ import { mkdir as mkdir6, readFile as readFile13, rename as rename5, rm as rm6, writeFile as writeFile8 } from "fs/promises";
4355
5115
  var activeTransactions = /* @__PURE__ */ new Set();
4356
5116
  function message(error) {
4357
5117
  return error instanceof Error ? error.message : String(error);
@@ -4360,7 +5120,7 @@ function digest(data) {
4360
5120
  return createHash2("sha256").update(data).digest("hex");
4361
5121
  }
4362
5122
  function portableRelative(from, to) {
4363
- return path15.relative(from, path15.resolve(to)).split(path15.sep).join("/") || ".";
5123
+ return path17.relative(from, path17.resolve(to)).split(path17.sep).join("/") || ".";
4364
5124
  }
4365
5125
  function canonical(value) {
4366
5126
  if (value === null || typeof value === "string" || typeof value === "boolean") return value;
@@ -4411,7 +5171,7 @@ function parseArtifactManifest(absolute, data) {
4411
5171
  }
4412
5172
  async function readOptional(file) {
4413
5173
  try {
4414
- return await readFile12(file);
5174
+ return await readFile13(file);
4415
5175
  } catch (error) {
4416
5176
  if (isCode(error, "ENOENT")) return null;
4417
5177
  throw error;
@@ -4430,8 +5190,8 @@ function processIsAlive(pid) {
4430
5190
  }
4431
5191
  }
4432
5192
  function validTemporaryPath(candidate, destination, type) {
4433
- return path15.dirname(candidate) === path15.dirname(destination) && path15.basename(candidate).startsWith(
4434
- `.${path15.basename(destination)}.pixelkiln-${type}-`
5193
+ return path17.dirname(candidate) === path17.dirname(destination) && path17.basename(candidate).startsWith(
5194
+ `.${path17.basename(destination)}.pixelkiln-${type}-`
4435
5195
  );
4436
5196
  }
4437
5197
  function parseTransaction(journal, data) {
@@ -4447,14 +5207,14 @@ function parseTransaction(journal, data) {
4447
5207
  return raw;
4448
5208
  }
4449
5209
  async function removeTransactionFiles(journal) {
4450
- await rm5(journal, { force: true });
4451
- await rm5(transactionMarker(journal), { force: true });
5210
+ await rm6(journal, { force: true });
5211
+ await rm6(transactionMarker(journal), { force: true });
4452
5212
  }
4453
5213
  async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
4454
- const journal = path15.resolve(recoveryFile);
5214
+ const journal = path17.resolve(recoveryFile);
4455
5215
  const bytes = await readOptional(journal);
4456
5216
  if (!bytes) {
4457
- await rm5(transactionMarker(journal), { force: true });
5217
+ await rm6(transactionMarker(journal), { force: true });
4458
5218
  return;
4459
5219
  }
4460
5220
  if (activeTransactions.has(journal)) {
@@ -4467,7 +5227,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
4467
5227
  );
4468
5228
  }
4469
5229
  for (const entry of transaction.entries) {
4470
- if (typeof entry.destination !== "string" || typeof entry.stage !== "string" || typeof entry.sha256 !== "string" || entry.backup !== void 0 && typeof entry.backup !== "string" || !allowedDestinations.has(path15.resolve(entry.destination)) || !validTemporaryPath(entry.stage, entry.destination, "stage") || entry.backup !== void 0 && !validTemporaryPath(entry.backup, entry.destination, "backup")) {
5230
+ 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")) {
4471
5231
  throw new Error(
4472
5232
  `Refusing unsafe artifact recovery from ${journal}; its destinations or temporary paths do not match the current bundle.`
4473
5233
  );
@@ -4493,7 +5253,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
4493
5253
  }
4494
5254
  await remove2(entry.destination, errors);
4495
5255
  try {
4496
- await rename4(entry.backup, entry.destination);
5256
+ await rename5(entry.backup, entry.destination);
4497
5257
  } catch (error) {
4498
5258
  errors.push(
4499
5259
  `could not restore ${entry.destination}; previous file remains at ${entry.backup}: ${message(error)}`
@@ -4513,7 +5273,7 @@ async function recoverArtifactTransaction(recoveryFile, allowedDestinations) {
4513
5273
  await removeTransactionFiles(journal);
4514
5274
  }
4515
5275
  function createArtifactBundleManifest(manifestPath, outputs, provenance) {
4516
- const root = path15.dirname(path15.resolve(manifestPath));
5276
+ const root = path17.dirname(path17.resolve(manifestPath));
4517
5277
  const sources = provenance.sources.map((source) => ({
4518
5278
  ...source,
4519
5279
  path: portableRelative(root, source.path)
@@ -4543,11 +5303,11 @@ function withArtifactManifest(manifestPath, outputs, provenance) {
4543
5303
  ];
4544
5304
  }
4545
5305
  async function writeManagedArtifactBundle(manifestPath, outputs, provenance, options = {}) {
4546
- const absoluteManifest = path15.resolve(manifestPath);
5306
+ const absoluteManifest = path17.resolve(manifestPath);
4547
5307
  const recoveryFile = `${absoluteManifest}.transaction`;
4548
5308
  const allowedDestinations = /* @__PURE__ */ new Set([
4549
5309
  absoluteManifest,
4550
- ...outputs.map((output) => path15.resolve(output.path))
5310
+ ...outputs.map((output) => path17.resolve(output.path))
4551
5311
  ]);
4552
5312
  await recoverArtifactTransaction(recoveryFile, allowedDestinations);
4553
5313
  const existingManifestBytes = await readOptional(absoluteManifest);
@@ -4565,13 +5325,13 @@ async function writeManagedArtifactBundle(manifestPath, outputs, provenance, opt
4565
5325
  }
4566
5326
  }
4567
5327
  if (!options.force) {
4568
- const root = path15.dirname(absoluteManifest);
5328
+ const root = path17.dirname(absoluteManifest);
4569
5329
  const recorded = new Map(
4570
- previous?.outputs.map((output) => [path15.resolve(root, output.path), output.sha256]) ?? []
5330
+ previous?.outputs.map((output) => [path17.resolve(root, output.path), output.sha256]) ?? []
4571
5331
  );
4572
5332
  const conflicts = [];
4573
5333
  for (const output of outputs) {
4574
- const destination = path15.resolve(output.path);
5334
+ const destination = path17.resolve(output.path);
4575
5335
  const current = await readOptional(destination);
4576
5336
  if (!current || digest(current) === digest(output.data)) continue;
4577
5337
  const expected = recorded.get(destination);
@@ -4596,7 +5356,7 @@ function isCode(error, code) {
4596
5356
  }
4597
5357
  async function remove2(file, errors) {
4598
5358
  try {
4599
- await rm5(file, { force: true });
5359
+ await rm6(file, { force: true });
4600
5360
  } catch (error) {
4601
5361
  errors?.push(`could not remove ${file}: ${message(error)}`);
4602
5362
  }
@@ -4609,7 +5369,7 @@ async function rollback(prepared) {
4609
5369
  for (const artifact of [...prepared].reverse()) {
4610
5370
  if (!artifact.backup || !artifact.backedUp) continue;
4611
5371
  try {
4612
- await rename4(artifact.backup, artifact.destination);
5372
+ await rename5(artifact.backup, artifact.destination);
4613
5373
  artifact.backedUp = false;
4614
5374
  } catch (error) {
4615
5375
  errors.push(
@@ -4626,7 +5386,7 @@ async function writeArtifactBundle(files, options = {}) {
4626
5386
  if (!files.length) throw new Error("An artifact bundle must contain at least one file.");
4627
5387
  const normalized = files.map((file) => {
4628
5388
  if (!file.path.trim()) throw new Error("Artifact paths cannot be empty.");
4629
- return { destination: path15.resolve(file.path), data: Buffer.from(file.data) };
5389
+ return { destination: path17.resolve(file.path), data: Buffer.from(file.data) };
4630
5390
  });
4631
5391
  const destinations = /* @__PURE__ */ new Set();
4632
5392
  for (const artifact of normalized) {
@@ -4642,7 +5402,7 @@ async function writeArtifactBundle(files, options = {}) {
4642
5402
  const unchanged = [];
4643
5403
  for (const artifact of normalized) {
4644
5404
  try {
4645
- const current = await readFile12(artifact.destination);
5405
+ const current = await readFile13(artifact.destination);
4646
5406
  if (current.equals(artifact.data)) {
4647
5407
  unchanged.push(artifact.destination);
4648
5408
  } else {
@@ -4656,21 +5416,21 @@ async function writeArtifactBundle(files, options = {}) {
4656
5416
  if (!changed.length) return { changed: [], unchanged };
4657
5417
  const token = randomUUID2();
4658
5418
  for (const [index, artifact] of changed.entries()) {
4659
- const basename = path15.basename(artifact.destination);
4660
- artifact.stage = path15.join(
4661
- path15.dirname(artifact.destination),
5419
+ const basename = path17.basename(artifact.destination);
5420
+ artifact.stage = path17.join(
5421
+ path17.dirname(artifact.destination),
4662
5422
  `.${basename}.pixelkiln-stage-${token}-${index}`
4663
5423
  );
4664
5424
  if (artifact.existed) {
4665
- artifact.backup = path15.join(
4666
- path15.dirname(artifact.destination),
5425
+ artifact.backup = path17.join(
5426
+ path17.dirname(artifact.destination),
4667
5427
  `.${basename}.pixelkiln-backup-${token}-${index}`
4668
5428
  );
4669
5429
  }
4670
5430
  }
4671
- const journal = options.recoveryFile ? path15.resolve(options.recoveryFile) : null;
5431
+ const journal = options.recoveryFile ? path17.resolve(options.recoveryFile) : null;
4672
5432
  if (journal) {
4673
- await mkdir5(path15.dirname(journal), { recursive: true });
5433
+ await mkdir6(path17.dirname(journal), { recursive: true });
4674
5434
  const transaction = {
4675
5435
  format: "pixelkiln-artifact-transaction",
4676
5436
  version: 1,
@@ -4684,7 +5444,7 @@ async function writeArtifactBundle(files, options = {}) {
4684
5444
  }))
4685
5445
  };
4686
5446
  try {
4687
- await writeFile7(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
5447
+ await writeFile8(journal, JSON.stringify(transaction, null, 2) + "\n", { flag: "wx" });
4688
5448
  activeTransactions.add(journal);
4689
5449
  } catch (error) {
4690
5450
  throw new Error(
@@ -4695,9 +5455,9 @@ async function writeArtifactBundle(files, options = {}) {
4695
5455
  }
4696
5456
  try {
4697
5457
  for (const [index, artifact] of changed.entries()) {
4698
- await mkdir5(path15.dirname(artifact.destination), { recursive: true });
5458
+ await mkdir6(path17.dirname(artifact.destination), { recursive: true });
4699
5459
  await options.beforeStage?.(artifact.destination, index);
4700
- await writeFile7(artifact.stage, artifact.data, { flag: "wx" });
5460
+ await writeFile8(artifact.stage, artifact.data, { flag: "wx" });
4701
5461
  }
4702
5462
  } catch (error) {
4703
5463
  const cleanupErrors2 = await rollback(changed);
@@ -4710,17 +5470,17 @@ async function writeArtifactBundle(files, options = {}) {
4710
5470
  try {
4711
5471
  for (const artifact of changed) {
4712
5472
  if (!artifact.existed) continue;
4713
- await rename4(artifact.destination, artifact.backup);
5473
+ await rename5(artifact.destination, artifact.backup);
4714
5474
  artifact.backedUp = true;
4715
5475
  }
4716
5476
  for (const [index, artifact] of changed.entries()) {
4717
5477
  await options.beforePromote?.(artifact.destination, index);
4718
- await rename4(artifact.stage, artifact.destination);
5478
+ await rename5(artifact.stage, artifact.destination);
4719
5479
  artifact.stage = void 0;
4720
5480
  artifact.promoted = true;
4721
5481
  }
4722
5482
  if (journal) {
4723
- await writeFile7(transactionMarker(journal), `${token}
5483
+ await writeFile8(transactionMarker(journal), `${token}
4724
5484
  `, { flag: "wx" });
4725
5485
  durableCommit = true;
4726
5486
  await options.afterCommit?.();
@@ -4765,11 +5525,11 @@ async function writeArtifactBundle(files, options = {}) {
4765
5525
  // src/cli.ts
4766
5526
  var log = (msg = "") => console.log(msg);
4767
5527
  async function provenanceFile(id, file) {
4768
- const absolute = path16.resolve(file);
5528
+ const absolute = path18.resolve(file);
4769
5529
  return {
4770
5530
  id,
4771
5531
  path: absolute,
4772
- sha256: existsSync14(absolute) ? await sha256File(absolute) : null,
5532
+ sha256: existsSync15(absolute) ? await sha256File(absolute) : null,
4773
5533
  included: true
4774
5534
  };
4775
5535
  }
@@ -4793,7 +5553,10 @@ var VALUE_FLAGS = [
4793
5553
  "--max-distance",
4794
5554
  "--min-transparency",
4795
5555
  "--max-colors",
4796
- "--sigma"
5556
+ "--sigma",
5557
+ "--workspace",
5558
+ "--provider",
5559
+ "--account"
4797
5560
  ];
4798
5561
  var BOOL_FLAGS = [
4799
5562
  "--force",
@@ -4832,18 +5595,43 @@ var COMMANDS = [
4832
5595
  "tag",
4833
5596
  "balance",
4834
5597
  "status",
5598
+ "workspace",
4835
5599
  "help",
4836
5600
  "--help",
4837
5601
  "-h",
4838
5602
  "--version",
4839
5603
  "-v"
4840
5604
  ];
5605
+ var WORKSPACE_SUBCOMMANDS = ["add", "remove", "list", "status", "claims"];
4841
5606
  function parseArgs(argv) {
4842
5607
  const [command = "help"] = argv;
4843
5608
  if (!COMMANDS.includes(command)) {
4844
5609
  throw new Error(`Unknown command "${command}". Run \`pixelkiln help\` for the list.`);
4845
5610
  }
4846
- const rest = argv.slice(1);
5611
+ let rest = argv.slice(1);
5612
+ let subcommand;
5613
+ let target;
5614
+ if (command === "workspace") {
5615
+ subcommand = rest[0];
5616
+ if (subcommand === void 0 || subcommand.startsWith("-")) {
5617
+ throw new Error(`workspace needs a subcommand: ${WORKSPACE_SUBCOMMANDS.join(", ")}`);
5618
+ }
5619
+ if (!WORKSPACE_SUBCOMMANDS.includes(subcommand)) {
5620
+ throw new Error(
5621
+ `Unknown workspace subcommand "${subcommand}". Known: ${WORKSPACE_SUBCOMMANDS.join(", ")}`
5622
+ );
5623
+ }
5624
+ rest = rest.slice(1);
5625
+ if (subcommand === "add" || subcommand === "remove") {
5626
+ target = rest[0];
5627
+ if (target === void 0 || target.startsWith("-")) {
5628
+ throw new Error(
5629
+ subcommand === "add" ? "workspace add needs a manifest path." : "workspace remove needs a project id or manifest path."
5630
+ );
5631
+ }
5632
+ rest = rest.slice(1);
5633
+ }
5634
+ }
4847
5635
  for (let i = 0; i < rest.length; i++) {
4848
5636
  const token = rest[i];
4849
5637
  if (!token.startsWith("-")) {
@@ -4920,7 +5708,8 @@ function parseArgs(argv) {
4920
5708
  return {
4921
5709
  command,
4922
5710
  manifest,
4923
- lock: get("--lock") ?? path16.join(path16.dirname(path16.resolve(manifest)), "pixelkiln.lock.json"),
5711
+ lock: get("--lock") ?? path18.join(path18.dirname(path18.resolve(manifest)), "pixelkiln.lock.json"),
5712
+ explicitLock: get("--lock"),
4924
5713
  styles: list("--style"),
4925
5714
  assets: list("--only"),
4926
5715
  force: rest.includes("--force"),
@@ -4949,10 +5738,15 @@ function parseArgs(argv) {
4949
5738
  maxDistance: numberOption("--max-distance", { min: 0 }),
4950
5739
  minTransparency: numberOption("--min-transparency", { min: 0, max: 1 }),
4951
5740
  maxColors: numberOption("--max-colors", { min: 1, integer: true }),
4952
- sigma: numberOption("--sigma", { min: Number.EPSILON })
5741
+ sigma: numberOption("--sigma", { min: Number.EPSILON }),
5742
+ subcommand,
5743
+ workspace: get("--workspace"),
5744
+ target,
5745
+ provider: get("--provider"),
5746
+ account: get("--account")
4953
5747
  };
4954
5748
  }
4955
- var HELP = `pixelkiln \u2014 manifest-driven pixel art generation (PixelLab)
5749
+ var HELP = `pixelkiln \u2014 manifest-driven pixel art generation
4956
5750
 
4957
5751
  pixelkiln <command> [options]
4958
5752
 
@@ -4982,6 +5776,8 @@ Commands
4982
5776
  tag Push manifest tags to the objects upstream (free).
4983
5777
  balance Show the provider's remaining balance.
4984
5778
  status Summarise the lockfile.
5779
+ workspace Register sibling projects and derive account-wide claims/status.
5780
+ add/remove/list/status/claims. Offline.
4985
5781
 
4986
5782
  Options
4987
5783
  --columns <n> pack/export: sprites or tiles per row (default: near-square)
@@ -5009,6 +5805,8 @@ Options
5009
5805
  --no-open Do not auto-open the browser during pick
5010
5806
  --tag Also push tags upstream after fetch
5011
5807
  --claims a.json,b Other projects' lockfiles (salvage; required if account is shared)
5808
+ --workspace <path> Workspace catalog (default: pixelkiln.workspace.json). Also
5809
+ derives salvage's claim set instead of repeated --claims.
5012
5810
  --from <dir> Source tree for init
5013
5811
  --write-prompts adopt: recover prompts into the manifest
5014
5812
 
@@ -5021,6 +5819,9 @@ Examples
5021
5819
  pixelkiln pack --inputs sprites.json --out dist/sheet # no manifest needed
5022
5820
  pixelkiln mount --style ground
5023
5821
  pixelkiln export --style ground --only terrain --format tiled
5822
+ pixelkiln workspace add ../other-game/pixelkiln.manifest.json
5823
+ pixelkiln workspace status --json
5824
+ pixelkiln salvage --workspace pixelkiln.workspace.json
5024
5825
  `;
5025
5826
  function printPlan(plan) {
5026
5827
  const counts = summarize(plan);
@@ -5061,6 +5862,23 @@ async function confirm(question, auto) {
5061
5862
  process.stdin.pause();
5062
5863
  return answer === "y" || answer === "yes";
5063
5864
  }
5865
+ async function requireCompleteWorkspaceClaims(workspacePath) {
5866
+ if (!existsSync15(workspacePath)) {
5867
+ throw new Error(`Workspace catalog not found: ${workspacePath}`);
5868
+ }
5869
+ const dir = path18.dirname(path18.resolve(workspacePath));
5870
+ const ws = await loadWorkspace(workspacePath);
5871
+ const diagnostics = validateWorkspace(ws, dir);
5872
+ const errors = diagnostics.filter((d) => d.level === "error");
5873
+ if (errors.length) {
5874
+ throw new Error(
5875
+ `Workspace catalog at ${workspacePath} is not safe to derive a claim set from:
5876
+ ` + errors.map((d) => ` ${d.id}: ${d.message}`).join("\n")
5877
+ );
5878
+ }
5879
+ const claims = await workspaceClaims(ws, dir);
5880
+ return { ws, dir, diagnostics, claims };
5881
+ }
5064
5882
  async function main() {
5065
5883
  const args = parseArgs(process.argv.slice(2));
5066
5884
  if (args.command === "help" || args.command === "--help" || args.command === "-h") {
@@ -5069,16 +5887,17 @@ async function main() {
5069
5887
  }
5070
5888
  if (args.command === "--version" || args.command === "-v") {
5071
5889
  const pkg = JSON.parse(
5072
- await readFile13(new URL("../package.json", import.meta.url), "utf8")
5890
+ await readFile14(new URL("../package.json", import.meta.url), "utf8")
5073
5891
  );
5074
5892
  log(`${pkg.name} ${pkg.version}`);
5075
5893
  return;
5076
5894
  }
5077
5895
  if (args.command === "balance") {
5078
- loadEnvFiles(path16.dirname(path16.resolve(args.manifest)));
5896
+ loadEnvFiles(path18.dirname(path18.resolve(args.manifest)));
5079
5897
  loadEnvFiles(process.cwd());
5080
- const p = PixelLabProvider.fromEnv();
5081
- const b = await p.balance();
5898
+ const loaded2 = await loadManifest(args.manifest);
5899
+ const p = createProvider(loaded2.manifest.provider, "online");
5900
+ const b = await requireBalance(p)();
5082
5901
  log(` provider: ${p.id}`);
5083
5902
  log(` plan: ${b.plan ?? "n/a"}`);
5084
5903
  log(` remaining: ${formatCost(b.unit, b.remaining)}${b.total ? ` of ${b.total}` : ""}`);
@@ -5086,31 +5905,31 @@ async function main() {
5086
5905
  }
5087
5906
  if (args.command === "init") {
5088
5907
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
5089
- const root = path16.resolve(args.from);
5090
- if (!existsSync14(root)) throw new Error(`No directory at ${root}`);
5908
+ const root = path18.resolve(args.from);
5909
+ if (!existsSync15(root)) throw new Error(`No directory at ${root}`);
5091
5910
  const generator = args.generator ?? "map";
5092
5911
  if (generator !== "1dir" && generator !== "map") {
5093
5912
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
5094
5913
  }
5095
- const target = path16.resolve(args.out ?? "pixelkiln.manifest.json");
5914
+ const target = path18.resolve(args.out ?? "pixelkiln.manifest.json");
5096
5915
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
5097
5916
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
5098
5917
  const manifest = buildManifest(
5099
- args.name ?? path16.basename(path16.dirname(target)),
5918
+ args.name ?? path18.basename(path18.dirname(target)),
5100
5919
  args.styles[0] ?? "base",
5101
5920
  generator,
5102
- path16.relative(path16.dirname(target), root) || ".",
5921
+ path18.relative(path18.dirname(target), root) || ".",
5103
5922
  assets
5104
5923
  );
5105
5924
  await writeManifestFile(target, manifest);
5106
- log(` scanned ${assets.length} PNG(s) under ${path16.relative(process.cwd(), root)}`);
5925
+ log(` scanned ${assets.length} PNG(s) under ${path18.relative(process.cwd(), root)}`);
5107
5926
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
5108
- log(` wrote ${path16.relative(process.cwd(), target)}`);
5927
+ log(` wrote ${path18.relative(process.cwd(), target)}`);
5109
5928
  log(`
5110
5929
  Prompts are intentionally empty. To recover the real ones from your`);
5111
5930
  log(` PixelLab account instead of inventing them:`);
5112
5931
  log(`
5113
- pixelkiln adopt --manifest ${path16.relative(process.cwd(), target)} --write-prompts
5932
+ pixelkiln adopt --manifest ${path18.relative(process.cwd(), target)} --write-prompts
5114
5933
  `);
5115
5934
  return;
5116
5935
  }
@@ -5158,10 +5977,10 @@ async function main() {
5158
5977
  if (args.primaryOnly || args.outputRoles.length) {
5159
5978
  throw new Error("--primary-only and --output-role require manifest-driven pack");
5160
5979
  }
5161
- const raw = JSON.parse(await readFile13(path16.resolve(args.inputs), "utf8"));
5980
+ const raw = JSON.parse(await readFile14(path18.resolve(args.inputs), "utf8"));
5162
5981
  const inputs = resolvePackInputs(raw, args.inputs);
5163
5982
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
5164
- const base = path16.resolve(args.out.replace(/\.png$/, ""));
5983
+ const base = path18.resolve(args.out.replace(/\.png$/, ""));
5165
5984
  const outputs = [
5166
5985
  { path: `${base}.png`, data: png },
5167
5986
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -5174,20 +5993,145 @@ async function main() {
5174
5993
  log(
5175
5994
  ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
5176
5995
  );
5177
- log(` ${path16.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
5996
+ log(` ${path18.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
5178
5997
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
5179
5998
  return;
5180
5999
  }
5181
- if (!existsSync14(path16.resolve(args.manifest))) {
6000
+ if (args.command === "workspace") {
6001
+ const workspacePath = path18.resolve(args.workspace ?? "pixelkiln.workspace.json");
6002
+ const dir = path18.dirname(workspacePath);
6003
+ if (args.subcommand === "add") {
6004
+ const manifestPath = path18.resolve(args.target);
6005
+ const loadedTarget = await loadManifest(manifestPath);
6006
+ const lockPath = args.explicitLock ? path18.resolve(args.explicitLock) : path18.join(path18.dirname(manifestPath), "pixelkiln.lock.json");
6007
+ const ws = await loadWorkspace(workspacePath);
6008
+ const id = args.name ?? loadedTarget.manifest.name;
6009
+ if (ws.projects.some((p) => p.id === id)) {
6010
+ throw new Error(
6011
+ `Project id "${id}" is already registered in ${workspacePath}. Pass --name for a different id.`
6012
+ );
6013
+ }
6014
+ const lockOwner = ws.projects.find((p) => resolveProject(dir, p).lockPath === lockPath);
6015
+ if (lockOwner) {
6016
+ throw new Error(`Lockfile ${lockPath} is already registered under project id "${lockOwner.id}".`);
6017
+ }
6018
+ const project = {
6019
+ id,
6020
+ manifest: toPortablePath(dir, manifestPath),
6021
+ lock: toPortablePath(dir, lockPath),
6022
+ provider: args.provider ?? loadedTarget.manifest.provider,
6023
+ ...args.account ? { account: args.account } : {}
6024
+ };
6025
+ await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
6026
+ log(` registered "${id}" in ${path18.relative(process.cwd(), workspacePath)}`);
6027
+ log(` manifest: ${project.manifest}`);
6028
+ log(` lock: ${project.lock}`);
6029
+ if (!existsSync15(lockPath)) {
6030
+ log(
6031
+ ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
6032
+ );
6033
+ }
6034
+ return;
6035
+ }
6036
+ if (args.subcommand === "remove") {
6037
+ const ws = await loadWorkspace(workspacePath);
6038
+ const resolvedTarget = path18.resolve(args.target);
6039
+ const match = ws.projects.find(
6040
+ (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
6041
+ );
6042
+ if (!match) {
6043
+ throw new Error(`No registered project matches "${args.target}" (checked id and manifest path).`);
6044
+ }
6045
+ await saveWorkspace(workspacePath, {
6046
+ version: 1,
6047
+ projects: ws.projects.filter((p) => p !== match)
6048
+ });
6049
+ log(` removed "${match.id}" from ${path18.relative(process.cwd(), workspacePath)}`);
6050
+ return;
6051
+ }
6052
+ if (args.subcommand === "list") {
6053
+ if (!existsSync15(workspacePath)) {
6054
+ throw new Error(`Workspace catalog not found: ${workspacePath}`);
6055
+ }
6056
+ const ws = await loadWorkspace(workspacePath);
6057
+ const diagnostics = validateWorkspace(ws, dir);
6058
+ if (args.json) {
6059
+ log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
6060
+ } else if (!ws.projects.length) {
6061
+ log(` no projects registered in ${path18.relative(process.cwd(), workspacePath)}`);
6062
+ } else {
6063
+ log(` ${ws.projects.length} project(s) in ${path18.relative(process.cwd(), workspacePath)}:`);
6064
+ for (const p of ws.projects) {
6065
+ log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
6066
+ }
6067
+ for (const d of diagnostics) log(` ${d.level === "error" ? "ERROR" : "WARN "} ${d.id.padEnd(18)} ${d.message}`);
6068
+ }
6069
+ if (args.check && diagnostics.some((d) => d.level === "error")) process.exitCode = 1;
6070
+ return;
6071
+ }
6072
+ if (args.subcommand === "status") {
6073
+ if (!existsSync15(workspacePath)) {
6074
+ throw new Error(`Workspace catalog not found: ${workspacePath}`);
6075
+ }
6076
+ const ws = await loadWorkspace(workspacePath);
6077
+ const report = await workspaceStatus(ws, dir);
6078
+ if (args.json) {
6079
+ log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
6080
+ } else {
6081
+ log(` workspace: ${path18.relative(process.cwd(), workspacePath)}`);
6082
+ for (const p of report.projects) {
6083
+ if (p.error) {
6084
+ log(`
6085
+ ${p.id} \u2014 ERROR: ${p.error}`);
6086
+ continue;
6087
+ }
6088
+ log(`
6089
+ ${p.id} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
6090
+ log(` ${p.entries} lock entries`);
6091
+ for (const [state, n] of Object.entries(p.byState)) if (n) log(` ${state.padEnd(12)} ${n}`);
6092
+ for (const [unit, amount] of Object.entries(p.spendByUnit).sort()) {
6093
+ if (amount) log(` spend: ${formatCost(unit, amount)}`);
6094
+ }
6095
+ }
6096
+ log(`
6097
+ totals:`);
6098
+ for (const [state, n] of Object.entries(report.totals.byState)) if (n) log(` ${state.padEnd(12)} ${n}`);
6099
+ for (const [unit, amount] of Object.entries(report.totals.spendByUnit).sort()) {
6100
+ if (amount) log(` spend: ${formatCost(unit, amount)}`);
6101
+ }
6102
+ log(` claims: ${report.totals.claims}`);
6103
+ for (const d of report.diagnostics) log(` ${d.level === "error" ? "ERROR" : "WARN "} ${d.id.padEnd(18)} ${d.message}`);
6104
+ }
6105
+ if (args.check && !report.safe) process.exitCode = 1;
6106
+ return;
6107
+ }
6108
+ if (args.subcommand === "claims") {
6109
+ const { claims, diagnostics } = await requireCompleteWorkspaceClaims(workspacePath);
6110
+ if (args.json) {
6111
+ log(JSON.stringify({
6112
+ version: 1,
6113
+ claimed: [...claims.claimed].sort(),
6114
+ byProject: claims.byProject,
6115
+ lockPaths: claims.lockPaths
6116
+ }, null, 2));
6117
+ } else {
6118
+ log(` ${claims.claimed.size} claimed id(s) across ${claims.lockPaths.length} lockfile(s):`);
6119
+ for (const [id, n] of Object.entries(claims.byProject).sort()) log(` ${id.padEnd(24)} ${n}`);
6120
+ for (const d of diagnostics) log(` WARN ${d.id.padEnd(18)} ${d.message}`);
6121
+ }
6122
+ return;
6123
+ }
6124
+ }
6125
+ if (!existsSync15(path18.resolve(args.manifest))) {
5182
6126
  throw new Error(
5183
- `No manifest at ${path16.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
6127
+ `No manifest at ${path18.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
5184
6128
  );
5185
6129
  }
5186
- const manifestDir = path16.dirname(path16.resolve(args.manifest));
6130
+ const manifestDir = path18.dirname(path18.resolve(args.manifest));
5187
6131
  const envFiles = [...loadEnvFiles(manifestDir)];
5188
- if (path16.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
6132
+ if (path18.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
5189
6133
  const loaded = await loadManifest(args.manifest);
5190
- const estimator = PixelLabProvider.forOffline();
6134
+ const estimator = createProvider(loaded.manifest.provider, "offline");
5191
6135
  const specs = await resolveSpecs(loaded, {
5192
6136
  styles: args.styles,
5193
6137
  assets: args.assets,
@@ -5206,9 +6150,9 @@ async function main() {
5206
6150
  for (const [s, n] of Object.entries(byStatus).sort()) log(` ${s.padEnd(12)} ${n}`);
5207
6151
  const spend = spendByUnit(lock);
5208
6152
  let reported = false;
5209
- for (const unit of ["generations", "usd", "free"]) {
5210
- if (spend[unit]) {
5211
- log(` recorded successful submissions: ${formatCost(unit, spend[unit])}`);
6153
+ for (const [unit, amount] of Object.entries(spend).sort()) {
6154
+ if (amount) {
6155
+ log(` recorded successful submissions: ${formatCost(unit, amount)}`);
5212
6156
  reported = true;
5213
6157
  }
5214
6158
  }
@@ -5217,12 +6161,14 @@ async function main() {
5217
6161
  }
5218
6162
  const plan = await buildPlan(specs, lock, { force: args.force });
5219
6163
  if (args.command === "doctor") {
5220
- const apiKeyPresent = Boolean(process.env.PIXELLAB_API_KEY);
5221
- const provider2 = !args.dryRun && apiKeyPresent ? PixelLabProvider.fromEnv() : void 0;
6164
+ const factory = providerFactory(loaded.manifest.provider);
6165
+ const apiKeyPresent = !factory.credentialEnv || Boolean(process.env[factory.credentialEnv]);
6166
+ const provider2 = !args.dryRun && apiKeyPresent ? createProvider(loaded.manifest.provider, "online") : void 0;
5222
6167
  const report = await doctor(loaded, specs, lock, args.lock, {
5223
6168
  provider: provider2,
5224
6169
  offline: args.dryRun,
5225
- apiKeyPresent
6170
+ apiKeyPresent,
6171
+ credentialEnv: factory.credentialEnv
5226
6172
  });
5227
6173
  if (args.json) {
5228
6174
  log(JSON.stringify(report, null, 2));
@@ -5260,7 +6206,7 @@ async function main() {
5260
6206
  let intact = true;
5261
6207
  for (const output of entry.outputs) {
5262
6208
  const file = resolveOutputPath(output.path, item.spec.root);
5263
- if (!existsSync14(file) || await sha256File(file) !== output.sha256) {
6209
+ if (!existsSync15(file) || await sha256File(file) !== output.sha256) {
5264
6210
  intact = false;
5265
6211
  break;
5266
6212
  }
@@ -5318,7 +6264,7 @@ async function main() {
5318
6264
  if (args.primaryOnly && args.outputRoles.length) {
5319
6265
  throw new Error("pack accepts either --primary-only or --output-role, not both");
5320
6266
  }
5321
- const manifestDir2 = path16.dirname(path16.resolve(args.manifest));
6267
+ const manifestDir2 = path18.dirname(path18.resolve(args.manifest));
5322
6268
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
5323
6269
  for (const styleId of styleIds) {
5324
6270
  const { png, atlas, skipped, sources } = packStyle(lock, styleId, manifestDir2, {
@@ -5327,7 +6273,7 @@ async function main() {
5327
6273
  primaryOnly: args.primaryOnly
5328
6274
  });
5329
6275
  const style = loaded.manifest.styles[styleId];
5330
- const base = args.out ? path16.resolve(args.out.replace(/\.png$/, "")) : path16.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
6276
+ const base = args.out ? path18.resolve(args.out.replace(/\.png$/, "")) : path18.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
5331
6277
  const outputs = [
5332
6278
  { path: `${base}.png`, data: png },
5333
6279
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -5350,13 +6296,13 @@ async function main() {
5350
6296
  log(
5351
6297
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
5352
6298
  );
5353
- log(` ${path16.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
6299
+ log(` ${path18.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
5354
6300
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
5355
6301
  }
5356
6302
  return;
5357
6303
  }
5358
6304
  if (args.command === "mount") {
5359
- const manifestDir2 = path16.dirname(path16.resolve(args.manifest));
6305
+ const manifestDir2 = path18.dirname(path18.resolve(args.manifest));
5360
6306
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
5361
6307
  for (const styleId of styleIds) {
5362
6308
  const style = loaded.manifest.styles[styleId];
@@ -5386,7 +6332,7 @@ async function main() {
5386
6332
  sources,
5387
6333
  outputRoles
5388
6334
  );
5389
- const out = path16.resolve(manifestDir2, style.mount.out);
6335
+ const out = path18.resolve(manifestDir2, style.mount.out);
5390
6336
  const metadata = out.replace(/\.png$/, "") + ".json";
5391
6337
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
5392
6338
  const outputs = [
@@ -5399,7 +6345,7 @@ async function main() {
5399
6345
  await provenanceFile("$manifest", args.manifest),
5400
6346
  await provenanceFile("$lock", args.lock),
5401
6347
  ...artifactSources.filter(
5402
- (source) => source.id !== "$base" || path16.resolve(source.path) !== out
6348
+ (source) => source.id !== "$base" || path18.resolve(source.path) !== out
5403
6349
  )
5404
6350
  ],
5405
6351
  options: {
@@ -5412,7 +6358,7 @@ async function main() {
5412
6358
  log(
5413
6359
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
5414
6360
  );
5415
- log(` ${path16.relative(process.cwd(), out)} + atlas/provenance JSON`);
6361
+ log(` ${path18.relative(process.cwd(), out)} + atlas/provenance JSON`);
5416
6362
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
5417
6363
  }
5418
6364
  return;
@@ -5468,7 +6414,7 @@ async function main() {
5468
6414
  }
5469
6415
  if (args.command === "export") {
5470
6416
  const format = args.format ?? "generic";
5471
- const manifestDir2 = path16.dirname(path16.resolve(args.manifest));
6417
+ const manifestDir2 = path18.dirname(path18.resolve(args.manifest));
5472
6418
  const selected = specs.filter((spec) => {
5473
6419
  if (spec.generator !== "tiles") return false;
5474
6420
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -5484,12 +6430,12 @@ async function main() {
5484
6430
  for (const spec of selected) {
5485
6431
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5486
6432
  const style = loaded.manifest.styles[spec.styleId];
5487
- const defaultBase = path16.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
5488
- const base = args.out ? path16.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
6433
+ const defaultBase = path18.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
6434
+ const base = args.out ? path18.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
5489
6435
  const result = exportTileset(entry, spec, {
5490
6436
  format,
5491
6437
  manifestDir: manifestDir2,
5492
- imageName: path16.basename(`${base}.png`),
6438
+ imageName: path18.basename(`${base}.png`),
5493
6439
  columns: args.columns
5494
6440
  });
5495
6441
  const outputs = [
@@ -5507,7 +6453,7 @@ async function main() {
5507
6453
  asset: spec.assetId,
5508
6454
  columns: args.columns ?? null,
5509
6455
  format,
5510
- image: path16.basename(`${base}.png`),
6456
+ image: path18.basename(`${base}.png`),
5511
6457
  providerRules: result.generic.providerRules,
5512
6458
  style: spec.styleId,
5513
6459
  tileType: spec.tileType ?? null
@@ -5517,12 +6463,12 @@ async function main() {
5517
6463
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
5518
6464
  );
5519
6465
  log(
5520
- ` ${path16.relative(process.cwd(), base)}.png + ${path16.basename(base)}${result.extension} + .pixelkiln.json`
6466
+ ` ${path18.relative(process.cwd(), base)}.png + ${path18.basename(base)}${result.extension} + .pixelkiln.json`
5521
6467
  );
5522
6468
  }
5523
6469
  return;
5524
6470
  }
5525
- const provider = args.command === "restore" || args.command === "fetch" && !args.tag ? PixelLabProvider.forDownloads() : PixelLabProvider.fromEnv();
6471
+ const provider = args.command === "restore" || args.command === "fetch" && !args.tag ? createProvider(loaded.manifest.provider, "downloads") : createProvider(loaded.manifest.provider, "online");
5526
6472
  if (args.command === "adopt") {
5527
6473
  log(`
5528
6474
  Reconciling account objects against files already on disk\u2026`);
@@ -5557,10 +6503,10 @@ async function main() {
5557
6503
  tagged ${n} object(s) upstream`);
5558
6504
  }
5559
6505
  if (args.writePrompts) {
5560
- const { filled, stillEmpty } = await writePromptsBack(path16.resolve(args.manifest), lock, {
6506
+ const { filled, stillEmpty } = await writePromptsBack(path18.resolve(args.manifest), lock, {
5561
6507
  onProgress: log
5562
6508
  });
5563
- log(` recovered ${filled} prompt(s) into ${path16.relative(process.cwd(), args.manifest)}`);
6509
+ log(` recovered ${filled} prompt(s) into ${path18.relative(process.cwd(), args.manifest)}`);
5564
6510
  const reloaded = await loadManifest(args.manifest);
5565
6511
  const rebased = await resolveSpecs(reloaded, {
5566
6512
  styles: args.styles,
@@ -5589,19 +6535,39 @@ async function main() {
5589
6535
  if (args.command === "salvage") {
5590
6536
  const jsonMode = args.dryRun && args.json;
5591
6537
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
5592
- const ownLock = path16.resolve(args.lock);
6538
+ const ownLock = path18.resolve(args.lock);
6539
+ let workspaceProjects = [];
6540
+ let workspaceDir = "";
6541
+ if (args.workspace) {
6542
+ const workspacePath = path18.resolve(args.workspace);
6543
+ workspaceDir = path18.dirname(workspacePath);
6544
+ const complete = await requireCompleteWorkspaceClaims(workspacePath);
6545
+ workspaceProjects = complete.ws.projects;
6546
+ for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
6547
+ }
6548
+ const workspaceLockPaths = workspaceProjects.map((p) => resolveProject(workspaceDir, p).lockPath);
5593
6549
  const lockPaths = [
5594
- ...existsSync14(ownLock) ? [ownLock] : [],
5595
- ...args.claims.map((c) => path16.resolve(c))
6550
+ .../* @__PURE__ */ new Set([
6551
+ ...workspaceLockPaths,
6552
+ ...existsSync15(ownLock) ? [ownLock] : [],
6553
+ ...args.claims.map((c) => path18.resolve(c))
6554
+ ])
5596
6555
  ];
5597
6556
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
5598
- for (const p of lockPaths) diag(` ${path16.relative(process.cwd(), p)}`);
5599
- if (!args.claims.length) {
6557
+ for (const p of lockPaths) diag(` ${path18.relative(process.cwd(), p)}`);
6558
+ if (!args.claims.length && !args.workspace) {
5600
6559
  diag(
5601
6560
  `
5602
6561
  Only this project's lockfile was consulted. If the account is shared,
5603
- pass every other project's lockfile via --claims a.json,b.json or you
5604
- will see their assets listed as unclaimed.`
6562
+ pass every other project's lockfile via --claims a.json,b.json, or
6563
+ register every project in a workspace catalog and pass --workspace.`
6564
+ );
6565
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path18.resolve(args.manifest))) {
6566
+ diag(
6567
+ `
6568
+ This project's manifest is not registered in the workspace catalog. Its own
6569
+ lockfile is still included above, so this run's claim set is complete \u2014 but
6570
+ \`pixelkiln workspace add ${args.manifest}\` would keep it aggregated too.`
5605
6571
  );
5606
6572
  }
5607
6573
  const claimed = await loadClaims(lockPaths);
@@ -5613,16 +6579,11 @@ async function main() {
5613
6579
  nothing to triage`);
5614
6580
  return;
5615
6581
  }
5616
- const siblings = [];
5617
- for (const claimPath of args.claims.map((c) => path16.resolve(c))) {
5618
- const siblingManifestPath = path16.join(path16.dirname(claimPath), "pixelkiln.manifest.json");
5619
- if (!existsSync14(siblingManifestPath)) continue;
5620
- try {
5621
- const siblingLoaded = await loadManifest(siblingManifestPath);
5622
- siblings.push({ label: path16.basename(path16.dirname(siblingManifestPath)), manifest: siblingLoaded.manifest });
5623
- } catch {
5624
- }
5625
- }
6582
+ const siblings = await loadSiblingManifests(
6583
+ args.manifest,
6584
+ workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
6585
+ args.claims
6586
+ );
5626
6587
  const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, loaded.manifest, siblings);
5627
6588
  const multiStyle = Object.keys(loaded.manifest.styles).length > 1;
5628
6589
  if (multiStyle) {
@@ -5685,7 +6646,7 @@ async function main() {
5685
6646
  manifestPath: loaded.path,
5686
6647
  manifest: loaded.manifest,
5687
6648
  styleId,
5688
- importDir: path16.resolve(loaded.root, style.outDir),
6649
+ importDir: path18.resolve(loaded.root, style.outDir),
5689
6650
  lock,
5690
6651
  lockPath: args.lock
5691
6652
  },
@@ -5750,7 +6711,7 @@ async function main() {
5750
6711
  return;
5751
6712
  }
5752
6713
  log(`
5753
- This permanently deletes them from your PixelLab account.`);
6714
+ This permanently deletes them from your ${provider.id} account.`);
5754
6715
  log(` Any local files already downloaded are untouched, but the objects`);
5755
6716
  log(` and their URLs are gone and cannot be re-downloaded.`);
5756
6717
  if (!await confirm(` Delete ${doomed.length} object(s)?`, args.yes)) {
@@ -5785,21 +6746,26 @@ async function main() {
5785
6746
  return;
5786
6747
  }
5787
6748
  if (plan.actionable.length) {
5788
- const balance = await provider.balance();
5789
- log(`
6749
+ const balance = provider.balance ? await provider.balance() : null;
6750
+ if (balance) {
6751
+ log(`
5790
6752
  balance: ${formatCost(balance.unit, balance.remaining)} remaining (${provider.id})`);
5791
- if (balance.unit !== plan.costUnit) {
5792
- throw new Error(
5793
- `Provider estimate unit ${plan.costUnit} does not match balance unit ${balance.unit}.`
5794
- );
5795
- }
5796
- if (balance.unit !== "free" && plan.cost > balance.remaining) {
5797
- throw new Error(
5798
- `This run needs ${formatCost(balance.unit, plan.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
5799
- );
6753
+ if (balance.unit !== plan.costUnit) {
6754
+ throw new Error(
6755
+ `Provider estimate unit ${plan.costUnit} does not match balance unit ${balance.unit}.`
6756
+ );
6757
+ }
6758
+ if (balance.unit !== "free" && plan.cost > balance.remaining) {
6759
+ throw new Error(
6760
+ `This run needs ${formatCost(balance.unit, plan.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
6761
+ );
6762
+ }
6763
+ } else {
6764
+ log(`
6765
+ ${provider.id} does not expose an account balance; enforcing the explicit run budget`);
5800
6766
  }
5801
6767
  const ok = await confirm(
5802
- ` Spend ${formatCost(balance.unit, plan.cost)} on ${plan.actionable.length} asset(s)?`,
6768
+ ` Spend ${formatCost(plan.costUnit, plan.cost)} on ${plan.actionable.length} asset(s)?`,
5803
6769
  args.yes
5804
6770
  );
5805
6771
  if (!ok) {
@@ -5817,7 +6783,8 @@ async function main() {
5817
6783
  submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`
5818
6784
  );
5819
6785
  try {
5820
- const after = await provider.balance();
6786
+ const after = balance && provider.balance ? await provider.balance() : null;
6787
+ if (!balance || !after) throw new Error("balance reporting is unsupported");
5821
6788
  const measured = measureBalanceChange(balance, after);
5822
6789
  if (measured) {
5823
6790
  const movement = measured.credited ? `${formatCost(measured.unit, measured.credited)} credited` : `${formatCost(measured.unit, measured.spent)} consumed`;