ANDRO VAE Decode
Your VAE decode is silently throwing away 0.3% of the image and almost all of the precision
- samples
- vae
- image
- range_report
Every stock VAE decode in ComfyUI finishes with this line:
process_output = lambda image: image.add_(1.0).div_(2.0).clamp_(0.0, 1.0)
That's a [-1,1] → [0,1] rescale followed by a hard clamp. Decoders routinely emit values past [0,1] - the pack's own measurements across a bunch of VAEs found ranges like -0.0196 … +1.0186 and -0.0715 … +1.0445, with 0.01–0.34% of samples outside the bounds. That's the top of your speculars and the toe of your shadows getting deleted before any node downstream can see them. On top of that, most VAEs decode in bfloat16, which quantises the output to roughly 77 distinct levels in a mid-band instead of the 3.3 million float32 can hold. Neither loss is visible in the graph - the image still looks fine, it just can't survive a grade.
VAE Decode (float32, no clamp) is a drop-in replacement for VAEDecode / VAEDecodeTiled that gives both back. If you push AI frames into DaVinci, an EXR pipeline, or any sort of color-managed VFX handoff, this is the node the pack exists for. If you're exporting 8-bit PNGs to share, honestly, skip it - an 8-bit container physically can't hold what this saves, and you're adding decode time for nothing.
How it works
At decode time the node temporarily swaps vae.process_output for the same maths minus the clamp, optionally casts the decoder weights and vae_dtype to float32, and restores everything in a finally block - so other graphs in the same session are unaffected. Two guardrails matter:
- It does not assume every VAE uses the
[-1,1] → [0,1]default. TAEHV/lighttae, MiniMax H3 and StageA already emit[0,1]and set an identity transform; substituting the default there would rescale the image and wreck it. The node probes the VAE's own transform with-1/0/1and leaves anything unfamiliar alone, reporting it. - The float32 cast is guarded - quantised weights refuse the cast gracefully and the node falls back to the VAE's own precision with a note.
It reaches into vae.process_output, vae.vae_dtype and first_stage_model, none of which are public API, so a ComfyUI refactor could break it. That's why the fallbacks exist: a surprise degrades into "no change, with a note" rather than a corrupted image.
The inputs that matter
keep_out_of_range(default on) - off reproduces stock ComfyUI exactly. On keeps the undershoot/overshoot so an EXR can carry it. This is the "am I sure I'm not just seeing a different decode" switch for A/B testing.precision(float32default, orvae default) - the cost is real: roughly 3× decode time and 2× the VAE's VRAM.tiledplustile_size- and here's the trap. The node defaults totile_size 384for a reason. In bf16, 384 and 768 cost the same; in float32, 768 becomes a cliff: the pack measured a 12-second decode turn into 21 minutes, no error, just a progress bar that stops moving. ComfyUI's own LTX-2.5 template shipsVAEDecodeTiledat 768, which is fine for bf16 - swap in float32 and leave it, and you've made yourself a twenty-minute decode. Halve the tile.temporal_size(default 4096) you should generally leave high: a temporal tile edge has no context, and the blend leaves a soft frame on every seam.Tile Seam Checkfrom this same pack will tell you when one sneaks through.
Outputs
image wires straight into anything a stock decode feeds - SaveImage, Save EXR (float32), upscalers. range_report is a STRING with the decoded min/max and a note if anything was skipped or unrecognised; stick it in a text display or just read the console.
Install
ComfyUI Manager (search "comfyui-vae-float32"), or:
cd ComfyUI/custom_nodes
git clone https://github.com/AndreiOrehov/comfyui-vae-float32
Restart ComfyUI. No model downloads - it works with whatever VAE you already have. pip install OpenEXR only matters if you want EXR output (the pack falls back to float TIFF otherwise).
Where people get burned
- The tiling cliff above. If a float32 tiled decode crawls, your
tile_sizeis too big, not too small. - LTX heights. The latent grid floor-divides by 32, so 1080 silently becomes 1056. Use 1088 or 1056 and know which one you picked.
- MiniMax H3, once. The README records a one-off
ValueError: Buffer too smallthat never reproduced; if you ever hit it,precision: vae defaultsidesteps the cast. - "Nothing changed" is a feature. The two decodes look identical - that's the point. Measure the difference with Image Compare (numeric) from this pack; that's what it's for.
Inputs (9)
| Name | Type | Default | Description |
|---|---|---|---|
| samples | LATENT | The latent to decode - the same input the stock VAE Decode takes, so this node drops straight into an existing graph in its place. Nested latents are unbound to their first element, as the stock node does. | |
| vae | VAE | The VAE that belongs to the model. Any VAE works: this node probes the VAE's own output transform instead of assuming the default one, so VAEs that already emit [0,1] are left alone rather than rescaled. | |
| keep_out_of_range | BOOLEAN | true | ComfyUI ends every decode with clamp_(0.0, 1.0) (comfy/sd.py:502), and the decoder does emit outside that: measured -0.0715 .. +1.0445 on a real LTX-2.5 generation, 0.01-0.34% of samples depending on the shot. Those are specular highlights and shadow detail, deleted before any node downstream can see them. ON keeps them - only useful if what follows can carry them (EXR, or Remap Range first). OFF reproduces stock ComfyUI exactly, for an A/B. |
| precision | COMBO | float32 | 'vae default' is bfloat16 on most VAEs, which is coarser than it sounds: across [0.2, 0.3] of a frame it can represent 77 distinct values, and that is the FORMAT's entire grid there, not a property of the picture. float32 gives ~3.3 million in the same window. Costs roughly 3x the decode time and 2x the VAE's VRAM. Nothing recovers this afterwards - a float32 container around bf16 values is empty precision. |
| tiled | BOOLEAN | true | Decode in tiles instead of whole frames. On by default: float32 decoding needs far more VRAM than the stock bf16 path. Only the SPATIAL split is active with these defaults, and overlap blends it - temporal_size is left high on purpose, because cutting along time is what leaves a soft frame on every seam. |
| tile_sizeopt | INT | 38464–4096 | Spatial tile in PIXELS, and the knob to cut when you run out of memory - cut it before ever touching temporal_size. Cost is a CLIFF, not a slope: while the decode fits in VRAM the tile size is nearly free, and the moment it stops fitting, weights start paging and the same decode takes tens of times longer, with no error and no warning - just a progress bar that stops moving. WHERE that cliff sits depends on your card, your VAE and the frame size, so there is no universal safe number; this node estimates it for YOUR machine and says so in the report. One measured example, RTX 5090 32GB on LTX-2.5 at 1280x704x121 in float32: 384 = 44s, 512 = 42s, 768 = 1247s. In bfloat16 the same run is 12.1s at both 384 and 768, which is why ComfyUI's LTX-2.5 template ships 768 - right for bf16, ruinous in float32 on that card. |
| overlapopt | INT | 640–4096 | Pixels of overlap between neighbouring spatial tiles, blended so the join does not show. 64 against a 384 tile is enough in practice: gradient excess at the tile boundaries measures 1.03-1.05x, well under the 1.30x ANDRO Seam Check needs before it calls anything a peak. Raise it only if Seam Check reports REGULAR vertical or horizontal peaks - a single strong line is content, not a seam. Bigger overlap means more pixels decoded twice, so it costs time. |
| temporal_sizeopt | INT | 40968–4096 | Video VAEs only: how many frames are decoded per temporal tile. LEAVE IT HIGH. At 4096 nothing is cut along time at all, which is the point - a diffusion decoder has no context at a temporal tile edge, so the blend leaves a visibly SOFTER frame on every seam, evenly spaced and easy to miss. Measured: temporal_size 32 put a soft frame every 24 frames. Overlap softens that but never removes it. If you are short on memory, cut tile_size instead - the spatial seam is the one overlap can genuinely blend away. ANDRO Seam Check finds these. |
| temporal_overlapopt | INT | 84–4096 | Frames of overlap between temporal tiles. Does nothing while temporal_size stays at 4096, because then there is only one temporal tile. It cannot rescue temporal tiling either: more overlap softens the seam frame, it never removes it, since the decoder still had no context at that edge. |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| image | IMAGE | The decoded batch, still carrying values outside [0,1] when that is on. |
| range_report | STRING | What the decode actually did: measured range, percentiles, how much the stock clamp would have deleted, and the dtypes it really ran in. |