pixelkiln 0.33.0 → 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),
@@ -2629,9 +2669,9 @@ var RetroDiffusionClient = class {
2629
2669
  }
2630
2670
  return balance;
2631
2671
  }
2632
- async call(path31, init = {}) {
2672
+ async call(path32, init = {}) {
2633
2673
  if (!this.token) throw new Error("RD_API_KEY is not set");
2634
- const response = await this.request(`${this.baseUrl}${path31}`, {
2674
+ const response = await this.request(`${this.baseUrl}${path32}`, {
2635
2675
  ...init,
2636
2676
  headers: {
2637
2677
  "Content-Type": "application/json",
@@ -6175,313 +6215,54 @@ function summarize(plan) {
6175
6215
  return counts;
6176
6216
  }
6177
6217
 
6178
- // src/pipeline/submit.ts
6179
- var IN_FLIGHT_POLL_MS = 5e3;
6180
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
6181
- async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
6182
- const limits = provider.rateLimit?.() ?? DEFAULT_RATE_LIMIT;
6183
- const maxInFlight = opts.maxInFlight ?? limits.maxInFlight;
6184
- const spacing = opts.spacingMs ?? limits.spacingMs;
6185
- const slotPollMs = opts.slotPollMs ?? IN_FLIGHT_POLL_MS;
6186
- const slotTimeoutMs = opts.slotTimeoutMs ?? 15 * 60 * 1e3;
6187
- const log2 = opts.onProgress ?? (() => {
6188
- });
6189
- const estimates = new Map(items.map((item) => [
6190
- item.key,
6191
- validateCostEstimate(provider.id, provider.estimate(item.spec))
6192
- ]));
6193
- const units = new Set([...estimates.values()].map((estimate) => estimate.unit));
6194
- if (units.size > 1) {
6195
- throw new Error(`Provider "${provider.id}" returned mixed cost units for one submission batch`);
6196
- }
6197
- const unit = units.values().next().value ?? "free";
6198
- if (opts.budget != null) {
6199
- const cost = [...estimates.values()].reduce((sum, estimate) => sum + estimate.amount, 0);
6200
- if (cost > opts.budget) {
6201
- throw new Error(
6202
- `This run would spend ${formatCost(unit, cost)} but the budget is ${formatCost(unit, opts.budget)}. Narrow it with --only/--style, or raise --budget.`
6203
- );
6204
- }
6205
- }
6206
- const styleImages = /* @__PURE__ */ new Map();
6207
- for (const styleId of new Set(items.map((i) => i.spec.styleId))) {
6208
- styleImages.set(styleId, await resolveStyleImages(loaded, styleId));
6209
- }
6210
- let submitted = 0;
6211
- let failed = 0;
6212
- let spent = 0;
6213
- let lastSubmitAt = 0;
6214
- const inFlight = /* @__PURE__ */ new Map();
6215
- let lastSlotError = null;
6216
- async function pruneInFlight() {
6217
- for (const [id, spec] of [...inFlight]) {
6218
- try {
6219
- const state = await provider.poll(id, spec.generator, { spec, tileFeature: spec.tileFeature });
6220
- if (state.status !== "processing") inFlight.delete(id);
6221
- lastSlotError = null;
6222
- } catch (err) {
6223
- lastSlotError = err instanceof Error ? err.message : String(err);
6224
- }
6225
- }
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;
6226
6245
  }
6227
- async function waitForSlot() {
6228
- const started = Date.now();
6229
- while (inFlight.size >= maxInFlight) {
6230
- if (Date.now() - started >= slotTimeoutMs) {
6231
- throw new Error(
6232
- `Timed out waiting for a generation slot` + (lastSlotError ? `; last poll error: ${lastSlotError}` : "")
6233
- );
6234
- }
6235
- await sleep2(slotPollMs);
6236
- await pruneInFlight();
6237
- }
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;
6238
6250
  }
6239
- for (const { spec, key } of items) {
6240
- const estimate = estimates.get(key);
6241
- await waitForSlot();
6242
- const since = Date.now() - lastSubmitAt;
6243
- if (since < spacing) await sleep2(spacing - since);
6244
- await requireRevisionReady(spec, lock);
6245
- const previousEntry = lock.entries[key];
6246
- const resumesCheckpoint = Boolean(
6247
- previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
6248
- );
6249
- const previousJobId = resumesCheckpoint ? previousEntry.jobId : void 0;
6250
- const previousMetadata = resumesCheckpoint ? previousEntry.providerMetadata[provider.id] : void 0;
6251
- const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
6252
- upsert(lock, key, {
6253
- styleId: spec.styleId,
6254
- assetId: spec.assetId,
6255
- specHash: spec.specHash,
6256
- generator: spec.generator,
6257
- tileFeature: spec.tileFeature ?? null,
6258
- prompt: spec.prompt,
6259
- width: spec.width,
6260
- height: spec.height,
6261
- revision: spec.revision ? {
6262
- mode: spec.revision.mode,
6263
- sourceAssetId: spec.revision.sourceAssetId,
6264
- sourceSha256: spec.revision.sourceSha256,
6265
- ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {},
6266
- ...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
6267
- } : null,
6268
- status: "pending",
6269
- jobId: previousJobId ?? null,
6270
- ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
6271
- objectId: null,
6272
- reviewObjectId: null,
6273
- candidateIndex: null,
6274
- error: null,
6275
- outputs: [],
6276
- // Keep the old ownership proof while new bytes are pending. Fetch may
6277
- // replace that file only while its hash still matches this record.
6278
- supersededOutputs,
6279
- providerMetadata: resumesCheckpoint ? previousEntry.providerMetadata : {},
6280
- sourceUrl: null,
6281
- sourceUrls: [],
6282
- submittedAt: resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
6283
- cost: estimate.amount,
6284
- costUnit: estimate.unit,
6285
- // Persist routing before the request starts. If the process stops while
6286
- // the provider is accepting work, a resumed run must not fall back to
6287
- // PixelLab (the legacy lock default) for another provider's entry.
6288
- provider: provider.id,
6289
- downloadedAt: null
6290
- });
6291
- await saveLock(lockPath, lock);
6292
- lastSubmitAt = Date.now();
6293
- try {
6294
- const refs = styleImages.get(spec.styleId) ?? [];
6295
- const { jobId, metadata } = await provider.submit(spec, refs, {
6296
- ...previousJobId ? { previousJobId } : {},
6297
- ...previousMetadata ? { previousMetadata } : {},
6298
- checkpoint: async (checkpoint) => {
6299
- if (!checkpoint.jobId) throw new Error("Provider checkpoint returned an empty job id");
6300
- const entry = lock.entries[key];
6301
- upsert(lock, key, {
6302
- jobId: checkpoint.jobId,
6303
- submissionComplete: checkpoint.complete,
6304
- reviewObjectId: checkpoint.complete && (spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature) ? checkpoint.jobId : null,
6305
- status: checkpoint.complete ? "processing" : "pending",
6306
- error: null,
6307
- providerMetadata: checkpoint.metadata ? {
6308
- ...entry.providerMetadata,
6309
- [provider.id]: {
6310
- ...entry.providerMetadata[provider.id],
6311
- ...checkpoint.metadata
6312
- }
6313
- } : entry.providerMetadata
6314
- });
6315
- await saveLock(lockPath, lock);
6316
- }
6317
- });
6318
- upsert(lock, key, {
6319
- jobId,
6320
- submissionComplete: true,
6321
- // A multi-candidate generator routes through review; record the parent
6322
- // so `pick` knows where to look.
6323
- reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
6324
- status: "processing",
6325
- providerMetadata: metadata ? { ...lock.entries[key].providerMetadata, [provider.id]: metadata } : lock.entries[key].providerMetadata
6326
- });
6327
- inFlight.set(jobId, spec);
6328
- submitted++;
6329
- spent += estimate.amount;
6330
- log2(
6331
- ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
6332
- );
6333
- } catch (err) {
6334
- const message6 = err instanceof Error ? err.message : String(err);
6335
- const entry = lock.entries[key];
6336
- if (entry.jobId && entry.submissionComplete === true) {
6337
- upsert(lock, key, { status: "processing", error: null });
6338
- inFlight.set(entry.jobId, spec);
6339
- submitted++;
6340
- spent += estimate.amount;
6341
- log2(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
6342
- } else {
6343
- failed++;
6344
- upsert(lock, key, {
6345
- status: "failed",
6346
- error: message6,
6347
- cost: entry.jobId ? estimate.amount : 0
6348
- });
6349
- log2(` FAILED ${key}: ${message6}`);
6350
- }
6351
- }
6352
- 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;
6353
6259
  }
6354
- return { submitted, failed, spent, unit };
6260
+ if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
6261
+ return !isSensitiveSourceUrl(value);
6355
6262
  }
6356
6263
 
6357
- // src/pipeline/poll.ts
6358
- var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
6359
- async function poll(provider, lock, lockPath, opts = {}) {
6360
- const interval = opts.intervalMs ?? 5e3;
6361
- const timeout = opts.timeoutMs ?? 15 * 60 * 1e3;
6362
- const log2 = opts.onProgress ?? (() => {
6363
- });
6364
- const started = Date.now();
6365
- const specByKey = new Map((opts.specs ?? []).map((s) => [lockKey(s.styleId, s.assetId), s]));
6366
- const selectedKeys = opts.specs ? new Set(specByKey.keys()) : null;
6367
- const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
6368
- const incompleteSubmissions = Object.entries(lock.entries).filter(
6369
- ([key, entry]) => entry.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && entry.jobId && entry.submissionComplete === false && (entry.status === "pending" || entry.status === "processing")
6370
- );
6371
- for (const [key] of incompleteSubmissions) {
6372
- log2(` incomplete submission ${key}; rerun \`pixelkiln submit\` to resume`);
6373
- }
6374
- result.stillRunning = incompleteSubmissions.length;
6375
- const pending = () => Object.entries(lock.entries).filter(
6376
- ([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && e.submissionComplete !== false && (e.status === "pending" || e.status === "processing")
6377
- );
6378
- while (pending().length > 0) {
6379
- if (Date.now() - started > timeout) {
6380
- result.stillRunning += pending().length;
6381
- log2(` timed out with ${result.stillRunning} job(s) still running \u2014 re-run \`poll\` to resume`);
6382
- break;
6383
- }
6384
- for (const [key, entry] of pending()) {
6385
- try {
6386
- const currentSpec = specByKey.get(key);
6387
- const state = await provider.poll(entry.jobId, entry.generator, {
6388
- tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
6389
- spec: currentSpec
6390
- });
6391
- if (state.status === "review" || state.status === "review-set") {
6392
- upsert(lock, key, {
6393
- status: "review",
6394
- reviewObjectId: entry.jobId,
6395
- providerMetadata: state.metadata ? {
6396
- ...entry.providerMetadata,
6397
- [provider.id]: {
6398
- ...entry.providerMetadata[provider.id],
6399
- ...state.metadata
6400
- }
6401
- } : entry.providerMetadata
6402
- });
6403
- result.review++;
6404
- log2(
6405
- state.status === "review-set" ? ` review ${key} (${state.frameUrls.length} ordered frames)` : ` review ${key} (${state.candidateUrls.length} candidates)`
6406
- );
6407
- } else if (state.status === "ready") {
6408
- const sourceUrls = state.sources?.length ? state.sources : state.sourceUrl ? [{ url: state.sourceUrl }] : [];
6409
- upsert(lock, key, {
6410
- status: "selected",
6411
- objectId: state.objectId,
6412
- sourceUrl: sourceUrls[0]?.url ?? null,
6413
- sourceUrls,
6414
- providerMetadata: state.metadata ? {
6415
- ...entry.providerMetadata,
6416
- [provider.id]: {
6417
- ...entry.providerMetadata[provider.id],
6418
- ...state.metadata
6419
- }
6420
- } : entry.providerMetadata,
6421
- error: null
6422
- });
6423
- result.completed++;
6424
- log2(` ready ${key}`);
6425
- } else if (state.status === "failed") {
6426
- upsert(lock, key, { status: "failed", error: state.error });
6427
- result.failed++;
6428
- log2(` FAILED ${key}: ${state.error}`);
6429
- }
6430
- } catch (err) {
6431
- log2(` error polling ${key}: ${err instanceof Error ? err.message : String(err)}`);
6432
- }
6433
- }
6434
- await saveLock(lockPath, lock);
6435
- if (pending().length > 0) await sleep3(interval);
6436
- }
6437
- await saveLock(lockPath, lock);
6438
- return result;
6439
- }
6440
-
6441
- // src/pipeline/fetch.ts
6442
- import { existsSync as existsSync10, readFileSync as readFileSync4 } from "fs";
6443
- import { mkdir as mkdir3, readFile as readFile7, rename as rename3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6444
- import path14 from "path";
6445
-
6446
- // src/source-url.ts
6447
- var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
6448
- "access_token",
6449
- "apikey",
6450
- "api_key",
6451
- "auth",
6452
- "authorization",
6453
- "awsaccesskeyid",
6454
- "googleaccessid",
6455
- "key",
6456
- "sig"
6457
- ]);
6458
- function isSensitiveSourceUrl(value) {
6459
- let parsed;
6460
- try {
6461
- parsed = new URL(value);
6462
- } catch {
6463
- return false;
6464
- }
6465
- if (parsed.username || parsed.password) return true;
6466
- for (const key of parsed.searchParams.keys()) {
6467
- const normalized = key.toLowerCase();
6468
- if (SENSITIVE_QUERY_KEYS.has(normalized) || normalized.includes("credential") || normalized.includes("signature") || normalized.includes("security-token") || normalized.includes("token")) return true;
6469
- }
6470
- return false;
6471
- }
6472
- function shouldPersistSourceUrl(value) {
6473
- let parsed;
6474
- try {
6475
- parsed = new URL(value);
6476
- } catch {
6477
- return true;
6478
- }
6479
- if (parsed.protocol === "data:" || parsed.protocol === "file:") return false;
6480
- return !isSensitiveSourceUrl(value);
6481
- }
6482
-
6483
- // src/pipeline/fetch.ts
6484
- async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6264
+ // src/pipeline/fetch.ts
6265
+ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6485
6266
  const log2 = opts.onProgress ?? (() => {
6486
6267
  });
6487
6268
  const result = { downloaded: 0, skipped: 0, failed: 0, ...opts.refresh ? { unchanged: 0 } : {} };
@@ -6499,7 +6280,9 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6499
6280
  return e.outputs.some((output) => {
6500
6281
  const file = resolveOutputPath(output.path, spec.root);
6501
6282
  if (!existsSync10(file)) return true;
6502
- 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);
6503
6286
  });
6504
6287
  });
6505
6288
  const concurrency = Math.min(Math.max(1, opts.concurrency ?? 8), Math.max(1, pending.length));
@@ -6569,7 +6352,7 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6569
6352
  (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
6570
6353
  );
6571
6354
  if (!opts.force) {
6572
- if (!recorded && superseded && currentHash === superseded.sha256) {
6355
+ if (superseded && currentHash === superseded.sha256) {
6573
6356
  } else if (recorded || superseded) {
6574
6357
  throw new Error(
6575
6358
  `refusing to overwrite modified output ${target}; pass --force to replace it`
@@ -6643,79 +6426,476 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
6643
6426
  } catch (err) {
6644
6427
  const message6 = err instanceof Error ? err.message : String(err);
6645
6428
  upsert(lock, key, {
6646
- status: "download-failed",
6647
- outputs: mergeOutputs(entry.outputs, outputs),
6648
- 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
6649
6802
  });
6650
- result.failed++;
6651
- log2(` FAILED ${key}: ${message6}`);
6803
+ log2(` FAILED ${key}: ${message6}`);
6652
6804
  }
6653
- await saveLock(lockPath, lock);
6654
6805
  }
6806
+ await saveLock(lockPath, lock);
6655
6807
  }
6656
- await Promise.all(Array.from({ length: concurrency }, worker));
6657
- await saveLock(lockPath, lock);
6658
- return result;
6659
- }
6660
- async function readCachedMedia(cacheDir, hash, mediaType) {
6661
- const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6662
- if (!existsSync10(file)) return null;
6663
- try {
6664
- const buf = await readFile7(file);
6665
- if (sha256(buf) !== hash) return null;
6666
- validateMedia(buf, mediaType);
6667
- return buf;
6668
- } catch {
6669
- return null;
6670
- }
6671
- }
6672
- async function cacheMedia(cacheDir, buf, mediaType, knownHash) {
6673
- const hash = knownHash ?? sha256(buf);
6674
- validateMedia(buf, mediaType);
6675
- const file = path14.join(cacheDir, cacheFileName(hash, mediaType));
6676
- if (await readCachedMedia(cacheDir, hash, mediaType)) return;
6677
- await mkdir3(cacheDir, { recursive: true });
6678
- const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
6679
- await writeFile3(tmp, buf);
6680
- try {
6681
- await rename3(tmp, file);
6682
- } catch (err) {
6683
- if (!existsSync10(file)) throw err;
6684
- } finally {
6685
- await rm4(tmp, { force: true });
6686
- }
6687
- }
6688
- function mergeOutputs(previous, current) {
6689
- const merged = /* @__PURE__ */ new Map();
6690
- for (const output of [...previous, ...current]) {
6691
- merged.set(output.role ? `role:${output.role}` : `path:${output.path}`, output);
6692
- }
6693
- return [...merged.values()];
6808
+ return { submitted, failed, spent, unit };
6694
6809
  }
6695
- 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;
6696
6816
  const log2 = opts.onProgress ?? (() => {
6697
6817
  });
6698
- const specByKey = new Map(specs.map((s) => [lockKey(s.styleId, s.assetId), s]));
6699
- let tagged = 0;
6700
- for (const [key, entry] of Object.entries(lock.entries)) {
6701
- const spec = specByKey.get(key);
6702
- if (!spec || entry.provider !== provider.id || !entry.objectId) continue;
6703
- if (entry.generator === "pixflux") continue;
6704
- if (!provider.setTags) return tagged;
6705
- try {
6706
- await provider.setTags(entry.objectId, spec.tags);
6707
- tagged++;
6708
- } catch (err) {
6709
- 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
+ }
6710
6887
  }
6888
+ await saveLock(lockPath, lock);
6889
+ if (pending().length > 0) await sleep3(interval);
6711
6890
  }
6712
- return tagged;
6891
+ await saveLock(lockPath, lock);
6892
+ return result;
6713
6893
  }
6714
6894
 
6715
6895
  // src/pipeline/doctor.ts
6716
- import { constants, existsSync as existsSync11 } from "fs";
6896
+ import { constants, existsSync as existsSync12 } from "fs";
6717
6897
  import { access, stat as stat2 } from "fs/promises";
6718
- import path15 from "path";
6898
+ import path16 from "path";
6719
6899
  async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6720
6900
  const checks = [];
6721
6901
  const add = (id, level, message6) => checks.push({ id, level, message: message6 });
@@ -6726,8 +6906,8 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6726
6906
  );
6727
6907
  add("lock", "ok", `${Object.keys(lock.entries).length} valid v2 lock entr(ies)`);
6728
6908
  const dirs = [...new Set(specs.flatMap((spec) => [
6729
- path15.dirname(spec.outFile),
6730
- ...spec.quality ? [path15.dirname(spec.quality.outFile)] : []
6909
+ path16.dirname(spec.outFile),
6910
+ ...spec.quality ? [path16.dirname(spec.quality.outFile)] : []
6731
6911
  ]))];
6732
6912
  const unwritable = [];
6733
6913
  for (const dir of dirs) {
@@ -6750,7 +6930,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6750
6930
  for (let index = 0; index < entry.outputs.length; index++) {
6751
6931
  const output = entry.outputs[index];
6752
6932
  const spec = specByKey.get(key);
6753
- 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));
6754
6934
  const owner = outputOwners.get(absolute);
6755
6935
  if (owner && owner !== key) duplicateOutputs.push(`${output.path} (${owner}, ${key})`);
6756
6936
  outputOwners.set(absolute, key);
@@ -6765,7 +6945,7 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6765
6945
  } else {
6766
6946
  add("lock-outputs", "ok", `${outputOwners.size} recorded output path(s) are uniquely owned`);
6767
6947
  }
6768
- const cacheDir = path15.join(path15.dirname(lockPath), ".pixelkiln", "cache");
6948
+ const cacheDir = path16.join(path16.dirname(lockPath), ".pixelkiln", "cache");
6769
6949
  const stranded = [];
6770
6950
  const invalidState = [];
6771
6951
  const staleMapUrls = [];
@@ -6781,11 +6961,11 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
6781
6961
  const hasSource = Boolean(entry.sourceUrl || entry.sourceUrls?.length);
6782
6962
  let hasCache = false;
6783
6963
  for (const output of entry.outputs) {
6784
- const cached = path15.join(
6964
+ const cached = path16.join(
6785
6965
  cacheDir,
6786
6966
  cacheFileName(output.sha256, output.mediaType ?? MediaType.PNG)
6787
6967
  );
6788
- if (existsSync11(cached) && await sha256File(cached) === output.sha256) {
6968
+ if (existsSync12(cached) && await sha256File(cached) === output.sha256) {
6789
6969
  hasCache = true;
6790
6970
  break;
6791
6971
  }
@@ -6881,20 +7061,20 @@ async function nearestExistingDirectory(start) {
6881
7061
  if ((await stat2(current)).isDirectory()) return current;
6882
7062
  } catch {
6883
7063
  }
6884
- const parent = path15.dirname(current);
7064
+ const parent = path16.dirname(current);
6885
7065
  if (parent === current) return current;
6886
7066
  current = parent;
6887
7067
  }
6888
7068
  }
6889
7069
 
6890
7070
  // src/pipeline/adopt.ts
6891
- import { existsSync as existsSync13 } from "fs";
7071
+ import { existsSync as existsSync14 } from "fs";
6892
7072
  import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
6893
- import path16 from "path";
7073
+ import path17 from "path";
6894
7074
 
6895
7075
  // src/cache.ts
6896
7076
  import { mkdir as mkdir4, readFile as readFile8, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
6897
- import { existsSync as existsSync12 } from "fs";
7077
+ import { existsSync as existsSync13 } from "fs";
6898
7078
  import pathModule from "path";
6899
7079
  import { z as z3 } from "zod";
6900
7080
  var HashCacheSchema = z3.object({
@@ -6909,10 +7089,10 @@ function isSha256Hash(value) {
6909
7089
  function parseCache(value) {
6910
7090
  return HashCacheSchema.parse(value);
6911
7091
  }
6912
- async function loadCache(path31) {
6913
- if (!existsSync12(path31)) return { version: 1, hashes: {} };
7092
+ async function loadCache(path32) {
7093
+ if (!existsSync13(path32)) return { version: 1, hashes: {} };
6914
7094
  try {
6915
- const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path31, "utf8")));
7095
+ const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile8(path32, "utf8")));
6916
7096
  if (!parsed.success) return { version: 1, hashes: {} };
6917
7097
  return {
6918
7098
  version: 1,
@@ -6924,18 +7104,18 @@ async function loadCache(path31) {
6924
7104
  return { version: 1, hashes: {} };
6925
7105
  }
6926
7106
  }
6927
- async function saveCache(path31, cache) {
7107
+ async function saveCache(path32, cache) {
6928
7108
  const sorted = {};
6929
7109
  for (const key of Object.keys(cache.hashes).sort()) {
6930
7110
  const hash = cache.hashes[key];
6931
7111
  if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
6932
7112
  sorted[key] = hash;
6933
7113
  }
6934
- await mkdir4(pathModule.dirname(pathModule.resolve(path31)), { recursive: true });
6935
- 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`;
6936
7116
  try {
6937
7117
  await writeFile4(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
6938
- await rename4(tmp, path31);
7118
+ await rename4(tmp, path32);
6939
7119
  } finally {
6940
7120
  await rm5(tmp, { force: true });
6941
7121
  }
@@ -6966,7 +7146,7 @@ async function adopt(provider, specs, lock, lockPath, opts = {}) {
6966
7146
  const localByHash = /* @__PURE__ */ new Map();
6967
7147
  const unmatchedLocal = [];
6968
7148
  for (const spec of specs) {
6969
- if (!existsSync13(spec.outFile)) {
7149
+ if (!existsSync14(spec.outFile)) {
6970
7150
  unmatchedLocal.push(`${spec.styleId}/${spec.assetId} (no file at ${spec.outFile})`);
6971
7151
  continue;
6972
7152
  }
@@ -7123,13 +7303,13 @@ function formatUnmatchedRemote(objects, limit = 15) {
7123
7303
  }
7124
7304
 
7125
7305
  // src/pick/server.ts
7126
- import { existsSync as existsSync15 } from "fs";
7306
+ import { existsSync as existsSync16 } from "fs";
7127
7307
  import { readFile as readFile12 } from "fs/promises";
7128
7308
 
7129
7309
  // src/pipeline/init.ts
7130
7310
  import { readdir, readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
7131
- import { existsSync as existsSync14 } from "fs";
7132
- import path17 from "path";
7311
+ import { existsSync as existsSync15 } from "fs";
7312
+ import path18 from "path";
7133
7313
  function pngSize(buf) {
7134
7314
  if (buf.length < 24) return null;
7135
7315
  const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
@@ -7143,7 +7323,7 @@ function slugify(value) {
7143
7323
  async function walk(dir, exclude) {
7144
7324
  const out = [];
7145
7325
  for (const entry of await readdir(dir, { withFileTypes: true })) {
7146
- const full = path17.join(dir, entry.name);
7326
+ const full = path18.join(dir, entry.name);
7147
7327
  if (exclude.some((x) => full.includes(x))) continue;
7148
7328
  if (entry.isDirectory()) out.push(...await walk(full, exclude));
7149
7329
  else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
@@ -7162,9 +7342,9 @@ async function scanAssets(root, opts = {}) {
7162
7342
  skipped.push(`${file} (not a readable PNG)`);
7163
7343
  continue;
7164
7344
  }
7165
- const rel = path17.relative(root, file);
7166
- const category = path17.dirname(rel) === "." ? "" : path17.dirname(rel);
7167
- 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));
7168
7348
  if (seen.has(id)) {
7169
7349
  const n = seen.get(id) + 1;
7170
7350
  seen.set(id, n);
@@ -7221,7 +7401,7 @@ function mode(values) {
7221
7401
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
7222
7402
  }
7223
7403
  async function writeManifestFile(target, manifest) {
7224
- if (existsSync14(target)) {
7404
+ if (existsSync15(target)) {
7225
7405
  throw new Error(`${target} already exists \u2014 refusing to overwrite an existing manifest.`);
7226
7406
  }
7227
7407
  await writeFile6(target, JSON.stringify(manifest, null, 2) + "\n");
@@ -7707,7 +7887,7 @@ async function currentArt(entry, spec) {
7707
7887
  const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
7708
7888
  if (!previous) return null;
7709
7889
  const file = resolveOutputPath(previous.path, spec.root);
7710
- if (!existsSync15(file)) return null;
7890
+ if (!existsSync16(file)) return null;
7711
7891
  const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
7712
7892
  if (!contentType) return null;
7713
7893
  let width = entry.width;
@@ -7878,14 +8058,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
7878
8058
 
7879
8059
  // src/gallery/snapshot.ts
7880
8060
  import { createHash as createHash3 } from "crypto";
7881
- import { existsSync as existsSync18 } from "fs";
8061
+ import { existsSync as existsSync19 } from "fs";
7882
8062
  import { readFile as readFile16, stat as stat3 } from "fs/promises";
7883
- import path22 from "path";
8063
+ import path23 from "path";
7884
8064
 
7885
8065
  // src/workspace.ts
7886
8066
  import { mkdir as mkdir5, readFile as readFile13, rename as rename5, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7887
- import { existsSync as existsSync16 } from "fs";
7888
- import path18 from "path";
8067
+ import { existsSync as existsSync17 } from "fs";
8068
+ import path19 from "path";
7889
8069
  import { z as z4 } from "zod";
7890
8070
  var WorkspaceProjectSchema = z4.object({
7891
8071
  id: z4.string().min(1),
@@ -7910,7 +8090,7 @@ ${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message
7910
8090
  );
7911
8091
  }
7912
8092
  async function loadWorkspace(workspacePath) {
7913
- if (!existsSync16(workspacePath)) return { version: 1, projects: [] };
8093
+ if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
7914
8094
  let raw;
7915
8095
  try {
7916
8096
  raw = JSON.parse(await readFile13(workspacePath, "utf8"));
@@ -7927,7 +8107,7 @@ async function saveWorkspace(workspacePath, ws) {
7927
8107
  version: 1,
7928
8108
  projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
7929
8109
  };
7930
- await mkdir5(path18.dirname(path18.resolve(workspacePath)), { recursive: true });
8110
+ await mkdir5(path19.dirname(path19.resolve(workspacePath)), { recursive: true });
7931
8111
  const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7932
8112
  try {
7933
8113
  await writeFile7(tmp, JSON.stringify(sorted, null, 2) + "\n");
@@ -7937,12 +8117,12 @@ async function saveWorkspace(workspacePath, ws) {
7937
8117
  }
7938
8118
  }
7939
8119
  function toPortablePath(dir, absolute) {
7940
- return path18.relative(dir, absolute).split(path18.sep).join("/");
8120
+ return path19.relative(dir, absolute).split(path19.sep).join("/");
7941
8121
  }
7942
8122
  function resolveProject(dir, project) {
7943
8123
  return {
7944
- manifestPath: path18.resolve(dir, project.manifest.split("/").join(path18.sep)),
7945
- 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))
7946
8126
  };
7947
8127
  }
7948
8128
  function validateWorkspace(ws, dir) {
@@ -7955,21 +8135,21 @@ function validateWorkspace(ws, dir) {
7955
8135
  const { manifestPath, lockPath } = resolveProject(dir, project);
7956
8136
  lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
7957
8137
  manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
7958
- if (path18.isAbsolute(project.manifest) || path18.isAbsolute(project.lock)) {
8138
+ if (path19.isAbsolute(project.manifest) || path19.isAbsolute(project.lock)) {
7959
8139
  diagnostics.push({
7960
8140
  id: "absolute-path",
7961
8141
  level: "warning",
7962
8142
  message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
7963
8143
  });
7964
8144
  }
7965
- if (!existsSync16(manifestPath)) {
8145
+ if (!existsSync17(manifestPath)) {
7966
8146
  diagnostics.push({
7967
8147
  id: "missing-manifest",
7968
8148
  level: "error",
7969
8149
  message: `project "${project.id}" manifest not found: ${manifestPath}`
7970
8150
  });
7971
8151
  }
7972
- if (!existsSync16(lockPath)) {
8152
+ if (!existsSync17(lockPath)) {
7973
8153
  diagnostics.push({
7974
8154
  id: "missing-lock",
7975
8155
  level: "error",
@@ -8016,12 +8196,12 @@ function validateWorkspace(ws, dir) {
8016
8196
  }
8017
8197
 
8018
8198
  // src/gallery/edit.ts
8019
- import path21 from "path";
8199
+ import path22 from "path";
8020
8200
  import { z as z7 } from "zod";
8021
8201
 
8022
8202
  // src/manifest-edit.ts
8023
8203
  import { readFile as readFile14, rename as rename6, rm as rm7, writeFile as writeFile8 } from "fs/promises";
8024
- import path19 from "path";
8204
+ import path20 from "path";
8025
8205
  import { z as z5 } from "zod";
8026
8206
  var HexSha = z5.string().regex(/^[0-9a-f]{64}$/);
8027
8207
  var AssetPatchSchema = z5.object({
@@ -8233,7 +8413,7 @@ function applyEdit(raw, edit) {
8233
8413
  }
8234
8414
  }
8235
8415
  async function applyManifestEdit(manifestPath, edit) {
8236
- const absolute = path19.resolve(manifestPath);
8416
+ const absolute = path20.resolve(manifestPath);
8237
8417
  const original = await readFile14(absolute, "utf8");
8238
8418
  const before = sha256(original);
8239
8419
  if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
@@ -8248,9 +8428,9 @@ async function applyManifestEdit(manifestPath, edit) {
8248
8428
  applyEdit(raw, edit);
8249
8429
  const next = serializeLike(original, raw);
8250
8430
  if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
8251
- const tmp = path19.join(
8252
- path19.dirname(absolute),
8253
- `.${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`
8254
8434
  );
8255
8435
  try {
8256
8436
  await writeFile8(tmp, next);
@@ -8270,18 +8450,18 @@ async function applyManifestEdit(manifestPath, edit) {
8270
8450
 
8271
8451
  // src/pipeline/hand-edit.ts
8272
8452
  import { spawn as spawn2 } from "child_process";
8273
- import { existsSync as existsSync17 } from "fs";
8453
+ import { existsSync as existsSync18 } from "fs";
8274
8454
  import { copyFile, mkdir as mkdir6, readFile as readFile15, rename as rename7, rm as rm8, writeFile as writeFile9 } from "fs/promises";
8275
- import path20 from "path";
8455
+ import path21 from "path";
8276
8456
  import { z as z6 } from "zod";
8277
8457
  var HAND_EDIT_DIR = "edits";
8278
8458
  function handEditPath(loaded, spec) {
8279
8459
  const style = loaded.manifest.styles[spec.styleId];
8280
- const outDir = path20.resolve(loaded.root, style?.outDir ?? ".");
8281
- const relative = path20.relative(outDir, spec.outFile);
8282
- const inside = relative && !relative.startsWith("..") && !path20.isAbsolute(relative);
8283
- const file = inside ? relative : path20.basename(spec.outFile);
8284
- 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"));
8285
8465
  }
8286
8466
  function handEditBases(spec, lock) {
8287
8467
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -8294,10 +8474,10 @@ function handEditBases(spec, lock) {
8294
8474
  }));
8295
8475
  if (members.some((member) => member.mediaType && member.mediaType !== "image/png")) return null;
8296
8476
  if (entry.outputs.length === 1 && !primaryOutput(entry)) return null;
8297
- if (!members.every((member) => existsSync17(member.path))) return null;
8477
+ if (!members.every((member) => existsSync18(member.path))) return null;
8298
8478
  return members.map(({ role, path: file }) => ({ role, path: file }));
8299
8479
  }
8300
- return existsSync17(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8480
+ return existsSync18(spec.outFile) ? [{ role: null, path: spec.outFile }] : null;
8301
8481
  }
8302
8482
  function handEditMembers(loaded, spec, lock, editPath) {
8303
8483
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
@@ -8323,18 +8503,18 @@ async function startHandEdit(loaded, lock, spec, opts = {}) {
8323
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`
8324
8504
  );
8325
8505
  }
8326
- const existing = spec.source ? path20.resolve(loaded.root, spec.source) : null;
8506
+ const existing = spec.source ? path21.resolve(loaded.root, spec.source) : null;
8327
8507
  const editPath = existing ?? handEditPath(loaded, spec);
8328
8508
  const members = handEditMembers(loaded, spec, lock, editPath);
8329
8509
  let created = false;
8330
8510
  for (const member of members) {
8331
- if (existsSync17(member.path)) continue;
8511
+ if (existsSync18(member.path)) continue;
8332
8512
  if (!member.base) {
8333
8513
  throw new ManifestEditError(
8334
8514
  `${spec.styleId}/${spec.assetId} has no generated PNG on disk to start from; generate or restore it first`
8335
8515
  );
8336
8516
  }
8337
- await mkdir6(path20.dirname(member.path), { recursive: true });
8517
+ await mkdir6(path21.dirname(member.path), { recursive: true });
8338
8518
  await copyFile(member.base, member.path);
8339
8519
  created = true;
8340
8520
  }
@@ -8472,7 +8652,7 @@ async function saveHandEdit(loaded, lock, spec, input) {
8472
8652
  editor: input.editor,
8473
8653
  protocol: input.protocol,
8474
8654
  savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
8475
- project: projectPath ? path20.basename(projectPath) : null,
8655
+ project: projectPath ? path21.basename(projectPath) : null,
8476
8656
  outputs: incoming.map((frame, index) => ({
8477
8657
  role: frame.role,
8478
8658
  basedOn: basedOnHash(entry, index),
@@ -8607,7 +8787,7 @@ function createGalleryEditHandler(opts) {
8607
8787
  }
8608
8788
  const result = await applyManifestEdit(manifestPath, parsed.data);
8609
8789
  if (result.changed) {
8610
- const relative = path21.relative(process.cwd(), result.manifestPath);
8790
+ const relative = path22.relative(process.cwd(), result.manifestPath);
8611
8791
  const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
8612
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;
8613
8793
  log2(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
@@ -8619,7 +8799,7 @@ function createGalleryEditHandler(opts) {
8619
8799
  // src/gallery/snapshot.ts
8620
8800
  var PROJECT_FILE_TYPE = "application/octet-stream";
8621
8801
  function galleryMediaId(absolutePath) {
8622
- 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);
8623
8803
  }
8624
8804
  function galleryMediaRoute(id) {
8625
8805
  return `/media/${id}`;
@@ -8634,6 +8814,38 @@ function matchesFilter(filter, styleId, assetId) {
8634
8814
  if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
8635
8815
  return true;
8636
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
+ }
8637
8849
  var MAX_COMPARED_BYTES = 8 * 1024 * 1024;
8638
8850
  async function samePixels(a, b) {
8639
8851
  try {
@@ -8690,10 +8902,10 @@ async function describeOutput(media, root, absolutePath, recorded = {}) {
8690
8902
  async function describeQuality(media, root, inspection) {
8691
8903
  const outputPaths = inspection.outputs ?? [inspection.output];
8692
8904
  const outputs = await Promise.all(
8693
- outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
8905
+ outputPaths.map((file) => describeOutput(media, root, path23.resolve(root, file)))
8694
8906
  );
8695
- const recordPath = path22.resolve(root, inspection.record);
8696
- const recordExists = existsSync18(recordPath);
8907
+ const recordPath = path23.resolve(root, inspection.record);
8908
+ const recordExists = existsSync19(recordPath);
8697
8909
  let options = null;
8698
8910
  let check = null;
8699
8911
  if (recordExists) {
@@ -8712,7 +8924,7 @@ async function describeQuality(media, root, inspection) {
8712
8924
  return {
8713
8925
  state: inspection.state,
8714
8926
  reason: inspection.reason,
8715
- output: portableOutputPath(path22.resolve(root, inspection.output), root),
8927
+ output: portableOutputPath(path23.resolve(root, inspection.output), root),
8716
8928
  record: portableOutputPath(recordPath, root),
8717
8929
  recordExists,
8718
8930
  outputs,
@@ -8751,6 +8963,7 @@ async function buildGallerySnapshot(opts) {
8751
8963
  const items = [];
8752
8964
  const manifestText = await readFile16(loaded.path, "utf8");
8753
8965
  const manifestSha256 = sha256(manifestText);
8966
+ const cacheDir = path23.resolve(path23.dirname(opts.lockPath), ".pixelkiln", "cache");
8754
8967
  let rawStyles = {};
8755
8968
  try {
8756
8969
  const raw = JSON.parse(manifestText);
@@ -8774,12 +8987,12 @@ async function buildGallerySnapshot(opts) {
8774
8987
  )
8775
8988
  );
8776
8989
  if (spec.source) {
8777
- const editPath = path22.resolve(root, spec.source);
8990
+ const editPath = path23.resolve(root, spec.source);
8778
8991
  const memberCount = sourceIsStem(spec.source, entry, root) ? entry.outputs.length : 1;
8779
8992
  const editShas = await Promise.all(
8780
8993
  Array.from({ length: memberCount }, async (_, index) => {
8781
8994
  const file = sourceOutputPath(spec.source, entry, index, root);
8782
- return { file, sha256: existsSync18(file) ? await sha256File(file) : null };
8995
+ return { file, sha256: existsSync19(file) ? await sha256File(file) : null };
8783
8996
  })
8784
8997
  );
8785
8998
  edits = await Promise.all(editShas.map(({ file, sha256: editSha }) => describeOutput(media, root, file, { sha256: editSha })));
@@ -8787,7 +9000,7 @@ async function buildGallerySnapshot(opts) {
8787
9000
  const companion = await readHandEditCompanion(editPath);
8788
9001
  if (companion) {
8789
9002
  const projectPath = handEditProjectPath(editPath);
8790
- const project = companion.project && existsSync18(projectPath) ? await fileInfo(projectPath) : null;
9003
+ const project = companion.project && existsSync19(projectPath) ? await fileInfo(projectPath) : null;
8791
9004
  let projectUrl = null;
8792
9005
  if (project) {
8793
9006
  const id = galleryMediaId(projectPath);
@@ -8823,8 +9036,8 @@ async function buildGallerySnapshot(opts) {
8823
9036
  editStatus = edits.some((member) => !member.exists) ? "missing" : editChanged.every((changed) => !changed) ? "same" : regenerated ? "regenerated-since" : "edited";
8824
9037
  }
8825
9038
  } else if (spec.source) {
8826
- outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
8827
- } else if (!entry && existsSync18(spec.outFile)) {
9039
+ outputs = [await describeOutput(media, root, path23.resolve(root, spec.source))];
9040
+ } else if (!entry && existsSync19(spec.outFile)) {
8828
9041
  outputs = [await describeOutput(media, root, spec.outFile)];
8829
9042
  } else {
8830
9043
  outputs = [];
@@ -8875,6 +9088,7 @@ async function buildGallerySnapshot(opts) {
8875
9088
  editMeta,
8876
9089
  upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8877
9090
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
9091
+ history: entry ? describeHistory(media, entry, cacheDir) : [],
8878
9092
  tags: spec.tags,
8879
9093
  category: asset?.category ?? null
8880
9094
  });
@@ -8934,6 +9148,7 @@ async function buildGallerySnapshot(opts) {
8934
9148
  editMeta: null,
8935
9149
  upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
8936
9150
  refreshable: false,
9151
+ history: describeHistory(media, entry, cacheDir),
8937
9152
  tags: [],
8938
9153
  category: null
8939
9154
  });
@@ -9001,11 +9216,12 @@ async function buildGallerySnapshot(opts) {
9001
9216
  id: loaded.manifest.name,
9002
9217
  name: loaded.manifest.name,
9003
9218
  manifest: loaded.path,
9004
- lock: path22.resolve(opts.lockPath),
9219
+ lock: path23.resolve(opts.lockPath),
9005
9220
  root,
9006
9221
  provider: loaded.manifest.provider,
9007
9222
  account: null,
9008
9223
  manifestSha256,
9224
+ historyLimit: historyLimit(loaded.manifest),
9009
9225
  entries: Object.keys(lock.entries).length,
9010
9226
  items: items.length,
9011
9227
  spendByUnit: spendByUnit(lock),
@@ -9026,7 +9242,7 @@ async function buildGallerySnapshot(opts) {
9026
9242
  return { snapshot, media };
9027
9243
  }
9028
9244
  async function buildWorkspaceGallerySnapshot(opts) {
9029
- const dir = path22.dirname(path22.resolve(opts.workspacePath));
9245
+ const dir = path23.dirname(path23.resolve(opts.workspacePath));
9030
9246
  const media = /* @__PURE__ */ new Map();
9031
9247
  const projects = [];
9032
9248
  const items = [];
@@ -9069,11 +9285,12 @@ async function buildWorkspaceGallerySnapshot(opts) {
9069
9285
  id: project.id,
9070
9286
  name: loaded.manifest.name,
9071
9287
  manifest: manifestPath,
9072
- lock: path22.resolve(lockPath),
9288
+ lock: path23.resolve(lockPath),
9073
9289
  root: loaded.root,
9074
9290
  provider: loaded.manifest.provider,
9075
9291
  account: project.account ?? null,
9076
9292
  manifestSha256: manifestSha256 ?? sha256(await readFile16(manifestPath, "utf8")),
9293
+ historyLimit: historyLimit(loaded.manifest),
9077
9294
  entries: Object.keys(lock.entries).length,
9078
9295
  items: projectItems.length,
9079
9296
  spendByUnit: spendByUnit(lock),
@@ -9084,11 +9301,12 @@ async function buildWorkspaceGallerySnapshot(opts) {
9084
9301
  id: project.id,
9085
9302
  name: project.id,
9086
9303
  manifest: manifestPath,
9087
- lock: path22.resolve(lockPath),
9088
- root: path22.dirname(manifestPath),
9304
+ lock: path23.resolve(lockPath),
9305
+ root: path23.dirname(manifestPath),
9089
9306
  provider: project.provider,
9090
9307
  account: project.account ?? null,
9091
9308
  manifestSha256: null,
9309
+ historyLimit: 0,
9092
9310
  entries: 0,
9093
9311
  items: 0,
9094
9312
  spendByUnit: {},
@@ -9111,7 +9329,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
9111
9329
  version: 1,
9112
9330
  generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
9113
9331
  project: null,
9114
- workspace: { path: path22.resolve(opts.workspacePath), dir, projects },
9332
+ workspace: { path: path23.resolve(opts.workspacePath), dir, projects },
9115
9333
  filter,
9116
9334
  totals: {
9117
9335
  entries: projects.reduce((sum, project) => sum + project.entries, 0),
@@ -9432,6 +9650,19 @@ function renderGallery(snapshot, opts = {}) {
9432
9650
  background-size:12px 12px; background-position:0 0,6px 6px; display:grid; place-items:center; padding:8px; min-height:96px; }
9433
9651
  .pair figure img { image-rendering:pixelated; display:block; max-width:100%; }
9434
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; }
9435
9666
  .dot.busy { background:var(--cool); animation: pulse 1.2s ease-in-out infinite; }
9436
9667
  @keyframes pulse { 0%, 100% { opacity:1; } 50% { opacity:.25; } }
9437
9668
  @media (prefers-reduced-motion: reduce) { .dot.busy { animation:none; } }
@@ -9605,7 +9836,7 @@ const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetchi
9605
9836
  // The job still working on this record, if any: what the drawer and its card
9606
9837
  // show while a regeneration, restore, or pull is in flight.
9607
9838
  const activeJobFor = (item) => GEN.jobs.find((job) => ACTIVE_PHASES.has(job.phase) && job.project === item.project && job.keys.includes(item.key)) || null;
9608
- const JOB_VERB = { generate: 'regenerating', resume: 'resuming', refresh: 'pulling upstream changes for', restore: 'restoring' };
9839
+ const JOB_VERB = { generate: 'regenerating', resume: 'resuming', refresh: 'pulling upstream changes for', restore: 'restoring', revert: 'bringing back a previous generation of' };
9609
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' };
9610
9841
  const remainingBudget = (provider) => {
9611
9842
  const keyed = GEN.budget.byProvider[provider];
@@ -9645,6 +9876,7 @@ async function pollJobs() {
9645
9876
  ? (JOB_VERB[job.mode] || job.mode).replace(/ing$/, 'ing') + ' this asset failed: ' + (job.error || 'see the log above')
9646
9877
  : job.mode === 'generate' ? 'Regenerated. The new result is on disk and recorded as this generation.'
9647
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.'
9648
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.')
9649
9881
  : 'Done.' };
9650
9882
  }
@@ -9676,7 +9908,7 @@ function renderJobs() {
9676
9908
  const row = el('div', 'job');
9677
9909
  const ph = el('span', 'ph');
9678
9910
  ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
9679
- 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') +
9680
9912
  (job.project ? ' in ' + job.project : '');
9681
9913
  const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
9682
9914
  last.title = job.keys.join('\\n');
@@ -9801,15 +10033,16 @@ function budgetLine() {
9801
10033
 
9802
10034
  // The confirm step: what will be sent, what it is estimated to cost, and what
9803
10035
  // this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
9804
- 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 } = {}) {
9805
10037
  const host = $('dialog-host');
9806
10038
  host.textContent = '';
9807
10039
  const wrap = el('div', 'dialog');
9808
10040
  const form = el('form');
9809
10041
  const noun = items.length + (items.length === 1 ? ' asset' : ' assets');
9810
- const free = resume || refresh || restore;
10042
+ const free = resume || refresh || restore || revert !== null;
9811
10043
  const replacing = items.filter((i) => i.state === 'orphaned' && i.outputs.every((o) => o.exists) || i.state === 'untracked');
9812
10044
  form.append(el('h3', null, refresh ? 'Pull upstream changes for ' + noun
10045
+ : revert !== null ? 'Bring back generation #' + revert + ' of ' + items[0].key
9813
10046
  : restore ? 'Restore ' + noun
9814
10047
  : resume ? 'Resume ' + noun
9815
10048
  : (force ? 'Regenerate ' : 'Generate ') + noun));
@@ -9831,6 +10064,10 @@ function generateDialog(items, { project = null, force = false, resume = false,
9831
10064
  const sum = el('div', 'sum');
9832
10065
  if (refresh) {
9833
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.'));
9834
10071
  } else if (restore) {
9835
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.'));
9836
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.'));
@@ -9852,7 +10089,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
9852
10089
  }
9853
10090
  form.append(sum);
9854
10091
  const actions = el('div', 'actions');
9855
- 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');
9856
10093
  go.type = 'submit';
9857
10094
  const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
9858
10095
  const msg = el('span', 'msg');
@@ -9868,6 +10105,7 @@ function generateDialog(items, { project = null, force = false, resume = false,
9868
10105
  if (resume) body.resume = true;
9869
10106
  if (refresh) body.refresh = true;
9870
10107
  if (restore) body.restore = true;
10108
+ if (revert !== null) body.revert = revert;
9871
10109
  const job = await postGenerate(body);
9872
10110
  host.textContent = '';
9873
10111
  GEN.jobs.unshift(job);
@@ -10498,6 +10736,54 @@ function candidatesControl(style) {
10498
10736
  return wrap;
10499
10737
  }
10500
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
+
10501
10787
  function upstreamSection(item) {
10502
10788
  if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
10503
10789
  const s = el('section', 'meta');
@@ -11322,6 +11608,8 @@ function renderDrawer() {
11322
11608
  if (hand) body.append(hand);
11323
11609
  const upstream = upstreamSection(item);
11324
11610
  if (upstream) body.append(upstream);
11611
+ const versions = historySection(item);
11612
+ if (versions) body.append(versions);
11325
11613
 
11326
11614
  if (item.revision || item.revisionParentKey) {
11327
11615
  const { s, dl } = section('Lineage');
@@ -11868,10 +12156,15 @@ var GenerateRequestSchema = z8.object({
11868
12156
  /** Re-download downloaded outputs and replace files whose object changed upstream. */
11869
12157
  refresh: z8.boolean().optional(),
11870
12158
  /** Put recorded outputs back on disk from the cache or the provider, without generating. */
11871
- restore: z8.boolean().optional()
11872
- }).strict().refine((request) => [request.resume, request.refresh, request.restore].filter(Boolean).length <= 1, {
11873
- message: "resume, refresh, and restore are exclusive"
11874
- });
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" });
11875
12168
  var GenerateRequestError = class extends Error {
11876
12169
  constructor(message6, status = 400) {
11877
12170
  super(message6);
@@ -12002,6 +12295,22 @@ function createGenerateHandlers(opts) {
12002
12295
  await restore(job, ctx, specs);
12003
12296
  return;
12004
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
+ }
12005
12314
  if (job.mode === "generate") {
12006
12315
  job.phase = "submitting";
12007
12316
  for (const group of groups) {
@@ -12072,8 +12381,13 @@ function createGenerateHandlers(opts) {
12072
12381
  }
12073
12382
  }
12074
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
+ }
12075
12389
  let groups = [];
12076
- if (!request.resume && !request.refresh && !request.restore) {
12390
+ if (!request.resume && !request.refresh && !request.restore && request.revert === void 0) {
12077
12391
  const plan = await buildPlan(specs, ctx.lock, { force: request.force });
12078
12392
  if (!plan.actionable.length) {
12079
12393
  const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
@@ -12093,8 +12407,9 @@ function createGenerateHandlers(opts) {
12093
12407
  id: randomBytes2(8).toString("hex"),
12094
12408
  project: request.project ?? null,
12095
12409
  keys: request.keys,
12096
- 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",
12097
12411
  force: Boolean(request.force),
12412
+ revert: request.revert ?? null,
12098
12413
  phase: "queued",
12099
12414
  startedAt: now().toISOString(),
12100
12415
  finishedAt: null,
@@ -12106,7 +12421,7 @@ function createGenerateHandlers(opts) {
12106
12421
  };
12107
12422
  jobs.set(job.id, job);
12108
12423
  contexts.set(job.id, ctx);
12109
- 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("; "));
12110
12425
  void run(job, ctx, specs, groups);
12111
12426
  return job;
12112
12427
  },
@@ -12157,14 +12472,14 @@ function createGenerateHandlers(opts) {
12157
12472
 
12158
12473
  // src/gallery/editor.ts
12159
12474
  import { readFile as readFile19 } from "fs/promises";
12160
- import path24 from "path";
12475
+ import path25 from "path";
12161
12476
 
12162
12477
  // src/editor/install.ts
12163
12478
  import { createHash as createHash4 } from "crypto";
12164
- import { existsSync as existsSync19 } from "fs";
12479
+ import { existsSync as existsSync20 } from "fs";
12165
12480
  import { mkdir as mkdir7, readFile as readFile18, rename as rename8, rm as rm9, stat as stat4, writeFile as writeFile10 } from "fs/promises";
12166
12481
  import os2 from "os";
12167
- import path23 from "path";
12482
+ import path24 from "path";
12168
12483
 
12169
12484
  // tools/pixelorama-bridge/pin.json
12170
12485
  var pin_default = {
@@ -12228,15 +12543,15 @@ var EditorInstallError = class extends Error {
12228
12543
  };
12229
12544
  function editorToolsRoot() {
12230
12545
  const override = process.env.PIXELKILN_TOOLS_DIR?.trim();
12231
- if (override) return path23.resolve(override);
12232
- 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");
12233
12548
  if (process.platform === "win32") {
12234
- 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");
12235
12550
  }
12236
- 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");
12237
12552
  }
12238
12553
  function editorDir(pin = EDITOR_PIN, root = editorToolsRoot()) {
12239
- return path23.join(root, "pixelorama", pin.release ?? "unpublished");
12554
+ return path24.join(root, "pixelorama", pin.release ?? "unpublished");
12240
12555
  }
12241
12556
  function editorBaseUrl(pin = EDITOR_PIN) {
12242
12557
  const override = process.env.PIXELKILN_EDITOR_URL?.trim();
@@ -12259,8 +12574,8 @@ async function editorStatus(opts = {}) {
12259
12574
  let totalBytes = 0;
12260
12575
  for (const [name, expected] of Object.entries(pin.files)) {
12261
12576
  totalBytes += expected.bytes;
12262
- const file = path23.join(dir, name);
12263
- 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;
12264
12579
  if (info && info.size === expected.bytes && await sha256Of(file) === expected.sha256) {
12265
12580
  installedBytes += expected.bytes;
12266
12581
  } else {
@@ -12317,7 +12632,7 @@ async function installEditor(opts = {}) {
12317
12632
  if (digest2 !== expected.sha256) {
12318
12633
  throw new EditorInstallError(`${name}: sha256 ${digest2.slice(0, 12)}\u2026 does not match the pinned ${expected.sha256.slice(0, 12)}\u2026`, name);
12319
12634
  }
12320
- const final = path23.join(dir, name);
12635
+ const final = path24.join(dir, name);
12321
12636
  const tmp = `${final}.${process.pid}.part`;
12322
12637
  try {
12323
12638
  await writeFile10(tmp, bytes);
@@ -12363,7 +12678,7 @@ var EDITOR_CONTENT_TYPES = {
12363
12678
  ".json": "application/json; charset=utf-8"
12364
12679
  };
12365
12680
  function editorContentType(name) {
12366
- return EDITOR_CONTENT_TYPES[path23.extname(name).toLowerCase()] ?? null;
12681
+ return EDITOR_CONTENT_TYPES[path24.extname(name).toLowerCase()] ?? null;
12367
12682
  }
12368
12683
 
12369
12684
  // src/gallery/editor.ts
@@ -12439,7 +12754,7 @@ function createGalleryEditorHandlers(opts = {}) {
12439
12754
  if (!status.installed) return null;
12440
12755
  let bytes;
12441
12756
  try {
12442
- bytes = await readFile19(path24.join(dir, name));
12757
+ bytes = await readFile19(path25.join(dir, name));
12443
12758
  } catch {
12444
12759
  verified = null;
12445
12760
  return null;
@@ -12455,15 +12770,15 @@ function createGalleryEditorHandlers(opts = {}) {
12455
12770
 
12456
12771
  // src/pipeline/salvage.ts
12457
12772
  import { readFile as readFile20 } from "fs/promises";
12458
- import { existsSync as existsSync20 } from "fs";
12459
- import path25 from "path";
12773
+ import { existsSync as existsSync21 } from "fs";
12774
+ import path26 from "path";
12460
12775
  function providerClaimId(provider, objectId) {
12461
12776
  return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
12462
12777
  }
12463
12778
  async function loadClaims(lockPaths, opts = {}) {
12464
12779
  const claimed = /* @__PURE__ */ new Set();
12465
12780
  for (const p of lockPaths) {
12466
- if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
12781
+ if (!existsSync21(p)) throw new Error(`Claim lockfile not found: ${p}`);
12467
12782
  let parsed;
12468
12783
  try {
12469
12784
  parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
@@ -12514,20 +12829,20 @@ function matchStyleByPattern(prompt, manifest) {
12514
12829
  return null;
12515
12830
  }
12516
12831
  async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
12517
- const own = path25.resolve(ownManifestPath);
12832
+ const own = path26.resolve(ownManifestPath);
12518
12833
  const siblingManifestPaths = [
12519
12834
  .../* @__PURE__ */ new Set([
12520
12835
  ...workspaceManifestPaths,
12521
- ...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"))
12522
12837
  ])
12523
12838
  ];
12524
12839
  const siblings = [];
12525
12840
  for (const siblingManifestPath of siblingManifestPaths) {
12526
- if (path25.resolve(siblingManifestPath) === own) continue;
12527
- if (!existsSync20(siblingManifestPath)) continue;
12841
+ if (path26.resolve(siblingManifestPath) === own) continue;
12842
+ if (!existsSync21(siblingManifestPath)) continue;
12528
12843
  try {
12529
12844
  const { manifest } = await loadManifest(siblingManifestPath);
12530
- siblings.push({ label: path25.basename(path25.dirname(siblingManifestPath)), manifest });
12845
+ siblings.push({ label: path26.basename(path26.dirname(siblingManifestPath)), manifest });
12531
12846
  } catch {
12532
12847
  }
12533
12848
  }
@@ -12746,18 +13061,16 @@ async function workspaceStatus(ws, dir) {
12746
13061
  }
12747
13062
 
12748
13063
  // src/pipeline/cache-health.ts
12749
- import { existsSync as existsSync21 } from "fs";
13064
+ import { existsSync as existsSync22 } from "fs";
12750
13065
  import { readFile as readFile21, readdir as readdir2, rm as rm10 } from "fs/promises";
12751
- import path26 from "path";
13066
+ import path27 from "path";
12752
13067
  async function inspectCaches(lock, lockPath, options = {}) {
12753
- if (options.prune && !existsSync21(lockPath)) {
12754
- 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)}`);
12755
13070
  }
12756
- const contentDir = path26.resolve(path26.dirname(lockPath), ".pixelkiln", "cache");
12757
- const remotePath = path26.resolve(cachePathFor(lockPath));
12758
- const referenced = new Set(
12759
- Object.values(lock.entries).flatMap((entry) => entry.outputs.map((output) => output.sha256))
12760
- );
13071
+ const contentDir = path27.resolve(path27.dirname(lockPath), ".pixelkiln", "cache");
13072
+ const remotePath = path27.resolve(cachePathFor(lockPath));
13073
+ const referenced = referencedHashes(lock);
12761
13074
  let content = await inspectContentCache(contentDir, referenced);
12762
13075
  let remoteHashes = await inspectRemoteHashCache(remotePath);
12763
13076
  const removed = { contentFiles: 0, remoteHashEntries: 0, resetRemoteHashCache: false };
@@ -12768,7 +13081,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
12768
13081
  ]);
12769
13082
  for (const name of names) {
12770
13083
  try {
12771
- await rm10(path26.join(contentDir, name), { force: true });
13084
+ await rm10(path27.join(contentDir, name), { force: true });
12772
13085
  removed.contentFiles++;
12773
13086
  } catch {
12774
13087
  }
@@ -12795,7 +13108,7 @@ async function inspectCaches(lock, lockPath, options = {}) {
12795
13108
  async function inspectContentCache(contentDir, referenced) {
12796
13109
  const report = {
12797
13110
  path: contentDir,
12798
- exists: existsSync21(contentDir),
13111
+ exists: existsSync22(contentDir),
12799
13112
  files: 0,
12800
13113
  bytes: 0,
12801
13114
  valid: 0,
@@ -12816,7 +13129,7 @@ async function inspectContentCache(contentDir, referenced) {
12816
13129
  continue;
12817
13130
  }
12818
13131
  report.files++;
12819
- const file = path26.join(contentDir, entry.name);
13132
+ const file = path27.join(contentDir, entry.name);
12820
13133
  const mediaType = mediaTypeFromExtension(entry.name);
12821
13134
  const expected = mediaType ? entry.name.slice(0, -4) : "";
12822
13135
  let bytes;
@@ -12859,7 +13172,7 @@ async function inspectContentCache(contentDir, referenced) {
12859
13172
  async function inspectRemoteHashCache(remotePath) {
12860
13173
  const report = {
12861
13174
  path: remotePath,
12862
- exists: existsSync21(remotePath),
13175
+ exists: existsSync22(remotePath),
12863
13176
  entries: 0,
12864
13177
  valid: 0,
12865
13178
  invalidIds: [],
@@ -13143,7 +13456,7 @@ function isRecord2(value) {
13143
13456
 
13144
13457
  // src/pick/salvage-server.ts
13145
13458
  import { mkdir as mkdir8, writeFile as writeFile11, readFile as readFile22 } from "fs/promises";
13146
- import path27 from "path";
13459
+ import path28 from "path";
13147
13460
 
13148
13461
  // src/pick/salvage-sheet.ts
13149
13462
  var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -13308,7 +13621,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13308
13621
  });
13309
13622
  const html = renderSalvageSheet(orphans, {
13310
13623
  styleId: ctx.styleId,
13311
- importDir: path27.relative(process.cwd(), ctx.importDir) || "."
13624
+ importDir: path28.relative(process.cwd(), ctx.importDir) || "."
13312
13625
  });
13313
13626
  const byId = new Map(orphans.map((o) => [o.id, o]));
13314
13627
  const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
@@ -13337,9 +13650,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13337
13650
  if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
13338
13651
  decodePng(buf);
13339
13652
  const assetId = idFromPrompt(orphan.prompt, taken);
13340
- const rel = path27.join("_salvaged", `${assetId}.png`);
13341
- const outFile = path27.resolve(ctx.importDir, rel);
13342
- 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 });
13343
13656
  await writeFile11(outFile, buf);
13344
13657
  ctx.manifest.assets[assetId] = {
13345
13658
  prompt: orphan.prompt,
@@ -13372,7 +13685,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13372
13685
  sourceUrl: durableSource,
13373
13686
  sourceUrls: durableSource ? [{ url: durableSource }] : [],
13374
13687
  outputs: [{
13375
- path: portableOutputPath(outFile, path27.dirname(ctx.manifestPath)),
13688
+ path: portableOutputPath(outFile, path28.dirname(ctx.manifestPath)),
13376
13689
  sha256: sha256(buf)
13377
13690
  }],
13378
13691
  submittedAt: orphan.createdAt,
@@ -13406,8 +13719,8 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
13406
13719
  }
13407
13720
 
13408
13721
  // src/recipes.ts
13409
- import path28 from "path";
13410
- import { existsSync as existsSync22 } from "fs";
13722
+ import path29 from "path";
13723
+ import { existsSync as existsSync23 } from "fs";
13411
13724
  import { readdir as readdir3, readFile as readFile23 } from "fs/promises";
13412
13725
  import { fileURLToPath } from "url";
13413
13726
  import { z as z9 } from "zod";
@@ -13417,7 +13730,7 @@ var VERSION_RE = /^\d+\.\d+\.\d+$/;
13417
13730
  var RECIPE_FILE = "pixelkiln.recipe.json";
13418
13731
  var RECIPE_DIR_TOKEN = "{{recipeDir}}";
13419
13732
  var PortablePathSchema = z9.string().min(1).refine((value) => {
13420
- if (value.includes("\\") || path28.posix.isAbsolute(value)) return false;
13733
+ if (value.includes("\\") || path29.posix.isAbsolute(value)) return false;
13421
13734
  const parts = value.split("/");
13422
13735
  return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
13423
13736
  }, "expected a portable relative path without '.', '..', or backslashes");
@@ -13593,7 +13906,7 @@ function message4(error) {
13593
13906
  return error instanceof Error ? error.message : String(error);
13594
13907
  }
13595
13908
  async function readRecipeFile(recipePath, bundled) {
13596
- const absolute = path28.resolve(recipePath);
13909
+ const absolute = path29.resolve(recipePath);
13597
13910
  let raw;
13598
13911
  try {
13599
13912
  raw = JSON.parse(await readFile23(absolute, "utf8"));
@@ -13607,13 +13920,13 @@ async function readRecipeFile(recipePath, bundled) {
13607
13920
  ` + parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "$"}: ${issue.message}`).join("\n")
13608
13921
  );
13609
13922
  }
13610
- return { recipe: parsed.data, path: absolute, dir: path28.dirname(absolute), bundled };
13923
+ return { recipe: parsed.data, path: absolute, dir: path29.dirname(absolute), bundled };
13611
13924
  }
13612
13925
  async function walkRecipeFiles(root) {
13613
- if (!existsSync22(root)) return [];
13926
+ if (!existsSync23(root)) return [];
13614
13927
  const out = [];
13615
13928
  for (const entry of await readdir3(root, { withFileTypes: true })) {
13616
- const resolved = path28.join(root, entry.name);
13929
+ const resolved = path29.join(root, entry.name);
13617
13930
  if (entry.isDirectory()) out.push(...await walkRecipeFiles(resolved));
13618
13931
  else if (entry.isFile() && entry.name === RECIPE_FILE) out.push(resolved);
13619
13932
  }
@@ -13624,7 +13937,7 @@ function bundledRecipeRoot() {
13624
13937
  }
13625
13938
  async function listBundledRecipes(root = bundledRecipeRoot()) {
13626
13939
  const recipes = await Promise.all(
13627
- (await walkRecipeFiles(path28.resolve(root))).map((file) => readRecipeFile(file, true))
13940
+ (await walkRecipeFiles(path29.resolve(root))).map((file) => readRecipeFile(file, true))
13628
13941
  );
13629
13942
  return recipes.sort(
13630
13943
  (a, b) => a.recipe.id.localeCompare(b.recipe.id) || compareVersions(b.recipe.version, a.recipe.version)
@@ -13647,9 +13960,9 @@ function parseSelector(selector) {
13647
13960
  return { id: match[1], version: match[2] };
13648
13961
  }
13649
13962
  async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
13650
- const local = path28.resolve(target);
13651
- if (existsSync22(local)) {
13652
- 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);
13653
13966
  return readRecipeFile(recipePath, false);
13654
13967
  }
13655
13968
  if (target.startsWith(".") || target.startsWith("/") || target.includes("\\")) {
@@ -13663,8 +13976,8 @@ async function resolveRecipe(target, bundledRoot = bundledRecipeRoot()) {
13663
13976
  return matches[0];
13664
13977
  }
13665
13978
  async function verifyFile(root, file) {
13666
- const absolute = path28.resolve(root, ...file.path.split("/"));
13667
- if (!existsSync22(absolute)) {
13979
+ const absolute = path29.resolve(root, ...file.path.split("/"));
13980
+ if (!existsSync23(absolute)) {
13668
13981
  return { path: file.path, expectedSha256: file.sha256, actualSha256: null, status: "missing" };
13669
13982
  }
13670
13983
  const actualSha256 = await sha256File(absolute);
@@ -13679,7 +13992,7 @@ async function verifyRecipe(target, options = {}) {
13679
13992
  const loaded = await resolveRecipe(target, options.bundledRoot);
13680
13993
  const actualIntegrity = recipeDigest(loaded.recipe);
13681
13994
  const files = await Promise.all(loaded.recipe.files.map((file) => verifyFile(loaded.dir, file)));
13682
- const modelRoot = options.modelRoot ? path28.resolve(options.modelRoot) : null;
13995
+ const modelRoot = options.modelRoot ? path29.resolve(options.modelRoot) : null;
13683
13996
  const models = modelRoot ? await Promise.all(loaded.recipe.models.map(async (model) => ({
13684
13997
  ...await verifyFile(modelRoot, model),
13685
13998
  source: model.source,
@@ -13713,7 +14026,7 @@ async function verifyRecipe(target, options = {}) {
13713
14026
  };
13714
14027
  }
13715
14028
  function renderStyle(style, destination, cwd) {
13716
- const portableDir = path28.relative(cwd, destination).split(path28.sep).join("/") || ".";
14029
+ const portableDir = path29.relative(cwd, destination).split(path29.sep).join("/") || ".";
13717
14030
  const replace = (value) => {
13718
14031
  if (typeof value === "string") return value.replaceAll(RECIPE_DIR_TOKEN, portableDir);
13719
14032
  if (Array.isArray(value)) return value.map(replace);
@@ -13725,24 +14038,24 @@ function renderStyle(style, destination, cwd) {
13725
14038
  return replace(style);
13726
14039
  }
13727
14040
  async function installRecipe(target, options = {}) {
13728
- const cwd = path28.resolve(options.cwd ?? process.cwd());
14041
+ const cwd = path29.resolve(options.cwd ?? process.cwd());
13729
14042
  const loaded = await resolveRecipe(target, options.bundledRoot);
13730
14043
  const verification = await verifyRecipe(loaded.path);
13731
14044
  if (!verification.ok) {
13732
14045
  throw new Error(`Recipe ${loaded.recipe.id}@${loaded.recipe.version} failed integrity verification.`);
13733
14046
  }
13734
- 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);
13735
14048
  const sources = [
13736
14049
  { path: RECIPE_FILE, source: loaded.path },
13737
- ...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("/")) }))
13738
14051
  ];
13739
14052
  const files = await Promise.all(sources.map(async (file) => ({
13740
- path: path28.join(destination, ...file.path.split("/")),
14053
+ path: path29.join(destination, ...file.path.split("/")),
13741
14054
  data: await readFile23(file.source)
13742
14055
  })));
13743
14056
  if (!options.force) {
13744
14057
  for (const file of files) {
13745
- if (!existsSync22(file.path)) continue;
14058
+ if (!existsSync23(file.path)) continue;
13746
14059
  const current = await readFile23(file.path);
13747
14060
  if (!current.equals(file.data)) {
13748
14061
  throw new Error(
@@ -13766,15 +14079,15 @@ async function installRecipe(target, options = {}) {
13766
14079
  }
13767
14080
 
13768
14081
  // src/pipeline/quality-regression.ts
13769
- import path29 from "path";
13770
- import { existsSync as existsSync23 } from "fs";
14082
+ import path30 from "path";
14083
+ import { existsSync as existsSync24 } from "fs";
13771
14084
  import { readFile as readFile24 } from "fs/promises";
13772
14085
  import { z as z10 } from "zod";
13773
14086
  var SHA256_RE2 = /^[0-9a-f]{64}$/;
13774
14087
  var HEX_RE = /^#[0-9a-f]{6}$/;
13775
14088
  var MAX_REDMEAN_DISTANCE = 765;
13776
14089
  var QualityPathSchema = z10.string().min(1).refine(
13777
- (value) => !path29.posix.isAbsolute(value) && !value.includes("\\"),
14090
+ (value) => !path30.posix.isAbsolute(value) && !value.includes("\\"),
13778
14091
  "expected a portable relative path"
13779
14092
  );
13780
14093
  var DEFAULT_QUALITY_TOLERANCES = {
@@ -13921,7 +14234,7 @@ function measureDecodedImageQuality(png, digest2) {
13921
14234
  };
13922
14235
  }
13923
14236
  async function measureImageQuality(file) {
13924
- const absolute = path29.resolve(file);
14237
+ const absolute = path30.resolve(file);
13925
14238
  let bytes;
13926
14239
  try {
13927
14240
  bytes = await readFile24(absolute);
@@ -13943,11 +14256,11 @@ function resolveQualityInputs(raw, inputsFilePath) {
13943
14256
  }
13944
14257
  const ids = parsed.data.map((entry) => entry.id);
13945
14258
  if (new Set(ids).size !== ids.length) throw new Error("--inputs contains a duplicate quality case id.");
13946
- const root = path29.dirname(path29.resolve(inputsFilePath));
14259
+ const root = path30.dirname(path30.resolve(inputsFilePath));
13947
14260
  return parsed.data.map((entry) => ({
13948
14261
  id: entry.id,
13949
- file: path29.resolve(root, entry.path),
13950
- ...entry.record ? { record: path29.resolve(root, entry.record) } : {},
14262
+ file: path30.resolve(root, entry.path),
14263
+ ...entry.record ? { record: path30.resolve(root, entry.record) } : {},
13951
14264
  tolerances: QualityToleranceSchema.parse({
13952
14265
  ...DEFAULT_QUALITY_TOLERANCES,
13953
14266
  ...entry.tolerances
@@ -13955,7 +14268,7 @@ function resolveQualityInputs(raw, inputsFilePath) {
13955
14268
  }));
13956
14269
  }
13957
14270
  function portableRelative2(from, to) {
13958
- return path29.relative(from, path29.resolve(to)).split(path29.sep).join("/") || ".";
14271
+ return path30.relative(from, path30.resolve(to)).split(path30.sep).join("/") || ".";
13959
14272
  }
13960
14273
  async function verifyRecordForImage(recordPath, imagePath) {
13961
14274
  const verification = await verifyArtifactBundle(recordPath);
@@ -13970,20 +14283,20 @@ async function verifyRecordForImage(recordPath, imagePath) {
13970
14283
  if (record.kind !== "refine" || options === null || typeof options !== "object" || options.schema !== "pixelkiln-quality") {
13971
14284
  throw new Error(`Quality record ${recordPath} is not a PixelKiln refinement record.`);
13972
14285
  }
13973
- const outputs = record.outputs.map((output) => path29.resolve(path29.dirname(recordPath), output.path));
13974
- 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))) {
13975
14288
  throw new Error(`Quality record ${recordPath} does not own ${imagePath}.`);
13976
14289
  }
13977
14290
  }
13978
14291
  async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
13979
14292
  if (!inputs.length) throw new Error("Cannot snapshot an empty quality baseline.");
13980
- const absolute = path29.resolve(baselinePath);
14293
+ const absolute = path30.resolve(baselinePath);
13981
14294
  for (const input of inputs) {
13982
- 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) {
13983
14296
  throw new Error(`Quality baseline output would overwrite an input: ${absolute}.`);
13984
14297
  }
13985
14298
  }
13986
- const root = path29.dirname(absolute);
14299
+ const root = path30.dirname(absolute);
13987
14300
  const cases = [];
13988
14301
  for (const input of [...inputs].sort((a, b) => a.id.localeCompare(b.id))) {
13989
14302
  const expected = await measureImageQuality(input.file);
@@ -14010,7 +14323,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
14010
14323
  cases
14011
14324
  });
14012
14325
  const data = Buffer.from(JSON.stringify(baseline, null, 2) + "\n");
14013
- if (existsSync23(absolute) && !options.force) {
14326
+ if (existsSync24(absolute) && !options.force) {
14014
14327
  const current = await readFile24(absolute);
14015
14328
  if (!current.equals(data)) {
14016
14329
  throw new Error(`Quality baseline already exists with different content: ${absolute}. Pass --force to replace it.`);
@@ -14020,7 +14333,7 @@ async function snapshotQualityBaseline(inputs, baselinePath, options = {}) {
14020
14333
  return { baseline, path: absolute, changed: result.changed.length > 0 };
14021
14334
  }
14022
14335
  async function readQualityBaseline(baselinePath) {
14023
- const absolute = path29.resolve(baselinePath);
14336
+ const absolute = path30.resolve(baselinePath);
14024
14337
  let raw;
14025
14338
  try {
14026
14339
  raw = JSON.parse(await readFile24(absolute, "utf8"));
@@ -14093,12 +14406,12 @@ function regressionViolations(expected, actual, tolerances) {
14093
14406
  return { violations, warnings };
14094
14407
  }
14095
14408
  async function checkQualityBaseline(baselinePath) {
14096
- const absolute = path29.resolve(baselinePath);
14409
+ const absolute = path30.resolve(baselinePath);
14097
14410
  const baseline = await readQualityBaseline(absolute);
14098
- const root = path29.dirname(absolute);
14411
+ const root = path30.dirname(absolute);
14099
14412
  const cases = [];
14100
14413
  for (const entry of baseline.cases) {
14101
- const file = path29.resolve(root, entry.file);
14414
+ const file = path30.resolve(root, entry.file);
14102
14415
  let actual = null;
14103
14416
  const violations = [];
14104
14417
  const warnings = [];
@@ -14111,7 +14424,7 @@ async function checkQualityBaseline(baselinePath) {
14111
14424
  violations.push(message5(error));
14112
14425
  }
14113
14426
  if (entry.record) {
14114
- const recordPath = path29.resolve(root, entry.record.path);
14427
+ const recordPath = path30.resolve(root, entry.record.path);
14115
14428
  try {
14116
14429
  const recordHash = await sha256File(recordPath);
14117
14430
  if (recordHash !== entry.record.sha256) violations.push("quality record hash changed");
@@ -14197,13 +14510,13 @@ async function runGallery(initial, reload, args, access2) {
14197
14510
  };
14198
14511
  const loadProject = async (project) => {
14199
14512
  const ctx = await access2.loadProject(project);
14200
- const dir = path30.dirname(ctx.loaded.path);
14513
+ const dir = path31.dirname(ctx.loaded.path);
14201
14514
  const declared = readEnvFiles(dir);
14202
14515
  const credentialNames = new Set(availableProviders().flatMap((id) => providerCredentialEnvs(providerFactory(id))));
14203
14516
  for (const [name, value] of Object.entries(declared)) {
14204
14517
  if (credentialNames.has(name) && name in process.env && process.env[name] !== value) {
14205
14518
  throw new Error(
14206
- `${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.`
14207
14520
  );
14208
14521
  }
14209
14522
  }
@@ -14247,11 +14560,11 @@ async function runGallery(initial, reload, args, access2) {
14247
14560
  log("\n gallery closed");
14248
14561
  }
14249
14562
  async function provenanceFile(id, file) {
14250
- const absolute = path30.resolve(file);
14563
+ const absolute = path31.resolve(file);
14251
14564
  return {
14252
14565
  id,
14253
14566
  path: absolute,
14254
- sha256: existsSync24(absolute) ? await sha256File(absolute) : null,
14567
+ sha256: existsSync25(absolute) ? await sha256File(absolute) : null,
14255
14568
  included: true
14256
14569
  };
14257
14570
  }
@@ -14285,7 +14598,8 @@ var VALUE_FLAGS = [
14285
14598
  "--min-grid-confidence",
14286
14599
  "--reviewer",
14287
14600
  "--note",
14288
- "--model-root"
14601
+ "--model-root",
14602
+ "--generation"
14289
14603
  ];
14290
14604
  var BOOL_FLAGS = [
14291
14605
  "--force",
@@ -14330,6 +14644,7 @@ var COMMANDS = [
14330
14644
  "gallery",
14331
14645
  "edit",
14332
14646
  "tools",
14647
+ "history",
14333
14648
  "quality",
14334
14649
  "refine",
14335
14650
  "recipe",
@@ -14539,7 +14854,7 @@ function parseArgs(argv) {
14539
14854
  return {
14540
14855
  command,
14541
14856
  manifest,
14542
- 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"),
14543
14858
  explicitLock: get("--lock"),
14544
14859
  styles: list("--style"),
14545
14860
  assets: list("--only"),
@@ -14581,6 +14896,7 @@ function parseArgs(argv) {
14581
14896
  reviewer: get("--reviewer"),
14582
14897
  note: get("--note"),
14583
14898
  modelRoot: get("--model-root"),
14899
+ generation: get("--generation"),
14584
14900
  subcommand,
14585
14901
  workspace: get("--workspace"),
14586
14902
  target,
@@ -14640,7 +14956,9 @@ Commands
14640
14956
  poll Advance in-flight jobs to their settled state.
14641
14957
  pick Open the contact sheet to choose among candidates.
14642
14958
  fetch Download selected objects to their manifest paths.
14643
- 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.
14644
14962
  adopt Match existing account objects to files already in the repo.
14645
14963
  accept Keep existing art after a style reword \u2014 re-baseline, do not regenerate.
14646
14964
  salvage Triage account objects no lockfile claims. Recovers usable art.
@@ -14694,6 +15012,7 @@ Options
14694
15012
  --reviewer <name> refine approve: human reviewer recorded in provenance
14695
15013
  --note <text> refine approve: optional review note
14696
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)
14697
15016
  --prune cache: remove invalid/unreferenced local cache data
14698
15017
  --manifest <path> Default: pixelkiln.manifest.json
14699
15018
  --lock <path> Default: pixelkiln.lock.json beside the manifest
@@ -14891,10 +15210,10 @@ async function confirm(question, auto) {
14891
15210
  return answer === "y" || answer === "yes";
14892
15211
  }
14893
15212
  async function requireCompleteWorkspaceClaims(workspacePath) {
14894
- if (!existsSync24(workspacePath)) {
15213
+ if (!existsSync25(workspacePath)) {
14895
15214
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
14896
15215
  }
14897
- const dir = path30.dirname(path30.resolve(workspacePath));
15216
+ const dir = path31.dirname(path31.resolve(workspacePath));
14898
15217
  const ws = await loadWorkspace(workspacePath);
14899
15218
  const diagnostics = validateWorkspace(ws, dir);
14900
15219
  const errors = diagnostics.filter((d) => d.level === "error");
@@ -14930,15 +15249,15 @@ async function main() {
14930
15249
  if (!args.out) throw new Error("quality snapshot needs --out <pixelkiln.quality.json>.");
14931
15250
  let raw;
14932
15251
  try {
14933
- raw = JSON.parse(await readFile25(path30.resolve(args.inputs), "utf8"));
15252
+ raw = JSON.parse(await readFile25(path31.resolve(args.inputs), "utf8"));
14934
15253
  } catch (error) {
14935
15254
  throw new Error(
14936
- `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)}`,
14937
15256
  { cause: error }
14938
15257
  );
14939
15258
  }
14940
15259
  const inputs = resolveQualityInputs(raw, args.inputs);
14941
- if (path30.resolve(args.inputs) === path30.resolve(args.out)) {
15260
+ if (path31.resolve(args.inputs) === path31.resolve(args.out)) {
14942
15261
  throw new Error("quality snapshot --out must not overwrite its --inputs file.");
14943
15262
  }
14944
15263
  const result = await snapshotQualityBaseline(inputs, args.out, { force: args.force });
@@ -14951,7 +15270,7 @@ async function main() {
14951
15270
  }, null, 2));
14952
15271
  } else {
14953
15272
  log(
14954
- ` ${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))`
14955
15274
  );
14956
15275
  log(` Review the tolerances, commit the baseline, then run quality check in CI.`);
14957
15276
  }
@@ -15046,14 +15365,14 @@ async function main() {
15046
15365
  }, null, 2));
15047
15366
  } else {
15048
15367
  log(` installed ${result.recipe.id}@${result.recipe.version}`);
15049
- log(` ${path30.relative(process.cwd(), result.destination) || "."}`);
15368
+ log(` ${path31.relative(process.cwd(), result.destination) || "."}`);
15050
15369
  log(`
15051
15370
  Add this entry under your manifest's styles object:`);
15052
15371
  log(JSON.stringify({ [result.styleId]: result.style }, null, 2));
15053
15372
  if (result.recipe.models.length) {
15054
15373
  log(`
15055
15374
  Models are not downloaded automatically. Verify them with:`);
15056
- 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>`);
15057
15376
  }
15058
15377
  }
15059
15378
  return;
@@ -15087,12 +15406,12 @@ async function main() {
15087
15406
  ` recovered ${result2.detection.columns}x${result2.detection.rows} native grid (${result2.detection.confidence}: ${result2.detection.consensus})`
15088
15407
  );
15089
15408
  log(` enforced ${result2.palette.length}-color palette without dithering`);
15090
- log(` wrote ${path30.relative(process.cwd(), result2.output)}`);
15091
- 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)}`);
15092
15411
  log(`
15093
15412
  Automated checks passed. Human 1\xD7 review is still required:`);
15094
15413
  log(
15095
- ` 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"`
15096
15415
  );
15097
15416
  }
15098
15417
  return;
@@ -15120,7 +15439,7 @@ async function main() {
15120
15439
  if (args.json) log(JSON.stringify(result2, null, 2));
15121
15440
  else {
15122
15441
  log(
15123
- 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()}`
15124
15443
  );
15125
15444
  }
15126
15445
  return;
@@ -15129,14 +15448,14 @@ async function main() {
15129
15448
  if (args.json) {
15130
15449
  log(JSON.stringify(result, null, 2));
15131
15450
  } else {
15132
- 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)}`);
15133
15452
  for (const reason of result.reasons) log(` ${reason}`);
15134
15453
  }
15135
15454
  if (!result.safe) process.exitCode = 1;
15136
15455
  return;
15137
15456
  }
15138
15457
  if (args.command === "balance") {
15139
- loadEnvFiles(path30.dirname(path30.resolve(args.manifest)));
15458
+ loadEnvFiles(path31.dirname(path31.resolve(args.manifest)));
15140
15459
  loadEnvFiles(process.cwd());
15141
15460
  const loaded2 = await loadManifest(args.manifest);
15142
15461
  const selectedProvider = accountProviderId(loaded2.manifest, args.provider, "balance");
@@ -15149,31 +15468,31 @@ async function main() {
15149
15468
  }
15150
15469
  if (args.command === "init") {
15151
15470
  if (!args.from) throw new Error("init needs --from <dir> pointing at your existing PNGs.");
15152
- const root = path30.resolve(args.from);
15153
- 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}`);
15154
15473
  const generator = args.generator ?? "map";
15155
15474
  if (generator !== "1dir" && generator !== "map") {
15156
15475
  throw new Error(`--generator must be "1dir" or "map", got "${args.generator}".`);
15157
15476
  }
15158
- const target = path30.resolve(args.out ?? "pixelkiln.manifest.json");
15477
+ const target = path31.resolve(args.out ?? "pixelkiln.manifest.json");
15159
15478
  const { assets, skipped } = await scanAssets(root, { exclude: args.exclude });
15160
15479
  if (!assets.length) throw new Error(`No PNGs found under ${root}`);
15161
15480
  const manifest = buildManifest(
15162
- args.name ?? path30.basename(path30.dirname(target)),
15481
+ args.name ?? path31.basename(path31.dirname(target)),
15163
15482
  args.styles[0] ?? "base",
15164
15483
  generator,
15165
- path30.relative(path30.dirname(target), root) || ".",
15484
+ path31.relative(path31.dirname(target), root) || ".",
15166
15485
  assets
15167
15486
  );
15168
15487
  await writeManifestFile(target, manifest);
15169
- 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)}`);
15170
15489
  if (skipped.length) log(` skipped ${skipped.length} unreadable file(s)`);
15171
- log(` wrote ${path30.relative(process.cwd(), target)}`);
15490
+ log(` wrote ${path31.relative(process.cwd(), target)}`);
15172
15491
  log(`
15173
15492
  Prompts are intentionally empty. To recover the real ones from your`);
15174
15493
  log(` PixelLab account instead of inventing them:`);
15175
15494
  log(`
15176
- pixelkiln adopt --manifest ${path30.relative(process.cwd(), target)} --write-prompts
15495
+ pixelkiln adopt --manifest ${path31.relative(process.cwd(), target)} --write-prompts
15177
15496
  `);
15178
15497
  return;
15179
15498
  }
@@ -15221,10 +15540,10 @@ async function main() {
15221
15540
  if (args.primaryOnly || args.outputRoles.length) {
15222
15541
  throw new Error("--primary-only and --output-role require manifest-driven pack");
15223
15542
  }
15224
- const raw = JSON.parse(await readFile25(path30.resolve(args.inputs), "utf8"));
15543
+ const raw = JSON.parse(await readFile25(path31.resolve(args.inputs), "utf8"));
15225
15544
  const inputs = resolvePackInputs(raw, args.inputs);
15226
15545
  const { png, atlas, skipped, sources } = packSprites(inputs, { columns: args.columns });
15227
- const base = path30.resolve(args.out.replace(/\.png$/, ""));
15546
+ const base = path31.resolve(args.out.replace(/\.png$/, ""));
15228
15547
  const outputs = [
15229
15548
  { path: `${base}.png`, data: png },
15230
15549
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -15237,17 +15556,17 @@ async function main() {
15237
15556
  log(
15238
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`
15239
15558
  );
15240
- log(` ${path30.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15559
+ log(` ${path31.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15241
15560
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
15242
15561
  return;
15243
15562
  }
15244
15563
  if (args.command === "workspace") {
15245
- const workspacePath = path30.resolve(args.workspace ?? "pixelkiln.workspace.json");
15246
- const dir = path30.dirname(workspacePath);
15564
+ const workspacePath = path31.resolve(args.workspace ?? "pixelkiln.workspace.json");
15565
+ const dir = path31.dirname(workspacePath);
15247
15566
  if (args.subcommand === "add") {
15248
- const manifestPath = path30.resolve(args.target);
15567
+ const manifestPath = path31.resolve(args.target);
15249
15568
  const loadedTarget = await loadManifest(manifestPath);
15250
- 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");
15251
15570
  const ws = await loadWorkspace(workspacePath);
15252
15571
  const id = args.name ?? loadedTarget.manifest.name;
15253
15572
  if (ws.projects.some((p) => p.id === id)) {
@@ -15267,10 +15586,10 @@ async function main() {
15267
15586
  ...args.account ? { account: args.account } : {}
15268
15587
  };
15269
15588
  await saveWorkspace(workspacePath, { version: 1, projects: [...ws.projects, project] });
15270
- log(` registered "${id}" in ${path30.relative(process.cwd(), workspacePath)}`);
15589
+ log(` registered "${id}" in ${path31.relative(process.cwd(), workspacePath)}`);
15271
15590
  log(` manifest: ${project.manifest}`);
15272
15591
  log(` lock: ${project.lock}`);
15273
- if (!existsSync24(lockPath)) {
15592
+ if (!existsSync25(lockPath)) {
15274
15593
  log(
15275
15594
  ` warning: no lockfile there yet \u2014 this project contributes no claims until one is generated`
15276
15595
  );
@@ -15279,7 +15598,7 @@ async function main() {
15279
15598
  }
15280
15599
  if (args.subcommand === "remove") {
15281
15600
  const ws = await loadWorkspace(workspacePath);
15282
- const resolvedTarget = path30.resolve(args.target);
15601
+ const resolvedTarget = path31.resolve(args.target);
15283
15602
  const match = ws.projects.find(
15284
15603
  (p) => p.id === args.target || resolveProject(dir, p).manifestPath === resolvedTarget
15285
15604
  );
@@ -15290,11 +15609,11 @@ async function main() {
15290
15609
  version: 1,
15291
15610
  projects: ws.projects.filter((p) => p !== match)
15292
15611
  });
15293
- log(` removed "${match.id}" from ${path30.relative(process.cwd(), workspacePath)}`);
15612
+ log(` removed "${match.id}" from ${path31.relative(process.cwd(), workspacePath)}`);
15294
15613
  return;
15295
15614
  }
15296
15615
  if (args.subcommand === "list") {
15297
- if (!existsSync24(workspacePath)) {
15616
+ if (!existsSync25(workspacePath)) {
15298
15617
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
15299
15618
  }
15300
15619
  const ws = await loadWorkspace(workspacePath);
@@ -15302,9 +15621,9 @@ async function main() {
15302
15621
  if (args.json) {
15303
15622
  log(JSON.stringify({ version: 1, workspace: workspacePath, projects: ws.projects, diagnostics }, null, 2));
15304
15623
  } else if (!ws.projects.length) {
15305
- log(` no projects registered in ${path30.relative(process.cwd(), workspacePath)}`);
15624
+ log(` no projects registered in ${path31.relative(process.cwd(), workspacePath)}`);
15306
15625
  } else {
15307
- 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)}:`);
15308
15627
  for (const p of ws.projects) {
15309
15628
  log(` ${p.id.padEnd(24)} ${p.manifest.padEnd(40)} (${p.provider}${p.account ? `, ${p.account}` : ""})`);
15310
15629
  }
@@ -15314,7 +15633,7 @@ async function main() {
15314
15633
  return;
15315
15634
  }
15316
15635
  if (args.subcommand === "status") {
15317
- if (!existsSync24(workspacePath)) {
15636
+ if (!existsSync25(workspacePath)) {
15318
15637
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
15319
15638
  }
15320
15639
  const ws = await loadWorkspace(workspacePath);
@@ -15322,7 +15641,7 @@ async function main() {
15322
15641
  if (args.json) {
15323
15642
  log(JSON.stringify({ ...report, workspace: workspacePath }, null, 2));
15324
15643
  } else {
15325
- log(` workspace: ${path30.relative(process.cwd(), workspacePath)}`);
15644
+ log(` workspace: ${path31.relative(process.cwd(), workspacePath)}`);
15326
15645
  for (const p of report.projects) {
15327
15646
  if (p.error) {
15328
15647
  log(`
@@ -15369,8 +15688,8 @@ async function main() {
15369
15688
  }
15370
15689
  }
15371
15690
  if (args.command === "gallery" && args.workspace) {
15372
- const workspacePath = path30.resolve(args.workspace);
15373
- if (!existsSync24(workspacePath)) {
15691
+ const workspacePath = path31.resolve(args.workspace);
15692
+ if (!existsSync25(workspacePath)) {
15374
15693
  throw new Error(`Workspace catalog not found: ${workspacePath}`);
15375
15694
  }
15376
15695
  const filter = { styles: args.styles, assets: args.assets };
@@ -15390,7 +15709,7 @@ async function main() {
15390
15709
  const registered = async (projectId) => {
15391
15710
  const project = (await loadWorkspace(workspacePath)).projects.find((candidate) => candidate.id === projectId);
15392
15711
  if (!projectId || !project) throw new Error(`unknown workspace project "${projectId ?? ""}"`);
15393
- return resolveProject(path30.dirname(workspacePath), project);
15712
+ return resolveProject(path31.dirname(workspacePath), project);
15394
15713
  };
15395
15714
  await runGallery(initial, build, args, {
15396
15715
  manifestFor: async (projectId) => (await registered(projectId)).manifestPath,
@@ -15405,14 +15724,14 @@ async function main() {
15405
15724
  });
15406
15725
  return;
15407
15726
  }
15408
- if (!existsSync24(path30.resolve(args.manifest))) {
15727
+ if (!existsSync25(path31.resolve(args.manifest))) {
15409
15728
  throw new Error(
15410
- `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>\`.`
15411
15730
  );
15412
15731
  }
15413
- const manifestDir = path30.dirname(path30.resolve(args.manifest));
15732
+ const manifestDir = path31.dirname(path31.resolve(args.manifest));
15414
15733
  const envFiles2 = [...loadEnvFiles(manifestDir)];
15415
- if (path30.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
15734
+ if (path31.resolve(process.cwd()) !== manifestDir) envFiles2.push(...loadEnvFiles(process.cwd()));
15416
15735
  const loaded = await loadManifest(args.manifest);
15417
15736
  let specs = await resolveSpecs(loaded, {
15418
15737
  styles: args.styles,
@@ -15444,6 +15763,40 @@ async function main() {
15444
15763
  }
15445
15764
  const lock = await loadLock(args.lock);
15446
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
+ }
15447
15800
  if (args.command === "status") {
15448
15801
  const byStatus = {};
15449
15802
  for (const e of Object.values(lock.entries)) byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
@@ -15485,7 +15838,7 @@ async function main() {
15485
15838
  });
15486
15839
  };
15487
15840
  await runGallery(await build(), reload, args, {
15488
- manifestFor: () => path30.resolve(args.manifest),
15841
+ manifestFor: () => path31.resolve(args.manifest),
15489
15842
  loadProject: async () => {
15490
15843
  const freshLoaded = await loadManifest(args.manifest);
15491
15844
  const freshSpecs = await resolveSpecs(freshLoaded);
@@ -15513,7 +15866,7 @@ async function main() {
15513
15866
  const set = started.members.length > 1;
15514
15867
  const kind = spec.generator === "frames" ? "frames" : "members";
15515
15868
  log(` ${started.created ? "created" : "found"} ${started.source}${set ? ` (${started.members.length} ${kind})` : ""}${started.declared ? " and declared it as the asset's source" : ""}`);
15516
- 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)}`);
15517
15870
  if (args.noOpen) {
15518
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`);
15519
15872
  } else {
@@ -15563,7 +15916,7 @@ async function main() {
15563
15916
  log(` ${item.state.padEnd(16)} ${item.key} ${item.reason}`);
15564
15917
  if (item.state === "needs-approval") {
15565
15918
  log(
15566
- ` 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"`
15567
15920
  );
15568
15921
  }
15569
15922
  }
@@ -15654,7 +16007,7 @@ async function main() {
15654
16007
  let intact = true;
15655
16008
  for (const output of entry.outputs) {
15656
16009
  const file = resolveOutputPath(output.path, item.spec.root);
15657
- if (!existsSync24(file) || await sha256File(file) !== output.sha256) {
16010
+ if (!existsSync25(file) || await sha256File(file) !== output.sha256) {
15658
16011
  intact = false;
15659
16012
  break;
15660
16013
  }
@@ -15712,7 +16065,7 @@ async function main() {
15712
16065
  if (args.primaryOnly && args.outputRoles.length) {
15713
16066
  throw new Error("pack accepts either --primary-only or --output-role, not both");
15714
16067
  }
15715
- const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
16068
+ const manifestDir2 = path31.dirname(path31.resolve(args.manifest));
15716
16069
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
15717
16070
  for (const styleId of styleIds) {
15718
16071
  const packagingSpecs = await resolveSpecs(loaded, { styles: [styleId] });
@@ -15725,7 +16078,7 @@ async function main() {
15725
16078
  sources: manifestSources(loaded.manifest, styleId)
15726
16079
  });
15727
16080
  const style = loaded.manifest.styles[styleId];
15728
- 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`);
15729
16082
  const outputs = [
15730
16083
  { path: `${base}.png`, data: png },
15731
16084
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
@@ -15755,13 +16108,13 @@ async function main() {
15755
16108
  log(
15756
16109
  ` ${styleId} \u2014 ${atlas.frames.length} sprite(s), ${atlas.sheet.width}x${atlas.sheet.height} in ${atlas.columns} column(s)`
15757
16110
  );
15758
- log(` ${path30.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
16111
+ log(` ${path31.relative(process.cwd(), base)}.png + .json + .pixelkiln.json`);
15759
16112
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
15760
16113
  }
15761
16114
  return;
15762
16115
  }
15763
16116
  if (args.command === "mount") {
15764
- const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
16117
+ const manifestDir2 = path31.dirname(path31.resolve(args.manifest));
15765
16118
  const styleIds = args.styles.length ? args.styles : Object.keys(loaded.manifest.styles);
15766
16119
  for (const styleId of styleIds) {
15767
16120
  const style = loaded.manifest.styles[styleId];
@@ -15800,7 +16153,7 @@ async function main() {
15800
16153
  sources,
15801
16154
  outputRoles
15802
16155
  );
15803
- const out = path30.resolve(manifestDir2, style.mount.out);
16156
+ const out = path31.resolve(manifestDir2, style.mount.out);
15804
16157
  const metadata = out.replace(/\.png$/, "") + ".json";
15805
16158
  const companion = out.replace(/\.png$/, "") + ".pixelkiln.json";
15806
16159
  const outputs = [
@@ -15820,7 +16173,7 @@ async function main() {
15820
16173
  await provenanceFile("$lock", args.lock),
15821
16174
  ...qualityRecords,
15822
16175
  ...artifactSources.filter(
15823
- (source) => source.id !== "$base" || path30.resolve(source.path) !== out
16176
+ (source) => source.id !== "$base" || path31.resolve(source.path) !== out
15824
16177
  )
15825
16178
  ],
15826
16179
  options: {
@@ -15833,7 +16186,7 @@ async function main() {
15833
16186
  log(
15834
16187
  ` ${styleId} \u2014 ${atlas.frames.length} cell(s) into ${atlas.sheet.width}x${atlas.sheet.height}` + (overBase ? ` over ${style.mount.base}` : " (new sheet)")
15835
16188
  );
15836
- log(` ${path30.relative(process.cwd(), out)} + atlas/provenance JSON`);
16189
+ log(` ${path31.relative(process.cwd(), out)} + atlas/provenance JSON`);
15837
16190
  for (const s of skipped) log(` skipped ${s.id}: ${s.reason}`);
15838
16191
  }
15839
16192
  return;
@@ -15889,7 +16242,7 @@ async function main() {
15889
16242
  }
15890
16243
  if (args.command === "export") {
15891
16244
  const format = args.format ?? "generic";
15892
- const manifestDir2 = path30.dirname(path30.resolve(args.manifest));
16245
+ const manifestDir2 = path31.dirname(path31.resolve(args.manifest));
15893
16246
  const selected = specs.filter((spec) => {
15894
16247
  if (spec.generator !== "tiles") return false;
15895
16248
  if (args.styles.length && !args.styles.includes(spec.styleId)) return false;
@@ -15905,12 +16258,12 @@ async function main() {
15905
16258
  for (const spec of selected) {
15906
16259
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
15907
16260
  const style = loaded.manifest.styles[spec.styleId];
15908
- const defaultBase = path30.resolve(manifestDir2, style.outDir, `${spec.assetId}-tileset`);
15909
- 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;
15910
16263
  const result = exportTileset(entry, spec, {
15911
16264
  format,
15912
16265
  manifestDir: manifestDir2,
15913
- imageName: path30.basename(`${base}.png`),
16266
+ imageName: path31.basename(`${base}.png`),
15914
16267
  columns: args.columns
15915
16268
  });
15916
16269
  const outputs = [
@@ -15928,7 +16281,7 @@ async function main() {
15928
16281
  asset: spec.assetId,
15929
16282
  columns: args.columns ?? null,
15930
16283
  format,
15931
- image: path30.basename(`${base}.png`),
16284
+ image: path31.basename(`${base}.png`),
15932
16285
  providerRules: result.generic.providerRules,
15933
16286
  style: spec.styleId,
15934
16287
  tileType: spec.tileType ?? null
@@ -15938,12 +16291,32 @@ async function main() {
15938
16291
  ` ${spec.styleId}/${spec.assetId} \u2014 ${result.generic.tiles.length} tile(s), ${result.generic.sheet.width}x${result.generic.sheet.height} (${format})`
15939
16292
  );
15940
16293
  log(
15941
- ` ${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`
15942
16295
  );
15943
16296
  }
15944
16297
  return;
15945
16298
  }
15946
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
+ }
15947
16320
  const providers = /* @__PURE__ */ new Map();
15948
16321
  const providerFor = (id) => {
15949
16322
  let resolved = providers.get(id);
@@ -15988,12 +16361,12 @@ async function main() {
15988
16361
  tagged ${n} object(s) upstream`);
15989
16362
  }
15990
16363
  if (args.writePrompts) {
15991
- const { filled, stillEmpty } = await writePromptsBack(path30.resolve(args.manifest), lock, {
16364
+ const { filled, stillEmpty } = await writePromptsBack(path31.resolve(args.manifest), lock, {
15992
16365
  onProgress: log,
15993
16366
  provider: provider.id,
15994
16367
  assetIds: specs.map((spec) => spec.assetId)
15995
16368
  });
15996
- 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)}`);
15997
16370
  const reloaded = await loadManifest(args.manifest);
15998
16371
  const rebased = (await resolveSpecs(reloaded, {
15999
16372
  styles: args.styles,
@@ -16022,12 +16395,12 @@ async function main() {
16022
16395
  const provider = providerFor(selectedAccountProvider);
16023
16396
  const jsonMode = args.dryRun && args.json;
16024
16397
  const diag = jsonMode ? (msg = "") => console.error(msg) : log;
16025
- const ownLock = path30.resolve(args.lock);
16398
+ const ownLock = path31.resolve(args.lock);
16026
16399
  let workspaceProjects = [];
16027
16400
  let workspaceDir = "";
16028
16401
  if (args.workspace) {
16029
- const workspacePath = path30.resolve(args.workspace);
16030
- workspaceDir = path30.dirname(workspacePath);
16402
+ const workspacePath = path31.resolve(args.workspace);
16403
+ workspaceDir = path31.dirname(workspacePath);
16031
16404
  const complete = await requireCompleteWorkspaceClaims(workspacePath);
16032
16405
  workspaceProjects = complete.ws.projects;
16033
16406
  for (const d of complete.diagnostics) diag(` WARN ${d.id}: ${d.message}`);
@@ -16036,12 +16409,12 @@ async function main() {
16036
16409
  const lockPaths = [
16037
16410
  .../* @__PURE__ */ new Set([
16038
16411
  ...workspaceLockPaths,
16039
- ...existsSync24(ownLock) ? [ownLock] : [],
16040
- ...args.claims.map((c) => path30.resolve(c))
16412
+ ...existsSync25(ownLock) ? [ownLock] : [],
16413
+ ...args.claims.map((c) => path31.resolve(c))
16041
16414
  ])
16042
16415
  ];
16043
16416
  diag(` claim set (${lockPaths.length} lockfile(s)):`);
16044
- for (const p of lockPaths) diag(` ${path30.relative(process.cwd(), p)}`);
16417
+ for (const p of lockPaths) diag(` ${path31.relative(process.cwd(), p)}`);
16045
16418
  if (!args.claims.length && !args.workspace) {
16046
16419
  diag(
16047
16420
  `
@@ -16049,7 +16422,7 @@ async function main() {
16049
16422
  pass every other project's lockfile via --claims a.json,b.json, or
16050
16423
  register every project in a workspace catalog and pass --workspace.`
16051
16424
  );
16052
- } 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))) {
16053
16426
  diag(
16054
16427
  `
16055
16428
  This project's manifest is not registered in the workspace catalog. Its own
@@ -16133,7 +16506,7 @@ async function main() {
16133
16506
  manifestPath: loaded.path,
16134
16507
  manifest: accountManifest,
16135
16508
  styleId,
16136
- importDir: path30.resolve(loaded.root, style.outDir),
16509
+ importDir: path31.resolve(loaded.root, style.outDir),
16137
16510
  lock,
16138
16511
  lockPath: args.lock
16139
16512
  },