perf(qwen-image): add a tiling option to the Qwen-Image VAE nodes - #9427
Conversation
The Qwen-Image i2l node hardcoded vae.disable_tiling(), so a full-frame encode was the only option. At 2560x1440 that peaks at 9.26 GiB — on top of a resident multi-GB transformer, which is what makes an upscale round-trip run out of headroom exactly at this node while every other node fits. Adds `tiled` / `tile_size` input fields following the SD/SDXL i2l node, OR'd with the global force_tiled_decode setting. Off by default, so behaviour is unchanged unless enabled. estimate_vae_working_memory_qwen_image gains a matching tile_size parameter. Without it the change would be inert: the cache would keep reserving the full-frame figure (10.99 GiB at 2560x1440) and evict models to honour it, no matter what the VAE actually does. Tiled, it budgets one tile plus 25% overlap plus the resident RGB image, mirroring estimate_vae_working_memory_wan. Measured through the node at 2560x1440: 10.99 -> 0.26 GiB reserved, 9.26 -> 0.17 GiB actual peak, identical latent shape. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending; real images blend far better), which is why this stays opt-in.
Both nodes reserve working memory for a full-frame operation, which at high resolutions exceeds a 24 GB card, so the model cache evicts everything else to honour it. On CUDA at 2560x1440: 19.91 GiB for the decode and 10.99 GiB for the encode. Tiling is the intended escape hatch, but it did not work on either node: - qwen_image_i2l hardcoded vae.disable_tiling(), so it could not be enabled. - qwen_image_l2i honoured the global force_tiled_decode, but computed its working-memory estimate before and independently of that flag. Tiling bounded the VAE while the cache still reserved the full-frame figure, so the memory was never freed for anything else — effectively inert. Adds `tiled` / `tile_size` input fields to both nodes following the SD/SDXL i2l/l2i nodes, OR'd with force_tiled_decode. Off by default; behaviour is unchanged unless enabled. estimate_vae_working_memory_qwen_image gains a matching tile_size parameter, and both nodes resolve tile_size=0 to the VAE default (256px) before estimating. Tiled it budgets one tile plus 25% overlap plus the resident RGB image, mirroring estimate_vae_working_memory_wan. Without this the change would be cosmetic on i2l and remain inert on l2i. Measured through the i2l node at 2560x1440: 10.99 -> 0.26 GiB reserved, 9.26 -> 0.17 GiB actual peak, identical latent shape. Verified across eight resolutions that tiled and untiled encodes produce the same latent dimensions. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending), which is why this stays opt-in. Also fixes a crash in qwen_image_i2l: `width`/`height` are `int | None`, but the workflow UI sends 0 for an unset number input, and `0 is not None` reached `image.resize((0, 0))` -> "height and width must be > 0". Non-positive values are now treated as unset, matching how tile_size uses 0.
lstein
left a comment
There was a problem hiding this comment.
Thanks for this — the diagnosis is right and well argued. The observation that l2i's tiling was inert because the estimate was computed independently of the flag is a genuine find, and threading tile_size into estimate_vae_working_memory_qwen_image is the correct fix for it. The untiled path is byte-identical (the estimator's else branch is untouched and both nodes pass tile_size=None when tiled is false), schema/openapi are regenerated correctly including both version bumps, and CI is fully green.
The problem is in how tiling is turned on. Both nodes call
vae.enable_tiling(tile_sample_min_height=tile_size, tile_sample_min_width=tile_size)and never touch tile_sample_stride_height / tile_sample_stride_width. That leads to two independent ways to get silently corrupted output.
Blocker 1 — tile_size below 192 silently truncates the image / latent
AutoencoderKLQwenImage.__init__ sets tile_sample_min_* = 256 and tile_sample_stride_* = 192. tiled_encode / tiled_decode step the tile loops by stride but slice each accumulated tile to min:
for i in range(0, height, tile_latent_stride_height): # advances by stride
...
result_row.append(tile[:, :, :, : self.tile_sample_stride_height, : self.tile_sample_stride_width])So whenever min < stride, whole bands of the image are never processed and the output is smaller than requested. tile_size is declared multiple_of=8 with no lower bound, so every value from 8 to 184 hits this.
Measured against a real AutoencoderKLQwenImage (tiny random weights, CPU, diffusers 0.39.0):
encode 512x512 image tile_size=192 -> (1,16,1,64,64) ok
tile_size=128 -> (1,16,1,48,48) *** WRONG ***
tile_size=64 -> (1,16,1,24,24) *** WRONG ***
decode 64x64 latent tile_size=192 -> (1,3,1,512,512) ok
tile_size=128 -> (1,3,1,384,384) *** WRONG ***
tile_size=64 -> (1,3,1,192,192) *** WRONG ***
No exception is raised. On i2l the undersized latent flows onward as a perfectly valid-looking tensor.
The same omission has a second, opposite-direction effect above 256: the number of tiles is fixed by the stride, so raising tile_size grows every tile without eliminating any. At 2560x1440 the loops emit ceil(1440/192) x ceil(2560/192) = 8 x 14 = 112 tiles regardless of tile_size:
| tile_size | tiles | pixels processed | vs full-frame |
|---|---|---|---|
| 256 | 112 | 7.3 Mpx | 2.0x |
| 384 | 112 | 16.5 Mpx | 4.5x |
| 512 | 112 | 29.4 Mpx | 8.0x |
| 768 | 112 | 66.1 Mpx | 17.9x |
FieldDescriptions.vae_tile_size says "larger tile sizes generally produce better results at the cost of higher memory usage", and QA step 5 recommends going larger — but on this VAE the real cost of a larger tile is quadratic compute, not just memory. With a proportional stride, tile_size=512 would be ~1.8x, not 8x.
Suggested fix: pass all four parameters, keeping the stock 256/192 ratio, e.g.
vae.enable_tiling(
tile_sample_min_height=tile_size,
tile_sample_min_width=tile_size,
tile_sample_stride_height=tile_size * 3 // 4,
tile_sample_stride_width=tile_size * 3 // 4,
)This is exactly what anima_latents_to_image.py:144-149 already does. A ge= floor on the field would be belt-and-braces.
Blocker 2 — enable_tiling permanently mutates the cached VAE
self.tile_sample_min_height = tile_sample_min_height or self.tile_sample_min_heightenable_tiling writes through to the module, and disable_tiling() only clears use_tiling — it does not restore the sizes. That module is the model cache's own instance: LoadedModel.model and model_on_device both return self._cache_record.cached_model.model (load_base.py:103,110), and as_qwen_image_vae deliberately returns the same object. So a tile_size set once sticks for the lifetime of the cache entry.
Replaying the node's exact call sequence twice against one instance:
run A tile_size=512 -> model min now 512
run B tile_size=0 -> estimate=512, tiles=512 (documented as "the VAE default, 256")
run A tile_size=128 -> (1,3,1,384,384)
run B tile_size=0 -> (1,3,1,384,384) *** still corrupt; the user set nothing ***
then i2l tiled=True tile_size=0 -> latent (1,16,1,48,48) *** the leak crosses nodes ***
It also crosses model families. anima_latents_to_image sets min=512, stride=384 (ANIMA_VAE_TILE_SIZE / ANIMA_VAE_TILE_STRIDE) on the same Wan-classified VAE instance these nodes reinterpret — a native-layout qwen_image_vae single file is classified with the Anima base, as the node comments note, so one VAE loader can feed both nodes in a single workflow. Afterwards the documented-safe value corrupts, because the leaked stride is now larger than it:
after Anima l2i: min=512 stride=384
qwen l2i with tile_size=256 -> (1,3,1,384,384) expected (1,3,1,512,512)
The SD l2i this PR follows avoids precisely this with patch_vae_tiling_params — a context manager that restores the original values in a finally. Note qwen_image_latents_to_image.py:92 still carries the now-vestigial tiling_context = nullcontext(); that is the hook the SD node uses for it.
Fixing blocker 1 by always passing all four parameters also closes most of this, since nothing is then inherited. The residual piece is getattr(vae_info.model, "tile_sample_min_height", 256) in the estimate, which reads whatever the previous run leaked rather than a known default.
Repro script
Both blockers above, self-contained (no weights needed):
import torch
from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
vae = AutoencoderKLQwenImage(
base_dim=4, z_dim=16, dim_mult=[1, 1, 1, 1], num_res_blocks=1,
attn_scales=[], temperal_downsample=[False, True, True],
).eval()
z = torch.randn(1, 16, 1, 64, 64) # -> 512x512
# Blocker 1
vae.enable_tiling(tile_sample_min_height=128, tile_sample_min_width=128)
with torch.inference_mode():
print(tuple(vae.decode(z, return_dict=False)[0].shape)) # (1,3,1,384,384)
# Blocker 2 -- the node's tile_size=0 path, run afterwards
vae.enable_tiling() # no args == "model default"
with torch.inference_mode():
print(tuple(vae.decode(z, return_dict=False)[0].shape)) # (1,3,1,384,384), stillNon-blockers
The tiled estimate under-counts the accumulated tile buffers. working_memory += 3 * h * w * element_size budgets one RGB frame, but tiled_decode holds every decoded tile in rows (about (min/stride)^2 ~ 1.78x a full frame at the defaults) simultaneously with result_rows (~1x) and the final torch.cat plus its slice (~2x). At 2560x1440 fp16 that is roughly 116 MB actual against 22 MB budgeted — comfortably absorbed by the 25% tile slack within the 497 MB total. But the shortfall grows linearly with output area while the tile term stays constant, so it degrades in exactly the regime tiling exists for. estimate_vae_working_memory_wan, which the new docstring says this mirrors, uses clip_copies = 2 on decode; this one effectively uses 1.
force_tiled_decode now changes encode results. ORing it into i2l matches image_to_latents.py:158, so the precedent is fine — but users who already have it set in invokeai.yaml get a ~1.4%-different latent with no node or workflow change on their side. "Off by default, so behaviour is unchanged unless enabled" is not quite true for them; worth a line in the PR body / What's New rather than a code change.
The width/height zero-handling fix is an unrelated drive-by, untested and unmentioned in the summary. In self.width and self.height and self.width > 0 and self.height > 0 the first two clauses are redundant with the last two. Also note width=1024, height=0 now silently encodes at the original size rather than erroring — reasonable, but a behaviour change worth stating.
Test coverage. The new test patches the estimator, so nothing exercises the estimator's tiled arithmetic, the arguments actually passed to enable_tiling (where both blockers live), or the l2i node at all — and l2i is where the inert-tiling bug being fixed actually was. The try / except Exception: pass would also let the test pass if vae_encode blew up immediately after the estimate call.
Attacks that came back clean
- Untiled path is byte-identical to
main; the estimator's non-tiled branch is untouched and both nodes passtile_size=Nonewhentiledis false. - Negative
tile_sizevalues fall through to the> 0guard and are treated as "default" — no crash. - Images at or below the tile size over-reserve slightly rather than under-reserve (diffusers skips tiling entirely there).
QwenImageImageToLatentsInvocation.vae_encodehas no other callers, so the new keyword-only defaults break nothing.element_sizederivation and the ROCm/CUDA constant selection are unchanged.schema.tsandopenapi.jsonare correctly regenerated, including both1.0.0->1.1.0bumps and themultipleOf: 8constraint;typegen-checksandopenapi-checksboth pass.ruff checkclean; all 8 tests intest_qwen_image_working_memory.pypass.
enable_tiling() was called with tile_sample_min_* only, leaving the stride at the module's 192px default. The tile loops step by stride but slice each accumulated tile to min, so any tile_size below 192 silently dropped whole bands of the image -- a 128px tile turned a 512x512 decode into 384x384 with no error -- while sizes above 256 grew every tile without removing any, making compute scale with tile_size^2 (8x a full frame at 512px). Pass all four parameters with the stock 4:3 ratio, rounding the stride down to a multiple of the 8x spatial compression so the pixel and latent steps agree. Tile sizes below 64px are clamped; the field carries the 0 "use default" sentinel and so cannot take a pydantic lower bound. enable_tiling() also writes straight onto the module, and disable_tiling() only clears use_tiling. That module is the model cache's own instance, so a tile size set once persisted for the lifetime of the cache entry and leaked across invocations and into anima_latents_to_image, which shares the instance. Apply the geometry through a context manager that restores it, and resolve the 0 sentinel against a constant instead of the module's current value. Also budget the pixel-space buffers tiled_decode holds simultaneously (~5 frames, not 1) -- the term that grows with output area, so it degraded in exactly the regime tiling exists for.
The rounding in `_tile_stride_for` was load-bearing but uncovered: dropping it left the whole suite green, yet a raw 3/4 stride silently truncates the encode for any tile size whose 3/4 is not a multiple of 8. `tile_size` is `multiple_of=8`, so 72 and 80 are both reachable from the workflow UI and both land there (54 and 60). Cover it at the argument level (72 -> 48, 80 -> 56) and end-to-end against a real tiny VAE, plus a pin on the failure mode itself: min=72 with the un-rounded stride 54 turns a 512x512 encode into a 57x57 latent instead of 64x64, with no exception. Also correct the rationale on QWEN_IMAGE_VAE_MIN_TILE_SIZE. `_tile_stride_for` already floors the stride at 8, so the derived latent step never collapses to 0, and 8/24/32 all round to clean multiples of 8 and produce correctly sized output -- 64 is not the smallest valid tile. It is a cost floor: the tile count grows with the inverse square of the stride (1620 tiles at 64px versus 57,600 at 8px on a 2560x1440 frame). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Both blockers are fixed properly — not worked around — and the fix is better than what I suggested: pulling the geometry into patch_qwen_image_vae_tiling rather than open-coding four arguments at each call site means the pixel/latent stride relationship is stated once, next to the reason it matters. Approving.
I re-attacked both findings against 4ca19dfcc5 rather than reading the diff for plausibility.
Blocker 1 — tile stride
Swept every value the field can carry — tile_size=0 plus every multiple of 8 up to 1024 — across 8 shapes chosen for pathological leftovers (16x16, 80x80, 400x400, 272x144, 1024x576, 144x272, 1200x80, 64x64), asserting both the decoded pixel shape and the encoded latent shape:
total combos: 1032 failures: 0
The min > stride direction is fixed too — processed area at 2560x1440 is now flat instead of quadratic:
| tile_size | 192 | 256 | 384 | 512 | 768 |
|---|---|---|---|---|---|
| before | 1.4x | 2.0x | 4.5x | 8.0x | 17.9x |
| after | 1.80x | 1.99x | 1.80x | 1.99x | 2.40x |
Blocker 2 — cached-module mutation
Confirmed against diffusers 0.39.0 that enable_tiling on AutoencoderKLQwenImage and AutoencoderKLWan mutates exactly the five attributes the context manager saves — no sixth. Restore verified on the normal, untiled and exception paths, and the Anima scenario now holds in both directions: leaked 512/384 cannot change a Qwen decode, and is still intact for whoever set it afterwards.
I also went after the hazard the context manager newly opens up — two invocations interleaving save/restore on one module. It isn't reachable: one session worker per device, per-device caches, and _adopt_shared_cpu_weights deep-copies a meta shell (load_default.py:537, "Independent module per device") so two workers never hold the same VAE object.
Estimator
image_copies = 5 checks out. Measured peak allocated during tiled_decode on GPU, in multiples of one RGB frame:
1440x2560 tile=256 -> 3.78 tile=512 -> 5.21
2048x2048 tile=256 -> 3.73 tile=512 -> 5.09
3072x3072 tile=256 -> 3.73 tile=512 -> 3.93
encode tile=256 -> 0.62 .. 1.12
The one overshoot (5.21 at 1440x2560/512) is ~4.6 MB against a ~1.9 GB tile term, so the 25% overlap slack absorbs it many times over.
Pushed to this branch (3952b42)
One residual, which I've fixed rather than sending back:
The multiple-of-8 stride rounding was load-bearing but untested. I mutation-tested each element of the fix. Removing the stride arguments failed 20 tests, dropping the finally restore failed 6, removing the clamp failed 2, regressing the sentinel to a module read failed 3 — but deleting // 8 * 8 from _tile_stride_for left all 35 tests green. It is not cosmetic: tile_size is multiple_of=8, so 72 and 80 are reachable from the UI and their raw 3/4 stride (54, 60) is not, and a 512x512 encode at min=72, stride=54 returns a 57x57 latent instead of 64x64 — silently, exactly like the original bug. Above 136 it raises outright. Now covered at the argument level (72 -> 48, 80 -> 56), end-to-end through the real tiny VAE, and with an explicit pin on the failure mode; the mutation now fails 5 tests.
The same commit corrects the rationale on QWEN_IMAGE_VAE_MIN_TILE_SIZE. _tile_stride_for already floors the stride at 8, so the derived latent step can never collapse to 0, and 8 / 24 / 32 all round to clean multiples of 8 and produce correctly sized output — 64 is not the smallest valid tile. It's a cost floor: the tile count grows with the inverse square of the stride, 1620 tiles at 64px against 57,600 at 8px on a 2560x1440 frame. The clamp is right; only the reason was wrong, and it would have stopped someone from lowering it later.
The PR body now covers every non-blocker from the first round — the force_tiled_decode encode change, the width/height drive-by, and the corrected guidance on larger tiles. ruff check and ruff format clean, 801 passed in tests/app/invocations/.
Reviewing this against upstream invoke-ai#9427 -- the Qwen-Image tiling PR, which hit the same class of problem -- turned up two silent bugs here. enable_tiling() writes the geometry onto the module and disable_tiling() restores only the flag, and that module is the model cache's own instance. So a tiled Z-Image decode, or a single OOM retry, left use_tiling=True behind on the shared FLUX autoencoder. The nodes that reach that same instance but never touch the flag -- flux_vae_encode, pid_upscale, flux_pid_decode, and Anima's FLUX branch, whose disable_tiling() sits in its diffusers branch only -- would then have decoded and encoded tiled without asking. No error, no log line, just different output. scoped_vae_tiling sets the state for one block and restores every tiling attribute in a finally, on the normal, untiled and exception paths alike. It is the same shape as SD's patch_vae_tiling_params and Qwen's patch_qwen_image_vae_tiling; neither fits these classes, since the SD one is typed to AutoencoderKL/AutoencoderTiny, patches three attributes the FLUX autoencoder does not have, and leaves use_tiling to the caller. Second, the estimator resolved tile_size=0 with getattr(vae, "tile_sample_min_size", ...) -- which returns whatever the previous invocation left on the cached module rather than the default the node is asking for. It now resolves against a module-level constant, the same correction invoke-ai#9427 needed. Third, the node field had no lower bound: small values produced an enormous tile count, and a negative one raised ValueError in the middle of a generation. resolve_tile_size owns both the sentinel and a 128px cost floor -- a cost floor, not a validity one: the geometry stays correct all the way down, but the tile count grows with the inverse square of the tile size, and small tiles are measurably less accurate. The module was first named vae_tiling.py, which collided with the existing stable_diffusion/vae_tiling.py, and its test collided by basename with tests/backend/stable_diffusion/test_vae_tiling.py -- a collection error that aborts the whole suite. Renamed to vae_tiling_scope. Two findings from that review do not apply and were checked rather than assumed: truncation when the tile is smaller than the stride cannot happen here (the destination is preallocated at the exact output size and tiles merge into it), and compute goes the other way round -- larger tiles mean fewer tiles and are more accurate, so the existing field description is correct for this VAE. Mutations verified as caught: dropping the finally restore (6 tests), resolving the sentinel off the module again (12), removing the cost floor (4).
Summary
Both Qwen-Image VAE nodes reserve working memory for a full-frame operation, which at high resolutions is more than a 24 GB card has — so the cache evicts everything else to honour it. On CUDA at 2560x1440:
qwen_image_l2i(decode)qwen_image_i2l(encode)Tiling is the intended escape hatch, but today it does not work on either node:
vae.disable_tiling()— there was no way to enable it at all.force_tiled_decode, but computes its working-memory estimate before and independently of that flag. So tiling bounds the VAE while the cache still reserves the full-frame figure — the memory is never actually freed for anything else. Effectively inert.This PR adds
tiled/tile_sizeinput fields to both nodes, following the SD/SDXL i2l/l2i nodes, OR'd with the globalforce_tiled_decode. Off by default, so behaviour is unchanged unless enabled.estimate_vae_working_memory_qwen_imagegains a matchingtile_sizeparameter, and both nodes resolvetile_size=0to the default (256px) before estimating. Tiled, it budgets one tile plus 25% overlap plus the pixel-space buffers, mirroringestimate_vae_working_memory_wan. Without this the change would be cosmetic on i2l and remain inert on l2i.Measured end-to-end through the i2l node at 2560x1440: 10.99 → 0.26 GiB reserved, 9.26 → 0.17 GiB actual peak, identical latent shape. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending; real images blend far better), which is why this stays opt-in.
Applying the tile geometry correctly
AutoencoderKLQwenImagecarries a tile size and a tile stride (stock: 256px / 192px).tiled_encode/tiled_decodestep the tile loops by stride but slice each accumulated tile to min, andenable_tilinginherits the module's current value for any argument left out. Passing onlytile_sample_min_*therefore breaks in both directions:tile_sizegrows every tile without eliminating any: at 2560x1440 the loops emit 112 tiles regardless, making compute scale withtile_size²(8.0x a full frame at 512px, 17.9x at 768px).So both nodes pass all four parameters, keeping the stock 3/4 stride ratio, as
anima_latents_to_imagealready does. The stride is additionally rounded down to a multiple of the VAE's 8x spatial compression: the loops step in one space and slice in the other, so the pixel stride must be exactly 8x the latent stride or the two disagree. With a proportional stride the processed area stays flat at ~2.0x a full frame at any tile size.tile_sizevalues between 1 and 64 are clamped to 64 as a cost floor (1620 tiles at 64px vs 57,600 at 8px on a 2560×1440 frame). The field has to accept the 0 "use the default" sentinel and so cannot carry a pydantic lower bound.Scoping the tiling state
enable_tilingwrites the geometry straight onto the module anddisable_tilingonly clearsuse_tiling— it does not restore the sizes. That module is the model cache's own instance (LoadedModel.modelandmodel_on_deviceboth return it, andas_qwen_image_vaedeliberately returns the same object to keep partial-loading hooks intact), so atile_sizeset once would stick for the lifetime of the cache entry and leak into later invocations.It also crosses model families: a native-layout
qwen_image_vaesingle file is classified with the Anima base, soanima_latents_to_image— which sets 512/384 and never restores — can feed the same instance in a single workflow.Both nodes now apply the geometry through a context manager that restores all five attributes in a
finally, and resolve thetile_size=0sentinel against a constant rather than reading the module's currenttile_sample_min_height. Together that makes the nodes both non-leaking and immune to geometry left behind by anything else.Related Issues / Discussions
None filed — found while debugging VRAM exhaustion in a latents → image → upscale → image → latents workflow at 2560x1440 on a 24 GB card, where a ~12 GB transformer stays resident across the VAE round-trip.
QA Instructions
Reproducing the limit (no code needed): run a Qwen-Image / Krea-2 img2img round-trip at ~2560x1440 while a large transformer is resident. The VAE nodes request ~20 GB (decode) and ~11 GB (encode) of working memory, forcing the cache to evict the transformer; on a 24 GB card this surfaces as
Loading 0.0 MB into VRAM, but only -N MB were requestedand models loading at <100%.With this PR:
tiledon the Latents to Image and/or Image to Latents - Qwen Image nodes (or setforce_tiled_decode: trueininvokeai.yamlfor both).tiled: falserun. Images should be visually equivalent — tile seams are the failure mode to look for; none were observed on photographic content.tiledoff and confirm behaviour is identical tomain. This is the default path and the most important check.tile_size: 0uses the default (256px). Larger values mean fewer, larger tiles: reserved memory scales withtile_size²(256 → ~0.55 GiB decode, 512 → ~1.9 GiB, at any resolution) while the processed area stays ~2.0x a full frame either way. Values 1–64 are clamped to 64.Automated:
pytest tests/app/invocations/test_qwen_image_working_memory.py— covers the resolved tile size reaching the estimator, the arguments actually passed toenable_tilingon both nodes, the estimator's tiled arithmetic, and a class of tests against a real (tiny, randomly initialised)AutoencoderKLQwenImageasserting shape preservation across tile sizes, state restoration, and immunity to geometry left by another node.Not measured: the decode's runtime peak was not benchmarked separately — only its reservation, and the encode end-to-end. The decode constants themselves are unchanged from the existing calibration.
Behaviour changes worth calling out
force_tiled_decodenow affects encode. ORing the global flag into i2l matchesimage_to_latents.py, so the precedent is established — but users who already haveforce_tiled_decode: trueininvokeai.yamlwill get a ~1.4%-different latent with no node or workflow change on their side. "Off by default" holds for everyone else.width/heightzero-handling on i2l. The workflow UI cannot representNonein a number input and sends 0 for "unset", which reachedresize((0, 0))and raised "height and width must be > 0". Non-positive values are now treated as unset — which also means a half-filled pair (e.g.width=1024, height=0) encodes at the original size rather than erroring.Merge Plan
Nothing special. Both node versions bumped
1.0.0→1.1.0; all new fields have defaults, so saved workflows load unchanged and keep current (untiled) behaviour. The new fields change the generated OpenAPI schema, soschema.tsneeds regenerating as usual.Checklist
What's Newcopy (if doing a release after this PR) — worth a line forforce_tiled_decodeusers, see above