pixelkiln 0.48.0 → 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/index.cjs CHANGED
@@ -1243,6 +1243,10 @@ var TilesProSchema = import_zod2.z.object({
1243
1243
  kind: import_zod2.z.string().nullable().default(null),
1244
1244
  tile_rules: import_zod2.z.record(import_zod2.z.unknown()).nullable().optional()
1245
1245
  }).passthrough();
1246
+ var RevisionJobSubmitSchema = import_zod2.z.object({
1247
+ background_job_id: import_zod2.z.string().min(1),
1248
+ status: import_zod2.z.string().default("processing")
1249
+ }).passthrough();
1246
1250
  var PixelLabObjectSchema = import_zod2.z.object({
1247
1251
  id: import_zod2.z.string().min(1),
1248
1252
  name: import_zod2.z.string().nullable().default(null),
@@ -1714,6 +1718,59 @@ var PixelLabClient = class {
1714
1718
  if (page.characters.length === 0 || offset >= page.total) return;
1715
1719
  }
1716
1720
  }
1721
+ /**
1722
+ * Masked inpaint, PixelLab's `/inpaint-v3` (the endpoint its own docs list
1723
+ * first in the Inpaint section, its convention for "reach for this by
1724
+ * default"). Unlike the rest of this client, this method's shape is taken
1725
+ * from the OpenAPI spec, not exercised against a live account: the request
1726
+ * side is exact (`InpaintV3Request`), but a completed job's `last_response`
1727
+ * has no documented example for this endpoint (the spec's only worked
1728
+ * example is a character job's shape). `pollRevision` in pixellab.ts reads
1729
+ * it defensively and fails loudly on an unrecognized shape rather than
1730
+ * guessing.
1731
+ *
1732
+ * `crop_to_mask` defaults true upstream (confirmed in the schema): PixelLab
1733
+ * otherwise blends generated pixels outside the mask edge to "fit
1734
+ * naturally," which is the opposite of what a mask boundary is for.
1735
+ */
1736
+ async inpaintV3(args) {
1737
+ const size = { width: args.width, height: args.height };
1738
+ const body = {
1739
+ description: args.description,
1740
+ inpainting_image: { image: args.image, size },
1741
+ mask_image: { image: args.maskImage, size }
1742
+ };
1743
+ if (args.noBackground != null) body.no_background = args.noBackground;
1744
+ if (args.cropToMask != null) body.crop_to_mask = args.cropToMask;
1745
+ if (args.seed != null) body.seed = args.seed;
1746
+ return validateResponse(
1747
+ RevisionJobSubmitSchema,
1748
+ await this.request("/inpaint-v3", { method: "POST", body: JSON.stringify(body) }),
1749
+ "inpaint-v3"
1750
+ );
1751
+ }
1752
+ /**
1753
+ * Whole-image edit with no mask, PixelLab's `/edit-images-v2`. Same
1754
+ * not-yet-live-verified caveat as `inpaintV3` above. `edit_images` takes an
1755
+ * array (the endpoint supports editing several images with one
1756
+ * instruction); pixelkiln's `revision` model is one parent per child, so
1757
+ * this always sends exactly one.
1758
+ */
1759
+ async editImagesV2(args) {
1760
+ const body = {
1761
+ method: "edit_with_text",
1762
+ description: args.description,
1763
+ edit_images: [{ image: args.image, width: args.width, height: args.height }],
1764
+ image_size: { width: args.width, height: args.height }
1765
+ };
1766
+ if (args.noBackground != null) body.no_background = args.noBackground;
1767
+ if (args.seed != null) body.seed = args.seed;
1768
+ return validateResponse(
1769
+ RevisionJobSubmitSchema,
1770
+ await this.request("/edit-images-v2", { method: "POST", body: JSON.stringify(body) }),
1771
+ "edit-images-v2"
1772
+ );
1773
+ }
1717
1774
  async getBackgroundJob(jobId) {
1718
1775
  const raw = await this.request(`/background-jobs/${encodeURIComponent(jobId)}`);
1719
1776
  return validateResponse(BackgroundJobSchema, raw, "background-jobs/{id}");
@@ -2495,6 +2552,15 @@ var PixelLabProvider = class _PixelLabProvider {
2495
2552
  supports(generator) {
2496
2553
  return generator === "1dir" || generator === "map" || generator === "pixflux" || generator === "tiles" || generator === "character";
2497
2554
  }
2555
+ /**
2556
+ * `inpaint` (`/inpaint-v3`, a mask) and `image-to-image` (`/edit-images-v2`,
2557
+ * no mask) both exist on PixelLab. `outpaint` does not: there is no
2558
+ * canvas-expansion endpoint in the API, matching docs/REVISIONS.md's note
2559
+ * that no provider ships a tested outpaint path yet.
2560
+ */
2561
+ supportsRevision(mode2) {
2562
+ return mode2 === "inpaint" || mode2 === "image-to-image";
2563
+ }
2498
2564
  /** PixelLab's own constraints: submissions must be >2s apart, and
2499
2565
  * background jobs in flight are capped by subscription tier (Tier 1=8,
2500
2566
  * Tier 2=10, Tier 3=20); 8 is the safe floor across every tier. */
@@ -2515,6 +2581,11 @@ var PixelLabProvider = class _PixelLabProvider {
2515
2581
  return dir;
2516
2582
  }
2517
2583
  estimate(spec) {
2584
+ if (spec.revision) {
2585
+ const width = spec.revision.sourceWidth ?? spec.width;
2586
+ const height = spec.revision.sourceHeight ?? spec.height;
2587
+ return { unit: "generations", amount: generationCost(width, height, "1dir"), candidates: 1 };
2588
+ }
2518
2589
  if (spec.generator === "tiles") {
2519
2590
  return { unit: "generations", amount: spec.cost, candidates: spec.candidates };
2520
2591
  }
@@ -2528,6 +2599,12 @@ var PixelLabProvider = class _PixelLabProvider {
2528
2599
  };
2529
2600
  }
2530
2601
  validate(spec, styleImages) {
2602
+ if (spec.revision?.mode === "inpaint") {
2603
+ const { sourceWidth: width, sourceHeight: height } = spec.revision;
2604
+ if (width != null && height != null && (width < 32 || height < 32 || width > 512 || height > 512)) {
2605
+ throw new Error(`PixelLab inpaint source is ${width}x${height}; the API takes 32 to 512 pixels per side`);
2606
+ }
2607
+ }
2531
2608
  if (spec.generator === "1dir" && (spec.width < 32 || spec.width > 256)) {
2532
2609
  throw new Error("PixelLab 1dir dimensions must be between 32 and 256 pixels");
2533
2610
  }
@@ -2877,6 +2954,7 @@ var PixelLabProvider = class _PixelLabProvider {
2877
2954
  }
2878
2955
  async submit(spec, styleImages, context) {
2879
2956
  this.validate(spec, styleImages);
2957
+ if (spec.revision) return this.submitRevision(spec);
2880
2958
  if (spec.generator === "character") return this.submitCharacter(spec, styleImages, context);
2881
2959
  if (spec.generator === "pixflux") {
2882
2960
  const swatch = spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0;
@@ -2928,6 +3006,60 @@ var PixelLabProvider = class _PixelLabProvider {
2928
3006
  });
2929
3007
  return { jobId: res.object_id, metadata: { backgroundJobId: res.background_job_id } };
2930
3008
  }
3009
+ /**
3010
+ * `inpaint` reads the mask as PixelLab's own convention: white marks the
3011
+ * area to generate, black the area to preserve (`InpaintV3Request`'s own
3012
+ * field description). Unlike ComfyUI's revision graph, there is no
3013
+ * configurable side to this — document it as fixed rather than leave an
3014
+ * agent to guess, the way docs/REVISIONS.md tells a ComfyUI author to test
3015
+ * their own graph.
3016
+ */
3017
+ async submitRevision(spec) {
3018
+ const revision = spec.revision;
3019
+ if (!revision.sourceSha256 || !revision.sourceFormat || revision.sourceWidth == null || revision.sourceHeight == null) {
3020
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source is not ready`);
3021
+ }
3022
+ const sourceBytes = (0, import_node_fs2.readFileSync)(revision.sourceFile);
3023
+ if (sha256(sourceBytes) !== revision.sourceSha256) {
3024
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision source changed after the manifest was resolved`);
3025
+ }
3026
+ const image = { base64: sourceBytes.toString("base64"), format: revision.sourceFormat };
3027
+ const width = revision.sourceWidth;
3028
+ const height = revision.sourceHeight;
3029
+ if (revision.mode === "inpaint") {
3030
+ if (!revision.maskFile || !revision.maskSha256 || !revision.maskFormat) {
3031
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision mask is not ready`);
3032
+ }
3033
+ const maskBytes = (0, import_node_fs2.readFileSync)(revision.maskFile);
3034
+ if (sha256(maskBytes) !== revision.maskSha256) {
3035
+ throw new Error(`${spec.styleId}/${spec.assetId}: revision mask changed after the manifest was resolved`);
3036
+ }
3037
+ const res2 = await this.client.inpaintV3({
3038
+ description: spec.prompt,
3039
+ image,
3040
+ width,
3041
+ height,
3042
+ maskImage: { base64: maskBytes.toString("base64"), format: revision.maskFormat },
3043
+ noBackground: spec.noBackground,
3044
+ seed: spec.seed
3045
+ });
3046
+ return { jobId: res2.background_job_id };
3047
+ }
3048
+ if (revision.strength != null) {
3049
+ throw new Error(
3050
+ `${spec.styleId}/${spec.assetId}: PixelLab image-to-image revisions take no strength; edit-images-v2 always applies the full instruction`
3051
+ );
3052
+ }
3053
+ const res = await this.client.editImagesV2({
3054
+ description: spec.prompt,
3055
+ image,
3056
+ width,
3057
+ height,
3058
+ noBackground: spec.noBackground,
3059
+ seed: spec.seed
3060
+ });
3061
+ return { jobId: res.background_job_id };
3062
+ }
2931
3063
  /**
2932
3064
  * What a background job actually billed, once its own record is still
2933
3065
  * around to ask; see `billedFromUsage`. A stale or already-cleaned-up job
@@ -2944,6 +3076,7 @@ var PixelLabProvider = class _PixelLabProvider {
2944
3076
  }
2945
3077
  }
2946
3078
  async poll(jobId, generator, context) {
3079
+ if (context?.spec?.revision) return this.pollRevision(jobId);
2947
3080
  if (generator === "pixflux") {
2948
3081
  const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
2949
3082
  if ((0, import_node_fs2.existsSync)(file)) {
@@ -2974,6 +3107,58 @@ var PixelLabProvider = class _PixelLabProvider {
2974
3107
  etaSeconds: obj.eta_seconds ?? null
2975
3108
  };
2976
3109
  }
3110
+ /**
3111
+ * `/inpaint-v3` and `/edit-images-v2` both hand back a plain background
3112
+ * job with no resource of its own, polled generically at
3113
+ * `GET /background-jobs/{id}`. What a *completed* job's `last_response`
3114
+ * actually contains is not documented for either endpoint: the OpenAPI
3115
+ * spec's only worked example of that field is a character job's shape
3116
+ * (`character_id`, `uploaded_directions`, ...), not an inpaint or edit
3117
+ * job's. This reads every image shape seen elsewhere in this client
3118
+ * (a nested `{image: {base64, format}}`, a bare `{base64, format}`, or a
3119
+ * hosted URL under a handful of plausible keys) and fails loudly, naming
3120
+ * the keys it actually got, rather than guess wrong silently. Fixing a
3121
+ * real completed response into this list is a one-line change once one is
3122
+ * seen live.
3123
+ */
3124
+ async pollRevision(jobId) {
3125
+ const job = await this.client.getBackgroundJob(jobId);
3126
+ if (job.status === "failed") return { status: "failed", error: "revision job failed upstream" };
3127
+ if (job.status !== "completed") return { status: "processing" };
3128
+ const billed = billedFromUsage(job.usage);
3129
+ const done = job.last_response ?? {};
3130
+ const urlKeys = ["image_url", "download_url", "url", "output_url"];
3131
+ for (const key of urlKeys) {
3132
+ const url = done[key];
3133
+ if (typeof url === "string" && url) {
3134
+ return { status: "ready", objectId: jobId, sourceUrl: url, sources: [{ url }], billed };
3135
+ }
3136
+ }
3137
+ const imageKeys = ["image", "output_image", "result_image", "edited_image"];
3138
+ for (const key of imageKeys) {
3139
+ const candidate = done[key];
3140
+ const base64 = extractBase64(candidate);
3141
+ if (base64) {
3142
+ const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
3143
+ (0, import_node_fs2.writeFileSync)(file, Buffer.from(base64, "base64"));
3144
+ const sourceUrl = `file://${file}`;
3145
+ return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
3146
+ }
3147
+ }
3148
+ if (Array.isArray(done.images) && done.images.length) {
3149
+ const base64 = extractBase64(done.images[0]);
3150
+ if (base64) {
3151
+ const file = import_node_path.default.join(_PixelLabProvider.cacheDir(), `${jobId}.png`);
3152
+ (0, import_node_fs2.writeFileSync)(file, Buffer.from(base64, "base64"));
3153
+ const sourceUrl = `file://${file}`;
3154
+ return { status: "ready", objectId: jobId, sourceUrl, sources: [{ url: sourceUrl }], billed };
3155
+ }
3156
+ }
3157
+ return {
3158
+ status: "failed",
3159
+ 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`
3160
+ };
3161
+ }
2977
3162
  /**
2978
3163
  * Map objects need their own path because the `/map-objects/{id}` record is
2979
3164
  * deleted upstream roughly 8 hours after creation while the image survives in
@@ -3165,6 +3350,15 @@ function firstUrl(urls) {
3165
3350
  if (!urls) return null;
3166
3351
  return Object.values(urls).find((u) => typeof u === "string") ?? null;
3167
3352
  }
3353
+ function extractBase64(value) {
3354
+ if (typeof value === "string" && value) return value;
3355
+ if (value && typeof value === "object") {
3356
+ const obj = value;
3357
+ if (typeof obj.base64 === "string" && obj.base64) return obj.base64;
3358
+ if (obj.image) return extractBase64(obj.image);
3359
+ }
3360
+ return null;
3361
+ }
3168
3362
  function readReference(spec, reference) {
3169
3363
  const images = {};
3170
3364
  for (const [direction, image] of Object.entries(reference)) {
@@ -12336,7 +12530,9 @@ function describeHistory(media, entry, cacheDir) {
12336
12530
  retiredAt: generation.retiredAt,
12337
12531
  outputs,
12338
12532
  cached: outputs.length > 0 && outputs.every((output) => output.url !== null),
12339
- upstreamUrl: generation.provider === "pixellab" ? pixelLabObjectUrl(generation.generator, generation.objectId) : null
12533
+ // A revision's jobId is a plain background job id, not a PixelLab
12534
+ // account object: there is no `/create-object` page to point at.
12535
+ upstreamUrl: generation.provider === "pixellab" && !entry.revision ? pixelLabObjectUrl(generation.generator, generation.objectId) : null
12340
12536
  };
12341
12537
  });
12342
12538
  }
@@ -12601,7 +12797,7 @@ async function buildGallerySnapshot(opts) {
12601
12797
  editChanged,
12602
12798
  editStatus,
12603
12799
  editMeta,
12604
- upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
12800
+ upstreamUrl: entry?.provider === "pixellab" && !spec.revision ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
12605
12801
  refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
12606
12802
  history: entry ? describeHistory(media, entry, cacheDir) : [],
12607
12803
  tags: spec.tags,
@@ -12665,7 +12861,7 @@ async function buildGallerySnapshot(opts) {
12665
12861
  editChanged: [],
12666
12862
  editStatus: null,
12667
12863
  editMeta: null,
12668
- upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
12864
+ upstreamUrl: entry.provider === "pixellab" && !entry.revision ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
12669
12865
  refreshable: false,
12670
12866
  history: describeHistory(media, entry, cacheDir),
12671
12867
  tags: [],