pixelkiln 0.3.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
@@ -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";
@@ -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";
@@ -1359,6 +1855,10 @@ async function sha256File(path19) {
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)}`);
@@ -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
@@ -3727,7 +4243,6 @@ function emptyStateCounts() {
3727
4243
  }
3728
4244
  async function workspaceStatus(ws, dir) {
3729
4245
  const diagnostics = validateWorkspace(ws, dir);
3730
- const provider = PixelLabProvider.forOffline();
3731
4246
  const projects = [];
3732
4247
  const totalsByState = emptyStateCounts();
3733
4248
  const totalsSpend = { generations: 0, usd: 0, free: 0 };
@@ -3741,6 +4256,7 @@ async function workspaceStatus(ws, dir) {
3741
4256
  lock: lockPath
3742
4257
  };
3743
4258
  try {
4259
+ const provider = createProvider(project.provider, "offline");
3744
4260
  const loaded = await loadManifest(manifestPath);
3745
4261
  const specs = await resolveSpecs(loaded, { provider });
3746
4262
  const lock = await loadLock(lockPath);
@@ -3752,7 +4268,7 @@ async function workspaceStatus(ws, dir) {
3752
4268
  totalsByState[state] += byState[state];
3753
4269
  }
3754
4270
  for (const unit of Object.keys(spend)) {
3755
- totalsSpend[unit] += spend[unit];
4271
+ totalsSpend[unit] = (totalsSpend[unit] ?? 0) + (spend[unit] ?? 0);
3756
4272
  }
3757
4273
  projects.push({
3758
4274
  ...base,
@@ -3945,7 +4461,6 @@ function hex(c) {
3945
4461
  import { existsSync as existsSync14 } from "fs";
3946
4462
  import { readFile as readFile11, readdir as readdir2, rm as rm5 } from "fs/promises";
3947
4463
  import path15 from "path";
3948
- var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3949
4464
  async function inspectCaches(lock, lockPath, options = {}) {
3950
4465
  if (options.prune && !existsSync14(lockPath)) {
3951
4466
  throw new Error(`Refusing to prune without an existing lockfile at ${path15.resolve(lockPath)}`);
@@ -4014,7 +4529,8 @@ async function inspectContentCache(contentDir, referenced) {
4014
4529
  }
4015
4530
  report.files++;
4016
4531
  const file = path15.join(contentDir, entry.name);
4017
- const expected = entry.name.endsWith(".png") ? entry.name.slice(0, -4) : "";
4532
+ const mediaType = mediaTypeFromExtension(entry.name);
4533
+ const expected = mediaType ? entry.name.slice(0, -4) : "";
4018
4534
  let bytes;
4019
4535
  try {
4020
4536
  bytes = await readFile11(file);
@@ -4027,11 +4543,7 @@ async function inspectContentCache(contentDir, referenced) {
4027
4543
  continue;
4028
4544
  }
4029
4545
  if (!isSha256Hash(expected)) {
4030
- report.invalid.push({ name: entry.name, reason: "filename is not <sha256>.png" });
4031
- continue;
4032
- }
4033
- if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE2)) {
4034
- 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" });
4035
4547
  continue;
4036
4548
  }
4037
4549
  if (sha256(bytes) !== expected) {
@@ -4039,11 +4551,11 @@ async function inspectContentCache(contentDir, referenced) {
4039
4551
  continue;
4040
4552
  }
4041
4553
  try {
4042
- decodePng(bytes);
4554
+ validateMedia(bytes, mediaType);
4043
4555
  } catch (err) {
4044
4556
  report.invalid.push({
4045
4557
  name: entry.name,
4046
- 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)}`
4047
4559
  });
4048
4560
  continue;
4049
4561
  }
@@ -4499,7 +5011,7 @@ refresh();
4499
5011
  }
4500
5012
 
4501
5013
  // src/pick/salvage-server.ts
