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" }
}
}
}
conventionisopengl(PixelLab's default: green up, for Godot, Unity, and Phaser) ordirectx(green down, for Unreal). It is rejected on other modes and enters the spec hash only when set.strengthis 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.pngmembers), every frame is laid left to right in one image and sent withframes: 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.pngfiles 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-maprevision per direction, each from an asset whosesourceis 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-maprevision in a style that setsenforcePaletteorqualityis refused at plan time; put it in a style without either (aslitabove) and name the parent's style withfromStyle. Amirrorof 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
sourceSha256over 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.pngleft 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-maptakes 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
sourcebytes 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}forreduce-colors,0.01forcorrect-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 (unlikeinpaint's canvas-tiered cost) is not yet known. The live response's ownusagefield is read and recorded asbilledon every call regardless (context.metadata.revisionUsage, since there is no later background job to ask the waybilledForJobasks 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 asinpaint'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) andedit-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 forinterpolateand on the packed frame grid foredit-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. -
animatecalls/animate-with-text-v3,animate-pixminimaxcalls/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;animateadditionally caps at 16 frames (animate-pixminimaxallows 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, neitheranimatenoranimate-pixminimaxhas 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-skeletoncalls/animate-with-skeleton-v3, also a real async background job, at most 256 pixels per side and 3-15 frames (the frame count comes fromkeypointsFile, not a manifest field). See Skeleton-driven animation above for the full field list, thekeypointsFileshape, and theestimateSkeletonCLI-only-and-never-in-the-submission-path decision. Same unverified-live caveat asanimate/animate-pixminimax, sourced from the liveanimate_with_skeleton_v3MCP tool schema rather than an observed call. -
normal-mapcalls/normal-map(PixelLab 0.4.130), a background job whose map islast_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 throughpixelkiln 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.
-
strengthhas nowhere to go onimage-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 declaredwidth/height. Neither endpoint resizes; they edit the pixels that exist. -
/inpaint-v3takes 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-v3genuinely has a 20/25/40 three-tier structure, but not at the1dir/create-tiles-probreakpoints (1024px², 2048px²) this adapter borrows. Nine liveinpaintcalls, 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). The25tier is real, not a fiction — just positioned far higher than assumed. Every size at or above 2048px² falls intogenerationCost'selsebranch 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-imageis separately confirmed only at its own 32×32 floor (20 generations); its curve above that, including whether it shares any ofinpaint's breakpoints, is unmeasured. PixelLab's own tutorial reports its Pro edit tool at roughly 40 generations for a typical (larger) edit, consistent withimage-to-imagereaching the same ceilinginpaintmeasurably does, without saying where. -
A completed job's response shape is confirmed live for both, and the two do not match:
inpaint-v3'slast_response.imageis a single object,{type: "base64", width, height, base64};edit-images-v2's islast_response.images, an array of that same object shape (edit_imagesin 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).pollRevisionchecks the singularimagekey first and only then falls back to animagesarray, so both were matched correctly without any code change on either live run. Both responses also carrybilling_usage(duplicating the top-levelusage, the same pattern already documented for map objects and character bases below), aseed,generation_mode("inpainting_v3"vs."edit_images"), and an automatically color-reduced copy of the result pixelkiln does not read (quantized_imagesingular for inpaint,quantized_imagesplural for edit — the same singular/plural split as the main result).pollRevisioninsrc/providers/pixellab.tsreads 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.
