Guides

Controlled asset revisions

Revise current art with hashed parents, masks, approval gates, and side-by-side review. Covers PixelLab palette cleanup, animation, interpolation, and chaining revisions across styles.

PixelKiln can declare a new asset as a controlled revision of another asset. The parent may be committed art, a downloaded generation, or an approved quality output. PixelKiln hashes the exact parent and mask bytes, blocks stale dependencies before submission, and records the lineage in the lockfile.

The manifest and pipeline are provider-neutral. ComfyUI, PixelLab and Scenario implement it; PixelLab covers image-to-image, inpaint, reduce-colors, correct-pixelart, remove-background, normal-map, animate, animate-pixminimax, animate-skeleton, interpolate, and edit-animation, not outpaint (its API has no canvas-expansion endpoint). ComfyUI covers every mode generically except the five frame-set modes (animate, animate-pixminimax, animate-skeleton, interpolate, edit-animation), which need an ordered frame set its revision path cannot produce. Scenario covers image-to-image only, by uploading the parent as a reference image to its editing models (Set up Scenario). Retro Diffusion rejects revision work during offline resolution instead of silently starting a fresh text-to-image job.

Image-to-image

Keep the parent as its own asset and point the child at its stable asset id:

{
  "assets": {
    "tower-rough": {
      "prompt": "rough stone watchtower",
      "source": "concepts/tower-rough.png"
    },
    "tower-snow": {
      "prompt": "preserve the tower silhouette; add snow, ice, and cold blue shadows",
      "width": 96,
      "height": 96,
      "revision": {
        "mode": "image-to-image",
        "from": "tower-rough",
        "strength": 0.3
      }
    }
  }
}

from names another asset. It lives in the child's own style unless fromStyle names another (see Across styles). A source and its revision cannot be the same asset, and revision chains cannot contain a cycle. source and revision are mutually exclusive on one asset.

Start with a low strength. Values around 0.2–0.4 give the workflow room to change texture and lighting while retaining more of the source. Higher values permit larger changes and make silhouette drift more likely. Strength is an input to the provider workflow, not a cross-model quality guarantee.

From the gallery (pixelkiln gallery --edit): open a completed or committed asset's drawer and use "+ New revision" under "Revisions from this asset" to create a child of it. On a PixelLab sprite the mode picker offers image-to-image, inpaint, animate, animate-pixminimax, interpolate, reduce-colors, correct-pixelart, remove-background, and normal-map; on a PixelLab frame set (or a character's directions) it offers edit-animation, reduce-colors, and correct-pixelart, which take the whole set, plus normal-map on an animation loop. normal-map is hidden in a style that sets enforcePalette or quality. ComfyUI sprites get image-to-image and inpaint; other providers get image-to-image. The form asks only for the new asset id, the instruction or motion, and the mode's own options (strength, frames, fps, colours, dithering, removal task).

inpaint opens a mask editor over the sprite: paint the area to repaint with a brush sized in sprite pixels, erase, invert, undo. Saving writes the mask as a black-and-white PNG of the sprite's size at the path you give (default masks/<asset-id>.png) and declares the revision in one step; if the manifest refuses the asset the mask file is removed again. The sprite must be 32 to 512 pixels per side. interpolate offers the project's other single-image records of the same style and size as the ending keyframe (the parent is the start), for sprites 16 to 128 pixels per side; it appears only when there is one. The button only appears once the parent has usable pixels (committed source, a current downloaded generation, or a current approved quality output); the plan still reports blocked with the real reason if that turns out wrong once the asset exists. outpaint is not offered; no provider implements it.

"+ Skeleton animation", beside it on a single PixelLab sprite, creates an animate-skeleton child instead. The poses come from a keypoints file already in the project, from pasted JSON, or from "Estimate poses", which runs PixelLab's estimate-skeleton on the sprite (a direct call on your account, confirmed first and kept apart from the generation budget; at most --estimate-limit per session, default 10; square 16–256px sprites only) and fills in the starting pose and four frames to edit. The poses open in a drag-to-edit pose editor: the sprite with the pose being edited over it and the previous pose faint underneath, a frame strip, and tools to copy the previous pose, mirror a pose left to right, add or remove frames (3 to 15), play the motion, and undo. Arrow keys nudge the selected joint and ,/. step frames. Dragging rewrites the JSON beside it, and editing the JSON redraws the editor. Saving writes the keypoints file inside the project (never over an existing file unless asked, and removed again if the manifest refuses the asset) and adds the revision. An animate-skeleton record's Lineage section draws its poses over its parent the same way, and "Edit poses" opens the same editor on its keypoints file. Saving rewrites the file only if it still holds what the page loaded (an edit made elsewhere since is refused, not overwritten), and the animation is stale until generated again.

