pixelkiln 0.47.1 → 0.49.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
@@ -1374,6 +1374,8 @@ var LockHistoryEntrySchema = z.object({
1374
1374
  downloadedAt: z.string().nullable().default(null),
1375
1375
  cost: z.number().finite().nonnegative().default(0),
1376
1376
  costUnit: z.string().min(1).default("generations"),
1377
+ /** What the provider actually billed, read from the completed job; null when never read. */
1378
+ billed: z.object({ amount: z.number(), unit: z.string() }).nullable().default(null),
1377
1379
  provider: z.string().default("pixellab"),
1378
1380
  postprocess: PostprocessSchema.optional(),
1379
1381
  /** When the replacement was submitted. */
@@ -1465,6 +1467,14 @@ var LockEntrySchema = z.object({
1465
1467
  cost: z.number().finite().nonnegative().default(0),
1466
1468
  /** Unit for `cost`. Defaults preserve pre-unit PixelLab lockfiles. */
1467
1469
  costUnit: z.string().min(1).default("generations"),
1470
+ /**
1471
+ * What the provider actually billed, read from the completed job rather
1472
+ * than estimated at submit time. Null until a poll reads it, and for a
1473
+ * provider or generator `poll` does not read it from at all; `cost` above
1474
+ * is still what a wave budget spends against, since the real number is
1475
+ * only known after the fact.
1476
+ */
1477
+ billed: z.object({ amount: z.number(), unit: z.string() }).nullable().default(null),
1468
1478
  /** Which provider produced this. Absent on entries written before providers. */
1469
1479
  provider: z.string().default("pixellab"),
1470
1480
  /** Generations this entry replaced, newest first; see LockHistoryEntrySchema. */
@@ -5115,6 +5125,10 @@ var TilesProSchema = z2.object({
5115
5125
  kind: z2.string().nullable().default(null),
5116
5126
  tile_rules: z2.record(z2.unknown()).nullable().optional()
5117
5127
  }).passthrough();
5128
+ var RevisionJobSubmitSchema = z2.object({
5129
+ background_job_id: z2.string().min(1),
5130
+ status: z2.string().default("processing")
5131
+ }).passthrough();
5118
5132
  var PixelLabObjectSchema = z2.object({
5119
5133
  id: z2.string().min(1),
5120
5134
  name: z2.string().nullable().default(null),
@@ -5193,7 +5207,13 @@ var CharacterListSchema = z2.object({ characters: z2.array(CharacterSchema), tot
5193
5207
  var BackgroundJobSchema = z2.object({
5194
5208
  id: z2.string(),
5195
5209
  status: z2.string(),
5196
- last_response: z2.record(z2.unknown()).nullable().optional()
5210
+ last_response: z2.record(z2.unknown()).nullable().optional(),
5211
+ /**
5212
+ * The billed amount, confirmed live for a `map` object and a standard
5213
+ * character base (docs/ENDPOINTS.md, "Limits and billing"); duplicated at
5214
+ * `last_response.billing_usage` on the jobs observed so far.
5215
+ */
5216
+ usage: UsageSchema
5197
5217
  }).passthrough();
5198
5218
  var DeleteAnimationsSchema = z2.object({ success: z2.boolean(), deleted_count: z2.number().int().default(0), error: z2.string().nullable().optional() }).passthrough();
5199
5219
  function validateResponse(schema, raw, operation) {
@@ -5580,6 +5600,59 @@ var PixelLabClient = class {
5580
5600
  if (page.characters.length === 0 || offset >= page.total) return;
5581
5601
  }
5582
5602
  }
5603
+ /**
5604
+ * Masked inpaint, PixelLab's `/inpaint-v3` (the endpoint its own docs list
5605
+ * first in the Inpaint section, its convention for "reach for this by
5606
+ * default"). Unlike the rest of this client, this method's shape is taken
5607
+ * from the OpenAPI spec, not exercised against a live account: the request
5608
+ * side is exact (`InpaintV3Request`), but a completed job's `last_response`
5609
+ * has no documented example for this endpoint (the spec's only worked
5610
+ * example is a character job's shape). `pollRevision` in pixellab.ts reads
5611
+ * it defensively and fails loudly on an unrecognized shape rather than
5612
+ * guessing.
5613
+ *
5614
+ * `crop_to_mask` defaults true upstream (confirmed in the schema): PixelLab
5615
+ * otherwise blends generated pixels outside the mask edge to "fit
5616
+ * naturally," which is the opposite of what a mask boundary is for.
5617
+ */
5618
+ async inpaintV3(args) {
5619
+ const size = { width: args.width, height: args.height };
5620
+ const body = {
5621
+ description: args.description,
5622
+ inpainting_image: { image: args.image, size },
5623
+ mask_image: { image: args.maskImage, size }
5624
+ };
5625
+ if (args.noBackground != null) body.no_background = args.noBackground;
5626
+ if (args.cropToMask != null) body.crop_to_mask = args.cropToMask;
5627
+ if (args.seed != null) body.seed = args.seed;
5628
+ return validateResponse(
5629
+ RevisionJobSubmitSchema,
5630
+ await this.request("/inpaint-v3", { method: "POST", body: JSON.stringify(body) }),
5631
+ "inpaint-v3"
5632
+ );
5633
+ }
5634
+ /**
5635
+ * Whole-image edit with no mask, PixelLab's `/edit-images-v2`. Same
5636
+ * not-yet-live-verified caveat as `inpaintV3` above. `edit_images` takes an
5637
+ * array (the endpoint supports editing several images with one
5638
+ * instruction); pixelkiln's `revision` model is one parent per child, so
5639
+ * this always sends exactly one.
5640
+ */
5641
+ async editImagesV2(args) {
5642
+ const body = {
5643
+ method: "edit_with_text",
5644
+ description: args.description,
5645
+ edit_images: [{ image: args.image, width: args.width, height: args.height }],
5646
+ image_size: { width: args.width, height: args.height }
5647
+ };
5648
+ if (args.noBackground != null) body.no_background = args.noBackground;
5649
+ if (args.seed != null) body.seed = args.seed;
5650
+ return validateResponse(
5651
+ RevisionJobSubmitSchema,
5652
+ await this.request("/edit-images-v2", { method: "POST", body: JSON.stringify(body) }),
5653
+ "edit-images-v2"
5654
+ );
5655
+ }
5583
5656
  async getBackgroundJob(jobId) {
5584
5657
  const raw = await this.request(`/background-jobs/${encodeURIComponent(jobId)}`);
5585
5658
  return validateResponse(BackgroundJobSchema, raw, "background-jobs/{id}");
@@ -5647,6 +5720,14 @@ function pixelLabObjectUrl(generator, objectId) {
5647
5720
  }
5648
5721
  return null;
5649
5722
  }
5723
+ function billedFromUsage(usage) {
5724
+ if (!usage) return null;
5725
+ if (usage.type === "usd" || usage.type === void 0 && typeof usage.usd === "number") {
5726
+ return typeof usage.usd === "number" ? { amount: usage.usd, unit: "usd" } : null;
5727
+ }
5728
+ if (typeof usage.generations === "number") return { amount: usage.generations, unit: "generations" };
5729
+ return null;
5730
+ }
5650
5731
  function characterCost(spec) {
5651
5732
  const character = spec.character;
5652
5733
  if (!character) return generationCost(spec.width, spec.height, "1dir");
@@ -5712,6 +5793,15 @@ var PixelLabProvider = class _PixelLabProvider {
5712
5793
  supports(generator) {
5713
5794
  return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "character";
5714
5795
  }
5796
+ /**
5797
+ * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
5798
+ * no mask) both exist on PixelLab. `outpaint` does not: there is no
5799
+ * canvas-expansion endpoint in the API, matching docs/REVISIONS.md's note
5800
+ * that no provider ships a tested outpaint path yet.
5801
+ */
5802
+ supportsRevision(mode2) {
5803
+ return mode2 === "inpaint" || mode2 === "image-to-image";
5804
+ }
5715
5805
  /** PixelLab's own constraints: submissions must be >2s apart, and
5716
5806
  * background jobs in flight are capped by subscription tier (Tier 1=8,
5717
5807
  * Tier 2=10, Tier 3=20); 8 is the safe floor across every tier. */
@@ -5732,6 +5822,11 @@ var PixelLabProvider = class _PixelLabProvider {
5732
5822
  return dir;
5733
5823
  }
5734
5824
  estimate(spec) {
5825
+ if (spec.revision) {
5826
+ const width = spec.revision.sourceWidth ?? spec.width;
5827
+ const height = spec.revision.sourceHeight ?? spec.height;
5828
+ return { unit: "generations", amount: generationCost(width, height, "1dir"), candidates: 1 };
5829
+ }
5735
5830
  if (spec.generator === "tiles") {
5736
5831
  return { unit: "generations", amount: spec.cost, candidates: spec.candidates };
5737
5832
  }
@@ -5745,6 +5840,12 @@ var PixelLabProvider = class _PixelLabProvider {
5745
5840
  };
5746
5841
  }
5747
5842
  validate(spec, styleImages) {
5843
+ if (spec.revision?.mode === "inpaint") {
5844
+ const { sourceWidth: width, sourceHeight: height } = spec.revision;
5845
+ if (width != null && height != null && (width < 32 || height < 32 || width > 512 || height > 512)) {
5846
+ throw new Error(`PixelLab inpaint source is ${width}x${height}; the API takes 32 to 512 pixels per side`);
5847
+ }
5848
+ }
5748
5849
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
5749
5850
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
5750
5851
  }
@@ -6017,6 +6118,7 @@ var PixelLabProvider = class _PixelLabProvider {
6017
6118
  if (character.status !== "completed" || !character.rotation_urls) return { status: "processing" };
6018
6119
  const sources = rotationSources(character);
6019
6120
  if (!sources.length) return { status: "failed", error: "character completed with no rotation images" };
6121
+ const backgroundJobId = context?.metadata?.character?.backgroundJobId;
6020
6122
  return {
6021
6123
  status: "ready",
6022
6124
  objectId: character.id,
@@ -6032,7 +6134,8 @@ var PixelLabProvider = class _PixelLabProvider {
6032
6134
  size: character.size,
6033
6135
  updatedAt: character.updated_at ?? null
6034
6136
  }
6035
- }
6137
+ },
6138
+ billed: await this.billedForJob(backgroundJobId)
6036
6139
  };
6037
6140
  }
6038
6141
  /**
@@ -6044,7 +6147,7 @@ var PixelLabProvider = class _PixelLabProvider {
6044
6147
  */
6045
6148
  async pollCharacterAnimation(job, context) {
6046
6149
  const fps = context?.spec?.character?.animation?.fps ?? 8;
6047
- const review = (frames, animationId, groupId) => ({
6150
+ const review = (frames, animationId, groupId, billed) => ({
6048
6151
  status: "review-set",
6049
6152
  objectId: `${job.characterId}#${groupId ?? animationId ?? job.name}`,
6050
6153
  frameUrls: frames,
@@ -6060,7 +6163,8 @@ var PixelLabProvider = class _PixelLabProvider {
6060
6163
  animationName: job.name,
6061
6164
  direction: job.direction
6062
6165
  }
6063
- }
6166
+ },
6167
+ billed
6064
6168
  });
6065
6169
  let cleanedUp = job.jobIds.length === 0;
6066
6170
  for (const id of job.jobIds) {
@@ -6080,17 +6184,18 @@ var PixelLabProvider = class _PixelLabProvider {
6080
6184
  if (Array.isArray(frames) && frames.length && frames.every((f) => typeof f === "string")) {
6081
6185
  const character2 = await this.client.getCharacter(job.characterId);
6082
6186
  const group = findAnimation(character2, job.name, job.direction, animationId);
6083
- return review(frames, animationId, group?.groupId ?? null);
6187
+ return review(frames, animationId, group?.groupId ?? null, billedFromUsage(status.usage));
6084
6188
  }
6085
6189
  }
6086
6190
  const recorded = context?.metadata?.character?.animationId ?? null;
6087
6191
  const character = await this.client.getCharacter(job.characterId);
6088
6192
  const found = findAnimation(character, job.name, job.direction, recorded);
6089
- if (found) return review(found.frames, found.animationId, found.groupId);
6193
+ if (found) return review(found.frames, found.animationId, found.groupId, null);
6090
6194
  return cleanedUp ? { status: "failed", error: `animation job is gone upstream and no "${job.name}" ${job.direction} animation exists on the character` } : { status: "processing" };
6091
6195
  }
6092
6196
  async submit(spec, styleImages, context) {
6093
6197
  this.validate(spec, styleImages);
6198
+ if (spec.revision) return this.submitRevision(spec);
6094
6199
  if (spec.generator === "character") return this.submitCharacter(spec, styleImages, context);
6095
6200
  if (spec.generator === "pixflux") {
6096
6201
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
@@ -6119,7 +6224,7 @@ var PixelLabProvider = class _PixelLabProvider {
6119
6224
  // unlike 1dir's {type, base64, format} payload.
6120
6225
  styleImages: styleImages.map(({ base64, width, height }) => ({ base64, width, height }))
6121
6226
  });
6122
- return { jobId: res2.tile_id };
6227
+ return { jobId: res2.tile_id, metadata: { backgroundJobId: res2.background_job_id } };
6123
6228
  }
6124
6229
  if (spec.generator === "1dir") {
6125
6230
  const res2 = await this.client.create1Direction({
@@ -6128,7 +6233,7 @@ var PixelLabProvider = class _PixelLabProvider {
6128
6233
  view: spec.view === "sidescroller" ? "sidescroller" : "top-down",
6129
6234
  styleImages
6130
6235
  });
6131
- return { jobId: res2.object_id };
6236
+ return { jobId: res2.object_id, metadata: { backgroundJobId: res2.background_job_id } };
6132
6237
  }
6133
6238
  const res = await this.client.createMapObject({
6134
6239
  description: spec.prompt,
@@ -6140,9 +6245,79 @@ var PixelLabProvider = class _PixelLabProvider {
6140
6245
  detail: spec.detail,
6141
6246
  seed: spec.seed
6142
6247
  });
6143
- return { jobId: res.object_id };
6248
+ return { jobId: res.object_id, metadata: { backgroundJobId: res.background_job_id } };
6249
+ }
6250
+ /**
6251
+ * `inpaint` reads the mask as PixelLab's own convention: white marks the
6252
+ * area to generate, black the area to preserve (`InpaintV3Request`'s own
6253
+ * field description). Unlike ComfyUI's revision graph, there is no
6254
+ * configurable side to this — document it as fixed rather than leave an
6255
+ * agent to guess, the way docs/REVISIONS.md tells a ComfyUI author to test
6256
+ * their own graph.
6257
+ */
6258
+ async submitRevision(spec) {
6259
+ const revision = spec.revision;
6260
+ if (!revision.sourceSha256 || !revision.sourceFormat || revision.sourceWidth == null || revision.sourceHeight == null) {
6261
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
6262
+ }
6263
+ const sourceBytes = readFileSync2(revision.sourceFile);
6264
+ if (sha256(sourceBytes) !== revision.sourceSha256) {
6265
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
6266
+ }
6267
+ const image = { base64: sourceBytes.toString("base64"), format: revision.sourceFormat };
6268
+ const width = revision.sourceWidth;
6269
+ const height = revision.sourceHeight;
6270
+ if (revision.mode === "inpaint") {
6271
+ if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
6272
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
6273
+ }
6274
+ const maskBytes = readFileSync2(revision.maskFile);
6275
+ if (sha256(maskBytes) !== revision.maskSha256) {
6276
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision mask changed after the manifest was resolved`);
6277
+ }
6278
+ const res2 = await this.client.inpaintV3({
6279
+ description: spec.prompt,
6280
+ image,
6281
+ width,
6282
+ height,
6283
+ maskImage: { base64: maskBytes.toString("base64"), format: revision.maskFormat },
6284
+ noBackground: spec.noBackground,
6285
+ seed: spec.seed
6286
+ });
6287
+ return { jobId: res2.background_job_id };
6288
+ }
6289
+ if (revision.strength != null) {
6290
+ throw new Error(
6291
+ `${spec.styleId}/${spec.assetId}: PixelLab image-to-image revisions take no strength; edit-images-v2 always applies the full instruction`
6292
+ );
6293
+ }
6294
+ const res = await this.client.editImagesV2({
6295
+ description: spec.prompt,
6296
+ image,
6297
+ width,
6298
+ height,
6299
+ noBackground: spec.noBackground,
6300
+ seed: spec.seed
6301
+ });
6302
+ return { jobId: res.background_job_id };
6303
+ }
6304
+ /**
6305
+ * What a background job actually billed, once its own record is still
6306
+ * around to ask; see `billedFromUsage`. A stale or already-cleaned-up job
6307
+ * (a map object's record expires in hours; see `pollMap`) means "not
6308
+ * known", not a failure worth surfacing.
6309
+ */
6310
+ async billedForJob(backgroundJobId) {
6311
+ if (!backgroundJobId) return null;
6312
+ try {
6313
+ const job = await this.client.getBackgroundJob(backgroundJobId);
6314
+ return billedFromUsage(job.usage);
6315
+ } catch {
6316
+ return null;
6317
+ }
6144
6318
  }
6145
6319
  async poll(jobId, generator, context) {
6320
+ if (context?.spec?.revision) return this.pollRevision(jobId);
6146
6321
  if (generator === "pixflux") {
6147
6322
  const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6148
6323
  if (existsSync7(file)) {
@@ -6154,16 +6329,17 @@ var PixelLabProvider = class _PixelLabProvider {
6154
6329
  error: "pixflux result is no longer cached locally; re-run submit for this asset"
6155
6330
  };
6156
6331
  }
6157
- if (generator === "map") return this.pollMap(jobId);
6158
- if (generator === "tiles") return this.pollTiles(jobId, Boolean(context?.tileFeature));
6332
+ if (generator === "map") return this.pollMap(jobId, context);
6333
+ if (generator === "tiles") return this.pollTiles(jobId, Boolean(context?.tileFeature), context);
6159
6334
  if (generator === "character") return this.pollCharacter(jobId, context);
6335
+ const backgroundJobId = context?.metadata?.backgroundJobId;
6160
6336
  const obj = await this.client.getObject(jobId);
6161
6337
  if (obj.status === "review") {
6162
- return { status: "review", candidateUrls: obj.frame_urls ?? [] };
6338
+ return { status: "review", candidateUrls: obj.frame_urls ?? [], billed: await this.billedForJob(backgroundJobId) };
6163
6339
  }
6164
6340
  if (obj.status === "completed") {
6165
6341
  const url = firstUrl(obj.rotation_urls) ?? obj.preview_url ?? null;
6166
- return { status: "ready", objectId: obj.id, sourceUrl: url, sources: url ? [{ url }] : [] };
6342
+ return { status: "ready", objectId: obj.id, sourceUrl: url, sources: url ? [{ url }] : [], billed: await this.billedForJob(backgroundJobId) };
6167
6343
  }
6168
6344
  if (obj.status === "failed") return { status: "failed", error: "generation failed upstream" };
6169
6345
  return {
@@ -6172,6 +6348,58 @@ var PixelLabProvider = class _PixelLabProvider {
6172
6348
  etaSeconds: obj.eta_seconds ?? null
6173
6349
  };
6174
6350
  }
6351
+ /**
6352
+ * `/inpaint-v3` and `/edit-images-v2` both hand back a plain background
6353
+ * job with no resource of its own, polled generically at
6354
+ * `GET /background-jobs/{id}`. What a *completed* job's `last_response`
6355
+ * actually contains is not documented for either endpoint: the OpenAPI
6356
+ * spec's only worked example of that field is a character job's shape
6357
+ * (`character_id`, `uploaded_directions`, ...), not an inpaint or edit
6358
+ * job's. This reads every image shape seen elsewhere in this client
6359
+ * (a nested `{image: {base64, format}}`, a bare `{base64, format}`, or a
6360
+ * hosted URL under a handful of plausible keys) and fails loudly, naming
6361
+ * the keys it actually got, rather than guess wrong silently. Fixing a
6362
+ * real completed response into this list is a one-line change once one is
6363
+ * seen live.
6364
+ */
6365
+ async pollRevision(jobId) {
6366
+ const job = await this.client.getBackgroundJob(jobId);
6367
+ if (job.status === "failed") return { status: "failed", error: "revision job failed upstream" };
6368
+ if (job.status !== "completed") return { status: "processing" };
6369
+ const billed = billedFromUsage(job.usage);
6370
+ const done = job.last_response ?? {};
6371
+ const urlKeys = ["image_url", "download_url", "url", "output_url"];
6372
+ for (const key of urlKeys) {
6373
+ const url = done[key];
6374
+ if (typeof url === "string" && url) {
6375
+ return { status: "ready", objectId: jobId, sourceUrl: url, sources: [{ url }], billed };
6376
+ }
6377
+ }
6378
+ const imageKeys = ["image", "output_image", "result_image", "edited_image"];
6379
+ for (const key of imageKeys) {
6380
+ const candidate = done[key];
6381
+ const base64 = extractBase64(candidate);
6382
+ if (base64) {
6383
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6384
+ writeFileSync(file, Buffer.from(base64, "base64"));
6385
+ const sourceUrl = `file://${file}`;
6386
+ return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
6387
+ }
6388
+ }
6389
+ if (Array.isArray(done.images) && done.images.length) {
6390
+ const base64 = extractBase64(done.images[0]);
6391
+ if (base64) {
6392
+ const file = path12.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
6393
+ writeFileSync(file, Buffer.from(base64, "base64"));
6394
+ const sourceUrl = `file://${file}`;
6395
+ return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
6396
+ }
6397
+ }
6398
+ return {
6399
+ status: "failed",
6400
+ error: `Invalid PixelLab response for revision job ${jobId}: completed with no recognized image field (got: ${Object.keys(done).join(", ") || "no keys"}); update pollRevision in src/providers/pixellab.ts with the real shape`
6401
+ };
6402
+ }
6175
6403
  /**
6176
6404
  * Map objects need their own path because the `/map-objects/{id}` record is
6177
6405
  * deleted upstream roughly 8 hours after creation while the image survives in
@@ -6179,7 +6407,8 @@ var PixelLabProvider = class _PixelLabProvider {
6179
6407
  * `/objects` four months on, with `/map-objects` returning 404 for the same
6180
6408
  * id. So a 404 here is not evidence the work is lost.
6181
6409
  */
6182
- async pollMap(jobId) {
6410
+ async pollMap(jobId, context) {
6411
+ const backgroundJobId = context?.metadata?.backgroundJobId;
6183
6412
  try {
6184
6413
  const obj = await this.client.getMapObject(jobId);
6185
6414
  if (obj.status === "completed" && obj.download_url) {
@@ -6187,7 +6416,8 @@ var PixelLabProvider = class _PixelLabProvider {
6187
6416
  status: "ready",
6188
6417
  objectId: jobId,
6189
6418
  sourceUrl: obj.download_url,
6190
- sources: [{ url: obj.download_url }]
6419
+ sources: [{ url: obj.download_url }],
6420
+ billed: await this.billedForJob(backgroundJobId)
6191
6421
  };
6192
6422
  }
6193
6423
  if (obj.status === "failed") return { status: "failed", error: "generation failed upstream" };
@@ -6198,7 +6428,7 @@ var PixelLabProvider = class _PixelLabProvider {
6198
6428
  const survivor = await this.client.getObject(jobId).catch(() => null);
6199
6429
  const url = firstUrl(survivor?.rotation_urls) ?? survivor?.preview_url ?? null;
6200
6430
  if (survivor?.status === "completed" && url) {
6201
- return { status: "ready", objectId: survivor.id, sourceUrl: url, sources: [{ url }] };
6431
+ return { status: "ready", objectId: survivor.id, sourceUrl: url, sources: [{ url }], billed: null };
6202
6432
  }
6203
6433
  return {
6204
6434
  status: "failed",
@@ -6211,12 +6441,14 @@ var PixelLabProvider = class _PixelLabProvider {
6211
6441
  * `storage_urls` once finished. There is no `status` field to read and no
6212
6442
  * progress percentage on offer, so "processing" here carries no ETA.
6213
6443
  */
6214
- async pollTiles(tileId, connectable) {
6444
+ async pollTiles(tileId, connectable, context) {
6215
6445
  try {
6216
6446
  const set = await this.client.getTilesPro(tileId);
6217
6447
  const tiles = tilesInIndexOrder(set.storage_urls);
6218
6448
  if (!tiles.length) return { status: "failed", error: "tiles job returned no storage urls" };
6219
- if (!connectable) return { status: "review", candidateUrls: tiles.map((tile) => tile.url) };
6449
+ const backgroundJobId = context?.metadata?.backgroundJobId;
6450
+ const billed = await this.billedForJob(backgroundJobId);
6451
+ if (!connectable) return { status: "review", candidateUrls: tiles.map((tile) => tile.url), billed };
6220
6452
  return {
6221
6453
  status: "ready",
6222
6454
  objectId: tileId,
@@ -6228,7 +6460,8 @@ var PixelLabProvider = class _PixelLabProvider {
6228
6460
  metadata: {
6229
6461
  tileKind: set.kind,
6230
6462
  ...set.tile_rules ? { tileRules: set.tile_rules } : {}
6231
- }
6463
+ },
6464
+ billed
6232
6465
  };
6233
6466
  } catch (err) {
6234
6467
  if (err instanceof PixelLabError && err.status === 423) return { status: "processing" };
@@ -6358,6 +6591,15 @@ function firstUrl(urls) {
6358
6591
  if (!urls) return null;
6359
6592
  return Object.values(urls).find((u) => typeof u === "string") ?? null;
6360
6593
  }
6594
+ function extractBase64(value) {
6595
+ if (typeof value === "string" && value) return value;
6596
+ if (value && typeof value === "object") {
6597
+ const obj = value;
6598
+ if (typeof obj.base64 === "string" && obj.base64) return obj.base64;
6599
+ if (obj.image) return extractBase64(obj.image);
6600
+ }
6601
+ return null;
6602
+ }
6361
6603
  function readReference(spec, reference) {
6362
6604
  const images = {};
6363
6605
  for (const [direction, image] of Object.entries(reference)) {
@@ -10722,6 +10964,7 @@ function retireGeneration(entry, retiredAt) {
10722
10964
  downloadedAt: entry.downloadedAt,
10723
10965
  cost: entry.cost,
10724
10966
  costUnit: entry.costUnit,
10967
+ billed: entry.billed,
10725
10968
  provider: entry.provider,
10726
10969
  postprocess: entry.postprocess,
10727
10970
  retiredAt
@@ -10812,6 +11055,7 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
10812
11055
  downloadedAt: generation.downloadedAt,
10813
11056
  cost: generation.cost,
10814
11057
  costUnit: generation.costUnit,
11058
+ billed: generation.billed,
10815
11059
  provider: generation.provider,
10816
11060
  postprocess: generation.postprocess,
10817
11061
  history
@@ -10870,12 +11114,15 @@ function describeHistory(media, entry, cacheDir) {
10870
11114
  height: generation.height,
10871
11115
  cost: generation.cost,
10872
11116
  costUnit: generation.costUnit,
11117
+ billed: generation.billed,
10873
11118
  submittedAt: generation.submittedAt,
10874
11119
  downloadedAt: generation.downloadedAt,
10875
11120
  retiredAt: generation.retiredAt,
10876
11121
  outputs,
10877
11122
  cached: outputs.length > 0 && outputs.every((output) => output.url !== null),
10878
- upstreamUrl: generation.provider === "pixellab" ? pixelLabObjectUrl(generation.generator, generation.objectId) : null
11123
+ // A revision's jobId is a plain background job id, not a PixelLab
11124
+ // account object: there is no `/create-object` page to point at.
11125
+ upstreamUrl: generation.provider === "pixellab" && !entry.revision ? pixelLabObjectUrl(generation.generator, generation.objectId) : null
10879
11126
  };
10880
11127
  });
10881
11128
  }
@@ -11113,6 +11360,7 @@ async function buildGallerySnapshot(opts) {
11113
11360
  fps: quality?.frameSet?.fps ?? metadataFps(entry) ?? spec.quality?.fps ?? null,
11114
11361
  cost: entry?.cost ?? 0,
11115
11362
  costUnit: entry?.costUnit ?? spec.costUnit,
11363
+ billed: entry?.billed ?? null,
11116
11364
  estimatedCost: spec.cost,
11117
11365
  candidates: spec.candidates,
11118
11366
  submittedAt: entry?.submittedAt ?? null,
@@ -11139,7 +11387,7 @@ async function buildGallerySnapshot(opts) {
11139
11387
  editChanged,
11140
11388
  editStatus,
11141
11389
  editMeta,
11142
- upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
11390
+ upstreamUrl: entry?.provider === "pixellab" && !spec.revision ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
11143
11391
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
11144
11392
  history: entry ? describeHistory(media, entry, cacheDir) : [],
11145
11393
  tags: spec.tags,
@@ -11176,6 +11424,7 @@ async function buildGallerySnapshot(opts) {
11176
11424
  fps: metadataFps(entry),
11177
11425
  cost: entry.cost,
11178
11426
  costUnit: entry.costUnit,
11427
+ billed: entry.billed,
11179
11428
  estimatedCost: null,
11180
11429
  candidates: null,
11181
11430
  submittedAt: entry.submittedAt,
@@ -11202,7 +11451,7 @@ async function buildGallerySnapshot(opts) {
11202
11451
  editChanged: [],
11203
11452
  editStatus: null,
11204
11453
  editMeta: null,
11205
- upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
11454
+ upstreamUrl: entry.provider === "pixellab" && !entry.revision ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
11206
11455
  refreshable: false,
11207
11456
  history: describeHistory(media, entry, cacheDir),
11208
11457
  tags: [],
@@ -11818,7 +12067,8 @@ async function poll(provider, lock, lockPath, opts = {}) {
11818
12067
  ...entry.providerMetadata[provider.id],
11819
12068
  ...state.metadata
11820
12069
  }
11821
- } : entry.providerMetadata
12070
+ } : entry.providerMetadata,
12071
+ billed: state.billed ?? entry.billed
11822
12072
  });
11823
12073
  result.review++;
11824
12074
  log2(
@@ -11838,7 +12088,8 @@ async function poll(provider, lock, lockPath, opts = {}) {
11838
12088
  ...state.metadata
11839
12089
  }
11840
12090
  } : entry.providerMetadata,
11841
- error: null
12091
+ error: null,
12092
+ billed: state.billed ?? entry.billed
11842
12093
  });
11843
12094
  result.completed++;
11844
12095
  log2(` ready ${key}`);
@@ -13746,6 +13997,10 @@ async function runLifecycle(args, wave, spent) {
13746
13997
  }
13747
13998
 
13748
13999
  // src/cli/commands/history.ts
14000
+ function billedNote(cost, costUnit, billed) {
14001
+ if (!billed || billed.unit === costUnit && billed.amount === cost) return "";
14002
+ return ` (billed ${formatCost(billed.unit, billed.amount)})`;
14003
+ }
13749
14004
  async function runHistory(args) {
13750
14005
  const { loaded, specs, lock } = await openProject2(args);
13751
14006
  const limit = historyLimit(loaded.manifest);
@@ -13756,7 +14011,7 @@ async function runHistory(args) {
13756
14011
  limit,
13757
14012
  assets: selected.map(({ spec, entry }) => ({
13758
14013
  key: lockKey(spec.styleId, spec.assetId),
13759
- current: entry ? { objectId: entry.objectId, outputs: entry.outputs, downloadedAt: entry.downloadedAt, cost: entry.cost, costUnit: entry.costUnit } : null,
14014
+ current: entry ? { objectId: entry.objectId, outputs: entry.outputs, downloadedAt: entry.downloadedAt, cost: entry.cost, costUnit: entry.costUnit, billed: entry.billed } : null,
13760
14015
  history: entry?.history ?? []
13761
14016
  }))
13762
14017
  }, null, 2));
@@ -13770,10 +14025,10 @@ async function runHistory(args) {
13770
14025
  const key = lockKey(spec.styleId, spec.assetId);
13771
14026
  log(`
13772
14027
  ${key}`);
13773
- log(` current ${entry.outputs[0]?.sha256.slice(0, 12) ?? "\u2014"} ${entry.downloadedAt ?? ""} ${entry.objectId ?? ""}`);
14028
+ log(` current ${entry.outputs[0]?.sha256.slice(0, 12) ?? "\u2014"} ${entry.downloadedAt ?? ""} ${entry.objectId ?? ""}${billedNote(entry.cost, entry.costUnit, entry.billed)}`);
13774
14029
  for (const [i, generation] of entry.history.entries()) {
13775
14030
  const changed = generation.prompt !== entry.prompt ? " (different prompt)" : "";
13776
- log(` #${String(i + 1).padEnd(2)} ${generation.outputs[0]?.sha256.slice(0, 12) ?? "\u2014"} ${generation.downloadedAt ?? ""} ${generation.objectId ?? ""}${changed}`);
14031
+ log(` #${String(i + 1).padEnd(2)} ${generation.outputs[0]?.sha256.slice(0, 12) ?? "\u2014"} ${generation.downloadedAt ?? ""} ${generation.objectId ?? ""}${changed}${billedNote(generation.cost, generation.costUnit, generation.billed)}`);
13777
14032
  }
13778
14033
  }
13779
14034
  if (!shown) log(` no asset has a previous generation recorded${selected.length ? "" : " (nothing selected)"}`);
@@ -14468,21 +14723,29 @@ async function runPlan(args) {
14468
14723
  actionable: group.actionable.map((item) => item.key)
14469
14724
  })),
14470
14725
  actionable: plan.actionable.map((i) => i.key),
14471
- items: plan.items.map(({ key, state, reason, quality, spec }) => ({
14472
- key,
14473
- state,
14474
- reason,
14475
- ...spec.revision ? {
14476
- revision: {
14477
- mode: spec.revision.mode,
14478
- from: spec.revision.sourceAssetId,
14479
- sourceSha256: spec.revision.sourceSha256,
14480
- ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {},
14481
- ...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
14482
- }
14483
- } : {},
14484
- ...quality ? { quality: { state: quality.state, reason: quality.reason } } : {}
14485
- }))
14726
+ items: plan.items.map(({ key, state, reason, quality, spec }) => {
14727
+ const entry = lock.entries[key];
14728
+ const billedDiffers = entry?.billed && (entry.billed.unit !== entry.costUnit || entry.billed.amount !== entry.cost);
14729
+ return {
14730
+ key,
14731
+ state,
14732
+ reason,
14733
+ ...spec.revision ? {
14734
+ revision: {
14735
+ mode: spec.revision.mode,
14736
+ from: spec.revision.sourceAssetId,
14737
+ sourceSha256: spec.revision.sourceSha256,
14738
+ ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {},
14739
+ ...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
14740
+ }
14741
+ } : {},
14742
+ ...quality ? { quality: { state: quality.state, reason: quality.reason } } : {},
14743
+ // The estimate a wave budget spent against; present alongside the
14744
+ // provider's actual charge only when `poll` has read one and it
14745
+ // differs, since that is the only time the gap is worth a look.
14746
+ ...billedDiffers ? { cost: entry.cost, costUnit: entry.costUnit, billed: entry.billed } : {}
14747
+ };
14748
+ })
14486
14749
  }, null, 2));
14487
14750
  } else {
14488
14751
  printPlan(plan);