4502
- 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]);
4503
5015
  async function runSalvage(provider, orphans, ctx, opts = {}) {
4504
5016
  const log2 = opts.onProgress ?? (() => {
4505
5017
  });
@@ -4531,7 +5043,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
4531
5043
  if (decision.action === "import") {
4532
5044
  try {
4533
5045
  const buf = await provider.download(orphan.previewUrl);
4534
- 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");
4535
5047
  decodePng(buf);
4536
5048
  const assetId = idFromPrompt(orphan.prompt, taken);
4537
5049
  const rel = path16.join("_salvaged", `${assetId}.png`);
@@ -5234,7 +5746,7 @@ function parseArgs(argv) {
5234
5746
  account: get("--account")
5235
5747
  };
5236
5748
  }
5237
- var HELP = `pixelkiln \u2014 manifest-driven pixel art generation (PixelLab)
5749
+ var HELP = `pixelkiln \u2014 manifest-driven pixel art generation
5238
5750
 
5239
5751
  pixelkiln <command> [options]
5240
5752
 
@@ -5383,8 +5895,9 @@ async function main() {
5383
5895
  if (args.command === "balance") {
5384
5896
  loadEnvFiles(path18.dirname(path18.resolve(args.manifest)));
5385
5897
  loadEnvFiles(process.cwd());
5386
- const p = PixelLabProvider.fromEnv();
5387
- 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)();
5388
5901
  log(` provider: ${p.id}`);
5389
5902
  log(` plan: ${b.plan ?? "n/a"}`);
5390
5903
  log(` remaining: ${formatCost(b.unit, b.remaining)}${b.total ? ` of ${b.total}` : ""}`);
@@ -5506,7 +6019,7 @@ async function main() {
5506
6019
  id,
5507
6020
  manifest: toPortablePath(dir, manifestPath),
5508
6021
  lock: toPortablePath(dir, lockPath),
5509
- provider: args.provider ?? "pixellab",
6022
+ provider: args.provider ?? loadedTarget.manifest.provider,
5510
6023
  ...args.account ? { account: args.account } : {}
5511
6024
  };
5512
6025
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
@@ -5576,15 +6089,15 @@ async function main() {
5576
6089
  ${p.id} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
5577
6090
  log(` ${p.entries} lock entries`);
5578
6091
  for (const [state, n] of Object.entries(p.byState)) if (n) log(` ${state.padEnd(12)} ${n}`);
5579
- for (const unit of ["generations", "usd", "free"]) {
5580
- if (p.spendByUnit[unit]) log(` spend: ${formatCost(unit, p.spendByUnit[unit])}`);
6092
+ for (const [unit, amount] of Object.entries(p.spendByUnit).sort()) {
6093
+ if (amount) log(` spend: ${formatCost(unit, amount)}`);
5581
6094
  }
5582
6095
  }
5583
6096
  log(`
5584
6097
  totals:`);
5585
6098
  for (const [state, n] of Object.entries(report.totals.byState)) if (n) log(` ${state.padEnd(12)} ${n}`);
5586
- for (const unit of ["generations", "usd", "free"]) {
5587
- if (report.totals.spendByUnit[unit]) log(` spend: ${formatCost(unit, report.totals.spendByUnit[unit])}`);
6099
+ for (const [unit, amount] of Object.entries(report.totals.spendByUnit).sort()) {
6100
+ if (amount) log(` spend: ${formatCost(unit, amount)}`);
5588
6101
  }
5589
6102
  log(` claims: ${report.totals.claims}`);
5590
6103
  for (const d of report.diagnostics) log(` ${d.level === "error" ? "ERROR" : "WARN "} ${d.id.padEnd(18)} ${d.message}`);
@@ -5618,7 +6131,7 @@ async function main() {
5618
6131
  const envFiles = [...loadEnvFiles(manifestDir)];
5619
6132
  if (path18.resolve(process.cwd()) !== manifestDir) envFiles.push(...loadEnvFiles(process.cwd()));
5620
6133
  const loaded = await loadManifest(args.manifest);
5621
- const estimator = PixelLabProvider.forOffline();
6134
+ const estimator = createProvider(loaded.manifest.provider, "offline");
5622
6135
  const specs = await resolveSpecs(loaded, {
5623
6136
  styles: args.styles,
5624
6137
  assets: args.assets,
@@ -5637,9 +6150,9 @@ async function main() {
5637
6150
  for (const [s, n] of Object.entries(byStatus).sort()) log(` ${s.padEnd(12)} ${n}`);
5638
6151
  const spend = spendByUnit(lock);
5639
6152
  let reported = false;
5640
- for (const unit of ["generations", "usd", "free"]) {
5641
- if (spend[unit]) {
5642
- 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)}`);
5643
6156
  reported = true;
5644
6157
  }
5645
6158
  }
@@ -5648,12 +6161,14 @@ async function main() {
5648
6161
  }
5649
6162
  const plan = await buildPlan(specs, lock, { force: args.force });
5650
6163
  if (args.command === "doctor") {
5651
- const apiKeyPresent = Boolean(process.env.PIXELLAB_API_KEY);
5652
- 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;
5653
6167
  const report = await doctor(loaded, specs, lock, args.lock, {
5654
6168
  provider: provider2,
5655
6169
  offline: args.dryRun,
5656
- apiKeyPresent
6170
+ apiKeyPresent,
6171
+ credentialEnv: factory.credentialEnv
5657
6172
  });
5658
6173
  if (args.json) {
5659
6174
  log(JSON.stringify(report, null, 2));
@@ -5953,7 +6468,7 @@ async function main() {
5953
6468
  }
5954
6469
  return;
5955
6470
  }
5956
- 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");
5957
6472
  if (args.command === "adopt") {
5958
6473
  log(`
5959
6474
  Reconciling account objects against files already on disk\u2026`);
@@ -6196,7 +6711,7 @@ async function main() {
6196
6711
  return;
6197
6712
  }
6198
6713
  log(`
6199
- This permanently deletes them from your PixelLab account.`);
6714
+ This permanently deletes them from your ${provider.id} account.`);
6200
6715
  log(` Any local files already downloaded are untouched, but the objects`);
6201
6716
  log(` and their URLs are gone and cannot be re-downloaded.`);
6202
6717
  if (!await confirm(` Delete ${doomed.length} object(s)?`, args.yes)) {
@@ -6231,21 +6746,26 @@ async function main() {
6231
6746
  return;
6232
6747
  }
6233
6748
  if (plan.actionable.length) {
6234
- const balance = await provider.balance();
6235
- log(`
6749
+ const balance = provider.balance ? await provider.balance() : null;
6750
+ if (balance) {
6751
+ log(`
6236
6752
  balance: ${formatCost(balance.unit, balance.remaining)} remaining (${provider.id})`);
6237
- if (balance.unit !== plan.costUnit) {
6238
- throw new Error(
6239
- `Provider estimate unit ${plan.costUnit} does not match balance unit ${balance.unit}.`
6240
- );
6241
- }
6242
- if (balance.unit !== "free" && plan.cost > balance.remaining) {
6243
- throw new Error(
6244
- `This run needs ${formatCost(balance.unit, plan.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
6245
- );
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`);
6246
6766
  }
6247
6767
  const ok = await confirm(
6248
- ` 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)?`,
6249
6769
  args.yes
6250
6770
  );
6251
6771
  if (!ok) {
@@ -6263,7 +6783,8 @@ async function main() {
6263
6783
  submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`
6264
6784
  );
6265
6785
  try {
6266
- 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");
6267
6788
  const measured = measureBalanceChange(balance, after);
6268
6789
  if (measured) {
6269
6790
  const movement = measured.credited ? `${formatCost(measured.unit, measured.credited)} credited` : `${formatCost(measured.unit, measured.spent)} consumed`;