pixelkiln 0.32.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path30 from "path";
5
- import { existsSync as existsSync24 } from "fs";
4
+ import path31 from "path";
5
+ import { existsSync as existsSync25 } from "fs";
6
6
  import { readFile as readFile25 } from "fs/promises";
7
7
 
8
8
  // src/env.ts
@@ -134,10 +134,10 @@ import { createReadStream } from "fs";
134
134
  function sha256(data) {
135
135
  return createHash("sha256").update(data).digest("hex");
136
136
  }
137
- async function sha256File(path31) {
137
+ async function sha256File(path32) {
138
138
  return new Promise((resolve, reject) => {
139
139
  const hash = createHash("sha256");
140
- const stream = createReadStream(path31);
140
+ const stream = createReadStream(path32);
141
141
  stream.on("data", (chunk2) => hash.update(chunk2));
142
142
  stream.on("error", reject);
143
143
  stream.on("end", () => resolve(hash.digest("hex")));
@@ -1612,11 +1612,11 @@ var PixelLabClient = class {
1612
1612
  * common than the failure mode of retrying (a duplicate object), and a
1613
1613
  * duplicate is visible and free to delete whereas a silent gap is neither.
1614
1614
  */
1615
- async request(path31, init, attempt = 0) {
1615
+ async request(path32, init, attempt = 0) {
1616
1616
  const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
1617
1617
  let res;
1618
1618
  try {
1619
- res = await fetch(`${BASE}${path31}`, {
1619
+ res = await fetch(`${BASE}${path32}`, {
1620
1620
  ...init,
1621
1621
  signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
1622
1622
  headers: {
@@ -1628,24 +1628,24 @@ var PixelLabClient = class {
1628
1628
  } catch (err) {
1629
1629
  if (attempt < MAX_RETRIES) {
1630
1630
  await sleep(backoffMs(attempt));
1631
- return this.request(path31, init, attempt + 1);
1631
+ return this.request(path32, init, attempt + 1);
1632
1632
  }
1633
1633
  throw err;
1634
1634
  }
1635
1635
  if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
1636
1636
  const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
1637
1637
  await sleep(waitMs);
1638
- return this.request(path31, init, attempt + 1);
1638
+ return this.request(path32, init, attempt + 1);
1639
1639
  }
1640
1640
  const text = await res.text();
1641
1641
  if (!res.ok) {
1642
- throw new PixelLabError(`${init?.method ?? "GET"} ${path31} \u2192 ${res.status}`, res.status, text);
1642
+ throw new PixelLabError(`${init?.method ?? "GET"} ${path32} \u2192 ${res.status}`, res.status, text);
1643
1643
  }
1644
1644
  if (!text) return {};
1645
1645
  try {
1646
1646
  return JSON.parse(text);
1647
1647
  } catch {
1648
- throw new Error(`${init?.method ?? "GET"} ${path31} returned invalid JSON`);
1648
+ throw new Error(`${init?.method ?? "GET"} ${path32} returned invalid JSON`);
1649
1649
  }
1650
1650
  }
1651
1651
  async balance() {
@@ -2158,11 +2158,15 @@ var AssetSchema = z2.object({
2158
2158
  });
2159
2159
  }
2160
2160
  });
2161
+ var DEFAULT_HISTORY_LIMIT = 5;
2162
+ var HistoryLimitSchema = z2.number().int().min(0).max(100);
2161
2163
  var ManifestSchema = z2.object({
2162
2164
  $schema: z2.string().optional(),
2163
2165
  name: z2.string(),
2164
2166
  /** Generation backend. Existing manifests remain PixelLab by default. */
2165
2167
  provider: z2.string().min(1).default("pixellab"),
2168
+ /** Replaced generations kept per asset for this project; overrides PIXELKILN_HISTORY. 0 keeps none. */
2169
+ history: HistoryLimitSchema.optional(),
2166
2170
  styles: z2.record(StyleSchema),
2167
2171
  assets: z2.record(AssetSchema)
2168
2172
  }).strict();
@@ -2170,9 +2174,43 @@ var ManifestInputSchema = z2.object({
2170
2174
  $schema: z2.string().optional(),
2171
2175
  name: z2.string(),
2172
2176
  provider: z2.string().min(1).default("pixellab"),
2177
+ history: HistoryLimitSchema.optional(),
2173
2178
  styles: z2.record(StyleInputSchema),
2174
2179
  assets: z2.record(AssetSchema)
2175
2180
  }).strict();
2181
+ var LockOutputSchema = z2.object({
2182
+ path: z2.string(),
2183
+ sha256: z2.string(),
2184
+ role: z2.string().optional(),
2185
+ mediaType: MediaTypeSchema.optional()
2186
+ });
2187
+ var LockSourceSchema = z2.object({
2188
+ url: z2.string(),
2189
+ role: z2.string().optional(),
2190
+ mediaType: MediaTypeSchema.optional()
2191
+ });
2192
+ var LockHistoryEntrySchema = z2.object({
2193
+ specHash: z2.string(),
2194
+ generator: GeneratorSchema,
2195
+ tileFeature: z2.string().nullable().default(null),
2196
+ prompt: z2.string(),
2197
+ width: z2.number().int(),
2198
+ height: z2.number().int(),
2199
+ jobId: z2.string().nullable().default(null),
2200
+ objectId: z2.string().nullable().default(null),
2201
+ candidateIndex: z2.number().int().nullable().default(null),
2202
+ sourceUrl: z2.string().nullable().default(null),
2203
+ sourceUrls: z2.array(LockSourceSchema).default([]),
2204
+ outputs: z2.array(LockOutputSchema).default([]),
2205
+ providerMetadata: z2.record(z2.record(z2.unknown())).default({}),
2206
+ submittedAt: z2.string().nullable().default(null),
2207
+ downloadedAt: z2.string().nullable().default(null),
2208
+ cost: z2.number().finite().nonnegative().default(0),
2209
+ costUnit: z2.string().min(1).default("generations"),
2210
+ provider: z2.string().default("pixellab"),
2211
+ /** When the replacement was submitted. */
2212
+ retiredAt: z2.string()
2213
+ });
2176
2214
  var LockEntrySchema = z2.object({
2177
2215
  styleId: z2.string(),
2178
2216
  assetId: z2.string(),
@@ -2262,7 +2300,9 @@ var LockEntrySchema = z2.object({
2262
2300
  /** Unit for `cost`. Defaults preserve pre-unit PixelLab lockfiles. */
2263
2301
  costUnit: z2.string().min(1).default("generations"),
2264
2302
  /** Which provider produced this. Absent on entries written before providers. */
2265
- provider: z2.string().default("pixellab")
2303
+ provider: z2.string().default("pixellab"),
2304
+ /** Generations this entry replaced, newest first; see LockHistoryEntrySchema. */
2305
+ history: z2.array(LockHistoryEntrySchema).optional()
2266
2306
  });
2267
2307
  var LockSchema = z2.object({
2268
2308
  version: z2.literal(2),
@@ -2488,6 +2528,7 @@ var PixelLabProvider = class _PixelLabProvider {
2488
2528
  if (obj.status === "failed") return { status: "failed", error: "generation failed upstream" };
2489
2529
  return { status: "processing" };
2490
2530
  } catch (err) {
2531
+ if (err instanceof PixelLabError && err.status === 423) return { status: "processing" };
2491
2532
  if (!(err instanceof PixelLabError) || err.status !== 404) throw err;
2492
2533
  const survivor = await this.client.getObject(jobId).catch(() => null);
2493
2534
  const url = firstUrl(survivor?.rotation_urls) ?? survivor?.preview_url ?? null;
@@ -2628,9 +2669,9 @@ var RetroDiffusionClient = class {
2628
2669
  }
2629
2670
  return balance;
2630
2671
  }
2631
- async call(path31, init = {}) {
2672
+ async call(path32, init = {}) {
2632
2673
  if (!this.token) throw new Error("RD_API_KEY is not set");
2633
- const response = await this.request(`${this.baseUrl}${path31}`, {
2674
+ const response = await this.request(`${this.baseUrl}${path32}`, {
2634
2675
  ...init,
2635
2676
  headers: {
2636
2677
  "Content-Type": "application/json",
@@ -6174,313 +6215,54 @@ function summarize(plan) {
6174
6215
  return counts;
6175
6216
  }
6176
6217
 
6177
- // src/pipeline/submit.ts
6178
- var IN_FLIGHT_POLL_MS = 5e3;
6179
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
6180
- async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
6181
- const limits = provider.rateLimit?.() ?? DEFAULT_RATE_LIMIT;
6182
- const maxInFlight = opts.maxInFlight ?? limits.maxInFlight;
6183
- const spacing = opts.spacingMs ?? limits.spacingMs;
6184
- const slotPollMs = opts.slotPollMs ?? IN_FLIGHT_POLL_MS;
6185
- const slotTimeoutMs = opts.slotTimeoutMs ?? 15 * 60 * 1e3;
6186
- const log2 = opts.onProgress ?? (() => {
6187
- });
6188
- const estimates = new Map(items.map((item) => [
6189
- item.key,
6190
- validateCostEstimate(provider.id, provider.estimate(item.spec))
6191
- ]));
6192
- const units = new Set([...estimates.values()].map((estimate) => estimate.unit));
6193
- if (units.size > 1) {
6194
- throw new Error(`Provider "${provider.id}" returned mixed cost units for one submission batch`);
6195
- }
6196
- const unit = units.values().next().value ?? "free";
6197
- if (opts.budget != null) {
6198
- const cost = [...estimates.values()].reduce((sum, estimate) => sum + estimate.amount, 0);
6199
- if (cost > opts.budget) {
6200
- throw new Error(
6201
- `This run would spend ${formatCost(unit, cost)} but the budget is ${formatCost(unit, opts.budget)}. Narrow it with --only/--style, or raise --budget.`
6202
- );
6203
- }
6204
- }
6205
- const styleImages = /* @__PURE__ */ new Map();
6206
- for (const styleId of new Set(items.map((i) => i.spec.styleId))) {
6207
- styleImages.set(styleId, await resolveStyleImages(loaded, styleId));
6208
- }
6209
- let submitted = 0;
6210
- let failed = 0;
6211
- let spent = 0;
6212
- let lastSubmitAt = 0;
6213
- const inFlight = /* @__PURE__ */ new Map();
6214
- let lastSlotError = null;
6215
- async function pruneInFlight() {
6216
- for (const [id, spec] of [...inFlight]) {
6217
- try {
6218
- const state = await provider.poll(id, spec.generator, { spec, tileFeature: spec.tileFeature });
6219
- if (state.status !== "processing") inFlight.delete(id);
6220
- lastSlotError = null;
6221
- } catch (err) {
6222
- lastSlotError = err instanceof Error ? err.message : String(err);
6223
- }
6224
- }
6218
+ // src/pipeline/history.ts
6219
+ import { existsSync as existsSync11 } from "fs";
6220
+ import path15 from "path";
6221
+
6222
+ // src/pipeline/fetch.ts
6223
+ import { existsSync as existsSync10, readFileSync as readFileSync4 } from "fs";
6224
+ import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6225
+ import path14 from "path";
6226
+
6227
+ // src/source-url.ts
6228
+ var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
6229
+ "access_token",
6230
+ "apikey",
6231
+ "api_key",
6232
+ "auth",
6233
+ "authorization",
6234
+ "awsaccesskeyid",
6235
+ "googleaccessid",
6236
+ "key",
6237
+ "sig"
6238
+ ]);
6239
+ function isSensitiveSourceUrl(value) {
6240
+ let parsed;
6241
+ try {
6242
+ parsed = new URL(value);
6243
+ } catch {
6244
+ return false;
6225
6245
  }
6226
- async function waitForSlot() {
6227
- const started = Date.now();
6228
- while (inFlight.size >= maxInFlight) {
6229
- if (Date.now() - started >= slotTimeoutMs) {
6230
- throw new Error(
6231
- `Timed out waiting for a generation slot` + (lastSlotError ? `; last poll error: ${lastSlotError}` : "")
6232
- );
6233
- }
6234
- await sleep2(slotPollMs);
6235
- await pruneInFlight();
6236
- }
6246
+ if (parsed.username || parsed.password) return true;
6247
+ for (const key of parsed.searchParams.keys()) {
6248
+ const normalized = key.toLowerCase();
6249
+ if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
6237
6250
  }
6238
- for (const { spec, key } of items) {
6239
- const estimate = estimates.get(key);
6240
- await waitForSlot();
6241
- const since = Date.now() - lastSubmitAt;
6242
- if (since < spacing) await sleep2(spacing - since);
6243
- await requireRevisionReady(spec, lock);
6244
- const previousEntry = lock.entries[key];
6245
- const resumesCheckpoint = Boolean(
6246
- previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
6247
- );
6248
- const previousJobId = resumesCheckpoint ? previousEntry.jobId : void 0;
6249
- const previousMetadata = resumesCheckpoint ? previousEntry.providerMetadata[provider.id] : void 0;
6250
- const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
6251
- upsert(lock, key, {
6252
- styleId: spec.styleId,
6253
- assetId: spec.assetId,
6254
- specHash: spec.specHash,
6255
- generator: spec.generator,
6256
- tileFeature: spec.tileFeature ?? null,
6257
- prompt: spec.prompt,
6258
- width: spec.width,
6259
- height: spec.height,
6260
- revision: spec.revision ? {
6261
- mode: spec.revision.mode,
6262
- sourceAssetId: spec.revision.sourceAssetId,
6263
- sourceSha256: spec.revision.sourceSha256,
6264
- ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {},
6265
- ...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
6266
- } : null,
6267
- status: "pending",
6268
- jobId: previousJobId ?? null,
6269
- ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
6270
- objectId: null,
6271
- reviewObjectId: null,
6272
- candidateIndex: null,
6273
- error: null,
6274
- outputs: [],
6275
- // Keep the old ownership proof while new bytes are pending. Fetch may
6276
- // replace that file only while its hash still matches this record.
6277
- supersededOutputs,
6278
- providerMetadata: resumesCheckpoint ? previousEntry.providerMetadata : {},
6279
- sourceUrl: null,
6280
- sourceUrls: [],
6281
- submittedAt: resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
6282
- cost: estimate.amount,
6283
- costUnit: estimate.unit,
6284
- // Persist routing before the request starts. If the process stops while
6285
- // the provider is accepting work, a resumed run must not fall back to
6286
- // PixelLab (the legacy lock default) for another provider's entry.
6287
- provider: provider.id,
6288
- downloadedAt: null
6289
- });
6290
- await saveLock(lockPath, lock);
6291
- lastSubmitAt = Date.now();
6292
- try {
6293
- const refs = styleImages.get(spec.styleId) ?? [];
6294
- const { jobId, metadata } = await provider.submit(spec, refs, {
6295
- ...previousJobId ? { previousJobId } : {},
6296
- ...previousMetadata ? { previousMetadata } : {},
6297
- checkpoint: async (checkpoint) => {
6298
- if (!checkpoint.jobId) throw new Error("Provider checkpoint returned an empty job id");
6299
- const entry = lock.entries[key];
6300
- upsert(lock, key, {
6301
- jobId: checkpoint.jobId,
6302
- submissionComplete: checkpoint.complete,
6303
- reviewObjectId: checkpoint.complete && (spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature) ? checkpoint.jobId : null,
6304
- status: checkpoint.complete ? "processing" : "pending",
6305
- error: null,
6306
- providerMetadata: checkpoint.metadata ? {
6307
- ...entry.providerMetadata,
6308
- [provider.id]: {
6309
- ...entry.providerMetadata[provider.id],
6310
- ...checkpoint.metadata
6311
- }
6312
- } : entry.providerMetadata
6313
- });
6314
- await saveLock(lockPath, lock);
6315
- }
6316
- });
6317
- upsert(lock, key, {
6318
- jobId,
6319
- submissionComplete: true,
6320
- // A multi-candidate generator routes through review; record the parent
6321
- // so `pick` knows where to look.
6322
- reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
6323
- status: "processing",
6324
- providerMetadata: metadata ? { ...lock.entries[key].providerMetadata, [provider.id]: metadata } : lock.entries[key].providerMetadata
6325
- });
6326
- inFlight.set(jobId, spec);
6327
- submitted++;
6328
- spent += estimate.amount;
6329
- log2(
6330
- ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
6331
- );
6332
- } catch (err) {
6333
- const message6 = err instanceof Error ? err.message : String(err);
6334
- const entry = lock.entries[key];
6335
- if (entry.jobId && entry.submissionComplete === true) {
6336
- upsert(lock, key, { status: "processing", error: null });
6337
- inFlight.set(entry.jobId, spec);
6338
- submitted++;
6339
- spent += estimate.amount;
6340
- log2(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
6341
- } else {
6342
- failed++;
6343
- upsert(lock, key, {
6344
- status: "failed",
6345
- error: message6,
6346
- cost: entry.jobId ? estimate.amount : 0
6347
- });
6348
- log2(` FAILED ${key}: ${message6}`);
6349
- }
6350
- }
6351
- await saveLock(lockPath, lock);
6251
+ return false;
6252
+ }
6253
+ function shouldPersistSourceUrl(value) {
6254
+ let parsed;
6255
+ try {
6256
+ parsed = new URL(value);
6257
+ } catch {
6258
+ return true;
6352
6259
  }
6353
- return { submitted, failed, spent, unit };
6260
+ if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
6261
+ return !isSensitiveSourceUrl(value);
6354
6262
  }
6355
6263
 
6356
- // src/pipeline/poll.ts
6357
- var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
6358
- async function poll(provider, lock, lockPath, opts = {}) {
6359
- const interval = opts.intervalMs ?? 5e3;
6360
- const timeout = opts.timeoutMs ?? 15 * 60 * 1e3;
6361
- const log2 = opts.onProgress ?? (() => {
6362
- });
6363
- const started = Date.now();
6364
- const specByKey = new Map((opts.specs ?? []).map((s) => [lockKey(s.styleId, s.assetId), s]));
6365
- const selectedKeys = opts.specs ? new Set(specByKey.keys()) : null;
6366
- const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
6367
- const incompleteSubmissions = Object.entries(lock.entries).filter(
6368
- ([key, entry]) => entry.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && entry.jobId && entry.submissionComplete === false && (entry.status === "pending" || entry.status === "processing")
6369
- );
6370
- for (const [key] of incompleteSubmissions) {
6371
- log2(` incomplete submission ${key}; rerun \`pixelkiln submit\` to resume`);
6372
- }
6373
- result.stillRunning = incompleteSubmissions.length;
6374
- const pending = () => Object.entries(lock.entries).filter(
6375
- ([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && e.submissionComplete !== false && (e.status === "pending" || e.status === "processing")
6376
- );
6377
- while (pending().length > 0) {
6378
- if (Date.now() - started > timeout) {
6379
- result.stillRunning += pending().length;
6380
- log2(` timed out with ${result.stillRunning} job(s) still running \u2014 re-run \`poll\` to resume`);
6381
- break;
6382
- }
6383
- for (const [key, entry] of pending()) {
6384
- try {
6385
- const currentSpec = specByKey.get(key);
6386
- const state = await provider.poll(entry.jobId, entry.generator, {
6387
- tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
6388
- spec: currentSpec
6389
- });
6390
- if (state.status === "review" || state.status === "review-set") {
6391
- upsert(lock, key, {
6392
- status: "review",
6393
- reviewObjectId: entry.jobId,
6394
- providerMetadata: state.metadata ? {
6395
- ...entry.providerMetadata,
6396
- [provider.id]: {
6397
- ...entry.providerMetadata[provider.id],
6398
- ...state.metadata
6399
- }
6400
- } : entry.providerMetadata
6401
- });
6402
- result.review++;
6403
- log2(
6404
- state.status === "review-set" ? ` review ${key} (${state.frameUrls.length} ordered frames)` : ` review ${key} (${state.candidateUrls.length} candidates)`
6405
- );
6406
- } else if (state.status === "ready") {
6407
- const sourceUrls = state.sources?.length ? state.sources : state.sourceUrl ? [{ url: state.sourceUrl }] : [];
6408
- upsert(lock, key, {
6409
- status: "selected",
6410
- objectId: state.objectId,
6411
- sourceUrl: sourceUrls[0]?.url ?? null,
6412
- sourceUrls,
6413
- providerMetadata: state.metadata ? {
6414
- ...entry.providerMetadata,
6415
- [provider.id]: {
6416
- ...entry.providerMetadata[provider.id],
6417
- ...state.metadata
6418
- }
6419
- } : entry.providerMetadata,
6420
- error: null
6421
- });
6422
- result.completed++;
6423
- log2(` ready ${key}`);
6424
- } else if (state.status === "failed") {
6425
- upsert(lock, key, { status: "failed", error: state.error });
6426
- result.failed++;
6427
- log2(` FAILED ${key}: ${state.error}`);
6428
- }
6429
- } catch (err) {
6430
- log2(` error polling ${key}: ${err instanceof Error ? err.message : String(err)}`);
6431
- }
6432
- }
6433
- await saveLock(lockPath, lock);
6434
- if (pending().length > 0) await sleep3(interval);
6435
- }
6436
- await saveLock(lockPath, lock);
6437
- return result;
6438
- }
6439
-
6440
- // src/pipeline/fetch.ts
6441
- import { existsSync as existsSync10, readFileSync as readFileSync4 } from "fs";
6442
- import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6443
- import path14 from "path";
6444
-
6445
- // src/source-url.ts
6446
- var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
6447
- "access_token",
6448
- "apikey",
6449
- "api_key",
6450
- "auth",
6451
- "authorization",
6452
- "awsaccesskeyid",
6453
- "googleaccessid",
6454
- "key",
6455
- "sig"
6456
- ]);
6457
- function isSensitiveSourceUrl(value) {
6458
- let parsed;
6459
- try {
6460
- parsed = new URL(value);
6461
- } catch {
6462
- return false;
6463
- }
6464
- if (parsed.username || parsed.password) return true;
6465
- for (const key of parsed.searchParams.keys()) {
6466
- const normalized = key.toLowerCase();
6467
- if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
6468
- }
6469
- return false;
6470
- }
6471
- function shouldPersistSourceUrl(value) {
6472
- let parsed;
6473
- try {
6474
- parsed = new URL(value);
6475
- } catch {
6476
- return true;
6477
- }
6478
- if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
6479
- return !isSensitiveSourceUrl(value);
6480
- }
6481
-
6482
- // src/pipeline/fetch.ts
6483
- async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6264
+ // src/pipeline/fetch.ts
6265
+ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6484
6266
  const log2 = opts.onProgress ?? (() => {
6485
6267
  });
6486
6268
  const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
@@ -6498,7 +6280,9 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6498
6280
  return e.outputs.some((output) => {
6499
6281
  const file = resolveOutputPath(output.path, spec.root);
6500
6282
  if (!existsSync10(file)) return true;
6501
- return Boolean(opts.force) && sha256(readFileSync4(file)) !== output.sha256;
6283
+ const current = sha256(readFileSync4(file));
6284
+ if (current === output.sha256) return false;
6285
+ return Boolean(opts.force) || (e.supersededOutputs ?? []).some((old) => old.sha256 === current);
6502
6286
  });
6503
6287
  });
6504
6288
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
@@ -6568,7 +6352,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6568
6352
  (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
6569
6353
  );
6570
6354
  if (!opts.force) {
6571
- if (!recorded && superseded && currentHash === superseded.sha256) {
6355
+ if (superseded && currentHash === superseded.sha256) {
6572
6356
  } else if (recorded || superseded) {
6573
6357
  throw new Error(
6574
6358
  `refusing to overwrite modified output ${target}; pass --force to replace it`
@@ -6642,79 +6426,476 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6642
6426
  } catch (err) {
6643
6427
  const message6 = err instanceof Error ? err.message : String(err);
6644
6428
  upsert(lock, key, {
6645
- status: "download-failed",
6646
- outputs: mergeOutputs(entry.outputs, outputs),
6647
- error: `download failed: ${message6}`
6429
+ status: "download-failed",
6430
+ outputs: mergeOutputs(entry.outputs, outputs),
6431
+ error: `download failed: ${message6}`
6432
+ });
6433
+ result.failed++;
6434
+ log2(` FAILED ${key}: ${message6}`);
6435
+ }
6436
+ await saveLock(lockPath, lock);
6437
+ }
6438
+ }
6439
+ await Promise.all(Array.from({ length: concurrency }, worker));
6440
+ await saveLock(lockPath, lock);
6441
+ return result;
6442
+ }
6443
+ async function readCachedMedia(cacheDir, hash, mediaType) {
6444
+ const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6445
+ if (!existsSync10(file)) return null;
6446
+ try {
6447
+ const buf = await readFile7(file);
6448
+ if (sha256(buf) !== hash) return null;
6449
+ validateMedia(buf, mediaType);
6450
+ return buf;
6451
+ } catch {
6452
+ return null;
6453
+ }
6454
+ }
6455
+ async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
6456
+ const hash = knownHash ?? sha256(buf);
6457
+ validateMedia(buf, mediaType);
6458
+ const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6459
+ if (await readCachedMedia(cacheDir, hash, mediaType)) return;
6460
+ await mkdir3(cacheDir, { recursive: true });
6461
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6462
+ await writeFile3(tmp, buf);
6463
+ try {
6464
+ await rename3(tmp, file);
6465
+ } catch (err) {
6466
+ if (!existsSync10(file)) throw err;
6467
+ } finally {
6468
+ await rm4(tmp, { force: true });
6469
+ }
6470
+ }
6471
+ function mergeOutputs(previous, current) {
6472
+ const merged = /* @__PURE__ */ new Map();
6473
+ for (const output of [...previous, ...current]) {
6474
+ merged.set(output.role ? `role:${output.role}` : `path:${output.path}`, output);
6475
+ }
6476
+ return [...merged.values()];
6477
+ }
6478
+ async function pushTags(provider, specs, lock, opts = {}) {
6479
+ const log2 = opts.onProgress ?? (() => {
6480
+ });
6481
+ const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
6482
+ let tagged = 0;
6483
+ for (const [key, entry] of Object.entries(lock.entries)) {
6484
+ const spec = specByKey.get(key);
6485
+ if (!spec || entry.provider !== provider.id || !entry.objectId) continue;
6486
+ if (entry.generator === "pixflux") continue;
6487
+ if (!provider.setTags) return tagged;
6488
+ try {
6489
+ await provider.setTags(entry.objectId, spec.tags);
6490
+ tagged++;
6491
+ } catch (err) {
6492
+ log2(` tag failed ${key}: ${err instanceof Error ? err.message : String(err)}`);
6493
+ }
6494
+ }
6495
+ return tagged;
6496
+ }
6497
+
6498
+ // src/pipeline/history.ts
6499
+ var HISTORY_ENV = "PIXELKILN_HISTORY";
6500
+ function historyLimit(manifest, env = process.env) {
6501
+ if (manifest?.history !== void 0) return manifest.history;
6502
+ const raw = env[HISTORY_ENV]?.trim();
6503
+ if (raw === void 0 || raw === "") return DEFAULT_HISTORY_LIMIT;
6504
+ const value = Number(raw);
6505
+ if (!Number.isInteger(value) || value < 0 || value > 100) {
6506
+ throw new Error(`${HISTORY_ENV} must be a whole number from 0 to 100, got "${raw}"`);
6507
+ }
6508
+ return value;
6509
+ }
6510
+ function retireGeneration(entry, retiredAt) {
6511
+ return {
6512
+ specHash: entry.specHash,
6513
+ generator: entry.generator,
6514
+ tileFeature: entry.tileFeature,
6515
+ prompt: entry.prompt,
6516
+ width: entry.width,
6517
+ height: entry.height,
6518
+ jobId: entry.jobId,
6519
+ objectId: entry.objectId,
6520
+ candidateIndex: entry.candidateIndex,
6521
+ sourceUrl: entry.sourceUrl,
6522
+ sourceUrls: entry.sourceUrls,
6523
+ outputs: entry.outputs,
6524
+ providerMetadata: entry.providerMetadata,
6525
+ submittedAt: entry.submittedAt,
6526
+ downloadedAt: entry.downloadedAt,
6527
+ cost: entry.cost,
6528
+ costUnit: entry.costUnit,
6529
+ provider: entry.provider,
6530
+ retiredAt
6531
+ };
6532
+ }
6533
+ function historyAfterReplacing(previous, limit, retiredAt) {
6534
+ if (!previous) return void 0;
6535
+ const kept = previous.history ?? [];
6536
+ const outgoing = previous.status === "downloaded" && previous.outputs.length ? [retireGeneration(previous, retiredAt)] : [];
6537
+ const history = [...outgoing, ...kept].slice(0, limit);
6538
+ return history.length ? history : void 0;
6539
+ }
6540
+ function referencedHashes(lock) {
6541
+ const hashes = /* @__PURE__ */ new Set();
6542
+ for (const entry of Object.values(lock.entries)) {
6543
+ for (const output of entry.outputs) hashes.add(output.sha256);
6544
+ for (const generation of entry.history ?? []) for (const output of generation.outputs) hashes.add(output.sha256);
6545
+ }
6546
+ return hashes;
6547
+ }
6548
+ function pickHistory(entry, selector) {
6549
+ const history = entry.history ?? [];
6550
+ if (!history.length) throw new Error(`${entry.styleId}/${entry.assetId} has no previous generations recorded`);
6551
+ if (typeof selector === "number" || /^\d{1,3}$/.test(selector)) {
6552
+ const index = Number(selector);
6553
+ const generation = history[index - 1];
6554
+ if (!generation) throw new Error(`${entry.styleId}/${entry.assetId} keeps ${history.length} previous generation(s); there is no #${index}`);
6555
+ return { index, generation };
6556
+ }
6557
+ const prefix = selector.toLowerCase();
6558
+ const matches = history.map((generation, i) => ({ index: i + 1, generation })).filter(({ generation }) => generation.outputs.some((output) => output.sha256.startsWith(prefix)));
6559
+ if (matches.length !== 1) {
6560
+ throw new Error(
6561
+ matches.length ? `"${selector}" matches ${matches.length} previous generations of ${entry.styleId}/${entry.assetId}; give more of the hash` : `no previous generation of ${entry.styleId}/${entry.assetId} has an output hash starting with "${selector}"`
6562
+ );
6563
+ }
6564
+ return matches[0];
6565
+ }
6566
+ async function revertGeneration(provider, spec, lock, lockPath, opts) {
6567
+ const key = lockKey(spec.styleId, spec.assetId);
6568
+ const entry = lock.entries[key];
6569
+ if (!entry) throw new Error(`${key} is not in the lockfile`);
6570
+ if (entry.status !== "downloaded") {
6571
+ throw new Error(`${key} is ${entry.status}; finish or fetch the current generation before restoring an older one`);
6572
+ }
6573
+ const { index, generation } = pickHistory(entry, opts.generation);
6574
+ if (generation.provider !== provider.id) {
6575
+ throw new Error(`${key}'s previous generation #${index} came from ${generation.provider}, not ${provider.id}`);
6576
+ }
6577
+ for (let i = 0; i < entry.outputs.length; i++) {
6578
+ const file = currentEntryOutputPath(entry, spec, i);
6579
+ if (!existsSync11(file)) continue;
6580
+ if (await sha256File(file) !== entry.outputs[i].sha256 && !opts.force) {
6581
+ throw new Error(
6582
+ `${path15.relative(process.cwd(), file)} was changed after download; keep it with pixelkiln edit, or pass --force to replace it`
6583
+ );
6584
+ }
6585
+ }
6586
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6587
+ const remaining = (entry.history ?? []).filter((_, i) => i !== index - 1);
6588
+ const history = [retireGeneration(entry, now), ...remaining];
6589
+ upsert(lock, key, {
6590
+ specHash: generation.specHash,
6591
+ generator: generation.generator,
6592
+ tileFeature: generation.tileFeature,
6593
+ prompt: generation.prompt,
6594
+ width: generation.width,
6595
+ height: generation.height,
6596
+ jobId: generation.jobId,
6597
+ objectId: generation.objectId,
6598
+ candidateIndex: generation.candidateIndex,
6599
+ reviewObjectId: null,
6600
+ status: "downloaded",
6601
+ error: null,
6602
+ sourceUrl: generation.sourceUrl,
6603
+ sourceUrls: generation.sourceUrls,
6604
+ outputs: generation.outputs,
6605
+ // The files on disk are the outgoing generation's; that record authorizes
6606
+ // replacing them without --force, exactly as after a regeneration.
6607
+ supersededOutputs: entry.outputs,
6608
+ providerMetadata: generation.providerMetadata,
6609
+ submittedAt: generation.submittedAt,
6610
+ downloadedAt: generation.downloadedAt,
6611
+ cost: generation.cost,
6612
+ costUnit: generation.costUnit,
6613
+ provider: generation.provider,
6614
+ history
6615
+ });
6616
+ await saveLock(lockPath, lock);
6617
+ const result = await fetchAssets(provider, [spec], lock, lockPath, {
6618
+ onProgress: opts.onProgress,
6619
+ repair: true,
6620
+ force: opts.force,
6621
+ cacheDir: opts.cacheDir
6622
+ });
6623
+ if (result.failed) {
6624
+ throw new Error(`the previous generation's bytes could not be written back; run pixelkiln restore --only ${spec.assetId} --style ${spec.styleId} once the provider is reachable`);
6625
+ }
6626
+ return { restored: generation, index, downloaded: result.downloaded };
6627
+ }
6628
+
6629
+ // src/pipeline/submit.ts
6630
+ var IN_FLIGHT_POLL_MS = 5e3;
6631
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
6632
+ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
6633
+ const limits = provider.rateLimit?.() ?? DEFAULT_RATE_LIMIT;
6634
+ const maxInFlight = opts.maxInFlight ?? limits.maxInFlight;
6635
+ const spacing = opts.spacingMs ?? limits.spacingMs;
6636
+ const slotPollMs = opts.slotPollMs ?? IN_FLIGHT_POLL_MS;
6637
+ const slotTimeoutMs = opts.slotTimeoutMs ?? 15 * 60 * 1e3;
6638
+ const log2 = opts.onProgress ?? (() => {
6639
+ });
6640
+ const estimates = new Map(items.map((item) => [
6641
+ item.key,
6642
+ validateCostEstimate(provider.id, provider.estimate(item.spec))
6643
+ ]));
6644
+ const units = new Set([...estimates.values()].map((estimate) => estimate.unit));
6645
+ if (units.size > 1) {
6646
+ throw new Error(`Provider "${provider.id}" returned mixed cost units for one submission batch`);
6647
+ }
6648
+ const unit = units.values().next().value ?? "free";
6649
+ if (opts.budget != null) {
6650
+ const cost = [...estimates.values()].reduce((sum, estimate) => sum + estimate.amount, 0);
6651
+ if (cost > opts.budget) {
6652
+ throw new Error(
6653
+ `This run would spend ${formatCost(unit, cost)} but the budget is ${formatCost(unit, opts.budget)}. Narrow it with --only/--style, or raise --budget.`
6654
+ );
6655
+ }
6656
+ }
6657
+ const styleImages = /* @__PURE__ */ new Map();
6658
+ for (const styleId of new Set(items.map((i) => i.spec.styleId))) {
6659
+ styleImages.set(styleId, await resolveStyleImages(loaded, styleId));
6660
+ }
6661
+ let submitted = 0;
6662
+ let failed = 0;
6663
+ let spent = 0;
6664
+ let lastSubmitAt = 0;
6665
+ const inFlight = /* @__PURE__ */ new Map();
6666
+ let lastSlotError = null;
6667
+ async function pruneInFlight() {
6668
+ for (const [id, spec] of [...inFlight]) {
6669
+ try {
6670
+ const state = await provider.poll(id, spec.generator, { spec, tileFeature: spec.tileFeature });
6671
+ if (state.status !== "processing") inFlight.delete(id);
6672
+ lastSlotError = null;
6673
+ } catch (err) {
6674
+ lastSlotError = err instanceof Error ? err.message : String(err);
6675
+ }
6676
+ }
6677
+ }
6678
+ async function waitForSlot() {
6679
+ const started = Date.now();
6680
+ while (inFlight.size >= maxInFlight) {
6681
+ if (Date.now() - started >= slotTimeoutMs) {
6682
+ throw new Error(
6683
+ `Timed out waiting for a generation slot` + (lastSlotError ? `; last poll error: ${lastSlotError}` : "")
6684
+ );
6685
+ }
6686
+ await sleep2(slotPollMs);
6687
+ await pruneInFlight();
6688
+ }
6689
+ }
6690
+ for (const { spec, key } of items) {
6691
+ const estimate = estimates.get(key);
6692
+ await waitForSlot();
6693
+ const since = Date.now() - lastSubmitAt;
6694
+ if (since < spacing) await sleep2(spacing - since);
6695
+ await requireRevisionReady(spec, lock);
6696
+ const previousEntry = lock.entries[key];
6697
+ const resumesCheckpoint = Boolean(
6698
+ previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
6699
+ );
6700
+ const previousJobId = resumesCheckpoint ? previousEntry.jobId : void 0;
6701
+ const previousMetadata = resumesCheckpoint ? previousEntry.providerMetadata[provider.id] : void 0;
6702
+ const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
6703
+ const submittedAt = resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString();
6704
+ const history = historyAfterReplacing(previousEntry, historyLimit(loaded.manifest), submittedAt ?? (/* @__PURE__ */ new Date()).toISOString());
6705
+ upsert(lock, key, {
6706
+ styleId: spec.styleId,
6707
+ assetId: spec.assetId,
6708
+ specHash: spec.specHash,
6709
+ generator: spec.generator,
6710
+ tileFeature: spec.tileFeature ?? null,
6711
+ prompt: spec.prompt,
6712
+ width: spec.width,
6713
+ height: spec.height,
6714
+ revision: spec.revision ? {
6715
+ mode: spec.revision.mode,
6716
+ sourceAssetId: spec.revision.sourceAssetId,
6717
+ sourceSha256: spec.revision.sourceSha256,
6718
+ ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {},
6719
+ ...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
6720
+ } : null,
6721
+ status: "pending",
6722
+ jobId: previousJobId ?? null,
6723
+ ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
6724
+ objectId: null,
6725
+ reviewObjectId: null,
6726
+ candidateIndex: null,
6727
+ error: null,
6728
+ outputs: [],
6729
+ // Keep the old ownership proof while new bytes are pending. Fetch may
6730
+ // replace that file only while its hash still matches this record.
6731
+ supersededOutputs,
6732
+ providerMetadata: resumesCheckpoint ? previousEntry.providerMetadata : {},
6733
+ sourceUrl: null,
6734
+ sourceUrls: [],
6735
+ submittedAt,
6736
+ history,
6737
+ cost: estimate.amount,
6738
+ costUnit: estimate.unit,
6739
+ // Persist routing before the request starts. If the process stops while
6740
+ // the provider is accepting work, a resumed run must not fall back to
6741
+ // PixelLab (the legacy lock default) for another provider's entry.
6742
+ provider: provider.id,
6743
+ downloadedAt: null
6744
+ });
6745
+ await saveLock(lockPath, lock);
6746
+ lastSubmitAt = Date.now();
6747
+ try {
6748
+ const refs = styleImages.get(spec.styleId) ?? [];
6749
+ const { jobId, metadata } = await provider.submit(spec, refs, {
6750
+ ...previousJobId ? { previousJobId } : {},
6751
+ ...previousMetadata ? { previousMetadata } : {},
6752
+ checkpoint: async (checkpoint) => {
6753
+ if (!checkpoint.jobId) throw new Error("Provider checkpoint returned an empty job id");
6754
+ const entry = lock.entries[key];
6755
+ upsert(lock, key, {
6756
+ jobId: checkpoint.jobId,
6757
+ submissionComplete: checkpoint.complete,
6758
+ reviewObjectId: checkpoint.complete && (spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature) ? checkpoint.jobId : null,
6759
+ status: checkpoint.complete ? "processing" : "pending",
6760
+ error: null,
6761
+ providerMetadata: checkpoint.metadata ? {
6762
+ ...entry.providerMetadata,
6763
+ [provider.id]: {
6764
+ ...entry.providerMetadata[provider.id],
6765
+ ...checkpoint.metadata
6766
+ }
6767
+ } : entry.providerMetadata
6768
+ });
6769
+ await saveLock(lockPath, lock);
6770
+ }
6771
+ });
6772
+ upsert(lock, key, {
6773
+ jobId,
6774
+ submissionComplete: true,
6775
+ // A multi-candidate generator routes through review; record the parent
6776
+ // so `pick` knows where to look.
6777
+ reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
6778
+ status: "processing",
6779
+ providerMetadata: metadata ? { ...lock.entries[key].providerMetadata, [provider.id]: metadata } : lock.entries[key].providerMetadata
6780
+ });
6781
+ inFlight.set(jobId, spec);
6782
+ submitted++;
6783
+ spent += estimate.amount;
6784
+ log2(
6785
+ ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
6786
+ );
6787
+ } catch (err) {
6788
+ const message6 = err instanceof Error ? err.message : String(err);
6789
+ const entry = lock.entries[key];
6790
+ if (entry.jobId && entry.submissionComplete === true) {
6791
+ upsert(lock, key, { status: "processing", error: null });
6792
+ inFlight.set(entry.jobId, spec);
6793
+ submitted++;
6794
+ spent += estimate.amount;
6795
+ log2(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
6796
+ } else {
6797
+ failed++;
6798
+ upsert(lock, key, {
6799
+ status: "failed",
6800
+ error: message6,
6801
+ cost: entry.jobId ? estimate.amount : 0
6648
6802
  });
6649
- result.failed++;
6650
- log2(` FAILED ${key}: ${message6}`);
6803
+ log2(` FAILED ${key}: ${message6}`);
6651
6804
  }
6652
- await saveLock(lockPath, lock);
6653
6805
  }
6806
+ await saveLock(lockPath, lock);
6654
6807
  }
6655
- await Promise.all(Array.from({ length: concurrency }, worker));
6656
- await saveLock(lockPath, lock);
6657
- return result;
6658
- }
6659
- async function readCachedMedia(cacheDir, hash, mediaType) {
6660
- const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6661
- if (!existsSync10(file)) return null;
6662
- try {
6663
- const buf = await readFile7(file);
6664
- if (sha256(buf) !== hash) return null;
6665
- validateMedia(buf, mediaType);
6666
- return buf;
6667
- } catch {
6668
- return null;
6669
- }
6670
- }
6671
- async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
6672
- const hash = knownHash ?? sha256(buf);
6673
- validateMedia(buf, mediaType);
6674
- const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6675
- if (await readCachedMedia(cacheDir, hash, mediaType)) return;
6676
- await mkdir3(cacheDir, { recursive: true });
6677
- const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6678
- await writeFile3(tmp, buf);
6679
- try {
6680
- await rename3(tmp, file);
6681
- } catch (err) {
6682
- if (!existsSync10(file)) throw err;
6683
- } finally {
6684
- await rm4(tmp, { force: true });
6685
- }
6686
- }
6687
- function mergeOutputs(previous, current) {
6688
- const merged = /* @__PURE__ */ new Map();
6689
- for (const output of [...previous, ...current]) {
6690
- merged.set(output.role ? `role:${output.role}` : `path:${output.path}`, output);
6691
- }
6692
- return [...merged.values()];
6808
+ return { submitted, failed, spent, unit };
6693
6809
  }
6694
- async function pushTags(provider, specs, lock, opts = {}) {
6810
+
6811
+ // src/pipeline/poll.ts
6812
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
6813
+ async function poll(provider, lock, lockPath, opts = {}) {
6814
+ const interval = opts.intervalMs ?? 5e3;
6815
+ const timeout = opts.timeoutMs ?? 15 * 60 * 1e3;
6695
6816
  const log2 = opts.onProgress ?? (() => {
6696
6817
  });
6697
- const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
6698
- let tagged = 0;
6699
- for (const [key, entry] of Object.entries(lock.entries)) {
6700
- const spec = specByKey.get(key);
6701
- if (!spec || entry.provider !== provider.id || !entry.objectId) continue;
6702
- if (entry.generator === "pixflux") continue;
6703
- if (!provider.setTags) return tagged;
6704
- try {
6705
- await provider.setTags(entry.objectId, spec.tags);
6706
- tagged++;
6707
- } catch (err) {
6708
- log2(` tag failed ${key}: ${err instanceof Error ? err.message : String(err)}`);
6818
+ const started = Date.now();
6819
+ const specByKey = new Map((opts.specs ?? []).map((s) => [lockKey(s.styleId, s.assetId), s]));
6820
+ const selectedKeys = opts.specs ? new Set(specByKey.keys()) : null;
6821
+ const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
6822
+ const incompleteSubmissions = Object.entries(lock.entries).filter(
6823
+ ([key, entry]) => entry.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && entry.jobId && entry.submissionComplete === false && (entry.status === "pending" || entry.status === "processing")
6824
+ );
6825
+ for (const [key] of incompleteSubmissions) {
6826
+ log2(` incomplete submission ${key}; rerun \`pixelkiln submit\` to resume`);
6827
+ }
6828
+ result.stillRunning = incompleteSubmissions.length;
6829
+ const pending = () => Object.entries(lock.entries).filter(
6830
+ ([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && e.submissionComplete !== false && (e.status === "pending" || e.status === "processing")
6831
+ );
6832
+ while (pending().length > 0) {
6833
+ if (Date.now() - started > timeout) {
6834
+ result.stillRunning += pending().length;
6835
+ log2(` timed out with ${result.stillRunning} job(s) still running \u2014 re-run \`poll\` to resume`);
6836
+ break;
6837
+ }
6838
+ for (const [key, entry] of pending()) {
6839
+ try {
6840
+ const currentSpec = specByKey.get(key);
6841
+ const state = await provider.poll(entry.jobId, entry.generator, {
6842
+ tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
6843
+ spec: currentSpec
6844
+ });
6845
+ if (state.status === "review" || state.status === "review-set") {
6846
+ upsert(lock, key, {
6847
+ status: "review",
6848
+ reviewObjectId: entry.jobId,
6849
+ providerMetadata: state.metadata ? {
6850
+ ...entry.providerMetadata,
6851
+ [provider.id]: {
6852
+ ...entry.providerMetadata[provider.id],
6853
+ ...state.metadata
6854
+ }
6855
+ } : entry.providerMetadata
6856
+ });
6857
+ result.review++;
6858
+ log2(
6859
+ state.status === "review-set" ? ` review ${key} (${state.frameUrls.length} ordered frames)` : ` review ${key} (${state.candidateUrls.length} candidates)`
6860
+ );
6861
+ } else if (state.status === "ready") {
6862
+ const sourceUrls = state.sources?.length ? state.sources : state.sourceUrl ? [{ url: state.sourceUrl }] : [];
6863
+ upsert(lock, key, {
6864
+ status: "selected",
6865
+ objectId: state.objectId,
6866
+ sourceUrl: sourceUrls[0]?.url ?? null,
6867
+ sourceUrls,
6868
+ providerMetadata: state.metadata ? {
6869
+ ...entry.providerMetadata,
6870
+ [provider.id]: {
6871
+ ...entry.providerMetadata[provider.id],
6872
+ ...state.metadata
6873
+ }
6874
+ } : entry.providerMetadata,
6875
+ error: null
6876
+ });
6877
+ result.completed++;
6878
+ log2(` ready ${key}`);
6879
+ } else if (state.status === "failed") {
6880
+ upsert(lock, key, { status: "failed", error: state.error });
6881
+ result.failed++;
6882
+ log2(` FAILED ${key}: ${state.error}`);
6883
+ }
6884
+ } catch (err) {
6885
+ log2(` error polling ${key}: ${err instanceof Error ? err.message : String(err)}`);
6886
+ }
6709
6887
  }
6888
+ await saveLock(lockPath, lock);
6889
+ if (pending().length > 0) await sleep3(interval);
6710
6890
  }
6711
- return tagged;
6891
+ await saveLock(lockPath, lock);
6892
+ return result;
6712
6893
  }
6713
6894
 
6714
6895
  // src/pipeline/doctor.ts
6715
- import { constants, existsSync as existsSync11 } from "fs";
6896
+ import { constants, existsSync as existsSync12 } from "fs";
6716
6897
  import { access, stat as stat2 } from "fs/promises";
6717
- import path15 from "path";
6898
+ import path16 from "path";
6718
6899
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6719
6900
  const checks = [];
6720
6901
  const add = (id, level, message6) => checks.push({ id, level, message: message6 });
@@ -6725,8 +6906,8 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6725
6906
  );
6726
6907
  add("lock", "ok", `${Object.keys(lock.entries).length} valid v2 lock entr(ies)`);
6727
6908
  const dirs = [...new Set(specs.flatMap((spec) => [
6728
- path15.dirname(spec.outFile),
6729
- ...spec.quality ? [path15.dirname(spec.quality.outFile)] : []
6909
+ path16.dirname(spec.outFile),
6910
+ ...spec.quality ? [path16.dirname(spec.quality.outFile)] : []
6730
6911
  ]))];
6731
6912
  const unwritable = [];
6732
6913
  for (const dir of dirs) {
@@ -6749,7 +6930,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6749
6930
  for (let index = 0; index < entry.outputs.length; index++) {
6750
6931
  const output = entry.outputs[index];
6751
6932
  const spec = specByKey.get(key);
6752
- const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, path15.dirname(lockPath));
6933
+ const absolute = spec ? currentEntryOutputPath(entry, spec, index) : resolveOutputPath(output.path, path16.dirname(lockPath));
6753
6934
  const owner = outputOwners.get(absolute);
6754
6935
  if (owner && owner !== key) duplicateOutputs.push(`${output.path} (${owner}, ${key})`);
6755
6936
  outputOwners.set(absolute, key);
@@ -6764,7 +6945,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6764
6945
  } else {
6765
6946
  add("lock-outputs", "ok", `${outputOwners.size} recorded output path(s) are uniquely owned`);
6766
6947
  }
6767
- const cacheDir = path15.join(path15.dirname(lockPath), ".pixelkiln", "cache");
6948
+ const cacheDir = path16.join(path16.dirname(lockPath), ".pixelkiln", "cache");
6768
6949
  const stranded = [];
6769
6950
  const invalidState = [];
6770
6951
  const staleMapUrls = [];
@@ -6780,11 +6961,11 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6780
6961
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
6781
6962
  let hasCache = false;
6782
6963
  for (const output of entry.outputs) {
6783
- const cached = path15.join(
6964
+ const cached = path16.join(
6784
6965
  cacheDir,
6785
6966
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
6786
6967
  );
6787
- if (existsSync11(cached) && await sha256File(cached) === output.sha256) {
6968
+ if (existsSync12(cached) && await sha256File(cached) === output.sha256) {
6788
6969
  hasCache = true;
6789
6970
  break;
6790
6971
  }
@@ -6880,20 +7061,20 @@ async function nearestExistingDirectory(start) {
6880
7061
  if ((await stat2(current)).isDirectory()) return current;
6881
7062
  } catch {
6882
7063
  }
6883
- const parent = path15.dirname(current);
7064
+ const parent = path16.dirname(current);
6884
7065
  if (parent === current) return current;
6885
7066
  current = parent;
6886
7067
  }
6887
7068
  }
6888
7069
 
6889
7070
  // src/pipeline/adopt.ts
6890
- import { existsSync as existsSync13 } from "fs";
7071
+ import { existsSync as existsSync14 } from "fs";
6891
7072
  import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
6892
- import path16 from "path";
7073
+ import path17 from "path";
6893
7074
 
6894
7075
  // src/cache.ts
6895
7076
  import { mkdir as mkdir4, readFile as readFile8, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
6896
- import { existsSync as existsSync12 } from "fs";
7077
+ import { existsSync as existsSync13 } from "fs";
6897
7078
  import pathModule from "path";
6898
7079
  import { z as z3 } from "zod";
6899
7080
  var HashCacheSchema = z3.object({
@@ -6908,10 +7089,10 @@ function isSha256Hash(value) {
6908
7089
  function parseCache(value) {
6909
7090
  return HashCacheSchema.parse(value);
6910
7091
  }
6911
- async function loadCache(path31) {
6912
- if (!existsSync12(path31)) return { version: 1, hashes: {} };
7092
+ async function loadCache(path32) {
7093
+ if (!existsSync13(path32)) return { version: 1, hashes: {} };
6913
7094
  try {
6914
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path31, "utf8")));
7095
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path32, "utf8")));
6915
7096
  if (!parsed.success) return { version: 1, hashes: {} };
6916
7097
  return {
6917
7098
  version: 1,
@@ -6923,18 +7104,18 @@ async function loadCache(path31) {
6923
7104
  return { version: 1, hashes: {} };
6924
7105
  }
6925
7106
  }
6926
- async function saveCache(path31, cache) {
7107
+ async function saveCache(path32, cache) {
6927
7108
  const sorted = {};
6928
7109
  for (const key of Object.keys(cache.hashes).sort()) {
6929
7110
  const hash = cache.hashes[key];
6930
7111
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6931
7112
  sorted[key] = hash;
6932
7113
  }
6933
- await mkdir4(pathModule.dirname(pathModule.resolve(path31)), { recursive: true });
6934
- const tmp = `${path31}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7114
+ await mkdir4(pathModule.dirname(pathModule.resolve(path32)), { recursive: true });
7115
+ const tmp = `${path32}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6935
7116
  try {
6936
7117
  await writeFile4(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6937
- await rename4(tmp, path31);
7118
+ await rename4(tmp, path32);
6938
7119
  } finally {
6939
7120
  await rm5(tmp, { force: true });
6940
7121
  }
@@ -6965,7 +7146,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
6965
7146
  const localByHash = /* @__PURE__ */ new Map();
6966
7147
  const unmatchedLocal = [];
6967
7148
  for (const spec of specs) {
6968
- if (!existsSync13(spec.outFile)) {
7149
+ if (!existsSync14(spec.outFile)) {
6969
7150
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
6970
7151
  continue;
6971
7152
  }
@@ -7122,13 +7303,13 @@ function formatUnmatchedRemote(objects, limit = 15) {
7122
7303
  }
7123
7304
 
7124
7305
  // src/pick/server.ts
7125
- import { existsSync as existsSync15 } from "fs";
7306
+ import { existsSync as existsSync16 } from "fs";
7126
7307
  import { readFile as readFile12 } from "fs/promises";
7127
7308
 
7128
7309
  // src/pipeline/init.ts
7129
7310
  import { readdir, readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
7130
- import { existsSync as existsSync14 } from "fs";
7131
- import path17 from "path";
7311
+ import { existsSync as existsSync15 } from "fs";
7312
+ import path18 from "path";
7132
7313
  function pngSize(buf) {
7133
7314
  if (buf.length < 24) return null;
7134
7315
  const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
@@ -7142,7 +7323,7 @@ function slugify(value) {
7142
7323
  async function walk(dir, exclude) {
7143
7324
  const out = [];
7144
7325
  for (const entry of await readdir(dir, { withFileTypes: true })) {
7145
- const full = path17.join(dir, entry.name);
7326
+ const full = path18.join(dir, entry.name);
7146
7327
  if (exclude.some((x) => full.includes(x))) continue;
7147
7328
  if (entry.isDirectory()) out.push(...await walk(full, exclude));
7148
7329
  else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
@@ -7161,9 +7342,9 @@ async function scanAssets(root, opts = {}) {
7161
7342
  skipped.push(`${file} (not a readable PNG)`);
7162
7343
  continue;
7163
7344
  }
7164
- const rel = path17.relative(root, file);
7165
- const category = path17.dirname(rel) === "." ? "" : path17.dirname(rel);
7166
- let id = slugify(path17.basename(rel));
7345
+ const rel = path18.relative(root, file);
7346
+ const category = path18.dirname(rel) === "." ? "" : path18.dirname(rel);
7347
+ let id = slugify(path18.basename(rel));
7167
7348
  if (seen.has(id)) {
7168
7349
  const n = seen.get(id) + 1;
7169
7350
  seen.set(id, n);
@@ -7220,7 +7401,7 @@ function mode(values) {
7220
7401
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
7221
7402
  }
7222
7403
  async function writeManifestFile(target, manifest) {
7223
- if (existsSync14(target)) {
7404
+ if (existsSync15(target)) {
7224
7405
  throw new Error(`${target} already exists \u2014 refusing to overwrite an existing manifest.`);
7225
7406
  }
7226
7407
  await writeFile6(target, JSON.stringify(manifest, null, 2) + "\n");
@@ -7706,7 +7887,7 @@ async function currentArt(entry, spec) {
7706
7887
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
7707
7888
  if (!previous) return null;
7708
7889
  const file = resolveOutputPath(previous.path, spec.root);
7709
- if (!existsSync15(file)) return null;
7890
+ if (!existsSync16(file)) return null;
7710
7891
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
7711
7892
  if (!contentType) return null;
7712
7893
  let width = entry.width;
@@ -7877,14 +8058,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
7877
8058
 
7878
8059
  // src/gallery/snapshot.ts
7879
8060
  import { createHash as createHash3 } from "crypto";
7880
- import { existsSync as existsSync18 } from "fs";
8061
+ import { existsSync as existsSync19 } from "fs";
7881
8062
  import { readFile as readFile16, stat as stat3 } from "fs/promises";
7882
- import path22 from "path";
8063
+ import path23 from "path";
7883
8064
 
7884
8065
  // src/workspace.ts
7885
8066
  import { mkdir as mkdir5, readFile as readFile13, rename as rename5, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7886
- import { existsSync as existsSync16 } from "fs";
7887
- import path18 from "path";
8067
+ import { existsSync as existsSync17 } from "fs";
8068
+ import path19 from "path";
7888
8069
  import { z as z4 } from "zod";
7889
8070
  var WorkspaceProjectSchema = z4.object({
7890
8071
  id: z4.string().min(1),
@@ -7909,7 +8090,7 @@ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message
7909
8090
  );
7910
8091
  }
7911
8092
  async function loadWorkspace(workspacePath) {
7912
- if (!existsSync16(workspacePath)) return { version: 1, projects: [] };
8093
+ if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
7913
8094
  let raw;
7914
8095
  try {
7915
8096
  raw = JSON.parse(await readFile13(workspacePath, "utf8"));
@@ -7926,7 +8107,7 @@ async function saveWorkspace(workspacePath, ws) {
7926
8107
  version: 1,
7927
8108
  projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
7928
8109
  };
7929
- await mkdir5(path18.dirname(path18.resolve(workspacePath)), { recursive: true });
8110
+ await mkdir5(path19.dirname(path19.resolve(workspacePath)), { recursive: true });
7930
8111
  const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7931
8112
  try {
7932
8113
  await writeFile7(tmp, JSON.stringify(sorted, null, 2) + "\n");
@@ -7936,12 +8117,12 @@ async function saveWorkspace(workspacePath, ws) {
7936
8117
  }
7937
8118
  }
7938
8119
  function toPortablePath(dir, absolute) {
7939
- return path18.relative(dir, absolute).split(path18.sep).join("/");
8120
+ return path19.relative(dir, absolute).split(path19.sep).join("/");
7940
8121
  }
7941
8122
  function resolveProject(dir, project) {
7942
8123
  return {
7943
- manifestPath: path18.resolve(dir, project.manifest.split("/").join(path18.sep)),
7944
- lockPath: path18.resolve(dir, project.lock.split("/").join(path18.sep))
8124
+ manifestPath: path19.resolve(dir, project.manifest.split("/").join(path19.sep)),
8125
+ lockPath: path19.resolve(dir, project.lock.split("/").join(path19.sep))
7945
8126
  };
7946
8127
  }
7947
8128
  function validateWorkspace(ws, dir) {
@@ -7954,21 +8135,21 @@ function validateWorkspace(ws, dir) {
7954
8135
  const { manifestPath, lockPath } = resolveProject(dir, project);
7955
8136
  lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
7956
8137
  manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
7957
- if (path18.isAbsolute(project.manifest) || path18.isAbsolute(project.lock)) {
8138
+ if (path19.isAbsolute(project.manifest) || path19.isAbsolute(project.lock)) {
7958
8139
  diagnostics.push({
7959
8140
  id: "absolute-path",
7960
8141
  level: "warning",
7961
8142
  message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
7962
8143
  });
7963
8144
  }
7964
- if (!existsSync16(manifestPath)) {
8145
+ if (!existsSync17(manifestPath)) {
7965
8146
  diagnostics.push({
7966
8147
  id: "missing-manifest",
7967
8148
  level: "error",
7968
8149
  message: `project "${project.id}" manifest not found: ${manifestPath}`
7969
8150
  });
7970
8151
  }
7971
- if (!existsSync16(lockPath)) {
8152
+ if (!existsSync17(lockPath)) {
7972
8153
  diagnostics.push({
7973
8154
  id: "missing-lock",
7974
8155
  level: "error",
@@ -8015,12 +8196,12 @@ function validateWorkspace(ws, dir) {
8015
8196
  }
8016
8197
 
8017
8198
  // src/gallery/edit.ts
8018
- import path21 from "path";
8199
+ import path22 from "path";
8019
8200
  import { z as z7 } from "zod";
8020
8201
 
8021
8202
  // src/manifest-edit.ts
8022
8203
  import { readFile as readFile14, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
8023
- import path19 from "path";
8204
+ import path20 from "path";
8024
8205
  import { z as z5 } from "zod";
8025
8206
  var HexSha = z5.string().regex(/^[0-9a-f]{64}$/);
8026
8207
  var AssetPatchSchema = z5.object({
@@ -8232,7 +8413,7 @@ function applyEdit(raw, edit) {
8232
8413
  }
8233
8414
  }
8234
8415
  async function applyManifestEdit(manifestPath, edit) {
8235
- const absolute = path19.resolve(manifestPath);
8416
+ const absolute = path20.resolve(manifestPath);
8236
8417
  const original = await readFile14(absolute, "utf8");
8237
8418
  const before = sha256(original);
8238
8419
  if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
@@ -8247,9 +8428,9 @@ async function applyManifestEdit(manifestPath, edit) {
8247
8428
  applyEdit(raw, edit);
8248
8429
  const next = serializeLike(original, raw);
8249
8430
  if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
8250
- const tmp = path19.join(
8251
- path19.dirname(absolute),
8252
- `.${path19.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
8431
+ const tmp = path20.join(
8432
+ path20.dirname(absolute),
8433
+ `.${path20.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
8253
8434
  );
8254
8435
  try {
8255
8436
  await writeFile8(tmp, next);
@@ -8269,18 +8450,18 @@ async function applyManifestEdit(manifestPath, edit) {
8269
8450
 
8270
8451
  // src/pipeline/hand-edit.ts
8271
8452
  import { spawn as spawn2 } from "child_process";
8272
- import { existsSync as existsSync17 } from "fs";
8453
+ import { existsSync as existsSync18 } from "fs";
8273
8454
  import { copyFile, mkdir as mkdir6, readFile as readFile15, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
8274
- import path20 from "path";
8455
+ import path21 from "path";
8275
8456
  import { z as z6 } from "zod";
8276
8457
  var HAND_EDIT_DIR = "edits";
8277
8458
  function handEditPath(loaded, spec) {
8278
8459
  const style = loaded.manifest.styles[spec.styleId];
8279
- const outDir = path20.resolve(loaded.root, style?.outDir ?? ".");
8280
- const relative = path20.relative(outDir, spec.outFile);
8281
- const inside = relative && !relative.startsWith("..") && !path20.isAbsolute(relative);
8282
- const file = inside ? relative : path20.basename(spec.outFile);
8283
- return path20.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8460
+ const outDir = path21.resolve(loaded.root, style?.outDir ?? ".");
8461
+ const relative = path21.relative(outDir, spec.outFile);
8462
+ const inside = relative && !relative.startsWith("..") && !path21.isAbsolute(relative);
8463
+ const file = inside ? relative : path21.basename(spec.outFile);
8464
+ return path21.join(outDir, HAND_EDIT_DIR, file.replace(/\.gif$/i, ".png"));
8284
8465
  }
8285
8466
  function handEditBases(spec, lock) {
8286
8467
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -8293,10 +8474,10 @@ function handEditBases(spec, lock) {
8293
8474
  }));
8294
8475
  if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
8295
8476
  if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
8296
- if (!members.every((member) => existsSync17(member.path))) return null;
8477
+ if (!members.every((member) => existsSync18(member.path))) return null;
8297
8478
  return members.map(({ role, path: file }) => ({ role, path: file }));
8298
8479
  }
8299
- return existsSync17(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8480
+ return existsSync18(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8300
8481
  }
8301
8482
  function handEditMembers(loaded, spec, lock, editPath) {
8302
8483
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -8322,18 +8503,18 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
8322
8503
  `${spec.styleId}/${spec.assetId} declares ${spec.source}, one committed file placed for the whole set; clear that source (pixelkiln edit detach) to hand-edit the members one by one`
8323
8504
  );
8324
8505
  }
8325
- const existing = spec.source ? path20.resolve(loaded.root, spec.source) : null;
8506
+ const existing = spec.source ? path21.resolve(loaded.root, spec.source) : null;
8326
8507
  const editPath = existing ?? handEditPath(loaded, spec);
8327
8508
  const members = handEditMembers(loaded, spec, lock, editPath);
8328
8509
  let created = false;
8329
8510
  for (const member of members) {
8330
- if (existsSync17(member.path)) continue;
8511
+ if (existsSync18(member.path)) continue;
8331
8512
  if (!member.base) {
8332
8513
  throw new ManifestEditError(
8333
8514
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
8334
8515
  );
8335
8516
  }
8336
- await mkdir6(path20.dirname(member.path), { recursive: true });
8517
+ await mkdir6(path21.dirname(member.path), { recursive: true });
8337
8518
  await copyFile(member.base, member.path);
8338
8519
  created = true;
8339
8520
  }
@@ -8471,7 +8652,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
8471
8652
  editor: input.editor,
8472
8653
  protocol: input.protocol,
8473
8654
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
8474
- project: projectPath ? path20.basename(projectPath) : null,
8655
+ project: projectPath ? path21.basename(projectPath) : null,
8475
8656
  outputs: incoming.map((frame, index) => ({
8476
8657
  role: frame.role,
8477
8658
  basedOn: basedOnHash(entry, index),
@@ -8606,7 +8787,7 @@ function createGalleryEditHandler(opts) {
8606
8787
  }
8607
8788
  const result = await applyManifestEdit(manifestPath, parsed.data);
8608
8789
  if (result.changed) {
8609
- const relative = path21.relative(process.cwd(), result.manifestPath);
8790
+ const relative = path22.relative(process.cwd(), result.manifestPath);
8610
8791
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
8611
8792
  const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.action === "set-source" || parsed.data.action === "clear-source" ? `${parsed.data.styleId}/${parsed.data.assetId} source` : parsed.data.assetId;
8612
8793
  log2(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
@@ -8618,7 +8799,7 @@ function createGalleryEditHandler(opts) {
8618
8799
  // src/gallery/snapshot.ts
8619
8800
  var PROJECT_FILE_TYPE = "application/octet-stream";
8620
8801
  function galleryMediaId(absolutePath) {
8621
- return createHash3("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
8802
+ return createHash3("sha256").update(path23.resolve(absolutePath)).digest("hex").slice(0, 24);
8622
8803
  }
8623
8804
  function galleryMediaRoute(id) {
8624
8805
  return `/media/${id}`;
@@ -8633,6 +8814,38 @@ function matchesFilter(filter, styleId, assetId) {
8633
8814
  if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
8634
8815
  return true;
8635
8816
  }
8817
+ function describeHistory(media, entry, cacheDir) {
8818
+ return (entry.history ?? []).map((generation, i) => {
8819
+ const outputs = generation.outputs.map((output) => {
8820
+ const mediaType = output.mediaType ?? MediaType.PNG;
8821
+ const file = path23.join(cacheDir, cacheFileName(output.sha256, mediaType));
8822
+ let url = null;
8823
+ if (existsSync19(file)) {
8824
+ const id = galleryMediaId(file);
8825
+ media.set(id, { path: file, contentType: mediaType });
8826
+ url = `${galleryMediaRoute(id)}?v=${output.sha256.slice(0, 24)}`;
8827
+ }
8828
+ return { role: output.role ?? null, sha256: output.sha256, path: output.path, url };
8829
+ });
8830
+ return {
8831
+ index: i + 1,
8832
+ objectId: generation.objectId,
8833
+ jobId: generation.jobId,
8834
+ prompt: generation.prompt,
8835
+ promptDiffers: generation.prompt !== entry.prompt,
8836
+ width: generation.width,
8837
+ height: generation.height,
8838
+ cost: generation.cost,
8839
+ costUnit: generation.costUnit,
8840
+ submittedAt: generation.submittedAt,
8841
+ downloadedAt: generation.downloadedAt,
8842
+ retiredAt: generation.retiredAt,
8843
+ outputs,
8844
+ cached: outputs.length > 0 && outputs.every((output) => output.url !== null),
8845
+ upstreamUrl: generation.provider === "pixellab" ? pixelLabObjectUrl(generation.generator, generation.objectId) : null
8846
+ };
8847
+ });
8848
+ }
8636
8849
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
8637
8850
  async function samePixels(a, b) {
8638
8851
  try {
@@ -8689,10 +8902,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
8689
8902
  async function describeQuality(media, root, inspection) {
8690
8903
  const outputPaths = inspection.outputs ?? [inspection.output];
8691
8904
  const outputs = await Promise.all(
8692
- outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
8905
+ outputPaths.map((file) => describeOutput(media, root, path23.resolve(root, file)))
8693
8906
  );
8694
- const recordPath = path22.resolve(root, inspection.record);
8695
- const recordExists = existsSync18(recordPath);
8907
+ const recordPath = path23.resolve(root, inspection.record);
8908
+ const recordExists = existsSync19(recordPath);
8696
8909
  let options = null;
8697
8910
  let check = null;
8698
8911
  if (recordExists) {
@@ -8711,7 +8924,7 @@ async function describeQuality(media, root, inspection) {
8711
8924
  return {
8712
8925
  state: inspection.state,
8713
8926
  reason: inspection.reason,
8714
- output: portableOutputPath(path22.resolve(root, inspection.output), root),
8927
+ output: portableOutputPath(path23.resolve(root, inspection.output), root),
8715
8928
  record: portableOutputPath(recordPath, root),
8716
8929
  recordExists,
8717
8930
  outputs,
@@ -8750,6 +8963,7 @@ async function buildGallerySnapshot(opts) {
8750
8963
  const items = [];
8751
8964
  const manifestText = await readFile16(loaded.path, "utf8");
8752
8965
  const manifestSha256 = sha256(manifestText);
8966
+ const cacheDir = path23.resolve(path23.dirname(opts.lockPath), ".pixelkiln", "cache");
8753
8967
  let rawStyles = {};
8754
8968
  try {
8755
8969
  const raw = JSON.parse(manifestText);
@@ -8773,12 +8987,12 @@ async function buildGallerySnapshot(opts) {
8773
8987
  )
8774
8988
  );
8775
8989
  if (spec.source) {
8776
- const editPath = path22.resolve(root, spec.source);
8990
+ const editPath = path23.resolve(root, spec.source);
8777
8991
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
8778
8992
  const editShas = await Promise.all(
8779
8993
  Array.from({ length: memberCount }, async (_, index) => {
8780
8994
  const file = sourceOutputPath(spec.source, entry, index, root);
8781
- return { file, sha256: existsSync18(file) ? await sha256File(file) : null };
8995
+ return { file, sha256: existsSync19(file) ? await sha256File(file) : null };
8782
8996
  })
8783
8997
  );
8784
8998
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -8786,7 +9000,7 @@ async function buildGallerySnapshot(opts) {
8786
9000
  const companion = await readHandEditCompanion(editPath);
8787
9001
  if (companion) {
8788
9002
  const projectPath = handEditProjectPath(editPath);
8789
- const project = companion.project && existsSync18(projectPath) ? await fileInfo(projectPath) : null;
9003
+ const project = companion.project && existsSync19(projectPath) ? await fileInfo(projectPath) : null;
8790
9004
  let projectUrl = null;
8791
9005
  if (project) {
8792
9006
  const id = galleryMediaId(projectPath);
@@ -8822,8 +9036,8 @@ async function buildGallerySnapshot(opts) {
8822
9036
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
8823
9037
  }
8824
9038
  } else if (spec.source) {
8825
- outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
8826
- } else if (!entry && existsSync18(spec.outFile)) {
9039
+ outputs = [await describeOutput(media, root, path23.resolve(root, spec.source))];
9040
+ } else if (!entry && existsSync19(spec.outFile)) {
8827
9041
  outputs = [await describeOutput(media, root, spec.outFile)];
8828
9042
  } else {
8829
9043
  outputs = [];
@@ -8874,6 +9088,7 @@ async function buildGallerySnapshot(opts) {
8874
9088
  editMeta,
8875
9089
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8876
9090
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9091
+ history: entry ? describeHistory(media, entry, cacheDir) : [],
8877
9092
  tags: spec.tags,
8878
9093
  category: asset?.category ?? null
8879
9094
  });
@@ -8933,6 +9148,7 @@ async function buildGallerySnapshot(opts) {
8933
9148
  editMeta: null,
8934
9149
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8935
9150
  refreshable: false,
9151
+ history: describeHistory(media, entry, cacheDir),
8936
9152
  tags: [],
8937
9153
  category: null
8938
9154
  });
@@ -9000,11 +9216,12 @@ async function buildGallerySnapshot(opts) {
9000
9216
  id: loaded.manifest.name,
9001
9217
  name: loaded.manifest.name,
9002
9218
  manifest: loaded.path,
9003
- lock: path22.resolve(opts.lockPath),
9219
+ lock: path23.resolve(opts.lockPath),
9004
9220
  root,
9005
9221
  provider: loaded.manifest.provider,
9006
9222
  account: null,
9007
9223
  manifestSha256,
9224
+ historyLimit: historyLimit(loaded.manifest),
9008
9225
  entries: Object.keys(lock.entries).length,
9009
9226
  items: items.length,
9010
9227
  spendByUnit: spendByUnit(lock),
@@ -9025,7 +9242,7 @@ async function buildGallerySnapshot(opts) {
9025
9242
  return { snapshot, media };
9026
9243
  }
9027
9244
  async function buildWorkspaceGallerySnapshot(opts) {
9028
- const dir = path22.dirname(path22.resolve(opts.workspacePath));
9245
+ const dir = path23.dirname(path23.resolve(opts.workspacePath));
9029
9246
  const media = /* @__PURE__ */ new Map();
9030
9247
  const projects = [];
9031
9248
  const items = [];
@@ -9068,11 +9285,12 @@ async function buildWorkspaceGallerySnapshot(opts) {
9068
9285
  id: project.id,
9069
9286
  name: loaded.manifest.name,
9070
9287
  manifest: manifestPath,
9071
- lock: path22.resolve(lockPath),
9288
+ lock: path23.resolve(lockPath),
9072
9289
  root: loaded.root,
9073
9290
  provider: loaded.manifest.provider,
9074
9291
  account: project.account ?? null,
9075
9292
  manifestSha256: manifestSha256 ?? sha256(await readFile16(manifestPath, "utf8")),
9293
+ historyLimit: historyLimit(loaded.manifest),
9076
9294
  entries: Object.keys(lock.entries).length,
9077
9295
  items: projectItems.length,
9078
9296
  spendByUnit: spendByUnit(lock),
@@ -9083,11 +9301,12 @@ async function buildWorkspaceGallerySnapshot(opts) {
9083
9301
  id: project.id,
9084
9302
  name: project.id,
9085
9303
  manifest: manifestPath,
9086
- lock: path22.resolve(lockPath),
9087
- root: path22.dirname(manifestPath),
9304
+ lock: path23.resolve(lockPath),
9305
+ root: path23.dirname(manifestPath),
9088
9306
  provider: project.provider,
9089
9307
  account: project.account ?? null,
9090
9308
  manifestSha256: null,
9309
+ historyLimit: 0,
9091
9310
  entries: 0,
9092
9311
  items: 0,
9093
9312
  spendByUnit: {},
@@ -9110,7 +9329,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9110
9329
  version: 1,
9111
9330
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9112
9331
  project: null,
9113
- workspace: { path: path22.resolve(opts.workspacePath), dir, projects },
9332
+ workspace: { path: path23.resolve(opts.workspacePath), dir, projects },
9114
9333
  filter,
9115
9334
  totals: {
9116
9335
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -9431,6 +9650,31 @@ function renderGallery(snapshot, opts = {}) {
9431
9650
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
9432
9651
  .pair figure img { image-rendering:pixelated; display:block; max-width:100%; }
9433
9652
  .pair figcaption { font:11px/1.4 var(--mono); color:var(--dim); background:var(--panel); padding:2px 6px; margin-top:6px; }
9653
+ .versions { display:grid; gap:8px; margin:8px 0 4px; }
9654
+ .version { display:grid; grid-template-columns:auto 1fr auto; gap:6px 12px; align-items:center; border:1px solid var(--line);
9655
+ background:var(--panel-deep); padding:8px 10px; font-size:12.5px; }
9656
+ .version .thumb { width:64px; height:64px; display:grid; place-items:center; background-image:var(--checker); border:1px solid var(--line); }
9657
+ .version .thumb img { image-rendering:pixelated; max-width:64px; max-height:64px; }
9658
+ .version .thumb span { font:10.5px var(--mono); color:var(--dim); text-align:center; padding:4px; }
9659
+ .version .facts { display:grid; gap:2px; min-width:0; }
9660
+ .version .facts b { font:650 12px/1.4 var(--mono); }
9661
+ .version .facts .prompt { color:var(--dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9662
+ .version .facts .prompt.differs { color:var(--warn); }
9663
+ .version .facts .meta { color:var(--dim); font-size:12px; }
9664
+ .version .acts { display:grid; gap:6px; justify-items:end; }
9665
+ .version .acts button { padding:3px 9px; font-size:12px; white-space:nowrap; }
9666
+ .dot.busy { background:var(--cool); animation: pulse 1.2s ease-in-out infinite; }
9667
+ @keyframes pulse { 0%, 100% { opacity:1; } 50% { opacity:.25; } }
9668
+ @media (prefers-reduced-motion: reduce) { .dot.busy { animation:none; } }
9669
+ .card .busy-badge { position:absolute; left:6px; bottom:6px; display:inline-flex; align-items:center; gap:5px;
9670
+ background:var(--panel); color:var(--cool); border:1px solid var(--cool); padding:2px 7px; font:650 10.5px/1.4 var(--mono); }
9671
+ .drawer .job-strip { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; align-items:baseline; margin-top:10px;
9672
+ padding:10px 12px; border:1px solid var(--cool); background:color-mix(in srgb, var(--cool) 8%, var(--panel-deep)); font-size:12.5px; }
9673
+ .drawer .job-strip .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1.4 var(--mono); color:var(--cool); white-space:nowrap; }
9674
+ .drawer .job-strip .last { color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
9675
+ .drawer .job-strip .what { grid-column:1 / -1; color:var(--text); }
9676
+ .drawer .job-strip .acts { grid-column:1 / -1; display:flex; gap:6px; }
9677
+ .drawer .job-strip .acts button { padding:3px 9px; font-size:12px; }
9434
9678
  .card .pen { position:absolute; right:6px; top:6px; background:var(--panel); color:var(--accent-soft); border:1px solid var(--accent);
9435
9679
  font:700 11px/1 var(--mono); padding:3px 5px; }
9436
9680
  .hand-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
@@ -9589,6 +9833,11 @@ const splitTags = (value) => value.split(',').map((t) => t.trim()).filter(Boolea
9589
9833
 
9590
9834
  const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'review']);
9591
9835
  const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
9836
+ // The job still working on this record, if any: what the drawer and its card
9837
+ // show while a regeneration, restore, or pull is in flight.
9838
+ const activeJobFor = (item) => GEN.jobs.find((job) => ACTIVE_PHASES.has(job.phase) && job.project === item.project && job.keys.includes(item.key)) || null;
9839
+ const JOB_VERB = { generate: 'regenerating', resume: 'resuming', refresh: 'pulling upstream changes for', restore: 'restoring', revert: 'bringing back a previous generation of' };
9840
+ const PHASE_TEXT = { queued: 'queued', submitting: 'submitting to the provider', polling: 'waiting for the provider to finish', fetching: 'downloading the result', review: 'candidates are ready to review' };
9592
9841
  const remainingBudget = (provider) => {
9593
9842
  const keyed = GEN.budget.byProvider[provider];
9594
9843
  const ceiling = keyed !== undefined ? keyed : GEN.budget.amount;
@@ -9618,10 +9867,34 @@ async function pollJobs() {
9618
9867
  renderHeader();
9619
9868
  let changed = false;
9620
9869
  for (const job of GEN.jobs) {
9621
- if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) { ui.settled.add(job.id); changed = true; }
9870
+ if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) {
9871
+ ui.settled.add(job.id); changed = true;
9872
+ // The open record hears how its job ended, where the strip was.
9873
+ const open = ui.open && snap.items.find((i) => i.id === ui.open);
9874
+ if (open && job.project === open.project && job.keys.includes(open.key)) {
9875
+ ui.notice = { id: open.id, text: job.phase === 'failed'
9876
+ ? (JOB_VERB[job.mode] || job.mode).replace(/ing$/, 'ing') + ' this asset failed: ' + (job.error || 'see the log above')
9877
+ : job.mode === 'generate' ? 'Regenerated. The new result is on disk and recorded as this generation.'
9878
+ : job.mode === 'restore' ? 'Restored. The recorded bytes are back on disk.'
9879
+ : job.mode === 'revert' ? 'Brought back generation #' + job.revert + '. The one it replaced is now #1 in the list below.'
9880
+ : job.mode === 'refresh' ? (job.counts.downloaded ? 'Pulled the upstream change; the lockfile records the new bytes.' : 'Nothing changed upstream; the file was left alone.')
9881
+ : 'Done.' };
9882
+ }
9883
+ }
9622
9884
  if (job.phase === 'review' && !ui.settled.has(job.id + ':review')) { ui.settled.add(job.id + ':review'); changed = true; }
9623
9885
  }
9624
9886
  if (changed) refresh();
9887
+ else {
9888
+ // A job in flight: keep the open record's strip and the card badges honest without a full re-render.
9889
+ if (ui.open && !$('dialog-host').childNodes.length) renderDrawer();
9890
+ for (const c of document.querySelectorAll('.card')) {
9891
+ const item = snap.items.find((i) => i.id === c.dataset.key);
9892
+ const job = item && activeJobFor(item);
9893
+ const badge = c.querySelector('.busy-badge');
9894
+ if (job && !badge) c.replaceWith(card(item));
9895
+ else if (!job && badge) badge.remove();
9896
+ }
9897
+ }
9625
9898
  const active = GEN.jobs.some((job) => ACTIVE_PHASES.has(job.phase) && job.phase !== 'review');
9626
9899
  clearTimeout(jobsTimer);
9627
9900
  if (active) jobsTimer = setTimeout(pollJobs, 2000);
@@ -9635,7 +9908,7 @@ function renderJobs() {
9635
9908
  const row = el('div', 'job');
9636
9909
  const ph = el('span', 'ph');
9637
9910
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
9638
- const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'restore' ? 'restore ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
9911
+ const what = (job.mode === 'refresh' ? 'pull upstream for ' : job.mode === 'restore' ? 'restore ' : job.mode === 'revert' ? 'bring back generation #' + job.revert + ' of ' : job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
9639
9912
  (job.project ? ' in ' + job.project : '');
9640
9913
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
9641
9914
  last.title = job.keys.join('\\n');
@@ -9760,15 +10033,16 @@ function budgetLine() {
9760
10033
 
9761
10034
  // The confirm step: what will be sent, what it is estimated to cost, and what
9762
10035
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
9763
- function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false } = {}) {
10036
+ function generateDialog(items, { project = null, force = false, resume = false, refresh = false, restore = false, revert = null, generation = null } = {}) {
9764
10037
  const host = $('dialog-host');
9765
10038
  host.textContent = '';
9766
10039
  const wrap = el('div', 'dialog');
9767
10040
  const form = el('form');
9768
10041
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
9769
- const free = resume || refresh || restore;
10042
+ const free = resume || refresh || restore || revert !== null;
9770
10043
  const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
9771
10044
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
10045
+ : revert !== null ? 'Bring back generation #' + revert + ' of ' + items[0].key
9772
10046
  : restore ? 'Restore ' + noun
9773
10047
  : resume ? 'Resume ' + noun
9774
10048
  : (force ? 'Regenerate ' : 'Generate ') + noun));
@@ -9790,6 +10064,10 @@ function generateDialog(items, { project = null, force = false, resume = false,
9790
10064
  const sum = el('div', 'sum');
9791
10065
  if (refresh) {
9792
10066
  sum.append(el('div', null, 'Re-downloads each object from the provider and replaces the local file only when the object changed upstream \u2014 for example after editing it in the provider\u2019s own editor. Unchanged objects are left alone; a file you changed locally is refused. Nothing is submitted.'));
10067
+ } else if (revert !== null) {
10068
+ sum.append(el('div', null, 'The current generation moves into this asset\u2019s history and generation #' + revert + ' becomes current again: its object, hashes, and cost are recorded as this generation, and its bytes are written back ' +
10069
+ (generation && generation.cached ? 'from the local cache.' : 'by re-downloading them from the provider.') + ' Nothing is generated; nothing upstream changes. Undo it from the same list.'));
10070
+ if (generation && generation.promptDiffers) sum.append(el('div', 'warn', 'That generation was made with a different prompt than the current one; the record will show it as stale until the manifest matches or it is regenerated.'));
9793
10071
  } else if (restore) {
9794
10072
  sum.append(el('div', null, 'Puts the recorded bytes back on disk from the local cache or the provider object. Nothing is generated or submitted.'));
9795
10073
  if (force && replacing.length) sum.append(el('div', 'warn', 'The file on disk was changed after PixelKiln wrote it. Restoring discards that change. To keep it instead, cancel and use Edit by hand or Edit in browser first: the edit is copied beside the generated file, and the record stays intact.'));
@@ -9811,7 +10089,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
9811
10089
  }
9812
10090
  form.append(sum);
9813
10091
  const actions = el('div', 'actions');
9814
- const go = el('button', 'primary', refresh ? 'Pull changes' : restore ? (force && replacing.length ? 'Restore and discard my change' : 'Restore') : resume ? 'Resume' : force && replacing.length ? 'Generate and replace' : 'Generate');
10092
+ const go = el('button', 'primary', refresh ? 'Pull changes' : revert !== null ? 'Bring it back' : restore ? (force && replacing.length ? 'Restore and discard my change' : 'Restore') : resume ? 'Resume' : force && replacing.length ? 'Generate and replace' : 'Generate');
9815
10093
  go.type = 'submit';
9816
10094
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
9817
10095
  const msg = el('span', 'msg');
@@ -9827,6 +10105,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
9827
10105
  if (resume) body.resume = true;
9828
10106
  if (refresh) body.refresh = true;
9829
10107
  if (restore) body.restore = true;
10108
+ if (revert !== null) body.revert = revert;
9830
10109
  const job = await postGenerate(body);
9831
10110
  host.textContent = '';
9832
10111
  GEN.jobs.unshift(job);
@@ -10270,6 +10549,13 @@ function card(item) {
10270
10549
  if (item.editStatus === 'edited' || item.editStatus === 'regenerated-since') {
10271
10550
  const pen = el('span', 'pen', '\u270E'); pen.title = 'hand-edited'; cell.append(pen);
10272
10551
  }
10552
+ const job = activeJobFor(item);
10553
+ if (job) {
10554
+ const badge = el('span', 'busy-badge');
10555
+ badge.append(el('i', 'dot busy'), document.createTextNode(job.phase === 'review' ? 'review' : job.mode === 'generate' ? 'generating' : job.mode));
10556
+ badge.title = (JOB_VERB[job.mode] || job.mode) + ' this asset \u2014 ' + (PHASE_TEXT[job.phase] || job.phase);
10557
+ cell.append(badge);
10558
+ }
10273
10559
  c.append(cell, body);
10274
10560
  c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
10275
10561
  return c;
@@ -10450,6 +10736,54 @@ function candidatesControl(style) {
10450
10736
  return wrap;
10451
10737
  }
10452
10738
 
10739
+ // The generations this one replaced: what the lockfile still remembers, with
10740
+ // thumbnails from the content cache while the bytes are there, and a way back.
10741
+ function historySection(item) {
10742
+ const limit = projectOf(item)?.historyLimit;
10743
+ if (!item.history.length && !(limit > 0 && item.status === 'downloaded')) return null;
10744
+ const s = el('section', 'meta');
10745
+ s.append(el('h3', null, item.history.length ? 'Previous generations (' + item.history.length + ')' : 'Previous generations'));
10746
+ if (!item.history.length) {
10747
+ s.append(el('div', 'state-dim', 'None yet. A regeneration keeps the generation it replaces \u2014 up to ' + limit + ' per asset (PIXELKILN_HISTORY, or the manifest\u2019s history) \u2014 so it can be brought back from here.'));
10748
+ return s;
10749
+ }
10750
+ const list = el('div', 'versions');
10751
+ const job = GENERATION ? activeJobFor(item) : null;
10752
+ for (const g of item.history) {
10753
+ const v = el('div', 'version');
10754
+ const thumb = el('div', 'thumb');
10755
+ const first = g.outputs[0];
10756
+ if (first && first.url) { const img = el('img'); img.src = first.url; img.alt = 'generation #' + g.index; thumb.append(img); }
10757
+ else thumb.append(el('span', null, g.cached ? '' : 'not cached'));
10758
+ const facts = el('div', 'facts');
10759
+ const head = el('div');
10760
+ head.append(el('b', null, '#' + g.index), document.createTextNode(' \xB7 ' + fmtWhen(g.downloadedAt || g.submittedAt) + ' \xB7 ' + fmtCost(g.costUnit, g.cost) +
10761
+ (g.outputs.length > 1 ? ' \xB7 ' + g.outputs.length + ' files' : '')));
10762
+ facts.append(head);
10763
+ const prompt = el('div', 'prompt' + (g.promptDiffers ? ' differs' : ''), (g.promptDiffers ? 'prompt: ' : 'same prompt: ') + g.prompt);
10764
+ prompt.title = g.prompt;
10765
+ facts.append(prompt);
10766
+ const meta = el('div', 'meta');
10767
+ meta.append(document.createTextNode((first ? first.sha256.slice(0, 12) + '\u2026' : '') + (g.cached ? ' \xB7 bytes cached locally' : ' \xB7 not in the local cache; a restore re-downloads it')));
10768
+ if (g.upstreamUrl) { const a = el('a', null, 'open in ' + item.provider + ' \u2197'); a.href = g.upstreamUrl; a.target = '_blank'; a.rel = 'noopener'; meta.append(document.createTextNode(' \xB7 '), a); }
10769
+ facts.append(meta);
10770
+ const acts = el('div', 'acts');
10771
+ if (GENERATION) {
10772
+ const b = el('button', null, 'Restore this one \xB7 no cost'); b.type = 'button';
10773
+ b.disabled = !!job;
10774
+ b.title = job ? 'Wait for the running job to finish' : 'The current generation moves into history; this one becomes current and its bytes are written back';
10775
+ b.onclick = () => generateDialog([item], { project: item.project, revert: String(g.index), generation: g });
10776
+ acts.append(b);
10777
+ }
10778
+ v.append(thumb, facts, acts);
10779
+ list.append(v);
10780
+ }
10781
+ s.append(list);
10782
+ s.append(el('div', 'state-dim', 'Keeping up to ' + limit + ' per asset; a restore moves the current generation into this list, so it can be undone the same way.' +
10783
+ (GENERATION ? '' : ' Restoring needs the gallery started with --budget (any amount; nothing is spent).')));
10784
+ return s;
10785
+ }
10786
+
10453
10787
  function upstreamSection(item) {
10454
10788
  if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
10455
10789
  const s = el('section', 'meta');
@@ -10476,6 +10810,30 @@ function upstreamSection(item) {
10476
10810
  return s;
10477
10811
  }
10478
10812
 
10813
+ // What a job is doing to this record right now. The record's state line still
10814
+ // says what is on disk; this says what is about to replace it.
10815
+ function jobStrip(item, job) {
10816
+ const strip = el('div', 'job-strip');
10817
+ const ph = el('span', 'ph');
10818
+ ph.append(el('i', 'dot busy'), document.createTextNode(job.phase));
10819
+ const last = el('span', 'last', job.messages.length ? job.messages[job.messages.length - 1].trim() : '');
10820
+ last.title = job.messages.join('\\n');
10821
+ const what = el('span', 'what', (JOB_VERB[job.mode] || job.mode) + ' this asset \u2014 ' + (PHASE_TEXT[job.phase] || job.phase) +
10822
+ (job.mode === 'generate' && job.phase !== 'review' ? '. The current file stays until the new result is downloaded.' : '.'));
10823
+ strip.append(ph, last, what);
10824
+ const acts = el('div', 'acts');
10825
+ if (job.phase === 'review' && job.review.includes(item.key)) {
10826
+ const b = el('button', 'primary', 'Review candidates'); b.type = 'button'; b.onclick = () => openReview(job.id);
10827
+ acts.append(b);
10828
+ }
10829
+ const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log'); logBtn.type = 'button';
10830
+ logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderDrawer(); };
10831
+ acts.append(logBtn);
10832
+ strip.append(acts);
10833
+ if (ui.logs.has(job.id)) { const pre = el('pre', null, job.messages.join('\\n')); pre.style.gridColumn = '1 / -1'; strip.append(pre); }
10834
+ return strip;
10835
+ }
10836
+
10479
10837
  function generateActions(item) {
10480
10838
  const row = el('div', 'gen');
10481
10839
  if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
@@ -11183,7 +11541,9 @@ function renderDrawer() {
11183
11541
  body.append(previewHost);
11184
11542
  if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
11185
11543
  body.append(stateNode(item.state, item.reason));
11186
- if (GENERATION) body.append(generateActions(item));
11544
+ const job = GENERATION ? activeJobFor(item) : null;
11545
+ if (job) body.append(jobStrip(item, job));
11546
+ else if (GENERATION) body.append(generateActions(item));
11187
11547
  // Writes are opt-in flags on the command line; say so where the buttons would be.
11188
11548
  if ((!EDITABLE || !GENERATION) && item.declared) {
11189
11549
  const missing = [];
@@ -11248,6 +11608,8 @@ function renderDrawer() {
11248
11608
  if (hand) body.append(hand);
11249
11609
  const upstream = upstreamSection(item);
11250
11610
  if (upstream) body.append(upstream);
11611
+ const versions = historySection(item);
11612
+ if (versions) body.append(versions);
11251
11613
 
11252
11614
  if (item.revision || item.revisionParentKey) {
11253
11615
  const { s, dl } = section('Lineage');
@@ -11794,10 +12156,15 @@ var GenerateRequestSchema = z8.object({
11794
12156
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
11795
12157
  refresh: z8.boolean().optional(),
11796
12158
  /** Put recorded outputs back on disk from the cache or the provider, without generating. */
11797
- restore: z8.boolean().optional()
11798
- }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
11799
- message: "resume, refresh, and restore are exclusive"
11800
- });
12159
+ restore: z8.boolean().optional(),
12160
+ /**
12161
+ * Bring a replaced generation back: 1-based position in the record's
12162
+ * history (newest first) or an output hash prefix. One key at a time.
12163
+ */
12164
+ revert: z8.string().min(1).max(64).optional()
12165
+ }).strict().refine((request) => [request.resume, request.refresh, request.restore, request.revert !== void 0].filter(Boolean).length <= 1, {
12166
+ message: "resume, refresh, restore, and revert are exclusive"
12167
+ }).refine((request) => request.revert === void 0 || request.keys.length === 1, { message: "revert takes one key" });
11801
12168
  var GenerateRequestError = class extends Error {
11802
12169
  constructor(message6, status = 400) {
11803
12170
  super(message6);
@@ -11928,6 +12295,22 @@ function createGenerateHandlers(opts) {
11928
12295
  await restore(job, ctx, specs);
11929
12296
  return;
11930
12297
  }
12298
+ if (job.mode === "revert") {
12299
+ job.phase = "fetching";
12300
+ const spec = specs[0];
12301
+ const entry = ctx.lock.entries[lockKey(spec.styleId, spec.assetId)];
12302
+ const provider = opts.providerFor(job.project ?? void 0, entry.provider);
12303
+ const result = await revertGeneration(provider, spec, ctx.lock, ctx.lockPath, {
12304
+ generation: job.revert,
12305
+ force: job.force,
12306
+ onProgress: (line) => say(job, line)
12307
+ });
12308
+ job.counts.downloaded += result.downloaded;
12309
+ say(job, `restored generation #${result.index}${result.restored.objectId ? ` (${result.restored.objectId})` : ""}; the replaced one is now #1 in its history`);
12310
+ job.phase = "done";
12311
+ job.finishedAt = now().toISOString();
12312
+ return;
12313
+ }
11931
12314
  if (job.mode === "generate") {
11932
12315
  job.phase = "submitting";
11933
12316
  for (const group of groups) {
@@ -11998,8 +12381,13 @@ function createGenerateHandlers(opts) {
11998
12381
  }
11999
12382
  }
12000
12383
  }
12384
+ if (request.revert !== void 0) {
12385
+ const entry = ctx.lock.entries[request.keys[0]];
12386
+ if (!entry?.history?.length) throw new GenerateRequestError(`"${request.keys[0]}" has no previous generation recorded`);
12387
+ if (entry.status !== "downloaded") throw new GenerateRequestError(`"${request.keys[0]}" is ${entry.status}; finish or fetch the current generation first`);
12388
+ }
12001
12389
  let groups = [];
12002
- if (!request.resume && !request.refresh && !request.restore) {
12390
+ if (!request.resume && !request.refresh && !request.restore && request.revert === void 0) {
12003
12391
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
12004
12392
  if (!plan.actionable.length) {
12005
12393
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -12019,8 +12407,9 @@ function createGenerateHandlers(opts) {
12019
12407
  id: randomBytes2(8).toString("hex"),
12020
12408
  project: request.project ?? null,
12021
12409
  keys: request.keys,
12022
- mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : "generate",
12410
+ mode: request.refresh ? "refresh" : request.resume ? "resume" : request.restore ? "restore" : request.revert !== void 0 ? "revert" : "generate",
12023
12411
  force: Boolean(request.force),
12412
+ revert: request.revert ?? null,
12024
12413
  phase: "queued",
12025
12414
  startedAt: now().toISOString(),
12026
12415
  finishedAt: null,
@@ -12032,7 +12421,7 @@ function createGenerateHandlers(opts) {
12032
12421
  };
12033
12422
  jobs.set(job.id, job);
12034
12423
  contexts.set(job.id, ctx);
12035
- say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.restore ? `restoring ${request.keys.length} asset(s) at no cost${request.force ? " (replacing modified files)" : ""}` : request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
12424
+ say(job, request.refresh ? `checking ${request.keys.length} asset(s) upstream at no cost` : request.restore ? `restoring ${request.keys.length} asset(s) at no cost${request.force ? " (replacing modified files)" : ""}` : request.revert !== void 0 ? `bringing back generation ${request.revert} of ${request.keys[0]} at no cost` : request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
12036
12425
  void run(job, ctx, specs, groups);
12037
12426
  return job;
12038
12427
  },
@@ -12083,14 +12472,14 @@ function createGenerateHandlers(opts) {
12083
12472
 
12084
12473
  // src/gallery/editor.ts
12085
12474
  import { readFile as readFile19 } from "fs/promises";
12086
- import path24 from "path";
12475
+ import path25 from "path";
12087
12476
 
12088
12477
  // src/editor/install.ts
12089
12478
  import { createHash as createHash4 } from "crypto";
12090
- import { existsSync as existsSync19 } from "fs";
12479
+ import { existsSync as existsSync20 } from "fs";
12091
12480
  import { mkdir as mkdir7, readFile as readFile18, rename as rename8, rm as rm9, stat as stat4, writeFile as writeFile10 } from "fs/promises";
12092
12481
  import os2 from "os";
12093
- import path23 from "path";
12482
+ import path24 from "path";
12094
12483
 
12095
12484
  // tools/pixelorama-bridge/pin.json
12096
12485
  var pin_default = {
@@ -12154,15 +12543,15 @@ var EditorInstallError = class extends Error {
12154
12543
  };
12155
12544
  function editorToolsRoot() {
12156
12545
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
12157
- if (override) return path23.resolve(override);
12158
- if (process.platform === "darwin") return path23.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
12546
+ if (override) return path24.resolve(override);
12547
+ if (process.platform === "darwin") return path24.join(os2.homedir(), "Library", "Caches", "pixelkiln", "tools");
12159
12548
  if (process.platform === "win32") {
12160
- return path23.join(process.env.LOCALAPPDATA ?? path23.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
12549
+ return path24.join(process.env.LOCALAPPDATA ?? path24.join(os2.homedir(), "AppData", "Local"), "pixelkiln", "tools");
12161
12550
  }
12162
- return path23.join(process.env.XDG_CACHE_HOME ?? path23.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
12551
+ return path24.join(process.env.XDG_CACHE_HOME ?? path24.join(os2.homedir(), ".cache"), "pixelkiln", "tools");
12163
12552
  }
12164
12553
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
12165
- return path23.join(root, "pixelorama", pin.release ?? "unpublished");
12554
+ return path24.join(root, "pixelorama", pin.release ?? "unpublished");
12166
12555
  }
12167
12556
  function editorBaseUrl(pin = EDITOR_PIN) {
12168
12557
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -12185,8 +12574,8 @@ async function editorStatus(opts = {}) {
12185
12574
  let totalBytes = 0;
12186
12575
  for (const [name, expected] of Object.entries(pin.files)) {
12187
12576
  totalBytes += expected.bytes;
12188
- const file = path23.join(dir, name);
12189
- const info = existsSync19(file) ? await stat4(file).catch(() => null) : null;
12577
+ const file = path24.join(dir, name);
12578
+ const info = existsSync20(file) ? await stat4(file).catch(() => null) : null;
12190
12579
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
12191
12580
  installedBytes += expected.bytes;
12192
12581
  } else {
@@ -12243,7 +12632,7 @@ async function installEditor(opts = {}) {
12243
12632
  if (digest2 !== expected.sha256) {
12244
12633
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
12245
12634
  }
12246
- const final = path23.join(dir, name);
12635
+ const final = path24.join(dir, name);
12247
12636
  const tmp = `${final}.${process.pid}.part`;
12248
12637
  try {
12249
12638
  await writeFile10(tmp, bytes);
@@ -12289,7 +12678,7 @@ var EDITOR_CONTENT_TYPES = {
12289
12678
  ".json": "application/json; charset=utf-8"
12290
12679
  };
12291
12680
  function editorContentType(name) {
12292
- return EDITOR_CONTENT_TYPES[path23.extname(name).toLowerCase()] ?? null;
12681
+ return EDITOR_CONTENT_TYPES[path24.extname(name).toLowerCase()] ?? null;
12293
12682
  }
12294
12683
 
12295
12684
  // src/gallery/editor.ts
@@ -12365,7 +12754,7 @@ function createGalleryEditorHandlers(opts = {}) {
12365
12754
  if (!status.installed) return null;
12366
12755
  let bytes;
12367
12756
  try {
12368
- bytes = await readFile19(path24.join(dir, name));
12757
+ bytes = await readFile19(path25.join(dir, name));
12369
12758
  } catch {
12370
12759
  verified = null;
12371
12760
  return null;
@@ -12381,15 +12770,15 @@ function createGalleryEditorHandlers(opts = {}) {
12381
12770
 
12382
12771
  // src/pipeline/salvage.ts
12383
12772
  import { readFile as readFile20 } from "fs/promises";
12384
- import { existsSync as existsSync20 } from "fs";
12385
- import path25 from "path";
12773
+ import { existsSync as existsSync21 } from "fs";
12774
+ import path26 from "path";
12386
12775
  function providerClaimId(provider, objectId) {
12387
12776
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
12388
12777
  }
12389
12778
  async function loadClaims(lockPaths, opts = {}) {
12390
12779
  const claimed = /* @__PURE__ */ new Set();
12391
12780
  for (const p of lockPaths) {
12392
- if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
12781
+ if (!existsSync21(p)) throw new Error(`Claim lockfile not found: ${p}`);
12393
12782
  let parsed;
12394
12783
  try {
12395
12784
  parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
@@ -12440,20 +12829,20 @@ function matchStyleByPattern(prompt, manifest) {
12440
12829
  return null;
12441
12830
  }
12442
12831
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
12443
- const own = path25.resolve(ownManifestPath);
12832
+ const own = path26.resolve(ownManifestPath);
12444
12833
  const siblingManifestPaths = [
12445
12834
  .../* @__PURE__ */ new Set([
12446
12835
  ...workspaceManifestPaths,
12447
- ...claimPaths.map((c) => path25.join(path25.dirname(path25.resolve(c)), "pixelkiln.manifest.json"))
12836
+ ...claimPaths.map((c) => path26.join(path26.dirname(path26.resolve(c)), "pixelkiln.manifest.json"))
12448
12837
  ])
12449
12838
  ];
12450
12839
  const siblings = [];
12451
12840
  for (const siblingManifestPath of siblingManifestPaths) {
12452
- if (path25.resolve(siblingManifestPath) === own) continue;
12453
- if (!existsSync20(siblingManifestPath)) continue;
12841
+ if (path26.resolve(siblingManifestPath) === own) continue;
12842
+ if (!existsSync21(siblingManifestPath)) continue;
12454
12843
  try {
12455
12844
  const { manifest } = await loadManifest(siblingManifestPath);
12456
- siblings.push({ label: path25.basename(path25.dirname(siblingManifestPath)), manifest });
12845
+ siblings.push({ label: path26.basename(path26.dirname(siblingManifestPath)), manifest });
12457
12846
  } catch {
12458
12847
  }
12459
12848
  }
@@ -12672,18 +13061,16 @@ async function workspaceStatus(ws, dir) {
12672
13061
  }
12673
13062
 
12674
13063
  // src/pipeline/cache-health.ts
12675
- import { existsSync as existsSync21 } from "fs";
13064
+ import { existsSync as existsSync22 } from "fs";
12676
13065
  import { readFile as readFile21, readdir as readdir2, rm as rm10 } from "fs/promises";
12677
- import path26 from "path";
13066
+ import path27 from "path";
12678
13067
  async function inspectCaches(lock, lockPath, options = {}) {
12679
- if (options.prune && !existsSync21(lockPath)) {
12680
- throw new Error(`Refusing to prune without an existing lockfile at ${path26.resolve(lockPath)}`);
13068
+ if (options.prune && !existsSync22(lockPath)) {
13069
+ throw new Error(`Refusing to prune without an existing lockfile at ${path27.resolve(lockPath)}`);
12681
13070
  }
12682
- const contentDir = path26.resolve(path26.dirname(lockPath), ".pixelkiln", "cache");
12683
- const remotePath = path26.resolve(cachePathFor(lockPath));
12684
- const referenced = new Set(
12685
- Object.values(lock.entries).flatMap((entry) => entry.outputs.map((output) => output.sha256))
12686
- );
13071
+ const contentDir = path27.resolve(path27.dirname(lockPath), ".pixelkiln", "cache");
13072
+ const remotePath = path27.resolve(cachePathFor(lockPath));
13073
+ const referenced = referencedHashes(lock);
12687
13074
  let content = await inspectContentCache(contentDir, referenced);
12688
13075
  let remoteHashes = await inspectRemoteHashCache(remotePath);
12689
13076
  const removed = { contentFiles: 0, remoteHashEntries: 0, resetRemoteHashCache: false };
@@ -12694,7 +13081,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
12694
13081
  ]);
12695
13082
  for (const name of names) {
12696
13083
  try {
12697
- await rm10(path26.join(contentDir, name), { force: true });
13084
+ await rm10(path27.join(contentDir, name), { force: true });
12698
13085
  removed.contentFiles++;
12699
13086
  } catch {
12700
13087
  }
@@ -12721,7 +13108,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
12721
13108
  async function inspectContentCache(contentDir, referenced) {
12722
13109
  const report = {
12723
13110
  path: contentDir,
12724
- exists: existsSync21(contentDir),
13111
+ exists: existsSync22(contentDir),
12725
13112
  files: 0,
12726
13113
  bytes: 0,
12727
13114
  valid: 0,
@@ -12742,7 +13129,7 @@ async function inspectContentCache(contentDir, referenced) {
12742
13129
  continue;
12743
13130
  }
12744
13131
  report.files++;
12745
- const file = path26.join(contentDir, entry.name);
13132
+ const file = path27.join(contentDir, entry.name);
12746
13133
  const mediaType = mediaTypeFromExtension(entry.name);
12747
13134
  const expected = mediaType ? entry.name.slice(0, -4) : "";
12748
13135
  let bytes;
@@ -12785,7 +13172,7 @@ async function inspectContentCache(contentDir, referenced) {
12785
13172
  async function inspectRemoteHashCache(remotePath) {
12786
13173
  const report = {
12787
13174
  path: remotePath,
12788
- exists: existsSync21(remotePath),
13175
+ exists: existsSync22(remotePath),
12789
13176
  entries: 0,
12790
13177
  valid: 0,
12791
13178
  invalidIds: [],
@@ -13069,7 +13456,7 @@ function isRecord2(value) {
13069
13456
 
13070
13457
  // src/pick/salvage-server.ts
13071
13458
  import { mkdir as mkdir8, writeFile as writeFile11, readFile as readFile22 } from "fs/promises";
13072
- import path27 from "path";
13459
+ import path28 from "path";
13073
13460
 
13074
13461
  // src/pick/salvage-sheet.ts
13075
13462
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -13234,7 +13621,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13234
13621
  });
13235
13622
  const html = renderSalvageSheet(orphans, {
13236
13623
  styleId: ctx.styleId,
13237
- importDir: path27.relative(process.cwd(), ctx.importDir) || "."
13624
+ importDir: path28.relative(process.cwd(), ctx.importDir) || "."
13238
13625
  });
13239
13626
  const byId = new Map(orphans.map((o) => [o.id, o]));
13240
13627
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -13263,9 +13650,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13263
13650
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
13264
13651
  decodePng(buf);
13265
13652
  const assetId = idFromPrompt(orphan.prompt, taken);
13266
- const rel = path27.join("_salvaged", `${assetId}.png`);
13267
- const outFile = path27.resolve(ctx.importDir, rel);
13268
- await mkdir8(path27.dirname(outFile), { recursive: true });
13653
+ const rel = path28.join("_salvaged", `${assetId}.png`);
13654
+ const outFile = path28.resolve(ctx.importDir, rel);
13655
+ await mkdir8(path28.dirname(outFile), { recursive: true });
13269
13656
  await writeFile11(outFile, buf);
13270
13657
  ctx.manifest.assets[assetId] = {
13271
13658
  prompt: orphan.prompt,
@@ -13298,7 +13685,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13298
13685
  sourceUrl: durableSource,
13299
13686
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
13300
13687
  outputs: [{
13301
- path: portableOutputPath(outFile, path27.dirname(ctx.manifestPath)),
13688
+ path: portableOutputPath(outFile, path28.dirname(ctx.manifestPath)),
13302
13689
  sha256: sha256(buf)
13303
13690
  }],
13304
13691
  submittedAt: orphan.createdAt,
@@ -13332,8 +13719,8 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13332
13719
  }
13333
13720
 
13334
13721
  // src/recipes.ts
13335
- import path28 from "path";
13336
- import { existsSync as existsSync22 } from "fs";
13722
+ import path29 from "path";
13723
+ import { existsSync as existsSync23 } from "fs";
13337
13724
  import { readdir as readdir3, readFile as readFile23 } from "fs/promises";
13338
13725
  import { fileURLToPath } from "url";
13339
13726
  import { z as z9 } from "zod";
@@ -13343,7 +13730,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
13343
13730
  var RECIPE_FILE = "pixelkiln.recipe.json";
13344
13731
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
13345
13732
  var PortablePathSchema = z9.string().min(1).refine((value) => {
13346
- if (value.includes("\\") || path28.posix.isAbsolute(value)) return false;
13733
+ if (value.includes("\\") || path29.posix.isAbsolute(value)) return false;
13347
13734
  const parts = value.split("/");
13348
13735
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
13349
13736
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -13519,7 +13906,7 @@ function message4(error) {
13519
13906
  return error instanceof Error ? error.message : String(error);
13520
13907
  }
13521
13908
  async function readRecipeFile(recipePath, bundled) {
13522
- const absolute = path28.resolve(recipePath);
13909
+ const absolute = path29.resolve(recipePath);
13523
13910
  let raw;
13524
13911
  try {
13525
13912
  raw = JSON.parse(await readFile23(absolute, "utf8"));
@@ -13533,13 +13920,13 @@ async function readRecipeFile(recipePath, bundled) {
13533
13920
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
13534
13921
  );
13535
13922
  }
13536
- return { recipe: parsed.data, path: absolute, dir: path28.dirname(absolute), bundled };
13923
+ return { recipe: parsed.data, path: absolute, dir: path29.dirname(absolute), bundled };
13537
13924
  }
13538
13925
  async function walkRecipeFiles(root) {
13539
- if (!existsSync22(root)) return [];
13926
+ if (!existsSync23(root)) return [];
13540
13927
  const out = [];
13541
13928
  for (const entry of await readdir3(root, { withFileTypes: true })) {
13542
- const resolved = path28.join(root, entry.name);
13929
+ const resolved = path29.join(root, entry.name);
13543
13930
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
13544
13931
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
13545
13932
  }
@@ -13550,7 +13937,7 @@ function bundledRecipeRoot() {
13550
13937
  }
13551
13938
  async function listBundledRecipes(root = bundledRecipeRoot()) {
13552
13939
  const recipes = await Promise.all(
13553
- (await walkRecipeFiles(path28.resolve(root))).map((file) => readRecipeFile(file, true))
13940
+ (await walkRecipeFiles(path29.resolve(root))).map((file) => readRecipeFile(file, true))
13554
13941
  );
13555
13942
  return recipes.sort(
13556
13943
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -13573,9 +13960,9 @@ function parseSelector(selector) {
13573
13960
  return { id: match[1], version: match[2] };
13574
13961
  }
13575
13962
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
13576
- const local = path28.resolve(target);
13577
- if (existsSync22(local)) {
13578
- const recipePath = path28.basename(local) === RECIPE_FILE ? local : path28.join(local, RECIPE_FILE);
13963
+ const local = path29.resolve(target);
13964
+ if (existsSync23(local)) {
13965
+ const recipePath = path29.basename(local) === RECIPE_FILE ? local : path29.join(local, RECIPE_FILE);
13579
13966
  return readRecipeFile(recipePath, false);
13580
13967
  }
13581
13968
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -13589,8 +13976,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
13589
13976
  return matches[0];
13590
13977
  }
13591
13978
  async function verifyFile(root, file) {
13592
- const absolute = path28.resolve(root, ...file.path.split("/"));
13593
- if (!existsSync22(absolute)) {
13979
+ const absolute = path29.resolve(root, ...file.path.split("/"));
13980
+ if (!existsSync23(absolute)) {
13594
13981
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
13595
13982
  }
13596
13983
  const actualSha256 = await sha256File(absolute);
@@ -13605,7 +13992,7 @@ async function verifyRecipe(target, options = {}) {
13605
13992
  const loaded = await resolveRecipe(target, options.bundledRoot);
13606
13993
  const actualIntegrity = recipeDigest(loaded.recipe);
13607
13994
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
13608
- const modelRoot = options.modelRoot ? path28.resolve(options.modelRoot) : null;
13995
+ const modelRoot = options.modelRoot ? path29.resolve(options.modelRoot) : null;
13609
13996
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
13610
13997
  ...await verifyFile(modelRoot, model),
13611
13998
  source: model.source,
@@ -13639,7 +14026,7 @@ async function verifyRecipe(target, options = {}) {
13639
14026
  };
13640
14027
  }
13641
14028
  function renderStyle(style, destination, cwd) {
13642
- const portableDir = path28.relative(cwd, destination).split(path28.sep).join("/") || ".";
14029
+ const portableDir = path29.relative(cwd, destination).split(path29.sep).join("/") || ".";
13643
14030
  const replace = (value) => {
13644
14031
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
13645
14032
  if (Array.isArray(value)) return value.map(replace);
@@ -13651,24 +14038,24 @@ function renderStyle(style, destination, cwd) {
13651
14038
  return replace(style);
13652
14039
  }
13653
14040
  async function installRecipe(target, options = {}) {
13654
- const cwd = path28.resolve(options.cwd ?? process.cwd());
14041
+ const cwd = path29.resolve(options.cwd ?? process.cwd());
13655
14042
  const loaded = await resolveRecipe(target, options.bundledRoot);
13656
14043
  const verification = await verifyRecipe(loaded.path);
13657
14044
  if (!verification.ok) {
13658
14045
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
13659
14046
  }
13660
- const destination = options.out ? path28.resolve(cwd, options.out) : path28.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
14047
+ const destination = options.out ? path29.resolve(cwd, options.out) : path29.join(cwd, "pixelkiln-recipes", ...loaded.recipe.id.split("/"), loaded.recipe.version);
13661
14048
  const sources = [
13662
14049
  { path: RECIPE_FILE, source: loaded.path },
13663
- ...loaded.recipe.files.map((file) => ({ path: file.path, source: path28.join(loaded.dir, ...file.path.split("/")) }))
14050
+ ...loaded.recipe.files.map((file) => ({ path: file.path, source: path29.join(loaded.dir, ...file.path.split("/")) }))
13664
14051
  ];
13665
14052
  const files = await Promise.all(sources.map(async (file) => ({
13666
- path: path28.join(destination, ...file.path.split("/")),
14053
+ path: path29.join(destination, ...file.path.split("/")),
13667
14054
  data: await readFile23(file.source)
13668
14055
  })));
13669
14056
  if (!options.force) {
13670
14057
  for (const file of files) {
13671
- if (!existsSync22(file.path)) continue;
14058
+ if (!existsSync23(file.path)) continue;
13672
14059
  const current = await readFile23(file.path);
13673
14060
  if (!current.equals(file.data)) {
13674
14061
  throw new Error(
@@ -13692,15 +14079,15 @@ async function installRecipe(target, options = {}) {
13692
14079
  }
13693
14080
 
13694
14081
  // src/pipeline/quality-regression.ts
13695
- import path29 from "path";
13696
- import { existsSync as existsSync23 } from "fs";
14082
+ import path30 from "path";
14083
+ import { existsSync as existsSync24 } from "fs";
13697
14084
  import { readFile as readFile24 } from "fs/promises";
13698
14085
  import { z as z10 } from "zod";
13699
14086
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
13700
14087
  var HEX_RE = /^#[0-9a-f]{6}$/;
13701
14088
  var MAX_REDMEAN_DISTANCE = 765;
13702
14089
  var QualityPathSchema = z10.string().min(1).refine(
13703
- (value) => !path29.posix.isAbsolute(value) && !value.includes("\\"),
14090
+ (value) => !path30.posix.isAbsolute(value) && !value.includes("\\"),
13704
14091
  "expected a portable relative path"
13705
14092
  );
13706
14093
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -13847,7 +14234,7 @@ function measureDecodedImageQuality(png, digest2) {
13847
14234
  };
13848
14235
  }
13849
14236
  async function measureImageQuality(file) {
13850
- const absolute = path29.resolve(file);
14237
+ const absolute = path30.resolve(file);
13851
14238
  let bytes;
13852
14239
  try {
13853
14240
  bytes = await readFile24(absolute);
@@ -13869,11 +14256,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
13869
14256
  }
13870
14257
  const ids = parsed.data.map((entry) => entry.id);
13871
14258
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
13872
- const root = path29.dirname(path29.resolve(inputsFilePath));
14259
+ const root = path30.dirname(path30.resolve(inputsFilePath));
13873
14260
  return parsed.data.map((entry) => ({
13874
14261
  id: entry.id,
13875
- file: path29.resolve(root, entry.path),
13876
- ...entry.record ? { record: path29.resolve(root, entry.record) } : {},
14262
+ file: path30.resolve(root, entry.path),
14263
+ ...entry.record ? { record: path30.resolve(root, entry.record) } : {},
13877
14264
  tolerances: QualityToleranceSchema.parse({
13878
14265
  ...DEFAULT_QUALITY_TOLERANCES,
13879
14266
  ...entry.tolerances
@@ -13881,7 +14268,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
13881
14268
  }));
13882
14269
  }
13883
14270
  function portableRelative2(from, to) {
13884
- return path29.relative(from, path29.resolve(to)).split(path29.sep).join("/") || ".";
14271
+ return path30.relative(from, path30.resolve(to)).split(path30.sep).join("/") || ".";
13885
14272
  }
13886
14273
  async function verifyRecordForImage(recordPath, imagePath) {
13887
14274
  const verification = await verifyArtifactBundle(recordPath);
@@ -13896,20 +14283,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
13896
14283
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
13897
14284
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
13898
14285
  }
13899
- const outputs = record.outputs.map((output) => path29.resolve(path29.dirname(recordPath), output.path));
13900
- if (!outputs.includes(path29.resolve(imagePath))) {
14286
+ const outputs = record.outputs.map((output) => path30.resolve(path30.dirname(recordPath), output.path));
14287
+ if (!outputs.includes(path30.resolve(imagePath))) {
13901
14288
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
13902
14289
  }
13903
14290
  }
13904
14291
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
13905
14292
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
13906
- const absolute = path29.resolve(baselinePath);
14293
+ const absolute = path30.resolve(baselinePath);
13907
14294
  for (const input of inputs) {
13908
- if (path29.resolve(input.file) === absolute || input.record && path29.resolve(input.record) === absolute) {
14295
+ if (path30.resolve(input.file) === absolute || input.record && path30.resolve(input.record) === absolute) {
13909
14296
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
13910
14297
  }
13911
14298
  }
13912
- const root = path29.dirname(absolute);
14299
+ const root = path30.dirname(absolute);
13913
14300
  const cases = [];
13914
14301
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
13915
14302
  const expected = await measureImageQuality(input.file);
@@ -13936,7 +14323,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
13936
14323
  cases
13937
14324
  });
13938
14325
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
13939
- if (existsSync23(absolute) && !options.force) {
14326
+ if (existsSync24(absolute) && !options.force) {
13940
14327
  const current = await readFile24(absolute);
13941
14328
  if (!current.equals(data)) {
13942
14329
  throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
@@ -13946,7 +14333,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
13946
14333
  return { baseline, path: absolute, changed: result.changed.length > 0 };
13947
14334
  }
13948
14335
  async function readQualityBaseline(baselinePath) {
13949
- const absolute = path29.resolve(baselinePath);
14336
+ const absolute = path30.resolve(baselinePath);
13950
14337
  let raw;
13951
14338
  try {
13952
14339
  raw = JSON.parse(await readFile24(absolute, "utf8"));
@@ -14019,12 +14406,12 @@ function regressionViolations(expected, actual, tolerances) {
14019
14406
  return { violations, warnings };
14020
14407
  }
14021
14408
  async function checkQualityBaseline(baselinePath) {
14022
- const absolute = path29.resolve(baselinePath);
14409
+ const absolute = path30.resolve(baselinePath);
14023
14410
  const baseline = await readQualityBaseline(absolute);
14024
- const root = path29.dirname(absolute);
14411
+ const root = path30.dirname(absolute);
14025
14412
  const cases = [];
14026
14413
  for (const entry of baseline.cases) {
14027
- const file = path29.resolve(root, entry.file);
14414
+ const file = path30.resolve(root, entry.file);
14028
14415
  let actual = null;
14029
14416
  const violations = [];
14030
14417
  const warnings = [];
@@ -14037,7 +14424,7 @@ async function checkQualityBaseline(baselinePath) {
14037
14424
  violations.push(message5(error));
14038
14425
  }
14039
14426
  if (entry.record) {
14040
- const recordPath = path29.resolve(root, entry.record.path);
14427
+ const recordPath = path30.resolve(root, entry.record.path);
14041
14428
  try {
14042
14429
  const recordHash = await sha256File(recordPath);
14043
14430
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -14123,13 +14510,13 @@ async function runGallery(initial, reload, args, access2) {
14123
14510
  };
14124
14511
  const loadProject = async (project) => {
14125
14512
  const ctx = await access2.loadProject(project);
14126
- const dir = path30.dirname(ctx.loaded.path);
14513
+ const dir = path31.dirname(ctx.loaded.path);
14127
14514
  const declared = readEnvFiles(dir);
14128
14515
  const credentialNames = new Set(availableProviders().flatMap((id) => providerCredentialEnvs(providerFactory(id))));
14129
14516
  for (const [name, value] of Object.entries(declared)) {
14130
14517
  if (credentialNames.has(name) && name in process.env && process.env[name] !== value) {
14131
14518
  throw new Error(
14132
- `${path30.relative(process.cwd(), dir) || "."} sets ${name} to a different value than the one this gallery already loaded; run a separate gallery for that project so its work uses its own account.`
14519
+ `${path31.relative(process.cwd(), dir) || "."} sets ${name} to a different value than the one this gallery already loaded; run a separate gallery for that project so its work uses its own account.`
14133
14520
  );
14134
14521
  }
14135
14522
  }
@@ -14173,11 +14560,11 @@ async function runGallery(initial, reload, args, access2) {
14173
14560
  log("\n gallery closed");
14174
14561
  }
14175
14562
  async function provenanceFile(id, file) {
14176
- const absolute = path30.resolve(file);
14563
+ const absolute = path31.resolve(file);
14177
14564
  return {
14178
14565
  id,
14179
14566
  path: absolute,
14180
- sha256: existsSync24(absolute) ? await sha256File(absolute) : null,
14567
+ sha256: existsSync25(absolute) ? await sha256File(absolute) : null,
14181
14568
  included: true
14182
14569
  };
14183
14570
  }
@@ -14211,7 +14598,8 @@ var VALUE_FLAGS = [
14211
14598
  "--min-grid-confidence",
14212
14599
  "--reviewer",
14213
14600
  "--note",
14214
- "--model-root"
14601
+ "--model-root",
14602
+ "--generation"
14215
14603
  ];
14216
14604
  var BOOL_FLAGS = [
14217
14605
  "--force",
@@ -14256,6 +14644,7 @@ var COMMANDS = [
14256
14644
  "gallery",
14257
14645
  "edit",
14258
14646
  "tools",
14647
+ "history",
14259
14648
  "quality",
14260
14649
  "refine",
14261
14650
  "recipe",
@@ -14465,7 +14854,7 @@ function parseArgs(argv) {
14465
14854
  return {
14466
14855
  command,
14467
14856
  manifest,
14468
- lock: get("--lock") ?? path30.join(path30.dirname(path30.resolve(manifest)), "pixelkiln.lock.json"),
14857
+ lock: get("--lock") ?? path31.join(path31.dirname(path31.resolve(manifest)), "pixelkiln.lock.json"),
14469
14858
  explicitLock: get("--lock"),
14470
14859
  styles: list("--style"),
14471
14860
  assets: list("--only"),
@@ -14507,6 +14896,7 @@ function parseArgs(argv) {
14507
14896
  reviewer: get("--reviewer"),
14508
14897
  note: get("--note"),
14509
14898
  modelRoot: get("--model-root"),
14899
+ generation: get("--generation"),
14510
14900
  subcommand,
14511
14901
  workspace: get("--workspace"),
14512
14902
  target,
@@ -14566,7 +14956,9 @@ Commands
14566
14956
  poll Advance in-flight jobs to their settled state.
14567
14957
  pick Open the contact sheet to choose among candidates.
14568
14958
  fetch Download selected objects to their manifest paths.
14569
- restore Re-download missing generated files without generating new art.
14959
+ restore Re-download missing generated files without generating new art;
14960
+ --generation <n|hash> brings a previous generation back instead.
14961
+ history List the generations each asset has replaced and can restore.
14570
14962
  adopt Match existing account objects to files already in the repo.
14571
14963
  accept Keep existing art after a style reword \u2014 re-baseline, do not regenerate.
14572
14964
  salvage Triage account objects no lockfile claims. Recovers usable art.
@@ -14620,6 +15012,7 @@ Options
14620
15012
  --reviewer <name> refine approve: human reviewer recorded in provenance
14621
15013
  --note <text> refine approve: optional review note
14622
15014
  --model-root <dir> recipe verify: also hash required local model files
15015
+ --generation <n|hash> restore: the previous generation to bring back (1 = newest)
14623
15016
  --prune cache: remove invalid/unreferenced local cache data
14624
15017
  --manifest <path> Default: pixelkiln.manifest.json
14625
15018
  --lock <path> Default: pixelkiln.lock.json beside the manifest
@@ -14817,10 +15210,10 @@ async function confirm(question, auto) {
14817
15210
  return answer === "y" || answer === "yes";
14818
15211
  }
14819
15212
  async function requireCompleteWorkspaceClaims(workspacePath) {
14820
- if (!existsSync24(workspacePath)) {
15213
+ if (!existsSync25(workspacePath)) {
14821
15214
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14822
15215
  }
14823
- const dir = path30.dirname(path30.resolve(workspacePath));
15216
+ const dir = path31.dirname(path31.resolve(workspacePath));
14824
15217
  const ws = await loadWorkspace(workspacePath);
14825
15218
  const diagnostics = validateWorkspace(ws, dir);
14826
15219
  const errors = diagnostics.filter((d) => d.level === "error");
@@ -14856,15 +15249,15 @@ async function main() {
14856
15249
  if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
14857
15250
  let raw;
14858
15251
  try {
14859
- raw = JSON.parse(await readFile25(path30.resolve(args.inputs), "utf8"));
15252
+ raw = JSON.parse(await readFile25(path31.resolve(args.inputs), "utf8"));
14860
15253
  } catch (error) {
14861
15254
  throw new Error(
14862
- `Could not read quality inputs ${path30.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
15255
+ `Could not read quality inputs ${path31.resolve(args.inputs)}: ${error instanceof Error ? error.message : String(error)}`,
14863
15256
  { cause: error }
14864
15257
  );
14865
15258
  }
14866
15259
  const inputs = resolveQualityInputs(raw, args.inputs);
14867
- if (path30.resolve(args.inputs) === path30.resolve(args.out)) {
15260
+ if (path31.resolve(args.inputs) === path31.resolve(args.out)) {
14868
15261
  throw new Error("quality snapshot --out must not overwrite its --inputs file.");
14869
15262
  }
14870
15263
  const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
@@ -14877,7 +15270,7 @@ async function main() {
14877
15270
  }, null, 2));
14878
15271
  } else {
14879
15272
  log(
14880
- ` ${result.changed ? "wrote" : "unchanged"} ${path30.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
15273
+ ` ${result.changed ? "wrote" : "unchanged"} ${path31.relative(process.cwd(), result.path)} (${result.baseline.cases.length} case(s))`
14881
15274
  );
14882
15275
  log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
14883
15276
  }
@@ -14972,14 +15365,14 @@ async function main() {
14972
15365
  }, null, 2));
14973
15366
  } else {
14974
15367
  log(` installed ${result.recipe.id}@${result.recipe.version}`);
14975
- log(` ${path30.relative(process.cwd(), result.destination) || "."}`);
15368
+ log(` ${path31.relative(process.cwd(), result.destination) || "."}`);
14976
15369
  log(`
14977
15370
  Add this entry under your manifest's styles object:`);
14978
15371
  log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
14979
15372
  if (result.recipe.models.length) {
14980
15373
  log(`
14981
15374
  Models are not downloaded automatically. Verify them with:`);
14982
- log(` pixelkiln recipe verify ${path30.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
15375
+ log(` pixelkiln recipe verify ${path31.relative(process.cwd(), result.destination)} --model-root <ComfyUI/models>`);
14983
15376
  }
14984
15377
  }
14985
15378
  return;
@@ -15013,12 +15406,12 @@ async function main() {
15013
15406
  ` recovered ${result2.detection.columns}x${result2.detection.rows} native grid (${result2.detection.confidence}: ${result2.detection.consensus})`
15014
15407
  );
15015
15408
  log(` enforced ${result2.palette.length}-color palette without dithering`);
15016
- log(` wrote ${path30.relative(process.cwd(), result2.output)}`);
15017
- log(` quality record: ${path30.relative(process.cwd(), result2.record)}`);
15409
+ log(` wrote ${path31.relative(process.cwd(), result2.output)}`);
15410
+ log(` quality record: ${path31.relative(process.cwd(), result2.record)}`);
15018
15411
  log(`
15019
15412
  Automated checks passed. Human 1\xD7 review is still required:`);
15020
15413
  log(
15021
- ` pixelkiln refine approve --from ${path30.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
15414
+ ` pixelkiln refine approve --from ${path31.relative(process.cwd(), result2.record)} --reviewer "Your Name"`
15022
15415
  );
15023
15416
  }
15024
15417
  return;
@@ -15046,7 +15439,7 @@ async function main() {
15046
15439
  if (args.json) log(JSON.stringify(result2, null, 2));
15047
15440
  else {
15048
15441
  log(
15049
- result2.options.frameSet ? ` approved ${result2.outputs.length} frames in ${path30.relative(process.cwd(), result2.record)} by ${args.reviewer.trim()}` : ` approved ${path30.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`
15442
+ result2.options.frameSet ? ` approved ${result2.outputs.length} frames in ${path31.relative(process.cwd(), result2.record)} by ${args.reviewer.trim()}` : ` approved ${path31.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`
15050
15443
  );
15051
15444
  }
15052
15445
  return;
@@ -15055,14 +15448,14 @@ async function main() {
15055
15448
  if (args.json) {
15056
15449
  log(JSON.stringify(result, null, 2));
15057
15450
  } else {
15058
- log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path30.relative(process.cwd(), result.output || result.record)}`);
15451
+ log(` ${result.safe ? "release-ready" : "not release-ready"}: ${path31.relative(process.cwd(), result.output || result.record)}`);
15059
15452
  for (const reason of result.reasons) log(` ${reason}`);
15060
15453
  }
15061
15454
  if (!result.safe) process.exitCode = 1;
15062
15455
  return;
15063
15456
  }
15064
15457
  if (args.command === "balance") {
15065
- loadEnvFiles(path30.dirname(path30.resolve(args.manifest)));
15458
+ loadEnvFiles(path31.dirname(path31.resolve(args.manifest)));
15066
15459
  loadEnvFiles(process.cwd());
15067
15460
  const loaded2 = await loadManifest(args.manifest);
15068
15461
  const selectedProvider = accountProviderId(loaded2.manifest, args.provider, "balance");
@@ -15075,31 +15468,31 @@ async function main() {
15075
15468
  }
15076
15469
  if (args.command === "init") {
15077
15470
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
15078
- const root = path30.resolve(args.from);
15079
- if (!existsSync24(root)) throw new Error(`No directory at ${root}`);
15471
+ const root = path31.resolve(args.from);
15472
+ if (!existsSync25(root)) throw new Error(`No directory at ${root}`);
15080
15473
  const generator = args.generator ?? "map";
15081
15474
  if (generator !== "1dir" && generator !== "map") {
15082
15475
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
15083
15476
  }
15084
- const target = path30.resolve(args.out ?? "pixelkiln.manifest.json");
15477
+ const target = path31.resolve(args.out ?? "pixelkiln.manifest.json");
15085
15478
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
15086
15479
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
15087
15480
  const manifest = buildManifest(
15088
- args.name ?? path30.basename(path30.dirname(target)),
15481
+ args.name ?? path31.basename(path31.dirname(target)),
15089
15482
  args.styles[0] ?? "base",
15090
15483
  generator,
15091
- path30.relative(path30.dirname(target), root) || ".",
15484
+ path31.relative(path31.dirname(target), root) || ".",
15092
15485
  assets
15093
15486
  );
15094
15487
  await writeManifestFile(target, manifest);
15095
- log(` scanned ${assets.length} PNG(s) under ${path30.relative(process.cwd(), root)}`);
15488
+ log(` scanned ${assets.length} PNG(s) under ${path31.relative(process.cwd(), root)}`);
15096
15489
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
15097
- log(` wrote ${path30.relative(process.cwd(), target)}`);
15490
+ log(` wrote ${path31.relative(process.cwd(), target)}`);
15098
15491
  log(`
15099
15492
  Prompts are intentionally empty. To recover the real ones from your`);
15100
15493
  log(` PixelLab account instead of inventing them:`);
15101
15494
  log(`
15102
- pixelkiln adopt --manifest ${path30.relative(process.cwd(), target)} --write-prompts
15495
+ pixelkiln adopt --manifest ${path31.relative(process.cwd(), target)} --write-prompts
15103
15496
  `);
15104
15497
  return;
15105
15498
  }
@@ -15147,10 +15540,10 @@ async function main() {
15147
15540
  if (args.primaryOnly || args.outputRoles.length) {
15148
15541
  throw new Error("--primary-only and --output-role require manifest-driven pack");
15149
15542
  }
15150
- const raw = JSON.parse(await readFile25(path30.resolve(args.inputs), "utf8"));
15543
+ const raw = JSON.parse(await readFile25(path31.resolve(args.inputs), "utf8"));
15151
15544
  const inputs = resolvePackInputs(raw, args.inputs);
15152
15545
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
15153
- const base = path30.resolve(args.out.replace(/\.png$/, ""));
15546
+ const base = path31.resolve(args.out.replace(/\.png$/, ""));
15154
15547
  const outputs = [
15155
15548
  { path: `${base}.png`, data: png },
15156
15549
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -15163,17 +15556,17 @@ async function main() {
15163
15556
  log(
15164
15557
  ` ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s) \u2014 ${(png.length / 1024).toFixed(1)} KB`
15165
15558
  );
15166
- log(` ${path30.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15559
+ log(` ${path31.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15167
15560
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
15168
15561
  return;
15169
15562
  }
15170
15563
  if (args.command === "workspace") {
15171
- const workspacePath = path30.resolve(args.workspace ?? "pixelkiln.workspace.json");
15172
- const dir = path30.dirname(workspacePath);
15564
+ const workspacePath = path31.resolve(args.workspace ?? "pixelkiln.workspace.json");
15565
+ const dir = path31.dirname(workspacePath);
15173
15566
  if (args.subcommand === "add") {
15174
- const manifestPath = path30.resolve(args.target);
15567
+ const manifestPath = path31.resolve(args.target);
15175
15568
  const loadedTarget = await loadManifest(manifestPath);
15176
- const lockPath = args.explicitLock ? path30.resolve(args.explicitLock) : path30.join(path30.dirname(manifestPath), "pixelkiln.lock.json");
15569
+ const lockPath = args.explicitLock ? path31.resolve(args.explicitLock) : path31.join(path31.dirname(manifestPath), "pixelkiln.lock.json");
15177
15570
  const ws = await loadWorkspace(workspacePath);
15178
15571
  const id = args.name ?? loadedTarget.manifest.name;
15179
15572
  if (ws.projects.some((p) => p.id === id)) {
@@ -15193,10 +15586,10 @@ async function main() {
15193
15586
  ...args.account ? { account: args.account } : {}
15194
15587
  };
15195
15588
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
15196
- log(` registered "${id}" in ${path30.relative(process.cwd(), workspacePath)}`);
15589
+ log(` registered "${id}" in ${path31.relative(process.cwd(), workspacePath)}`);
15197
15590
  log(` manifest: ${project.manifest}`);
15198
15591
  log(` lock: ${project.lock}`);
15199
- if (!existsSync24(lockPath)) {
15592
+ if (!existsSync25(lockPath)) {
15200
15593
  log(
15201
15594
  ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
15202
15595
  );
@@ -15205,7 +15598,7 @@ async function main() {
15205
15598
  }
15206
15599
  if (args.subcommand === "remove") {
15207
15600
  const ws = await loadWorkspace(workspacePath);
15208
- const resolvedTarget = path30.resolve(args.target);
15601
+ const resolvedTarget = path31.resolve(args.target);
15209
15602
  const match = ws.projects.find(
15210
15603
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
15211
15604
  );
@@ -15216,11 +15609,11 @@ async function main() {
15216
15609
  version: 1,
15217
15610
  projects: ws.projects.filter((p) => p !== match)
15218
15611
  });
15219
- log(` removed "${match.id}" from ${path30.relative(process.cwd(), workspacePath)}`);
15612
+ log(` removed "${match.id}" from ${path31.relative(process.cwd(), workspacePath)}`);
15220
15613
  return;
15221
15614
  }
15222
15615
  if (args.subcommand === "list") {
15223
- if (!existsSync24(workspacePath)) {
15616
+ if (!existsSync25(workspacePath)) {
15224
15617
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
15225
15618
  }
15226
15619
  const ws = await loadWorkspace(workspacePath);
@@ -15228,9 +15621,9 @@ async function main() {
15228
15621
  if (args.json) {
15229
15622
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
15230
15623
  } else if (!ws.projects.length) {
15231
- log(` no projects registered in ${path30.relative(process.cwd(), workspacePath)}`);
15624
+ log(` no projects registered in ${path31.relative(process.cwd(), workspacePath)}`);
15232
15625
  } else {
15233
- log(` ${ws.projects.length} project(s) in ${path30.relative(process.cwd(), workspacePath)}:`);
15626
+ log(` ${ws.projects.length} project(s) in ${path31.relative(process.cwd(), workspacePath)}:`);
15234
15627
  for (const p of ws.projects) {
15235
15628
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
15236
15629
  }
@@ -15240,7 +15633,7 @@ async function main() {
15240
15633
  return;
15241
15634
  }
15242
15635
  if (args.subcommand === "status") {
15243
- if (!existsSync24(workspacePath)) {
15636
+ if (!existsSync25(workspacePath)) {
15244
15637
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
15245
15638
  }
15246
15639
  const ws = await loadWorkspace(workspacePath);
@@ -15248,7 +15641,7 @@ async function main() {
15248
15641
  if (args.json) {
15249
15642
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
15250
15643
  } else {
15251
- log(` workspace: ${path30.relative(process.cwd(), workspacePath)}`);
15644
+ log(` workspace: ${path31.relative(process.cwd(), workspacePath)}`);
15252
15645
  for (const p of report.projects) {
15253
15646
  if (p.error) {
15254
15647
  log(`
@@ -15295,8 +15688,8 @@ async function main() {
15295
15688
  }
15296
15689
  }
15297
15690
  if (args.command === "gallery" && args.workspace) {
15298
- const workspacePath = path30.resolve(args.workspace);
15299
- if (!existsSync24(workspacePath)) {
15691
+ const workspacePath = path31.resolve(args.workspace);
15692
+ if (!existsSync25(workspacePath)) {
15300
15693
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
15301
15694
  }
15302
15695
  const filter = { styles: args.styles, assets: args.assets };
@@ -15316,7 +15709,7 @@ async function main() {
15316
15709
  const registered = async (projectId) => {
15317
15710
  const project = (await loadWorkspace(workspacePath)).projects.find((candidate) => candidate.id === projectId);
15318
15711
  if (!projectId || !project) throw new Error(`unknown workspace project "${projectId ?? ""}"`);
15319
- return resolveProject(path30.dirname(workspacePath), project);
15712
+ return resolveProject(path31.dirname(workspacePath), project);
15320
15713
  };
15321
15714
  await runGallery(initial, build, args, {
15322
15715
  manifestFor: async (projectId) => (await registered(projectId)).manifestPath,
@@ -15331,14 +15724,14 @@ async function main() {
15331
15724
  });
15332
15725
  return;
15333
15726
  }
15334
- if (!existsSync24(path30.resolve(args.manifest))) {
15727
+ if (!existsSync25(path31.resolve(args.manifest))) {
15335
15728
  throw new Error(
15336
- `No manifest at ${path30.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
15729
+ `No manifest at ${path31.resolve(args.manifest)}. Pass --manifest, or run \`pixelkiln init --from <dir>\`.`
15337
15730
  );
15338
15731
  }
15339
- const manifestDir = path30.dirname(path30.resolve(args.manifest));
15732
+ const manifestDir = path31.dirname(path31.resolve(args.manifest));
15340
15733
  const envFiles2 = [...loadEnvFiles(manifestDir)];
15341
- if (path30.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
15734
+ if (path31.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
15342
15735
  const loaded = await loadManifest(args.manifest);
15343
15736
  let specs = await resolveSpecs(loaded, {
15344
15737
  styles: args.styles,
@@ -15370,6 +15763,40 @@ async function main() {
15370
15763
  }
15371
15764
  const lock = await loadLock(args.lock);
15372
15765
  normalizeLockOutputPaths(lock, specs);
15766
+ if (args.command === "history") {
15767
+ const limit = historyLimit(loaded.manifest);
15768
+ const selected = specs.map((spec) => ({ spec, entry: lock.entries[lockKey(spec.styleId, spec.assetId)] }));
15769
+ if (args.json) {
15770
+ log(JSON.stringify({
15771
+ version: 1,
15772
+ limit,
15773
+ assets: selected.map(({ spec, entry }) => ({
15774
+ key: lockKey(spec.styleId, spec.assetId),
15775
+ current: entry ? { objectId: entry.objectId, outputs: entry.outputs, downloadedAt: entry.downloadedAt, cost: entry.cost, costUnit: entry.costUnit } : null,
15776
+ history: entry?.history ?? []
15777
+ }))
15778
+ }, null, 2));
15779
+ return;
15780
+ }
15781
+ log(` keeping up to ${limit} replaced generation${limit === 1 ? "" : "s"} per asset` + (loaded.manifest.history !== void 0 ? " (manifest history)" : ` (${process.env.PIXELKILN_HISTORY ? "PIXELKILN_HISTORY" : "default; set PIXELKILN_HISTORY or manifest history"})`));
15782
+ let shown = 0;
15783
+ for (const { spec, entry } of selected) {
15784
+ if (!entry?.history?.length) continue;
15785
+ shown++;
15786
+ const key = lockKey(spec.styleId, spec.assetId);
15787
+ log(`
15788
+ ${key}`);
15789
+ log(` current ${entry.outputs[0]?.sha256.slice(0, 12) ?? "\u2014"} ${entry.downloadedAt ?? ""} ${entry.objectId ?? ""}`);
15790
+ for (const [i, generation] of entry.history.entries()) {
15791
+ const changed = generation.prompt !== entry.prompt ? " (different prompt)" : "";
15792
+ log(` #${String(i + 1).padEnd(2)} ${generation.outputs[0]?.sha256.slice(0, 12) ?? "\u2014"} ${generation.downloadedAt ?? ""} ${generation.objectId ?? ""}${changed}`);
15793
+ }
15794
+ }
15795
+ if (!shown) log(` no asset has a previous generation recorded${selected.length ? "" : " (nothing selected)"}`);
15796
+ else log(`
15797
+ bring one back: pixelkiln restore --only <asset> --style <style> --generation <n|hash>`);
15798
+ return;
15799
+ }
15373
15800
  if (args.command === "status") {
15374
15801
  const byStatus = {};
15375
15802
  for (const e of Object.values(lock.entries)) byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
@@ -15411,7 +15838,7 @@ async function main() {
15411
15838
  });
15412
15839
  };
15413
15840
  await runGallery(await build(), reload, args, {
15414
- manifestFor: () => path30.resolve(args.manifest),
15841
+ manifestFor: () => path31.resolve(args.manifest),
15415
15842
  loadProject: async () => {
15416
15843
  const freshLoaded = await loadManifest(args.manifest);
15417
15844
  const freshSpecs = await resolveSpecs(freshLoaded);
@@ -15439,7 +15866,7 @@ async function main() {
15439
15866
  const set = started.members.length > 1;
15440
15867
  const kind = spec.generator === "frames" ? "frames" : "members";
15441
15868
  log(` ${started.created ? "created" : "found"} ${started.source}${set ? ` (${started.members.length} ${kind})` : ""}${started.declared ? " and declared it as the asset's source" : ""}`);
15442
- if (set) for (const member of started.members) log(` ${member.role ?? ""} ${path30.relative(process.cwd(), member.path)}`);
15869
+ if (set) for (const member of started.members) log(` ${member.role ?? ""} ${path31.relative(process.cwd(), member.path)}`);
15443
15870
  if (args.noOpen) {
15444
15871
  log(` edit ${set ? "them" : "it"} with your own tool, then run pixelkiln plan; mount and pack place ${set ? "them" : "it"} in place of the generated art`);
15445
15872
  } else {
@@ -15489,7 +15916,7 @@ async function main() {
15489
15916
  log(` ${item.state.padEnd(16)} ${item.key} ${item.reason}`);
15490
15917
  if (item.state === "needs-approval") {
15491
15918
  log(
15492
- ` pixelkiln refine approve --from ${path30.relative(process.cwd(), item.record)} --reviewer "Your Name"`
15919
+ ` pixelkiln refine approve --from ${path31.relative(process.cwd(), item.record)} --reviewer "Your Name"`
15493
15920
  );
15494
15921
  }
15495
15922
  }
@@ -15580,7 +16007,7 @@ async function main() {
15580
16007
  let intact = true;
15581
16008
  for (const output of entry.outputs) {
15582
16009
  const file = resolveOutputPath(output.path, item.spec.root);
15583
- if (!existsSync24(file) || await sha256File(file) !== output.sha256) {
16010
+ if (!existsSync25(file) || await sha256File(file) !== output.sha256) {
15584
16011
  intact = false;
15585
16012
  break;
15586
16013
  }
@@ -15638,7 +16065,7 @@ async function main() {
15638
16065
  if (args.primaryOnly && args.outputRoles.length) {
15639
16066
  throw new Error("pack accepts either --primary-only or --output-role, not both");
15640
16067
  }
15641
- const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
16068
+ const manifestDir2 = path31.dirname(path31.resolve(args.manifest));
15642
16069
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
15643
16070
  for (const styleId of styleIds) {
15644
16071
  const packagingSpecs = await resolveSpecs(loaded, { styles: [styleId] });
@@ -15651,7 +16078,7 @@ async function main() {
15651
16078
  sources: manifestSources(loaded.manifest, styleId)
15652
16079
  });
15653
16080
  const style = loaded.manifest.styles[styleId];
15654
- const base = args.out ? path30.resolve(args.out.replace(/\.png$/, "")) : path30.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
16081
+ const base = args.out ? path31.resolve(args.out.replace(/\.png$/, "")) : path31.resolve(manifestDir2, style.outDir, `${styleId}-sheet`);
15655
16082
  const outputs = [
15656
16083
  { path: `${base}.png`, data: png },
15657
16084
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -15681,13 +16108,13 @@ async function main() {
15681
16108
  log(
15682
16109
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
15683
16110
  );
15684
- log(` ${path30.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
16111
+ log(` ${path31.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15685
16112
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
15686
16113
  }
15687
16114
  return;
15688
16115
  }
15689
16116
  if (args.command === "mount") {
15690
- const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
16117
+ const manifestDir2 = path31.dirname(path31.resolve(args.manifest));
15691
16118
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
15692
16119
  for (const styleId of styleIds) {
15693
16120
  const style = loaded.manifest.styles[styleId];
@@ -15726,7 +16153,7 @@ async function main() {
15726
16153
  sources,
15727
16154
  outputRoles
15728
16155
  );
15729
- const out = path30.resolve(manifestDir2, style.mount.out);
16156
+ const out = path31.resolve(manifestDir2, style.mount.out);
15730
16157
  const metadata = out.replace(/\.png$/, "") + ".json";
15731
16158
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
15732
16159
  const outputs = [
@@ -15746,7 +16173,7 @@ async function main() {
15746
16173
  await provenanceFile("$lock", args.lock),
15747
16174
  ...qualityRecords,
15748
16175
  ...artifactSources.filter(
15749
- (source) => source.id !== "$base" || path30.resolve(source.path) !== out
16176
+ (source) => source.id !== "$base" || path31.resolve(source.path) !== out
15750
16177
  )
15751
16178
  ],
15752
16179
  options: {
@@ -15759,7 +16186,7 @@ async function main() {
15759
16186
  log(
15760
16187
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
15761
16188
  );
15762
- log(` ${path30.relative(process.cwd(), out)} + atlas/provenance JSON`);
16189
+ log(` ${path31.relative(process.cwd(), out)} + atlas/provenance JSON`);
15763
16190
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
15764
16191
  }
15765
16192
  return;
@@ -15815,7 +16242,7 @@ async function main() {
15815
16242
  }
15816
16243
  if (args.command === "export") {
15817
16244
  const format = args.format ?? "generic";
15818
- const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
16245
+ const manifestDir2 = path31.dirname(path31.resolve(args.manifest));
15819
16246
  const selected = specs.filter((spec) => {
15820
16247
  if (spec.generator !== "tiles") return false;
15821
16248
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -15831,12 +16258,12 @@ async function main() {
15831
16258
  for (const spec of selected) {
15832
16259
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
15833
16260
  const style = loaded.manifest.styles[spec.styleId];
15834
- const defaultBase = path30.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
15835
- const base = args.out ? path30.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
16261
+ const defaultBase = path31.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
16262
+ const base = args.out ? path31.resolve(args.out.replace(/\.(?:png|json|tsj|tres)$/i, "")) : defaultBase;
15836
16263
  const result = exportTileset(entry, spec, {
15837
16264
  format,
15838
16265
  manifestDir: manifestDir2,
15839
- imageName: path30.basename(`${base}.png`),
16266
+ imageName: path31.basename(`${base}.png`),
15840
16267
  columns: args.columns
15841
16268
  });
15842
16269
  const outputs = [
@@ -15854,7 +16281,7 @@ async function main() {
15854
16281
  asset: spec.assetId,
15855
16282
  columns: args.columns ?? null,
15856
16283
  format,
15857
- image: path30.basename(`${base}.png`),
16284
+ image: path31.basename(`${base}.png`),
15858
16285
  providerRules: result.generic.providerRules,
15859
16286
  style: spec.styleId,
15860
16287
  tileType: spec.tileType ?? null
@@ -15864,12 +16291,32 @@ async function main() {
15864
16291
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
15865
16292
  );
15866
16293
  log(
15867
- ` ${path30.relative(process.cwd(), base)}.png + ${path30.basename(base)}${result.extension} + .pixelkiln.json`
16294
+ ` ${path31.relative(process.cwd(), base)}.png + ${path31.basename(base)}${result.extension} + .pixelkiln.json`
15868
16295
  );
15869
16296
  }
15870
16297
  return;
15871
16298
  }
15872
16299
  const providerMode = args.command === "restore" || args.command === "fetch" && !args.tag ? "downloads" : "online";
16300
+ if (args.command === "restore" && args.generation !== void 0) {
16301
+ if (specs.length !== 1) {
16302
+ throw new Error(
16303
+ specs.length ? `restore --generation works on one asset; --only/--style select ${specs.length}. Add --style or narrow --only.` : "restore --generation matched no asset; pass --only <asset id> (and --style when it is in several styles)."
16304
+ );
16305
+ }
16306
+ const spec = specs[0];
16307
+ const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
16308
+ if (!entry) throw new Error(`${spec.styleId}/${spec.assetId} is not in the lockfile`);
16309
+ const provider = createProvider(entry.provider, "downloads");
16310
+ const result = await revertGeneration(provider, spec, lock, args.lock, {
16311
+ generation: args.generation,
16312
+ force: args.force,
16313
+ onProgress: log
16314
+ });
16315
+ log(`
16316
+ restored generation #${result.index} of ${spec.styleId}/${spec.assetId}${result.restored.objectId ? ` (${result.restored.objectId})` : ""}; the replaced one is now #1 in its history`);
16317
+ log(` lockfile written: ${args.lock}`);
16318
+ return;
16319
+ }
15873
16320
  const providers = /* @__PURE__ */ new Map();
15874
16321
  const providerFor = (id) => {
15875
16322
  let resolved = providers.get(id);
@@ -15914,12 +16361,12 @@ async function main() {
15914
16361
  tagged ${n} object(s) upstream`);
15915
16362
  }
15916
16363
  if (args.writePrompts) {
15917
- const { filled, stillEmpty } = await writePromptsBack(path30.resolve(args.manifest), lock, {
16364
+ const { filled, stillEmpty } = await writePromptsBack(path31.resolve(args.manifest), lock, {
15918
16365
  onProgress: log,
15919
16366
  provider: provider.id,
15920
16367
  assetIds: specs.map((spec) => spec.assetId)
15921
16368
  });
15922
- log(` recovered ${filled} prompt(s) into ${path30.relative(process.cwd(), args.manifest)}`);
16369
+ log(` recovered ${filled} prompt(s) into ${path31.relative(process.cwd(), args.manifest)}`);
15923
16370
  const reloaded = await loadManifest(args.manifest);
15924
16371
  const rebased = (await resolveSpecs(reloaded, {
15925
16372
  styles: args.styles,
@@ -15948,12 +16395,12 @@ async function main() {
15948
16395
  const provider = providerFor(selectedAccountProvider);
15949
16396
  const jsonMode = args.dryRun && args.json;
15950
16397
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
15951
- const ownLock = path30.resolve(args.lock);
16398
+ const ownLock = path31.resolve(args.lock);
15952
16399
  let workspaceProjects = [];
15953
16400
  let workspaceDir = "";
15954
16401
  if (args.workspace) {
15955
- const workspacePath = path30.resolve(args.workspace);
15956
- workspaceDir = path30.dirname(workspacePath);
16402
+ const workspacePath = path31.resolve(args.workspace);
16403
+ workspaceDir = path31.dirname(workspacePath);
15957
16404
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
15958
16405
  workspaceProjects = complete.ws.projects;
15959
16406
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -15962,12 +16409,12 @@ async function main() {
15962
16409
  const lockPaths = [
15963
16410
  .../* @__PURE__ */ new Set([
15964
16411
  ...workspaceLockPaths,
15965
- ...existsSync24(ownLock) ? [ownLock] : [],
15966
- ...args.claims.map((c) => path30.resolve(c))
16412
+ ...existsSync25(ownLock) ? [ownLock] : [],
16413
+ ...args.claims.map((c) => path31.resolve(c))
15967
16414
  ])
15968
16415
  ];
15969
16416
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
15970
- for (const p of lockPaths) diag(` ${path30.relative(process.cwd(), p)}`);
16417
+ for (const p of lockPaths) diag(` ${path31.relative(process.cwd(), p)}`);
15971
16418
  if (!args.claims.length && !args.workspace) {
15972
16419
  diag(
15973
16420
  `
@@ -15975,7 +16422,7 @@ async function main() {
15975
16422
  pass every other project's lockfile via --claims a.json,b.json, or
15976
16423
  register every project in a workspace catalog and pass --workspace.`
15977
16424
  );
15978
- } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path30.resolve(args.manifest))) {
16425
+ } else if (args.workspace && !workspaceProjects.some((p) => resolveProject(workspaceDir, p).manifestPath === path31.resolve(args.manifest))) {
15979
16426
  diag(
15980
16427
  `
15981
16428
  This project's manifest is not registered in the workspace catalog. Its own
@@ -16059,7 +16506,7 @@ async function main() {
16059
16506
  manifestPath: loaded.path,
16060
16507
  manifest: accountManifest,
16061
16508
  styleId,
16062
- importDir: path30.resolve(loaded.root, style.outDir),
16509
+ importDir: path31.resolve(loaded.root, style.outDir),
16063
16510
  lock,
16064
16511
  lockPath: args.lock
16065
16512
  },