Across styles

A style has one provider and one model, so a chain that generates with one model and cleans up with another spans styles. Name the parent's style:

{
  "styles": {
    "gen": { "generator": "map", "outDir": "art/gen", "providerOptions": { "scenario": { "modelId": "model_bfl-flux-2-klein-9b", "maxComputeUnits": 1 } } },
    "pixelate": { "generator": "map", "outDir": "art/pixelate", "providerOptions": { "scenario": { "modelId": "model_sc-pixelate", "maxComputeUnits": 5, "referenceParameter": "image", "referenceArray": false } } }
  },
  "assets": {
    "knight": { "prompt": "a knight, isolated on white", "styles": ["gen"] },
    "knight-px": {
      "prompt": "pixelate",
      "styles": ["pixelate"],
      "revision": { "mode": "image-to-image", "from": "knight", "fromStyle": "gen" }
    }
  }
}

The child starts from the parent's output file, however that style produced it (any provider, or a placed source), and the dependency gate is unchanged: the child is blocked until the parent is downloaded, current, and untouched, and gen takes the whole chain to the end in one run, one wave per link. The parent must be an asset in the named style. Styles may depend on one another but not in a cycle, and fromStyle naming the asset's own style is the ordinary case. --style still selects which specs a run returns; a parent style it excludes is resolved, not generated.

A quality profile gates the style it is on. Put it on the last style of a chain, whose parents are in other styles, and only that style's output is gated. A profile on a style that also holds the raw parent would block the child.

Inpainting

An inpaint revision also declares a manifest-relative mask:

{
  "revision": {
    "mode": "inpaint",
    "from": "tower-rough",
    "mask": "masks/tower-door.png",
    "strength": 0.45
  }
}

The mask must be a readable PNG. When the parent exists during planning, the mask must have the same dimensions. The ComfyUI graph decides whether white or black pixels mark edits, so test the graph in ComfyUI before running it through PixelKiln. The mask bytes and parent bytes both participate in the child spec hash.

outpaint is also part of the provider contract. It does not accept a separate mask in the manifest; the workflow owns canvas expansion and masking. PixelKiln does not ship a tested outpaint recipe yet.

Cleanup: reduce-colors and correct-pixelart

Two more revision modes exist for cleaning up an existing asset's own pixels mechanically, rather than describing an edit: no prompt is sent to the provider for either (the asset's prompt stays a manifest-only label, searchable in the gallery like any other asset's).

reduce-colors quantizes the source onto a smaller palette, either an explicit count or lifted from a reference image:

{
  "revision": {
    "mode": "reduce-colors",
    "from": "hero-walk-east",
    "numColors": 16,
    "dithering": "4x4",
    "ditheringStrength": 6
  }
}

numColors (2–256) and paletteImage (a manifest-relative image whose colors become the palette, lifted the same way a mask is) are mutually exclusive; omit both to let PixelLab auto-detect a palette size. Unlike a mask, paletteImage has no size relationship to the source — it only lends colors — so it can be any shape. dithering (none, 2x2, 4x4, 8x8) and ditheringStrength (0–10) apply only to reduce-colors; setting them, or strength, on the wrong mode is rejected at manifest load, same as a mask on image-to-image.

correct-pixelart sharpens edges and drops stray/anti-aliased pixels without resizing — the reprocessing case image-to-pixelart's own docs explicitly exclude ("for photographs and 3-D renders, not for reprocessing" pixel art):

{
  "revision": {
    "mode": "correct-pixelart",
    "from": "hero-walk-east",
    "strength": 0.1
  }
}

