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/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/types.ts
2
2
  import { z } from "zod";
3
- var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles"]);
3
+ var MediaTypeSchema = z.enum(["image/png", "image/gif"]);
4
+ var GeneratorSchema = z.enum(["1dir", "map", "pixflux", "tiles", "animation"]);
4
5
  function tileVariationCount(descriptions) {
5
6
  return Math.max(1, descriptions) * 4;
6
7
  }
@@ -32,7 +33,7 @@ function tilesCost(tileSize, variations) {
32
33
  return 40;
33
34
  }
34
35
  var StyleImageSchema = z.object({
35
- /** Path to a PNG/JPEG, relative to the manifest file. Max 256x256. */
36
+ /** Path to a PNG/JPEG, relative to the manifest; the active provider validates limits. */
36
37
  path: z.string()
37
38
  });
38
39
  var StyleSchema = z.object({
@@ -143,7 +144,9 @@ var StyleSchema = z.object({
143
144
  out: z.string()
144
145
  }).strict().optional(),
145
146
  /** Tags applied to every object generated in this style, for server-side filtering. */
146
- tags: z.array(z.string()).default([])
147
+ tags: z.array(z.string()).default([]),
148
+ /** Adapter-owned settings, keyed by provider id. */
149
+ providerOptions: z.record(z.record(z.unknown())).default({})
147
150
  }).strict().refine((s) => !(s.tileFeature && s.styleImages.length), {
148
151
  message: "tileFeature and styleImages cannot be combined \u2014 a connectable set derives its own tile geometry, so remove one or the other",
149
152
  path: ["tileFeature"]
@@ -158,7 +161,7 @@ var AssetSchema = z.object({
158
161
  height: z.number().int().min(16).max(400).optional(),
159
162
  /** Overrides the style default. `1dir` generator only. */
160
163
  size: z.number().int().min(32).max(256).optional(),
161
- /** Explicit output path relative to outDir. Defaults to `<category>/<id>.png`. */
164
+ /** Explicit output path relative to outDir. Media-aware providers may replace its extension. */
162
165
  file: z.string().optional(),
163
166
  /**
164
167
  * Grid cell this asset owns in a mounted style, as [column, row].
@@ -210,6 +213,8 @@ var AssetSchema = z.object({
210
213
  var ManifestSchema = z.object({
211
214
  $schema: z.string().optional(),
212
215
  name: z.string(),
216
+ /** Generation backend. Existing manifests remain PixelLab by default. */
217
+ provider: z.string().min(1).default("pixellab"),
213
218
  styles: z.record(StyleSchema),
214
219
  assets: z.record(AssetSchema)
215
220
  }).strict();
@@ -247,7 +252,8 @@ var LockEntrySchema = z.object({
247
252
  sourceUrls: z.array(
248
253
  z.object({
249
254
  url: z.string(),
250
- role: z.string().optional()
255
+ role: z.string().optional(),
256
+ mediaType: MediaTypeSchema.optional()
251
257
  })
252
258
  ).default([]),
253
259
  /**
@@ -262,7 +268,8 @@ var LockEntrySchema = z.object({
262
268
  z.object({
263
269
  path: z.string(),
264
270
  sha256: z.string(),
265
- role: z.string().optional()
271
+ role: z.string().optional(),
272
+ mediaType: MediaTypeSchema.optional()
266
273
  })
267
274
  ).default([]),
268
275
  /**
@@ -276,7 +283,7 @@ var LockEntrySchema = z.object({
276
283
  /** Successful-submission estimate in `costUnit`; may be fractional USD. */
277
284
  cost: z.number().finite().nonnegative().default(0),
278
285
  /** Unit for `cost`. Defaults preserve pre-unit PixelLab lockfiles. */
279
- costUnit: z.enum(["generations", "usd", "free"]).default("generations"),
286
+ costUnit: z.string().min(1).default("generations"),
280
287
  /** Which provider produced this. Absent on entries written before providers. */
281
288
  provider: z.string().default("pixellab")
282
289
  });
@@ -650,7 +657,7 @@ function validateCostEstimate(providerId, value) {
650
657
  throw new Error(`Provider "${providerId}" returned an invalid cost estimate`);
651
658
  }
652
659
  const estimate = value;
653
- if (!["generations", "usd", "free"].includes(estimate.unit)) {
660
+ if (typeof estimate.unit !== "string" || !estimate.unit.trim()) {
654
661
  throw new Error(`Provider "${providerId}" returned an invalid cost unit`);
655
662
  }
656
663
  if (!Number.isFinite(estimate.amount) || estimate.amount < 0) {
@@ -692,18 +699,23 @@ function requireDelete(provider) {
692
699
  if (!provider.delete) throw new UnsupportedCapabilityError(provider.id, "deleting remote assets");
693
700
  return provider.delete.bind(provider);
694
701
  }
702
+ function requireSelectCandidate(provider) {
703
+ if (!provider.selectCandidate) {
704
+ throw new UnsupportedCapabilityError(provider.id, "candidate selection");
705
+ }
706
+ return provider.selectCandidate.bind(provider);
707
+ }
708
+ function requireBalance(provider) {
709
+ if (!provider.balance) throw new UnsupportedCapabilityError(provider.id, "account balance");
710
+ return provider.balance.bind(provider);
711
+ }
695
712
  function formatCost(unit, amount) {
696
713
  if (unit === "free") return "free";
697
714
  if (unit === "usd") return `$${amount.toFixed(2)}`;
698
- return `${amount} generation${amount === 1 ? "" : "s"}`;
715
+ if (unit === "generations") return `${amount} generation${amount === 1 ? "" : "s"}`;
716
+ return `${amount} ${unit}`;
699
717
  }
700
718
 
701
- // src/providers/pixellab.ts
702
- import { mkdirSync, existsSync, readFileSync, writeFileSync } from "fs";
703
- import { randomUUID } from "crypto";
704
- import os from "os";
705
- import path from "path";
706
-
707
719
  // src/png.ts
708
720
  import { deflateSync, inflateSync } from "zlib";
709
721
  var SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
@@ -1061,7 +1073,96 @@ function parseHex(hex2) {
1061
1073
  return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
1062
1074
  }
1063
1075
 
1076
+ // src/media.ts
1077
+ var MediaType = {
1078
+ PNG: "image/png",
1079
+ GIF: "image/gif"
1080
+ };
1081
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
1082
+ function mediaExtension(mediaType) {
1083
+ return mediaType === MediaType.GIF ? ".gif" : ".png";
1084
+ }
1085
+ function mediaTypeFromExtension(file) {
1086
+ const lower = file.toLowerCase();
1087
+ if (lower.endsWith(".png")) return MediaType.PNG;
1088
+ if (lower.endsWith(".gif")) return MediaType.GIF;
1089
+ return null;
1090
+ }
1091
+ function detectMediaType(bytes) {
1092
+ if (bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return MediaType.PNG;
1093
+ const header = bytes.subarray(0, 6).toString("ascii");
1094
+ if (header === "GIF87a" || header === "GIF89a") return MediaType.GIF;
1095
+ return null;
1096
+ }
1097
+ function validateMedia(bytes, expected) {
1098
+ const actual = detectMediaType(bytes);
1099
+ if (!actual) throw new Error(`response was not a supported PNG or GIF (${bytes.length} bytes)`);
1100
+ if (expected && actual !== expected) {
1101
+ throw new Error(`response was ${actual}, expected ${expected}`);
1102
+ }
1103
+ if (actual === MediaType.PNG) {
1104
+ decodePng(bytes);
1105
+ } else {
1106
+ validateGif(bytes);
1107
+ }
1108
+ return actual;
1109
+ }
1110
+ function validateGif(bytes) {
1111
+ if (bytes.length < 14) throw new Error("invalid GIF: truncated logical screen descriptor");
1112
+ const width = bytes.readUInt16LE(6);
1113
+ const height = bytes.readUInt16LE(8);
1114
+ if (!width || !height) throw new Error("invalid GIF: zero-sized logical screen");
1115
+ const packed = bytes[10];
1116
+ let offset = 13;
1117
+ if (packed & 128) offset += 3 * 2 ** ((packed & 7) + 1);
1118
+ if (offset > bytes.length) throw new Error("invalid GIF: truncated global color table");
1119
+ let sawImage = false;
1120
+ while (offset < bytes.length) {
1121
+ const marker = bytes[offset];
1122
+ if (marker === 59) {
1123
+ if (!sawImage) throw new Error("invalid GIF: contains no image frame");
1124
+ return;
1125
+ }
1126
+ if (marker === 44) {
1127
+ if (offset + 10 > bytes.length) throw new Error("invalid GIF: truncated image descriptor");
1128
+ const imagePacked = bytes[offset + 9];
1129
+ offset += 10;
1130
+ if (imagePacked & 128) offset += 3 * 2 ** ((imagePacked & 7) + 1);
1131
+ if (offset >= bytes.length) throw new Error("invalid GIF: missing image data");
1132
+ offset++;
1133
+ offset = skipSubBlocks(bytes, offset);
1134
+ sawImage = true;
1135
+ continue;
1136
+ }
1137
+ if (marker === 33) {
1138
+ if (offset + 2 > bytes.length) throw new Error("invalid GIF: truncated extension");
1139
+ offset = skipSubBlocks(bytes, offset + 2);
1140
+ continue;
1141
+ }
1142
+ throw new Error(`invalid GIF: unexpected block marker 0x${marker.toString(16)}`);
1143
+ }
1144
+ throw new Error("invalid GIF: missing trailer");
1145
+ }
1146
+ function skipSubBlocks(bytes, start) {
1147
+ let offset = start;
1148
+ for (; ; ) {
1149
+ if (offset >= bytes.length) throw new Error("invalid GIF: truncated data blocks");
1150
+ const size = bytes[offset];
1151
+ offset++;
1152
+ if (size === 0) return offset;
1153
+ offset += size;
1154
+ if (offset > bytes.length) throw new Error("invalid GIF: truncated data block");
1155
+ }
1156
+ }
1157
+ function cacheFileName(hash, mediaType = MediaType.PNG) {
1158
+ return `${hash}${mediaExtension(mediaType)}`;
1159
+ }
1160
+
1064
1161
  // src/providers/pixellab.ts
1162
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from "fs";
1163
+ import { randomUUID } from "crypto";
1164
+ import os from "os";
1165
+ import path from "path";
1065
1166
  var PixelLabProvider = class _PixelLabProvider {
1066
1167
  constructor(client) {
1067
1168
  this.client = client;
@@ -1111,7 +1212,44 @@ var PixelLabProvider = class _PixelLabProvider {
1111
1212
  candidates: spec.generator === "1dir" ? candidateCount(spec.size) : 1
1112
1213
  };
1113
1214
  }
1215
+ validate(spec, styleImages) {
1216
+ if (spec.generator === "map") {
1217
+ requirePixelLabOption("view", spec.view, ["low top-down", "high top-down", "side"]);
1218
+ requirePixelLabOption("outline", spec.outline, [
1219
+ "single color outline",
1220
+ "selective outline",
1221
+ "lineless"
1222
+ ]);
1223
+ requirePixelLabOption("shading", spec.shading, [
1224
+ "flat shading",
1225
+ "basic shading",
1226
+ "medium shading",
1227
+ "detailed shading"
1228
+ ]);
1229
+ requirePixelLabOption("detail", spec.detail, [
1230
+ "low detail",
1231
+ "medium detail",
1232
+ "high detail"
1233
+ ]);
1234
+ }
1235
+ if ((spec.generator === "map" || spec.generator === "pixflux") && styleImages.length) {
1236
+ throw new Error(`PixelLab ${spec.generator} does not support style images`);
1237
+ }
1238
+ for (const image of styleImages) {
1239
+ if (image.width > 256 || image.height > 256) {
1240
+ throw new Error(
1241
+ `Style image exceeds PixelLab's 256x256 limit (${image.width}x${image.height})`
1242
+ );
1243
+ }
1244
+ }
1245
+ if (spec.tags.length > 20) {
1246
+ throw new Error(
1247
+ `${spec.styleId}/${spec.assetId} resolves to ${spec.tags.length} tags, but PixelLab allows at most 20`
1248
+ );
1249
+ }
1250
+ }
1114
1251
  async submit(spec, styleImages) {
1252
+ this.validate(spec, styleImages);
1115
1253
  if (spec.generator === "pixflux") {
1116
1254
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
1117
1255
  const { png } = await this.client.createImagePixflux({
@@ -1297,6 +1435,11 @@ var PixelLabProvider = class _PixelLabProvider {
1297
1435
  await this.client.deleteObject(assetId);
1298
1436
  }
1299
1437
  };
1438
+ function requirePixelLabOption(name, value, allowed) {
1439
+ if (value != null && !allowed.includes(value)) {
1440
+ throw new Error(`PixelLab map ${name} must be one of: ${allowed.join(", ")}`);
1441
+ }
1442
+ }
1300
1443
  function tilesInIndexOrder(urls) {
1301
1444
  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);
1302
1445
  }
@@ -1305,6 +1448,319 @@ function firstUrl(urls) {
1305
1448
  return Object.values(urls).find((u) => typeof u === "string") ?? null;
1306
1449
  }
1307
1450
 
1451
+ // src/providers/retrodiffusion.ts
1452
+ var DEFAULT_BASE_URL = "https://api.retrodiffusion.ai/v1";
1453
+ var RetroDiffusionClient = class {
1454
+ constructor(token, baseUrl = DEFAULT_BASE_URL, request = fetch) {
1455
+ this.token = token;
1456
+ this.baseUrl = baseUrl;
1457
+ this.request = request;
1458
+ }
1459
+ token;
1460
+ baseUrl;
1461
+ request;
1462
+ async submit(body) {
1463
+ const response = await this.call("/inferences", { method: "POST", body: JSON.stringify(body) });
1464
+ const taskId = response.task_id;
1465
+ if (typeof taskId !== "string" || !taskId) {
1466
+ throw new Error("Retro Diffusion did not return an async task id");
1467
+ }
1468
+ return taskId;
1469
+ }
1470
+ async quote(body) {
1471
+ const response = await this.call("/inferences", {
1472
+ method: "POST",
1473
+ body: JSON.stringify({ ...body, check_cost: true })
1474
+ });
1475
+ const amount = response.balance_cost;
1476
+ if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) {
1477
+ throw new Error("Retro Diffusion returned an invalid cost quote");
1478
+ }
1479
+ return amount;
1480
+ }
1481
+ async task(id) {
1482
+ return await this.call(`/inferences/tasks/${encodeURIComponent(id)}`);
1483
+ }
1484
+ async balance() {
1485
+ const response = await this.call("/inferences/credits");
1486
+ const balance = response.balance;
1487
+ if (typeof balance !== "number" || !Number.isFinite(balance)) {
1488
+ throw new Error("Retro Diffusion returned an invalid balance");
1489
+ }
1490
+ return balance;
1491
+ }
1492
+ async call(path17, init = {}) {
1493
+ if (!this.token) throw new Error("RD_API_KEY is not set");
1494
+ const response = await this.request(`${this.baseUrl}${path17}`, {
1495
+ ...init,
1496
+ headers: {
1497
+ "Content-Type": "application/json",
1498
+ "X-RD-Token": this.token,
1499
+ ...init.headers
1500
+ }
1501
+ });
1502
+ const text = await response.text();
1503
+ let value = null;
1504
+ try {
1505
+ value = text ? JSON.parse(text) : null;
1506
+ } catch {
1507
+ value = text;
1508
+ }
1509
+ if (!response.ok) {
1510
+ const retry = response.headers.get("retry-after");
1511
+ const detail = retroError(value);
1512
+ throw new Error(
1513
+ `Retro Diffusion request failed (${response.status}): ${detail}` + (retry ? `; retry after ${retry}s` : "")
1514
+ );
1515
+ }
1516
+ return value;
1517
+ }
1518
+ };
1519
+ var RetroDiffusionProvider = class _RetroDiffusionProvider {
1520
+ constructor(client) {
1521
+ this.client = client;
1522
+ }
1523
+ client;
1524
+ id = "retrodiffusion";
1525
+ static fromEnv() {
1526
+ return new _RetroDiffusionProvider(new RetroDiffusionClient(process.env.RD_API_KEY));
1527
+ }
1528
+ static forOffline() {
1529
+ return new _RetroDiffusionProvider(new RetroDiffusionClient(void 0));
1530
+ }
1531
+ static forDownloads() {
1532
+ return _RetroDiffusionProvider.forOffline();
1533
+ }
1534
+ supports(generator) {
1535
+ return generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "animation";
1536
+ }
1537
+ estimate(spec) {
1538
+ const options = retroOptions(spec);
1539
+ const style = resolvedPromptStyle(spec, options);
1540
+ const count = options.numImages ?? 1;
1541
+ const pixels = spec.width * spec.height;
1542
+ let each;
1543
+ if (style.startsWith("rd_advanced_animation__")) {
1544
+ each = /__(?:custom_action|subtle_motion)$/.test(style) ? 0.25 : 0.14;
1545
+ } else if (style.startsWith("rd_animation__")) {
1546
+ each = /__(?:any_animation|8_dir_rotation)$/.test(style) ? 0.25 : 0.07;
1547
+ } else if (/^rd_tile__tileset(?:_advanced)?$/.test(style)) {
1548
+ each = 0.1;
1549
+ } else if (style.startsWith("rd_pro__")) {
1550
+ each = 0.18;
1551
+ } else if (style.startsWith("rd_fast__")) {
1552
+ each = Math.max(0.015, (pixels + 1e5) / 6e6);
1553
+ } else if (isLowResolutionStyle(style)) {
1554
+ each = Math.max(0.02, (pixels + 13700) / 6e5);
1555
+ } else {
1556
+ each = Math.max(0.025, (pixels + 5e4) / 2e6);
1557
+ }
1558
+ return { unit: "usd", amount: roundUsdEstimate(each * count), candidates: count };
1559
+ }
1560
+ validate(spec, styleImages) {
1561
+ const options = retroOptions(spec);
1562
+ const promptStyle = resolvedPromptStyle(spec, options);
1563
+ const count = options.numImages ?? 1;
1564
+ const isAnimation = /^(?:rd_animation__|rd_advanced_animation__)/.test(promptStyle);
1565
+ const isTile = promptStyle.startsWith("rd_tile__");
1566
+ if (!promptStyle || spec.generator === "animation" && !isAnimation || spec.generator === "tiles" && !isTile || spec.generator !== "animation" && spec.generator !== "tiles" && (isAnimation || isTile)) {
1567
+ throw new Error(
1568
+ `Retro Diffusion style "${promptStyle}" does not match generator "${spec.generator}"`
1569
+ );
1570
+ }
1571
+ if (!Number.isInteger(count) || count < 1 || count > 16) {
1572
+ throw new Error("Retro Diffusion numImages must be a whole number from 1 to 16");
1573
+ }
1574
+ if (spec.width < 16 || spec.height < 16 || spec.width > 512 || spec.height > 512) {
1575
+ throw new Error("Retro Diffusion output dimensions must be between 16 and 512 pixels");
1576
+ }
1577
+ if (styleImages.length > 9) {
1578
+ throw new Error("Retro Diffusion accepts at most 9 reference images");
1579
+ }
1580
+ if (spec.generator === "animation") {
1581
+ if (count !== 1) throw new Error("Retro Diffusion animations currently require numImages: 1");
1582
+ validateAnimation(promptStyle, spec, styleImages, options);
1583
+ } else if (spec.generator === "tiles") {
1584
+ validateTile(promptStyle, spec, styleImages, count, options);
1585
+ } else if (styleImages.length && !/^(?:rd_pro__|user__)/.test(promptStyle)) {
1586
+ throw new Error(
1587
+ `Retro Diffusion style "${promptStyle}" does not accept reference_images; use an RD Pro or user style`
1588
+ );
1589
+ }
1590
+ }
1591
+ async submit(spec, styleImages) {
1592
+ this.validate(spec, styleImages);
1593
+ const options = retroOptions(spec);
1594
+ const promptStyle = resolvedPromptStyle(spec, options);
1595
+ const animation = spec.generator === "animation";
1596
+ const tiles = spec.generator === "tiles";
1597
+ const body = {
1598
+ prompt: spec.prompt,
1599
+ prompt_style: promptStyle,
1600
+ width: spec.width,
1601
+ height: spec.height,
1602
+ num_images: options.numImages ?? 1,
1603
+ ...!animation && !tiles ? { remove_bg: options.removeBg ?? spec.noBackground } : {},
1604
+ ...spec.seed != null ? { seed: spec.seed } : {},
1605
+ ...animation || tiles ? styleImages[0] ? { input_image: styleImages[0].base64 } : {} : styleImages.length ? { reference_images: styleImages.map((image) => image.base64) } : {},
1606
+ ...tiles && styleImages[1] ? { extra_input_image: styleImages[1].base64 } : {},
1607
+ ...tiles && options.extraPrompt ? { extra_prompt: options.extraPrompt } : {},
1608
+ ...animation && options.framesDuration ? { frames_duration: options.framesDuration } : {},
1609
+ ...animation && options.returnSpritesheet ? { return_spritesheet: true } : {},
1610
+ ...!animation && options.tileX != null ? { tile_x: options.tileX } : {},
1611
+ ...!animation && options.tileY != null ? { tile_y: options.tileY } : {},
1612
+ ...spec.palette.length ? { input_palette: paletteSwatch(spec.palette).toString("base64") } : {}
1613
+ };
1614
+ const quoted = await this.client.quote(body);
1615
+ const estimated = this.estimate(spec).amount;
1616
+ if (quoted > estimated + 1e-6) {
1617
+ throw new Error(
1618
+ `Retro Diffusion quoted $${quoted.toFixed(6)}, above the offline estimate $${estimated.toFixed(6)}; no paid request was sent`
1619
+ );
1620
+ }
1621
+ return {
1622
+ jobId: await this.client.submit({ ...body, async: true, upload_outputs: true })
1623
+ };
1624
+ }
1625
+ async poll(jobId, generator, context) {
1626
+ const task = await this.client.task(jobId);
1627
+ if (task.status === "pending" || task.status === "running") return { status: "processing" };
1628
+ if (task.status === "failed") return { status: "failed", error: retroError(task.error) };
1629
+ if (task.status !== "succeeded") {
1630
+ return { status: "failed", error: `Retro Diffusion returned unknown task status "${String(task.status)}"` };
1631
+ }
1632
+ const options = context?.spec ? retroOptions(context.spec) : {};
1633
+ const mediaType = generator === "animation" && !options.returnSpritesheet ? MediaType.GIF : MediaType.PNG;
1634
+ const sources = resultSources(task.result, mediaType);
1635
+ const urls = sources.map((source) => source.url);
1636
+ if (!urls.length) return { status: "failed", error: "Retro Diffusion task returned no images" };
1637
+ if (urls.length > 1) return { status: "review", candidateUrls: urls };
1638
+ return {
1639
+ status: "ready",
1640
+ objectId: `${jobId}#0`,
1641
+ sourceUrl: urls[0],
1642
+ sources,
1643
+ metadata: {
1644
+ balanceCost: task.result?.balance_cost ?? null,
1645
+ remainingBalance: task.result?.remaining_balance ?? null,
1646
+ mediaType,
1647
+ kind: generator === "animation" ? "animation" : generator === "tiles" ? "tileset" : "image",
1648
+ ...context?.spec ? {
1649
+ promptStyle: resolvedPromptStyle(context.spec, options),
1650
+ width: context.spec.width,
1651
+ height: context.spec.height
1652
+ } : {}
1653
+ }
1654
+ };
1655
+ }
1656
+ async selectCandidate(jobId, index) {
1657
+ const task = await this.client.task(jobId);
1658
+ if (task.status !== "succeeded") throw new Error(`Retro Diffusion task ${jobId} is not ready`);
1659
+ const url = resultSources(task.result, MediaType.PNG)[index]?.url;
1660
+ if (!url) throw new Error(`Retro Diffusion task ${jobId} has no candidate at index ${index}`);
1661
+ return { objectId: `${jobId}#${index}`, sourceUrl: url };
1662
+ }
1663
+ async download(url) {
1664
+ const data = /^data:[^;]+;base64,(.+)$/.exec(url)?.[1];
1665
+ if (data) return Buffer.from(data, "base64");
1666
+ const response = await fetch(url);
1667
+ if (!response.ok) throw new Error(`Retro Diffusion download failed (${response.status})`);
1668
+ return Buffer.from(await response.arrayBuffer());
1669
+ }
1670
+ async balance() {
1671
+ return { unit: "usd", remaining: await this.client.balance() };
1672
+ }
1673
+ };
1674
+ function retroOptions(spec) {
1675
+ return spec.providerOptions;
1676
+ }
1677
+ function resultSources(result, mediaType) {
1678
+ const hosted = (result?.output_urls ?? []).filter((url) => typeof url === "string" && url.length > 0).map((url) => ({ url, mediaType }));
1679
+ if (hosted.length) return hosted;
1680
+ return (result?.base64_images ?? []).filter((data) => typeof data === "string" && data.length > 0).map((data) => ({
1681
+ url: `data:${mediaType};base64,${data}`,
1682
+ mediaType
1683
+ }));
1684
+ }
1685
+ function resolvedPromptStyle(spec, options) {
1686
+ return options.promptStyle ?? (spec.generator === "animation" ? "rd_animation__any_animation" : spec.generator === "tiles" ? "rd_tile__tileset" : "rd_plus__default");
1687
+ }
1688
+ function validateAnimation(style, spec, styleImages, options) {
1689
+ if (spec.width !== spec.height) throw new Error("Retro Diffusion animations must be square");
1690
+ if (style.startsWith("rd_advanced_animation__")) {
1691
+ if (styleImages.length !== 1) {
1692
+ throw new Error(`Retro Diffusion advanced animation style "${style}" requires one input image`);
1693
+ }
1694
+ if (spec.width < 32 || spec.width > 256) {
1695
+ throw new Error("Retro Diffusion advanced animations require dimensions from 32 to 256 pixels");
1696
+ }
1697
+ } else if (styleImages.length > 1) {
1698
+ throw new Error("Retro Diffusion prompt animations accept at most one input image");
1699
+ }
1700
+ 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;
1701
+ if (exact && spec.width !== exact) {
1702
+ throw new Error(`Retro Diffusion style "${style}" requires ${exact}x${exact} dimensions`);
1703
+ }
1704
+ if (style.endsWith("__vfx") && (spec.width < 24 || spec.width > 96)) {
1705
+ throw new Error("Retro Diffusion VFX animations require dimensions from 24 to 96 pixels");
1706
+ }
1707
+ if (options.framesDuration != null && ![4, 6, 8, 10, 12, 16].includes(options.framesDuration)) {
1708
+ throw new Error("Retro Diffusion framesDuration must be 4, 6, 8, 10, 12, or 16");
1709
+ }
1710
+ }
1711
+ function validateTile(style, spec, styleImages, count, options) {
1712
+ if (spec.width !== spec.height) throw new Error("Retro Diffusion tiles must be square");
1713
+ const size = spec.width;
1714
+ if (/^rd_tile__tileset(?:_advanced)?$/.test(style)) {
1715
+ if (size < 16 || size > 32) throw new Error("Retro Diffusion tilesets require 16\u201332px tiles");
1716
+ if (count !== 1) throw new Error("Retro Diffusion tilesets require numImages: 1");
1717
+ } else if (style === "rd_tile__single_tile" && (size < 16 || size > 64)) {
1718
+ throw new Error("Retro Diffusion single tiles require dimensions from 16 to 64 pixels");
1719
+ } else if (style === "rd_tile__tile_variation") {
1720
+ if (size < 16 || size > 128) throw new Error("Retro Diffusion tile variations require 16\u2013128px tiles");
1721
+ if (styleImages.length !== 1) throw new Error("Retro Diffusion tile variations require one input image");
1722
+ } else if (style === "rd_tile__tile_object" && (size < 16 || size > 96)) {
1723
+ throw new Error("Retro Diffusion tile objects require dimensions from 16 to 96 pixels");
1724
+ } else if (style === "rd_tile__scene_object" && (size < 64 || size > 384)) {
1725
+ throw new Error("Retro Diffusion tile scene objects require dimensions from 64 to 384 pixels");
1726
+ }
1727
+ if (style === "rd_tile__tileset" && styleImages.length > 1) {
1728
+ throw new Error("Retro Diffusion basic tilesets accept at most one input image");
1729
+ }
1730
+ if (style === "rd_tile__tileset_advanced") {
1731
+ if (styleImages.length > 2) throw new Error("Retro Diffusion advanced tilesets accept at most two input images");
1732
+ if (!options.extraPrompt && styleImages.length < 2) {
1733
+ throw new Error("Retro Diffusion advanced tilesets require extraPrompt or a second input image");
1734
+ }
1735
+ }
1736
+ }
1737
+ function isLowResolutionStyle(style) {
1738
+ return /(?:^|__)(?:mc_|low_res|classic|skill_icon|topdown_item)/.test(style);
1739
+ }
1740
+ function roundUsdEstimate(value) {
1741
+ return Math.ceil((value - 1e-9) * 1e3) / 1e3;
1742
+ }
1743
+ function retroError(value) {
1744
+ if (typeof value === "string") return value || "unknown error";
1745
+ if (!value || typeof value !== "object") return "unknown error";
1746
+ const record = value;
1747
+ if (typeof record.message === "string") return record.message;
1748
+ if (typeof record.detail === "string") return record.detail;
1749
+ if (Array.isArray(record.detail)) {
1750
+ const details = record.detail.map((item) => {
1751
+ if (!item || typeof item !== "object") return String(item);
1752
+ const detail = item;
1753
+ return typeof detail.msg === "string" ? detail.msg : JSON.stringify(item);
1754
+ }).filter(Boolean);
1755
+ if (details.length) return details.join("; ");
1756
+ }
1757
+ if (record.detail && typeof record.detail === "object") {
1758
+ const message2 = record.detail.message;
1759
+ if (typeof message2 === "string") return message2;
1760
+ }
1761
+ return JSON.stringify(value);
1762
+ }
1763
+
1308
1764
  // src/providers/fake.ts
1309
1765
  import { createHash } from "crypto";
1310
1766
  var FAKE_PNG = Buffer.from(
@@ -1449,6 +1905,47 @@ var FakeProvider = class {
1449
1905
  }
1450
1906
  };
1451
1907
 
1908
+ // src/providers/registry.ts
1909
+ var factories = /* @__PURE__ */ new Map();
1910
+ function registerProvider(factory) {
1911
+ const id = factory.id.trim();
1912
+ if (!id) throw new Error("Provider id cannot be empty");
1913
+ if (factories.has(id)) throw new Error(`Provider "${id}" is already registered`);
1914
+ factories.set(id, factory);
1915
+ }
1916
+ function providerFactory(id) {
1917
+ const factory = factories.get(id);
1918
+ if (!factory) {
1919
+ const available = [...factories.keys()].sort().join(", ") || "(none)";
1920
+ throw new Error(`Unknown provider "${id}". Available providers: ${available}`);
1921
+ }
1922
+ return factory;
1923
+ }
1924
+ function createProvider(id, mode2) {
1925
+ return providerFactory(id).create(mode2);
1926
+ }
1927
+ function availableProviders() {
1928
+ return [...factories.keys()].sort();
1929
+ }
1930
+ registerProvider({
1931
+ id: "pixellab",
1932
+ credentialEnv: "PIXELLAB_API_KEY",
1933
+ create(mode2) {
1934
+ if (mode2 === "online") return PixelLabProvider.fromEnv();
1935
+ if (mode2 === "downloads") return PixelLabProvider.forDownloads();
1936
+ return PixelLabProvider.forOffline();
1937
+ }
1938
+ });
1939
+ registerProvider({
1940
+ id: "retrodiffusion",
1941
+ credentialEnv: "RD_API_KEY",
1942
+ create(mode2) {
1943
+ if (mode2 === "online") return RetroDiffusionProvider.fromEnv();
1944
+ if (mode2 === "downloads") return RetroDiffusionProvider.forDownloads();
1945
+ return RetroDiffusionProvider.forOffline();
1946
+ }
1947
+ });
1948
+
1452
1949
  // src/manifest.ts
1453
1950
  import { readFile as readFile2 } from "fs/promises";
1454
1951
  import { existsSync as existsSync2 } from "fs";
@@ -1466,6 +1963,10 @@ async function sha256File(path17) {
1466
1963
  function specHash(spec, styleImageHashes) {
1467
1964
  return sha256(
1468
1965
  JSON.stringify({
1966
+ // Preserve every existing PixelLab hash while making a provider switch
1967
+ // invalidate the spec. Older manifests implicitly mean pixellab.
1968
+ provider: spec.provider === "pixellab" ? void 0 : spec.provider,
1969
+ providerOptions: Object.keys(spec.providerOptions).length > 0 ? spec.providerOptions : void 0,
1469
1970
  generator: spec.generator,
1470
1971
  prompt: spec.prompt,
1471
1972
  width: spec.width,
@@ -1479,7 +1980,7 @@ function specHash(spec, styleImageHashes) {
1479
1980
  // `noBackground` only reaches the wire for pixflux; the tile fields are
1480
1981
  // undefined for every other generator. `tileSize` is intentionally
1481
1982
  // absent — width/height are derived from it, so it is already covered.
1482
- noBackground: spec.generator === "pixflux" ? spec.noBackground : void 0,
1983
+ noBackground: spec.generator === "pixflux" || spec.provider !== "pixellab" ? spec.noBackground : void 0,
1483
1984
  tileType: spec.tileType,
1484
1985
  tileView: spec.tileView,
1485
1986
  tileFeature: spec.tileFeature,
@@ -1519,6 +2020,7 @@ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
1519
2020
  }
1520
2021
  async function resolveSpecs(loaded, filter) {
1521
2022
  const { manifest, root } = loaded;
2023
+ const activeProvider = filter?.provider ?? createProvider(manifest.provider, "offline");
1522
2024
  const specs = [];
1523
2025
  const styleIds = Object.keys(manifest.styles).filter(
1524
2026
  (id) => !filter?.styles?.length || filter.styles.includes(id)
@@ -1544,10 +2046,8 @@ async function resolveSpecs(loaded, filter) {
1544
2046
  const buf = await readFile2(abs);
1545
2047
  const metadata = imageMetadata(buf);
1546
2048
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
1547
- if (metadata.width < 1 || metadata.height < 1 || metadata.width > 256 || metadata.height > 256) {
1548
- throw new Error(
1549
- `Style image exceeds the API's 256x256 limit: ${abs} (${metadata.width}x${metadata.height})`
1550
- );
2049
+ if (metadata.width < 1 || metadata.height < 1) {
2050
+ throw new Error(`Style image has invalid dimensions: ${abs}`);
1551
2051
  }
1552
2052
  hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
1553
2053
  styleImageCache.set(abs, hit);
@@ -1567,8 +2067,8 @@ async function resolveSpecs(loaded, filter) {
1567
2067
  if (filter?.assets?.length && !filter.assets.includes(assetId)) continue;
1568
2068
  if (asset.styles.length && !asset.styles.includes(styleId)) continue;
1569
2069
  const generator = style.generator;
1570
- if (filter?.provider && !filter.provider.supports(generator)) {
1571
- throw new Error(`Provider "${filter.provider.id}" does not support generator "${generator}"`);
2070
+ if (!activeProvider.supports(generator)) {
2071
+ throw new Error(`Provider "${activeProvider.id}" does not support generator "${generator}"`);
1572
2072
  }
1573
2073
  let width;
1574
2074
  let height;
@@ -1595,6 +2095,8 @@ async function resolveSpecs(loaded, filter) {
1595
2095
  const base = {
1596
2096
  styleId,
1597
2097
  assetId,
2098
+ provider: activeProvider.id,
2099
+ providerOptions: style.providerOptions[activeProvider.id] ?? {},
1598
2100
  generator,
1599
2101
  prompt,
1600
2102
  width,
@@ -1626,11 +2128,6 @@ async function resolveSpecs(loaded, filter) {
1626
2128
  `style:${styleId}`
1627
2129
  ])
1628
2130
  ];
1629
- if (tags.length > 20) {
1630
- throw new Error(
1631
- `${styleId}/${assetId} resolves to ${tags.length} tags, but PixelLab allows at most 20`
1632
- );
1633
- }
1634
2131
  const resolved = {
1635
2132
  ...base,
1636
2133
  root,
@@ -1639,12 +2136,15 @@ async function resolveSpecs(loaded, filter) {
1639
2136
  source: asset.source,
1640
2137
  specHash: specHash(base, styleImageHashes)
1641
2138
  };
1642
- if (filter?.provider) {
1643
- const estimate = validateCostEstimate(filter.provider.id, filter.provider.estimate(resolved));
1644
- resolved.cost = estimate.amount;
1645
- resolved.costUnit = estimate.unit;
1646
- resolved.candidates = estimate.candidates;
1647
- }
2139
+ const resolvedImages = style.styleImages.map((image) => {
2140
+ const hit = styleImageCache.get(path2.resolve(root, image.path));
2141
+ return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
2142
+ });
2143
+ activeProvider.validate?.(resolved, resolvedImages);
2144
+ const estimate = validateCostEstimate(activeProvider.id, activeProvider.estimate(resolved));
2145
+ resolved.cost = estimate.amount;
2146
+ resolved.costUnit = estimate.unit;
2147
+ resolved.candidates = estimate.candidates;
1648
2148
  specs.push(resolved);
1649
2149
  }
1650
2150
  }
@@ -1668,10 +2168,8 @@ async function resolveStyleImages(loaded, styleId) {
1668
2168
  const buf = await readFile2(file);
1669
2169
  const metadata = imageMetadata(buf);
1670
2170
  if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
1671
- if (metadata.width < 1 || metadata.height < 1 || metadata.width > 256 || metadata.height > 256) {
1672
- throw new Error(
1673
- `Style image exceeds the API's 256x256 limit: ${file} (${metadata.width}x${metadata.height})`
1674
- );
2171
+ if (metadata.width < 1 || metadata.height < 1) {
2172
+ throw new Error(`Style image has invalid dimensions: ${file}`);
1675
2173
  }
1676
2174
  out.push({ base64: buf.toString("base64"), ...metadata });
1677
2175
  }
@@ -1873,12 +2371,13 @@ async function acquireFileLock(file) {
1873
2371
  function spendByUnit(lock) {
1874
2372
  const totals = { generations: 0, usd: 0, free: 0 };
1875
2373
  for (const entry of Object.values(lock.entries)) {
1876
- totals[entry.costUnit ?? "generations"] += entry.cost ?? 0;
2374
+ const unit = entry.costUnit ?? "generations";
2375
+ totals[unit] = (totals[unit] ?? 0) + (entry.cost ?? 0);
1877
2376
  }
1878
2377
  return totals;
1879
2378
  }
1880
2379
  function totalSpend(lock, unit = "generations") {
1881
- return spendByUnit(lock)[unit];
2380
+ return spendByUnit(lock)[unit] ?? 0;
1882
2381
  }
1883
2382
 
1884
2383
  // src/outputs.ts
@@ -1895,16 +2394,16 @@ function resolveOutputPath(recordedPath, manifestDir) {
1895
2394
  if (path4.win32.isAbsolute(recordedPath)) return recordedPath;
1896
2395
  return path4.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path4.sep));
1897
2396
  }
1898
- function expectedOutputPath(spec, role, index, total) {
1899
- if (total === 1) return spec.outFile;
2397
+ function expectedOutputPath(spec, role, index, total, mediaType) {
1900
2398
  const originalExt = path4.extname(spec.outFile);
1901
- const ext = originalExt || ".png";
2399
+ const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
1902
2400
  const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
2401
+ if (total === 1) return `${stem}${ext}`;
1903
2402
  const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
1904
2403
  return `${stem}-${safeRole}${ext}`;
1905
2404
  }
1906
2405
  function currentOutputPath(output, spec, index, total) {
1907
- return expectedOutputPath(spec, output.role, index, total);
2406
+ return expectedOutputPath(spec, output.role, index, total, output.mediaType);
1908
2407
  }
1909
2408
  function currentEntryOutputPath(entry, spec, index) {
1910
2409
  const output = entry.outputs[index];
@@ -2752,7 +3251,6 @@ function cachePathFor(lockPath) {
2752
3251
  }
2753
3252
 
2754
3253
  // src/pipeline/cache-health.ts
2755
- var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
2756
3254
  async function inspectCaches(lock, lockPath, options = {}) {
2757
3255
  if (options.prune && !existsSync7(lockPath)) {
2758
3256
  throw new Error(`Refusing to prune without an existing lockfile at ${path8.resolve(lockPath)}`);
@@ -2821,7 +3319,8 @@ async function inspectContentCache(contentDir, referenced) {
2821
3319
  }
2822
3320
  report.files++;
2823
3321
  const file = path8.join(contentDir, entry.name);
2824
- const expected = entry.name.endsWith(".png") ? entry.name.slice(0, -4) : "";
3322
+ const mediaType = mediaTypeFromExtension(entry.name);
3323
+ const expected = mediaType ? entry.name.slice(0, -4) : "";
2825
3324
  let bytes;
2826
3325
  try {
2827
3326
  bytes = await readFile7(file);
@@ -2834,11 +3333,7 @@ async function inspectContentCache(contentDir, referenced) {
2834
3333
  continue;
2835
3334
  }
2836
3335
  if (!isSha256Hash(expected)) {
2837
- report.invalid.push({ name: entry.name, reason: "filename is not <sha256>.png" });
2838
- continue;
2839
- }
2840
- if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
2841
- report.invalid.push({ name: entry.name, reason: "not a PNG" });
3336
+ report.invalid.push({ name: entry.name, reason: "filename is not <sha256>.png or <sha256>.gif" });
2842
3337
  continue;
2843
3338
  }
2844
3339
  if (sha256(bytes) !== expected) {
@@ -2846,11 +3341,11 @@ async function inspectContentCache(contentDir, referenced) {
2846
3341
  continue;
2847
3342
  }
2848
3343
  try {
2849
- decodePng(bytes);
3344
+ validateMedia(bytes, mediaType);
2850
3345
  } catch (err) {
2851
3346
  report.invalid.push({
2852
3347
  name: entry.name,
2853
- reason: `invalid PNG: ${err instanceof Error ? err.message : String(err)}`
3348
+ reason: `invalid ${mediaType === "image/gif" ? "GIF" : "PNG"}: ${err instanceof Error ? err.message : String(err)}`
2854
3349
  });
2855
3350
  continue;
2856
3351
  }
@@ -3439,7 +3934,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3439
3934
  async function pruneInFlight() {
3440
3935
  for (const [id, spec] of [...inFlight]) {
3441
3936
  try {
3442
- const state = await provider.poll(id, spec.generator, spec);
3937
+ const state = await provider.poll(id, spec.generator, { spec, tileFeature: spec.tileFeature });
3443
3938
  if (state.status !== "processing") inFlight.delete(id);
3444
3939
  lastSlotError = null;
3445
3940
  } catch (err) {
@@ -3491,7 +3986,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
3491
3986
  await saveLock(lockPath, lock);
3492
3987
  lastSubmitAt = Date.now();
3493
3988
  try {
3494
- const refs = spec.generator === "1dir" || spec.generator === "tiles" ? styleImages.get(spec.styleId) ?? [] : [];
3989
+ const refs = styleImages.get(spec.styleId) ?? [];
3495
3990
  const { jobId } = await provider.submit(spec, refs);
3496
3991
  upsert(lock, key, {
3497
3992
  jobId,
@@ -3541,7 +4036,8 @@ async function poll(provider, lock, lockPath, opts = {}) {
3541
4036
  try {
3542
4037
  const currentSpec = specByKey.get(key);
3543
4038
  const state = await provider.poll(entry.jobId, entry.generator, {
3544
- tileFeature: entry.tileFeature ?? currentSpec?.tileFeature
4039
+ tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
4040
+ spec: currentSpec
3545
4041
  });
3546
4042
  if (state.status === "review") {
3547
4043
  upsert(lock, key, { status: "review", reviewObjectId: entry.jobId });
@@ -3579,7 +4075,6 @@ async function poll(provider, lock, lockPath, opts = {}) {
3579
4075
  import { existsSync as existsSync8 } from "fs";
3580
4076
  import { mkdir as mkdir4, readFile as readFile8, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
3581
4077
  import path10 from "path";
3582
- var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3583
4078
  async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3584
4079
  const log = opts.onProgress ?? (() => {
3585
4080
  });
@@ -3611,7 +4106,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3611
4106
  result.skipped++;
3612
4107
  continue;
3613
4108
  }
3614
- 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 })) : [];
4109
+ 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 })) : [];
3615
4110
  if (!sources.length) {
3616
4111
  upsert(lock, key, {
3617
4112
  status: "download-failed",
@@ -3627,7 +4122,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3627
4122
  const recorded = entry.outputs.find(
3628
4123
  (o) => source.role ? o.role === source.role : !o.role && sources.length === 1
3629
4124
  );
3630
- const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length);
4125
+ const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
3631
4126
  if (existsSync8(target)) {
3632
4127
  if (!recorded) {
3633
4128
  throw new Error(`refusing to overwrite untracked output ${target}`);
@@ -3635,11 +4130,19 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3635
4130
  if (await sha256File(target) !== recorded.sha256) {
3636
4131
  throw new Error(`refusing to overwrite modified output ${target}`);
3637
4132
  }
3638
- if (cacheDir) await cachePng(cacheDir, await readFile8(target), recorded.sha256);
4133
+ if (cacheDir) {
4134
+ await cacheMedia(
4135
+ cacheDir,
4136
+ await readFile8(target),
4137
+ recorded.mediaType ?? MediaType.PNG,
4138
+ recorded.sha256
4139
+ );
4140
+ }
3639
4141
  outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
3640
4142
  continue;
3641
4143
  }
3642
- let buf = recorded && cacheDir ? await readCachedPng(cacheDir, recorded.sha256) : null;
4144
+ const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
4145
+ let buf = recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null;
3643
4146
  if (buf) {
3644
4147
  log(` cached ${path10.relative(process.cwd(), target)}`);
3645
4148
  } else {
@@ -3648,24 +4151,25 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3648
4151
  }
3649
4152
  buf = await provider.download(source.url);
3650
4153
  }
3651
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) {
3652
- throw new Error(`response for ${source.role ?? "asset"} was not a PNG (${buf.length} bytes)`);
3653
- }
4154
+ let mediaType;
3654
4155
  try {
3655
- decodePng(buf);
4156
+ mediaType = validateMedia(buf, expectedMediaType);
3656
4157
  } catch (err) {
4158
+ const label = expectedMediaType === MediaType.GIF ? "GIF" : "PNG";
4159
+ const mismatch = detectMediaType(buf) !== expectedMediaType;
3657
4160
  throw new Error(
3658
- `response for ${source.role ?? "asset"} was not a valid PNG: ${err instanceof Error ? err.message : String(err)}`
4161
+ `response for ${source.role ?? "asset"} was not ${mismatch ? "a" : "a valid"} ${label}` + (mismatch ? ` (${buf.length} bytes)` : `: ${err instanceof Error ? err.message : String(err)}`)
3659
4162
  );
3660
4163
  }
3661
- if (cacheDir) await cachePng(cacheDir, buf);
4164
+ if (cacheDir) await cacheMedia(cacheDir, buf, mediaType);
3662
4165
  await mkdir4(path10.dirname(target), { recursive: true });
3663
4166
  const tmp = `${target}.pixelkiln.tmp`;
3664
4167
  await writeFile4(tmp, buf);
3665
4168
  outputs.push({
3666
4169
  path: portableOutputPath(target, spec.root),
3667
4170
  sha256: sha256(buf),
3668
- ...source.role ? { role: source.role } : {}
4171
+ ...source.role ? { role: source.role } : {},
4172
+ mediaType
3669
4173
  });
3670
4174
  upsert(lock, key, { outputs: mergeOutputs(entry.outputs, outputs) });
3671
4175
  await saveLock(lockPath, lock);
@@ -3702,22 +4206,23 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
3702
4206
  await saveLock(lockPath, lock);
3703
4207
  return result;
3704
4208
  }
3705
- async function readCachedPng(cacheDir, hash) {
3706
- const file = path10.join(cacheDir, `${hash}.png`);
4209
+ async function readCachedMedia(cacheDir, hash, mediaType) {
4210
+ const file = path10.join(cacheDir, cacheFileName(hash, mediaType));
3707
4211
  if (!existsSync8(file)) return null;
3708
4212
  try {
3709
4213
  const buf = await readFile8(file);
3710
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2) || sha256(buf) !== hash) return null;
3711
- decodePng(buf);
4214
+ if (sha256(buf) !== hash) return null;
4215
+ validateMedia(buf, mediaType);
3712
4216
  return buf;
3713
4217
  } catch {
3714
4218
  return null;
3715
4219
  }
3716
4220
  }
3717
- async function cachePng(cacheDir, buf, knownHash) {
4221
+ async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
3718
4222
  const hash = knownHash ?? sha256(buf);
3719
- const file = path10.join(cacheDir, `${hash}.png`);
3720
- if (await readCachedPng(cacheDir, hash)) return;
4223
+ validateMedia(buf, mediaType);
4224
+ const file = path10.join(cacheDir, cacheFileName(hash, mediaType));
4225
+ if (await readCachedMedia(cacheDir, hash, mediaType)) return;
3721
4226
  await mkdir4(cacheDir, { recursive: true });
3722
4227
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3723
4228
  await writeFile4(tmp, buf);
@@ -3822,7 +4327,10 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
3822
4327
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
3823
4328
  let hasCache = false;
3824
4329
  for (const output of entry.outputs) {
3825
- const cached = path11.join(cacheDir, `${output.sha256}.png`);
4330
+ const cached = path11.join(
4331
+ cacheDir,
4332
+ cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
4333
+ );
3826
4334
  if (existsSync9(cached) && await sha256File(cached) === output.sha256) {
3827
4335
  hasCache = true;
3828
4336
  break;
@@ -3852,11 +4360,14 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
3852
4360
  add(
3853
4361
  "provider",
3854
4362
  "error",
3855
- opts.apiKeyPresent === false ? "PIXELLAB_API_KEY is not configured" : "provider is not configured"
4363
+ opts.apiKeyPresent === false ? `${opts.credentialEnv ?? "PIXELLAB_API_KEY"} is not configured` : "provider is not configured"
3856
4364
  );
4365
+ } else if (!opts.provider.balance) {
4366
+ add("provider", "ok", `${opts.provider.id} configured; balance reporting unavailable`);
3857
4367
  } else {
4368
+ const balanceFn = opts.provider.balance.bind(opts.provider);
3858
4369
  try {
3859
- const balance = await opts.provider.balance();
4370
+ const balance = await balanceFn();
3860
4371
  add("provider", "ok", `${opts.provider.id} reachable; ${balance.remaining} ${balance.unit} remaining`);
3861
4372
  } catch (err) {
3862
4373
  add("provider", "error", `provider connectivity failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -4347,7 +4858,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
4347
4858
  const entry = lock.entries[key];
4348
4859
  if (!group || !entry?.reviewObjectId) continue;
4349
4860
  if (!Number.isInteger(index) || index < 0 || index >= group.frameUrls.length) continue;
4350
- const { objectId, sourceUrl } = await provider.selectCandidate(
4861
+ const { objectId, sourceUrl } = await requireSelectCandidate(provider)(
4351
4862
  entry.reviewObjectId,
4352
4863
  index,
4353
4864
  `asset:${entry.assetId}`,
@@ -4437,6 +4948,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
4437
4948
  }
4438
4949
  return {
4439
4950
  name,
4951
+ provider: "pixellab",
4440
4952
  styles: {
4441
4953
  [styleId]: {
4442
4954
  generator,
@@ -4449,7 +4961,8 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
4449
4961
  styleImages: [],
4450
4962
  palette: [],
4451
4963
  outDir,
4452
- tags: [name]
4964
+ tags: [name],
4965
+ providerOptions: {}
4453
4966
  }
4454
4967
  },
4455
4968
  assets
@@ -4807,7 +5320,7 @@ refresh();
4807
5320
  }
4808
5321
 
4809
5322
  // src/pick/salvage-server.ts
4810
- var PNG_SIGNATURE3 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5323
+ var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
4811
5324
  async function runSalvage(provider, orphans, ctx, opts = {}) {
4812
5325
  const log = opts.onProgress ?? (() => {
4813
5326
  });
@@ -4839,7 +5352,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
4839
5352
  if (decision.action === "import") {
4840
5353
  try {
4841
5354
  const buf = await provider.download(orphan.previewUrl);
4842
- if (!buf.subarray(0, 8).equals(PNG_SIGNATURE3)) throw new Error("not a PNG");
5355
+ if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
4843
5356
  decodePng(buf);
4844
5357
  const assetId = idFromPrompt(orphan.prompt, taken);
4845
5358
  const rel = path15.join("_salvaged", `${assetId}.png`);
@@ -5072,7 +5585,6 @@ function emptyStateCounts() {
5072
5585
  }
5073
5586
  async function workspaceStatus(ws, dir) {
5074
5587
  const diagnostics = validateWorkspace(ws, dir);
5075
- const provider = PixelLabProvider.forOffline();
5076
5588
  const projects = [];
5077
5589
  const totalsByState = emptyStateCounts();
5078
5590
  const totalsSpend = { generations: 0, usd: 0, free: 0 };
@@ -5086,6 +5598,7 @@ async function workspaceStatus(ws, dir) {
5086
5598
  lock: lockPath
5087
5599
  };
5088
5600
  try {
5601
+ const provider = createProvider(project.provider, "offline");
5089
5602
  const loaded = await loadManifest(manifestPath);
5090
5603
  const specs = await resolveSpecs(loaded, { provider });
5091
5604
  const lock = await loadLock(lockPath);
@@ -5097,7 +5610,7 @@ async function workspaceStatus(ws, dir) {
5097
5610
  totalsByState[state] += byState[state];
5098
5611
  }
5099
5612
  for (const unit of Object.keys(spend)) {
5100
- totalsSpend[unit] += spend[unit];
5613
+ totalsSpend[unit] = (totalsSpend[unit] ?? 0) + (spend[unit] ?? 0);
5101
5614
  }
5102
5615
  projects.push({
5103
5616
  ...base,
@@ -5141,9 +5654,11 @@ export {
5141
5654
  MAX_DOWNLOAD_BYTES,
5142
5655
  MAX_RETRIES,
5143
5656
  ManifestSchema,
5657
+ MediaType,
5144
5658
  PixelLabClient,
5145
5659
  PixelLabError,
5146
5660
  PixelLabProvider,
5661
+ RetroDiffusionProvider,
5147
5662
  StyleSchema,
5148
5663
  UnsupportedCapabilityError,
5149
5664
  WorkspaceProjectSchema,
@@ -5151,16 +5666,20 @@ export {
5151
5666
  adopt,
5152
5667
  applyTags,
5153
5668
  auditStyle,
5669
+ availableProviders,
5154
5670
  backoffMs,
5155
5671
  buildManifest,
5156
5672
  buildPlan,
5673
+ cacheFileName,
5157
5674
  candidateCount,
5158
5675
  clientFromEnv,
5159
5676
  colorDistance,
5160
5677
  countNumberedDescriptions,
5161
5678
  createArtifactBundleManifest,
5679
+ createProvider,
5162
5680
  currentEntryOutputPath,
5163
5681
  currentOutputPath,
5682
+ detectMediaType,
5164
5683
  doctor,
5165
5684
  evaluateAudit,
5166
5685
  expectedOutputPath,
@@ -5183,6 +5702,8 @@ export {
5183
5702
  lockKey,
5184
5703
  matchOrphanStyle,
5185
5704
  measureBalanceChange,
5705
+ mediaExtension,
5706
+ mediaTypeFromExtension,
5186
5707
  mergePalettes,
5187
5708
  mountSprites,
5188
5709
  mountStyle,
@@ -5199,12 +5720,16 @@ export {
5199
5720
  poll,
5200
5721
  portableOutputPath,
5201
5722
  primaryOutput,
5723
+ providerFactory,
5202
5724
  pushTags,
5725
+ registerProvider,
5203
5726
  remove,
5204
5727
  renderSalvageSheet,
5205
5728
  renderSheet,
5729
+ requireBalance,
5206
5730
  requireDelete,
5207
5731
  requireList,
5732
+ requireSelectCandidate,
5208
5733
  resolveEntryOutputs,
5209
5734
  resolveOutputPath,
5210
5735
  resolvePackInputs,
@@ -5238,6 +5763,7 @@ export {
5238
5763
  totalSpend,
5239
5764
  upsert,
5240
5765
  validateCostEstimate,
5766
+ validateMedia,
5241
5767
  validateWorkspace,
5242
5768
  verifyArtifactBundle,
5243
5769
  withArtifactManifest,