From 22be8138d97061e918927c9f291fb504ab6db990 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 16 Oct 2026 17:20:42 +0200 Subject: [PATCH 1/3] fix(krea2): hold Raw's schedule shift at the end of its fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krea-2 Raw's mu kept climbing past 6400 image tokens (2.21 at 2048², 7.41 at 4096²); calculate_shift now holds it at the fit's end, as the FLUX.1, Z-Image and LTX-2 fits do. krea2_denoise 1.3.1; Turbo and an explicit shift are unchanged. --- .../03.Models/Local Models/krea-2.mdx | 9 +++- .../app/invocations/krea2/krea2_denoise.py | 11 +++-- invokeai/backend/krea2/sampling_utils.py | 10 +++- invokeai/frontend/api/openapi.json | 4 +- invokeai/frontend/api/schema.ts | 2 +- tests/app/invocations/test_krea2_denoise.py | 47 ++++++++++++++++++- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/docs/src/content/docs/Users Guide/03.Models/Local Models/krea-2.mdx b/docs/src/content/docs/Users Guide/03.Models/Local Models/krea-2.mdx index d87a444e5cd..81fc1496276 100644 --- a/docs/src/content/docs/Users Guide/03.Models/Local Models/krea-2.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/Local Models/krea-2.mdx @@ -1,7 +1,7 @@ --- title: Krea-2 description: Generate images with the Krea-2 text-to-image models (Turbo and Raw), including the GGUF / single-file workflow, the conditioning enhancers and training-free style reference. -lastUpdated: 2026-09-30 +lastUpdated: 2026-10-16 sidebar: order: 7 --- @@ -18,6 +18,13 @@ published checkpoints: The variant is detected automatically on install, and selecting a Krea-2 model sets sensible defaults (steps, CFG, 1024×1024) for that variant. +:::note[Image size and the noise schedule] +Raw puts more of its steps at high noise the larger the image, up to the area of 1280×1280 (about 1.6 +megapixels). Larger images keep that schedule, which is also the one Turbo always uses. Without the limit, a +2048×2048 Raw image would spend about half of its steps above 90% noise, and a 4096×4096 one all of them. +To sample with a different schedule, set **Shift** on the **Denoise - Krea-2** node in a workflow. +::: + ## License Krea-2 Turbo and Raw are released under the **Krea 2 Community License**. Commercial use of the model and its images is allowed only for companies with less than $1M in annual revenue; above that, an Enterprise license is needed. Redistributed copies must add "Krea" to the model name. The full Diffusers repositories are gated: accept the license on the model's HuggingFace page and add your HuggingFace token under **API Keys** in the Model Manager. The quantized GGUF and NVFP4 copies are not gated, but the same license applies. diff --git a/invokeai/app/invocations/krea2/krea2_denoise.py b/invokeai/app/invocations/krea2/krea2_denoise.py index 294fd018397..04aa13a51d5 100644 --- a/invokeai/app/invocations/krea2/krea2_denoise.py +++ b/invokeai/app/invocations/krea2/krea2_denoise.py @@ -136,7 +136,7 @@ def log_summary(self) -> None: title="Denoise - Krea-2", tags=["image", "krea2", "krea-2"], category="image", - version="1.3.0", + version="1.3.1", classification=Classification.Prototype, ) class Krea2DenoiseInvocation(BaseInvocation, WithMetadata, WithBoard): @@ -172,7 +172,7 @@ class Krea2DenoiseInvocation(BaseInvocation, WithMetadata, WithBoard): default=None, description="Override the resolution-aware timestep shift (mu). Leave unset -- or 0, which is what " "the node editor fills in for an untouched optional float -- to use the model default: mu=1.15 for " - "the distilled Turbo checkpoint, resolution-dependent for Raw.", + "the distilled Turbo checkpoint, resolution-dependent for Raw up to the area of 1280x1280 and held above it.", ) style_reference: Optional[Krea2StyleReferenceField] = InputField( default=None, @@ -402,9 +402,10 @@ def _run_diffusion(self, context: InvocationContext): elif self._is_distilled(context): mu = KREA2_DISTILLED_MU else: - # Resolution-aware shift. Honor the loaded scheduler's config so a Raw checkpoint that ships a - # customized scheduler_config.json is sampled with its own shift params, falling back to the - # Krea-2 defaults for the stock scheduler (and for any field the config omits). + # Resolution-aware shift, held at the end of its fit (see `calculate_shift`). Honor the loaded + # scheduler's config so a Raw checkpoint that ships a customized scheduler_config.json is sampled + # with its own shift params. A key that file omits takes the diffusers scheduler's own default, + # not Krea-2's; the Krea-2 defaults below apply only to the stock scheduler built above. mu = calculate_shift( image_seq_len, base_image_seq_len=getattr(scheduler.config, "base_image_seq_len", KREA2_BASE_IMAGE_SEQ_LEN), diff --git a/invokeai/backend/krea2/sampling_utils.py b/invokeai/backend/krea2/sampling_utils.py index 875c7cefd46..4c137609fe3 100644 --- a/invokeai/backend/krea2/sampling_utils.py +++ b/invokeai/backend/krea2/sampling_utils.py @@ -82,14 +82,20 @@ def calculate_shift( base_shift: float = KREA2_BASE_SHIFT, max_shift: float = KREA2_MAX_SHIFT, ) -> float: - """Resolution-aware mu (linear interpolation by sequence length). + """Resolution-aware mu: a line through the two config points, held at the last one. + + Past ``max_image_seq_len`` (6400 tokens, 1280x1280 of area) the line would keep climbing: mu 2.2 at + 2048x2048 puts about half of 28 steps above sigma 0.9, and mu 7.4 at 4096x4096 puts all of them + there, so an img2img start has no step near the sigma it asks for. Held with the stock config, Raw + ends at Turbo's fixed mu, which ComfyUI also uses for Krea-2 at every size; diffusers' + ``Krea2Pipeline`` extrapolates. NOTE: mu is fed straight into ``FlowMatchEulerDiscreteScheduler.set_timesteps(..., mu=mu)``; the exponential time-shift happens inside the scheduler. Do NOT ``exp()`` this value. """ m = (max_shift - base_shift) / (max_image_seq_len - base_image_seq_len) b = base_shift - m * base_image_seq_len - return image_seq_len * m + b + return min(image_seq_len, max_image_seq_len) * m + b def build_sigmas(steps: int) -> List[float]: diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index 018206e89dc..dc2c1c8c7af 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -61946,7 +61946,7 @@ } ], "default": null, - "description": "Override the resolution-aware timestep shift (mu). Leave unset -- or 0, which is what the node editor fills in for an untouched optional float -- to use the model default: mu=1.15 for the distilled Turbo checkpoint, resolution-dependent for Raw.", + "description": "Override the resolution-aware timestep shift (mu). Leave unset -- or 0, which is what the node editor fills in for an untouched optional float -- to use the model default: mu=1.15 for the distilled Turbo checkpoint, resolution-dependent for Raw up to the area of 1280x1280 and held above it.", "field_kind": "input", "input": "any", "orig_default": null, @@ -61999,7 +61999,7 @@ "tags": ["image", "krea2", "krea-2"], "title": "Denoise - Krea-2", "type": "object", - "version": "1.3.0", + "version": "1.3.1", "output": { "$ref": "#/components/schemas/LatentsOutput" } diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index 733984fe92e..af053c11b34 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -24310,7 +24310,7 @@ export type components = { seed?: number; /** * Shift - * @description Override the resolution-aware timestep shift (mu). Leave unset -- or 0, which is what the node editor fills in for an untouched optional float -- to use the model default: mu=1.15 for the distilled Turbo checkpoint, resolution-dependent for Raw. + * @description Override the resolution-aware timestep shift (mu). Leave unset -- or 0, which is what the node editor fills in for an untouched optional float -- to use the model default: mu=1.15 for the distilled Turbo checkpoint, resolution-dependent for Raw up to the area of 1280x1280 and held above it. * @default null */ shift?: number | null; diff --git a/tests/app/invocations/test_krea2_denoise.py b/tests/app/invocations/test_krea2_denoise.py index a2ca338534c..d1cc6bbfa36 100644 --- a/tests/app/invocations/test_krea2_denoise.py +++ b/tests/app/invocations/test_krea2_denoise.py @@ -775,16 +775,22 @@ def _mu_branch_context(tmp_path, transformer: _Transformer, variant=Krea2Variant ) -def _install_mu_capturing_scheduler(monkeypatch, captured: list[float], config_kwargs: dict) -> None: +def _install_mu_capturing_scheduler( + monkeypatch, captured: list[float], config_kwargs: dict, stop: bool = False +) -> None: class _MuCapturingScheduler: def __init__(self, **_kwargs) -> None: # The real diffusers scheduler exposes shift params on .config (a FrozenDict, attribute-access); # the denoise reads them via getattr(scheduler.config, , ). Omitting a key - # here (SimpleNamespace without the attr) exercises the fallback-to-default path. + # here (SimpleNamespace without the attr) exercises that fallback, which only the stock scheduler + # reaches: a real scheduler_config.json missing a key gets the diffusers scheduler's default. self.config = SimpleNamespace(num_train_timesteps=1000, **config_kwargs) def set_timesteps(self, *, sigmas, mu, device) -> None: captured.append(mu) + # A frame of real size need not be denoised to know the mu it was handed. + if stop: + raise _StopAtTimesteps self.sigmas = torch.tensor([*sigmas, 0.0], device=device) self.timesteps = self.sigmas[:-1] * self.config.num_train_timesteps @@ -813,6 +819,43 @@ def test_run_diffusion_mu_honors_scheduler_config_shift_params(monkeypatch, tmp_ assert captured[0] != pytest.approx(calculate_shift(image_seq_len)) +class _StopAtTimesteps(Exception): + """Ends a run once the scheduler has been handed its mu.""" + + +@pytest.mark.parametrize( + ("width", "height", "mu"), + [ + (512, 512, 0.58125), # 1024 tokens: on the line + (1024, 1024, 0.90625), # 4096 tokens + (2048, 768, 1.1229166666666667), # 6144 tokens: area decides, not a side + (1280, 1280, 1.15), # 6400 tokens: the line's end + (2048, 2048, 1.15), # 16384 tokens: would be 2.206 extrapolated + (4096, 4096, 1.15), # 65536 tokens: would be 7.41 + ], +) +def test_raw_mu_follows_the_fit_up_to_its_end_and_holds_there(width: int, height: int, mu: float) -> None: + from invokeai.backend.krea2.sampling_utils import calculate_shift + + # One token per 16x16 pixels, as `_run_diffusion` counts them. + assert calculate_shift((width // 16) * (height // 16)) == pytest.approx(mu) + + +def test_raw_mu_holds_at_the_end_of_a_custom_scheduler_fit(monkeypatch, tmp_path) -> None: + # A checkpoint's own scheduler_config.json moves the end of the fit, and the hold moves with it. + _patch_runtime(monkeypatch) + captured: list[float] = [] + custom = {"base_image_seq_len": 256, "max_image_seq_len": 1024, "base_shift": 0.3, "max_shift": 1.5} + _install_mu_capturing_scheduler(monkeypatch, captured, custom, stop=True) + invocation = _mu_branch_invocation() + invocation.width = invocation.height = 2048 + + with pytest.raises(_StopAtTimesteps): + invocation._run_diffusion(_mu_branch_context(tmp_path, _Transformer())) + + assert captured == [pytest.approx(1.5)] + + def test_run_diffusion_mu_falls_back_to_krea2_defaults_for_absent_config_keys(monkeypatch, tmp_path) -> None: # A scheduler config that omits some shift params must fall back to the Krea-2 defaults for exactly the # missing keys, honoring the ones it does provide. From 6a38e3f957503ce139eec2d855cc52ddda275308 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 16 Oct 2026 17:20:55 +0200 Subject: [PATCH 2/3] feat(upscale): support Krea-2 Turbo and Raw in the Upscale widget Adds a Krea-2 path (tiled Qwen-Image VAE, krea2_denoise) with a Qwen3-VL 4B slot and served CFG defaults. The index-clipping table entry now carries the node's rounding rule, since krea2_denoise rounds its start. --- .../04.Image Generation/upscale.mdx | 26 +- .../frontend/webv2/public/locales/en.json | 9 + .../webv2/src/features/generation/settings.ts | 1 + .../__snapshots__/upscaleGraphNodeTypes.json | 176 +++++ .../upscaleIndexClippedStarts.json | 725 ++++++++++++++++-- .../src/features/upscale/core/graph.test.ts | 79 ++ .../webv2/src/features/upscale/core/graph.ts | 120 ++- .../upscale/core/indexClippedStarts.test.ts | 35 +- .../features/upscale/core/settings.test.ts | 34 + .../src/features/upscale/core/settings.ts | 107 ++- .../webv2/src/features/upscale/core/types.ts | 1 + .../upscale/core/upscaleGraphCoverage.test.ts | 18 +- .../features/upscale/ui/UpscaleWidgetView.tsx | 7 + .../src/platform/ui/hints/hintRegistry.ts | 1 + .../webv2/src/workbench/invocation.test.ts | 2 +- .../test_frontend_graph_node_types.py | 6 +- .../test_upscale_index_clipped_starts.py | 130 +++- 17 files changed, 1328 insertions(+), 149 deletions(-) diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx index 2b5cebacfaa..6a3f574c23b 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx @@ -19,30 +19,30 @@ The panel is split into sections, top to bottom: - **Scale**: the output size is the source size multiplied by this value. The panel shows the resulting input and output size and warns when the output is large enough to need much more time and VRAM. - **Treatment preset**, or **Creativity** and **Structure** directly. Higher Creativity invents more detail and gives the prompt more influence. Higher Structure keeps the source composition more strictly; it is only shown when a Tile ControlNet is in use. - **Detail guidance**: the prompt. It is the project prompt shared with Generate. For upscaling, describe the medium, texture and style rather than new content. -- **Generation**: the **Main model** for the detail pass (**SD1.5**, **SDXL**, **FLUX.1**, **Z-Image** or **Anima**), steps, CFG scale, scheduler, seed, and LoRAs that match the main model. FLUX.1 and Z-Image always run **Euler**, so they show no scheduler. +- **Generation**: the **Main model** for the detail pass (**SD1.5**, **SDXL**, **FLUX.1**, **Z-Image**, **Anima** or **Krea-2**), steps, CFG scale, scheduler, seed, and LoRAs that match the main model. FLUX.1, Z-Image and Krea-2 always run **Euler**, so they show no scheduler. - **Advanced**: the components the main model needs (see below), tile size and tile overlap, VAE and VAE precision, and CLIP skip for SD1.5. ## What each model family needs -| | SD1.5 / SDXL | FLUX.1 | Z-Image | Anima | -|---|---|---|---|---| -| Tile ControlNet | Required: a Tile or Union ControlNet for the same family, under **Advanced** | Not used; the Structure slider is hidden | Optional: **Z-Image ControlNet Tile**, under **Advanced**. The Union ControlNet has no tile mode and is not offered | Not used; the Structure slider is hidden | -| Negative prompt | Available | Not used; FLUX.1 ignores it, so it is hidden and not recorded | Used only when CFG is greater than 1 | Used only when CFG is greater than 1 | -| CFG scale | Kept as you set it; back to 2 when you switch from Z-Image or Anima | Kept as you set it, and used as guidance; back to 2 when you switch from Z-Image or Anima | Set to the model's default when you pick the model: 1 for Z-Image Turbo, 4 for Z-Image Base | Set to the model's default when you pick the model: 4.5, or 6 for Anima-3.8B | -| Other components | Taken from the main model | Every FLUX.1 model except an SDNQ pipeline needs a **T5 Encoder**, **CLIP Embed** and **VAE**, all under **Advanced**. SDNQ pipelines bring their own. FLUX Fill models cannot be used | A **Qwen3 Encoder** (the 4B) and a FLUX **VAE**, unless the model is a Diffusers or complete SDNQ install that brings its own | A **Qwen3 Encoder** (the 0.6B) and a Qwen-Image **VAE**; Anima-3.8B also a **Qwen3.5 Encoder** | -| Detail pass | Tile by tile | One pass over the whole image | One pass over the whole image | One pass over the whole image | +| | SD1.5 / SDXL | FLUX.1 | Z-Image | Anima | Krea-2 | +|---|---|---|---|---|---| +| Tile ControlNet | Required: a Tile or Union ControlNet for the same family, under **Advanced** | Not used; the Structure slider is hidden | Optional: **Z-Image ControlNet Tile**, under **Advanced**. The Union ControlNet has no tile mode and is not offered | Not used; the Structure slider is hidden | Not used; the Structure slider is hidden | +| Negative prompt | Available | Not used; FLUX.1 ignores it, so it is hidden and not recorded | Used only when CFG is greater than 1 | Used only when CFG is greater than 1 | Used only when CFG is greater than 1 | +| CFG scale | Kept as you set it; back to 2 when you switch from Z-Image, Anima or Krea-2 | Kept as you set it, and used as guidance; back to 2 when you switch from Z-Image, Anima or Krea-2 | Set to the model's default when you pick the model: 1 for Z-Image Turbo, 4 for Z-Image Base | Set to the model's default when you pick the model: 4.5, or 6 for Anima-3.8B | Set to the model's default when you pick the model: 1 for Turbo, 5.5 for Raw | +| Other components | Taken from the main model | Every FLUX.1 model except an SDNQ pipeline needs a **T5 Encoder**, **CLIP Embed** and **VAE**, all under **Advanced**. SDNQ pipelines bring their own. FLUX Fill models cannot be used | A **Qwen3 Encoder** (the 4B) and a FLUX **VAE**, unless the model is a Diffusers or complete SDNQ install that brings its own | A **Qwen3 Encoder** (the 0.6B) and a Qwen-Image **VAE**; Anima-3.8B also a **Qwen3.5 Encoder** | A **Qwen3-VL Encoder** (the 4B) and a Qwen-Image **VAE**, unless the model is a Diffusers install that brings its own | +| Detail pass | Tile by tile | One pass over the whole image | One pass over the whole image | One pass over the whole image | One pass over the whole image | The panel says what is missing before you can queue, for example "Select a compatible Tile or Union ControlNet." or "This model needs a T5 encoder selected." :::note[Output size] -FLUX.1 and Z-Image need sizes that are a multiple of 16. When the enlarged image is not, the panel shrinks it slightly to the nearest fitting size before the detail pass — for example 2040 px becomes 2032 px. +FLUX.1, Z-Image and Krea-2 need sizes that are a multiple of 16. When the enlarged image is not, the panel shrinks it slightly to the nearest fitting size before the detail pass — for example 2040 px becomes 2032 px. ::: -### Creativity on Z-Image and Anima +### Creativity on Z-Image, Anima and Krea-2 -Z-Image and Anima count the start of the detail pass in steps rather than in noise level, so the same Creativity setting would add much more noise there than on FLUX.1. The panel translates Creativity for them: it starts the pass on the step whose noise level is closest to where FLUX.1 would start. A preset therefore means the same thing on all three, and the pass runs fewer steps than the Steps setting suggests. For example, **Balanced** at 30 steps runs the last 7 or 8, and both need at least 2 steps. +Z-Image, Anima and Krea-2 count the start of the detail pass in steps rather than in noise level, so the same Creativity setting would add much more noise there than on FLUX.1. The panel translates Creativity for them: it starts the pass on the step whose noise level is closest to where FLUX.1 would start. A preset therefore means the same thing on all four, and the pass runs fewer steps than the Steps setting suggests. For example, **Balanced** at 30 steps on a 2048 px result runs the last 7 or 8, and each needs at least 2 steps. Krea-2 Turbo and Raw both keep your Steps setting: Turbo, distilled for 8 steps, then runs the end of a 30-step schedule, and Raw, the undistilled model, gets the steps it needs. -The panel offers only schedulers that start where Creativity puts the pass: **Euler** on FLUX.1 and Z-Image, and **Euler**, **DPM++ 2M**, **DPM++ 2M SDE** and **ER-SDE** on Anima. Heun and LCM build their own noise schedule, so they would treat the enlarged image as pure noise or leave noise in it. +The panel offers only schedulers that start where Creativity puts the pass: **Euler** on FLUX.1, Z-Image and Krea-2, and **Euler**, **DPM++ 2M**, **DPM++ 2M SDE** and **ER-SDE** on Anima. Heun and LCM build their own noise schedule, so they would treat the enlarged image as pure noise or leave noise in it. The Z-Image Tile ControlNet is optional because the detail pass runs over the whole image at once: there are no tile seams for it to hold together. Adding it lets **Structure** pull the result back toward the enlarged image, at the cost of a second network's memory and time. @@ -50,6 +50,6 @@ The Z-Image Tile ControlNet is optional because the detail pass runs over the wh For SD1.5 and SDXL the detail pass works in tiles, so the output can be much larger than what the model generates in one go. Larger tiles can improve continuity but need more VRAM; more tile overlap reduces seams but costs time and memory. -For FLUX.1, Z-Image and Anima the detail pass runs over the whole image at once, so its memory use grows with the output size. There, **Tile size** only sets the tiles for the VAE encode and decode, and **Tile overlap** has no effect. Anima's decode picks its own tiles. +For FLUX.1, Z-Image, Anima and Krea-2 the detail pass runs over the whole image at once, so its memory use grows with the output size. There, **Tile size** only sets the tiles for the VAE encode and decode, and **Tile overlap** has no effect. Anima's decode picks its own tiles. For all model families, encoding and decoding the enlarged image with the VAE runs in tiles. See [Low-VRAM mode](/configuration/optimization/low-vram-mode/) if you still run out of memory. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 4e40c2b4ed6..38caaeec1be 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -971,6 +971,12 @@ "Z-Image and Anima read your prompt with a Qwen3 text encoder: Z-Image with the 4B, Anima with the 0.6B. A bare checkpoint does not carry one, so it has to be selected here." ] }, + "qwen3VlEncoder": { + "heading": "Qwen3-VL Encoder", + "paragraphs": [ + "Krea-2 reads your prompt with the Qwen3-VL 4B text encoder. A single-file or GGUF checkpoint does not carry one, so it has to be selected here." + ] + }, "referenceImage": { "heading": "Global Reference Image", "paragraphs": ["Applies a reference image to influence the entire generation."] @@ -2747,6 +2753,9 @@ "qwen35Encoder": "Qwen3.5 Encoder", "qwen35EncoderRequired": "This model needs a Qwen3.5 encoder selected.", "selectQwen35Encoder": "Select a Qwen3.5 encoder", + "qwen3VlEncoder": "Qwen3-VL Encoder", + "qwen3VlEncoderRequired": "This model needs a Qwen3-VL encoder selected.", + "selectQwen3VlEncoder": "Select a Qwen3-VL encoder", "negativePromptCfgGated": "Used only when CFG is greater than 1.", "vaeRequired": "This model needs a VAE selected.", "tileControlNet": "Tile ControlNet", diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 088197489f9..6f7e5649859 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -66,6 +66,7 @@ export { isAnimaQwen3Encoder, isBundledMainForBase, isDiffusersMainForBase, + isKrea2Qwen3VlEncoder, isNonAnimaQwen3Encoder, isVaeAcceptedByBase, isVaeCompatibleWithGenerateModel, diff --git a/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleGraphNodeTypes.json b/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleGraphNodeTypes.json index c0bac49c30b..0edbbb13917 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleGraphNodeTypes.json +++ b/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleGraphNodeTypes.json @@ -16,6 +16,9 @@ "collect collection anima_lora_collection_loader loras", "collect collection flux_denoise positive_text_conditioning", "collect collection flux_lora_collection_loader loras", + "collect collection krea2_denoise negative_conditioning", + "collect collection krea2_denoise positive_conditioning", + "collect collection krea2_lora_collection_loader loras", "collect collection lora_collection_loader loras", "collect collection sdxl_lora_collection_loader loras", "collect collection tiled_multi_diffusion_denoise_latents control", @@ -28,6 +31,7 @@ "core_metadata metadata anima_l2i metadata", "core_metadata metadata flux_vae_decode metadata", "core_metadata metadata l2i metadata", + "core_metadata metadata qwen_image_l2i metadata", "core_metadata metadata z_image_l2i metadata", "flux_denoise latents flux_vae_decode latents", "flux_lora_collection_loader clip flux_text_encoder clip", @@ -45,14 +49,24 @@ "img_resize height core_metadata height", "img_resize image anima_i2l image", "img_resize image flux_vae_encode image", + "img_resize image qwen_image_i2l image", "img_resize image z_image_control image", "img_resize image z_image_i2l image", "img_resize width core_metadata width", "integer value anima_denoise seed", "integer value core_metadata seed", "integer value flux_denoise seed", + "integer value krea2_denoise seed", "integer value noise seed", "integer value z_image_denoise seed", + "krea2_denoise latents qwen_image_l2i latents", + "krea2_lora_collection_loader qwen3_vl_encoder krea2_text_encoder qwen3_vl_encoder", + "krea2_lora_collection_loader transformer krea2_denoise transformer", + "krea2_model_loader qwen3_vl_encoder krea2_lora_collection_loader qwen3_vl_encoder", + "krea2_model_loader transformer krea2_lora_collection_loader transformer", + "krea2_model_loader vae qwen_image_i2l vae", + "krea2_model_loader vae qwen_image_l2i vae", + "krea2_text_encoder conditioning collect item", "lora_collection_loader clip compel clip", "lora_collection_loader unet tiled_multi_diffusion_denoise_latents unet", "lora_selector lora collect item", @@ -61,6 +75,7 @@ "main_model_loader vae i2l vae", "main_model_loader vae l2i vae", "noise noise tiled_multi_diffusion_denoise_latents noise", + "qwen_image_i2l latents krea2_denoise latents", "sdxl_compel_prompt conditioning tiled_multi_diffusion_denoise_latents negative_conditioning", "sdxl_compel_prompt conditioning tiled_multi_diffusion_denoise_latents positive_conditioning", "sdxl_lora_collection_loader clip sdxl_compel_prompt clip", @@ -77,6 +92,7 @@ "string value core_metadata negative_prompt", "string value core_metadata positive_prompt", "string value flux_text_encoder prompt", + "string value krea2_text_encoder prompt", "string value sdxl_compel_prompt prompt", "string value sdxl_compel_prompt style", "string value z_image_text_encoder prompt", @@ -195,6 +211,24 @@ "string", "unsharp_mask" ] + }, + "krea-2": { + "nodeTypes": [ + "collect", + "core_metadata", + "img_resize", + "integer", + "krea2_denoise", + "krea2_lora_collection_loader", + "krea2_model_loader", + "krea2_text_encoder", + "lora_selector", + "qwen_image_i2l", + "qwen_image_l2i", + "spandrel_image_to_image_autoscale", + "string", + "unsharp_mask" + ] } }, "fieldsByNodeType": { @@ -438,6 +472,7 @@ 2, 4, 4.5, + 5.5, 6 ], "clip_embed_model": [], @@ -454,6 +489,7 @@ "model": [], "qwen3_5_encoder": [], "qwen3_encoder": [], + "qwen3_vl_encoder": [], "rand_device": [ "cpu" ], @@ -703,6 +739,101 @@ "value" ] }, + "krea2_denoise": { + "inputs": [ + "latents", + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "literalInputs": { + "cfg_scale": [ + 1, + 5.5 + ], + "denoising_end": [ + 1 + ], + "denoising_start": [ + 0.6333333333333333, + 0.7666666666666667 + ], + "height": [ + 400 + ], + "is_intermediate": [ + true + ], + "steps": [ + 30 + ], + "use_cache": [ + true + ], + "width": [ + 800 + ] + }, + "outputs": [ + "latents" + ] + }, + "krea2_lora_collection_loader": { + "inputs": [ + "loras", + "qwen3_vl_encoder", + "transformer" + ], + "literalInputs": { + "is_intermediate": [ + true + ], + "use_cache": [ + true + ] + }, + "outputs": [ + "qwen3_vl_encoder", + "transformer" + ] + }, + "krea2_model_loader": { + "inputs": [], + "literalInputs": { + "is_intermediate": [ + true + ], + "model": [], + "qwen3_vl_encoder_model": [], + "use_cache": [ + true + ], + "vae_model": [] + }, + "outputs": [ + "qwen3_vl_encoder", + "transformer", + "vae" + ] + }, + "krea2_text_encoder": { + "inputs": [ + "prompt", + "qwen3_vl_encoder" + ], + "literalInputs": { + "is_intermediate": [ + true + ], + "use_cache": [ + true + ] + }, + "outputs": [ + "conditioning" + ] + }, "l2i": { "inputs": [ "latents", @@ -803,6 +934,51 @@ "noise" ] }, + "qwen_image_i2l": { + "inputs": [ + "image", + "vae" + ], + "literalInputs": { + "is_intermediate": [ + true + ], + "tile_size": [ + 1024 + ], + "tiled": [ + true + ], + "use_cache": [ + true + ] + }, + "outputs": [ + "latents" + ] + }, + "qwen_image_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "literalInputs": { + "is_intermediate": [ + false + ], + "tile_size": [ + 1024 + ], + "tiled": [ + true + ], + "use_cache": [ + false + ] + }, + "outputs": [] + }, "sdxl_compel_prompt": { "inputs": [ "clip", diff --git a/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleIndexClippedStarts.json b/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleIndexClippedStarts.json index 6a604a1716a..2fa5056b50d 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleIndexClippedStarts.json +++ b/invokeai/frontend/webv2/src/features/upscale/core/__snapshots__/upscaleIndexClippedStarts.json @@ -1,5 +1,5 @@ { - "_comment": "Generated by src/features/upscale/core/indexClippedStarts.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_upscale_index_clipped_starts.py, which drives the real z_image_denoise and anima_denoise with each denoisingStart and checks the pass enters at the step nearest fluxEntrySigma, and that the schedulers offered are the ones that start there.", + "_comment": "Generated by src/features/upscale/core/indexClippedStarts.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_upscale_index_clipped_starts.py, which drives the real z_image_denoise, anima_denoise and krea2_denoise with each denoisingStart and checks the pass enters at the step nearest fluxEntrySigma, and that the schedulers offered are the ones that start there.", "cases": [ { "base": "anima", @@ -7,7 +7,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.0019999999999998908, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "anima", @@ -15,7 +16,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.25149999999999995, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "anima", @@ -23,7 +25,8 @@ "denoisingStart": 0.8125, "fluxEntrySigma": 0.5009999999999999, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "anima", @@ -31,7 +34,8 @@ "denoisingStart": 0.5625, "fluxEntrySigma": 0.7505, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "anima", @@ -39,7 +43,8 @@ "denoisingStart": 0.3125, "fluxEntrySigma": 0.9002, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "anima", @@ -47,7 +52,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "anima", @@ -55,7 +61,8 @@ "denoisingStart": 0.9833333333333333, "fluxEntrySigma": 0.0019999999999998908, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "anima", @@ -63,7 +70,8 @@ "denoisingStart": 0.9166666666666666, "fluxEntrySigma": 0.25149999999999995, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "anima", @@ -71,7 +79,8 @@ "denoisingStart": 0.75, "fluxEntrySigma": 0.5009999999999999, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "anima", @@ -79,7 +88,8 @@ "denoisingStart": 0.5166666666666667, "fluxEntrySigma": 0.7505, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "anima", @@ -87,7 +97,8 @@ "denoisingStart": 0.25, "fluxEntrySigma": 0.9002, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "anima", @@ -95,7 +106,548 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.0019999999999998908, + "side": 1024, + "steps": 8, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.25149999999999995, + "side": 1024, + "steps": 8, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.75, + "fluxEntrySigma": 0.5009999999999999, + "side": 1024, + "steps": 8, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 1024, + "steps": 8, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.25, + "fluxEntrySigma": 0.9002, + "side": 1024, + "steps": 8, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 1024, + "steps": 8, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.9666666666666667, + "fluxEntrySigma": 0.0019999999999998908, + "side": 1024, + "steps": 30, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.9, + "fluxEntrySigma": 0.25149999999999995, + "side": 1024, + "steps": 30, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.7666666666666667, + "fluxEntrySigma": 0.5009999999999999, + "side": 1024, + "steps": 30, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 1024, + "steps": 30, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.26666666666666666, + "fluxEntrySigma": 0.9002, + "side": 1024, + "steps": 30, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 1024, + "steps": 30, + "variant": "krea2_turbo" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.0019999999999998908, + "side": 512, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.25149999999999995, + "side": 512, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.625, + "fluxEntrySigma": 0.5009999999999999, + "side": 512, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.375, + "fluxEntrySigma": 0.7505, + "side": 512, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.125, + "fluxEntrySigma": 0.9002, + "side": 512, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 512, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.9666666666666667, + "fluxEntrySigma": 0.0019999999999998908, + "side": 512, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.8333333333333334, + "fluxEntrySigma": 0.25149999999999995, + "side": 512, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.6333333333333333, + "fluxEntrySigma": 0.5009999999999999, + "side": 512, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.36666666666666664, + "fluxEntrySigma": 0.7505, + "side": 512, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.16666666666666666, + "fluxEntrySigma": 0.9002, + "side": 512, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 512, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.0019999999999998908, + "side": 1024, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.25149999999999995, + "side": 1024, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.75, + "fluxEntrySigma": 0.5009999999999999, + "side": 1024, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 1024, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.25, + "fluxEntrySigma": 0.9002, + "side": 1024, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 1024, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.9666666666666667, + "fluxEntrySigma": 0.0019999999999998908, + "side": 1024, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.8666666666666667, + "fluxEntrySigma": 0.25149999999999995, + "side": 1024, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.7, + "fluxEntrySigma": 0.5009999999999999, + "side": 1024, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.4666666666666667, + "fluxEntrySigma": 0.7505, + "side": 1024, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.2, + "fluxEntrySigma": 0.9002, + "side": 1024, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 1024, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.0019999999999998908, + "side": 2048, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.25149999999999995, + "side": 2048, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.75, + "fluxEntrySigma": 0.5009999999999999, + "side": 2048, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 2048, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.25, + "fluxEntrySigma": 0.9002, + "side": 2048, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 2048, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.9666666666666667, + "fluxEntrySigma": 0.0019999999999998908, + "side": 2048, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.9, + "fluxEntrySigma": 0.25149999999999995, + "side": 2048, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.7666666666666667, + "fluxEntrySigma": 0.5009999999999999, + "side": 2048, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 2048, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.26666666666666666, + "fluxEntrySigma": 0.9002, + "side": 2048, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 2048, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.0019999999999998908, + "side": 4096, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.875, + "fluxEntrySigma": 0.25149999999999995, + "side": 4096, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.75, + "fluxEntrySigma": 0.5009999999999999, + "side": 4096, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 4096, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.25, + "fluxEntrySigma": 0.9002, + "side": 4096, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 4096, + "steps": 8, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -10, + "denoisingStart": 0.9666666666666667, + "fluxEntrySigma": 0.0019999999999998908, + "side": 4096, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": -5, + "denoisingStart": 0.9, + "fluxEntrySigma": 0.25149999999999995, + "side": 4096, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 0, + "denoisingStart": 0.7666666666666667, + "fluxEntrySigma": 0.5009999999999999, + "side": 4096, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 5, + "denoisingStart": 0.5, + "fluxEntrySigma": 0.7505, + "side": 4096, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 8, + "denoisingStart": 0.26666666666666666, + "fluxEntrySigma": 0.9002, + "side": 4096, + "steps": 30, + "variant": "krea2_base" + }, + { + "base": "krea-2", + "creativity": 10, + "denoisingStart": 0, + "fluxEntrySigma": 1, + "side": 4096, + "steps": 30, + "variant": "krea2_base" }, { "base": "z-image", @@ -103,7 +655,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.0019999999999998908, "side": 512, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -111,7 +664,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.25149999999999995, "side": 512, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -119,7 +673,8 @@ "denoisingStart": 0.6875, "fluxEntrySigma": 0.5009999999999999, "side": 512, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -127,7 +682,8 @@ "denoisingStart": 0.4375, "fluxEntrySigma": 0.7505, "side": 512, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -135,7 +691,8 @@ "denoisingStart": 0.1875, "fluxEntrySigma": 0.9002, "side": 512, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -143,7 +700,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 512, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -151,7 +709,8 @@ "denoisingStart": 0.9833333333333333, "fluxEntrySigma": 0.0019999999999998908, "side": 512, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -159,7 +718,8 @@ "denoisingStart": 0.85, "fluxEntrySigma": 0.25149999999999995, "side": 512, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -167,7 +727,8 @@ "denoisingStart": 0.6833333333333333, "fluxEntrySigma": 0.5009999999999999, "side": 512, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -175,7 +736,8 @@ "denoisingStart": 0.4166666666666667, "fluxEntrySigma": 0.7505, "side": 512, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -183,7 +745,8 @@ "denoisingStart": 0.18333333333333332, "fluxEntrySigma": 0.9002, "side": 512, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -191,7 +754,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 512, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -199,7 +763,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.0019999999999998908, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -207,7 +772,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.25149999999999995, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -215,7 +781,8 @@ "denoisingStart": 0.8125, "fluxEntrySigma": 0.5009999999999999, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -223,7 +790,8 @@ "denoisingStart": 0.5625, "fluxEntrySigma": 0.7505, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -231,7 +799,8 @@ "denoisingStart": 0.3125, "fluxEntrySigma": 0.9002, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -239,7 +808,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 1024, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -247,7 +817,8 @@ "denoisingStart": 0.9833333333333333, "fluxEntrySigma": 0.0019999999999998908, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -255,7 +826,8 @@ "denoisingStart": 0.9166666666666666, "fluxEntrySigma": 0.25149999999999995, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -263,7 +835,8 @@ "denoisingStart": 0.7833333333333333, "fluxEntrySigma": 0.5009999999999999, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -271,7 +844,8 @@ "denoisingStart": 0.5166666666666667, "fluxEntrySigma": 0.7505, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -279,7 +853,8 @@ "denoisingStart": 0.2833333333333333, "fluxEntrySigma": 0.9002, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -287,7 +862,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 1024, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -295,7 +871,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.0019999999999998908, "side": 2048, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -303,7 +880,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.25149999999999995, "side": 2048, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -311,7 +889,8 @@ "denoisingStart": 0.8125, "fluxEntrySigma": 0.5009999999999999, "side": 2048, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -319,7 +898,8 @@ "denoisingStart": 0.5625, "fluxEntrySigma": 0.7505, "side": 2048, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -327,7 +907,8 @@ "denoisingStart": 0.3125, "fluxEntrySigma": 0.9002, "side": 2048, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -335,7 +916,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 2048, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -343,7 +925,8 @@ "denoisingStart": 0.9833333333333333, "fluxEntrySigma": 0.0019999999999998908, "side": 2048, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -351,7 +934,8 @@ "denoisingStart": 0.9166666666666666, "fluxEntrySigma": 0.25149999999999995, "side": 2048, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -359,7 +943,8 @@ "denoisingStart": 0.7833333333333333, "fluxEntrySigma": 0.5009999999999999, "side": 2048, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -367,7 +952,8 @@ "denoisingStart": 0.5166666666666667, "fluxEntrySigma": 0.7505, "side": 2048, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -375,7 +961,8 @@ "denoisingStart": 0.2833333333333333, "fluxEntrySigma": 0.9002, "side": 2048, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -383,7 +970,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 2048, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -391,7 +979,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.0019999999999998908, "side": 4096, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -399,7 +988,8 @@ "denoisingStart": 0.9375, "fluxEntrySigma": 0.25149999999999995, "side": 4096, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -407,7 +997,8 @@ "denoisingStart": 0.8125, "fluxEntrySigma": 0.5009999999999999, "side": 4096, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -415,7 +1006,8 @@ "denoisingStart": 0.5625, "fluxEntrySigma": 0.7505, "side": 4096, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -423,7 +1015,8 @@ "denoisingStart": 0.3125, "fluxEntrySigma": 0.9002, "side": 4096, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -431,7 +1024,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 4096, - "steps": 8 + "steps": 8, + "variant": null }, { "base": "z-image", @@ -439,7 +1033,8 @@ "denoisingStart": 0.9833333333333333, "fluxEntrySigma": 0.0019999999999998908, "side": 4096, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -447,7 +1042,8 @@ "denoisingStart": 0.9166666666666666, "fluxEntrySigma": 0.25149999999999995, "side": 4096, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -455,7 +1051,8 @@ "denoisingStart": 0.7833333333333333, "fluxEntrySigma": 0.5009999999999999, "side": 4096, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -463,7 +1060,8 @@ "denoisingStart": 0.5166666666666667, "fluxEntrySigma": 0.7505, "side": 4096, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -471,7 +1069,8 @@ "denoisingStart": 0.2833333333333333, "fluxEntrySigma": 0.9002, "side": 4096, - "steps": 30 + "steps": 30, + "variant": null }, { "base": "z-image", @@ -479,7 +1078,8 @@ "denoisingStart": 0, "fluxEntrySigma": 1, "side": 4096, - "steps": 30 + "steps": 30, + "variant": null } ], "schedulers": { @@ -489,6 +1089,9 @@ "dpmpp_2m_sde", "er_sde" ], + "krea-2": [ + "euler" + ], "z-image": [ "euler" ] diff --git a/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts b/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts index cbe05e45983..157dedbb9c3 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts @@ -411,6 +411,71 @@ describe('compileUpscaleGraph for Anima', () => { }); }); +const createKrea2Values = (variant: 'krea2_turbo' | 'krea2_base', format = 'gguf_quantized') => { + const models = [ + { ...withVariant(model('main', 'main', 'krea-2'), variant), format } as ModelConfig, + model('spandrel', 'spandrel_image_to_image', 'any'), + model('qwenvae', 'vae', 'qwen-image'), + // Ideogram 4's 8B is the same model type and sorts first; `krea2_model_loader` refuses it. + withVariant(model('qwen3vl-8b', 'qwen3_vl_encoder', 'any'), 'qwen3_vl_8b'), + withVariant(model('qwen3vl-4b', 'qwen3_vl_encoder', 'any'), 'qwen3_vl_4b'), + ]; + + return { + ...createDefaultUpscaleWidgetValues(models), + cfgScale: variant === 'krea2_base' ? 5.5 : 1, + inputImage: { height: 101, image_name: 'input.png', width: 203 }, + positivePrompt: 'detail', + }; +}; + +describe('compileUpscaleGraph for Krea-2', () => { + it('drives the second pass with krea2 nodes and the Qwen-Image VAE, tiled at both ends', () => { + const { edges, nodes } = compileUpscaleGraph(createKrea2Values('krea2_base'), 'gallery', { + useCpuNoise: true, + }).backendGraph; + + expect(nodes.model_loader).toMatchObject({ type: 'krea2_model_loader' }); + expect(nodes.model_loader?.qwen3_vl_encoder_model).toMatchObject({ key: 'qwen3vl-4b' }); + expect(nodes.model_loader?.vae_model).toMatchObject({ key: 'qwenvae' }); + expect(nodes.i2l).toMatchObject({ tile_size: 1024, tiled: true, type: 'qwen_image_i2l' }); + expect(nodes.upscale_output).toMatchObject({ tile_size: 1024, tiled: true, type: 'qwen_image_l2i' }); + // 203x101 at 4x: Spandrel gives 808x400, and krea2_denoise takes multiples of 16 only. + expect(nodes.denoise_latents).toMatchObject({ + cfg_scale: 5.5, + height: 400, + steps: 30, + type: 'krea2_denoise', + width: 800, + }); + // The node has no scheduler, and picks its own shift: fixed for Turbo, by size for Raw. + expect(nodes.denoise_latents).not.toHaveProperty('scheduler'); + expect(nodes.denoise_latents).not.toHaveProperty('shift'); + expect(nodes.core_metadata).toMatchObject({ qwen3_vl_encoder: { key: 'qwen3vl-4b' }, scheduler: 'euler' }); + expect(hasEdge(edges, 'fit_to_grid', 'image', 'i2l', 'image')).toBe(true); + expect(hasEdge(edges, 'i2l', 'latents', 'denoise_latents', 'latents')).toBe(true); + expect(hasEdge(edges, 'neg_cond_collect', 'collection', 'denoise_latents', 'negative_conditioning')).toBe(true); + expect(hasEdge(edges, 'model_loader', 'qwen3_vl_encoder', 'pos_cond', 'qwen3_vl_encoder')).toBe(true); + }); + + it('runs Turbo at CFG 1 without the unconditional branch it would not use', () => { + const { nodes } = compileUpscaleGraph(createKrea2Values('krea2_turbo'), 'gallery', { + useCpuNoise: true, + }).backendGraph; + + expect(nodes.denoise_latents?.cfg_scale).toBe(1); + expect(nodes.neg_cond).toBeUndefined(); + }); + + it('lets a Diffusers install supply its own encoder and VAE', () => { + const values = { ...createKrea2Values('krea2_turbo', 'diffusers'), vae: null }; + const { nodes } = compileUpscaleGraph(values, 'gallery', { useCpuNoise: true }).backendGraph; + + expect(nodes.model_loader?.qwen3_vl_encoder_model).toBeUndefined(); + expect(nodes.model_loader?.vae_model).toBeUndefined(); + }); +}); + describe('Creativity on index-clipping architectures', () => { // Expected starts come from the backend's own schedules (`ZImageDenoiseInvocation._get_sigmas` // with `_calculate_shift`, `AnimaDenoiseInvocation._get_sigmas`) and the nodes' clipping line, @@ -443,6 +508,19 @@ describe('Creativity on index-clipping architectures', () => { expect(startFor(createAnimaValues(), creativity, 1024)).toBeCloseTo(expected, 6); }); + // `krea2_denoise` rounds `start * steps` instead, so the start is the step itself. Expected steps were + // worked out from the node's schedule: `exp(mu) * t / (1 + (exp(mu) - 1) * t)` at `t = 1 - i / 30`. + it.each([ + ['Turbo', 'krea2_turbo', 1024, 0, 23 / 30], + ['Turbo', 'krea2_turbo', 1024, 5, 15 / 30], + // 1024px: Raw's fit gives mu 0.906, a flatter schedule than Turbo's 1.15. + ['Raw', 'krea2_base', 512, 0, 21 / 30], + // 2048px: past 1280x1280 Raw is held at mu 1.15, Turbo's schedule. + ['Raw', 'krea2_base', 1024, 0, 23 / 30], + ] as const)('starts Krea-2 %s from a %ipx input where FLUX starts', (_label, variant, side, creativity, expected) => { + expect(startFor(createKrea2Values(variant), creativity, side)).toBeCloseTo(expected, 6); + }); + it('leaves the value-clipping FLUX pass on the legacy formula', () => { expect(startFor(createFluxValues(), 0, 1024)).toBeCloseTo(0.499, 6); }); @@ -473,6 +551,7 @@ describe('upscale metadata', () => { ['flux', () => createFluxValues()], ['z-image', () => createZImageValues()], ['anima', () => createAnimaValues()], + ['krea-2', () => createKrea2Values('krea2_base')], ] as const)('records the size of the image the %s pass encoded, which is the size of the result', (_base, create) => { // Where a pass resizes Spandrel's frame, the result does not have Spandrel's size: a 1x // restoration model leaves the frame unscaled, and its multiple of 8 is not FLUX's or Z-Image's diff --git a/invokeai/frontend/webv2/src/features/upscale/core/graph.ts b/invokeai/frontend/webv2/src/features/upscale/core/graph.ts index 8d606cbaac8..9aba9da136b 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/upscale/core/graph.ts @@ -18,7 +18,7 @@ import { } from '@features/generation/graph'; import { isBundledMainForBase } from '@features/generation/settings'; -import type { UpscaleBase } from './settings'; +import type { UpscaleBase, UpscaleIndexClipping } from './settings'; import type { CompiledUpscaleGraph, UpscaleWidgetValues } from './types'; import { @@ -38,13 +38,19 @@ export const getUpscaleDenoisingStart = (creativity: number): number => ((creati * * Creativity is defined by where FLUX enters: `flux_denoise` clips the schedule by *value*, so a start * of 0.499 enters at sigma 0.501 whatever the shift or step count. Z-Image and Anima clip by *index* - * instead, so the same number enters far noisier there -- 0.499 starts Z-Image at sigma 0.78. Both - * build `sigma = shift * t / (1 + (shift - 1) * t)` at `t = 1 - i / steps` and begin at - * `i = int(start * steps)`. This picks the `i` whose sigma is nearest `1 - valueStart` and aims at the - * middle of that step, so float error in the node's product cannot floor it one step short. It goes - * away when those nodes clip by value; `test_upscale_index_clipped_starts.py` fails when they do. + * instead, so the same number enters far noisier there -- 0.499 starts Z-Image at sigma 0.78. All of + * them build `sigma = shift * t / (1 + (shift - 1) * t)` at `t = 1 - i / steps`. This picks the `i` + * whose sigma is nearest `1 - valueStart`. Z-Image and Anima begin at `i = int(start * steps)`, so the + * start aims at the middle of that step, where float error in the node's product cannot floor it one + * step short. Krea-2 rounds instead, half to even, so its start is the step itself. This goes away when + * those nodes clip by value; `test_upscale_index_clipped_starts.py` fails when they do. */ -export const getIndexClippedDenoisingStart = (valueStart: number, steps: number, shift: number): number => { +export const getIndexClippedDenoisingStart = ( + valueStart: number, + steps: number, + shift: number, + rounding: UpscaleIndexClipping['rounding'] +): number => { const target = 1 - valueStart; let entryIndex = 0; let entryDistance = Number.POSITIVE_INFINITY; @@ -60,7 +66,11 @@ export const getIndexClippedDenoisingStart = (valueStart: number, steps: number, } } - return entryIndex === 0 ? 0 : (entryIndex + 0.5) / steps; + if (entryIndex === 0) { + return 0; + } + + return rounding === 'floor' ? (entryIndex + 0.5) / steps : entryIndex / steps; }; export const getUpscaleControlNetValues = (structure: number) => { @@ -113,6 +123,7 @@ const addUpscaleMetadata = ( ...(components.clipEmbedModel ? { clip_embed_model: toModelIdentifier(components.clipEmbedModel) } : {}), ...(components.qwen3EncoderModel ? { qwen3_encoder: toModelIdentifier(components.qwen3EncoderModel) } : {}), ...(components.qwen35EncoderModel ? { qwen3_5_encoder: toModelIdentifier(components.qwen35EncoderModel) } : {}), + ...(components.qwen3VlEncoderModel ? { qwen3_vl_encoder: toModelIdentifier(components.qwen3VlEncoderModel) } : {}), creativity: settings.creativity, id: 'core_metadata', // Runs after the path builder, so the graph already holds the LoRAs this architecture loads. @@ -625,7 +636,87 @@ const buildAnimaUpscalePath: UpscalePathBuilder = ({ }; /** - * Z-Image's and Anima's text encoders take their encoders on named fields and hand the denoise a + * Krea-2 runs the pass whole like FLUX.1, both VAE ends tiled. It encodes and decodes with the + * Qwen-Image VAE through that family's nodes, as Generate and Canvas do. `krea2_denoise` picks its own + * shift, fixed for Turbo and by size for Raw, so the graph sends none. + */ +const buildKrea2UpscalePath: UpscalePathBuilder = ({ + graph, + settings, + model, + components, + destination, + positivePrompt, + negativePrompt, + seed, + frame, + denoiseSize, + denoisingStart, +}) => { + const modelLoader = addNode(graph, { + id: 'model_loader', + model: toModelIdentifier(model), + qwen3_vl_encoder_model: components.qwen3VlEncoderModel + ? toModelIdentifier(components.qwen3VlEncoderModel) + : undefined, + type: 'krea2_model_loader', + vae_model: settings.vae ? toModelIdentifier(settings.vae) : undefined, + }); + const activeLoras = getActiveCompatibleLoras({ loras: settings.loras }, model); + const loraSource = activeLoras.length + ? addTransformerLoraCollectionLoader(graph, activeLoras, 'krea2_lora_collection_loader', modelLoader, [ + 'transformer', + 'qwen3_vl_encoder', + ]) + : modelLoader; + const imageToLatents = addNode(graph, { + id: 'i2l', + tile_size: settings.tileSize, + tiled: true, + type: 'qwen_image_i2l', + }); + const denoise = addNode(graph, { + cfg_scale: settings.cfgScale, + denoising_end: 1, + denoising_start: denoisingStart, + height: denoiseSize.height, + id: 'denoise_latents', + steps: settings.steps, + type: 'krea2_denoise', + width: denoiseSize.width, + }); + const output = addNode(graph, { + id: 'upscale_output', + is_intermediate: destination === 'canvas', + tile_size: settings.tileSize, + tiled: true, + type: 'qwen_image_l2i', + use_cache: false, + }); + + addPromptConditioning(graph, { + denoise, + encoderFields: [{ field: 'qwen3_vl_encoder', source: loraSource }], + encoderType: 'krea2_text_encoder', + negativePrompt: wiresUpscaleNegativePrompt(model, settings.cfgScale) ? negativePrompt : null, + positivePrompt, + }); + addEdge(graph, loraSource, 'transformer', denoise, 'transformer'); + addEdge(graph, seed, 'value', denoise, 'seed'); + + const fitted = fitFrameToDenoiseSize(graph, frame, denoiseSize); + + addEdge(graph, fitted, 'image', imageToLatents, 'image'); + addEdge(graph, modelLoader, 'vae', imageToLatents, 'vae'); + addEdge(graph, imageToLatents, 'latents', denoise, 'latents'); + addEdge(graph, denoise, 'latents', output, 'latents'); + addEdge(graph, modelLoader, 'vae', output, 'vae'); + + return { frame: fitted, output, title: `${model.name} upscale` }; +}; + +/** + * Z-Image's, Anima's and Krea-2's text encoders take their encoders on named fields and hand the denoise a * collected list; the negative branch exists only where the caller decided it is read. */ const addPromptConditioning = ( @@ -669,6 +760,7 @@ const addPromptConditioning = ( const UPSCALE_PATH_BUILDERS = { anima: buildAnimaUpscalePath, flux: buildFluxUpscalePath, + 'krea-2': buildKrea2UpscalePath, 'sd-1': buildSdUpscalePath, sdxl: buildSdUpscalePath, 'z-image': buildZImageUpscalePath, @@ -705,8 +797,14 @@ export const compileUpscaleGraph = ( width: upscaleDenoiseDimension(inputImage.width, settings.scale, grid), }; const valueClippedStart = getUpscaleDenoisingStart(settings.creativity); - const denoisingStart = architecture?.indexClippedShift - ? getIndexClippedDenoisingStart(valueClippedStart, settings.steps, architecture.indexClippedShift(denoiseSize)) + const clipping = architecture?.indexClipping; + const denoisingStart = clipping + ? getIndexClippedDenoisingStart( + valueClippedStart, + settings.steps, + clipping.shift(model, denoiseSize), + clipping.rounding + ) : valueClippedStart; const graph: BackendGraphContract = { edges: [], id: 'upscale-graph', nodes: {} }; const positivePrompt = addNode(graph, { id: 'positive_prompt', type: 'string' }); diff --git a/invokeai/frontend/webv2/src/features/upscale/core/indexClippedStarts.test.ts b/invokeai/frontend/webv2/src/features/upscale/core/indexClippedStarts.test.ts index 5ff37d3233a..c036cbfc25d 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/indexClippedStarts.test.ts +++ b/invokeai/frontend/webv2/src/features/upscale/core/indexClippedStarts.test.ts @@ -1,5 +1,7 @@ /** The starts the widget sends index-clipping denoise nodes, recorded for the backend to replay. */ +import type { MainModelConfig } from '@features/generation/contracts'; + import { describe, expect, it } from 'vitest'; import type { UpscaleBase } from './settings'; @@ -9,33 +11,43 @@ import { UPSCALE_ARCHITECTURES } from './settings'; const CREATIVITIES = [-10, -5, 0, 5, 8, 10]; const STEP_COUNTS = [8, 30]; -// Z-Image's shift follows the frame up to one megapixel; Anima's is fixed. -const FRAME_SIDES: Partial> = { anima: [1024], 'z-image': [512, 1024, 2048, 4096] }; +// Z-Image's shift follows the frame up to one megapixel and Krea-2 Raw's up to 1280x1280, held above; +// Anima's and Krea-2 Turbo's are fixed. +const MODELS: readonly { base: UpscaleBase; variant: string | null; sides: readonly number[] }[] = [ + { base: 'anima', sides: [1024], variant: null }, + { base: 'krea-2', sides: [1024], variant: 'krea2_turbo' }, + { base: 'krea-2', sides: [512, 1024, 2048, 4096], variant: 'krea2_base' }, + { base: 'z-image', sides: [512, 1024, 2048, 4096], variant: null }, +]; +const BASES = [...new Set(MODELS.map((model) => model.base))]; describe('index-clipped denoising starts', () => { it('covers every architecture whose denoise node clips by index', () => { const indexClipped = Object.entries(UPSCALE_ARCHITECTURES) - .filter(([, architecture]) => architecture.indexClippedShift) + .filter(([, architecture]) => architecture.indexClipping) .map(([base]) => base); - expect(Object.keys(FRAME_SIDES).sort()).toEqual(indexClipped.sort()); + expect([...BASES].sort()).toEqual(indexClipped.sort()); }); it('records what the widget sends, so the backend can check it against its real schedules', async () => { - const cases = Object.entries(FRAME_SIDES).flatMap(([base, sides]) => + const cases = MODELS.flatMap(({ base, sides, variant }) => sides.flatMap((side) => STEP_COUNTS.flatMap((steps) => CREATIVITIES.map((creativity) => { const valueStart = getUpscaleDenoisingStart(creativity); - const shift = UPSCALE_ARCHITECTURES[base as UpscaleBase].indexClippedShift!({ height: side, width: side }); + const clipping = UPSCALE_ARCHITECTURES[base].indexClipping!; + const model = { base, variant } as MainModelConfig; + const shift = clipping.shift(model, { height: side, width: side }); return { base, creativity, - denoisingStart: getIndexClippedDenoisingStart(valueStart, steps, shift), + denoisingStart: getIndexClippedDenoisingStart(valueStart, steps, shift, clipping.rounding), fluxEntrySigma: 1 - valueStart, side, steps, + variant, }; }) ) @@ -45,12 +57,11 @@ describe('index-clipped denoising starts', () => { _comment: 'Generated by src/features/upscale/core/indexClippedStarts.test.ts; regenerate with `vitest -u`. ' + 'Consumed by tests/app/invocations/test_upscale_index_clipped_starts.py, which drives the real ' + - 'z_image_denoise and anima_denoise with each denoisingStart and checks the pass enters at the ' + - 'step nearest fluxEntrySigma, and that the schedulers offered are the ones that start there.', + 'z_image_denoise, anima_denoise and krea2_denoise with each denoisingStart and checks the pass ' + + 'enters at the step nearest fluxEntrySigma, and that the schedulers offered are the ones that ' + + 'start there.', cases, - schedulers: Object.fromEntries( - Object.keys(FRAME_SIDES).map((base) => [base, UPSCALE_ARCHITECTURES[base as UpscaleBase].schedulers]) - ), + schedulers: Object.fromEntries(BASES.map((base) => [base, UPSCALE_ARCHITECTURES[base].schedulers])), }; await expect(`${JSON.stringify(fixture, null, 2)}\n`).toMatchFileSnapshot( diff --git a/invokeai/frontend/webv2/src/features/upscale/core/settings.test.ts b/invokeai/frontend/webv2/src/features/upscale/core/settings.test.ts index 7954e764b51..21de7ee21b0 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/upscale/core/settings.test.ts @@ -340,6 +340,32 @@ describe('Z-Image and Anima components', () => { ); }); + it('gives a single-file Krea-2 the Qwen3-VL 4B, never the 8B its loader refuses', () => { + // Ideogram 4's 8B is the same model type and sorts first here. + const qwen3Vl8b = withVariant(model('qwen3vl-8b', 'qwen3_vl_encoder', 'any'), 'qwen3_vl_8b'); + const qwen3Vl4b = withVariant(model('qwen3vl-4b', 'qwen3_vl_encoder', 'any'), 'qwen3_vl_4b'); + const base = [model('spandrel', 'spandrel_image_to_image', 'any'), model('vae', 'vae', 'qwen-image')]; + const raw = { ...withVariant(model('k', 'main', 'krea-2'), 'krea2_base'), format: 'gguf_quantized' } as ModelConfig; + const values = ready([raw, ...base, qwen3Vl8b, qwen3Vl4b]); + + expect([values.qwen3VlEncoderModel?.key, values.vae?.key]).toEqual(['qwen3vl-4b', 'vae']); + expect(getUpscaleValidationReasons(values)).toEqual([]); + expect(getUpscaleValidationReasons(ready([raw, ...base, qwen3Vl8b]))).toEqual([ + 'Upscale needs a Qwen3-VL 4B encoder for this main model.', + ]); + }); + + it('lets a Diffusers Krea-2 bring its own encoder and VAE', () => { + const diffusers = { + ...withVariant(model('k', 'main', 'krea-2'), 'krea2_turbo'), + format: 'diffusers', + } as ModelConfig; + + expect( + getUpscaleValidationReasons(ready([diffusers, model('spandrel', 'spandrel_image_to_image', 'any')])) + ).toEqual([]); + }); + it('keeps a hand-picked encoder through a switch to an architecture that does not read it', () => { const models = [ model('flux', 'main', 'flux'), @@ -371,6 +397,8 @@ describe('picking a main model', () => { withVariant(model('z-base', 'main', 'z-image'), 'zbase'), withVariant(model('anima', 'main', 'anima'), 'anima_qwen3'), withVariant(model('anima-38', 'main', 'anima'), 'anima_qwen35'), + withVariant(model('krea2-turbo', 'main', 'krea-2'), 'krea2_turbo'), + withVariant(model('krea2-raw', 'main', 'krea-2'), 'krea2_base'), ]; const cfgAfterPicking = (key: string) => selectUpscaleMainModel( @@ -384,6 +412,10 @@ describe('picking a main model', () => { expect(['z-turbo', 'z-base', 'anima', 'anima-38'].map(cfgAfterPicking)).toEqual([1, 4, 4.5, 6]); }); + it('runs Krea-2 Turbo without CFG and Raw at the CFG it is served with', () => { + expect(['krea2-turbo', 'krea2-raw'].map(cfgAfterPicking)).toEqual([1, 5.5]); + }); + it('leaves SD and FLUX at the CFG the user set', () => { expect(['sd15', 'flux'].map(cfgAfterPicking)).toEqual([3, 3]); }); @@ -462,6 +494,8 @@ describe('schedulers per architecture', () => { expect(offered('flux')).toEqual(['euler']); expect(offered('z-image')).toEqual(['euler']); expect(offered('anima')).toEqual(['euler', 'dpmpp_2m', 'dpmpp_2m_sde', 'er_sde']); + // `krea2_denoise` has no scheduler input. + expect(offered('krea-2')).toEqual(['euler']); // SD's pass adds noise with the scheduler itself, so every one of its schedulers starts there. expect(offered('sdxl')).toEqual( getSchedulerOptions(model('sdxl', 'main', 'sdxl') as never).map((option) => option.value) diff --git a/invokeai/frontend/webv2/src/features/upscale/core/settings.ts b/invokeai/frontend/webv2/src/features/upscale/core/settings.ts index 64df5552969..a2505e81d82 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/upscale/core/settings.ts @@ -18,6 +18,7 @@ import { isAnimaQwen35Encoder, isAnimaQwen3Encoder, isBundledMainForBase, + isKrea2Qwen3VlEncoder, isLoraCompatibleWithModel, isLoraModelConfig, isMainModelConfig, @@ -34,9 +35,9 @@ import { isSeedMode, SEED_MAX } from '@platform/core/seed'; import type { SpandrelModelConfig, TileControlNetModelConfig, UpscaleWidgetValues } from './types'; -// The schedule shifts of the two denoise nodes that clip `denoising_start` by step index, for the -// table's `indexClippedShift`; `getIndexClippedDenoisingStart` in graph.ts uses them to start those -// nodes where FLUX would. They go away when those nodes clip by value. +// The schedule shifts of the denoise nodes that clip `denoising_start` by step index, for the table's +// `indexClipping`; `getIndexClippedDenoisingStart` in graph.ts uses them to start those nodes where +// FLUX would. They go away when those nodes clip by value. /** `z_image_denoise._calculate_shift`, held at the fit's 4096-token end since the clamp (#378). */ const Z_IMAGE_SHIFT_MIN_SEQ_LEN = 256; @@ -55,13 +56,46 @@ const zImageScheduleShift = ({ width, height }: { width: number; height: number /** `ANIMA_SHIFT`: Anima's schedule is shifted by a constant, not by resolution. */ const ANIMA_SCHEDULE_SHIFT = 3; +/** + * `krea2/sampling_utils.py`: Turbo's fixed mu, and Raw's fit by token count. A Diffusers install samples + * with its own `scheduler/scheduler_config.json`; Krea's ships these same numbers, and one that did not + * would move Raw's starts below 1280x1280 without the backend contract noticing. + */ +const KREA2_DISTILLED_MU = 1.15; +const KREA2_SHIFT_MIN_SEQ_LEN = 256; +const KREA2_SHIFT_MAX_SEQ_LEN = 6400; +const KREA2_BASE_SHIFT = 0.5; +const KREA2_MAX_SHIFT = 1.15; + +/** + * Krea-2's linear shift, `exp(mu)`: fixed for Turbo, and for Raw (`krea2_base`) its fit at one token per + * 16x16 pixels, held at the fit's 6400-token end as `krea2_denoise` holds it. Like the node, anything that + * is not Raw counts as distilled. + */ +const krea2ScheduleShift = (model: MainModelConfig, { width, height }: { width: number; height: number }): number => { + if (model.variant !== 'krea2_base') { + return Math.exp(KREA2_DISTILLED_MU); + } + + const seqLen = Math.min(Math.floor(width / 16) * Math.floor(height / 16), KREA2_SHIFT_MAX_SEQ_LEN); + const slope = (KREA2_MAX_SHIFT - KREA2_BASE_SHIFT) / (KREA2_SHIFT_MAX_SEQ_LEN - KREA2_SHIFT_MIN_SEQ_LEN); + + return Math.exp(seqLen * slope + KREA2_BASE_SHIFT - slope * KREA2_SHIFT_MIN_SEQ_LEN); +}; + /** A text encoder the widget can be asked for; each is its own persisted field. */ -export type UpscaleComponentField = 't5EncoderModel' | 'clipEmbedModel' | 'qwen3EncoderModel' | 'qwen35EncoderModel'; +export type UpscaleComponentField = + | 't5EncoderModel' + | 'clipEmbedModel' + | 'qwen3EncoderModel' + | 'qwen35EncoderModel' + | 'qwen3VlEncoderModel'; const UPSCALE_COMPONENT_TYPES: Record = { clipEmbedModel: 'clip_embed', qwen35EncoderModel: 'qwen3_5_encoder', qwen3EncoderModel: 'qwen3_encoder', + qwen3VlEncoderModel: 'qwen3_vl_encoder', t5EncoderModel: 't5_encoder', }; @@ -106,6 +140,12 @@ const ANIMA_QWEN35_SLOT: UpscaleComponentSlot = { field: 'qwen35EncoderModel', label: 'Qwen3.5 encoder', }; +// Krea-2 reads the 4B; Ideogram 4's 8B is the same model type and `krea2_model_loader` refuses it. +const KREA2_QWEN3_VL_SLOT: UpscaleComponentSlot = { + accepts: isKrea2Qwen3VlEncoder, + field: 'qwen3VlEncoderModel', + label: 'Qwen3-VL 4B encoder', +}; /** What a main model's loader must be handed. A model that bundles its own parts needs none of it. */ export interface UpscaleComponents { @@ -134,6 +174,17 @@ const nameIncludes = (name: string): boolean => needles.some((needle) => name.toLowerCase().includes(needle)); +/** How a denoise node that clips `denoising_start` by step index lands on its schedule. */ +export interface UpscaleIndexClipping { + /** The linear schedule shift the node runs this main model at, for the frame it denoises. */ + readonly shift: (model: MainModelConfig, size: { width: number; height: number }) => number; + /** + * Whether the node keeps `int(start * steps)` or Python's `int(round(start * steps))`, which rounds a + * half to even and so cannot be aimed at the middle of a step. + */ + readonly rounding: 'floor' | 'round'; +} + /** * Which main-model architectures the Upscale widget can drive, and what each one needs. * @@ -169,11 +220,11 @@ export interface UpscaleArchitecture { */ readonly negativePrompt: 'always' | 'cfg-gated' | 'never'; /** - * The schedule shift of a denoise node that clips `denoising_start` by step index rather than by - * value, for the frame it runs at. The compiler uses it to start such a node where FLUX would - * start for the same Creativity; null where the node clips by value or runs its own schedule. + * How a denoise node that clips `denoising_start` by step index rather than by value does it. The + * compiler uses it to start such a node where FLUX would start for the same Creativity; null where + * the node clips by value or runs its own schedule. */ - readonly indexClippedShift: ((size: { width: number; height: number }) => number) | null; + readonly indexClipping: UpscaleIndexClipping | null; /** * The schedulers that start where Creativity puts the pass; null where all of the base's do. The * img2img blend noises the frame to the clipped schedule's first sigma, but Heun and LCM on the @@ -183,10 +234,11 @@ export interface UpscaleArchitecture { */ readonly schedulers: readonly string[] | null; /** - * Whether a new main model sets CFG to that model's served default. Z-Image Turbo is distilled for - * CFG 1, and anything above runs a second, unconditional pass per step at upscale resolution; - * Z-Image Base and Anima want 4 to 6. SD and FLUX keep the user's CFG -- SD's multi-diffusion pass - * is tuned to the widget's 2 -- and get that 2 back when the user comes from one of the others. + * Whether a new main model sets CFG to that model's served default. Z-Image Turbo and Krea-2 Turbo are + * distilled for CFG 1, and anything above runs a second, unconditional pass per step at upscale + * resolution; Z-Image Base, Anima and Krea-2 Raw want 4 to 6. SD and FLUX keep the user's CFG -- SD's + * multi-diffusion pass is tuned to the widget's 2 -- and get that 2 back when the user comes from one + * of the others. */ readonly takesModelGuidance: boolean; /** Shown in validation messages, so a user reads "FLUX.1" rather than "flux". */ @@ -199,7 +251,7 @@ export const UPSCALE_ARCHITECTURES = { 'sd-1': { components: () => NO_COMPONENTS, denoiseGrid: 8, - indexClippedShift: null, + indexClipping: null, label: 'SD1.5', negativePrompt: 'always', schedulers: null, @@ -209,7 +261,7 @@ export const UPSCALE_ARCHITECTURES = { sdxl: { components: () => NO_COMPONENTS, denoiseGrid: 8, - indexClippedShift: null, + indexClipping: null, label: 'SDXL', negativePrompt: 'always', schedulers: null, @@ -223,7 +275,7 @@ export const UPSCALE_ARCHITECTURES = { components: (model) => model.format === 'sdnq_quantized' ? NO_COMPONENTS : { slots: [T5_ENCODER_SLOT, CLIP_EMBED_SLOT], vae: true }, denoiseGrid: 16, - indexClippedShift: null, + indexClipping: null, label: 'FLUX.1', negativePrompt: 'never', // `flux/denoise.py` hands Heun and LCM to the same diffusers schedulers as `z_image_denoise`, so @@ -237,7 +289,7 @@ export const UPSCALE_ARCHITECTURES = { components: (model) => isBundledMainForBase('z-image')(model) ? NO_COMPONENTS : { slots: [Z_IMAGE_QWEN3_SLOT], vae: true }, denoiseGrid: 16, - indexClippedShift: zImageScheduleShift, + indexClipping: { rounding: 'floor', shift: (_model, size) => zImageScheduleShift(size) }, label: 'Z-Image', negativePrompt: 'cfg-gated', schedulers: ['euler'], @@ -251,13 +303,26 @@ export const UPSCALE_ARCHITECTURES = { vae: true, }), denoiseGrid: 8, - indexClippedShift: () => ANIMA_SCHEDULE_SHIFT, + indexClipping: { rounding: 'floor', shift: () => ANIMA_SCHEDULE_SHIFT }, label: 'Anima', negativePrompt: 'cfg-gated', schedulers: ['euler', 'dpmpp_2m', 'dpmpp_2m_sde', 'er_sde'], takesModelGuidance: true, tileControlNet: null, }, + 'krea-2': { + // Only a Diffusers install carries its Qwen3-VL encoder and VAE; `krea2_model_loader` refuses a + // single-file or GGUF transformer without both. + components: (model) => (model.format === 'diffusers' ? NO_COMPONENTS : { slots: [KREA2_QWEN3_VL_SLOT], vae: true }), + denoiseGrid: 16, + indexClipping: { rounding: 'round', shift: krea2ScheduleShift }, + label: 'Krea-2', + negativePrompt: 'cfg-gated', + // `krea2_denoise` has no scheduler input: it steps with Euler. + schedulers: ['euler'], + takesModelGuidance: true, + tileControlNet: null, + }, } as const satisfies Record; export type UpscaleBase = keyof typeof UPSCALE_ARCHITECTURES; @@ -296,11 +361,11 @@ export const wiresUpscaleNegativePrompt = (model: MainModelConfig | null, cfgSca }; /** - * Index clipping keeps `int(start * steps)` of the schedule, so a single step leaves nothing between + * Index clipping starts the pass on a whole step of the schedule, so a single step leaves nothing between * starting from pure noise and not denoising at all; the pass needs at least two. */ export const upscaleMinSteps = (model: MainModelConfig | null): number => - upscaleArchitectureFor(model)?.indexClippedShift ? 2 : 1; + upscaleArchitectureFor(model)?.indexClipping ? 2 : 1; /** The base's schedulers, less those that would ignore Creativity; see `schedulers`. */ export const upscaleSchedulerOptions = (model: MainModelConfig | null) => { @@ -399,6 +464,7 @@ export const activeUpscaleComponents = (values: Pick }[] = [ { label: 'sd-1', main: model('sd1', 'main', 'sd-1') }, @@ -54,7 +55,10 @@ const CASES: { label: string; main: ModelConfig; patch?: Partial key === 'z-tile') as UpscaleWidgetValues['tileControlnetModel'], + }, }, { label: 'z-image-diffusers', main: model('z-diffusers', 'main', 'z-image', { format: 'diffusers' }) }, { label: 'anima', main: model('anima', 'main', 'anima', { variant: 'anima_qwen3' }), patch: { cfgScale: 4.5 } }, @@ -63,6 +67,16 @@ const CASES: { label: string; main: ModelConfig; patch?: Partial { diff --git a/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleWidgetView.tsx b/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleWidgetView.tsx index 5be831bdf1b..e86c4d6ac10 100644 --- a/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleWidgetView.tsx @@ -138,6 +138,13 @@ const COMPONENT_FIELD_UI: Record< placeholder: 'widgets.upscale.selectQwen3Encoder', required: 'widgets.upscale.qwen3EncoderRequired', }, + qwen3VlEncoderModel: { + hint: 'qwen3VlEncoder', + label: 'widgets.upscale.qwen3VlEncoder', + modelTypes: ['qwen3_vl_encoder'], + placeholder: 'widgets.upscale.selectQwen3VlEncoder', + required: 'widgets.upscale.qwen3VlEncoderRequired', + }, t5EncoderModel: { hint: 't5Encoder', label: 'widgets.upscale.t5Encoder', diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts b/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts index 77f751b0f1c..cdcc4fad834 100644 --- a/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts +++ b/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts @@ -50,6 +50,7 @@ export const FEATURE_HINTS = { }, qwen35Encoder: {}, qwen3Encoder: {}, + qwen3VlEncoder: {}, referenceImage: { href: 'https://support.invoke.ai/support/solutions/articles/151000159340-global-and-regional-reference-images-ip-adapters-', }, diff --git a/invokeai/frontend/webv2/src/workbench/invocation.test.ts b/invokeai/frontend/webv2/src/workbench/invocation.test.ts index 94bf1d85d64..8f7baa8dbd8 100644 --- a/invokeai/frontend/webv2/src/workbench/invocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocation.test.ts @@ -233,7 +233,7 @@ describe('resolveInvocationRoute — upscale source', () => { expect(result.sourceValid).toBe(false); expect(result.validationReasons).toContain( - 'Upscale supports only SD1.5, SDXL, FLUX.1, Z-Image or Anima main models.' + 'Upscale supports only SD1.5, SDXL, FLUX.1, Z-Image, Anima or Krea-2 main models.' ); }); diff --git a/tests/app/invocations/test_frontend_graph_node_types.py b/tests/app/invocations/test_frontend_graph_node_types.py index 0a6b3ea1bb9..7f9509e029a 100644 --- a/tests/app/invocations/test_frontend_graph_node_types.py +++ b/tests/app/invocations/test_frontend_graph_node_types.py @@ -103,15 +103,15 @@ def test_every_connection_the_panel_compiles_is_one_the_queue_will_accept(contra # The number of architectures each panel supports. A contract that silently stopped being # regenerated would make every assertion below vacuous, so the count is pinned: a new architecture # updates this number and the fixture in the same commit. -SUPPORTED_BASE_COUNTS = {"generate": 14, "video": 3, "upscale": 5} +SUPPORTED_BASE_COUNTS = {"generate": 14, "video": 3, "upscale": 6} # Floors separating "the fixture records literals" from "it records a fraction of them". Set just # under the current numbers (generate: 58 node types carrying 252 scalar values; video: 25 carrying -# 142; upscale: 39 carrying 164) rather than far below them: a recording bug that dropped most of the +# 142; upscale: 45 carrying 189) rather than far below them: a recording bug that dropped most of the # values while keeping the names would otherwise leave every annotation check below passing over a # short list. They are not per-architecture expectations, so removing an architecture means lowering # them deliberately. -LITERAL_FLOORS = {"generate": (55, 240), "video": (23, 135), "upscale": (37, 155)} +LITERAL_FLOORS = {"generate": (55, 240), "video": (23, 135), "upscale": (43, 180)} @pytest.mark.parametrize("contract", sorted(CONTRACTS), ids=lambda contract: contract) diff --git a/tests/app/invocations/test_upscale_index_clipped_starts.py b/tests/app/invocations/test_upscale_index_clipped_starts.py index e17c3596c15..940137203d5 100644 --- a/tests/app/invocations/test_upscale_index_clipped_starts.py +++ b/tests/app/invocations/test_upscale_index_clipped_starts.py @@ -1,15 +1,16 @@ -"""The Upscale widget's Creativity translation holds against the real Z-Image and Anima nodes. +"""The Upscale widget's Creativity translation holds against the real Z-Image, Anima and Krea-2 nodes. -`flux_denoise` clips `denoising_start` by sigma value; `z_image_denoise` and `anima_denoise` clip it by -step index on a shifted schedule, so the same number enters them far noisier. The widget therefore -does not send them FLUX's start: `getIndexClippedDenoisingStart` (`features/upscale/core/graph.ts`) -picks the step whose sigma is nearest the one FLUX would enter at, from its own copy of each node's -shift (`settings.ts`) and clipping rule. +`flux_denoise` clips `denoising_start` by sigma value; `z_image_denoise`, `anima_denoise` and +`krea2_denoise` clip it by step index on a shifted schedule, so the same number enters them far +noisier. The widget therefore does not send them FLUX's start: `getIndexClippedDenoisingStart` +(`features/upscale/core/graph.ts`) picks the step whose sigma is nearest the one FLUX would enter at, +from its own copy of each node's shift and clipping rule (`indexClipping` in `settings.ts`). Krea-2's +shift depends on the variant, which each case records. `upscaleIndexClippedStarts.json`, written by `indexClippedStarts.test.ts` (regenerate with `vitest -u`), records what the widget sends. Each case here drives the real `_run_diffusion` -- the real shift, the -real `_get_sigmas`, the real clipping and the real img2img blend -- and reads the entry sigma back out -of the blended tensor. It has to be the step nearest FLUX's entry. A change to either node's shift, +real schedule, the real clipping and the real img2img blend -- and reads the entry sigma back out of +the blended tensor. It has to be the step nearest FLUX's entry. A change to any of these nodes' shift, schedule or clipping fails here instead of silently moving every Upscale preset; that includes a move to value clipping, which would leave the translation entering far too late. @@ -26,11 +27,14 @@ import pytest import torch +from diffusers.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler from invokeai.app.invocations.anima.anima_denoise import AnimaDenoiseInvocation +from invokeai.app.invocations.krea2.krea2_denoise import Krea2DenoiseInvocation from invokeai.app.invocations.z_image.z_image_denoise import ZImageDenoiseInvocation from invokeai.backend.anima.scheduler_driver import AnimaSchedulerDriver from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP, ZIMAGE_SCHEDULER_MAP +from invokeai.backend.model_manager.taxonomy import Krea2VariantType FIXTURE = ( Path(__file__).parents[3] @@ -49,6 +53,7 @@ OFFERED_SCHEDULERS = FIXTURE_DATA["schedulers"] Z_IMAGE_MODULE = "invokeai.app.invocations.z_image.z_image_denoise" ANIMA_MODULE = "invokeai.app.invocations.anima.anima_denoise" +KREA2_MODULE = "invokeai.app.invocations.krea2.krea2_denoise" class _Stop(Exception): @@ -130,8 +135,8 @@ def _z_image_node(side: int, steps: int, denoising_start: float, scheduler: str) return node, conditioning -def z_image_entry(side: int, steps: int, denoising_start: float) -> tuple[list[float], float]: - node, conditioning = _z_image_node(side, steps, denoising_start, "euler") +def z_image_entry(case: dict) -> tuple[list[float], float]: + node, conditioning = _z_image_node(case["side"], case["steps"], case["denoisingStart"], "euler") return _entry( node, Z_IMAGE_MODULE, @@ -142,9 +147,9 @@ def z_image_entry(side: int, steps: int, denoising_start: float) -> tuple[list[f ) -def z_image_scheduler_entry(side: int, steps: int, denoising_start: float, scheduler: str) -> float: +def z_image_scheduler_entry(case: dict, scheduler: str) -> float: """The sigma `z_image_denoise` hands the transformer first, read off the scheduler it set up.""" - node, conditioning = _z_image_node(side, steps, denoising_start, scheduler) + node, conditioning = _z_image_node(case["side"], case["steps"], case["denoisingStart"], scheduler) made = [] def recording(scheduler_class): @@ -197,8 +202,8 @@ def _anima_node(side: int, steps: int, denoising_start: float, scheduler: str) - return node, [patch.object(node, "_load_text_conditionings", return_value=[SimpleNamespace(mask=None)])] -def anima_entry(side: int, steps: int, denoising_start: float) -> tuple[list[float], float]: - node, conditioning = _anima_node(side, steps, denoising_start, "euler") +def anima_entry(case: dict) -> tuple[list[float], float]: + node, conditioning = _anima_node(case["side"], case["steps"], case["denoisingStart"], "euler") return _entry( node, ANIMA_MODULE, @@ -209,9 +214,9 @@ def anima_entry(side: int, steps: int, denoising_start: float) -> tuple[list[flo ) -def anima_scheduler_entry(side: int, steps: int, denoising_start: float, scheduler: str) -> float: +def anima_scheduler_entry(case: dict, scheduler: str) -> float: """The sigma `anima_denoise` hands the transformer first, read off the real scheduler driver.""" - node, conditioning = _anima_node(side, steps, denoising_start, scheduler) + node, conditioning = _anima_node(case["side"], case["steps"], case["denoisingStart"], scheduler) first: dict[str, float] = {} def probe(**kwargs): @@ -235,28 +240,95 @@ def probe(**kwargs): return first["sigma"] -ENTRY = {"z-image": z_image_entry, "anima": anima_entry} +def krea2_entry(case: dict) -> tuple[list[float], float]: + """Run `krea2_denoise._run_diffusion` with its real scheduler to the blend, which it packs next. + + The model folder has no `scheduler/`, so the node builds the stock scheduler; the shift it is given is + the node's own choice for the case's variant and frame. The blend is caught on its way into + `pack_latents`, so the tensors can stay small whatever the frame. + """ + node = Krea2DenoiseInvocation.model_construct( + id="upscale", + latents=MagicMock(latents_name="latents"), + denoise_mask=None, + denoising_start=case["denoisingStart"], + denoising_end=1.0, + transformer=MagicMock(transformer="transformer"), + positive_conditioning=SimpleNamespace(conditioning_name="positive"), + negative_conditioning=None, + cfg_scale=1.0, + width=case["side"], + height=case["side"], + steps=case["steps"], + seed=0, + shift=None, + style_reference=None, + style_reference_conditioning=None, + ) + schedule: dict[str, list[float]] = {} + + class _RecordingScheduler(FlowMatchEulerDiscreteScheduler): + def set_timesteps(self, *args, **kwargs): + super().set_timesteps(*args, **kwargs) + schedule["sigmas"] = self.sigmas.tolist() + + captured: dict[str, torch.Tensor] = {} + + def capture(latents, *_args): + captured["latents"] = latents + raise _Stop + + context = MagicMock() + context.tensors.load.return_value = torch.zeros(1, 16, 8, 8) + context.models.get_config.return_value = SimpleNamespace(variant=Krea2VariantType(case["variant"])) + context.models.get_absolute_path.return_value = Path("no-such-krea2-model") + conditioning = SimpleNamespace(regional_text_conditioning=SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 12, 8))) + with ExitStack() as stack: + stack.enter_context(patch(f"{KREA2_MODULE}.TorchDevice.choose_torch_device", return_value=torch.device("cpu"))) + stack.enter_context(patch(f"{KREA2_MODULE}.TorchDevice.choose_bfloat16_safe_dtype", return_value=torch.float32)) + stack.enter_context( + patch( + "diffusers.schedulers.scheduling_flow_match_euler_discrete.FlowMatchEulerDiscreteScheduler", + _RecordingScheduler, + ) + ) + stack.enter_context(patch.object(node, "_load_text_conditioning", return_value=conditioning)) + stack.enter_context(patch.object(node, "_get_noise", return_value=torch.ones(1, 16, 8, 8))) + stack.enter_context(patch(f"{KREA2_MODULE}.pack_latents", capture)) + with pytest.raises(_Stop): + node._run_diffusion(context) + + blended = captured["latents"] + assert torch.allclose(blended, blended.flatten()[:1].expand_as(blended)), "the blend should be uniform" + return schedule["sigmas"], float(blended.flatten()[0]) + + +ENTRY = {"z-image": z_image_entry, "anima": anima_entry, "krea-2": krea2_entry} SCHEDULER_ENTRY = {"z-image": z_image_scheduler_entry, "anima": anima_scheduler_entry} -SCHEDULERS = {"z-image": ZIMAGE_SCHEDULER_MAP, "anima": ANIMA_SCHEDULER_MAP} +SCHEDULERS = {"z-image": list(ZIMAGE_SCHEDULER_MAP), "anima": list(ANIMA_SCHEDULER_MAP)} # Under half the narrowest step at any fixture entry (0.011), so no scheduler passes a step off. DPM++ # builds its own schedule, ending at 1/1000 rather than 0, and rounds its timesteps to integers: that # moves its entry by up to 0.0013, which is not a step. ENTRY_TOLERANCE = 0.005 -def test_the_fixture_covers_both_index_clipping_nodes(): +def test_the_fixture_covers_every_index_clipping_node(): """An empty or one-sided fixture would make every case below vacuous.""" assert {case["base"] for case in CASES} == set(ENTRY) - assert len(CASES) >= 60 + # Krea-2's shift depends on the variant: Turbo's is fixed, Raw's follows the frame and is held. + assert {case["variant"] for case in CASES if case["base"] == "krea-2"} == {"krea2_turbo", "krea2_base"} + assert len(CASES) >= 120 @pytest.mark.parametrize( "case", CASES, - ids=lambda case: f"{case['base']}-{case['side']}px-{case['steps']}st-creativity{case['creativity']}", + ids=lambda case: ( + f"{case['variant'] or case['base']}-{case['side']}px-{case['steps']}st-creativity{case['creativity']}" + ), ) def test_the_pass_enters_at_the_step_nearest_where_flux_enters(case: dict) -> None: - sigmas, entry = ENTRY[case["base"]](case["side"], case["steps"], case["denoisingStart"]) + sigmas, entry = ENTRY[case["base"]](case) target = case["fluxEntrySigma"] # The last sigma is 0, where nothing is left to denoise; ties go to the noisier step. nearest = min(sigmas[:-1], key=lambda sigma: (abs(sigma - target), -sigma)) @@ -267,19 +339,23 @@ def test_the_pass_enters_at_the_step_nearest_where_flux_enters(case: dict) -> No ) -@pytest.mark.parametrize("base", sorted(ENTRY)) +def test_krea2_has_no_scheduler_to_offer() -> None: + # The widget's Euler-only list for Krea-2 stands for the node having no scheduler input at all. + assert "scheduler" not in Krea2DenoiseInvocation.model_fields + assert OFFERED_SCHEDULERS["krea-2"] == ["euler"] + + +@pytest.mark.parametrize("base", sorted(SCHEDULER_ENTRY)) def test_the_widget_offers_exactly_the_schedulers_that_start_where_the_blend_left_the_latents(base: str) -> None: cases = [case for case in CASES if case["base"] == base] - blends = [ENTRY[base](case["side"], case["steps"], case["denoisingStart"])[1] for case in cases] + blends = [ENTRY[base](case)[1] for case in cases] misses: dict[str, float] = {} for scheduler in SCHEDULERS[base]: # Euler is not handed to a diffusers scheduler: the node steps the schedule it blended from. if scheduler == "euler": continue - entries = [ - SCHEDULER_ENTRY[base](case["side"], case["steps"], case["denoisingStart"], scheduler) for case in cases - ] + entries = [SCHEDULER_ENTRY[base](case, scheduler) for case in cases] worst = max(abs(entry - blend) for entry, blend in zip(entries, blends, strict=True)) if worst > ENTRY_TOLERANCE: misses[scheduler] = worst From 6c1a9315eafc836a50890c8d0796363eed6558cd Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 18 Oct 2026 11:32:05 -0400 Subject: [PATCH 3/3] test(upscale): count Krea-2 among the upscale panel's bases Co-Authored-By: Claude Opus 5.5 --- tests/app/invocations/test_frontend_graph_node_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app/invocations/test_frontend_graph_node_types.py b/tests/app/invocations/test_frontend_graph_node_types.py index 0a5d8536e26..0ef9b284972 100644 --- a/tests/app/invocations/test_frontend_graph_node_types.py +++ b/tests/app/invocations/test_frontend_graph_node_types.py @@ -103,7 +103,7 @@ def test_every_connection_the_panel_compiles_is_one_the_queue_will_accept(contra # The number of architectures each panel supports. A contract that silently stopped being # regenerated would make every assertion below vacuous, so the count is pinned: a new architecture # updates this number and the fixture in the same commit. -SUPPORTED_BASE_COUNTS = {"generate": 15, "video": 3, "upscale": 5} +SUPPORTED_BASE_COUNTS = {"generate": 15, "video": 3, "upscale": 6} # Floors separating "the fixture records literals" from "it records a fraction of them". Set just # under the current numbers (generate: 58 node types carrying 252 scalar values; video: 25 carrying