strength (0–1, PixelLab's own default 0.1) is how far the model may move from the source; start low and only raise it for real repair.

Remove a background

remove-background calls PixelLab's /remove-background and returns the same sprite on a transparent background. It is synchronous like the other cleanup modes, takes one image of at most 400 by 400 pixels, and refuses a set parent. Our measurements found it the one utility that leaves a locked palette intact (it also drops stray fringe pixels; see ENDPOINTS); cost is 1 generation.

{
  "revision": {
    "mode": "remove-background",
    "from": "hero",
    "removalTask": "complex",
    "description": "a knight holding a sword"
  }
}

removalTask is simple (PixelLab's default: faster, for flat backgrounds) or complex (slower, for detailed edges). description is an optional hint naming the foreground. Both are rejected on other modes, as is strength.

Normal maps

normal-map calls PixelLab's /normal-map and returns the parent's normal map, so a game engine can light the sprite. The map is the parent's size: RGB is the way each pixel faces, and alpha is 255 where the sprite shows and 0 elsewhere. It is a background job, and costs 0.5 generation per call, charged only when the map is delivered (measured: a sprite and an 8-frame strip each billed exactly 0.5).

{
  "styles": {
    "lit": { "generator": "map", "outDir": "art/normals" }
  },
  "assets": {
    "hero-walk-east-normals": {
      "prompt": "normal map",
      "styles": ["lit"],
      "revision": { "mode": "normal-map", "from": "hero-walk-east", "fromStyle": "chars", "convention": "opengl" }
    }
  }
}
  • convention is opengl (PixelLab's default: green up, for Godot, Unity, and Phaser) or directx (green down, for Unreal). It is rejected on other modes and enters the spec hash only when set. strength is refused.
  • The parent must be a PNG, 8 to 512 pixels per side; both are checked at plan time.
  • A loop goes as one strip. When the parent is an animation (its -frame-NN.png members), every frame is laid left to right in one image and sent with frames: N, up to 32. PixelLab shapes the first frame and carries it to the rest, so the light holds steady across the loop instead of flickering. The returned strip is cut back into -frame-NN.png files under the parent's roles, keeping its playback rate. A whole loop is one call, so it costs 0.5 too.
  • A character's directions are refused. They are separate views, not frames of one motion: sent as a strip, each would be shaped from the first direction, and one call per direction would put several paid jobs behind one lock entry with no way to recover half of them. Make one normal-map revision per direction, each from an asset whose source is that direction's file.
  • A normal map is lighting data, not art. Its colours are directions, so palette snapping or the quality gate's grid repair would wreck it. A normal-map revision in a style that sets enforcePalette or quality is refused at plan time; put it in a style without either (as lit above) and name the parent's style with fromStyle. A mirror of a normal map is refused too: flipping the pixels leaves every red channel pointing the old way.

For a loose file outside the manifest, pixelkiln normal-map does the same job (see CLI).

Revising a whole set at once

Both modes also take a parent that is a set rather than one image: a character's or object's directions, an animation loop, or another frame-set revision. Every member goes to PixelLab in one call, which is the point of both endpoints: an animation's frames (or a character's eight directions) come back on one shared palette, or through one consistent cleanup pass, instead of drifting frame by frame the way revising each member separately would.

{
  "assets": {
    "hero-walk-east": { "prompt": "walking", "animation": { "of": "hero", "direction": "east" } },
    "hero-walk-east-16c": {
      "prompt": "16-color walk",
      "revision": { "mode": "reduce-colors", "from": "hero-walk-east", "numColors": 16 }
    }
  }
}

Nothing new is declared: when the parent has no file at its own path but has <stem>-<role>.png members beside it, those members are the source. Two role vocabularies are recognized: a full 8- or 4-direction rotation (-south.png, -south-east.png, ...), or numbered frames (-frame-00.png upward). The child's output keeps the parent's roles, so hero-walk-east-16c-frame-03.png is the reduced version of hero-walk-east-frame-03.png, and a frame set keeps its playback rate.

  • Every member's hash goes into the child's identity, so a change to any one frame (not only the first) makes the child stale. The lock records one sourceSha256 over every member hash in order, the same formula a mirror records over its source.
  • Before spending, the member files found on disk are checked against the parent's own lock entry. A -frame-11.png left over from an older, longer generation blocks the revision with a message naming the count mismatch rather than being sent as if it belonged.
  • Members must all be the same size. reduce-colors' 512×512 limit is for the whole call: sixteen 64×64 frames fit, five 256×256 do not.
  • normal-map takes an animation loop as one strip but refuses a set of directions (see Normal maps).
  • Every other mode except edit-animation (below) reads exactly one image and refuses a set parent at plan time, naming the modes that take one.

Animation and interpolation

Two more modes animate any existing asset from a text description — a prop, a scene, a portrait, not just a character/objectPro family member. Neither needs a PixelLab character or object resource; both read the source's own pixels the same way image-to-image does:

{
  "assets": {
    "chest-open-wobble": {
      "prompt": "the open chest wobbling gently",
      "revision": {
        "mode": "animate",
        "from": "chest-open",
        "frames": 8,
        "fps": 8
      }
    }
  }
}

animate calls /animate-with-text-v3 (4–16 frames, even). animate-pixminimax calls /animate-pixminimax — PixelLab's MiniMax-powered engine, beta and gated to a tier 1 subscription or higher — for 4 to 40 frames and two extra knobs: direction (the sprite's facing, which also aims enhancePrompt's motion the right way on screen) and the same enhancePrompt. It also takes two caption fields, subjectDescription (what the parent shows, one noun phrase) and initialPose (the pose it is in). PixelLab reads any caption field you leave out off the image with an AI model, and can decline some images, failing the job until you send them yourself; sending all of them, with enhancePrompt off, rewrites nothing. The style's view is sent as the view caption. frames must be a multiple of 4 for this mode, which is checked at plan time. Both modes take driftThreshold, PixelLab's colour de-flicker sensitivity: frames whose foreground drifts from the first frame beyond it are corrected toward it, 0 corrects every frame, and omitting it keeps PixelLab's default. Both reject a declared strength (there is no denoise knob here, same as image-to-image) and both send the asset's own prompt as the motion description — action for animate, description for animate-pixminimax.

lastFrame pins where the motion ends — a manifest-relative image the same size as the source — turning an open-ended animation into an interpolation between two known poses, PixelLab's own "Animate Between 2 Frames" tool. Unlike a mask, no size relationship to the source is required at the manifest layer; PixelLab's own API is where a mismatch is caught.

direction + enhancePrompt together have a real gotcha, demonstrated by PixelLab's own tutorial rather than inferred from the schema: enhancePrompt can expand the motion description with camera-relative language ("towards the camera") that fights the requested world-facing direction. The failure is direction-specific — cardinal directions generated fine in the same batch an off-cardinal one (north-east) did not — so a multi-directional animate-pixminimax set that breaks at one or two directions but not others is more likely the enhanced prompt's own wording than the model or the direction value itself. The same tutorial states PixMiniMax "allows up to 40 frames," a second, independent (though still not live-billed) confirmation of the 40-frame ceiling below.

The result is an ordered frame set, not a single image — the source's own frames PNGs, <asset>-frame-00.png onward, exactly the naming a character loop already uses. Like any generative animation, it lands in candidate review first (pixelkiln pick), not straight to downloadable output; there is no per-candidate choice to make (it is one ordered set, previewed as a loop), just accept or reject the whole thing.

Neither endpoint's completed-job response shape has been exercised against a live account. The request fields above come from PixelLab's live OpenAPI document; the shape of a finished job (which key holds the frame list, and whether it's hosted URLs or inline base64) is an informed guess checked against several plausible shapes (see pollAnimateRevision in src/providers/pixellab.ts), the same defensive posture pollRevision already takes for image-to-image/inpaint. Cost is likewise unmeasured: estimate() borrows character's own measured v3-loop formula (ceil(width × height × frames / 65536), since the request shapes are near-identical) as a placeholder, plus enhancePrompt's own documented +0.05-generation surcharge, which is a real schema number rather than a guess.

Interpolate between two keyframes

interpolate calls /interpolation-v2, PixelLab's dedicated "Interpolate (Pro)" tool: the parent is the start keyframe, lastFrame (required here) is the end keyframe, and the asset's prompt describes the transition. Unlike lastFrame on animate, the frame count is the model's own choice (PixelLab documents "typically 4-8"); there is no frames field.

{
  "assets": {
    "knight-hit-fall": {
      "prompt": "knocked backward off his feet by a heavy blow",
      "revision": {
        "mode": "interpolate",
        "from": "knight-hit-airborne",
        "lastFrame": "art/knight-hit-landed.png",
        "fps": 12
      }
    }
  }
}

This is the "key poses first, then interpolate the gap" pattern from PixelLab's own tutorials, for motion one animate pass cannot produce cleanly: build each key pose as a plain image-to-image revision (an airborne "just hit" frame, then a "landed" frame edited from it), then let interpolate fill the motion between them. lastFrame can be any earlier asset's own generated output file, and the plan holds the revision as blocked until that file exists. Both keyframes must be the same size, 16 to 128 pixels per side.

Edit every frame of an animation at once

edit-animation calls /edit-animation-v2: one text edit ("add a red cape") applied across a whole frame set in a single call, so the change stays consistent from frame to frame. It is image-to-image for an entire animation. The parent must be a set (see Revising a whole set at once); a single-image parent is refused. The result keeps the parent's roles, so a character's eight directions edited together stay eight directions.

{
  "revision": { "mode": "edit-animation", "from": "hero-walk-east" }
}

Frames are 16 to 256 pixels per side, and because PixelLab packs every frame into one grid before editing, the frame ceiling falls as frames grow: 16 frames up to 64px, 9 up to 80px, 4 up to 256px. Both are checked at plan time.

interpolate and edit-animation land in candidate review like animate, take fps (defaulting to the parent's own rate when it has one, else 8), and reject strength. Neither has been exercised against a live account: request fields come from PixelLab's live OpenAPI document, the completed job is read by the same defensive frame-list lookup animate uses, and cost is an estimate (see PixelLab below).

ComfyUI supports none of the four frame-set modes: its revision path always writes a single output image, and an animation is a frame set — a structural gap rather than a missing binding, so supportsRevision refuses it up front rather than failing partway through submission.

Skeleton-driven animation

A third mode, animate-skeleton, poses the source image frame-by-frame from a supplied 18-joint skeleton per frame instead of a text motion description:

{
  "assets": {
    "hero-swing": {
      "prompt": "swinging the sword downward",
      "revision": {
        "mode": "animate-skeleton",
        "from": "hero-idle",
        "keypointsFile": "poses/hero-swing.json",
        "direction": "south"
      }
    }
  }
}

keypointsFile names a manifest-relative committed JSON file, not inline manifest data — the same reasoning mask/lastFrame are files rather than inline pixels. It must match the shape src/skeleton.ts's SkeletonSetSchema validates: {"firstFrameKeypoints": [...18 joints], "frames": [[...18 joints], ...3 to 15 of them]}, each joint {"label": "RIGHT KNEE", "x": 0.52, "y": 0.71, "z_index": 9, "depth": 4} (depth optional — PixelLab fills it from skeletonTemplate, default mannequin, when omitted). The request also carries the style's view (side, low top-down, or high top-down) so the motion is posed for the sprite's own camera; PixelLab assumes low top-down otherwise. label is one of PixelLab's 18 joints (NOSE, NECK, RIGHT/LEFT SHOULDER, ELBOW, ARM for the wrist, HIP, KNEE, LEG for the ankle, EYE, EAR), each exactly once per pose; a misspelled or repeated joint fails when the manifest loads. Bootstrap this file with pixelkiln estimate-skeleton <image> --out poses/hero-swing.json (see CLI reference), which writes the estimated pose and four copies of it as frames, then move joints frame by frame. Check each edit with pixelkiln skeleton-preview hero-swing, which draws every pose over the source image, locally and for free.

direction is required for this mode (unlike animate-pixminimax, where it is optional). frames/fps/lastFrame/enhancePrompt are all rejected — the frame count comes from the keypoints file itself (frames.length), never a manifest number. description (optional: what the character looks like — colours, clothing, held items) is a new field distinct from the asset's own prompt, which this mode sends as the motion's short label (PixelLab's action) — the same prompt-as-motion convention animate/animate-pixminimax already use, just under this endpoint's own field name.

firstFrameKeypoints (inside the keypoints file) describes the pose the reference image is already in — PixelLab redraws that frame first to learn its background and colors before animating forward, and does not return it. PixelKiln cannot verify this against the image's actual content. A mismatch does not error; it silently degrades transparency and color accuracy in every generated frame. When in doubt, run estimate-skeleton on the reference image itself and use that result, rather than hand-authoring a guess.

Deliberately not wired into the manifest pipeline: estimate-skeleton itself. PixelLabClient.estimateSkeleton() exists and the CLI command above calls it directly, but nothing in resolveSpecs/estimate()/submitRevision ever calls it — planning stays offline by construction (the keypoints file is read like any other manifest input, never a network call), and the only PixelLab endpoint animate-skeleton's submission composes is animate-with-skeleton-v3 itself, not two never-tested-live calls chained together. A future pipeline stage that budgets and tracks estimate-skeleton calls the way gen does everything else is tracked separately, once this mode has shipped and seen real use.

Cost uses PixelLab's own documented anchors (3 frames = 2 generations, 8 = 3, 15 = 4 — barely moving with length, unlike animate/animate-pixminimax's canvas-area formula) and interpolates between the nearest two for any in-between frame count, ceiling'd — the safe direction for --budget — since no in-between value is confirmed live. Neither animate-with-skeleton-v3 nor estimate-skeleton has been exercised against a live account; request field names come from the live animate_with_skeleton_v3 MCP tool schema (the same authoritative source already trusted for the character/object family), not an observed call.

ComfyUI does not support this mode either, for the same structural reason as animate/animate-pixminimax.

Dependency gate

pixelkiln plan reports a revision as blocked when its parent is not safe to use. A blocked item is not actionable and contributes no generation cost. PixelKiln requires one of these parent states:

  • committed source bytes are present;
  • generated output is downloaded, matches the current parent spec, and still has its recorded hash; or
  • when the parent style declares quality, its refined PNG and named human approval are current.

The same check runs again immediately before submission. If the parent or mask changes after planning, no provider request starts and no new lock entry is written.

Generate or approve the parent first, then plan the child:

pixelkiln plan --only tower-snow
pixelkiln gen --only tower-rough --budget comfyui=0
pixelkiln refine --only tower-rough
pixelkiln refine approve \
  --from art/final/tower-rough.pixelkiln.json \
  --reviewer "Your Name"
pixelkiln plan --only tower-snow
pixelkiln gen --only tower-snow --budget comfyui=0

Skip the generation/refinement steps that do not apply to a committed source. Selecting only the child still resolves its transitive parents for hashing and readiness; it does not submit those parents implicitly.

ComfyUI bindings

A revision workflow uses the normal prompt/output bindings plus these optional bindings:

{
  "bindings": {
    "prompt": { "nodeId": "6", "input": "text" },
    "width": { "nodeId": "11", "input": "width" },
    "height": { "nodeId": "11", "input": "height" },
    "batchSize": { "nodeId": "15", "input": "amount" },
    "seed": { "nodeId": "3", "input": "seed" },
    "sourceImage": { "nodeId": "12", "input": "image" },
    "maskImage": { "nodeId": "13", "input": "image" },
    "strength": { "nodeId": "3", "input": "denoise" }
  }
}

sourceImage is required for every ComfyUI revision. maskImage is required for inpainting. strength is required when the asset declares strength. Width and height must be supplied together. If they are omitted, the requested output must keep the source dimensions.

At submission, PixelKiln verifies each hash, uploads deterministic pixelkiln/<sha256>.png or .jpg inputs to ComfyUI, binds a cloned workflow, then queues it. It never changes the committed workflow file. A review with multiple candidates shows the source beside the candidates, so preserving the silhouette can be judged directly.

Install the first bundled revision workflow with:

pixelkiln recipe install comfyui/pixel-art-xl-img2img@1.0.0

It uses SDXL Base 1.0 and Pixel Art XL on a square 1024×1024 working canvas, then writes the requested final dimensions with nearest-exact scaling. Use it only for square sources. Its workflow integrity and bindings are tested, and a live Apple MPS smoke completed at strengths 0.25, 0.4, and 0.6. All three preserved the broad fortress footprint but missed the requested snow treatment, lost transparency, and expanded a 15-color source into 4,208–5,224 colors. That is transport evidence, not a quality recommendation. Treat every result as a composition candidate and use the normal native-grid, palette, alpha, and human approval gate before shipping. See the committed smoke.

PixelLab

"engine": "pro-flash" on an image-to-image or inpaint revision sends it to PixelLab's Pro Flash tier instead: /edit-image-pro-flash (a text edit at the source's own size; the canvas never grows) or /inpaint-image-pro-flash (replaces only the mask's white pixels and keeps the rest exactly). It is the same model the imageProFlash generator and the pro-flash character engine use, priced on the same provisional Pro Flash tier (5 generations up to 96px, 6 up to 208px, 9 beyond) instead of the 10–25 of the default Pro endpoints. Sources must be 32 to 256px per side in multiples of 4; strength is refused, since neither endpoint has one. The finished job is read the same way as the default endpoints'. PixelLab also offers reference-image edits, a context_image around an inpaint, and inpaint output layers; none is modeled yet.

PixelLab needs no bindings: image-to-image calls /edit-images-v2, inpaint calls /inpaint-v3, both as a plain background job polled the same way as every other PixelLab submission. reduce-colors calls /reduce-colors and correct-pixelart calls /correct-pixelart — PixelLab's "Cleanup" tier — and neither is a background job at all: both endpoints answer synchronously, the whole result already in the POST response, the same as pixflux's own /create-image-pixflux. submitRevision caches the decoded PNG to a local file under a fresh id immediately, exactly like pixflux does, and poll just confirms the file is still there rather than asking PixelLab anything further — there is nothing to ask.

  • Cost is confirmed live: a flat 0.1 generations for both, on a 32×32 source, on a Tier 2 subscription account. The live OpenAPI schema's own response examples are dollar-denominated (usage: {type: "usd", usd: 0.005} for reduce-colors, 0.01 for correct-pixelart), and — exactly as this codebase has repeatedly found for other endpoints (isometricTile, objectPro) — that example does not predict real subscription billing. The account's balance moved by exactly 0.1 generations for each call (pixellab balance change: 0.1000000000003638 generations consumed, floating-point noise aside), matching PixelLab's own MCP tool descriptions rather than the OpenAPI example. Only confirmed at this one size so far; whether it stays flat at larger canvases (unlike inpaint's canvas-tiered cost) is not yet known. The live response's own usage field is read and recorded as billed on every call regardless (context.metadata.revisionUsage, since there is no later background job to ask the way billedForJob asks for every other adapter call).

  • reduce-colors's total size limit is 512×512 worth of pixels (262144px²) across every member sent; correct-pixelart's is 1024 pixels per side. Both are checked before a request is sent, the same as inpaint's 32–512px floor/ceiling. The 0.1-generation cost above was measured on one image; a whole member set is still one call, but whether its price stays flat is unmeasured.

  • interpolate (/interpolation-v2) and edit-animation (/edit-animation-v2) are both documented "Pro" endpoints with no usage example in the schema and no live measurement. estimate() borrows the same 10/15/25 canvas tiers the other unmeasured Pro edits use, sized on the keyframe for interpolate and on the packed frame grid for edit-animation (four 32×32 frames price as a 64×64 canvas, the 40 tier). Over-reading is the safe direction for --budget; expect a live bill at or below the plan.

  • animate calls /animate-with-text-v3, animate-pixminimax calls /animate-pixminimax — both real async background jobs, unlike the two Cleanup-tier modes above, since they generate new frames rather than transform the source. Both take at most 256 pixels per side; animate additionally caps at 16 frames (animate-pixminimax allows up to 40). See Animation and interpolation above for the full field list, the frame-set output shape, and its own (still unmeasured) cost caveat — unlike the Cleanup tier above, neither animate nor animate-pixminimax has been exercised against a live account yet, and the request/response shapes come from PixelLab's live OpenAPI document, not an observed call. Treat the exact field names as provisional until a real call confirms them.

  • animate-skeleton calls /animate-with-skeleton-v3, also a real async background job, at most 256 pixels per side and 3-15 frames (the frame count comes from keypointsFile, not a manifest field). See Skeleton-driven animation above for the full field list, the keypointsFile shape, and the estimateSkeleton CLI-only-and-never-in-the-submission-path decision. Same unverified-live caveat as animate/animate-pixminimax, sourced from the live animate_with_skeleton_v3 MCP tool schema rather than an observed call.

  • normal-map calls /normal-map (PixelLab 0.4.130), a background job whose map is last_response.image; a loop's frames go and come back as one horizontal strip, cut into per-frame files on poll. See Normal maps. Measured once through pixelkiln normal-map: an 8-frame strip billed exactly 0.5 generations and came back the same size with matching alpha (docs/ENDPOINTS.md). The revision mode billed 0.5 on a 64×64 sprite too.

  • The mask convention is fixed, not graph-defined like ComfyUI's: white marks the area to generate, black the area to preserve. There is no way to flip it.

  • strength has nowhere to go on image-to-image. PixelLab's edit endpoint applies the instruction in full; declaring a strength fails the submission rather than silently ignoring it.

  • The source image is sent at its own actual dimensions (revision.sourceWidth / sourceHeight), not the child asset's declared width/height. Neither endpoint resizes; they edit the pixels that exist.

  • /inpaint-v3 takes 32 to 512 pixels per side; a smaller or larger source is refused before any request is sent. There is no chunking built in for a source over that ceiling: PixelLab's own Aseprite-plugin tutorial hits the same wall on a full-canvas edit and works around it by selecting and submitting a sub-region instead of the whole image, one that still includes some existing content (not just blank space) so the model has context. A revision over 512px on a side needs the same treatment — a separate, smaller revision asset targeting a sub-region — until this adapter does that automatically.

  • inpaint-v3 genuinely has a 20/25/40 three-tier structure, but not at the 1dir/create-tiles-pro breakpoints (1024px², 2048px²) this adapter borrows. Nine live inpaint calls, bisecting toward the real breakpoints: 32×32 (1024px²) through 256×256 (65536px²) — 1024px², 1600px², 16384px², 65536px² — all billed 20; 288×288 (82944px²) and 320×320 (102400px²) both billed 25; 352×352 (123904px²), 384×384 (147456px²), and 512×512 (262144px², the ceiling) all billed 40. Both breakpoints are now tightly bracketed: 20→25 sits somewhere in (65536px², 82944px²] — a 1.27x range — and 25→40 somewhere in (102400px², 123904px²] — a 1.21x range. generationCost's existing 1024px²/2048px² breakpoints are demonstrably wrong throughout this range: they charge 25 far too early (anything in (2048px², 65536px²] is really still 20) and reach 40 far too early too (anything in (2048px², 102400px²] that is not yet confirmed 25 is really 20, not 40). The 25 tier is real, not a fiction — just positioned far higher than assumed. Every size at or above 2048px² falls into generationCost's else branch and returns 40 regardless: correct for 352×352, 384×384, and 512×512, but a full-tier overestimate for 288×288 and 320×320, whose confirmed real cost is 25, not 40. Every miss measured so far is still in the safe direction (never bills more than predicted). image-to-image is separately confirmed only at its own 32×32 floor (20 generations); its curve above that, including whether it shares any of inpaint's breakpoints, is unmeasured. PixelLab's own tutorial reports its Pro edit tool at roughly 40 generations for a typical (larger) edit, consistent with image-to-image reaching the same ceiling inpaint measurably does, without saying where.

  • A completed job's response shape is confirmed live for both, and the two do not match: inpaint-v3's last_response.image is a single object, {type: "base64", width, height, base64}; edit-images-v2's is last_response.images, an array of that same object shape (edit_images in the request is also an array — the endpoint supports editing several images with one instruction, and the response shape mirrors that even though pixelkiln always sends and expects exactly one). pollRevision checks the singular image key first and only then falls back to an images array, so both were matched correctly without any code change on either live run. Both responses also carry billing_usage (duplicating the top-level usage, the same pattern already documented for map objects and character bases below), a seed, generation_mode ("inpainting_v3" vs. "edit_images"), and an automatically color-reduced copy of the result pixelkiln does not read (quantized_image singular for inpaint, quantized_images plural for edit — the same singular/plural split as the main result). pollRevision in src/providers/pixellab.ts reads all of this defensively and fails the job with a named error rather than crash or guess wrong, for whatever shape still isn't covered.

Provenance and invalidation

The lock entry records revision mode, parent id, parent hash, optional mask hash, strength, and — for reduce-colors — numColors, the palette image's hash, and the dithering settings, or — for animate/animate-pixminimax — frames, fps, the last frame's hash, direction, and enhancePrompt (interpolate records fps and the end keyframe's hash; edit-animation records fps), or — for animate-skeleton — the keypoints file's hash, skeletonTemplate, and description (direction is recorded the same way; frames comes from the keypoints file itself, same field, same staleness rule as the others). For a set parent, the parent hash is one hash over every member's hash in order. ComfyUI provider metadata also retains the same lineage beside the workflow hash.

Changing the prompt, workflow, mode, strength, parent bytes, mask bytes, color count, palette image bytes, dithering settings, frame count, last frame bytes, direction, enhancePrompt, keypoints file bytes, skeletonTemplate, or description makes the child stale. Moving an unchanged parent, mask, palette image, last frame file, or keypoints file does not. When a child has a quality profile, the new raw output hash also invalidates its old quality record, so a revised image cannot inherit approval from an earlier generation.

Commit the parent art or parent lock entry, masks, child output, child lock entry, and any quality companion. Do not commit ComfyUI input-cache files; they can be recreated from the hashed project inputs.